Compare commits
32
Commits
95cc3bd23f
..
master
@@ -167,7 +167,7 @@ per-contract fees and the CME calendar.
|
||||
Per account type, in the **Hard Daily Loss** column: the **daily loss limit**
|
||||
(Tradeify: 3% of size — every leg then carries a stop that keeps the firm's
|
||||
day inside it, less fees and a reserve of 10% of the limit or 0.1% of the
|
||||
position, whichever is more) and the **static max drawdown** (Tradeify
|
||||
position, whichever is more — 0.02% on Vest, whose stops fill closer) and the **static max drawdown** (Tradeify
|
||||
1-Step: $6,000 on 100K). When hitting the day's loss limit would leave the
|
||||
account within 15% of that loss above its max-drawdown floor, the leg is a
|
||||
*last stand*: no daily-loss stop, running to its take-profit or the firm's
|
||||
@@ -256,6 +256,19 @@ own traffic since Vest publishes no API):
|
||||
so the bot marks its own opens and closes as it sends them; anything
|
||||
unmarked since marking began was someone else's. The **Trades** button on
|
||||
the Vest firm's dashboard row lists them, and the account page shows its own.
|
||||
- Claims: the bot claims a funded account's profit by itself at each stage's
|
||||
target — whether or not the scheduler is running (a claim opens nothing).
|
||||
The dashboard's **Auto-claim payouts** box turns it off (`auto_claim`). A
|
||||
claim holds every further claim on the account until Vest's transactions
|
||||
show its WITHDRAW; one whose request failed in transit holds them too, until
|
||||
the transactions show it went in, or an hour of them shows it didn't (one
|
||||
that never went out, for want of an account token, holds nothing). Each
|
||||
claim counts as its own payout in the report, even two on one Vest day, and
|
||||
the cycle a day's second claim starts is sent with its own `cycleSeq` (the
|
||||
master needs to key on it to keep that cycle apart). The account page's **Claim
|
||||
now** button claims the whole profit above the account size at once — any
|
||||
amount from $1 — when the account is flat; it asks first, since it moves
|
||||
money to the login's main account.
|
||||
|
||||
`npm run check` includes the Vest helpers (scripts/check-vest.mjs).
|
||||
|
||||
|
||||
+158
-30
@@ -83,6 +83,106 @@ function fmt(value: number) {
|
||||
return value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
/** Claim now: the account's whole profit above its size, to the firm's main
|
||||
* account (Vest's Claim Profit) — for payout accounts on a firm that takes
|
||||
* claims by API. Confirmed inline, since it moves money. */
|
||||
function ClaimNowButton({ accountId, onDone }: { accountId: number; onDone: () => void }) {
|
||||
type Claimable = { claimable: number; target: number | null; firm: string } | { claimable: null; reason: string; unsupported: boolean };
|
||||
const [info, setInfo] = useState<Claimable | null>(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
|
||||
const check = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/accounts/${accountId}/claim`, { cache: 'no-store' });
|
||||
if (res.ok) setInfo(await res.json());
|
||||
} catch {
|
||||
/* keep the last answer */
|
||||
}
|
||||
}, [accountId]);
|
||||
|
||||
useEffect(() => {
|
||||
void check();
|
||||
const t = setInterval(() => { void check(); }, 10_000);
|
||||
return () => clearInterval(t);
|
||||
}, [check]);
|
||||
|
||||
async function claim(amount: number) {
|
||||
setBusy(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await fetch(`/api/accounts/${accountId}/claim`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ amount }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||
setMessage({
|
||||
ok: true,
|
||||
text: `Claimed $${fmt(body.claimed)}${body.received != null ? ` — $${fmt(body.received)} to your main account` : ''} · stage ${body.stage} next`,
|
||||
});
|
||||
onDone();
|
||||
} catch (err) {
|
||||
setMessage({ ok: false, text: err instanceof Error ? err.message : 'Claim failed' });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setConfirming(false);
|
||||
void check();
|
||||
}
|
||||
}
|
||||
|
||||
if (!info || (info.claimable === null && info.unsupported)) return null;
|
||||
|
||||
if (confirming && info.claimable !== null) {
|
||||
const amount = info.claimable;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-600">
|
||||
Claim ${fmt(amount)} to your {info.firm} main account? The firm pays your split of it.
|
||||
</span>
|
||||
<button
|
||||
onClick={() => { void claim(amount); }}
|
||||
disabled={busy}
|
||||
className="px-2.5 py-1 bg-emerald-600 hover:bg-emerald-700 disabled:opacity-50 text-white text-xs font-semibold rounded-md transition-colors"
|
||||
>
|
||||
{busy ? 'Claiming…' : `Claim $${fmt(amount)}`}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirming(false)}
|
||||
disabled={busy}
|
||||
className="px-2.5 py-1 bg-slate-100 hover:bg-slate-200 disabled:opacity-50 text-slate-600 text-xs font-semibold rounded-md transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{message && <span className={`text-xs ${message.ok ? 'text-emerald-600' : 'text-red-500'}`}>{message.text}</span>}
|
||||
{info.claimable !== null ? (
|
||||
<button
|
||||
onClick={() => { setMessage(null); setConfirming(true); }}
|
||||
className="px-2.5 py-1 bg-emerald-50 hover:bg-emerald-100 text-emerald-700 text-xs font-semibold rounded-md transition-colors"
|
||||
title={`Claim the whole profit above the account size now${info.target !== null ? ` (the bot claims by itself at the stage's $${fmt(info.target)} target)` : ''}`}
|
||||
>
|
||||
Claim now · ${fmt(info.claimable)}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className="px-2.5 py-1 bg-slate-100 text-slate-400 text-xs font-semibold rounded-md cursor-not-allowed"
|
||||
title={info.reason}
|
||||
>
|
||||
Claim now — {info.reason}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Wipes the account's cached P&L / fund / meta rows and refetches from
|
||||
* Tradovate. Confirmed inline because rows older than the report's 28-day
|
||||
* window cannot come back. */
|
||||
@@ -182,6 +282,7 @@ function ObjRow({ passed, label, value }: { passed: boolean; label: ReactNode; v
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function EquityTooltip({ active, payload, label }: any) {
|
||||
if (!active || !payload?.length) return null;
|
||||
// Always read from the raw data point so fill-only series don't interfere
|
||||
@@ -387,9 +488,11 @@ export default function AccountPage() {
|
||||
}, [accountId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
// The first load from a timer too, like the polls: not setState
|
||||
// from the effect's own body.
|
||||
const first = setTimeout(() => { void load(); }, 0);
|
||||
const interval = setInterval(() => { void load(); }, 5000);
|
||||
return () => clearInterval(interval);
|
||||
return () => { clearTimeout(first); clearInterval(interval); };
|
||||
}, [load]);
|
||||
|
||||
if (!account) {
|
||||
@@ -413,9 +516,12 @@ export default function AccountPage() {
|
||||
|
||||
// The firm's hard daily loss limit, and the least a day must make to count
|
||||
// as a trading day — two different numbers; neither is set on every type.
|
||||
const dailyLossLimit = (cfg?.dailyLossLimit ?? 0) > 0 ? cfg!.dailyLossLimit! : null;
|
||||
// The limit and the min days are the current stage's, which may set its own.
|
||||
const stageLossLimit = account.effectiveDailyLossLimit ?? cfg?.dailyLossLimit ?? 0;
|
||||
const dailyLossLimit = stageLossLimit > 0 ? stageLossLimit : null;
|
||||
const minDayPnL = (cfg?.minDayPnL ?? 0) > 0 ? cfg!.minDayPnL : null;
|
||||
const daysPassed = cfg != null && account.daysTraded >= cfg.minTradingDays;
|
||||
const minTradingDays = account.effectiveMinTradingDays ?? cfg?.minTradingDays ?? 0;
|
||||
const daysPassed = cfg != null && account.daysTraded >= minTradingDays;
|
||||
// Dead when balance hits the liquidation floor — Tradovate reports its
|
||||
// (positive) floor; TopstepX floors are reconstructed MLLs, negative or
|
||||
// +$0.01 once locked at breakeven. 0 = no floor known.
|
||||
@@ -452,13 +558,19 @@ export default function AccountPage() {
|
||||
}
|
||||
const sortedEvents = [...allDates].sort().map((d) => eventsByDate.get(d)!);
|
||||
|
||||
// Running equity after each event, summed here rather than inside the
|
||||
// map below: a render callback mustn't reassign an outer variable.
|
||||
const runningAfter: number[] = [];
|
||||
let running = 0;
|
||||
for (const ev of sortedEvents) {
|
||||
if (ev.fundAmt !== undefined) running += ev.fundAmt; // withdrawals reduce equity
|
||||
if (ev.pnl !== undefined) running += ev.pnl;
|
||||
runningAfter.push(running);
|
||||
}
|
||||
const equityData = [
|
||||
{ label: '', equity: 0, pnl: 0, origin: true, pos: 0, neg: 0, isWithdrawal: false },
|
||||
...sortedEvents.map((ev) => {
|
||||
if (ev.fundAmt !== undefined) running += ev.fundAmt; // withdrawals reduce equity
|
||||
if (ev.pnl !== undefined) running += ev.pnl;
|
||||
const equity = Math.round(running * 100) / 100;
|
||||
...sortedEvents.map((ev, i) => {
|
||||
const equity = Math.round(runningAfter[i] * 100) / 100;
|
||||
return {
|
||||
label: fmtDate(ev.date),
|
||||
equity,
|
||||
@@ -497,7 +609,7 @@ export default function AccountPage() {
|
||||
// books exactly its plan, cycles complete, and the configured withdrawal
|
||||
// fires the day a cycle finishes. Same planner the trade path runs, so the
|
||||
// numbers (bumps and drain amounts included) are what would actually be
|
||||
// aimed for. Shown up to TWO payouts ahead.
|
||||
// aimed for. Shown up to TWO payout days ahead.
|
||||
const rawProjection = (cfg && !isDead) ? projectPerfectPath({
|
||||
accountId: account.id,
|
||||
cfg,
|
||||
@@ -505,7 +617,15 @@ export default function AccountPage() {
|
||||
fundTransactions,
|
||||
balance: account.amount,
|
||||
realizedPnL: account.realizedPnL,
|
||||
maxWithdrawals: 2,
|
||||
// The day the firm's numbers follow (Vest's, until 20:00 ET).
|
||||
rawTradeDate: account.tradeDate,
|
||||
// The state stage counts the payouts that pick it: today's for a type
|
||||
// whose stages reset each day, else every one, each on its own.
|
||||
stageInputs: cfg.stagesSameDay === true
|
||||
? { stagesSameDay: true, sameDayWithdrawals: account.stage - 1 }
|
||||
: { payouts: account.stage - 1 },
|
||||
// Such a type pays out once per stage on each of those days.
|
||||
maxWithdrawals: cfg.stagesSameDay === true ? 2 * (1 + cfg.withdrawalStages.length) : 2,
|
||||
payout: {
|
||||
withdrawalPct: cfg.withdrawalPct,
|
||||
payoutCap: cfg.payoutCap,
|
||||
@@ -521,23 +641,21 @@ export default function AccountPage() {
|
||||
: undefined,
|
||||
},
|
||||
}) : [];
|
||||
// Cut the projection at the second payout — days beyond it are noise here.
|
||||
const secondPayoutIdx = (() => {
|
||||
let seen = 0;
|
||||
for (let i = 0; i < rawProjection.length; i++) {
|
||||
const p = rawProjection[i];
|
||||
if (p.withdrawal !== undefined || p.payoutReady) {
|
||||
seen++;
|
||||
if (seen >= 2 || p.payoutReady) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
})();
|
||||
const projection = secondPayoutIdx >= 0 ? rawProjection.slice(0, secondPayoutIdx + 1) : rawProjection;
|
||||
// Cut the projection after the second payout day — days beyond it are
|
||||
// noise here. The path's dates never go back, and a payoutReady entry is
|
||||
// always its last.
|
||||
const payoutDays = [...new Set(rawProjection.filter((p) => p.withdrawal !== undefined || p.payoutReady).map((p) => p.date))];
|
||||
const projection = payoutDays.length >= 2 ? rawProjection.filter((p) => p.date <= payoutDays[1]) : rawProjection;
|
||||
// A payout day is the cycle's FINAL trading day — it can carry both the
|
||||
// day's booked P&L and the withdrawal.
|
||||
const projPnlMap = new Map(projection.filter((p) => p.pnl !== 0).map((p) => [p.date, p.pnl]));
|
||||
const projFundMap = new Map(projection.filter((p) => p.withdrawal !== undefined).map((p) => [p.date, p.withdrawal!]));
|
||||
// day's booked P&L and the withdrawal. A same-day type's day can hold
|
||||
// several cycles: their P&L and withdrawals add up, and the day shows
|
||||
// the last stage it traded.
|
||||
const projPnlMap = new Map<string, number>();
|
||||
const projFundMap = new Map<string, number>();
|
||||
for (const p of projection) {
|
||||
if (p.pnl !== 0) projPnlMap.set(p.date, Math.round(((projPnlMap.get(p.date) ?? 0) + p.pnl) * 100) / 100);
|
||||
if (p.withdrawal !== undefined) projFundMap.set(p.date, Math.round(((projFundMap.get(p.date) ?? 0) + p.withdrawal) * 100) / 100);
|
||||
}
|
||||
const projStageMap = new Map(projection.map((p) => [p.date, p.stage]));
|
||||
/** Cumulative probability of the perfect path surviving through each projected day. */
|
||||
const projProbMap = new Map(projection.map((p) => [p.date, p.prob]));
|
||||
@@ -552,14 +670,20 @@ export default function AccountPage() {
|
||||
];
|
||||
const calendarMonths = [...new Set(allCalendarDates.map((d) => d.slice(0, 7)))].sort();
|
||||
|
||||
// Build stage map: stage increments after each withdrawal (negative fund txn)
|
||||
// Build stage map: stage increments after each withdrawal (negative fund txn).
|
||||
// A type whose stages reset each day starts every day on stage 1 and
|
||||
// trades its next stage after a payout that day, as the projected days
|
||||
// show — one fund row a day holds all of that day's payouts, so a day
|
||||
// with one reads stage 2.
|
||||
const sortedWithdrawalDates = fundTransactions
|
||||
.filter((f) => f.amount < 0)
|
||||
.map((f) => f.date)
|
||||
.sort();
|
||||
const stageMap = new Map<string, number>();
|
||||
for (const d of displayPnL) {
|
||||
const stage = sortedWithdrawalDates.filter((wd) => wd < d.date).length + 1;
|
||||
const stage = cfg?.stagesSameDay === true
|
||||
? sortedWithdrawalDates.filter((wd) => wd === d.date).length + 1
|
||||
: sortedWithdrawalDates.filter((wd) => wd < d.date).length + 1;
|
||||
stageMap.set(d.date, stage);
|
||||
}
|
||||
|
||||
@@ -588,7 +712,10 @@ export default function AccountPage() {
|
||||
☠ DEAD
|
||||
</span>
|
||||
)}
|
||||
<WipeDataButton accountId={account.id} onDone={() => { void load(); }} />
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<ClaimNowButton accountId={account.id} onDone={() => { void load(); }} />
|
||||
<WipeDataButton accountId={account.id} onDone={() => { void load(); }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isDead && (
|
||||
@@ -636,7 +763,7 @@ export default function AccountPage() {
|
||||
<ObjRow
|
||||
passed={daysPassed}
|
||||
label="Trading Days"
|
||||
value={cfg ? `${account.daysTraded} of ${cfg.minTradingDays}` : '—'}
|
||||
value={cfg ? `${account.daysTraded} of ${minTradingDays}` : '—'}
|
||||
/>
|
||||
{dailyTarget != null && (
|
||||
<div className="flex items-center justify-between py-3 border-b border-slate-100 last:border-0">
|
||||
@@ -789,6 +916,7 @@ export default function AccountPage() {
|
||||
stroke={`url(#${strokeGradId})`}
|
||||
strokeWidth={2.5}
|
||||
fill="none"
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
dot={(props: any) => {
|
||||
if (props.payload?.origin) return <g key={props.key} />;
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { claimableNow, claimAccountNow, ClaimRefusedError } from '@/lib/auto-trade';
|
||||
import { errorMessage } from '@/lib/platform-client';
|
||||
|
||||
/**
|
||||
* Claim now (the account page's button). GET: how much the account could
|
||||
* claim right now — its whole profit above the account size — or why not.
|
||||
* POST { amount }: claim it, to the login's main account (Vest's Claim
|
||||
* Profit). Only a person pressing the button calls this; the bot claims on
|
||||
* its own at each stage's target (runClaims).
|
||||
*/
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return NextResponse.json(claimableNow(Number(id)));
|
||||
}
|
||||
|
||||
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
// JSON only: a cross-site form or no-cors fetch can't send it without a
|
||||
// preflight this route never answers.
|
||||
if (!(req.headers.get('content-type') ?? '').includes('application/json')) {
|
||||
return NextResponse.json({ error: 'JSON body required' }, { status: 415 });
|
||||
}
|
||||
const { id } = await params;
|
||||
const body = await req.json().catch(() => ({})) as { amount?: unknown };
|
||||
const amount = Number(body.amount);
|
||||
if (!Number.isFinite(amount)) return NextResponse.json({ error: 'amount required' }, { status: 400 });
|
||||
try {
|
||||
return NextResponse.json({ ok: true, ...await claimAccountNow(Number(id), amount) });
|
||||
} catch (err) {
|
||||
if (err instanceof ClaimRefusedError) return NextResponse.json({ error: err.message }, { status: 400 });
|
||||
console.error(`[claim-now] account ${id}: ${errorMessage(err)}`);
|
||||
return NextResponse.json({ error: `the claim failed: ${errorMessage(err)}` }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { getSetting, setSetting } from '@/lib/db';
|
||||
import { scheduleBackupPush } from '@/lib/backup-sync';
|
||||
import { getRequestUserAgent } from '@/lib/request-user-agent';
|
||||
|
||||
const VALID_KEYS = ['max_concurrent_accounts', 'tick_interval_seconds', 'master_dashboard_url', 'instance_name', 'trading_hours', 'twocaptcha_api_key', 'twocaptcha_proxy', 'master_backup_token', 'proxy_url'] as const;
|
||||
const VALID_KEYS = ['max_concurrent_accounts', 'tick_interval_seconds', 'master_dashboard_url', 'instance_name', 'trading_hours', 'twocaptcha_api_key', 'twocaptcha_proxy', 'master_backup_token', 'proxy_url', 'auto_claim', 'auto_activate'] as const;
|
||||
type SettingKey = typeof VALID_KEYS[number];
|
||||
|
||||
export async function GET() {
|
||||
@@ -25,6 +25,15 @@ export async function PATCH(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json() as Partial<Record<SettingKey, string | number>>;
|
||||
|
||||
// Claims and activations run unless it reads exactly 'off' — say so
|
||||
// exactly, and before anything is written.
|
||||
for (const key of ['auto_claim', 'auto_activate'] as const) {
|
||||
const v = body[key];
|
||||
if (v !== undefined && v !== null && v !== 'on' && v !== 'off') {
|
||||
return NextResponse.json({ error: `${key} is 'on' or 'off'` }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of VALID_KEYS) {
|
||||
if (key in body) {
|
||||
const raw = body[key];
|
||||
|
||||
+30
-20
@@ -9,9 +9,9 @@ import { rankNextLeg, dayLossRoom } from '@/lib/leg-estimate';
|
||||
import { obfuscationDayBooked } from '@/lib/auto-trade';
|
||||
import type { AccountState } from '@/types';
|
||||
import {
|
||||
combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, currentTradeDateCT,
|
||||
obfuscationStopDollars, dllStopDollars, botStopsActive, stageInputsFor,
|
||||
maxLossRoom, lastStand as isLastStand,
|
||||
combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, tradeDateFor,
|
||||
obfuscationStopDollars, dllStopDollars, botStopsActive, stageInputsFor, stagePayouts,
|
||||
maxLossRoom, lastStand as isLastStand, shortestNextLeg,
|
||||
LEG_COMPLETION_EPSILON,
|
||||
} from '@/lib/trading-logic';
|
||||
|
||||
@@ -19,7 +19,6 @@ export async function GET() {
|
||||
try {
|
||||
const firms = getCachedCatalog();
|
||||
const clients = getClients();
|
||||
const tradeDate = currentTradeDateCT();
|
||||
|
||||
const state = firms.map((f) => {
|
||||
const client = clients.get(f.id);
|
||||
@@ -30,6 +29,9 @@ export async function GET() {
|
||||
if (!client || client.accountList.length === 0) {
|
||||
return { firmId: f.id, firm: f.name, connected, accounts: [] };
|
||||
}
|
||||
// The day the firm's accounts trade on (tradeDateFor), as the
|
||||
// engine plans them — Vest's turns at 20:00 ET, not 5 PM CT.
|
||||
const tradeDate = tradeDateFor(client);
|
||||
// Accounts whose rules date was set by hand — one read per firm
|
||||
// rather than one per account.
|
||||
const rulesDateByHand = new Set(listAssignments(f.id)
|
||||
@@ -61,11 +63,11 @@ export async function GET() {
|
||||
const isDead = autoLiqThreshold !== 0 && cash.amount <= autoLiqThreshold;
|
||||
const allFundTxns = client.fundTransactions?.[acc.id] ?? [];
|
||||
const priorProfit = client.priorProfit?.[acc.id] ?? 0;
|
||||
// Stage = 1 + number of withdrawals (negative fund transactions),
|
||||
// or of today's payouts for a type whose stages reset each day.
|
||||
const stage = 1 + (cfg?.stagesSameDay
|
||||
? (client.sameDayClaims?.(acc.id) ?? 0)
|
||||
: allFundTxns.filter((f) => f.amount < 0).length);
|
||||
// Stage = 1 + number of payouts (negative fund transactions, or
|
||||
// the client's own list where a row holds several), or of
|
||||
// today's for a type whose stages reset each day.
|
||||
const stageInputs = stageInputsFor(client, acc.id, cfg ?? {});
|
||||
const stage = 1 + stagePayouts(allFundTxns, stageInputs);
|
||||
// Stage-resolved config: a drain stage swaps in its own min-day
|
||||
// (default $5) and no profit target.
|
||||
const effective = cfg ? resolveEffectiveConfig(
|
||||
@@ -77,7 +79,7 @@ export async function GET() {
|
||||
priorProfit,
|
||||
allFundTxns,
|
||||
cfg.minDayPnL,
|
||||
stageInputsFor(client, acc.id, cfg),
|
||||
stageInputs,
|
||||
) : null;
|
||||
// Days traded counts only days that hit minDayPnL (when set) — that's what
|
||||
// the firm requires toward the min trading day rule.
|
||||
@@ -133,7 +135,7 @@ export async function GET() {
|
||||
// daily-loss stop, before fees. A min day trades its whole
|
||||
// remaining target as one leg.
|
||||
const dll = effective?.dailyLossLimit ?? 0;
|
||||
dllRoom = dayLossRoom(client, acc.id, dll, cash, combined.realizedPnL);
|
||||
dllRoom = dayLossRoom(client, acc.id, dll, cash, combined.realizedPnL, plan ? shortestNextLeg(plan) : 0);
|
||||
// The max-drawdown floor, and whether the next leg is a last
|
||||
// stand — no daily-loss stop — as fireLeg decides it.
|
||||
const floorRoom = maxLossRoom({
|
||||
@@ -147,14 +149,6 @@ export async function GET() {
|
||||
// "stop $912" for a leg that never fires.
|
||||
const dayBooked = obfuscationDayBooked(cfg, acc.id, tradeDate, combined.realizedPnL) !== null;
|
||||
lastStandNext = !dayBooked && dllRoom !== null && isLastStand({ dllRoom, dailyLossLimit: dll, maxLossRoom: floorRoom });
|
||||
if (plan && !dayBooked && botStopsActive(cfg)) {
|
||||
const obf = obfuscationStopDollars(cfg, dailyPnL, plan, combined.tradeDate, plan.remainingToday);
|
||||
const hard = dllRoom !== null && !lastStandNext
|
||||
? dllStopDollars({ room: dllRoom, dailyLossLimit: dll, entryFee: 0, commissionPct: 0 })
|
||||
: Infinity;
|
||||
const stop = Math.min(Number.isFinite(obf) && obf > 0 ? obf : Infinity, hard);
|
||||
if (Number.isFinite(stop) && stop > 0) nextStop = Math.round(stop * 100) / 100;
|
||||
}
|
||||
|
||||
// DXtrade: the next leg sized on every surveyed instrument as
|
||||
// fireLeg would, and the one a batch would pick.
|
||||
@@ -179,6 +173,16 @@ export async function GET() {
|
||||
};
|
||||
}
|
||||
}
|
||||
if (plan && !dayBooked && botStopsActive(cfg, effective)) {
|
||||
const obf = obfuscationStopDollars(cfg, dailyPnL, plan, combined.tradeDate, plan.remainingToday);
|
||||
// The reserve grows with the position (dllReserve): at
|
||||
// the size the next leg would trade, where it's known.
|
||||
const hard = dllRoom !== null && !lastStandNext
|
||||
? dllStopDollars({ room: dllRoom, dailyLossLimit: dll, entryFee: 0, commissionPct: 0, notional: holdEstimate?.notional, slippageFraction: client.stopSlippageFraction })
|
||||
: Infinity;
|
||||
const stop = Math.min(Number.isFinite(obf) && obf > 0 ? obf : Infinity, hard);
|
||||
if (Number.isFinite(stop) && stop > 0) nextStop = Math.round(stop * 100) / 100;
|
||||
}
|
||||
|
||||
// Effective profit target = max(stage target, consistency realTarget).
|
||||
// If a big day forces the consistency rule, the account must reach maxDay/consistency
|
||||
@@ -249,6 +253,7 @@ export async function GET() {
|
||||
completed = cycleCompletion({
|
||||
accountId: acc.id,
|
||||
rawTradeDate: tradeDate,
|
||||
client,
|
||||
cycleDailyPnL: client.dailyPnL[acc.id],
|
||||
rawRealizedPnL: cash.realizedPnL,
|
||||
combineCmeHolidays: cfg.combineCmeHolidays,
|
||||
@@ -267,11 +272,14 @@ export async function GET() {
|
||||
// The combined day's P&L, not just the current session's —
|
||||
// on the Tuesday after a holiday this includes Sun/Mon.
|
||||
realizedPnL: combined.realizedPnL,
|
||||
// The firm's raw trade date, which the account page
|
||||
// projects the payouts from.
|
||||
tradeDate,
|
||||
daysTraded,
|
||||
// An account the bot puts stops on whose day booked red via
|
||||
// its own stop — the personal DLL. Firm DLL lockouts show
|
||||
// via !active instead.
|
||||
personalDll: !!cfg && botStopsActive(cfg)
|
||||
personalDll: !!cfg && !!effective && botStopsActive(cfg, effective)
|
||||
&& acc.active && !client.positions[acc.id]
|
||||
&& combined.realizedPnL <= -25,
|
||||
positionDirection: client.positions[acc.id]
|
||||
@@ -291,6 +299,8 @@ export async function GET() {
|
||||
lastStand: lastStandNext,
|
||||
remainingToday,
|
||||
effectiveProfitTarget,
|
||||
effectiveDailyLossLimit: effective?.dailyLossLimit ?? null,
|
||||
effectiveMinTradingDays: effective?.minTradingDays ?? null,
|
||||
dailyPnL,
|
||||
fullDailyPnL: combineCmeSessions(
|
||||
cfg?.combineCmeHolidays === true,
|
||||
|
||||
@@ -10,7 +10,10 @@ import type { TradeView } from '@/types';
|
||||
* Who opened and closed each one: the bot marks its own orders as it sends
|
||||
* them, so after marking began (TRADES_SINCE_SETTING) anything unmarked was
|
||||
* someone else's. A close Vest calls "user_requested" is an order on the
|
||||
* account — the bot's if it marked it, else outside.
|
||||
* account — the bot's if it marked it, else outside. Trades built from
|
||||
* futures fills (lib/trade-recorder.ts, DXtrade's record) carry their marks
|
||||
* already: 'bot', 'outside', or 'unknown' from before the bot noted its own
|
||||
* orders.
|
||||
*/
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
@@ -29,8 +32,8 @@ export async function GET(req: Request) {
|
||||
const open = t.closedAt === null && t.closeReason === null && t.closedBy === null;
|
||||
const closedBy = open ? null
|
||||
: t.closedBy === 'bot' ? 'bot'
|
||||
: t.closeReason === 'user_requested' ? (tracked(t.closedAt ?? t.openedAt) ? 'outside' : 'user_requested')
|
||||
: t.closeReason ?? 'unknown';
|
||||
: t.closeReason === 'user_requested' ? (t.closedBy === 'outside' || tracked(t.closedAt ?? t.openedAt) ? 'outside' : 'user_requested')
|
||||
: t.closeReason ?? (t.closedBy === 'outside' ? 'outside' : t.closedBy === 'unknown' ? 'user_requested' : 'unknown');
|
||||
const net = open || t.pnl === null ? null
|
||||
: Math.round((t.pnl - Math.abs(t.fee ?? 0) + (t.funding ?? 0)) * 100) / 100;
|
||||
return {
|
||||
@@ -41,14 +44,14 @@ export async function GET(req: Request) {
|
||||
side: t.side,
|
||||
quantity: t.quantity,
|
||||
openPrice: t.openPrice,
|
||||
closePrice: t.closePrice,
|
||||
pnl: t.pnl,
|
||||
closePrice: open ? null : t.closePrice,
|
||||
pnl: open ? null : t.pnl,
|
||||
fee: t.fee,
|
||||
funding: t.funding,
|
||||
net,
|
||||
openedAt: t.openedAt,
|
||||
closedAt: t.closedAt,
|
||||
openedBy: t.openedBy === 'bot' ? 'bot' : tracked(t.openedAt) ? 'outside' : null,
|
||||
openedBy: t.openedBy === 'bot' ? 'bot' : t.openedBy === 'unknown' ? null : t.openedBy === 'outside' || tracked(t.openedAt) ? 'outside' : null,
|
||||
closedBy,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { TradeView } from '@/types';
|
||||
/**
|
||||
* Recorded positions from /api/trades — every trade on the accounts, the
|
||||
* bot's and anyone else's, with who opened it and how it closed. On the
|
||||
* dashboard under a Vest firm (inline) and on the account page (as a card,
|
||||
* dashboard under each firm (inline) and on the account page (as a card,
|
||||
* hidden while there's nothing to show).
|
||||
*/
|
||||
|
||||
@@ -16,7 +16,7 @@ const PILL = 'inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold white
|
||||
|
||||
const CLOSED_BY: Record<string, { label: string; cls: string; title: string }> = {
|
||||
bot: { label: 'Bot', cls: 'bg-violet-100 text-violet-700', title: "The bot's own close order" },
|
||||
outside: { label: 'Outside', cls: 'bg-amber-100 text-amber-700', title: 'Closed by an order the bot did not send — by hand on Vest, or another tool' },
|
||||
outside: { label: 'Outside', cls: 'bg-amber-100 text-amber-700', title: 'Closed by an order the bot did not send — by hand on the platform, another tool, or the firm’s own liquidation' },
|
||||
stop_loss: { label: 'Stop loss', cls: 'bg-red-100 text-red-700', title: 'Its stop-loss triggered' },
|
||||
take_profit: { label: 'Take profit', cls: 'bg-green-100 text-green-700', title: 'Its take-profit triggered' },
|
||||
liquidation: { label: 'Liquidation', cls: 'bg-red-200 text-red-800', title: 'Liquidated by Vest' },
|
||||
@@ -24,6 +24,7 @@ const CLOSED_BY: Record<string, { label: string; cls: string; title: string }> =
|
||||
temporary_leverage_limit: { label: 'Leverage limit', cls: 'bg-orange-100 text-orange-700', title: 'Reduced by Vest’s temporary (weekend) leverage limit' },
|
||||
corporate_action: { label: 'Corporate action', cls: 'bg-slate-100 text-slate-600', title: 'Closed by a corporate action' },
|
||||
user_requested: { label: 'Self-requested', cls: 'bg-slate-100 text-slate-600', title: 'An order on the account closed it — from before the bot marked its own closes, so whose is unknown' },
|
||||
unknown: { label: 'Unknown', cls: 'bg-slate-100 text-slate-500', title: 'Closed while the platform’s history wasn’t being read — when and how aren’t on record' },
|
||||
};
|
||||
|
||||
function money(v: number): string {
|
||||
@@ -44,7 +45,7 @@ function when(ms: number): string {
|
||||
|
||||
function OpenedBy({ by }: { by: TradeView['openedBy'] }) {
|
||||
if (by === 'bot') return <span className={`${PILL} bg-violet-100 text-violet-700`} title="Opened by the bot">Bot</span>;
|
||||
if (by === 'outside') return <span className={`${PILL} bg-amber-100 text-amber-700`} title="Opened by an order the bot did not send — by hand on Vest, or another tool">Outside</span>;
|
||||
if (by === 'outside') return <span className={`${PILL} bg-amber-100 text-amber-700`} title="Opened by an order the bot did not send — by hand on the platform, or another tool">Outside</span>;
|
||||
return <span className="text-slate-400" title="Opened before the bot began marking its own trades">—</span>;
|
||||
}
|
||||
|
||||
@@ -105,7 +106,7 @@ export function TradesTable({ query, title, showAccount = false, formatAccount =
|
||||
const table = !trades
|
||||
? <div className="text-sm text-slate-400 italic py-2">{failed ? 'Couldn’t load trades' : 'Loading trades…'}</div>
|
||||
: trades.length === 0
|
||||
? <div className="text-sm text-slate-400 italic py-2">No trades recorded yet — they appear after the next history read (within the hour, or a minute after a position closes)</div>
|
||||
? <div className="text-sm text-slate-400 italic py-2">No trades recorded yet — they appear a few minutes after a fill, once the platform’s history is read</div>
|
||||
: (
|
||||
<div className="overflow-x-auto max-h-[28rem] overflow-y-auto">
|
||||
<table className="w-full text-xs">
|
||||
|
||||
+52
-4
@@ -453,7 +453,7 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned, onStateChang
|
||||
<AccountTypeCell account={account} firm={firm} onAssigned={onAssigned} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-600 tabular-nums">
|
||||
{account.daysTraded} / {cfg?.minTradingDays ?? '—'}
|
||||
{account.daysTraded} / {account.effectiveMinTradingDays ?? cfg?.minTradingDays ?? '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-600 tabular-nums">
|
||||
<div className="flex flex-col">
|
||||
@@ -473,7 +473,7 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned, onStateChang
|
||||
<span className="text-red-600" title="Last stand: hitting the day's loss limit would leave this account within 15% of that loss above its max-drawdown floor — nothing left to trade on — so the next leg carries no daily-loss stop and runs to its take-profit or the firm's breach"> · no stop (last stand)</span>
|
||||
)}
|
||||
{typeof account.dllRoom === 'number' && (
|
||||
<span className="text-slate-500" title="Hard daily loss limit: what the firm's day may still lose before a breach. A reserve — 10% of the limit, or 0.1% of the position if larger — is never spent."> · DLL room ${fmt(account.dllRoom)}</span>
|
||||
<span className="text-slate-500" title="Hard daily loss limit: what the firm's day may still lose before a breach. A reserve — 10% of the limit, or 0.1% of the position if larger (0.02% on Vest) — is never spent."> · DLL room ${fmt(account.dllRoom)}</span>
|
||||
)}
|
||||
{account.holdEstimate && (
|
||||
<span
|
||||
@@ -652,7 +652,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
||||
)}
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{firm.platform === 'vest' && hasCredentials && (
|
||||
{hasCredentials && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowTrades((v) => !v); }}
|
||||
className={`px-2 py-0.5 text-xs font-semibold rounded-md transition-colors ${showTrades ? 'bg-violet-100 text-violet-700' : 'text-slate-500 hover:bg-slate-200'}`}
|
||||
@@ -743,6 +743,8 @@ export default function Home() {
|
||||
const [tickInterval, setTickInterval] = useState('60');
|
||||
const [tradeLoading, setTradeLoading] = useState(false);
|
||||
const [stopAfterAll, setStopAfterAll] = useState(false);
|
||||
const [autoClaim, setAutoClaim] = useState(true);
|
||||
const [autoActivate, setAutoActivate] = useState(true);
|
||||
|
||||
const handleSort = (col: SortKey) => {
|
||||
if (sortKey === col) {
|
||||
@@ -812,8 +814,10 @@ export default function Home() {
|
||||
|
||||
fetch('/api/settings')
|
||||
.then((r) => r.json())
|
||||
.then((s: { tick_interval_seconds?: string | null }) => {
|
||||
.then((s: { tick_interval_seconds?: string | null; auto_claim?: string | null; auto_activate?: string | null }) => {
|
||||
if (s.tick_interval_seconds != null) setTickInterval(s.tick_interval_seconds);
|
||||
setAutoClaim(s.auto_claim !== 'off');
|
||||
setAutoActivate(s.auto_activate !== 'off');
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
@@ -1000,6 +1004,50 @@ export default function Home() {
|
||||
/>
|
||||
<span className="text-xs text-slate-400 whitespace-nowrap">Stop after all eligible</span>
|
||||
</label>
|
||||
{config.some((f) => f.platform === 'vest') && (
|
||||
<label
|
||||
className="flex items-center gap-1.5 ml-2 cursor-pointer select-none"
|
||||
title="Claim a funded account's profit as soon as it reaches its stage's target (Vest) — whether or not the scheduler is running"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoClaim}
|
||||
onChange={(e) => {
|
||||
const on = e.target.checked;
|
||||
setAutoClaim(on);
|
||||
fetch('/api/settings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ auto_claim: on ? 'on' : 'off' }),
|
||||
}).then((r) => { if (!r.ok) setAutoClaim(!on); }).catch(() => setAutoClaim(!on));
|
||||
}}
|
||||
className="rounded border-slate-300 text-emerald-500 focus:ring-emerald-500"
|
||||
/>
|
||||
<span className="text-xs text-slate-400 whitespace-nowrap">Auto-claim payouts</span>
|
||||
</label>
|
||||
)}
|
||||
{config.some((f) => f.platform === 'vest') && (
|
||||
<label
|
||||
className="flex items-center gap-1.5 ml-2 cursor-pointer select-none"
|
||||
title="Activate a passed evaluation's funded account as soon as it passes (Vest) — whether or not the scheduler is running"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoActivate}
|
||||
onChange={(e) => {
|
||||
const on = e.target.checked;
|
||||
setAutoActivate(on);
|
||||
fetch('/api/settings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ auto_activate: on ? 'on' : 'off' }),
|
||||
}).then((r) => { if (!r.ok) setAutoActivate(!on); }).catch(() => setAutoActivate(!on));
|
||||
}}
|
||||
className="rounded border-slate-300 text-emerald-500 focus:ring-emerald-500"
|
||||
/>
|
||||
<span className="text-xs text-slate-400 whitespace-nowrap">Auto-activate funded</span>
|
||||
</label>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{hasAnyPosition && (
|
||||
<button
|
||||
|
||||
@@ -95,9 +95,26 @@ dashboard POST /api/vest/session holds it in memory for the firm's Vest cl
|
||||
- The hook only reads the Vest app's own `/v3/auth/refresh` and `/register`
|
||||
answers. It renews nothing and sends nothing; the session stays the app's,
|
||||
so the bot never logs the tab out.
|
||||
- It takes the login's token only. The app renews each trading account's own
|
||||
token through the same `/v3/auth/refresh` (with that account's refresh token
|
||||
in the body), and one of those sees a single account. Before 0.17.2 the hook
|
||||
relayed those too, and the dashboard dropped every other account; the
|
||||
dashboard now refuses such a token and falls back to the login's.
|
||||
- The token lives ~15 minutes and the app renews it by itself while the tab is
|
||||
open. The tab is marked not auto-discardable, since a discarded tab stops
|
||||
renewing.
|
||||
- Several Vest tabs each renew on their own, and every renewal moves the login
|
||||
on to a new session (localStorage `vest-auth-session`). A token from before
|
||||
still reads, but Vest refuses to mint an account's token with it (401, code
|
||||
1002), so the bot's orders and history reads fail. Since 0.18.0 the hook
|
||||
records each token's session id, and the relay sends the token Vest's
|
||||
current session holds, whichever tab has it, not the first tab's. A tab
|
||||
hooked by an older build is matched by its token's expiry instead; one that
|
||||
renewed before any hook was in it shows in the popup as "none holds the
|
||||
current session" until it renews again or is reloaded.
|
||||
- A sign-in's answer (`/register`) carries the login's own refresh token, and
|
||||
is taken too since 0.18.0; before, the first token after a sign-in waited
|
||||
for the tab's first renewal.
|
||||
- When the dashboard has a Vest firm (its credential is the login's wallet)
|
||||
and no Vest tab is open, the extension opens one in the background. The app
|
||||
signs back in from its 30-day session. After that the person signs in again
|
||||
|
||||
+61
-7
@@ -1204,6 +1204,13 @@ async function tick() {
|
||||
// Vest app renewed for itself (~15 minutes each); this lifts it and posts it to
|
||||
// the dashboard whenever it changes. Nothing here renews or signs anything: the
|
||||
// session stays the app's.
|
||||
//
|
||||
// With several Vest tabs open, each renews on its own, and each renewal moves
|
||||
// the login on to a new session: the other tabs' tokens still read, but Vest
|
||||
// refuses to mint an account's token with them (401, code 1002) — the bot's
|
||||
// history reads and orders fail until the tab it relays renews again. So the
|
||||
// token relayed is the one Vest's current session holds (pickVestSession),
|
||||
// whichever tab it is in, not the first tab's.
|
||||
|
||||
const VEST_PATTERN = 'https://next.vestmarkets.com/*';
|
||||
const VEST_URL = 'https://next.vestmarkets.com/portfolio';
|
||||
@@ -1220,10 +1227,47 @@ let vestLastPostAt = 0;
|
||||
let vestOpenedAt = 0;
|
||||
|
||||
/** Runs in the Vest tab's MAIN world: the session vest-relay.js holds, while
|
||||
* it has life left. Self-contained. */
|
||||
* it has life left, and the current session the Vest app keeps for all its
|
||||
* tabs. Self-contained. Version 2's token whenever it holds one: it sees
|
||||
* every login renewal version 1 does once it's in, so a version-1 token it
|
||||
* lacks is an account's (the 0.17.0/0.17.1 hooks kept those too). */
|
||||
function pageVestSession() {
|
||||
const s = window.__afVestSession;
|
||||
return { hooked: window.__afVestHook === true, session: s && s.expiresAt > Date.now() + 30000 ? s : null };
|
||||
const live = (s) => (s && typeof s.accessToken === 'string' && s.expiresAt > Date.now() + 30000 ? s : null);
|
||||
const session = live(window.__afVestLogin) ?? live(window.__afVestSession);
|
||||
let current = null;
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem('vest-auth-session') || 'null');
|
||||
if (raw && typeof raw === 'object') {
|
||||
current = {
|
||||
refreshTokenId: typeof raw.refreshTokenId === 'string' && raw.refreshTokenId ? raw.refreshTokenId : null,
|
||||
accessTokenExpiresAt: Number.isFinite(raw.accessTokenExpiresAt) ? raw.accessTokenExpiresAt : null,
|
||||
};
|
||||
}
|
||||
} catch { /* no session kept */ }
|
||||
return { hooked: window.__afVestHook === 2, session, current };
|
||||
}
|
||||
|
||||
/** Whether a tab's token is the one the Vest app's current session holds:
|
||||
* the same session id — the app's own check before it mints — else, for a
|
||||
* token caught by a version-1 hook (no id), the same expiry, which the app
|
||||
* writes into the session with the token. */
|
||||
function isCurrentVestSession(session, current) {
|
||||
if (!session || !current) return false;
|
||||
if (session.refreshTokenId && current.refreshTokenId) return session.refreshTokenId === current.refreshTokenId;
|
||||
return current.accessTokenExpiresAt !== null && session.expiresAt === current.accessTokenExpiresAt;
|
||||
}
|
||||
|
||||
/** Out of every Vest tab's pageVestSession, the session to relay: the
|
||||
* current one, else — no tab holds it (one renewed before its hook was in)
|
||||
* — the latest to expire, which still reads. `current` is null when no tab
|
||||
* shows the app's session record to judge by. */
|
||||
function pickVestSession(pages) {
|
||||
const live = pages.filter((p) => p && p.session);
|
||||
if (live.length === 0) return null;
|
||||
const current = live.find((p) => isCurrentVestSession(p.session, p.current));
|
||||
if (current) return { session: current.session, current: true };
|
||||
const newest = live.reduce((x, y) => (y.session.expiresAt > x.session.expiresAt ? y : x));
|
||||
return { session: newest.session, current: pages.some((p) => p && p.current) ? false : null };
|
||||
}
|
||||
|
||||
/** The wallets the dashboard's Vest firms trade; [] from an older dashboard. */
|
||||
@@ -1259,7 +1303,8 @@ async function relayVestSession(cfg) {
|
||||
return;
|
||||
}
|
||||
|
||||
let found = null;
|
||||
// Every tab: the current session may be in any of them.
|
||||
const pages = [];
|
||||
for (const tab of tabs) {
|
||||
// A discarded tab stops renewing the session; keep Chrome from it.
|
||||
if (tab.autoDiscardable !== false) chrome.tabs.update(tab.id, { autoDiscardable: false }).catch(() => {});
|
||||
@@ -1272,12 +1317,14 @@ async function relayVestSession(cfg) {
|
||||
// next renewal is caught.
|
||||
await chrome.scripting.executeScript({ target: { tabId: tab.id }, world: 'MAIN', files: ['vest-relay.js'] }).catch(() => {});
|
||||
}
|
||||
if (out?.session) { found = out.session; break; }
|
||||
if (out) pages.push(out);
|
||||
}
|
||||
if (!found) {
|
||||
const picked = pickVestSession(pages);
|
||||
if (!picked) {
|
||||
await setState({ vest: 'Vest tab open — waiting for its session (sign in there if it shows Log In)' });
|
||||
return;
|
||||
}
|
||||
const found = picked.session;
|
||||
|
||||
const fingerprint = `${found.expiresAt}:${found.accessToken.length}:${found.accessToken.slice(-16)}`;
|
||||
if (fingerprint === vestLastRelayed && now - vestLastPostAt < VEST_REPOST_MS) return;
|
||||
@@ -1297,7 +1344,14 @@ async function relayVestSession(cfg) {
|
||||
const mins = Math.max(0, Math.round((found.expiresAt - Date.now()) / 60000));
|
||||
const note = Array.isArray(body.bound) && body.bound.length > 0 ? ` · bound to ${body.bound.join(', ')}`
|
||||
: body.used ? '' : ' (no Vest firm here uses this wallet)';
|
||||
await setState({ vest: `Relayed ${short}${note} · good ${mins}m` });
|
||||
// A tab that renewed before its hook was in holds the current session
|
||||
// unseen: until it renews again, orders and history reads can fail —
|
||||
// reloading it hooks it.
|
||||
const tabsNote = picked.current === false
|
||||
? (tabs.length === 1 ? " · its token isn't Vest's current session — reload the Vest tab"
|
||||
: ` · ${tabs.length} Vest tabs and none holds the current session — reload them, or keep one open`)
|
||||
: tabs.length > 1 ? ` · ${tabs.length} Vest tabs${picked.current ? ', relaying the current one' : ''}` : '';
|
||||
await setState({ vest: `Relayed ${short}${note} · good ${mins}m${tabsNote}` });
|
||||
}
|
||||
|
||||
// ── 2FA: the person's authenticator code for the firm site you are on ───────
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "AutoFirmer Capture",
|
||||
"version": "0.17.1",
|
||||
"version": "0.18.0",
|
||||
|
||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxdbOMRR+58a9dHlbIKtlALtS3E4eltBGqfTnUc0lzJuWj5ZA4+5YFSdUnxv5N1ogxwpJUGkUV3MnKJuZnTekCmsNwjCy41a20Xtc7jQLHrIBZkesB6cB+d0l9Vo8M6EC3ZMD8Iv6MdAalU3GZh1Hn9vm24NjJ+xUT1qNIVHEcNUIb9evYobR/XHv+JAp6bOFnaLM+3wpyYKe4efMhsz0F86u9thRNhQnraTqUWfnKNpsHPK2zivaYHrziEEWX7Y3EsMHkf2FRhkUe7IDoxeJM49DBToTo9sAJUkcgLeGCGZLo7H3mcxyn/zERYR+rD3k5xdeBtjoZHXFx2+FanLb9wIDAQAB",
|
||||
"update_url": "https://ext.juicerroom.com/updates.xml",
|
||||
|
||||
+42
-14
@@ -6,34 +6,59 @@
|
||||
*
|
||||
* Vest's app renews its own ~15-minute access token by itself: at load, then
|
||||
* whenever one nears expiry (POST /v3/auth/refresh, or /v3/auth/register at
|
||||
* sign-in). Those answers carry the token, its expiry and the login's wallet.
|
||||
* This hook reads them off the app's own XHRs and keeps the latest on
|
||||
* `window.__afVestSession`, where background.js collects it and relays it to
|
||||
* the local dashboard, whose Vest client trades with it.
|
||||
* sign-in). Those answers carry the token, its expiry, the login's wallet and
|
||||
* the session it belongs to (refreshTokenId). This hook reads them off the
|
||||
* app's own XHRs and keeps the latest on `window.__afVestLogin`, where
|
||||
* background.js collects it and relays it to the local dashboard, whose Vest
|
||||
* client trades with it.
|
||||
*
|
||||
* Every tab renews on its own, and each renewal moves the login on to a new
|
||||
* session (localStorage `vest-auth-session`): a token from before still reads,
|
||||
* but Vest refuses to mint an account's token with it (401, code 1002). The
|
||||
* session id lets background.js relay the tab whose token is the current one.
|
||||
*
|
||||
* It sends nothing anywhere, changes no request and never renews anything
|
||||
* itself: the session stays the app's, refreshed by the app with its own
|
||||
* device key, so the tab is never logged out by the bot.
|
||||
*
|
||||
* The app also renews each trading account's own token through the same
|
||||
* /v3/auth/refresh, sending that account's refresh token in the body (the
|
||||
* login's rides in a cookie) and getting it back renewed. Those tokens see
|
||||
* one account only, so they are skipped: relaying one would leave the
|
||||
* dashboard with a single account. A sign-in's answer carries the login's
|
||||
* own refresh token, and is taken.
|
||||
*
|
||||
* Version 2. A page hooked by version 1 (`__afVestHook === true`, keeping
|
||||
* `__afVestSession`, no session id, no sign-ins) takes this one beside it.
|
||||
*/
|
||||
|
||||
(() => {
|
||||
if (window.__afVestHook) return;
|
||||
window.__afVestHook = true;
|
||||
if (window.__afVestHook === 2) return;
|
||||
window.__afVestHook = 2;
|
||||
|
||||
const AUTH = /^https:\/\/api-gateway\.hz\.vestmarkets\.com\/v3\/auth\/(refresh|register)(\?|$)/;
|
||||
const SIGN_IN = /\/v3\/auth\/register(\?|$)/;
|
||||
|
||||
function take(url, status, text) {
|
||||
if (status < 200 || status >= 300 || !AUTH.test(url)) return;
|
||||
/** A request body that carries a refresh token: an account's renewal. */
|
||||
function accountRenewal(reqBody) {
|
||||
return typeof reqBody === 'string' && /"refreshToken"\s*:\s*"[^"]/.test(reqBody);
|
||||
}
|
||||
|
||||
function take(url, status, text, accountRequest) {
|
||||
if (accountRequest || status < 200 || status >= 300 || !AUTH.test(url)) return;
|
||||
let body;
|
||||
try { body = JSON.parse(text); } catch { return; }
|
||||
if (!body || typeof body.accessToken !== 'string' || !body.accessToken) return;
|
||||
// A renewal answering with a refresh token is an account's.
|
||||
if (!SIGN_IN.test(url) && typeof body.refreshToken === 'string' && body.refreshToken) return;
|
||||
const expiresAt = Number(body.accessExpiresAtMs);
|
||||
if (!Number.isFinite(expiresAt)) return;
|
||||
window.__afVestSession = {
|
||||
window.__afVestLogin = {
|
||||
accessToken: body.accessToken,
|
||||
expiresAt,
|
||||
primaryAddr: typeof body.primaryAddr === 'string' ? body.primaryAddr : '',
|
||||
userId: typeof body.userId === 'string' ? body.userId : '',
|
||||
refreshTokenId: typeof body.refreshTokenId === 'string' && body.refreshTokenId ? body.refreshTokenId : null,
|
||||
at: Date.now(),
|
||||
};
|
||||
}
|
||||
@@ -44,12 +69,14 @@
|
||||
try { this.__afVestUrl = String(url); } catch { /* ignore */ }
|
||||
return open.apply(this, arguments);
|
||||
};
|
||||
XMLHttpRequest.prototype.send = function () {
|
||||
XMLHttpRequest.prototype.send = function (reqBody) {
|
||||
if (this.__afVestUrl && AUTH.test(this.__afVestUrl)) {
|
||||
let acct = false;
|
||||
try { acct = accountRenewal(reqBody); } catch { /* ignore */ }
|
||||
this.addEventListener('loadend', () => {
|
||||
try {
|
||||
if (this.responseType === '' || this.responseType === 'text') take(this.__afVestUrl, this.status, this.responseText);
|
||||
else if (this.responseType === 'json') take(this.__afVestUrl, this.status, JSON.stringify(this.response));
|
||||
if (this.responseType === '' || this.responseType === 'text') take(this.__afVestUrl, this.status, this.responseText, acct);
|
||||
else if (this.responseType === 'json') take(this.__afVestUrl, this.status, JSON.stringify(this.response), acct);
|
||||
} catch { /* never break the app */ }
|
||||
});
|
||||
}
|
||||
@@ -57,11 +84,12 @@
|
||||
};
|
||||
|
||||
const fetch0 = window.fetch;
|
||||
window.fetch = async function (input) {
|
||||
window.fetch = async function (input, init) {
|
||||
const res = await fetch0.apply(this, arguments);
|
||||
try {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input && input.url;
|
||||
if (url && AUTH.test(url)) res.clone().text().then((t) => take(url, res.status, t)).catch(() => {});
|
||||
const acct = accountRenewal(init && init.body);
|
||||
if (url && AUTH.test(url)) res.clone().text().then((t) => take(url, res.status, t, acct)).catch(() => {});
|
||||
} catch { /* never break the app */ }
|
||||
return res;
|
||||
};
|
||||
|
||||
+283
-37
@@ -11,24 +11,25 @@ import { getClients } from './clients';
|
||||
import { getCachedFirm, getTradableFirms } from './firm-catalog';
|
||||
import { ensureRulesEvidence, getAccountConfig } from './account-type-resolver';
|
||||
import {
|
||||
combineCmeSessions, computeDayPlan, groupLegFraction, pickLegAmount, resolveEffectiveConfig, currentTradeDateCT,
|
||||
combineCmeSessions, computeDayPlan, groupLegFraction, pickLegAmount, resolveEffectiveConfig, tradeDateFor,
|
||||
obfuscationStopDollars, takeProfitPlan, onTick, percentFee, dllRoom, dllStopDollars, dllViable, stopTicks,
|
||||
expectedSwapToWin, planLegSize, maxLossRoom, breachDistance,
|
||||
expectedSwapToWin, planLegSize, maxLossRoom, breachDistance, legCloseNote,
|
||||
isNoTradeTime, isPastSessionClose,
|
||||
BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY,
|
||||
FALLBACK_COMMISSION_PER_CONTRACT, MAX_COMMISSION_PER_CONTRACT, DLL_MIN_STOP,
|
||||
CME_EVENING_OPEN_MIN, foldReason, withinFirmCaps,
|
||||
fundRowsKey, markFinishedCycle, unbookedPayoutDrop, machineSide, stageInputsFor, floorRoom,
|
||||
type DayPlan, type FinishedCycleMark,
|
||||
fundRowsKey, markFinishedCycle, unbookedPayoutDrop, machineSide, stageInputsFor, stagePayouts, floorRoom, shortestNextLeg,
|
||||
claimedCycle, withinEndedCycleWindow,
|
||||
type DayPlan, type FinishedCycleMark, type ClaimedCycle,
|
||||
} from './trading-logic';
|
||||
import { getSetting, setSetting, loadAccountMeta, saveAccountMeta } from './db';
|
||||
import { getSetting, setSetting, loadAccountMeta, saveAccountMeta, loadClaimedCycles, saveClaimedCycles } from './db';
|
||||
import type { LaneTick, TickReason } from './master-protocol';
|
||||
import { isNotionalPlatform } from './master-protocol';
|
||||
import { POINT_VALUES, housekeepingRoot, HOUSEKEEPING_ROOT, MAX_HOLD_HOURS, futuresContracts } from './instrument-specs';
|
||||
import { firmInstruments, drawFuturesInstrument, spreadBlock, type LegShape } from './instruments';
|
||||
import { recordEntry } from './trade-mix';
|
||||
import { sizingHoldHours } from './person-size';
|
||||
import { errorMessage, isClientReady, isOrderNotPlaced, type PlatformClient, type PlatformContract, type PickContext } from './platform-client';
|
||||
import { errorMessage, isClientReady, isNothingToClose, isOrderNotPlaced, type PlatformClient, type PlatformContract, type PickContext } from './platform-client';
|
||||
|
||||
// ── process-wide state ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -71,6 +72,11 @@ interface TradeRuntime {
|
||||
sideReservation?: { side: 'Buy' | 'Sell'; lane: string; lease: number };
|
||||
/** Account id → when its last payout claim failed (runClaims backoff). */
|
||||
claimFailedAt?: Map<number, number>;
|
||||
/** Account id → when its last funded-account activation failed
|
||||
* (runActivations backoff). */
|
||||
activateFailedAt?: Map<number, number>;
|
||||
/** Lanes whose runActivations is out (one at a time per lane). */
|
||||
activationsRunning?: Set<string>;
|
||||
/** Accounts whose exit the broker refused — a bracket while a hard daily
|
||||
* loss limit was on, or a lone take-profit: the position was flattened
|
||||
* and the account sits out the trade date rather than bleed a round trip
|
||||
@@ -260,7 +266,10 @@ function unrecordLeg(accountId: number, tradeDate: string, legAmount: number): v
|
||||
* (OrderNotPlacedError). Anything ambiguous, like an ack or fill timeout,
|
||||
* keeps the leg: the order may well have filled, and a leg counted that never
|
||||
* happened only costs a leg, while one forgotten that did can trade the
|
||||
* account twice.
|
||||
* account twice. Open-only: where the platform checks (Vest, DXtrade), an
|
||||
* account that already holds the symbol — a hand trade the synced positions
|
||||
* don't show yet — is refused rather than having that position closed as the
|
||||
* "entry".
|
||||
*/
|
||||
async function sendEntry(
|
||||
tag: string,
|
||||
@@ -277,7 +286,7 @@ async function sendEntry(
|
||||
const at = Date.now();
|
||||
const realizedBefore = client.accountCashBalances[acc.id]?.realizedPnL ?? 0;
|
||||
try {
|
||||
const fill = await client.sendOrder(acc.id, contractName, contracts, action, 'Market');
|
||||
const fill = await client.sendOrder(acc.id, contractName, contracts, action, 'Market', undefined, 'open');
|
||||
outcomes.set(acc.id, { tradeDate, state: 'filled', at, realizedBefore, legAmount });
|
||||
return fill;
|
||||
} catch (err) {
|
||||
@@ -621,7 +630,7 @@ function dailyLossGate(
|
||||
cfg: { maxPositionSize: number; firmId: string },
|
||||
cash: { amount: number; realizedPnL: number },
|
||||
combinedRealized: number,
|
||||
remainingToday: number,
|
||||
plan: { remainingToday: number; splitAllowed: boolean },
|
||||
singleContract: boolean,
|
||||
): { room: number } | { reason: string } {
|
||||
if (!(dll > 0)) return { room: Infinity };
|
||||
@@ -634,6 +643,7 @@ function dailyLossGate(
|
||||
room = floorRoom({
|
||||
dailyLossLimit: dll, closedBalance: cash.amount, floor,
|
||||
realizedToday: Math.min(cash.realizedPnL, combinedRealized),
|
||||
gain: client.dayGainForRoom?.(accountId, { leg: shortestNextLeg(plan), limit: dll }) ?? 0,
|
||||
});
|
||||
} else {
|
||||
const dayStart: number | null | undefined = client.dayStartBalance?.(accountId);
|
||||
@@ -651,7 +661,7 @@ function dailyLossGate(
|
||||
// could use. Percent-fee firms size by notional, which isn't known
|
||||
// here — fireLeg re-checks with the real size before any entry.
|
||||
const contractsBound = singleContract ? 1
|
||||
: Math.min(Math.max(1, Math.ceil(remainingToday / 1000)), cfg.maxPositionSize > 0 ? cfg.maxPositionSize : Infinity);
|
||||
: Math.min(Math.max(1, Math.ceil(plan.remainingToday / 1000)), cfg.maxPositionSize > 0 ? cfg.maxPositionSize : Infinity);
|
||||
const percentFees = (getCachedFirm(cfg.firmId)?.commissionPct ?? 0) > 0;
|
||||
const feeEstimate = percentFees ? 0 : 2 * FALLBACK_COMMISSION_PER_CONTRACT * contractsBound;
|
||||
if (!dllViable(room, dll, feeEstimate)) {
|
||||
@@ -837,7 +847,7 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a
|
||||
// (day done, challenge complete, leg cap) win. A daily-loss stop-out
|
||||
// leaves about the reserve plus fees, which is below what a trade needs —
|
||||
// so a stop-out benches the account for the rest of the day by itself.
|
||||
const gate = dailyLossGate(client, acc.id, effective.dailyLossLimit, cfg, cash, combined.realizedPnL, plan.remainingToday, isExtraDay || effective.drain);
|
||||
const gate = dailyLossGate(client, acc.id, effective.dailyLossLimit, cfg, cash, combined.realizedPnL, plan, isExtraDay || effective.drain);
|
||||
if ('reason' in gate) return { ...evaluated, eligible: false, reason: gate.reason };
|
||||
if (Number.isFinite(gate.room)) return { ...evaluated, eligible: true, reason: '', dllRoom: gate.room };
|
||||
|
||||
@@ -1042,7 +1052,10 @@ async function runTradeLocked(
|
||||
// operator's pick; 'Auto' is drawn per batch from the batch firm's list.
|
||||
const explicit = futuresLane && symbol !== 'Auto' ? symbol : null;
|
||||
|
||||
const tradeDate = currentTradeDateCT();
|
||||
// The lane's own day (tradeDateFor): a notional lane is one firm — a
|
||||
// Vest lane's day turns at 20:00 ET, its rows and realized P&L with it —
|
||||
// and the futures lane's clients all keep the Central trade date.
|
||||
const tradeDate = tradeDateFor(open[0]?.client);
|
||||
const maxConcurrent = maxConcurrentAccounts();
|
||||
|
||||
// ── Phase 1: the eligible accounts across the lane's firms ──
|
||||
@@ -1216,6 +1229,13 @@ async function runTradeLocked(
|
||||
console.warn('[auto-trade] superseded while collecting accounts — abandoning this batch');
|
||||
return { outcome: 'skipped-in-flight', results: [], detail: 'superseded by another batch while choosing' };
|
||||
}
|
||||
// A Vest lane's day turns with its client's numbers (tradeDateFor). One
|
||||
// that turned while choosing would size and count these legs on the day
|
||||
// that ended — the next tick plans on the new one.
|
||||
if (batch.some((item) => tradeDateFor(item.client) !== tradeDate)) {
|
||||
console.warn(`[auto-trade] ${laneName(lane)}: the trade date turned while choosing — abandoning this batch`);
|
||||
return { outcome: 'skipped-in-flight', results: [], detail: 'the trade date turned while choosing' };
|
||||
}
|
||||
|
||||
console.log(`[auto-trade] ${laneName(lane)}: ${allEligible.length} eligible account(s) across ${groups.size} prefix group(s) — firing batch of ${batch.length} ${batchPrefix || '(no prefix)'} on ${resolvedSymbol}`);
|
||||
runtime().lastFireAt = Date.now();
|
||||
@@ -1236,10 +1256,10 @@ async function runTradeLocked(
|
||||
const mnqContract = await client.findFrontMonthContract(microRoot);
|
||||
if (!mnqContract) throw new Error(`${microRoot} contract not found for extra-day trade`);
|
||||
|
||||
const fill = await client.sendOrder(acc.id, mnqContract.name, 1, resolvedAction, 'Market');
|
||||
const fill = await client.sendOrder(acc.id, mnqContract.name, 1, resolvedAction, 'Market', undefined, 'open');
|
||||
recordEntry(item.firmName, microRoot, resolvedAction, 1, mnqContract.id);
|
||||
const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy';
|
||||
const exitFill = await client.sendOrder(acc.id, mnqContract.name, 1, exitAction, 'Market');
|
||||
const exitFill = await client.sendOrder(acc.id, mnqContract.name, 1, exitAction, 'Market', undefined, 'close');
|
||||
|
||||
console.log(`[auto-trade] ${acc.name} (${item.firmName}) extra-day: ${resolvedAction} 1x${mnqContract.name} @ ${fill.price} | exited @ ${exitFill.price} (market)`);
|
||||
|
||||
@@ -1299,7 +1319,11 @@ const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
* Close a just-filled position at market because it must not stay open — its
|
||||
* daily-loss stop has no room, or the broker refused its exit bracket — and
|
||||
* report the leg as failed. A flatten that fails too is the worst case: a
|
||||
* position with no stop, so it is shouted about.
|
||||
* position with no stop, so it is shouted about. Close-only: on an account
|
||||
* that turned out flat it must not open a fresh position with no exits, and
|
||||
* where the platform checks (Vest, DXtrade) and its own record agrees, that
|
||||
* flat account has nothing riding — the leg still fails, but without the
|
||||
* alarm. A flat read the record contradicts still gets the alarm.
|
||||
*/
|
||||
async function flattenAndFail(
|
||||
tag: string,
|
||||
@@ -1313,9 +1337,13 @@ async function flattenAndFail(
|
||||
why: string,
|
||||
): Promise<never> {
|
||||
try {
|
||||
const exit = await client.sendOrder(acc.id, contractName, contracts, exitAction, 'Market');
|
||||
const exit = await client.sendOrder(acc.id, contractName, contracts, exitAction, 'Market', undefined, 'close');
|
||||
console.warn(`${tag} ${acc.name}: flattened @ ${exit.price} — ${why}`);
|
||||
} catch (err) {
|
||||
if (isNothingToClose(err)) {
|
||||
console.warn(`${tag} ${acc.name}: nothing to flatten — ${errorMessage(err)} (${why})`);
|
||||
throw new Error(`entry filled @ ${entryPrice}; ${why}; already flat — nothing to flatten`);
|
||||
}
|
||||
console.error(`${tag} ${acc.name}: FLATTEN FAILED (${errorMessage(err)}) — the position may be open WITHOUT A STOP; close it at the broker (${why})`);
|
||||
throw new Error(`entry filled @ ${entryPrice}; ${why}; flatten failed — ${errorMessage(err)}`);
|
||||
}
|
||||
@@ -1398,6 +1426,7 @@ function sizeLeg(item: SizingItem, contract: LegContract, legAmount: number, tra
|
||||
maxLossRoom: floorRoom,
|
||||
obfuscationDollars: obfuscation,
|
||||
availableFunds: live.availableFunds ?? live.amount,
|
||||
slippageFraction: item.client.stopSlippageFraction,
|
||||
});
|
||||
return { ...plan, pct, obfuscation, maxLossRoom: floorRoom };
|
||||
}
|
||||
@@ -1486,6 +1515,10 @@ async function fireLeg(
|
||||
if (!plan.sizing) {
|
||||
throw new Error(`no position size for ${legContract.name} — its recent volatility is unknown`);
|
||||
}
|
||||
// The pick held the batch's lead leg to its market's close; this
|
||||
// account's leg can size longer (another stage, a leverage cap).
|
||||
const closeNote = legCloseNote(legContract.closesAt, plan.sizing.predictedHoldHours, Date.now());
|
||||
if (closeNote) throw new Error(`${legContract.name}: ${closeNote} — not entering`);
|
||||
contracts = Math.max(1, Math.round(plan.sizing.notional / stepNotional));
|
||||
if (plan.cap > 0) contracts = Math.min(contracts, Math.max(1, Math.floor(plan.cap / stepNotional)));
|
||||
if ((legContract.maxContracts ?? 0) > 0) contracts = Math.min(contracts, legContract.maxContracts!);
|
||||
@@ -1544,7 +1577,7 @@ async function fireLeg(
|
||||
const dllFee = pct > 0 ? entryFee : (measured ?? MAX_COMMISSION_PER_CONTRACT * contracts);
|
||||
const notional = fill.price * pv * contracts;
|
||||
const dllDollars = dllOn && !plan.lastStand
|
||||
? dllStopDollars({ room: item.dllRoom, dailyLossLimit: dll, entryFee: dllFee, commissionPct: pct, notional })
|
||||
? dllStopDollars({ room: item.dllRoom, dailyLossLimit: dll, entryFee: dllFee, commissionPct: pct, notional, slippageFraction: client.stopSlippageFraction })
|
||||
: Infinity;
|
||||
const ticks = stopTicks({ obfuscationDollars: obfuscation, dllDollars, pointValue: pv, contracts, tickSize: legContract.tickSize });
|
||||
if (ticks === 0) {
|
||||
@@ -1740,7 +1773,8 @@ async function copyTradeLocked(lease: number, lane: string): Promise<unknown[]>
|
||||
return [];
|
||||
}
|
||||
|
||||
const tradeDate = currentTradeDateCT();
|
||||
// The lane's own day, as in runTrade.
|
||||
const tradeDate = tradeDateFor(ready[0]?.client);
|
||||
|
||||
// Collect eligible accounts (same logic as Phase 1 of runTrade)
|
||||
const eligible: LegItem[] = [];
|
||||
@@ -1804,6 +1838,10 @@ async function copyTradeLocked(lease: number, lane: string): Promise<unknown[]>
|
||||
console.warn('[copy-trade] superseded while collecting accounts — abandoning this copy');
|
||||
return [];
|
||||
}
|
||||
if (batch.some((item) => tradeDateFor(item.client) !== tradeDate)) {
|
||||
console.warn('[copy-trade] the trade date turned while choosing — abandoning this copy');
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log(`[copy-trade] copying ${resolvedAction} ${resolvedSymbol} to ${batch.length} account(s)`);
|
||||
runtime().lastFireAt = Date.now();
|
||||
@@ -1858,7 +1896,7 @@ export async function fireManualLeg(accountId: number, action: 'Buy' | 'Sell' |
|
||||
const lease = acquireTradeMutex('[manual-leg]', lane);
|
||||
if (lease === null) throw new Error('another batch is in flight in this lane');
|
||||
try {
|
||||
const tradeDate = currentTradeDateCT();
|
||||
const tradeDate = tradeDateFor(client);
|
||||
const ev = evaluateAccountForTrade(client, acc, tradeDate);
|
||||
if (!ev || !ev.plan) throw new Error(ev?.reason || 'account has no account type');
|
||||
if (!ev.eligible && !ev.reason.startsWith('already traded today')) throw new Error(ev.reason);
|
||||
@@ -1868,7 +1906,7 @@ export async function fireManualLeg(accountId: number, action: 'Buy' | 'Sell' |
|
||||
// The once-a-day return comes before the daily-loss check: run it here.
|
||||
let room = ev.dllRoom;
|
||||
if (!ev.eligible) {
|
||||
const gate = dailyLossGate(client, acc.id, ev.dll, cfg, ev.cash, ev.cash.realizedPnL, ev.plan.remainingToday, ev.drain);
|
||||
const gate = dailyLossGate(client, acc.id, ev.dll, cfg, ev.cash, ev.cash.realizedPnL, ev.plan, ev.drain);
|
||||
if ('reason' in gate) throw new Error(gate.reason);
|
||||
room = gate.room;
|
||||
}
|
||||
@@ -1947,6 +1985,7 @@ function claimDue(client: any, acc: { id: number; active: boolean }, now: number
|
||||
if (!cfg || cfg.payoutable !== true || !acc.active) return null;
|
||||
if (client.positions[acc.id] || client.hasWorkingOrders?.(acc.id) || runtime().accountsInFlight.has(acc.id)) return null;
|
||||
if (!client.historyReady?.(acc.id)) return null;
|
||||
if (client.claimPending?.(acc.id)) return null;
|
||||
if (now - (runtime().claimFailedAt?.get(acc.id) ?? 0) < CLAIM_RETRY_MS) return null;
|
||||
const cash = client.accountCashBalances[acc.id];
|
||||
if (!cash) return null;
|
||||
@@ -1966,8 +2005,9 @@ function claimDue(client: any, acc: { id: number; active: boolean }, now: number
|
||||
* Profit). The account then starts its next stage the same day: its leg
|
||||
* counts and entry outcome for the day are cleared, and a type whose stages
|
||||
* reset each day trades its next stage on the room the claim freed. Under the
|
||||
* lane's mutex, so no batch reads an account mid-claim. Off with the
|
||||
* auto_claim setting at 'off'.
|
||||
* lane's mutex, so no batch reads an account mid-claim. Run by the
|
||||
* scheduler's tick, or by the claim watcher while the scheduler is off. Off
|
||||
* with the auto_claim setting at 'off'.
|
||||
*/
|
||||
async function runClaims(lane: string, ready: FirmClient[]): Promise<void> {
|
||||
if (getSetting('auto_claim') === 'off') return;
|
||||
@@ -1982,20 +2022,17 @@ async function runClaims(lane: string, ready: FirmClient[]): Promise<void> {
|
||||
const lease = acquireTradeMutex('[auto-claim]', lane);
|
||||
if (lease === null) return;
|
||||
try {
|
||||
const tradeDate = currentTradeDateCT();
|
||||
for (const { firm, client, acc, amount } of due) {
|
||||
for (const { firm, client, acc } of due) {
|
||||
if (!holdsTradeMutex(lease, lane)) return;
|
||||
if (claimDue(client, acc, Date.now()) === null) continue; // settled since
|
||||
// Settled since, or a different amount now: claim what is due now.
|
||||
const amount = claimDue(client, acc, Date.now());
|
||||
if (amount === null) continue;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { claimed, received } = await (client as any).claimProfit(acc.id, amount);
|
||||
runtime().legCounts.delete(acc.id);
|
||||
runtime().entryOutcomes?.delete(acc.id);
|
||||
runtime().claimFailedAt?.delete(acc.id);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const stage = 1 + ((client as any).sameDayClaims?.(acc.id) ?? 0);
|
||||
console.log(`[auto-claim] ${firm.name} ${acc.name}: claimed $${claimed}${received !== null ? ` (you get $${received})` : ''}`
|
||||
+ ` — trading stage ${stage} next (${tradeDate})`);
|
||||
const ends = claimEnds(client, acc);
|
||||
// In flight while it claims: a batch that takes over a wedged
|
||||
// lane, or a Claim now, leaves the account alone.
|
||||
const { claimed, received } = await withAccountInFlight(acc.id, () => client.claimProfit!(acc.id, amount));
|
||||
afterClaim('[auto-claim]', firm, client, acc, claimed, received, tradeDateFor(client), ends);
|
||||
} catch (err) {
|
||||
(runtime().claimFailedAt ??= new Map()).set(acc.id, Date.now());
|
||||
console.error(`[auto-claim] ${firm.name} ${acc.name}: claim of $${Math.floor(amount * 100) / 100} failed — ${errorMessage(err)}; retrying in ${CLAIM_RETRY_MS / 60_000}m`);
|
||||
@@ -2006,12 +2043,218 @@ async function runClaims(lane: string, ready: FirmClient[]): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** How long a failed activation waits before it's tried again — a refusal
|
||||
* (Vest's cap on funded accounts, say) holds for a while. */
|
||||
const ACTIVATE_RETRY_MS = 15 * 60_000;
|
||||
|
||||
/**
|
||||
* Activate the funded account of every passed evaluation in the lane that
|
||||
* the bot trades (one with an account type, as runClaims goes by — Vest caps
|
||||
* funded accounts, and one kept by hand isn't the bot's to spend a slot on),
|
||||
* where the platform does that by API (Vest: a passed evaluation waits until
|
||||
* it's asked). It opens and closes nothing — the evaluation no longer
|
||||
* trades, and the funded account shows on the next sync and trades from
|
||||
* there — so it takes no lane mutex, and the scheduler doesn't wait on it.
|
||||
* One run per lane at a time. Run by the scheduler's tick, or by the claim
|
||||
* watcher while the scheduler is off. Off with the auto_activate setting at
|
||||
* 'off'.
|
||||
*/
|
||||
async function runActivations(lane: string, ready: FirmClient[]): Promise<void> {
|
||||
if (getSetting('auto_activate') === 'off') return;
|
||||
const running = (runtime().activationsRunning ??= new Set());
|
||||
if (running.has(lane)) return;
|
||||
running.add(lane);
|
||||
try {
|
||||
for (const { firm, client } of inLane(ready, lane)) {
|
||||
if (typeof client.activatable !== 'function' || typeof client.activateFunded !== 'function') continue;
|
||||
for (const id of client.activatable()) {
|
||||
const acc = client.accountList.find((a) => a.id === id);
|
||||
if (!acc || !getAccountConfig(id).accountType) continue;
|
||||
if (Date.now() - (runtime().activateFailedAt?.get(id) ?? 0) < ACTIVATE_RETRY_MS) continue;
|
||||
try {
|
||||
const { funded } = await client.activateFunded(id);
|
||||
runtime().activateFailedAt?.delete(id);
|
||||
console.log(`[auto-activate] ${firm.name} ${acc.name}: passed — funded account ${funded ?? '(listed on the next sync)'} activated`);
|
||||
} catch (err) {
|
||||
(runtime().activateFailedAt ??= new Map()).set(id, Date.now());
|
||||
console.error(`[auto-activate] ${firm.name} ${acc.name}: activation failed — ${errorMessage(err)}; retrying in ${ACTIVATE_RETRY_MS / 60_000}m`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
running.delete(lane);
|
||||
}
|
||||
}
|
||||
|
||||
/** How often auto-claims run while the scheduler is off — its tick's pace. */
|
||||
const CLAIM_WATCH_MS = 60_000;
|
||||
|
||||
const _claimWatch = globalThis as typeof globalThis & { __claimWatcher?: ReturnType<typeof setInterval>; __claimTickBusy?: boolean };
|
||||
|
||||
/**
|
||||
* Auto-claims don't wait on the scheduler: a payout locks in profit and
|
||||
* opens nothing, so stopping the trading doesn't stop it — a funded account
|
||||
* at its target while the bot is off is claimed all the same, before
|
||||
* whatever trades it next can take the profit back. While the scheduler
|
||||
* runs, its own tick claims (first thing in each lane), and this stays out
|
||||
* of the way. Off with the auto_claim setting at 'off'. Activations of passed
|
||||
* evaluations' funded accounts ride along the same way (auto_activate).
|
||||
* Started once, with the clients.
|
||||
*/
|
||||
export function startClaimWatcher(): void {
|
||||
if (_claimWatch.__claimWatcher) return;
|
||||
_claimWatch.__claimWatcher = setInterval(() => { void claimTick(); }, CLAIM_WATCH_MS);
|
||||
}
|
||||
|
||||
async function claimTick(): Promise<void> {
|
||||
if (getState().running || _claimWatch.__claimTickBusy) return;
|
||||
if (getSetting('auto_claim') === 'off' && getSetting('auto_activate') === 'off') return;
|
||||
_claimWatch.__claimTickBusy = true;
|
||||
try {
|
||||
// Ready clients only, as the tick: a stale snapshot may hide a
|
||||
// position. runClaims takes each lane's mutex itself; each step
|
||||
// checks its own setting.
|
||||
const { ready } = firmClients();
|
||||
for (const lane of tradableLanes()) {
|
||||
if (getState().running) return; // the scheduler took over
|
||||
await runClaims(lane, ready);
|
||||
await runActivations(lane, ready);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[auto-claim] ${errorMessage(err)}`);
|
||||
} finally {
|
||||
_claimWatch.__claimTickBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The completed cycle a claim about to go out ends (claimedCycle), taken
|
||||
* before the claim books its payout. Not on rows a failed read left behind
|
||||
* — the master keeps it — which is historyFresh's own test for the
|
||||
* account; not its client-wide one, no read in flight, as each claim
|
||||
* starts a read and the next claim of the tick would never name its
|
||||
* cycle. The claim already stands on the account's read (historyReady).
|
||||
* Never throws: a payout isn't held back for its record. */
|
||||
function claimEnds(client: PlatformClient, acc: { id: number; name: string }): ClaimedCycle | null {
|
||||
try {
|
||||
const cfg = getAccountConfig(acc.id).accountType;
|
||||
// Judged as claimDue claims: within LEG_COMPLETION_EPSILON of the target.
|
||||
return cfg && !client.lastFetchErrors?.[acc.name] ? claimedCycle(client, acc.id, cfg, new Date(), LEG_COMPLETION_EPSILON) : null;
|
||||
} catch (err) {
|
||||
console.error(`[claim] ${acc.name}: the cycle the claim ends couldn't be read — ${errorMessage(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** A claim went through: the account starts its next stage — the day's leg
|
||||
* count and entry outcome are cleared, as if it hadn't traded today — and
|
||||
* the completed cycle it ended (claimEnds) is kept for the reporter, which
|
||||
* may never have seen it complete. */
|
||||
function afterClaim(
|
||||
tag: string, firm: TradableFirm, client: PlatformClient, acc: { id: number; name: string },
|
||||
claimed: number, received: number | null, tradeDate: string, ends: ClaimedCycle | null,
|
||||
): number {
|
||||
runtime().legCounts.delete(acc.id);
|
||||
runtime().entryOutcomes?.delete(acc.id);
|
||||
runtime().claimFailedAt?.delete(acc.id);
|
||||
if (ends) {
|
||||
try {
|
||||
saveClaimedCycles(acc.id, [...loadClaimedCycles(acc.id).filter((c) => withinEndedCycleWindow(c.date, ends.date)), ends]);
|
||||
} catch (err) {
|
||||
console.error(`${tag} ${firm.name} ${acc.name}: the completed cycle the claim ended wasn't saved — ${errorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
// The stage the engine trades next, counted as it counts it (stagePayouts).
|
||||
const cfg = getAccountConfig(acc.id).accountType;
|
||||
const stage = 1 + stagePayouts(client.fundTransactions?.[acc.id] ?? [], cfg ? stageInputsFor(client, acc.id, cfg) : {});
|
||||
console.log(`${tag} ${firm.name} ${acc.name}: claimed $${claimed}${received !== null ? ` (you get $${received})` : ''}`
|
||||
+ ` — trading stage ${stage} next (${tradeDate})`);
|
||||
return stage;
|
||||
}
|
||||
|
||||
/** A Claim now the account can't take as asked — said back to the person
|
||||
* as is (the claim never reached the platform). */
|
||||
export class ClaimRefusedError extends Error {}
|
||||
|
||||
/** Whether an account can be claimed by hand right now, and how much: its
|
||||
* whole profit above the account size, whatever its stage's target. */
|
||||
function manualClaim(accountId: number):
|
||||
| { firm: TradableFirm; client: PlatformClient; acc: { id: number; name: string }; amount: number; target: number | null }
|
||||
| { reason: string; unsupported?: true } {
|
||||
const { ready, unready } = firmClients();
|
||||
const holds = ({ client }: FirmClient) => client.accountList.some((a) => a.id === accountId);
|
||||
const item = ready.find(holds);
|
||||
if (!item) {
|
||||
const stale = unready.find(holds);
|
||||
if (stale && typeof stale.client.claimProfit !== 'function') return { reason: `${stale.firm.name} takes no claims through its API`, unsupported: true };
|
||||
return { reason: stale ? `${stale.firm.name} hasn't synced recently — wait for its next sync` : 'the account is not on a connected firm' };
|
||||
}
|
||||
const { firm, client } = item;
|
||||
if (typeof client.claimProfit !== 'function') return { reason: `${firm.name} takes no claims through its API`, unsupported: true };
|
||||
const acc = client.accountList.find((a) => a.id === accountId)!;
|
||||
const cfg = getAccountConfig(acc.id).accountType;
|
||||
if (!cfg) return { reason: 'no account type is assigned' };
|
||||
if (cfg.payoutable !== true) return { reason: `${cfg.label} doesn't take payouts`, unsupported: true };
|
||||
if (!acc.active) return { reason: `${firm.name} shows the account inactive` };
|
||||
if (client.positions[acc.id] || client.hasWorkingOrders?.(acc.id) || runtime().accountsInFlight.has(acc.id)) {
|
||||
return { reason: 'a position or order is open — the account must be flat' };
|
||||
}
|
||||
if (!client.historyReady(acc.id)) return { reason: 'its history is still loading' };
|
||||
if (client.claimPending?.(acc.id)) return { reason: 'the last claim is still settling — wait for the next sync' };
|
||||
const cash = client.accountCashBalances[acc.id];
|
||||
if (!cash) return { reason: 'its balance has not been read yet' };
|
||||
const profit = Math.floor((cash.amount - cfg.accountSize) * 100) / 100;
|
||||
if (!(profit >= 1)) {
|
||||
return { reason: `$${Math.abs(profit).toFixed(2)} ${profit < 0 ? 'below' : 'above'} its $${cfg.accountSize.toLocaleString()} start — nothing to claim (the least is $1)` };
|
||||
}
|
||||
const effective = resolveEffectiveConfig(
|
||||
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages,
|
||||
client.priorProfit?.[acc.id] ?? 0, client.fundTransactions?.[acc.id] ?? [], cfg.minDayPnL,
|
||||
stageInputsFor(client, acc.id, cfg),
|
||||
);
|
||||
return { firm, client, acc, amount: profit, target: effective.profitTarget > 0 ? effective.profitTarget : null };
|
||||
}
|
||||
|
||||
/** The dashboard's view of manualClaim. */
|
||||
export function claimableNow(accountId: number):
|
||||
| { claimable: number; target: number | null; firm: string }
|
||||
| { claimable: null; reason: string; unsupported: boolean } {
|
||||
const c = manualClaim(accountId);
|
||||
if ('reason' in c) return { claimable: null, reason: c.reason, unsupported: c.unsupported === true };
|
||||
return { claimable: c.amount, target: c.target, firm: c.firm.name };
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim now, from the dashboard: the person pressed it, so the stage's
|
||||
* target doesn't matter — any profit from $1 up to all of it above the
|
||||
* account size. The same checks as an automatic claim, made again under the
|
||||
* lane's mutex (no batch reads the account mid-claim), and the same
|
||||
* bookkeeping after: the account trades its next stage.
|
||||
*/
|
||||
export async function claimAccountNow(accountId: number, amount: number): Promise<{ claimed: number; received: number | null; stage: number }> {
|
||||
const first = manualClaim(accountId);
|
||||
if ('reason' in first) throw new ClaimRefusedError(first.reason);
|
||||
const lane = laneOf(first.firm);
|
||||
const lease = acquireTradeMutex('[claim-now]', lane);
|
||||
if (lease === null) throw new ClaimRefusedError(`the bot is placing a trade on ${first.firm.name} right now — try again in a minute`);
|
||||
try {
|
||||
const c = manualClaim(accountId);
|
||||
if ('reason' in c) throw new ClaimRefusedError(c.reason);
|
||||
if (!(amount >= 1)) throw new ClaimRefusedError(`$${amount} is below the $1 minimum claim`);
|
||||
if (amount > c.amount + 0.005) throw new ClaimRefusedError(`only $${c.amount.toFixed(2)} is claimable now`);
|
||||
const ends = claimEnds(c.client, c.acc);
|
||||
const { claimed, received } = await withAccountInFlight(c.acc.id, () => c.client.claimProfit!(c.acc.id, amount));
|
||||
const stage = afterClaim('[claim-now]', c.firm, c.client, c.acc, claimed, received, tradeDateFor(c.client), ends);
|
||||
return { claimed, received, stage };
|
||||
} finally {
|
||||
releaseTradeMutex(lease, lane);
|
||||
}
|
||||
}
|
||||
|
||||
// ── eligibility check ────────────────────────────────────────────────────────
|
||||
|
||||
/** Returns true if any configured account could still trade today (eligible for a next leg / extra-day, or has an open position). */
|
||||
function hasRemainingConfiguredAccounts(): boolean {
|
||||
const { ready, unready } = firmClients();
|
||||
const tradeDate = currentTradeDateCT();
|
||||
|
||||
// A client that isn't ready can't say whether its accounts are done — its
|
||||
// snapshot is missing or stale — so while one might hold any of ours, the
|
||||
@@ -2044,7 +2287,7 @@ function hasRemainingConfiguredAccounts(): boolean {
|
||||
// Order being placed right now = still in play (evaluation calls it ineligible)
|
||||
if (runtime().accountsInFlight.has(acc.id)) return true;
|
||||
|
||||
const ev = evaluateAccountForTrade(client, acc, tradeDate);
|
||||
const ev = evaluateAccountForTrade(client, acc, tradeDateFor(client));
|
||||
if (ev?.eligible) return true;
|
||||
// Waiting on its history (or its day-start balance), not done: it may
|
||||
// still owe today's trade — during the session and before it opens
|
||||
@@ -2152,8 +2395,11 @@ export function startScheduler(action: 'Buy' | 'Sell' | 'Auto', symbol: string,
|
||||
continue;
|
||||
}
|
||||
// Payouts first, account by account — a claim doesn't wait
|
||||
// for the rest of the lane to go flat.
|
||||
// for the rest of the lane to go flat. Nor does a passed
|
||||
// evaluation's funded account, and the batch doesn't wait
|
||||
// on it: a slow one would hold back the lane's entry.
|
||||
await runClaims(lane, ready);
|
||||
void runActivations(lane, ready).catch((err) => console.error(`[auto-activate] ${laneName(lane)}: ${errorMessage(err)}`));
|
||||
// Skip this lane if any of its accounts still has an open
|
||||
// position from its previous batch — a batch fires only
|
||||
// when every (ready) account in the lane is flat.
|
||||
|
||||
+4
-1
@@ -15,7 +15,7 @@ import { applyBrokerHttpDefaults, applyBrokerProxy } from './outbound-proxy';
|
||||
import axios from './http-client';
|
||||
import { startMasterLink } from './master-link';
|
||||
import { startUpdaterWatchdog } from './updater-watchdog';
|
||||
import { resumeSchedulerIfPersisted } from './auto-trade';
|
||||
import { resumeSchedulerIfPersisted, startClaimWatcher } from './auto-trade';
|
||||
import { superviseQuotes, SUPERVISE_INTERVAL_MS, type QuoteClient } from './live-quotes';
|
||||
|
||||
// Use global to persist the client pool across HMR reloads in dev mode.
|
||||
@@ -189,6 +189,9 @@ export function getClients(): Map<string, PlatformClient> {
|
||||
|
||||
if (!g.__tradovateClientsInitialized) {
|
||||
g.__tradovateClientsInitialized = true;
|
||||
// Payouts at target are claimed whether or not the scheduler runs —
|
||||
// started first, so a later init step that throws can't leave it off.
|
||||
startClaimWatcher();
|
||||
try {
|
||||
// Before anything dials out: a timeout on every broker HTTP call,
|
||||
// and the operator's proxy (if any) under the broker clients.
|
||||
|
||||
@@ -2,6 +2,7 @@ import Database from 'better-sqlite3';
|
||||
import { mkdirSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { sortableIso, stampRulesDate } from './kept-rules';
|
||||
import type { ClaimedCycle } from './trading-logic';
|
||||
|
||||
// AUTOFIRMER_DB lets several instances share one checkout, each with its own
|
||||
// database. It is read once, at module load — schema creation and seeding are
|
||||
@@ -556,6 +557,29 @@ export function loadEndedCycleStart(accountId: number): { fundDate: string; incl
|
||||
}
|
||||
}
|
||||
|
||||
/** The completed cycles this instance's own claims ended (trading-logic
|
||||
* claimedCycle), oldest first — saved by the claim (auto-trade afterClaim),
|
||||
* since it can end one before any report sees it complete; the reporter
|
||||
* names each for the catch-up's week. Gone with a wipe, as the claims
|
||||
* themselves are. */
|
||||
export function saveClaimedCycles(accountId: number, list: ClaimedCycle[]): void {
|
||||
saveAccountMeta(accountId, 'claimed_cycles', JSON.stringify(list));
|
||||
}
|
||||
|
||||
export function loadClaimedCycles(accountId: number): ClaimedCycle[] {
|
||||
const raw = loadAccountMeta(accountId, 'claimed_cycles');
|
||||
if (raw === null) return [];
|
||||
try {
|
||||
const list = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(list)) return [];
|
||||
return list.filter((c): c is ClaimedCycle => typeof c?.date === 'string' && typeof c.cycle === 'string'
|
||||
&& Number.isInteger(c.stage) && typeof c.drain === 'boolean' && Number.isInteger(c.cycleSeq))
|
||||
.map(({ date, stage, drain, cycle, cycleSeq, at }) => ({ date, stage, drain, cycle, cycleSeq, ...(Number.isFinite(at) ? { at } : {}) }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes every cached row for an account — daily P&L, fund transactions and
|
||||
* meta — so the next report fetch rebuilds it from scratch. Rows older than
|
||||
* the report's 28-day window are gone for good; that is the point (stale
|
||||
@@ -582,7 +606,9 @@ export function wipeAccountData(accountId: number): void {
|
||||
// Every position a platform's history shows — the bot's and anyone else's
|
||||
// (Vest: GET /v3/positions). The platform can't tell an API order from a
|
||||
// click, so opened_by / closed_by record what this instance did itself:
|
||||
// 'bot' when its own order opened or closed the position, NULL otherwise.
|
||||
// 'bot' when its own order opened or closed the position, NULL otherwise —
|
||||
// or 'outside', set by hand for a trade from before marking began whose
|
||||
// origin the bot's own log settles.
|
||||
// A wipe leaves these alone: they're refetched, but the marks aren't.
|
||||
|
||||
db.exec(`
|
||||
@@ -650,8 +676,10 @@ export function saveTrades(rows: Omit<TradeRow, 'openedBy' | 'closedBy'>[]): voi
|
||||
quantity = CASE WHEN excluded.quantity > 0 THEN excluded.quantity ELSE trades.quantity END,
|
||||
leverage = COALESCE(excluded.leverage, trades.leverage),
|
||||
open_price = COALESCE(excluded.open_price, trades.open_price),
|
||||
close_price = COALESCE(excluded.close_price, trades.close_price),
|
||||
pnl = COALESCE(excluded.pnl, trades.pnl),
|
||||
close_price = CASE WHEN COALESCE(excluded.closed_at, trades.closed_at) IS NULL THEN NULL
|
||||
ELSE COALESCE(excluded.close_price, trades.close_price) END,
|
||||
pnl = CASE WHEN COALESCE(excluded.closed_at, trades.closed_at) IS NULL THEN NULL
|
||||
ELSE COALESCE(excluded.pnl, trades.pnl) END,
|
||||
fee = COALESCE(excluded.fee, trades.fee),
|
||||
funding = COALESCE(excluded.funding, trades.funding),
|
||||
opened_at = excluded.opened_at,
|
||||
@@ -680,6 +708,125 @@ export function markTrade(
|
||||
`).run(t.positionId, t.accountId, t.accountName, t.symbol, t.side, t.quantity, t.openPrice, t.openedAt, Date.now());
|
||||
}
|
||||
|
||||
/** Which of these positions this instance's own order opened (markTrade). */
|
||||
export function botOpenedPositions(positionIds: string[]): Set<string> {
|
||||
if (positionIds.length === 0) return new Set();
|
||||
const rows = db.prepare(`SELECT position_id AS positionId FROM trades
|
||||
WHERE opened_by = 'bot' AND position_id IN (${positionIds.map(() => '?').join(', ')})`)
|
||||
.all(...positionIds) as { positionId: string }[];
|
||||
return new Set(rows.map((r) => r.positionId));
|
||||
}
|
||||
|
||||
/** Vest accounts with a trade the table still shows open (Vest's position
|
||||
* ids are bare numbers; the fill-built trades below carry a prefix). */
|
||||
export function openTradeAccounts(): { accountId: number; accountName: string }[] {
|
||||
return db.prepare(`SELECT account_id AS accountId, MAX(account_name) AS accountName FROM trades
|
||||
WHERE closed_at IS NULL AND position_id NOT LIKE '%:%' GROUP BY account_id`)
|
||||
.all() as { accountId: number; accountName: string }[];
|
||||
}
|
||||
|
||||
// ── Own orders, and trades built from fills ──────────────────────────────────
|
||||
//
|
||||
// TopstepX and Tradovate report fills, not positions, and neither says who
|
||||
// placed an order. So every order this instance places is noted here as it
|
||||
// goes in, and a trade built from fills (lib/trade-recorder.ts) is the bot's
|
||||
// when its opening fill's order is one of these. Its kind tells how the bot
|
||||
// closed one: its stop, its take-profit limit, or a market order. Those
|
||||
// trades carry a platform prefix on their id ("tsx:…", "tdv:…") and their
|
||||
// marks are worked out afresh on each pass: 'bot', 'outside', or 'unknown'
|
||||
// for a fill from before this table began.
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS own_orders (
|
||||
account_id INTEGER NOT NULL,
|
||||
order_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
placed_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (account_id, order_id)
|
||||
);
|
||||
`);
|
||||
|
||||
/** settings key: when this instance began noting its own futures orders. */
|
||||
const OWN_ORDERS_SINCE_SETTING = 'own_orders_since';
|
||||
const ownOrdersSinceMs = Number(getOrCreateSetting(OWN_ORDERS_SINCE_SETTING, () => String(Date.now())));
|
||||
|
||||
export function ownOrdersSince(): number {
|
||||
return ownOrdersSinceMs;
|
||||
}
|
||||
|
||||
export type OwnOrderKind = 'market' | 'limit' | 'stop';
|
||||
|
||||
export function recordOwnOrder(accountId: number, orderId: string | number, kind: OwnOrderKind): void {
|
||||
db.prepare('INSERT OR IGNORE INTO own_orders (account_id, order_id, kind, placed_at) VALUES (?, ?, ?, ?)')
|
||||
.run(accountId, String(orderId), kind, Date.now());
|
||||
}
|
||||
|
||||
export function loadOwnOrders(accountId: number): Map<string, OwnOrderKind> {
|
||||
const rows = db.prepare('SELECT order_id AS orderId, kind FROM own_orders WHERE account_id = ?').all(accountId) as { orderId: string; kind: OwnOrderKind }[];
|
||||
return new Map(rows.map((r) => [r.orderId, r.kind]));
|
||||
}
|
||||
|
||||
export type FillTradeRow = Omit<TradeRow, 'leverage' | 'funding'>;
|
||||
|
||||
/** One account's stored trades off one platform — for one contract, when
|
||||
* `symbol` is given. */
|
||||
export function loadFillTrades(accountId: number, prefix: string, symbol?: string): FillTradeRow[] {
|
||||
return db.prepare(`
|
||||
SELECT position_id AS positionId, account_id AS accountId, account_name AS accountName, symbol, side, quantity,
|
||||
open_price AS openPrice, close_price AS closePrice, pnl, fee, opened_at AS openedAt, closed_at AS closedAt,
|
||||
close_reason AS closeReason, opened_by AS openedBy, closed_by AS closedBy
|
||||
FROM trades WHERE account_id = ? AND position_id LIKE ? ${symbol === undefined ? '' : 'AND symbol = ?'}
|
||||
`).all(accountId, `${prefix}:%`, ...(symbol === undefined ? [] : [symbol])) as FillTradeRow[];
|
||||
}
|
||||
|
||||
/** Trades rebuilt from fills: every field as worked out this pass, except a
|
||||
* null openedBy (a carried trade's) keeps the stored one. */
|
||||
export function saveFillTrades(rows: FillTradeRow[]): void {
|
||||
const up = db.prepare(`
|
||||
INSERT INTO trades (position_id, account_id, account_name, symbol, side, quantity, open_price, close_price,
|
||||
pnl, fee, opened_at, closed_at, close_reason, opened_by, closed_by, updated_at)
|
||||
VALUES (@positionId, @accountId, @accountName, @symbol, @side, @quantity, @openPrice, @closePrice,
|
||||
@pnl, @fee, @openedAt, @closedAt, @closeReason, @openedBy, @closedBy, @updatedAt)
|
||||
ON CONFLICT(position_id) DO UPDATE SET
|
||||
account_name = excluded.account_name,
|
||||
side = excluded.side,
|
||||
quantity = excluded.quantity,
|
||||
open_price = COALESCE(excluded.open_price, trades.open_price),
|
||||
close_price = excluded.close_price,
|
||||
pnl = excluded.pnl,
|
||||
fee = COALESCE(excluded.fee, trades.fee),
|
||||
opened_at = excluded.opened_at,
|
||||
closed_at = excluded.closed_at,
|
||||
close_reason = excluded.close_reason,
|
||||
opened_by = COALESCE(excluded.opened_by, trades.opened_by),
|
||||
closed_by = excluded.closed_by,
|
||||
updated_at = excluded.updated_at
|
||||
`);
|
||||
const now = Date.now();
|
||||
db.transaction(() => {
|
||||
for (const r of rows) up.run({ ...r, updatedAt: now });
|
||||
})();
|
||||
}
|
||||
|
||||
/** Trades a pass found never existed (built off a history that moved
|
||||
* under it). */
|
||||
export function deleteTrades(positionIds: string[]): void {
|
||||
const del = db.prepare('DELETE FROM trades WHERE position_id = ?');
|
||||
db.transaction(() => {
|
||||
for (const id of positionIds) del.run(id);
|
||||
})();
|
||||
}
|
||||
|
||||
/** Open trades whose close the history no longer reaches: closed, how and
|
||||
* when unknown. */
|
||||
export function closeTradesUnseen(positionIds: string[]): void {
|
||||
const up = db.prepare(`UPDATE trades SET close_reason = 'unknown', updated_at = ? WHERE position_id = ? AND closed_at IS NULL AND close_reason IS NULL`);
|
||||
const now = Date.now();
|
||||
db.transaction(() => {
|
||||
for (const id of positionIds) up.run(now, id);
|
||||
})();
|
||||
}
|
||||
|
||||
/** Newest first (by close, else open); every account when accountIds is
|
||||
* undefined. */
|
||||
export function loadTrades(o: { accountIds?: number[]; limit: number }): TradeRow[] {
|
||||
|
||||
+147
-11
@@ -36,25 +36,31 @@ import { currentTradeDateCT, FIRM_RESET_UTC_HOUR, rankForTarget } from './tradin
|
||||
import { sizingHoldHours } from './person-size';
|
||||
import {
|
||||
saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions,
|
||||
saveEndedCycleStart,
|
||||
saveEndedCycleStart, loadFillTrades, saveFillTrades, closeTradesUnseen, type FillTradeRow,
|
||||
} from './db';
|
||||
import { getAccountConfig } from './account-type-resolver';
|
||||
import { getCachedFirm } from './firm-catalog';
|
||||
import { firmInstruments } from './instruments';
|
||||
import type { SymbolMapCandidate } from './master-protocol';
|
||||
import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth, PickContext } from './platform-client';
|
||||
import { OrderNotPlacedError, isOrderNotPlaced, errorMessage } from './platform-client';
|
||||
import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth, PickContext, OrderIntent } from './platform-client';
|
||||
import { OrderNotPlacedError, isOrderNotPlaced, errorMessage, orderEffect } from './platform-client';
|
||||
import {
|
||||
dxAccountId, accountDisplayName, accountCodeOf, encodeContractName, decodeContractName, roundToStep,
|
||||
lastUtcReset, ctTradeDayStart, firmDayOf, timeOf, parseDurationMs, ciNum, isFundType, signedFundValue, newOrderCode,
|
||||
inTradingHours, realizedVolPerHour, scoreCandidate, candidateRejection, parseTransfers, marginRateOf,
|
||||
parseHistoryOrder, dxTrades,
|
||||
} from './dxtrade-helpers';
|
||||
import type { DxTxn } from './dxtrade-helpers';
|
||||
import type { DxTxn, DxHistoryOrder, DxKnownPosition } from './dxtrade-helpers';
|
||||
import { hyperliquidCoins, hyperliquidCoinFor, hyperliquidQuote, hyperliquidHourlyCloses } from './hyperliquid-feed';
|
||||
|
||||
// ── timings ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const SYNC_INTERVAL_MS = 60_000;
|
||||
/** The trades record reads an active account's orders this often — sooner
|
||||
* once its positions or the day's cash show something happened. */
|
||||
const TRADES_RECORD_MS = 5 * 60_000;
|
||||
/** How far back the trades record reads orders and cash. */
|
||||
const TRADES_WINDOW_MS = 2 * 86_400_000;
|
||||
const SYNC_WEDGED_MS = 3 * 60_000;
|
||||
const WATCHDOG_INTERVAL_MS = 60_000;
|
||||
/** Logged in longer than this with no good sync for as long → re-login. */
|
||||
@@ -257,6 +263,12 @@ export class DXTradeClient implements PlatformClient {
|
||||
private recentTxns = new Map<number, Map<string, DxTxn>>();
|
||||
/** Accounts whose balance and day's transactions came back this pass. */
|
||||
private dayFresh = new Set<number>();
|
||||
/** The trades record: when each account was last read, and what its
|
||||
* positions and day's cash looked like then. */
|
||||
private tradesRecordedAt = new Map<number, { at: number; sig: string }>();
|
||||
private tradesRecordInFlight = false;
|
||||
/** The order history refused a time-from: read the latest orders instead. */
|
||||
private historyTakesNoTimeFrom = false;
|
||||
/** When each open position was first seen, for hold-time logging. */
|
||||
private seenOpenAt = new Map<number, { positionCode: string; at: number; naggedAt: number }>();
|
||||
|
||||
@@ -685,6 +697,7 @@ export class DXTradeClient implements PlatformClient {
|
||||
await this.reapStrayOrders();
|
||||
this.checkNakedPositions();
|
||||
if (superseded()) return;
|
||||
if (fresh) void this.recordTrades();
|
||||
|
||||
console.log(`${this.tag} sync — ${list.length} account(s), ${Object.keys(this.positions).length} open position(s)`);
|
||||
this.maybeFetchDaysTraded();
|
||||
@@ -1330,6 +1343,122 @@ export class DXTradeClient implements PlatformClient {
|
||||
return {};
|
||||
}
|
||||
|
||||
// ── trades record ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Every position on the accounts, the bot's and anyone else's, in the
|
||||
* trades table the dashboard lists — off the last two days' filled orders
|
||||
* and their SETTLEMENT and COMMISSION. The bot's orders carry its af-
|
||||
* codes, so who opened and closed each needs nothing kept locally. Only
|
||||
* an account with something going on is read (an open position, the
|
||||
* day's cash, a trade still open in the table), each at most every few
|
||||
* minutes. Never fails the sync.
|
||||
*/
|
||||
private async recordTrades(): Promise<void> {
|
||||
if (this.tradesRecordInFlight || !this.token || this.intentionalDisconnect) return;
|
||||
const now = Date.now();
|
||||
const due: { acc: PlatformAccount; code: string; sig: string; stored: FillTradeRow[] }[] = [];
|
||||
for (const acc of this.accountList) {
|
||||
const code = this.codeById.get(acc.id);
|
||||
if (!code || !this.dayFresh.has(acc.id)) continue;
|
||||
const positions = (this.positionsDetail.get(acc.id) ?? []).map((p) => p.positionCode).sort().join(',');
|
||||
const trading = [...(this.recentTxns.get(acc.id)?.values() ?? [])].filter((t) => !isFundType(t.type)).length;
|
||||
const sig = positions || trading > 0 ? `${positions}|${trading}` : '';
|
||||
const stored = loadFillTrades(acc.id, 'dx');
|
||||
const last = this.tradesRecordedAt.get(acc.id);
|
||||
const storedOpen = stored.some((t) => t.closedAt === null && t.closeReason === null);
|
||||
if (sig === '' && !storedOpen && (!last || last.sig === '')) continue;
|
||||
if (last && last.sig === sig && now - last.at < TRADES_RECORD_MS) continue;
|
||||
due.push({ acc, code, sig, stored });
|
||||
}
|
||||
if (due.length === 0) return;
|
||||
|
||||
this.tradesRecordInFlight = true;
|
||||
try {
|
||||
const from = new Date(now - TRADES_WINDOW_MS).toISOString();
|
||||
const txns = (await this.perAccounts<unknown>('/accounts/transfers', due.map((d) => d.code), (d) => [d],
|
||||
{ 'time-from': from, limit: 1000 }, 'history')).flatMap((body) => parseTransfers(body));
|
||||
for (const { acc, code, sig, stored } of due) {
|
||||
if (this.intentionalDisconnect) return;
|
||||
const orders = await this.historyOrders(code, from);
|
||||
if (orders === null) continue;
|
||||
this.saveTrades(acc, orders, txns.filter((t) => t.account === code), stored);
|
||||
this.tradesRecordedAt.set(acc.id, { at: Date.now(), sig });
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`${this.tag} trades record failed: ${errorMessage(err)}`);
|
||||
} finally {
|
||||
this.tradesRecordInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** An account's filled orders since `from`; null when they couldn't be read. */
|
||||
private async historyOrders(code: string, from: string): Promise<DxHistoryOrder[] | null> {
|
||||
const path = `/accounts/${encodeURIComponent(code)}/orders/history`;
|
||||
try {
|
||||
let data: { orders?: unknown[] } | undefined;
|
||||
try {
|
||||
({ data } = await this.request<{ orders?: unknown[] }>('history', 'GET', path, {
|
||||
params: this.historyTakesNoTimeFrom ? { limit: 500 } : { 'time-from': from, limit: 500 },
|
||||
}));
|
||||
} catch (err) {
|
||||
if (!(err instanceof DxApiError) || err.status !== 400 || this.historyTakesNoTimeFrom) throw err;
|
||||
this.historyTakesNoTimeFrom = true;
|
||||
({ data } = await this.request<{ orders?: unknown[] }>('history', 'GET', path, { params: { limit: 500 } }));
|
||||
}
|
||||
const since = Date.parse(from);
|
||||
return (data?.orders ?? []).map(parseHistoryOrder).filter((o): o is DxHistoryOrder => o !== null && o.at >= since);
|
||||
} catch (err) {
|
||||
console.warn(`${this.tag} order history for ${accountDisplayName(code)} failed: ${errorMessage(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private saveTrades(acc: PlatformAccount, orders: DxHistoryOrder[], txns: DxTxn[], stored: FillTradeRow[]): void {
|
||||
const prefix = `dx:${acc.id}:`;
|
||||
const open = new Map<string, DxKnownPosition>();
|
||||
for (const p of this.positionsDetail.get(acc.id) ?? []) {
|
||||
open.set(p.positionCode, {
|
||||
symbol: p.symbol, side: p.side === 'BUY' ? 'long' : 'short', quantity: p.quantity,
|
||||
openPrice: Number.isFinite(p.openPrice) ? p.openPrice : null, openedAt: p.openTime,
|
||||
});
|
||||
}
|
||||
const known = new Map<string, DxKnownPosition>(stored.map((t) => [t.positionId.slice(prefix.length), {
|
||||
symbol: t.symbol, side: t.side, quantity: t.quantity, openPrice: t.openPrice, openedAt: t.openedAt,
|
||||
}]));
|
||||
const ours = (code: string | null) => code !== null && code.startsWith('af-');
|
||||
const rows: FillTradeRow[] = dxTrades(orders, txns, open, known)
|
||||
.filter((t) => Number.isFinite(t.openedAt))
|
||||
.map((t) => {
|
||||
const closed = t.closedAt !== null;
|
||||
const exit = closed && ours(t.closeCode) ? t.closeType : null;
|
||||
return {
|
||||
positionId: `${prefix}${t.positionCode}`,
|
||||
accountId: acc.id,
|
||||
accountName: acc.name,
|
||||
symbol: t.symbol,
|
||||
side: t.side,
|
||||
quantity: t.quantity,
|
||||
openPrice: t.openPrice,
|
||||
closePrice: t.closePrice,
|
||||
pnl: t.pnl,
|
||||
fee: t.fee,
|
||||
openedAt: t.openedAt,
|
||||
closedAt: t.closedAt,
|
||||
closeReason: exit === 'STOP' ? 'stop_loss' : exit === 'LIMIT' ? 'take_profit' : null,
|
||||
openedBy: t.openCode === null ? null : ours(t.openCode) ? 'bot' : 'outside',
|
||||
closedBy: !closed || exit === 'STOP' || exit === 'LIMIT' ? null : ours(t.closeCode) ? 'bot' : 'outside',
|
||||
};
|
||||
});
|
||||
saveFillTrades(rows);
|
||||
// Open in the table, gone from the account, and nothing in the
|
||||
// history closed it: closed out of sight.
|
||||
const seen = new Set(rows.map((r) => r.positionId));
|
||||
closeTradesUnseen(stored
|
||||
.filter((t) => t.closedAt === null && t.closeReason === null && !seen.has(t.positionId) && !open.has(t.positionId.slice(prefix.length)))
|
||||
.map((t) => t.positionId));
|
||||
}
|
||||
|
||||
// ── orders ───────────────────────────────────────────────────────────────
|
||||
|
||||
private async freshPositions(account: string): Promise<DxPosition[]> {
|
||||
@@ -1428,10 +1557,10 @@ export class DXTradeClient implements PlatformClient {
|
||||
|
||||
/**
|
||||
* Place an order and wait for its fill: an OPEN when the account is flat,
|
||||
* a CLOSE of the position when it holds the opposite side. Adding to a
|
||||
* position is refused, and so is an OPEN against another account's
|
||||
* opposite position in the same symbol (no hedging). Resolves with the
|
||||
* fill `price`.
|
||||
* a CLOSE of the position when it holds the opposite side — only the one
|
||||
* `intent` names, when given (orderEffect). Adding to a position is
|
||||
* refused, and so is an OPEN against another account's opposite position
|
||||
* in the same symbol (no hedging). Resolves with the fill `price`.
|
||||
*/
|
||||
public async sendOrder(
|
||||
accountId: number,
|
||||
@@ -1440,9 +1569,10 @@ export class DXTradeClient implements PlatformClient {
|
||||
action: 'Buy' | 'Sell',
|
||||
orderType: 'Market' | 'Limit',
|
||||
price?: number,
|
||||
intent?: OrderIntent,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
return await this.sendOrderAndWait(accountId, contractSymbol, quantity, action, orderType, price);
|
||||
return await this.sendOrderAndWait(accountId, contractSymbol, quantity, action, orderType, price, intent);
|
||||
} catch (err) {
|
||||
if (!isOrderNotPlaced(err)) this.suspectAccounts.set(accountId, Date.now());
|
||||
throw err;
|
||||
@@ -1456,6 +1586,7 @@ export class DXTradeClient implements PlatformClient {
|
||||
action: 'Buy' | 'Sell',
|
||||
orderType: 'Market' | 'Limit',
|
||||
price?: number,
|
||||
intent?: OrderIntent,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const { account, symbol, step } = this.orderContext(accountId, contractSymbol);
|
||||
const side: 'BUY' | 'SELL' = action === 'Buy' ? 'BUY' : 'SELL';
|
||||
@@ -1465,8 +1596,13 @@ export class DXTradeClient implements PlatformClient {
|
||||
} catch (err) {
|
||||
throw new OrderNotPlacedError(`DXtrade position check failed — order not sent: ${errorMessage(err)}`);
|
||||
}
|
||||
const existing = positions.find((p) => p.symbol === symbol);
|
||||
if (existing && existing.side === side) throw new OrderNotPlacedError(`adding to an open ${symbol} position is not supported`);
|
||||
const held = positions.find((p) => p.symbol === symbol);
|
||||
const sideOf = (s: 'BUY' | 'SELL'): 'long' | 'short' => (s === 'BUY' ? 'long' : 'short');
|
||||
const mine = this.positionsDetail.get(accountId)?.find((p) => p.symbol === symbol);
|
||||
// orderEffect picks OPEN or CLOSE; `existing` is the position a CLOSE closes.
|
||||
const existing = orderEffect({
|
||||
symbol, held: held ? sideOf(held.side) : null, recorded: mine ? sideOf(mine.side) : null, side: sideOf(side), intent,
|
||||
}) === 'close' ? held : undefined;
|
||||
|
||||
const code = newOrderCode(existing ? 'x' : 'e');
|
||||
let body: Record<string, unknown>;
|
||||
|
||||
@@ -328,3 +328,156 @@ export function candidateRejection(o: {
|
||||
if (o.currency && o.currency !== o.accountCurrency) return `priced in ${o.currency}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── trades ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** A filled order off /orders/history, as the trades record needs it. */
|
||||
export interface DxHistoryOrder {
|
||||
orderCode: string;
|
||||
orderId: number | undefined;
|
||||
/** MARKET, LIMIT, STOP. */
|
||||
type: string;
|
||||
instrument: string;
|
||||
positionCode: string | undefined;
|
||||
effect: 'OPEN' | 'CLOSE' | undefined;
|
||||
side: 'BUY' | 'SELL' | undefined;
|
||||
/** Filled. */
|
||||
qty: number;
|
||||
/** Average fill price. */
|
||||
price: number | undefined;
|
||||
/** The last fill, else the order's own time. */
|
||||
at: number;
|
||||
}
|
||||
|
||||
/** null for an order that never filled. */
|
||||
export function parseHistoryOrder(raw: unknown): DxHistoryOrder | null {
|
||||
const o = raw as Record<string, unknown>;
|
||||
if (!o || typeof o !== 'object' || typeof o.orderCode !== 'string') return null;
|
||||
const legs = Array.isArray(o.legs) ? (o.legs as Record<string, unknown>[]) : [];
|
||||
const leg = legs[0] ?? {};
|
||||
const qty = Math.abs(ciNum(leg, 'filledQuantity') ?? ciNum(o, 'filledQuantity') ?? 0);
|
||||
if (!(qty > 0)) return null;
|
||||
const executions = Array.isArray(o.executions) ? (o.executions as Record<string, unknown>[]) : [];
|
||||
const fillTimes = executions
|
||||
.filter((e) => (ciNum(e, 'lastQuantity') ?? ciNum(e, 'filledQuantity') ?? 0) > 0)
|
||||
.map((e) => timeOf(e.transactionTime))
|
||||
.filter(Number.isFinite);
|
||||
const at = fillTimes.length > 0 ? Math.max(...fillTimes) : [timeOf(o.transactionTime), timeOf(o.issueTime)].find(Number.isFinite) ?? NaN;
|
||||
if (!Number.isFinite(at)) return null;
|
||||
const effect = leg.positionEffect === 'OPEN' || leg.positionEffect === 'CLOSE' ? leg.positionEffect : undefined;
|
||||
const sideRaw = leg.side ?? o.side;
|
||||
return {
|
||||
orderCode: o.orderCode,
|
||||
orderId: ciNum(o, 'orderId'),
|
||||
type: String(o.type ?? ''),
|
||||
instrument: String(leg.instrument ?? o.instrument ?? ''),
|
||||
positionCode: typeof leg.positionCode === 'string' && leg.positionCode ? leg.positionCode : undefined,
|
||||
effect,
|
||||
side: sideRaw === 'BUY' || sideRaw === 'SELL' ? sideRaw : undefined,
|
||||
qty,
|
||||
price: ciNum(leg, 'averagePrice')
|
||||
?? [...executions].reverse().map((e) => ciNum(e, 'averagePrice')).find((p) => p !== undefined && p > 0),
|
||||
at,
|
||||
};
|
||||
}
|
||||
|
||||
/** One position, from the orders that opened and closed it. */
|
||||
export interface DxTrade {
|
||||
positionCode: string;
|
||||
symbol: string;
|
||||
side: 'long' | 'short';
|
||||
quantity: number;
|
||||
openPrice: number | null;
|
||||
closePrice: number | null;
|
||||
/** The SETTLEMENT its closes booked; null while open or unseen. */
|
||||
pnl: number | null;
|
||||
/** Its COMMISSION, opens and closes; null when none was seen. */
|
||||
fee: number | null;
|
||||
openedAt: number;
|
||||
closedAt: number | null;
|
||||
/** The opening order's code; null when it opened before the history. */
|
||||
openCode: string | null;
|
||||
/** The order that closed it, and its type. */
|
||||
closeCode: string | null;
|
||||
closeType: string | null;
|
||||
}
|
||||
|
||||
/** What the record knows of a position already: the open one on the
|
||||
* account now, or one stored from an earlier pass. */
|
||||
export interface DxKnownPosition {
|
||||
symbol: string;
|
||||
side: 'long' | 'short';
|
||||
quantity: number;
|
||||
openPrice: number | null;
|
||||
openedAt: number;
|
||||
}
|
||||
|
||||
const vwap = (orders: DxHistoryOrder[]): number | null => {
|
||||
const priced = orders.filter((o) => o.price !== undefined);
|
||||
const qty = priced.reduce((s, o) => s + o.qty, 0);
|
||||
return qty > 0 ? priced.reduce((s, o) => s + o.price! * o.qty, 0) / qty : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The account's positions, out of its filled orders (one account) and its
|
||||
* cash transactions. On a position-based account an opening order's position
|
||||
* code is its order id, so an OPEN leg without one takes that. A position the
|
||||
* account still holds (`open`) stays open whatever closed part of it; one
|
||||
* whose opening is older than the orders at hand is completed from `known`,
|
||||
* and left out when nothing knows it.
|
||||
*/
|
||||
export function dxTrades(orders: DxHistoryOrder[], txns: DxTxn[], open: Map<string, DxKnownPosition>, known: Map<string, DxKnownPosition>): DxTrade[] {
|
||||
const groups = new Map<string, DxHistoryOrder[]>();
|
||||
for (const o of orders) {
|
||||
const code = o.positionCode ?? (o.effect === 'OPEN' && o.orderId !== undefined ? String(o.orderId) : undefined);
|
||||
if (!code) continue;
|
||||
groups.set(code, [...(groups.get(code) ?? []), o]);
|
||||
}
|
||||
for (const code of open.keys()) if (!groups.has(code)) groups.set(code, []);
|
||||
|
||||
const settled = new Map<string, number>();
|
||||
const commission = new Map<string, number>();
|
||||
for (const t of txns) {
|
||||
if (!t.orderCode) continue;
|
||||
if (t.type === 'SETTLEMENT') settled.set(t.orderCode, (settled.get(t.orderCode) ?? 0) + t.value);
|
||||
else if (t.type === 'COMMISSION') commission.set(t.orderCode, (commission.get(t.orderCode) ?? 0) + Math.abs(t.value));
|
||||
}
|
||||
|
||||
const out: DxTrade[] = [];
|
||||
for (const [code, list] of groups) {
|
||||
const sorted = [...list].sort((a, b) => a.at - b.at);
|
||||
const now = open.get(code);
|
||||
const before = now ?? known.get(code);
|
||||
// An OPEN leg, or one on the same side as the opening.
|
||||
const firstOpen = sorted.find((o) => o.effect === 'OPEN') ?? (before ? undefined : sorted.find((o) => o.effect === undefined));
|
||||
const openSide = firstOpen?.side ?? (before ? (before.side === 'long' ? 'BUY' : 'SELL') : undefined);
|
||||
if (!openSide) continue;
|
||||
const opens = sorted.filter((o) => o.effect === 'OPEN' || (o.effect === undefined && o.side === openSide));
|
||||
const closes = sorted.filter((o) => !opens.includes(o));
|
||||
if (opens.length === 0 && !before) continue;
|
||||
|
||||
const openPrice = opens.length > 0 ? vwap(opens) : before!.openPrice;
|
||||
const closeQty = closes.reduce((s, o) => s + o.qty, 0);
|
||||
const closed = !now && closeQty > 0;
|
||||
const last = closed ? closes[closes.length - 1] : undefined;
|
||||
const codes = sorted.map((o) => o.orderCode);
|
||||
const fees = codes.filter((c) => commission.has(c));
|
||||
const pnlCodes = closes.map((o) => o.orderCode).filter((c) => settled.has(c));
|
||||
out.push({
|
||||
positionCode: code,
|
||||
symbol: opens[0]?.instrument || before?.symbol || closes[0]?.instrument || '',
|
||||
side: openSide === 'BUY' ? 'long' : 'short',
|
||||
quantity: Math.max(opens.reduce((s, o) => s + o.qty, 0), now?.quantity ?? 0, before?.quantity ?? 0),
|
||||
openPrice: openPrice ?? now?.openPrice ?? null,
|
||||
closePrice: closed ? vwap(closes) : null,
|
||||
pnl: closed && pnlCodes.length > 0 ? Math.round(pnlCodes.reduce((s, c) => s + settled.get(c)!, 0) * 100) / 100 : null,
|
||||
fee: fees.length > 0 ? Math.round(fees.reduce((s, c) => s + commission.get(c)!, 0) * 100) / 100 : null,
|
||||
openedAt: opens.length > 0 ? opens[0].at : before!.openedAt,
|
||||
closedAt: last ? last.at : null,
|
||||
openCode: opens.length > 0 ? opens[0].orderCode : null,
|
||||
closeCode: last?.orderCode ?? null,
|
||||
closeType: last?.type ?? null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Trades out of fills, for the futures platforms (TopstepX, Tradovate). A
|
||||
* trade runs from flat back to flat: adds and partial closes stay inside it,
|
||||
* and a fill that crosses zero closes one trade and opens the next. Vest and
|
||||
* DXtrade report whole positions themselves.
|
||||
*
|
||||
* No imports, so scripts/check-fill-trades.mjs can test it directly.
|
||||
*/
|
||||
|
||||
export interface Fill {
|
||||
id: string;
|
||||
orderId: string;
|
||||
at: number;
|
||||
side: 'buy' | 'sell';
|
||||
qty: number;
|
||||
price: number;
|
||||
/** What the fill cost (positive); null when unknown. */
|
||||
fee: number | null;
|
||||
/** The P&L the platform booked on this fill, before fees (TopstepX's
|
||||
* closing half-turns); null to work it out from the prices. */
|
||||
pnl: number | null;
|
||||
}
|
||||
|
||||
/** A trade already stored as open, from before the fills at hand begin —
|
||||
* continued when they start part-way through a position. */
|
||||
export interface CarriedTrade {
|
||||
id: string;
|
||||
side: 'long' | 'short';
|
||||
quantity: number;
|
||||
openPrice: number | null;
|
||||
fee: number | null;
|
||||
openedAt: number;
|
||||
}
|
||||
|
||||
export interface FillTrade {
|
||||
/** The first fill's id, or the carried trade's. */
|
||||
id: string;
|
||||
side: 'long' | 'short';
|
||||
/** The most held at once. */
|
||||
quantity: number;
|
||||
/** Average of the fills that opened or added to it. */
|
||||
openPrice: number | null;
|
||||
/** Average of the fills that reduced it; null while open. */
|
||||
closePrice: number | null;
|
||||
/** Before fees; null while open, or when it can't be told. */
|
||||
pnl: number | null;
|
||||
fee: number | null;
|
||||
openedAt: number;
|
||||
closedAt: number | null;
|
||||
/** The order whose fill opened it; null for a carried trade. */
|
||||
openOrderId: string | null;
|
||||
/** The order whose fill took it flat; null while open. */
|
||||
closeOrderId: string | null;
|
||||
carried: boolean;
|
||||
}
|
||||
|
||||
const EPS = 1e-9;
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
const signed = (f: Fill) => (f.side === 'buy' ? f.qty : -f.qty);
|
||||
|
||||
/** Oldest first; ties by id, numerically when both are numbers. */
|
||||
export function byFillTime(a: Fill, b: Fill): number {
|
||||
if (a.at !== b.at) return a.at - b.at;
|
||||
const an = Number(a.id);
|
||||
const bn = Number(b.id);
|
||||
if (Number.isFinite(an) && Number.isFinite(bn)) return an - bn;
|
||||
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
||||
}
|
||||
|
||||
/** The position before the first of `fills`, given the one after the last. */
|
||||
export function startingNet(fills: Fill[], currentNet: number): number {
|
||||
const before = currentNet - fills.reduce((s, f) => s + signed(f), 0);
|
||||
return Math.abs(before) < EPS ? 0 : before;
|
||||
}
|
||||
|
||||
interface Building {
|
||||
id: string;
|
||||
dir: 1 | -1;
|
||||
net: number;
|
||||
maxAbs: number;
|
||||
openQty: number;
|
||||
openNotional: number | null;
|
||||
closeQty: number;
|
||||
closeNotional: number;
|
||||
platformPnl: number;
|
||||
pnlFromPlatform: boolean;
|
||||
fee: number | null;
|
||||
openedAt: number;
|
||||
openOrderId: string | null;
|
||||
carried: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The trades `fills` (one account, one contract) make. `startNet` is the
|
||||
* position before the first fill (startingNet): when it isn't flat, the
|
||||
* fills start inside a position — `carry` continues the stored trade when it
|
||||
* is on the same side, and without one those fills are passed over until the
|
||||
* account is flat. `pointValue` (dollars per point per contract) works out
|
||||
* the P&L of a fill the platform gave none for.
|
||||
*/
|
||||
export function fillsToTrades(fills: Fill[], o: { startNet: number; pointValue: number | null; carry?: CarriedTrade | null }): FillTrade[] {
|
||||
const out: FillTrade[] = [];
|
||||
let net = Math.abs(o.startNet) < EPS ? 0 : o.startNet;
|
||||
let cur: Building | null = null;
|
||||
if (net !== 0 && o.carry && (o.carry.side === 'long') === net > 0) {
|
||||
const qty = Math.abs(net);
|
||||
cur = {
|
||||
id: o.carry.id,
|
||||
dir: net > 0 ? 1 : -1,
|
||||
net,
|
||||
maxAbs: Math.max(qty, o.carry.quantity),
|
||||
openQty: qty,
|
||||
openNotional: o.carry.openPrice === null ? null : o.carry.openPrice * qty,
|
||||
closeQty: 0,
|
||||
closeNotional: 0,
|
||||
platformPnl: 0,
|
||||
pnlFromPlatform: true,
|
||||
fee: o.carry.fee,
|
||||
openedAt: o.carry.openedAt,
|
||||
openOrderId: null,
|
||||
carried: true,
|
||||
};
|
||||
}
|
||||
|
||||
const open = (f: Fill, qty: number, feeShare: number | null): Building => ({
|
||||
id: f.id,
|
||||
dir: f.side === 'buy' ? 1 : -1,
|
||||
net: f.side === 'buy' ? qty : -qty,
|
||||
maxAbs: qty,
|
||||
openQty: qty,
|
||||
openNotional: f.price * qty,
|
||||
closeQty: 0,
|
||||
closeNotional: 0,
|
||||
platformPnl: 0,
|
||||
pnlFromPlatform: true,
|
||||
fee: feeShare,
|
||||
openedAt: f.at,
|
||||
openOrderId: f.orderId,
|
||||
carried: false,
|
||||
});
|
||||
const addFee = (b: Building, fee: number | null) => {
|
||||
b.fee = b.fee === null || fee === null ? null : b.fee + fee;
|
||||
};
|
||||
|
||||
for (const f of [...fills].sort(byFillTime)) {
|
||||
if (!(f.qty > 0)) continue;
|
||||
const s = signed(f);
|
||||
if (cur === null && net !== 0) {
|
||||
// Inside a position whose start isn't on record: wait for flat.
|
||||
if (Math.sign(s) === Math.sign(net)) {
|
||||
net += s;
|
||||
continue;
|
||||
}
|
||||
const closing = Math.min(Math.abs(s), Math.abs(net));
|
||||
net += Math.sign(s) * closing;
|
||||
if (Math.abs(net) < EPS) net = 0;
|
||||
const rest = f.qty - closing;
|
||||
if (net === 0 && rest > EPS) {
|
||||
cur = open(f, rest, f.fee === null ? null : f.fee * (rest / f.qty));
|
||||
net = cur.net;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (cur === null) {
|
||||
cur = open(f, f.qty, f.fee);
|
||||
net = cur.net;
|
||||
continue;
|
||||
}
|
||||
if (Math.sign(s) === cur.dir) {
|
||||
cur.net += s;
|
||||
net = cur.net;
|
||||
cur.maxAbs = Math.max(cur.maxAbs, Math.abs(cur.net));
|
||||
cur.openQty += f.qty;
|
||||
cur.openNotional = cur.openNotional === null ? null : cur.openNotional + f.price * f.qty;
|
||||
addFee(cur, f.fee);
|
||||
continue;
|
||||
}
|
||||
const closing = Math.min(f.qty, Math.abs(cur.net));
|
||||
const rest = f.qty - closing;
|
||||
addFee(cur, f.fee === null ? null : f.fee * (closing / f.qty));
|
||||
cur.closeQty += closing;
|
||||
cur.closeNotional += f.price * closing;
|
||||
if (f.pnl === null) cur.pnlFromPlatform = false;
|
||||
else cur.platformPnl += f.pnl;
|
||||
cur.net += Math.sign(s) * closing;
|
||||
if (Math.abs(cur.net) < EPS) cur.net = 0;
|
||||
net = cur.net;
|
||||
if (cur.net !== 0) continue;
|
||||
|
||||
out.push(finish(cur, f, o.pointValue));
|
||||
cur = null;
|
||||
if (rest > EPS) {
|
||||
cur = open(f, rest, f.fee === null ? null : f.fee * (rest / f.qty));
|
||||
net = cur.net;
|
||||
}
|
||||
}
|
||||
if (cur !== null) {
|
||||
out.push({
|
||||
id: cur.id,
|
||||
side: cur.dir === 1 ? 'long' : 'short',
|
||||
quantity: cur.maxAbs,
|
||||
openPrice: cur.openNotional === null ? null : cur.openNotional / cur.openQty,
|
||||
closePrice: null,
|
||||
pnl: null,
|
||||
fee: cur.fee === null ? null : round2(cur.fee),
|
||||
openedAt: cur.openedAt,
|
||||
closedAt: null,
|
||||
openOrderId: cur.openOrderId,
|
||||
closeOrderId: null,
|
||||
carried: cur.carried,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function finish(b: Building, last: Fill, pointValue: number | null): FillTrade {
|
||||
// Flat to flat, so as much was bought as sold: the P&L is the difference
|
||||
// in what each side came to.
|
||||
const computed = b.openNotional === null || pointValue === null
|
||||
? null
|
||||
: b.dir * (b.closeNotional - b.openNotional) * pointValue;
|
||||
const pnl = b.pnlFromPlatform ? b.platformPnl : computed;
|
||||
return {
|
||||
id: b.id,
|
||||
side: b.dir === 1 ? 'long' : 'short',
|
||||
quantity: b.maxAbs,
|
||||
openPrice: b.openNotional === null ? null : b.openNotional / b.openQty,
|
||||
closePrice: b.closeQty > 0 ? b.closeNotional / b.closeQty : null,
|
||||
pnl: pnl === null ? null : round2(pnl),
|
||||
fee: b.fee === null ? null : round2(b.fee),
|
||||
openedAt: b.openedAt,
|
||||
closedAt: last.at,
|
||||
openOrderId: b.openOrderId,
|
||||
closeOrderId: last.orderId,
|
||||
carried: b.carried,
|
||||
};
|
||||
}
|
||||
|
||||
/** "MNQZ6" → "MNQ"; a name with no month code comes back as it is. */
|
||||
export function contractRoot(name: string): string {
|
||||
const m = /^([A-Z0-9]+?)[FGHJKMNQUVXZ]\d{1,2}$/.exec(name.trim().toUpperCase());
|
||||
return m ? m[1] : name.trim().toUpperCase();
|
||||
}
|
||||
+18
-4
@@ -49,8 +49,11 @@ const COLD_RETRY_MAX_MS = 60_000;
|
||||
* first sync on a newer parser fetches the whole catalog.
|
||||
* 2: kept rule sets (keptAccountRules) and the firm's maxConcurrentAccounts.
|
||||
* Stamped only on a payload from a master that sends keptAccountRules: one
|
||||
* from an older master can't vouch for kept sets it never sent. */
|
||||
export const CATALOG_PARSER_VERSION = 2;
|
||||
* from an older master can't vouch for kept sets it never sent.
|
||||
* 3: Vest firms, which a build from before the platform skipped. A platform
|
||||
* learned later needs no bump: the cache names the platforms it skipped
|
||||
* (skippedPlatforms), and a build that knows one fetches in full. */
|
||||
export const CATALOG_PARSER_VERSION = 3;
|
||||
|
||||
/** A Date header further off than this is somebody else's clock, not the master's. */
|
||||
const MAX_MASTER_OFFSET_MS = 365 * 24 * 60 * 60 * 1000;
|
||||
@@ -103,6 +106,10 @@ function cache(): CatalogCache {
|
||||
const parser = (stored as { parser?: unknown }).parser;
|
||||
if (typeof parser === 'number') payload.parser = parser;
|
||||
else delete payload.parser;
|
||||
// Likewise the platforms it skipped: their firms aren't in the blob.
|
||||
const skipped = (stored as { skippedPlatforms?: unknown }).skippedPlatforms;
|
||||
if (Array.isArray(skipped)) payload.skippedPlatforms = skipped.filter((p): p is string => typeof p === 'string');
|
||||
else delete payload.skippedPlatforms;
|
||||
} catch {
|
||||
console.error('[catalog] cached payload is unreadable — ignoring it');
|
||||
}
|
||||
@@ -283,6 +290,8 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
|
||||
if (!Array.isArray(body.firms)) throw new Error('missing firms');
|
||||
|
||||
const firms: CatalogFirm[] = [];
|
||||
// Stamped on the payload, so a build that learns one refetches its firms.
|
||||
const skippedPlatforms = new Set<string>();
|
||||
body.firms.forEach((f: unknown, i: number) => {
|
||||
if (typeof f !== 'object' || f === null) throw new Error(`firm ${i} is not an object`);
|
||||
const firm = f as Record<string, unknown>;
|
||||
@@ -297,6 +306,7 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
|
||||
const platform = firm.platform === undefined ? 'tradovate' : firm.platform;
|
||||
if (!isFirmPlatform(platform)) {
|
||||
warnUnknownPlatform(firm.id, firm.name, platform);
|
||||
skippedPlatforms.add(String(platform));
|
||||
return;
|
||||
}
|
||||
// Percent commission and every-day trading are settings of the
|
||||
@@ -349,6 +359,7 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
|
||||
return {
|
||||
version: body.version, updatedAt: body.updatedAt, firms, ...(trading ? { trading } : {}),
|
||||
...(keptAware ? { parser: CATALOG_PARSER_VERSION } : {}),
|
||||
...(skippedPlatforms.size > 0 ? { skippedPlatforms: [...skippedPlatforms] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -424,10 +435,13 @@ export async function syncCatalog(): Promise<{ ok: boolean; changed: boolean; ve
|
||||
// `since` only against the master the cache came from: two masters'
|
||||
// version numbers can collide, and an 'unchanged' from the wrong one
|
||||
// would keep the other's catalog. Nor for a cache an older parser wrote:
|
||||
// it lacks what that parser dropped, and 'unchanged' would keep it so.
|
||||
// it lacks what that parser dropped, and 'unchanged' would keep it so —
|
||||
// fields, or firms on a platform it didn't know and this build does.
|
||||
const hasCache = getCachedCatalog().length > 0;
|
||||
const source = url.origin;
|
||||
const cacheCurrent = (cache().payload?.parser ?? 0) >= CATALOG_PARSER_VERSION;
|
||||
const cached = cache().payload;
|
||||
const cacheCurrent = (cached?.parser ?? 0) >= CATALOG_PARSER_VERSION
|
||||
&& !(cached?.skippedPlatforms ?? []).some((p) => isFirmPlatform(p));
|
||||
if (hasCache && cacheCurrent && getSetting('catalog_source_url') === source) url.searchParams.set('since', String(version));
|
||||
|
||||
try {
|
||||
|
||||
+6
-2
@@ -11,7 +11,7 @@ import type { PlatformContract } from './platform-client';
|
||||
import type { SymbolStats } from './master-protocol';
|
||||
import {
|
||||
combineCmeSessions, computeDayPlan, resolveEffectiveConfig, obfuscationStopDollars, dllRoom as computeDllRoom,
|
||||
planLegSize, rankForTarget, maxLossRoom, lastStand, dllStopDollars, stageInputsFor, floorRoom,
|
||||
planLegSize, rankForTarget, maxLossRoom, lastStand, dllStopDollars, stageInputsFor, floorRoom, shortestNextLeg,
|
||||
FALLBACK_COMMISSION_PER_CONTRACT,
|
||||
} from './trading-logic';
|
||||
|
||||
@@ -27,6 +27,8 @@ export function dayLossRoom(
|
||||
dll: number,
|
||||
cash: { amount: number; realizedPnL: number },
|
||||
combinedRealized: number,
|
||||
/** The shortest take-profit the next leg may have (shortestNextLeg). */
|
||||
nextLeg: number,
|
||||
): number | null {
|
||||
if (!(dll > 0)) return null;
|
||||
const floor: number | null | undefined = client.dailyLossFloor?.(accountId);
|
||||
@@ -35,6 +37,7 @@ export function dayLossRoom(
|
||||
return floorRoom({
|
||||
dailyLossLimit: dll, closedBalance: cash.amount, floor,
|
||||
realizedToday: Math.min(cash.realizedPnL, combinedRealized),
|
||||
gain: client.dayGainForRoom?.(accountId, { leg: nextLeg, limit: dll }) ?? 0,
|
||||
});
|
||||
}
|
||||
const dayStart = client.dayStartBalance?.(accountId);
|
||||
@@ -97,6 +100,7 @@ export function rankNextLeg(o: {
|
||||
maxLossRoom: floorRoom,
|
||||
obfuscationDollars: o.obfuscationDollars,
|
||||
availableFunds: funds,
|
||||
slippageFraction: o.client.stopSlippageFraction,
|
||||
});
|
||||
const p = plan(true);
|
||||
if (!p.sizing || p.cost === null) return [];
|
||||
@@ -143,7 +147,7 @@ function accountPlan(
|
||||
);
|
||||
if (!plan) return null;
|
||||
const dll = effective.dailyLossLimit;
|
||||
const room = dayLossRoom(client, acc.id, dll, cash, combined.realizedPnL);
|
||||
const room = dayLossRoom(client, acc.id, dll, cash, combined.realizedPnL, shortestNextLeg(plan));
|
||||
const obfuscation = obfuscationStopDollars(cfg, combined.dailyPnL, plan, combined.tradeDate, plan.remainingToday);
|
||||
return { cfg, cash, plan, dll, room, obfuscation };
|
||||
}
|
||||
|
||||
+19
-7
@@ -36,11 +36,19 @@ export interface AccountTypeStats {
|
||||
* cycle ready to withdraw, 'drain' = a drain stage's days booked;
|
||||
* cycle = the date of the fund transaction that started the cycle ('' for
|
||||
* the account's first cycle) — firm + name + cycle identify one
|
||||
* completed cycle. The list also carries caught-up entries: for up to 7
|
||||
* completed cycle, but where one day started several (Vest: claim,
|
||||
* trade again, claim again — a fund row holds the day's payouts):
|
||||
* cycleSeq = which of that day's cycles, 1 for the second and so on,
|
||||
* sent only when above 0 (trading-logic completionPlace). A master that
|
||||
* keys on it tells them apart; one that doesn't folds them into the
|
||||
* day's first. The list also carries caught-up entries: for up to 7
|
||||
* days after a fund transaction starts a new cycle, it includes the cycle
|
||||
* that just ended if that cycle had met the rule. Caught-up entries are
|
||||
* not counted in `completed`. Optional: older instances omit it. */
|
||||
completedAccounts?: { name: string; date: string; stage: number; kind: 'pass' | 'payout' | 'drain'; cycle: string }[];
|
||||
* that just ended if that cycle had met the rule — and, for 7 days after
|
||||
* it, each cycle the instance's own payout claim ended complete, dated
|
||||
* the claim's day and listed last, newest first (a claim can end one
|
||||
* before any report sees it). Caught-up and claimed entries are not
|
||||
* counted in `completed`. Optional: older instances omit it. */
|
||||
completedAccounts?: { name: string; date: string; stage: number; kind: 'pass' | 'payout' | 'drain'; cycle: string; cycleSeq?: number }[];
|
||||
/** Live accounts by trading days completed toward the current target:
|
||||
* {daysTraded: accountCount}. Feeds the master's payout calendar, so
|
||||
* accounts that are dead, DLL-locked, or in the red are left out — the
|
||||
@@ -109,9 +117,10 @@ export interface FirmStats {
|
||||
* instances omit it.
|
||||
*
|
||||
* withdrawals = how many times the balance has gone down for a payout
|
||||
* (negative fund transactions), which tells the master a completed
|
||||
* cycle's money is out. Omitted while the account's fund history hasn't
|
||||
* loaded, so a restart can't read as zero withdrawals.
|
||||
* (negative fund transactions; on Vest, each claim, though a day's share
|
||||
* one fund row), which tells the master a completed cycle's money is
|
||||
* out. Omitted while the account's fund history hasn't loaded, so a
|
||||
* restart can't read as zero withdrawals.
|
||||
*
|
||||
* The rest come from statusVersion builds (platform-client.ts
|
||||
* accountStatus), each left out when null or at its default. status:
|
||||
@@ -508,6 +517,9 @@ export interface FirmCatalogPayload {
|
||||
* payload. An older parser dropped fields it didn't know, so a cache
|
||||
* without it, or below the current one, isn't trusted to hold them. */
|
||||
parser?: number;
|
||||
/** Instance-local, like parser: the platforms of the firms that parser
|
||||
* skipped as unknown to it. A build that knows one fetches in full. */
|
||||
skippedPlatforms?: string[];
|
||||
}
|
||||
|
||||
export interface FirmCatalogUnchanged {
|
||||
|
||||
+6
-6
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* The hold this machine sizes DXtrade legs for. Every person's machine would
|
||||
* otherwise size a like account in a like state to the same position as
|
||||
* everyone else's; each person's own draw (personHoldHours) puts their sizes
|
||||
* within ±PERSON_SIZE_SPREAD of the plan instead, the same for all of their
|
||||
* accounts and new each trade date.
|
||||
* The hold this machine sizes DXtrade and Vest legs for. Every person's
|
||||
* machine would otherwise size a like account in a like state to the same
|
||||
* position as everyone else's; each person's own draw (personHoldHours) puts
|
||||
* their sizes within ±PERSON_SIZE_SPREAD of the plan instead, the same for
|
||||
* all of their accounts and new each trade date.
|
||||
*/
|
||||
|
||||
import { getSetting } from './db';
|
||||
@@ -15,7 +15,7 @@ function personKey(): string {
|
||||
return getSetting('instance_id') || getSetting('instance_name') || '';
|
||||
}
|
||||
|
||||
/** The hold a DXtrade firm's legs are sized for on this machine today. */
|
||||
/** The hold a DXtrade or Vest firm's legs are sized for on this machine today. */
|
||||
export function sizingHoldHours(
|
||||
firm: { targetHoldHours?: number } | null | undefined,
|
||||
tradeDate: string = currentTradeDateCT(),
|
||||
|
||||
+110
-2
@@ -48,8 +48,16 @@ export interface PlatformContract {
|
||||
/** Margin posted per dollar of notional (0.2 = 5:1 leverage), where the
|
||||
* broker says — caps a position at available funds ÷ this. */
|
||||
marginRate?: number;
|
||||
/** When the instrument's market next closes for long (epoch ms; Vest: a
|
||||
* weekend or holiday, not the maintenance hour), where the platform
|
||||
* says: no leg starts that wouldn't be done by then (clearsClose), each
|
||||
* account's at its own size (fireLeg). */
|
||||
closesAt?: number;
|
||||
}
|
||||
|
||||
/** What an order is for, where the caller knows (see sendOrder). */
|
||||
export type OrderIntent = 'open' | 'close';
|
||||
|
||||
export interface PlatformClient {
|
||||
accountList: PlatformAccount[];
|
||||
/** realizedPnL is today's. pendingRoll (Tradovate): the broker still shows
|
||||
@@ -74,6 +82,11 @@ export interface PlatformClient {
|
||||
fundTransactions: { [accountId: number]: { date: string; amount: number }[] };
|
||||
priorProfit: { [accountId: number]: number };
|
||||
autoLiqThresholds: { [accountId: number]: number };
|
||||
/** Share of a position's notional a stop may fill past its price — the
|
||||
* part of the daily-loss reserve that grows with size (dllReserve).
|
||||
* Only where this platform's stops are measured to fill closer than
|
||||
* DLL_SLIPPAGE_FRACTION, the default. */
|
||||
stopSlippageFraction?: number;
|
||||
|
||||
fetchDaysComplete: boolean;
|
||||
syncComplete: boolean;
|
||||
@@ -125,20 +138,51 @@ export interface PlatformClient {
|
||||
* The equity the account may not fall below today, where the platform
|
||||
* states its daily-loss floor itself and moves it with the day's
|
||||
* transfers (Vest: reset balance × (1 − rate), lowered by a payout). The
|
||||
* engine's room is then min(the stage's limit, balance − floor), so a
|
||||
* engine's room is then what's left of the stage's limit (widened by
|
||||
* dayGainForRoom), never more than balance − floor (floorRoom), so a
|
||||
* same-day payout that lowers the floor frees room to trade again. null =
|
||||
* the platform has one but it isn't known yet (hold); undefined = none
|
||||
* here (the engine falls back to dayStartBalance / realized P&L).
|
||||
*/
|
||||
dailyLossFloor?(accountId: number): number | null | undefined;
|
||||
/**
|
||||
* With dailyLossFloor: how much of the day's gain a leg starting now, its
|
||||
* take-profit `leg` away and its room `limit` before any gain, may add to
|
||||
* its daily-loss room (floorRoom's `gain`) — the gain still in the account
|
||||
* since the day's base, no more than keeps the leg all but sure to close
|
||||
* before the platform's daily reset (resetClearStop).
|
||||
*/
|
||||
dayGainForRoom?(accountId: number, o: { leg: number; limit: number }): number;
|
||||
/** The trade date at `now` (epoch ms) that this platform's daily rows and
|
||||
* realized P&L follow, where its day turns at another hour than the
|
||||
* 5 PM CT roll (Vest: 20:00 ET). The engine plans the accounts and
|
||||
* counts their legs by it (tradeDateFor); absent = currentTradeDateCT. */
|
||||
tradeDate?(now: number): string;
|
||||
/** Payouts made since the firm's last daily reset — what picks the stage
|
||||
* of an account type whose stages reset each day. */
|
||||
sameDayClaims?(accountId: number): number;
|
||||
/** Each payout taken from the account, by the day it counts on — one
|
||||
* entry per payout, where a fund row can hold several (Vest books a
|
||||
* whole day's claims as one); undefined until the platform's own record
|
||||
* of them has been read. Without it, each negative fund row is one, on
|
||||
* its date. */
|
||||
payoutDays?(accountId: number): string[] | undefined;
|
||||
/** Request a payout of `amount` from the account to the login's own main
|
||||
* account, where the platform takes one by API (Vest's Claim Profit).
|
||||
* Resolves once the platform accepted it and the books were updated;
|
||||
* rejects with the platform's reason otherwise. */
|
||||
claimProfit?(accountId: number, amount: number): Promise<{ claimed: number; received: number | null }>;
|
||||
/** A claim this client made that no good sync has read past yet: the
|
||||
* balance on hand may not show it, so no claim may be judged on it. */
|
||||
claimPending?(accountId: number): boolean;
|
||||
/** Passed evaluations whose funded account the platform activates by
|
||||
* API (Vest's "Go to funded account"), by account id — less any this
|
||||
* client already asked for that no sync has read back yet. */
|
||||
activatable?(): number[];
|
||||
/** Activate the funded account of a passed evaluation. Resolves once the
|
||||
* platform opened it, with its name when the answer gives one (the next
|
||||
* sync lists it); rejects with the platform's reason otherwise. */
|
||||
activateFunded?(accountId: number): Promise<{ funded: string | null }>;
|
||||
/** Drop the account's history from memory (the wipe routes), so it reads
|
||||
* unloaded until the next fetch rebuilds it. */
|
||||
forgetHistory(accountId: number, accountName: string): void;
|
||||
@@ -148,13 +192,20 @@ export interface PlatformClient {
|
||||
findFrontMonthContract(productName: string, pick?: PickContext): Promise<PlatformContract | null>;
|
||||
fetchInstrumentFees(symbols: string[]): Promise<{ [symbol: string]: number }>;
|
||||
|
||||
/** `intent`, where the caller knows what the order is for: an entry
|
||||
* ('open') must never close a position it didn't know was there, a
|
||||
* flatten ('close') must never open one on an account that turned out
|
||||
* flat. A platform that checks it (orderEffect) refuses the order
|
||||
* otherwise (NothingToCloseError for a close with nothing to close);
|
||||
* left out, or on a platform that doesn't check, the position decides. */
|
||||
sendOrder(
|
||||
accountId: number,
|
||||
contractSymbol: string,
|
||||
quantity: number,
|
||||
action: 'Buy' | 'Sell',
|
||||
orderType: 'Market' | 'Limit',
|
||||
price?: number
|
||||
price?: number,
|
||||
intent?: OrderIntent,
|
||||
): Promise<Record<string, unknown>>;
|
||||
|
||||
placeOrderNoWait(
|
||||
@@ -435,6 +486,63 @@ export function isOrderNotPlaced(err: unknown): boolean {
|
||||
return !!err && typeof err === 'object' && (err as { orderNotPlaced?: unknown }).orderNotPlaced === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A close-only order (sendOrder's 'close') refused because a fresh read
|
||||
* showed no position in the symbol to close, and the client's own record
|
||||
* agrees: the account is flat there, so a flatten that gets this has nothing
|
||||
* left to do — nothing rides without its stop.
|
||||
*/
|
||||
export class NothingToCloseError extends OrderNotPlacedError {
|
||||
readonly nothingToClose = true;
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'NothingToCloseError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Duck-typed, like isOrderNotPlaced. */
|
||||
export function isNothingToClose(err: unknown): boolean {
|
||||
return !!err && typeof err === 'object' && (err as { nothingToClose?: unknown }).nothingToClose === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* What an order on `side` does on a position-based platform (Vest, DXtrade),
|
||||
* given a fresh read of the account's position in the symbol (`held`: its
|
||||
* side, null when flat) and the client's own last record of it (`recorded`):
|
||||
* open, or close (or reduce) — or it throws why nothing is sent. With no
|
||||
* intent a flat account opens and the opposite side closes. An entry ('open')
|
||||
* never closes: a hand trade the engine didn't know about would be closed in
|
||||
* its place, and the leg's flatten would then open a position with no exits.
|
||||
* A flatten ('close') never opens, and a flat read is NothingToCloseError only
|
||||
* when the record agrees — one empty read just after a fill the client saw
|
||||
* may be the gateway lagging, and a leg riding without its stop must still be
|
||||
* shouted about. Adding to a position is never supported.
|
||||
*/
|
||||
export function orderEffect(o: {
|
||||
symbol: string;
|
||||
held: 'long' | 'short' | null;
|
||||
recorded: 'long' | 'short' | null;
|
||||
side: 'long' | 'short';
|
||||
intent?: OrderIntent;
|
||||
}): 'open' | 'close' {
|
||||
const { symbol, held, recorded, side, intent } = o;
|
||||
if (held === side) {
|
||||
throw new OrderNotPlacedError(intent === 'close'
|
||||
? `the account holds a ${held} ${symbol} position, not one to close — nothing sent`
|
||||
: `adding to an open ${symbol} position is not supported`);
|
||||
}
|
||||
if (held && intent === 'open') {
|
||||
throw new OrderNotPlacedError(`a ${held} ${symbol} position is already open — an entry never closes it, order not sent`);
|
||||
}
|
||||
if (!held && intent === 'close') {
|
||||
if (recorded && recorded !== side) {
|
||||
throw new OrderNotPlacedError(`a fresh read shows no ${symbol} position, but this client last saw a ${recorded} one open — nothing sent`);
|
||||
}
|
||||
throw new NothingToCloseError(`no ${symbol} position to close — nothing sent`);
|
||||
}
|
||||
return held ? 'close' : 'open';
|
||||
}
|
||||
|
||||
/** An error's message without the rest of it. AxiosErrors carry the request
|
||||
* config — Tradovate's login body (the password) and Bearer tokens — so they
|
||||
* must never be logged whole. */
|
||||
|
||||
+91
-14
@@ -11,7 +11,7 @@
|
||||
* POST keeps those working unchanged.
|
||||
*/
|
||||
|
||||
import { getSetting, loadEndedCycleStart } from './db';
|
||||
import { getSetting, loadClaimedCycles, loadEndedCycleStart } from './db';
|
||||
import { pushReport } from './master-link';
|
||||
import { getBuildVersion } from './version';
|
||||
import { tradeMixFor } from './trade-mix';
|
||||
@@ -20,7 +20,10 @@ import { getClients } from './clients';
|
||||
import { getAccountTypeById, getKeptRuleIdsForFirm, getTradableFirms } from './firm-catalog';
|
||||
import { getAccountConfig } from './account-type-resolver';
|
||||
import { getSchedulerStatus } from './auto-trade';
|
||||
import { botStopsActive, combineCmeSessions, computeDayPlan, currentTradeDateCT, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig, stageInputsFor } from './trading-logic';
|
||||
import {
|
||||
botStopsActive, combineCmeSessions, computeDayPlan, completionPlace, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON,
|
||||
projectPerfectPath, resolveEffectiveConfig, stageInputsFor, tradeDateFor, withinEndedCycleWindow,
|
||||
} from './trading-logic';
|
||||
import type { AccountTypeStats, FirmStats, InstrumentStats, SymbolStats } from './master-protocol';
|
||||
import { accountNextLeg, accountLegShape } from './leg-estimate';
|
||||
import { sizingHoldHours } from './person-size';
|
||||
@@ -33,9 +36,6 @@ function collectFirmStats() {
|
||||
// the master's table with rows for firms it does not run.
|
||||
const firms = getTradableFirms();
|
||||
const clients = getClients();
|
||||
// One raw trade date for the whole report: the combined views below roll
|
||||
// it forward, the completion check needs it as is.
|
||||
const rawToday = currentTradeDateCT();
|
||||
// projectPerfectPath's `loops` test: a type whose completed cycle keeps
|
||||
// going pays out (a funded account), one that stops just passes.
|
||||
const loops = (cfg: { payoutable?: boolean; withdrawalStages: unknown[] }): boolean =>
|
||||
@@ -49,6 +49,11 @@ function collectFirmStats() {
|
||||
const keptRuleIds = getKeptRuleIdsForFirm(f.id);
|
||||
// accounts: [] only once synced (see emptyFirmStats).
|
||||
if (!client || client.accountList.length === 0) return emptyFirmStats(f, client, keptRuleIds);
|
||||
// One raw trade date per firm, the one its accounts trade on
|
||||
// (tradeDateFor — Vest's turns at 20:00 ET, not 5 PM CT): the
|
||||
// combined views below roll it forward, the completion check needs
|
||||
// it as is.
|
||||
const rawToday = tradeDateFor(client);
|
||||
|
||||
let totalAccounts = 0;
|
||||
let accountsTraded = 0;
|
||||
@@ -90,6 +95,11 @@ function collectFirmStats() {
|
||||
}
|
||||
return entry;
|
||||
};
|
||||
// The completed cycles this instance's own claims ended, by slice
|
||||
// (listed after the accounts — see below).
|
||||
const claimedBySlice = new Map<AccountTypeStats, NonNullable<AccountTypeStats['completedAccounts']>>();
|
||||
/** When each claimed entry's claim was made, to list the newest first. */
|
||||
const claimedAt = new Map<object, number>();
|
||||
|
||||
for (const acc of client.accountList) {
|
||||
const reportedCash = client.accountCashBalances[acc.id];
|
||||
@@ -104,11 +114,22 @@ function collectFirmStats() {
|
||||
// standing, while a wrong zero would clear one early.
|
||||
const loadedFundTxns = client.fundTransactions?.[acc.id];
|
||||
const fundTxns = loadedFundTxns ?? [];
|
||||
// One per payout, on its day: a client whose fund rows hold a
|
||||
// whole day's (Vest: two claims the same day, one row) lists them
|
||||
// itself, and says nothing until it has read them — falling back
|
||||
// to the rows there would report fewer and then more again.
|
||||
const rowPayoutDays = (rows: { date: string; amount: number }[]) => rows.filter((f) => f.amount < 0).map((f) => f.date);
|
||||
const payoutDays = client.payoutDays
|
||||
? client.payoutDays(acc.id)
|
||||
: loadedFundTxns && rowPayoutDays(loadedFundTxns);
|
||||
const payoutDaysTaken = payoutDays ?? rowPayoutDays(fundTxns);
|
||||
const withdrawals = payoutDays?.length;
|
||||
const withdrawalsTaken = payoutDaysTaken.length;
|
||||
accounts.push({
|
||||
name: acc.name,
|
||||
size: cfg?.accountSize ?? 0,
|
||||
payoutable: cfg ? loops(cfg) : null,
|
||||
...(loadedFundTxns ? { withdrawals: loadedFundTxns.filter((f) => f.amount < 0).length } : {}),
|
||||
...(withdrawals !== undefined ? { withdrawals } : {}),
|
||||
// For the master's account ledger. Data only: nothing here
|
||||
// trades on it, and the totals below keep their own test.
|
||||
...statusFieldsOrUnknown(() => accountStatusFields({
|
||||
@@ -208,6 +229,8 @@ function collectFirmStats() {
|
||||
fundTransactions: fundTxns,
|
||||
balance: cash.amount,
|
||||
realizedPnL: cash.realizedPnL,
|
||||
rawTradeDate: rawToday,
|
||||
stageInputs: stageInputsFor(client, acc.id, cfg),
|
||||
payout: {
|
||||
withdrawalPct: cfg.withdrawalPct,
|
||||
payoutCap: cfg.payoutCap,
|
||||
@@ -247,7 +270,7 @@ function collectFirmStats() {
|
||||
maxDay: qualifying.length > 0 ? Math.max(...qualifying.map((d) => d.pnl)) : 0,
|
||||
sum,
|
||||
high: highWater,
|
||||
withdrawals: fundTxns.filter((f) => f.amount < 0).length,
|
||||
withdrawals: withdrawalsTaken,
|
||||
target: effective.profitTarget,
|
||||
consistency: effective.consistency,
|
||||
minDays: effective.minTradingDays,
|
||||
@@ -271,6 +294,7 @@ function collectFirmStats() {
|
||||
const ended = endedCycleCompletion({
|
||||
accountId: acc.id,
|
||||
rawTradeDate: rawToday,
|
||||
client,
|
||||
fullDailyPnL: client.fullDailyPnL[acc.id] ?? [],
|
||||
cycleDailyPnL: client.dailyPnL[acc.id],
|
||||
fundTransactions: fundTxns,
|
||||
@@ -281,16 +305,47 @@ function collectFirmStats() {
|
||||
cfg,
|
||||
});
|
||||
if (ended) {
|
||||
// Counted one per payout, as the final day counts this
|
||||
// cycle, so the two name it alike.
|
||||
const place = completionPlace({
|
||||
payoutDays: payoutDaysTaken, fundTransactions: fundTxns, cycle: ended.cycle,
|
||||
end: client.lastFundDates?.[acc.id] ?? undefined,
|
||||
});
|
||||
(slice.completedAccounts ??= []).push({
|
||||
name: acc.name,
|
||||
date: ended.date,
|
||||
stage: ended.stage,
|
||||
stage: place.stage,
|
||||
kind: completionKind(cfg, ended.drain),
|
||||
cycle: ended.cycle,
|
||||
...(place.cycleSeq > 0 ? { cycleSeq: place.cycleSeq } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// The completed cycles this instance's own claims ended
|
||||
// (afterClaim), named for the catch-up's week: a claim can end
|
||||
// one before any report saw it complete, where the catch-up
|
||||
// can't find it again (a Vest day's second claim, or one right
|
||||
// after the reset — trading-logic claimedCycle). Named only, as
|
||||
// the catch-up's: no counter moves.
|
||||
if (cfg) {
|
||||
for (const c of loadClaimedCycles(acc.id).reverse()) {
|
||||
if (!withinEndedCycleWindow(c.date, rawToday)) continue;
|
||||
let list = claimedBySlice.get(slice);
|
||||
if (!list) claimedBySlice.set(slice, list = []);
|
||||
const entry = {
|
||||
name: acc.name,
|
||||
date: c.date,
|
||||
stage: c.stage,
|
||||
kind: completionKind(cfg, c.drain),
|
||||
cycle: c.cycle,
|
||||
...(c.cycleSeq > 0 ? { cycleSeq: c.cycleSeq } : {}),
|
||||
};
|
||||
claimedAt.set(entry, c.at ?? 0);
|
||||
list.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
if (client.positions[acc.id]) {
|
||||
inTrade++;
|
||||
accountsTraded++;
|
||||
@@ -307,6 +362,7 @@ function collectFirmStats() {
|
||||
const completion = cycleCompletion({
|
||||
accountId: acc.id,
|
||||
rawTradeDate: rawToday,
|
||||
client,
|
||||
cycleDailyPnL: client.dailyPnL[acc.id],
|
||||
rawRealizedPnL: cash.realizedPnL,
|
||||
combineCmeHolidays: cfg.combineCmeHolidays,
|
||||
@@ -326,14 +382,18 @@ function collectFirmStats() {
|
||||
dayEntry.targetHit++;
|
||||
dayEntry.completed = (dayEntry.completed ?? 0) + 1;
|
||||
}
|
||||
// The fund transaction that started this cycle, and its
|
||||
// place among that day's payouts — the catch-up names the
|
||||
// same cycle by the same key.
|
||||
const cycle = client.lastFundDates?.[acc.id] ?? '';
|
||||
const place = completionPlace({ payoutDays: payoutDaysTaken, fundTransactions: fundTxns, cycle });
|
||||
(slice.completedAccounts ??= []).push({
|
||||
name: acc.name,
|
||||
date: combined.tradeDate,
|
||||
stage: 1 + fundTxns.filter((f) => f.amount < 0).length,
|
||||
stage: place.stage,
|
||||
kind: completionKind(cfg, effective.drain),
|
||||
// The fund transaction that started this cycle — the
|
||||
// catch-up names the same cycle by the same key.
|
||||
cycle: client.lastFundDates?.[acc.id] ?? '',
|
||||
cycle,
|
||||
...(place.cycleSeq > 0 ? { cycleSeq: place.cycleSeq } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -373,11 +433,11 @@ function collectFirmStats() {
|
||||
// A grind-day fill (dayTarget 0) can flag targetHit on a
|
||||
// negative day — a loss is still a loss, so red wins.
|
||||
// A red day on an account the bot puts stops on (the
|
||||
// obfuscation stops, or a hard daily loss limit) is its own
|
||||
// obfuscation stops, or its stage's hard daily loss limit) is its own
|
||||
// personal DLL, booked on purpose — not a genuine loss. (A
|
||||
// firm DLL lockout took the !acc.active branch above and
|
||||
// stays a loss.)
|
||||
if (botStopsActive(cfg) && combined.realizedPnL <= -25) {
|
||||
if (botStopsActive(cfg, effective) && combined.realizedPnL <= -25) {
|
||||
personalDllCount++;
|
||||
slice.personalDll = (slice.personalDll ?? 0) + 1;
|
||||
} else if (combined.realizedPnL < 0) {
|
||||
@@ -395,6 +455,23 @@ function collectFirmStats() {
|
||||
}
|
||||
}
|
||||
|
||||
// Claimed cycles go last in each slice's list, newest first: the
|
||||
// master reads only so many entries a slice (twice its accounts), so
|
||||
// what it has long had drops off before a final day, a catch-up or a
|
||||
// fresh claim. One those already name goes once.
|
||||
for (const [slice, claimed] of claimedBySlice) {
|
||||
const list = slice.completedAccounts ?? [];
|
||||
const key = (e: (typeof list)[number]) => `${e.name}\n${e.cycle}\n${e.cycleSeq ?? 0}`;
|
||||
const named = new Set(list.map(key));
|
||||
// Newest first across accounts: a day's ties go by the claim's time.
|
||||
for (const e of claimed.sort((a, b) => b.date.localeCompare(a.date) || (claimedAt.get(b) ?? 0) - (claimedAt.get(a) ?? 0))) {
|
||||
if (named.has(key(e))) continue;
|
||||
named.add(key(e));
|
||||
list.push(e);
|
||||
}
|
||||
if (list.length > 0) slice.completedAccounts = list;
|
||||
}
|
||||
|
||||
// Biggest shortfall first — the account types with the most left to do
|
||||
// are the ones worth reading, and Unassigned sinks to the bottom only
|
||||
// when it is genuinely small.
|
||||
|
||||
@@ -61,3 +61,14 @@ export function topstepCommission(root: string | null, size: number): number {
|
||||
const perSide = (root !== null ? TOPSTEP_COMMISSION_PER_SIDE[root] : undefined) ?? TOPSTEP_COMMISSION_FALLBACK;
|
||||
return Math.round(perSide * Math.abs(size || 0) * 100) / 100;
|
||||
}
|
||||
|
||||
/** How a contract reads on the dashboard: "CON.F.US.ENQ.Z26" → "NQZ6" (the
|
||||
* root where it's known, else the product code); anything else as it is. */
|
||||
export function topstepxSymbol(contractId: unknown, learned?: ReadonlyMap<string, string>): string {
|
||||
const parts = String(contractId ?? '').split('.');
|
||||
const code = topstepxProductCode(contractId);
|
||||
if (!code) return String(contractId ?? '');
|
||||
const expiry = /^([FGHJKMNQUVXZ])(\d{1,2})$/.exec(parts[4] ?? '');
|
||||
const root = topstepxRoot(contractId, learned) ?? code;
|
||||
return expiry ? `${root}${expiry[1]}${expiry[2].slice(-1)}` : root;
|
||||
}
|
||||
|
||||
+60
-2
@@ -27,9 +27,11 @@ import * as signalR from '@microsoft/signalr';
|
||||
import { currentTradeDateCT, FUND_EVENT_MIN, fundReconcileStep, type FundSighting } from './trading-logic';
|
||||
import { getCachedContract } from './contract-resolver';
|
||||
import { signalrWebSocket } from './outbound-proxy';
|
||||
import { saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions, saveEndedCycleStart, dropFundTransaction } from './db';
|
||||
import { saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions, saveEndedCycleStart, dropFundTransaction, recordOwnOrder } from './db';
|
||||
import { getAccountConfig } from './account-type-resolver';
|
||||
import { topstepxProductCode, topstepxRoot, topstepCommission, TOPSTEP_COMMISSION_FALLBACK } from './topstep-fees';
|
||||
import { topstepxProductCode, topstepxRoot, topstepxSymbol, topstepCommission, TOPSTEP_COMMISSION_FALLBACK } from './topstep-fees';
|
||||
import { recordFillTrades } from './trade-recorder';
|
||||
import type { Fill } from './fill-trades';
|
||||
import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth } from './platform-client';
|
||||
import { OrderNotPlacedError, isOrderNotPlaced, errorMessage } from './platform-client';
|
||||
|
||||
@@ -844,6 +846,44 @@ export class TopstepXClient implements PlatformClient {
|
||||
return (t.fees ?? 0) + topstepCommission(root, t.size);
|
||||
}
|
||||
|
||||
/** The trades the last two days' fills make, in the trades table the
|
||||
* dashboard lists (lib/trade-recorder.ts). Never fails the sync. */
|
||||
private recordTrades(acc: PlatformAccount, trades: TsxTrade[], open: { contractId: string; type: number; size: number }[]): void {
|
||||
try {
|
||||
const learned = this.learnedProductRoots();
|
||||
const byContract = new Map<string, Fill[]>();
|
||||
for (const p of open) byContract.set(p.contractId, []);
|
||||
for (const t of trades) {
|
||||
if (t.voided || !(t.size > 0)) continue;
|
||||
const at = Date.parse(t.creationTimestamp);
|
||||
if (!Number.isFinite(at)) continue;
|
||||
const fills = byContract.get(t.contractId) ?? [];
|
||||
fills.push({
|
||||
id: String(t.id),
|
||||
orderId: String(t.orderId),
|
||||
at,
|
||||
side: t.side === SIDE_BUY ? 'buy' : 'sell',
|
||||
qty: t.size,
|
||||
price: t.price,
|
||||
fee: this.tradeFees(t, learned),
|
||||
pnl: t.profitAndLoss,
|
||||
});
|
||||
byContract.set(t.contractId, fills);
|
||||
}
|
||||
recordFillTrades('tsx', acc, [...byContract].map(([contractId, fills]) => {
|
||||
const p = open.find((x) => x.contractId === contractId);
|
||||
return {
|
||||
symbol: topstepxSymbol(contractId, learned),
|
||||
pointValue: null,
|
||||
fills,
|
||||
currentNet: !p ? 0 : p.type === 1 ? p.size : -p.size,
|
||||
};
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error(`[topstepx] trade record failed for ${acc.name}: ${errorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Product code → root, off the contracts resolved this run
|
||||
* ("CON.F.US.GCE.Z26" resolved for GC → GCE is GC). */
|
||||
private learnedProductRoots(): Map<string, string> {
|
||||
@@ -900,12 +940,15 @@ export class TopstepXClient implements PlatformClient {
|
||||
for (const acc of this.accountList) {
|
||||
const balance = (accRes.accounts ?? []).find((a) => a.id === acc.id)?.balance ?? 0;
|
||||
let accountFresh = true;
|
||||
/** This pass's open positions, for the trades record. */
|
||||
let openNow: { contractId: string; type: number; size: number }[] | null = null;
|
||||
|
||||
try {
|
||||
const posRes = await this.post<{ positions: { accountId: number; contractId: string; type: number; size: number; averagePrice: number; creationTimestamp: string }[] }>(
|
||||
'/api/Position/searchOpen', { accountId: acc.id });
|
||||
if (superseded()) return;
|
||||
const open = (posRes.positions ?? []).filter((p) => p.size > 0);
|
||||
openNow = open;
|
||||
if (open.length > 0) {
|
||||
this.applyPosition(open[0]);
|
||||
} else {
|
||||
@@ -941,6 +984,7 @@ export class TopstepXClient implements PlatformClient {
|
||||
map.set(t.id, t);
|
||||
}
|
||||
this.todayTrades[acc.id] = map;
|
||||
if (openNow !== null) this.recordTrades(acc, tradeRes.trades ?? [], openNow);
|
||||
} catch (err) {
|
||||
accountFresh = false;
|
||||
console.error(`[topstepx] trade sync failed for ${acc.name}: ${errorMessage(err)}`);
|
||||
@@ -1602,9 +1646,11 @@ export class TopstepXClient implements PlatformClient {
|
||||
trailPrice: null,
|
||||
customTag: null,
|
||||
});
|
||||
this.noteOwnOrder(accountId, res.orderId, type);
|
||||
return res.orderId;
|
||||
} catch (err) {
|
||||
if (err instanceof GatewayRefusedError) {
|
||||
if (err.orderId !== undefined) this.noteOwnOrder(accountId, err.orderId, type);
|
||||
const ambiguous = err.orderId !== undefined
|
||||
|| (err.errorCode !== undefined && AMBIGUOUS_PLACE_ERRORS.has(err.errorCode));
|
||||
if (!ambiguous) throw new OrderNotPlacedError(`TopstepX refused the order: ${err.message}`);
|
||||
@@ -1614,6 +1660,7 @@ export class TopstepXClient implements PlatformClient {
|
||||
const status = response?.status;
|
||||
// Same rule as a refusal: an answer that names an order means it exists.
|
||||
const namesOrder = typeof response?.data?.orderId === 'number' && response.data.orderId > 0;
|
||||
if (namesOrder) this.noteOwnOrder(accountId, response?.data?.orderId, type);
|
||||
if (typeof status === 'number' && REFUSED_BEFORE_HANDLER.has(status) && !namesOrder) {
|
||||
throw new OrderNotPlacedError(`TopstepX rejected the order request: ${errorMessage(err)}`);
|
||||
}
|
||||
@@ -1621,6 +1668,17 @@ export class TopstepXClient implements PlatformClient {
|
||||
}
|
||||
}
|
||||
|
||||
/** Notes an order as the bot's, so the trade its fill opens or closes is
|
||||
* credited to the bot (lib/trade-recorder.ts). */
|
||||
private noteOwnOrder(accountId: number, orderId: unknown, type: number): void {
|
||||
if (typeof orderId !== 'number' || !(orderId > 0)) return;
|
||||
try {
|
||||
recordOwnOrder(accountId, orderId, type === TYPE_STOP ? 'stop' : type === TYPE_LIMIT ? 'limit' : 'market');
|
||||
} catch (err) {
|
||||
console.error(`[topstepx] could not note order ${orderId} as the bot's: ${errorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** See PlatformClient: an account whose last order's outcome is unknown
|
||||
* counts as holding a working order until a sync's Order/searchOpen shows
|
||||
* none. */
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { byFillTime, fillsToTrades, startingNet, type Fill, type CarriedTrade } from './fill-trades';
|
||||
import {
|
||||
loadOwnOrders, ownOrdersSince, loadFillTrades, saveFillTrades, deleteTrades, closeTradesUnseen,
|
||||
type FillTradeRow,
|
||||
} from './db';
|
||||
|
||||
/**
|
||||
* Records the trades a futures platform's fills make (lib/fill-trades.ts) in
|
||||
* the trades table the dashboard lists, with who opened and closed each: the
|
||||
* bot when the fill's order is one it placed (own_orders), and how — its
|
||||
* stop, its take-profit, or a market close.
|
||||
*
|
||||
* Each pass hands over a window of fills (TopstepX: two days; Tradovate: the
|
||||
* session's) with the account's position now, and the window's trades are
|
||||
* rebuilt whole. A history and a position read a moment apart can disagree —
|
||||
* a fill landing between the two calls — and that pass would rebuild the
|
||||
* wrong trades, so a contract is written only when the position before its
|
||||
* window comes out the same two passes running.
|
||||
*/
|
||||
|
||||
export interface ContractFills {
|
||||
/** As shown: "NQZ6". */
|
||||
symbol: string;
|
||||
/** Dollars per point per contract, for P&L the platform didn't give. */
|
||||
pointValue: number | null;
|
||||
fills: Fill[];
|
||||
/** Signed contracts held now. */
|
||||
currentNet: number;
|
||||
}
|
||||
|
||||
/** The position before each window, last pass: prefix:account:symbol → net. */
|
||||
const lastStart = new Map<string, number>();
|
||||
|
||||
export function recordFillTrades(prefix: string, account: { id: number; name: string }, contracts: ContractFills[]): void {
|
||||
let own: Map<string, string> | null = null;
|
||||
for (const c of contracts) {
|
||||
const fills = [...c.fills].sort(byFillTime);
|
||||
const startNet = startingNet(fills, c.currentNet);
|
||||
const key = `${prefix}:${account.id}:${c.symbol}`;
|
||||
const steady = lastStart.get(key) === startNet;
|
||||
lastStart.set(key, startNet);
|
||||
if (!steady) continue;
|
||||
|
||||
const stored = loadFillTrades(account.id, prefix, c.symbol);
|
||||
const isOpen = (t: FillTradeRow) => t.closedAt === null && t.closeReason === null;
|
||||
const windowStart = fills.length > 0 ? fills[0].at : Infinity;
|
||||
|
||||
if (fills.length === 0) {
|
||||
// Nothing in the window: an open trade stays open while the
|
||||
// position does, and is closed out of sight once it's gone.
|
||||
if (startNet === 0) closeTradesUnseen(stored.filter(isOpen).map((t) => t.positionId));
|
||||
continue;
|
||||
}
|
||||
|
||||
const carryRow = stored
|
||||
.filter((t) => isOpen(t) && t.openedAt < windowStart)
|
||||
.sort((a, b) => b.openedAt - a.openedAt)[0];
|
||||
const carry: CarriedTrade | null = carryRow
|
||||
? { id: carryRow.positionId, side: carryRow.side, quantity: carryRow.quantity, openPrice: carryRow.openPrice, fee: carryRow.fee, openedAt: carryRow.openedAt }
|
||||
: null;
|
||||
const trades = fillsToTrades(fills, { startNet, pointValue: c.pointValue, carry });
|
||||
|
||||
own ??= loadOwnOrders(account.id);
|
||||
const since = ownOrdersSince();
|
||||
const rows: FillTradeRow[] = trades.map((t) => {
|
||||
const closeKind = t.closeOrderId === null ? undefined : own!.get(t.closeOrderId);
|
||||
const closed = t.closedAt !== null;
|
||||
return {
|
||||
positionId: t.carried ? t.id : `${prefix}:${t.id}`,
|
||||
accountId: account.id,
|
||||
accountName: account.name,
|
||||
symbol: c.symbol,
|
||||
side: t.side,
|
||||
quantity: t.quantity,
|
||||
openPrice: t.openPrice,
|
||||
closePrice: t.closePrice,
|
||||
pnl: t.pnl,
|
||||
fee: t.fee,
|
||||
openedAt: t.openedAt,
|
||||
closedAt: t.closedAt,
|
||||
closeReason: !closed ? null : closeKind === 'stop' ? 'stop_loss' : closeKind === 'limit' ? 'take_profit' : null,
|
||||
openedBy: t.carried ? null
|
||||
: t.openOrderId !== null && own!.has(t.openOrderId) ? 'bot'
|
||||
: t.openedAt >= since ? 'outside' : 'unknown',
|
||||
// A trade opened before own orders were noted had its exits
|
||||
// placed then too: whose close it was is unknown, not outside.
|
||||
closedBy: !closed || closeKind === 'stop' || closeKind === 'limit' ? null
|
||||
: closeKind === 'market' ? 'bot'
|
||||
: t.openedAt >= since ? 'outside' : 'unknown',
|
||||
};
|
||||
});
|
||||
saveFillTrades(rows);
|
||||
|
||||
const ids = new Set(rows.map((r) => r.positionId));
|
||||
// Built last time off a window that has since moved under it.
|
||||
deleteTrades(stored.filter((t) => t.openedAt >= windowStart && !ids.has(t.positionId)).map((t) => t.positionId));
|
||||
// Open from before the window, and the window doesn't carry it on:
|
||||
// it closed somewhere the history no longer reaches.
|
||||
closeTradesUnseen(stored.filter((t) => isOpen(t) && t.openedAt < windowStart && !ids.has(t.positionId)).map((t) => t.positionId));
|
||||
}
|
||||
}
|
||||
+332
-80
@@ -37,6 +37,19 @@ export interface StageInputs {
|
||||
dailyLossLimit?: number;
|
||||
stagesSameDay?: boolean;
|
||||
sameDayWithdrawals?: number;
|
||||
/** Every payout the account has made, one per payout, where a fund row
|
||||
* can hold several (PlatformClient.payoutDays — Vest books a day's
|
||||
* claims as one row). Absent = one per negative fund row. */
|
||||
payouts?: number;
|
||||
}
|
||||
|
||||
/** The payouts that pick an account's stage: today's for a type whose
|
||||
* stages reset each day, else every one — the client's own count when it
|
||||
* gives one (StageInputs.payouts), else the negative fund rows. */
|
||||
export function stagePayouts(fundTransactions: { date: string; amount: number }[], inputs: StageInputs = {}): number {
|
||||
if (inputs.stagesSameDay) return Math.max(0, Math.floor(inputs.sameDayWithdrawals ?? 0));
|
||||
if (typeof inputs.payouts === 'number' && Number.isFinite(inputs.payouts)) return Math.max(0, Math.floor(inputs.payouts));
|
||||
return fundTransactions.filter((f) => f.amount < 0).length;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,9 +86,7 @@ export function resolveEffectiveConfig(
|
||||
// Same-day stages (Vest): only today's payouts count, so every new
|
||||
// day starts back at stage 1 — the room a payout frees lasts only
|
||||
// until the firm's next daily reset.
|
||||
const withdrawalCount = inputs.stagesSameDay
|
||||
? Math.max(0, Math.floor(inputs.sameDayWithdrawals ?? 0))
|
||||
: fundTransactions.filter((f) => f.amount < 0).length;
|
||||
const withdrawalCount = stagePayouts(fundTransactions, inputs);
|
||||
if (withdrawalCount === 0) {
|
||||
return { profitTarget, consistency, minTradingDays, minDayPnL, drain: false, dailyLossLimit }; // Stage 1 = base values
|
||||
}
|
||||
@@ -165,6 +176,18 @@ export function currentTradeDateCT(now: Date = new Date()): string {
|
||||
return new Date(date).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* The trade date a client's accounts are planned on and count their legs by:
|
||||
* the platform's own day where its daily rows and realized P&L follow one
|
||||
* that turns at another hour (Vest: 20:00 ET, two hours after the 5 PM CT
|
||||
* roll), else the Central trade date. Between the two rolls the Central date
|
||||
* would count the platform's still-open day both as a prior day and as
|
||||
* today's realized P&L, and start the day's leg count over mid-day.
|
||||
*/
|
||||
export function tradeDateFor(client: { tradeDate?: (now: number) => string } | null | undefined, now: Date = new Date()): string {
|
||||
return typeof client?.tradeDate === 'function' ? client.tradeDate(now.getTime()) : currentTradeDateCT(now);
|
||||
}
|
||||
|
||||
/** Drain-day target grid: $4–$15 in $0.50 steps (23 values, MNQ's
|
||||
* per-contract tick), always traded on MNQ. Drain days qualify at ≥
|
||||
* DRAIN_DAY_MIN, so every draw counts. Seeded per (account, trade date) like
|
||||
@@ -442,6 +465,11 @@ export function projectPerfectPath(opts: {
|
||||
fundTransactions: { date: string; amount: number }[];
|
||||
balance: number;
|
||||
realizedPnL: number;
|
||||
/** What picks the stage beyond the fund rows (stageInputsFor). A type
|
||||
* whose stages reset each day trades today on today's payouts and
|
||||
* every projected day from the base stage. Absent = every withdrawal
|
||||
* counts. */
|
||||
stageInputs?: StageInputs;
|
||||
/** Trading days to look ahead. */
|
||||
horizonDays?: number;
|
||||
maxWithdrawals?: number;
|
||||
@@ -463,6 +491,9 @@ export function projectPerfectPath(opts: {
|
||||
};
|
||||
/** Defaults to the current time. */
|
||||
now?: Date;
|
||||
/** The raw trade date the inputs follow (tradeDateFor — Vest's turns at
|
||||
* 20:00 ET); defaults to the Central one at `now`. */
|
||||
rawTradeDate?: string;
|
||||
}): ProjectedDay[] {
|
||||
const { accountId, cfg } = opts;
|
||||
const horizon = opts.horizonDays ?? 40;
|
||||
@@ -473,7 +504,7 @@ export function projectPerfectPath(opts: {
|
||||
const dd = Math.max(0, opts.payout?.drawdown ?? 0);
|
||||
const everyDay = cfg.tradesEveryDay === true;
|
||||
|
||||
const rawToday = currentTradeDateCT(opts.now);
|
||||
const rawToday = opts.rawTradeDate ?? currentTradeDateCT(opts.now);
|
||||
// The firm's own day view: a combining firm folds holiday-session P&L
|
||||
// into its clearing date — a Labor-Day trade and the Tuesday after it are
|
||||
// ONE trading day, exactly as the firm counts min days. Identity view
|
||||
@@ -528,17 +559,38 @@ export function projectPerfectPath(opts: {
|
||||
return room / (room + dayTarget);
|
||||
};
|
||||
|
||||
const effectiveNow = () => resolveEffectiveConfig(
|
||||
// A type whose stages reset each day (Vest) counts only the payouts made
|
||||
// since its day began: today's real ones plus the path's own on that
|
||||
// date, so every projected day starts on the base stage.
|
||||
const sameDay = opts.stageInputs?.stagesSameDay === true;
|
||||
/** The path's own payouts, by the date they are claimed on. */
|
||||
const paidOn = new Map<string, number>();
|
||||
/** The account's real payouts, one per payout (stagePayouts). */
|
||||
const paidReal = stagePayouts(opts.fundTransactions, { payouts: opts.stageInputs?.payouts });
|
||||
/** Payouts before the stage traded on `day`: that day's own for a
|
||||
* same-day type, else every withdrawal so far. */
|
||||
const paidBefore = (day: string): number => (sameDay
|
||||
? (day === today ? Math.max(0, Math.floor(opts.stageInputs?.sameDayWithdrawals ?? 0)) : 0) + (paidOn.get(day) ?? 0)
|
||||
: paidReal + withdrawals);
|
||||
/** A same-day type trades its next stage on the date a payout lands
|
||||
* (runClaims claims, then trades again on the room the claim freed) —
|
||||
* once through its stages a day: the last one repeats, and the path
|
||||
* stops there. */
|
||||
const tradesAgain = (day: string): boolean => sameDay
|
||||
&& (paidOn.get(day) ?? 0) > 0
|
||||
&& paidBefore(day) <= cfg.withdrawalStages.length;
|
||||
const effectiveOn = (day: string) => resolveEffectiveConfig(
|
||||
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity,
|
||||
cfg.withdrawalStages, 0, funds, cfg.minDayPnL,
|
||||
sameDay ? { stagesSameDay: true, sameDayWithdrawals: paidBefore(day) } : { payouts: paidBefore(day) },
|
||||
);
|
||||
/** The payout parameters of the stage currently being traded: per-stage
|
||||
/** The payout parameters of the stage being completed: per-stage
|
||||
* overrides when set, else the type defaults. */
|
||||
const stagePayout = (): { pct: number; cap: number } => {
|
||||
const count = funds.filter((f) => f.amount < 0).length;
|
||||
const stagePayout = (stage: number): { pct: number; cap: number } => {
|
||||
const count = stage - 1;
|
||||
if (count === 0 || cfg.withdrawalStages.length === 0) return { pct: basePct, cap: baseCap };
|
||||
const stage = cfg.withdrawalStages[Math.min(count - 1, cfg.withdrawalStages.length - 1)];
|
||||
return { pct: stage.withdrawalPct ?? basePct, cap: stage.payoutCap ?? baseCap };
|
||||
const s = cfg.withdrawalStages[Math.min(count - 1, cfg.withdrawalStages.length - 1)];
|
||||
return { pct: s.withdrawalPct ?? basePct, cap: s.payoutCap ?? baseCap };
|
||||
};
|
||||
const qualifyingDays = (minDay: number, excludeDate: string) => (
|
||||
minDay > 0
|
||||
@@ -550,7 +602,7 @@ export function projectPerfectPath(opts: {
|
||||
// Skipped when "today" is a weekend date (Saturday, or Friday evening
|
||||
// after the roll): there is no session to finish.
|
||||
if (todayHasSession) {
|
||||
const eff = effectiveNow();
|
||||
const eff = effectiveOn(today);
|
||||
const plan = computeDayPlan(
|
||||
accountId, today, eff.profitTarget, eff.consistency, sim, eff.minDayPnL,
|
||||
eff.minTradingDays, balance - cfg.accountSize, opts.realizedPnL, cfg.splitEnabled, eff.drain,
|
||||
@@ -579,7 +631,7 @@ export function projectPerfectPath(opts: {
|
||||
// over and over. Only a non-payoutable eval stops after one completion.
|
||||
const loops = cfg.withdrawalStages.length > 0 || cfg.payoutable === true;
|
||||
const finishCycle = (onDate: string, dayPnl: number, stage: number, drain: boolean): boolean => {
|
||||
const { pct, cap } = stagePayout();
|
||||
const { pct, cap } = stagePayout(stage);
|
||||
const rawGross = round2(Math.max(0, balance - cfg.accountSize) * pct);
|
||||
const gross = cap > 0 ? Math.min(rawGross, round2(cap)) : rawGross;
|
||||
const net = round2(gross * profitSplit);
|
||||
@@ -588,6 +640,9 @@ export function projectPerfectPath(opts: {
|
||||
balance = round2(balance - gross);
|
||||
sim = []; // withdrawal excludes its day — fresh cycle
|
||||
withdrawals++;
|
||||
// A cycle that stood complete before today is claimed today.
|
||||
const claimDay = onDate < today ? today : onDate;
|
||||
paidOn.set(claimDay, (paidOn.get(claimDay) ?? 0) + 1);
|
||||
high = Math.max(high, dd); // a payout locks the trail at breakeven
|
||||
out.push({ date: onDate, pnl: dayPnl, stage, drain, withdrawal: -gross, prob, gross, net });
|
||||
return false;
|
||||
@@ -601,14 +656,14 @@ export function projectPerfectPath(opts: {
|
||||
// requestable after that session's close. Never "today" — a projection
|
||||
// computed over the weekend must not slide a Friday finish to Sunday.
|
||||
{
|
||||
const eff = effectiveNow();
|
||||
const eff = effectiveOn(today);
|
||||
const probe = nextDay(today);
|
||||
const plan = computeDayPlan(
|
||||
accountId, probe, eff.profitTarget, eff.consistency, sim, eff.minDayPnL,
|
||||
eff.minTradingDays, balance - cfg.accountSize, 0, cfg.splitEnabled, eff.drain,
|
||||
);
|
||||
if (plan && plan.dayTarget <= 0 && eff.minTradingDays <= qualifyingDays(eff.minDayPnL, probe)) {
|
||||
const stage = 1 + funds.filter((f) => f.amount < 0).length;
|
||||
const stage = 1 + paidBefore(today);
|
||||
const traded = sim.filter((d) => d.pnl !== 0);
|
||||
const doneOn = traded.length > 0
|
||||
? traded.reduce((a, b) => (a.date > b.date ? a : b)).date
|
||||
@@ -617,11 +672,15 @@ export function projectPerfectPath(opts: {
|
||||
}
|
||||
}
|
||||
|
||||
// A same-day type's next stage trades on the date its payout landed,
|
||||
// today's included; anything else moves on a day.
|
||||
let date = today;
|
||||
for (let step = 0; step < horizon; step++) {
|
||||
date = nextDay(date);
|
||||
const eff = effectiveNow();
|
||||
const stage = 1 + funds.filter((f) => f.amount < 0).length;
|
||||
let again = todayHasSession && tradesAgain(today);
|
||||
for (let step = 0; again || step < horizon;) {
|
||||
if (again) again = false;
|
||||
else { date = nextDay(date); step++; }
|
||||
const eff = effectiveOn(date);
|
||||
const stage = 1 + paidBefore(date);
|
||||
const plan = computeDayPlan(
|
||||
accountId, date, eff.profitTarget, eff.consistency, sim, eff.minDayPnL,
|
||||
eff.minTradingDays, balance - cfg.accountSize, 0, cfg.splitEnabled, eff.drain,
|
||||
@@ -634,6 +693,7 @@ export function projectPerfectPath(opts: {
|
||||
// The cycle completed mid-projection with nothing left to trade —
|
||||
// the pending payout lands on this day.
|
||||
if (finishCycle(date, 0, stage, eff.drain)) break;
|
||||
again = tradesAgain(date);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -647,7 +707,7 @@ export function projectPerfectPath(opts: {
|
||||
|
||||
// Did this day complete the cycle? Then it IS the payout day.
|
||||
const nd = nextDay(date);
|
||||
const eff2 = effectiveNow();
|
||||
const eff2 = effectiveOn(date);
|
||||
const plan2 = computeDayPlan(
|
||||
accountId, nd, eff2.profitTarget, eff2.consistency, sim, eff2.minDayPnL,
|
||||
eff2.minTradingDays, balance - cfg.accountSize, 0, cfg.splitEnabled, eff2.drain,
|
||||
@@ -657,6 +717,7 @@ export function projectPerfectPath(opts: {
|
||||
&& eff2.minTradingDays <= qualifyingDays(eff2.minDayPnL, nd);
|
||||
if (doneAfter) {
|
||||
if (finishCycle(date, pnl, stage, eff.drain)) break;
|
||||
again = tradesAgain(date);
|
||||
} else {
|
||||
out.push({ date, pnl, stage, drain: eff.drain, prob });
|
||||
}
|
||||
@@ -672,14 +733,22 @@ export type CycleCompletion = 'none' | 'today' | 'before';
|
||||
|
||||
/** How long a new trade date waits before it can read 'today': the platforms
|
||||
* reset realizedPnL on their own clock (a Tradovate cashBalance event, the
|
||||
* TopstepX 60s sync), not at 5 PM sharp, and until then the last session's
|
||||
* number — already booked under its own date — would count as a second day. */
|
||||
* TopstepX 60s sync, Vest's next sync after 20:00 ET), not at the roll
|
||||
* sharp, and until then the last session's number — already booked under
|
||||
* its own date — would count as a second day. */
|
||||
const ROLL_SETTLE_MS = 5 * 60 * 1000;
|
||||
|
||||
/** How long after a fund transaction starts a new cycle the cycle it ended is
|
||||
* still checked for a missed completion. */
|
||||
const ENDED_CYCLE_WINDOW_DAYS = 7;
|
||||
|
||||
/** Is `date` (a fund date, or a claimed cycle's — claimedCycle) within the
|
||||
* week its ended cycle is still named? False for an unparseable date. */
|
||||
export function withinEndedCycleWindow(date: string, rawTradeDate: string): boolean {
|
||||
const ageDays = (Date.parse(`${rawTradeDate}T00:00:00Z`) - Date.parse(`${date}T00:00:00Z`)) / (24 * 60 * 60 * 1000);
|
||||
return ageDays <= ENDED_CYCLE_WINDOW_DAYS; // NaN: false
|
||||
}
|
||||
|
||||
/** Nothing left to trade AND the days booked: with no min-day amount the
|
||||
* plan coasts at 0 while days are still owed, so count them too. */
|
||||
function cycleOwesNothing(
|
||||
@@ -704,13 +773,14 @@ function cycleOwesNothing(
|
||||
* weekend (it is the last session's stale number, already booked under its
|
||||
* own date), a Monday with a weekend-dated row — the live session filed under
|
||||
* a weekend date, which would count it twice — and the first minutes after
|
||||
* the 5 PM roll. An every-day firm's weekends are real sessions, so only the
|
||||
* roll window applies to it. */
|
||||
* the roll: the client's own (tradeDateFor), else 5 PM CT. An every-day
|
||||
* firm's weekends are real sessions, so only the roll window applies to it. */
|
||||
function sessionDoubtful(
|
||||
rawTradeDate: string,
|
||||
cycleDailyPnL: { date: string; pnl: number }[],
|
||||
now: Date,
|
||||
everyDay = false,
|
||||
client?: { tradeDate?: (now: number) => string },
|
||||
): boolean {
|
||||
const day = new Date(`${rawTradeDate}T00:00:00Z`);
|
||||
const dow = day.getUTCDay();
|
||||
@@ -721,7 +791,7 @@ function sessionDoubtful(
|
||||
if (cycleDailyPnL.some((d) => d.date === saturday || d.date === sunday)) return true;
|
||||
}
|
||||
// Settle window: a few minutes ago it was still an earlier trade date.
|
||||
return currentTradeDateCT(new Date(now.getTime() - ROLL_SETTLE_MS)) < rawTradeDate;
|
||||
return tradeDateFor(client, new Date(now.getTime() - ROLL_SETTLE_MS)) < rawTradeDate;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -734,14 +804,18 @@ function sessionDoubtful(
|
||||
* 'none' instead: a weekend (realizedPnL is the last session's stale number,
|
||||
* already booked under its own date), a Monday with a weekend-dated row — the
|
||||
* live session filed under a weekend date, which would count it twice — and
|
||||
* the first minutes after the 5 PM roll. 'before' skips those guards: it only
|
||||
* the first minutes after the roll. 'before' skips those guards: it only
|
||||
* keeps a locked, untraded pass out of the reporter's losses.
|
||||
*/
|
||||
export function cycleCompletion(opts: {
|
||||
accountId: number;
|
||||
/** currentTradeDateCT() — NOT the combined date, which rolls a weekend
|
||||
* forward to Monday. */
|
||||
/** tradeDateFor(client) — NOT the combined date, which rolls a weekend
|
||||
* forward to Monday. On a platform whose day turns later than 5 PM CT
|
||||
* (Vest), the Central date would count its open day twice. */
|
||||
rawTradeDate: string;
|
||||
/** The account's client: the settle window follows its day's roll
|
||||
* (tradeDateFor). */
|
||||
client?: { tradeDate?: (now: number) => string };
|
||||
/** Current-cycle daily P&L as the client holds it (client.dailyPnL). */
|
||||
cycleDailyPnL: { date: string; pnl: number }[];
|
||||
rawRealizedPnL: number;
|
||||
@@ -764,7 +838,7 @@ export function cycleCompletion(opts: {
|
||||
const prior = view.dailyPnL.filter((d) => d.date !== view.tradeDate);
|
||||
|
||||
if (cycleOwesNothing(accountId, eff, prior, view.tradeDate, equityProfit, view.realizedPnL, splitEnabled)) return 'before';
|
||||
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date(), everyDay)) return 'none';
|
||||
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date(), everyDay, opts.client)) return 'none';
|
||||
|
||||
const withToday = [...prior, { date: view.tradeDate, pnl: view.realizedPnL }];
|
||||
const probe = nextTradingDate(view.tradeDate, everyDay);
|
||||
@@ -772,6 +846,32 @@ export function cycleCompletion(opts: {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* A completed cycle's place among the account's payouts, counted one per
|
||||
* payout: `payoutDays` holds each payout's day once per payout
|
||||
* (PlatformClient.payoutDays — a Vest day's two claims share a fund row —
|
||||
* else the negative fund rows' dates). stage = 1 + the payouts before the
|
||||
* cycle's own: those before `end`, the fund date that ended it (the
|
||||
* catch-up), else every one (today's final day). cycleSeq = the payouts on
|
||||
* the cycle's start day (`cycle`, '' = the account's first) that came
|
||||
* before it, beyond the one whose fund row names that day — 0 but for a
|
||||
* cycle that a second payout the same day started, which `cycle` alone
|
||||
* can't tell from that day's first.
|
||||
*/
|
||||
export function completionPlace(o: {
|
||||
payoutDays: string[];
|
||||
fundTransactions: { date: string; amount: number }[];
|
||||
cycle: string;
|
||||
end?: string;
|
||||
}): { stage: number; cycleSeq: number } {
|
||||
const stage = 1 + o.payoutDays.filter((d) => o.end === undefined || d < o.end).length;
|
||||
if (o.cycle === '') return { stage, cycleSeq: 0 };
|
||||
// A deposit's day (Vest's activation) names the cycle it started; each
|
||||
// payout that day starts another.
|
||||
const named = o.fundTransactions.some((f) => f.date === o.cycle && f.amount < 0) ? 1 : 0;
|
||||
return { stage, cycleSeq: Math.max(0, o.payoutDays.filter((d) => d === o.cycle).length - named) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Catch-up for a missed final day: did the cycle the latest fund transaction
|
||||
* just ended stand complete — profit target, consistency and minimum days all
|
||||
@@ -799,8 +899,10 @@ export function cycleCompletion(opts: {
|
||||
*/
|
||||
export function endedCycleCompletion(opts: {
|
||||
accountId: number;
|
||||
/** currentTradeDateCT(). */
|
||||
/** tradeDateFor(client). */
|
||||
rawTradeDate: string;
|
||||
/** As in cycleCompletion. */
|
||||
client?: { tradeDate?: (now: number) => string };
|
||||
/** Every cycle's daily P&L, raw (client.fullDailyPnL). */
|
||||
fullDailyPnL: { date: string; pnl: number }[];
|
||||
/** Current-cycle daily P&L, raw (client.dailyPnL). */
|
||||
@@ -819,6 +921,8 @@ export function endedCycleCompletion(opts: {
|
||||
targetSameEquity: boolean; withdrawalStages: WithdrawalStage[];
|
||||
combineCmeHolidays: boolean; splitEnabled: boolean;
|
||||
tradesEveryDay?: boolean;
|
||||
/** Stages reset each day (StageInputs). */
|
||||
stagesSameDay?: boolean;
|
||||
};
|
||||
/** Defaults to the current time. */
|
||||
now?: Date;
|
||||
@@ -827,9 +931,8 @@ export function endedCycleCompletion(opts: {
|
||||
const everyDay = cfg.tradesEveryDay === true;
|
||||
const combine = effectiveCombine(cfg);
|
||||
if (lastFundDate === null || start === null) return null;
|
||||
const ageDays = (Date.parse(`${rawTradeDate}T00:00:00Z`) - Date.parse(`${lastFundDate}T00:00:00Z`)) / (24 * 60 * 60 * 1000);
|
||||
if (!(ageDays <= ENDED_CYCLE_WINDOW_DAYS)) return null; // NaN included: an unparseable date
|
||||
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date(), everyDay)) return null;
|
||||
if (!withinEndedCycleWindow(lastFundDate, rawTradeDate)) return null;
|
||||
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date(), everyDay, opts.client)) return null;
|
||||
|
||||
const latest = (a: string, b: string) => (b > a ? b : a);
|
||||
const earlierFunds = fundTransactions.filter((f) => f.date < lastFundDate);
|
||||
@@ -852,10 +955,14 @@ export function endedCycleCompletion(opts: {
|
||||
const fundAmt = fundTransactions.filter((f) => f.date === lastFundDate).reduce((s, f) => s + f.amount, 0);
|
||||
const equityBefore = round2(opts.equityProfit - curTotal - fundAmt);
|
||||
|
||||
// The stage the ended cycle traded on: only the withdrawals before it.
|
||||
// The stage the ended cycle traded on: only the withdrawals before it —
|
||||
// for a type whose stages reset each day, only that day's. One fund row
|
||||
// a day shows none before it, and the rows here hold that whole day,
|
||||
// which began on the base stage: it is judged there.
|
||||
const eff = resolveEffectiveConfig(
|
||||
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity,
|
||||
cfg.withdrawalStages, 0, earlierFunds, cfg.minDayPnL,
|
||||
cfg.stagesSameDay === true ? { stagesSameDay: true, sameDayWithdrawals: 0 } : {},
|
||||
);
|
||||
const rows = combineCmeSessions(combine, endedRaw, lastFundDate, 0).dailyPnL;
|
||||
// A combining firm's folded day can net to 0 — nothing traded, as the firm counts it.
|
||||
@@ -866,12 +973,83 @@ export function endedCycleCompletion(opts: {
|
||||
|
||||
return {
|
||||
date: traded.map((d) => d.date).reduce(latest),
|
||||
// The payout's number among the account's withdrawals, a same-day
|
||||
// type's too: the master clears the entry once that many have booked.
|
||||
stage: 1 + earlierFunds.filter((f) => f.amount < 0).length,
|
||||
drain: eff.drain,
|
||||
cycle: prevFundDate ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
/** A completed cycle a payout claim ended (claimedCycle). */
|
||||
export interface ClaimedCycle { date: string; stage: number; drain: boolean; cycle: string; cycleSeq: number; at?: number }
|
||||
|
||||
/**
|
||||
* The cycle a payout claim about to go out ends (PlatformClient.claimProfit),
|
||||
* when it stands complete — cycleCompletion's 'today' or 'before' on the
|
||||
* client's numbers just before the claim — named and numbered as the final
|
||||
* day names it (completionPlace), so the master dedupes the two. date = the
|
||||
* claim's trade date. Null for a claim short of the rule (a Claim now below
|
||||
* the target) or on numbers not read yet.
|
||||
*
|
||||
* Taken before the claim books its payout, and kept once it goes through
|
||||
* (auto-trade afterClaim). The claim starts a new cycle at once, and two of
|
||||
* the cycles it can end no report would name otherwise. A Vest day's second
|
||||
* cycle: the auto-claim at the next tick beats the 30 s report, and its
|
||||
* payout books into that day's fund row, so the latest fund date doesn't
|
||||
* move for the catch-up. And one claimed right after the 20:00 ET reset,
|
||||
* which puts it back on the first stage, whose target the day's leftover
|
||||
* profit meets: all of its P&L sits in the day row of the cycle before it,
|
||||
* so endedCycleCompletion reads none of it.
|
||||
*/
|
||||
export function claimedCycle(
|
||||
client: {
|
||||
accountCashBalances: { [accountId: number]: { amount: number; realizedPnL: number } | undefined };
|
||||
dailyPnL: { [accountId: number]: { date: string; pnl: number }[] | undefined };
|
||||
fundTransactions?: { [accountId: number]: { date: string; amount: number }[] | undefined };
|
||||
lastFundDates?: { [accountId: number]: string | null | undefined };
|
||||
priorProfit?: { [accountId: number]: number | undefined };
|
||||
tradeDate?: (now: number) => string;
|
||||
sameDayClaims?: (accountId: number) => number;
|
||||
payoutDays?: (accountId: number) => string[] | undefined;
|
||||
},
|
||||
accountId: number,
|
||||
cfg: {
|
||||
profitTarget: number; consistency: number; minTradingDays: number; minDayPnL: number; accountSize: number;
|
||||
targetSameEquity: boolean; withdrawalStages: WithdrawalStage[]; splitEnabled: boolean;
|
||||
combineCmeHolidays?: boolean; tradesEveryDay?: boolean; dailyLossLimit?: number; stagesSameDay?: boolean;
|
||||
},
|
||||
now: Date = new Date(),
|
||||
tolerance = 0,
|
||||
): ClaimedCycle | null {
|
||||
const cash = client.accountCashBalances[accountId];
|
||||
const cycleDailyPnL = client.dailyPnL[accountId];
|
||||
const fundTransactions = client.fundTransactions?.[accountId];
|
||||
if (!cash || !cycleDailyPnL || !fundTransactions) return null;
|
||||
const effective = resolveEffectiveConfig(
|
||||
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages,
|
||||
client.priorProfit?.[accountId] ?? 0, fundTransactions, cfg.minDayPnL, stageInputsFor(client, accountId, cfg),
|
||||
);
|
||||
// `tolerance`: how far short of the target the claim still ends the
|
||||
// cycle — the engine's day is done within LEG_COMPLETION_EPSILON of it.
|
||||
const judged = tolerance > 0 && effective.profitTarget > tolerance
|
||||
? { ...effective, profitTarget: effective.profitTarget - tolerance } : effective;
|
||||
const rawTradeDate = tradeDateFor(client, now);
|
||||
const completion = cycleCompletion({
|
||||
accountId, rawTradeDate, client, cycleDailyPnL, rawRealizedPnL: cash.realizedPnL,
|
||||
combineCmeHolidays: cfg.combineCmeHolidays === true, tradesEveryDay: cfg.tradesEveryDay === true,
|
||||
effective: judged, equityProfit: cash.amount - cfg.accountSize, splitEnabled: cfg.splitEnabled, now,
|
||||
});
|
||||
if (completion === 'none') return null;
|
||||
const cycle = client.lastFundDates?.[accountId] ?? '';
|
||||
const payoutDays = client.payoutDays?.(accountId) ?? fundTransactions.filter((f) => f.amount < 0).map((f) => f.date);
|
||||
const place = completionPlace({ payoutDays, fundTransactions, cycle });
|
||||
return {
|
||||
date: combineCmeSessions(effectiveCombine(cfg), [], rawTradeDate, 0).tradeDate,
|
||||
stage: place.stage, drain: effective.drain, cycle, cycleSeq: place.cycleSeq, at: now.getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the next trading day's profit target for an account.
|
||||
*
|
||||
@@ -1327,18 +1505,119 @@ export function obfuscationStopDollars(
|
||||
* that fills worse than its price (gaps, thin weekend books). */
|
||||
export const DLL_RESERVE_FRACTION = 0.10;
|
||||
/** …or this share of the position's notional, when that is more: a stop's
|
||||
* slippage grows with the position, a flat reserve doesn't. */
|
||||
* slippage grows with the position, a flat reserve doesn't. A platform whose
|
||||
* stops are known to fill closer states its own (stopSlippageFraction). */
|
||||
export const DLL_SLIPPAGE_FRACTION = 0.001;
|
||||
|
||||
/** The part of the day's room a stop leaves unspent. */
|
||||
export function dllReserve(dailyLossLimit: number, notional = 0): number {
|
||||
return Math.max(DLL_RESERVE_FRACTION * dailyLossLimit, DLL_SLIPPAGE_FRACTION * Math.max(0, notional));
|
||||
export function dllReserve(dailyLossLimit: number, notional = 0, slippageFraction = DLL_SLIPPAGE_FRACTION): number {
|
||||
const share = Number.isFinite(slippageFraction) && slippageFraction >= 0 ? slippageFraction : DLL_SLIPPAGE_FRACTION;
|
||||
return Math.max(DLL_RESERVE_FRACTION * dailyLossLimit, share * Math.max(0, notional));
|
||||
}
|
||||
/** Smallest daily-loss stop worth trading. Under a fair walk a leg of L with a
|
||||
* stop of S wins S/(S + L) of the time — about 9% for $50 against $500 — so
|
||||
* anything tighter just spends the day's room on fees. */
|
||||
export const DLL_MIN_STOP = 50;
|
||||
|
||||
/** The stage inputs for one account: the type's daily loss limit, and for a
|
||||
* type whose stages reset each day, the client's count of today's payouts —
|
||||
* for any other, of every payout, where the client lists them one by one
|
||||
* (payoutDays) and has read them. */
|
||||
export function stageInputsFor(
|
||||
client: { sameDayClaims?: (accountId: number) => number; payoutDays?: (accountId: number) => string[] | undefined },
|
||||
accountId: number,
|
||||
cfg: { dailyLossLimit?: number; stagesSameDay?: boolean },
|
||||
): StageInputs {
|
||||
const sameDay = cfg.stagesSameDay === true;
|
||||
const payouts = !sameDay && typeof client.payoutDays === 'function' ? client.payoutDays(accountId)?.length : undefined;
|
||||
return {
|
||||
dailyLossLimit: cfg.dailyLossLimit ?? 0,
|
||||
stagesSameDay: sameDay,
|
||||
sameDayWithdrawals: sameDay && typeof client.sameDayClaims === 'function' ? client.sameDayClaims(accountId) : 0,
|
||||
...(payouts !== undefined ? { payouts } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** A leg is given this many of its target holds to be done before a daily
|
||||
* reset counts its open loss against the next day: under a fair walk, a leg
|
||||
* whose take-profit and stop are within 2:1 is still open after four holds
|
||||
* about 1–1.5% of the time, and the reset guard fits those. */
|
||||
export const RESET_CLEAR_HOLDS = 4;
|
||||
|
||||
/** The shortest take-profit the plan's next leg may have — what
|
||||
* resetClearStop is held to: a split day can carve a leg as small as
|
||||
* MIN_LEG (pickLegAmount), else the leg is all that remains. */
|
||||
export function shortestNextLeg(plan: { remainingToday: number; splitAllowed: boolean }): number {
|
||||
return plan.splitAllowed ? Math.min(plan.remainingToday, MIN_LEG) : plan.remainingToday;
|
||||
}
|
||||
|
||||
/**
|
||||
* The widest stop a leg whose take-profit is `leg` away may carry and still
|
||||
* be all but surely closed by a daily reset `resetInMs` away; 0 when the
|
||||
* reset is under RESET_CLEAR_HOLDS holds off. The walk's exit time has a
|
||||
* tail that decays as e^(−π²·t/(2g·hold)), g = (A+B)²/(A·B): a lopsided leg
|
||||
* (a $5,000 stop on a $500 take-profit) lingers far longer than its hold. So
|
||||
* the leg may be as lopsided as k = holds-to-reset ÷ RESET_CLEAR_HOLDS times
|
||||
* the 2:1 shape (g = 4.5) allows — g ≤ 4.5k — which keeps its chance of
|
||||
* still being open at the reset what a 2:1 leg's is at four holds.
|
||||
*/
|
||||
export function resetClearStop(o: { leg: number; resetInMs: number; holdHours: number }): number {
|
||||
if (!(o.leg > 0) || !(o.holdHours > 0) || !Number.isFinite(o.resetInMs)) return 0;
|
||||
const k = o.resetInMs / (RESET_CLEAR_HOLDS * o.holdHours * 3_600_000);
|
||||
if (k < 1) return 0;
|
||||
const c = 4.5 * k - 2;
|
||||
return (o.leg * (c + Math.sqrt(c * c - 4))) / 2;
|
||||
}
|
||||
|
||||
/** Whether a leg of `holdHours` starting now is all but sure to be done
|
||||
* before its market closes `closeInMs` from now: the RESET_CLEAR_HOLDS
|
||||
* holds a daily reset is given (resetClearStop). A leg still open at the
|
||||
* close can't be exited until the market reopens, and meets the reopen's
|
||||
* gap with its stop. */
|
||||
export function clearsClose(o: { closeInMs: number; holdHours: number }): boolean {
|
||||
return o.closeInMs >= RESET_CLEAR_HOLDS * o.holdHours * 3_600_000;
|
||||
}
|
||||
|
||||
/** Why a leg of `holdHours` starting at `now` doesn't open: its market
|
||||
* closes at `closesAt` (epoch ms) before it clears (clearsClose). null when
|
||||
* it clears, or no close is known (a futures contract). */
|
||||
export function legCloseNote(closesAt: number | null | undefined, holdHours: number, now: number): string | null {
|
||||
if (closesAt === null || closesAt === undefined || clearsClose({ closeInMs: closesAt - now, holdHours })) return null;
|
||||
return `closes in ${((closesAt - now) / 3_600_000).toFixed(1)}h, under ${RESET_CLEAR_HOLDS} holds of ${holdHours.toFixed(1)}h`;
|
||||
}
|
||||
|
||||
/**
|
||||
* dllRoom where the platform states its daily-loss floor itself (Vest: the
|
||||
* equity the account may not fall below today, already moved by the day's
|
||||
* transfers): what's left of the stage's limit after the day's losses, and
|
||||
* never more than the floor leaves. The firm's floor alone isn't enough — a
|
||||
* limit set below the firm's (Vest's 3% is $1,500; the bot's day stops at
|
||||
* $1,229) would let a day that already lost $1,111 trade the $389 Vest still
|
||||
* allows. Infinity when there is no limit.
|
||||
*
|
||||
* `gain` widens it: on a split day the next leg may give back what the
|
||||
* earlier legs made, plus the limit — a $1,400 day up $2,604 gets $4,004 —
|
||||
* as Vest's own floor does. The client says how much (dayGainForRoom): the
|
||||
* gain still in the account, no more than keeps the leg all but sure to be
|
||||
* closed by the reset (resetClearStop) — a leg still open there has all of
|
||||
* its loss counted against the next day. Never more than the day's realized
|
||||
* gain either way.
|
||||
*/
|
||||
export function floorRoom(o: {
|
||||
dailyLossLimit: number;
|
||||
closedBalance: number;
|
||||
floor: number;
|
||||
/** The day's realized P&L since its base (Vest: since this client's
|
||||
* latest claim today, else since the reset). */
|
||||
realizedToday: number;
|
||||
/** What of the day's gain the room may grow by; 0 (default) = none. */
|
||||
gain?: number;
|
||||
}): number {
|
||||
if (!(o.dailyLossLimit > 0)) return Infinity;
|
||||
const widen = Math.min(Math.max(0, o.realizedToday), Math.max(0, o.gain ?? 0));
|
||||
return round2(Math.min(o.dailyLossLimit + Math.min(0, o.realizedToday) + widen, o.closedBalance - o.floor));
|
||||
}
|
||||
|
||||
/**
|
||||
* How much the firm's day may still lose before a hard daily-loss breach.
|
||||
* Infinity when the account type has no limit.
|
||||
@@ -1350,42 +1629,6 @@ export const DLL_MIN_STOP = 50;
|
||||
* the next reset has its whole move counted against the NEW day — room
|
||||
* borrowed from today's profit would be a breach tomorrow.
|
||||
*/
|
||||
/** The stage inputs for one account: the type's daily loss limit, and for a
|
||||
* type whose stages reset each day, the client's count of today's payouts. */
|
||||
export function stageInputsFor(
|
||||
client: { sameDayClaims?: (accountId: number) => number },
|
||||
accountId: number,
|
||||
cfg: { dailyLossLimit?: number; stagesSameDay?: boolean },
|
||||
): StageInputs {
|
||||
const sameDay = cfg.stagesSameDay === true;
|
||||
return {
|
||||
dailyLossLimit: cfg.dailyLossLimit ?? 0,
|
||||
stagesSameDay: sameDay,
|
||||
sameDayWithdrawals: sameDay && typeof client.sameDayClaims === 'function' ? client.sameDayClaims(accountId) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* dllRoom where the platform states its daily-loss floor itself (Vest: the
|
||||
* equity the account may not fall below today, already moved by the day's
|
||||
* transfers): what's left of the stage's limit after the day's losses, and
|
||||
* never more than the floor leaves. The firm's floor alone isn't enough — a
|
||||
* limit set below the firm's (Vest's 3% is $1,500; the bot's day stops at
|
||||
* $1,229) would let a day that already lost $1,111 trade the $389 Vest still
|
||||
* allows. Infinity when there is no limit.
|
||||
*/
|
||||
export function floorRoom(o: {
|
||||
dailyLossLimit: number;
|
||||
closedBalance: number;
|
||||
floor: number;
|
||||
/** The day's realized P&L since its base (Vest: since this client's
|
||||
* latest claim today, else since the reset). */
|
||||
realizedToday: number;
|
||||
}): number {
|
||||
if (!(o.dailyLossLimit > 0)) return Infinity;
|
||||
return round2(Math.min(o.dailyLossLimit + Math.min(0, o.realizedToday), o.closedBalance - o.floor));
|
||||
}
|
||||
|
||||
export function dllRoom(o: {
|
||||
dailyLossLimit: number;
|
||||
closedBalance: number;
|
||||
@@ -1412,10 +1655,10 @@ export function dllRoom(o: {
|
||||
* reset has its entry fee on the old day's books, and the swap takes no more
|
||||
* than that slot on the new day's.
|
||||
*/
|
||||
export function dllStopDollars(o: { room: number; dailyLossLimit: number; entryFee: number; commissionPct: number; notional?: number }): number {
|
||||
export function dllStopDollars(o: { room: number; dailyLossLimit: number; entryFee: number; commissionPct: number; notional?: number; slippageFraction?: number }): number {
|
||||
if (!Number.isFinite(o.room)) return Infinity;
|
||||
const r = Math.max(0, o.commissionPct) / 100;
|
||||
return (o.room - dllReserve(o.dailyLossLimit, o.notional) - 2 * o.entryFee - 0.01) / (1 + r);
|
||||
return (o.room - dllReserve(o.dailyLossLimit, o.notional, o.slippageFraction) - 2 * o.entryFee - 0.01) / (1 + r);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1447,7 +1690,9 @@ export const LAST_STAND_FRACTION = 0.15;
|
||||
*/
|
||||
export function lastStand(o: { dllRoom: number; dailyLossLimit: number; maxLossRoom: number | null }): boolean {
|
||||
if (o.maxLossRoom === null || !(o.dailyLossLimit > 0) || !Number.isFinite(o.dllRoom) || !(o.dllRoom > 0)) return false;
|
||||
return o.maxLossRoom - o.dllRoom <= LAST_STAND_FRACTION * o.dllRoom;
|
||||
// A full day's loss is the limit, however much a green day widened the
|
||||
// room (floorRoom): a stop-out leaving $300 over the floor keeps a day.
|
||||
return o.maxLossRoom - o.dllRoom <= LAST_STAND_FRACTION * Math.min(o.dllRoom, o.dailyLossLimit);
|
||||
}
|
||||
|
||||
/** On a last stand: how far price can move against the leg before the firm's
|
||||
@@ -1490,10 +1735,15 @@ export function stopTicks(o: {
|
||||
}
|
||||
|
||||
/** Whether the bot places its own stops for this account type — the
|
||||
* obfuscation options or a hard daily loss limit. A red day on such an
|
||||
* account is the bot's own stop (the personal DLL), not a firm lockout. */
|
||||
export function botStopsActive(cfg: { firstDayStop: boolean; minDayStop: boolean; dailyLossLimit?: number }): boolean {
|
||||
return cfg.firstDayStop || cfg.minDayStop || (cfg.dailyLossLimit ?? 0) > 0;
|
||||
* obfuscation options or a hard daily loss limit: the current stage's
|
||||
* (resolveEffectiveConfig) when given, which a stage may set on a type with
|
||||
* none. A red day on such an account is the bot's own stop (the personal
|
||||
* DLL), not a firm lockout. */
|
||||
export function botStopsActive(
|
||||
cfg: { firstDayStop: boolean; minDayStop: boolean; dailyLossLimit?: number },
|
||||
effective?: { dailyLossLimit: number },
|
||||
): boolean {
|
||||
return cfg.firstDayStop || cfg.minDayStop || (effective ? effective.dailyLossLimit : cfg.dailyLossLimit ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1721,6 +1971,8 @@ export function planLegSize(o: {
|
||||
/** Infinity (or ≤ 0) = no obfuscation stop. */
|
||||
obfuscationDollars: number;
|
||||
availableFunds?: number;
|
||||
/** The platform's stopSlippageFraction; default DLL_SLIPPAGE_FRACTION. */
|
||||
slippageFraction?: number;
|
||||
}): {
|
||||
lastStand: boolean;
|
||||
cap: number;
|
||||
@@ -1736,7 +1988,7 @@ export function planLegSize(o: {
|
||||
obf,
|
||||
!dllOn ? Infinity
|
||||
: stand ? breachDistance({ dllRoom: o.dllRoom, maxLossRoom: o.maxLossRoom, entryFee: r * notional })
|
||||
: dllStopDollars({ room: o.dllRoom, dailyLossLimit: o.dailyLossLimit, entryFee: r * notional, commissionPct: o.commissionPct, notional }),
|
||||
: dllStopDollars({ room: o.dllRoom, dailyLossLimit: o.dailyLossLimit, entryFee: r * notional, commissionPct: o.commissionPct, notional, slippageFraction: o.slippageFraction }),
|
||||
);
|
||||
const cap = notionalCap({ marginRate: o.contract.marginRate, availableFunds: o.availableFunds });
|
||||
const step = o.contract.stepNotional ?? 0;
|
||||
|
||||
+141
-1
@@ -6,7 +6,9 @@ import { computeSec, randomUUIDV4 } from './tradovate-helpers';
|
||||
import { currentTradeDateCT } from './trading-logic';
|
||||
import { POINT_VALUES } from './instrument-specs';
|
||||
import { getCachedContract, resolveContracts } from './contract-resolver';
|
||||
import { saveDailyPnL, loadDailyPnL, saveAccountMeta, loadAccountMeta, saveFundTransactions, loadFundTransactions, saveEndedCycleStart } from './db';
|
||||
import { saveDailyPnL, loadDailyPnL, saveAccountMeta, loadAccountMeta, saveFundTransactions, loadFundTransactions, saveEndedCycleStart, recordOwnOrder, type OwnOrderKind } from './db';
|
||||
import { recordFillTrades, type ContractFills } from './trade-recorder';
|
||||
import { contractRoot, type Fill } from './fill-trades';
|
||||
import { brokerWebSocket } from './outbound-proxy';
|
||||
import { FILLS_FALLBACK_NOTE, OrderNotPlacedError, SYNC_STALE_MS, errorMessage, type ClientHealth } from './platform-client';
|
||||
|
||||
@@ -113,6 +115,8 @@ const FINISHED_ORDER_STATUSES = new Set(['Filled', 'Canceled', 'Rejected', 'Expi
|
||||
* a payout is hundreds. */
|
||||
const FUND_DRIFT_TOLERANCE = 25;
|
||||
const CONTRACT_FIND_TIMEOUT_MS = 15_000;
|
||||
/** How often a product's fees are asked for again, for the trades record. */
|
||||
const FEES_REFRESH_MS = 6 * 60 * 60 * 1000;
|
||||
/** On-demand front-month resolve (several Yahoo lookups, then Tradovate). */
|
||||
const CONTRACT_RESOLVE_DEADLINE_MS = 30_000;
|
||||
const READY_STATE_NAMES = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'];
|
||||
@@ -371,6 +375,13 @@ export class TradovateClient {
|
||||
* outcome clears it. */
|
||||
private cashHistoryRefusedSince = new Map<number, number>();
|
||||
/** Interval handles tracked so they can be cleared on reconnect */
|
||||
/** Contract id → name ("NQZ6"), for the trades record. */
|
||||
private contractNames = new Map<number, string>();
|
||||
private contractLookupInFlight = false;
|
||||
/** Product → fees per contract per side, for the trades record. */
|
||||
private feePerSide = new Map<string, number>();
|
||||
private feesAskedAt = new Map<string, number>();
|
||||
private feesInFlight = false;
|
||||
private syncInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private tokenRenewalInterval: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -1049,6 +1060,8 @@ export class TradovateClient {
|
||||
{} as { [accountId: number]: { contractId: number; netPos: number; netPrice: number; timestamp: Date } }
|
||||
);
|
||||
|
||||
this.recordTrades(response);
|
||||
|
||||
this.lastSyncAt = Date.now();
|
||||
this.lastSyncStartedAt = requestedAt || this.lastSyncAt;
|
||||
// A session counts as working once it has synced (abandonSocket).
|
||||
@@ -2028,6 +2041,128 @@ export class TradovateClient {
|
||||
}
|
||||
}
|
||||
|
||||
/** Notes an order as the bot's, so the trade its fill opens or closes is
|
||||
* credited to the bot (lib/trade-recorder.ts). */
|
||||
private noteOwnOrder(accountId: number, orderId: unknown, kind: OwnOrderKind): void {
|
||||
if (typeof orderId !== 'number' || !(orderId > 0)) return;
|
||||
try {
|
||||
recordOwnOrder(accountId, orderId, kind);
|
||||
} catch (err) {
|
||||
console.error(`${this.tag} could not note order ${orderId} as the bot's: ${errorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The trades the sync snapshot's fills make (the session's), in the
|
||||
* trades table the dashboard lists (lib/trade-recorder.ts). A fill names
|
||||
* its order, not its account, so the snapshot's orders say whose it is.
|
||||
* Never fails the sync.
|
||||
*/
|
||||
private recordTrades(response: { contracts?: unknown[]; orders?: unknown[]; positions?: unknown[]; fills?: unknown[] }): void {
|
||||
try {
|
||||
for (const c of (response.contracts ?? []) as { id?: unknown; name?: unknown }[]) {
|
||||
if (typeof c?.id === 'number' && typeof c.name === 'string') this.contractNames.set(c.id, c.name);
|
||||
}
|
||||
const accountOfOrder = new Map<number, number>();
|
||||
for (const o of (response.orders ?? []) as { id?: unknown; accountId?: unknown }[]) {
|
||||
if (typeof o?.id === 'number' && typeof o.accountId === 'number') accountOfOrder.set(o.id, o.accountId);
|
||||
}
|
||||
const books = new Map<number, Map<number, { fills: Fill[]; net: number; netAt: number }>>();
|
||||
const book = (accountId: number, contractId: number) => {
|
||||
const contracts = books.get(accountId) ?? new Map<number, { fills: Fill[]; net: number; netAt: number }>();
|
||||
books.set(accountId, contracts);
|
||||
const b = contracts.get(contractId) ?? { fills: [], net: 0, netAt: -Infinity };
|
||||
contracts.set(contractId, b);
|
||||
return b;
|
||||
};
|
||||
for (const p of (response.positions ?? []) as { accountId?: unknown; contractId?: unknown; netPos?: unknown; timestamp?: unknown }[]) {
|
||||
if (typeof p?.accountId !== 'number' || typeof p.contractId !== 'number') continue;
|
||||
const b = book(p.accountId, p.contractId);
|
||||
const at = Date.parse(String(p.timestamp ?? '')) || 0;
|
||||
if (at >= b.netAt) {
|
||||
b.net = typeof p.netPos === 'number' ? p.netPos : 0;
|
||||
b.netAt = at;
|
||||
}
|
||||
}
|
||||
for (const raw of (response.fills ?? []) as Record<string, unknown>[]) {
|
||||
const f = raw ?? {};
|
||||
if (f.active === false || typeof f.id !== 'number' || typeof f.orderId !== 'number' || typeof f.contractId !== 'number') continue;
|
||||
const qty = Number(f.qty);
|
||||
const price = Number(f.price);
|
||||
const at = Date.parse(String(f.timestamp ?? ''));
|
||||
const accountId = accountOfOrder.get(f.orderId);
|
||||
if (accountId === undefined || !(qty > 0) || !Number.isFinite(price) || !Number.isFinite(at)) continue;
|
||||
book(accountId, f.contractId).fills.push({
|
||||
id: String(f.id), orderId: String(f.orderId), at, side: f.action === 'Buy' ? 'buy' : 'sell', qty, price, fee: null, pnl: null,
|
||||
});
|
||||
}
|
||||
|
||||
const unnamed = new Set<number>();
|
||||
const roots = new Set<string>();
|
||||
for (const acc of this.accountList) {
|
||||
const contracts = books.get(acc.id);
|
||||
if (!contracts) continue;
|
||||
const list: ContractFills[] = [];
|
||||
for (const [contractId, b] of contracts) {
|
||||
if (b.fills.length === 0 && b.net === 0) continue;
|
||||
const name = this.contractNames.get(contractId);
|
||||
if (!name) {
|
||||
unnamed.add(contractId);
|
||||
continue;
|
||||
}
|
||||
const root = contractRoot(name);
|
||||
roots.add(root);
|
||||
const perSide = this.feePerSide.get(root);
|
||||
list.push({
|
||||
symbol: name,
|
||||
pointValue: POINT_VALUES[root] ?? null,
|
||||
currentNet: b.net,
|
||||
fills: b.fills.map((f) => ({ ...f, fee: perSide === undefined ? null : Math.round(perSide * f.qty * 100) / 100 })),
|
||||
});
|
||||
}
|
||||
if (list.length > 0) recordFillTrades('tdv', acc, list);
|
||||
}
|
||||
if (unnamed.size > 0) void this.lookUpContractNames([...unnamed]);
|
||||
this.refreshFees(roots);
|
||||
} catch (err) {
|
||||
console.error(`${this.tag} trade record failed: ${errorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Names for contracts the snapshot didn't describe — the next sync's
|
||||
* record uses them. */
|
||||
private async lookUpContractNames(ids: number[]): Promise<void> {
|
||||
if (this.contractLookupInFlight) return;
|
||||
this.contractLookupInFlight = true;
|
||||
try {
|
||||
await this.sendRequest('contract/items', `ids=${ids.join(',')}`, CONTRACT_FIND_TIMEOUT_MS, (data) => {
|
||||
for (const c of (Array.isArray(data) ? data : []) as { id?: unknown; name?: unknown }[]) {
|
||||
if (typeof c?.id === 'number' && typeof c.name === 'string') this.contractNames.set(c.id, c.name);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(`${this.tag} contract/items failed: ${errorMessage(err)}`);
|
||||
} finally {
|
||||
this.contractLookupInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Each product's fees per contract per side (getproductfeeparams), for
|
||||
* the trades record: asked for a product once, then every few hours. */
|
||||
private refreshFees(roots: Set<string>): void {
|
||||
const now = Date.now();
|
||||
const due = [...roots].filter((r) => now - (this.feesAskedAt.get(r) ?? 0) >= FEES_REFRESH_MS);
|
||||
if (due.length === 0 || this.feesInFlight || this.products.length === 0) return;
|
||||
this.feesInFlight = true;
|
||||
for (const r of due) this.feesAskedAt.set(r, now);
|
||||
this.fetchInstrumentFees(due)
|
||||
.then((fees) => {
|
||||
for (const [root, fee] of Object.entries(fees)) this.feePerSide.set(root, fee);
|
||||
})
|
||||
.catch((err) => console.warn(`${this.tag} fee lookup failed: ${errorMessage(err)}`))
|
||||
.finally(() => { this.feesInFlight = false; });
|
||||
}
|
||||
|
||||
/** A string or empty ack is the broker refusing the order — nothing was placed. */
|
||||
private static acceptedAck(response: unknown, endpoint: string): { orderId?: number; id?: number; commandStatus?: string } {
|
||||
if (typeof response === 'string') throw new OrderNotPlacedError(response || `${endpoint} refused`);
|
||||
@@ -2148,6 +2283,7 @@ export class TradovateClient {
|
||||
const ack = TradovateClient.acceptedAck(response, 'order/placeorder');
|
||||
// Tradovate returns the command object: { id, commandStatus, orderId, ... }
|
||||
const orderId = ack.orderId ?? ack.id;
|
||||
this.noteOwnOrder(accountId, orderId, orderType === 'Limit' ? 'limit' : 'market');
|
||||
console.log(`${this.tag} sendOrder orderId=${orderId} status=${ack.commandStatus}`);
|
||||
|
||||
// Wait for the fill event — it carries the real execution price
|
||||
@@ -2180,6 +2316,7 @@ export class TradovateClient {
|
||||
ACK_TIMEOUT_MS,
|
||||
(response) => {
|
||||
const ack = TradovateClient.acceptedAck(response, 'order/placeorder');
|
||||
this.noteOwnOrder(accountId, ack.orderId ?? ack.id, orderType === 'Limit' ? 'limit' : 'market');
|
||||
return { orderId: ack.orderId ?? ack.id };
|
||||
},
|
||||
);
|
||||
@@ -2219,6 +2356,9 @@ export class TradovateClient {
|
||||
ACK_TIMEOUT_MS,
|
||||
(response) => {
|
||||
const ack = TradovateClient.acceptedAck(response, 'order/placeoco');
|
||||
// PlaceOCOResult: orderId is the take-profit limit, ocoId its stop.
|
||||
this.noteOwnOrder(accountId, ack.orderId ?? ack.id, 'limit');
|
||||
this.noteOwnOrder(accountId, (response as { ocoId?: unknown }).ocoId, 'stop');
|
||||
return { orderId: ack.orderId ?? ack.id };
|
||||
},
|
||||
);
|
||||
|
||||
+973
-148
File diff suppressed because it is too large
Load Diff
+328
-20
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Pure helpers for the Vest client (lib/vest-class.ts) — parsing the gateway's
|
||||
* answers, naming accounts, Vest's trading day, market hours, volatility.
|
||||
* answers and errors, naming accounts, Vest's trading day, profit-claim
|
||||
* bookkeeping, market hours, volatility.
|
||||
*
|
||||
* Only Node built-ins are imported, so scripts/check-vest.mjs loads this file
|
||||
* directly (node --experimental-strip-types).
|
||||
@@ -52,6 +53,47 @@ export function formatDecimal(value: number, decimals: number): string {
|
||||
return d > 0 ? fixed.replace(/\.?0+$/, '') || '0' : fixed;
|
||||
}
|
||||
|
||||
// ── gateway errors ──────────────────────────────────────────────────────────
|
||||
|
||||
/** The gateway answered with an error status. */
|
||||
export class VestApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string | undefined;
|
||||
constructor(message: string, status: number, code: string | undefined) {
|
||||
super(message);
|
||||
this.name = 'VestApiError';
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/** The request never went out: the account token it needed couldn't be had. */
|
||||
export class VestNotSentError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'VestNotSentError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a failed request proves nothing it asked for happened: it never
|
||||
* went out, or the gateway refused it (a 4xx, but not a timeout). A
|
||||
* transport failure or a 5xx may have gone in. */
|
||||
export function nothingWentIn(err: unknown): boolean {
|
||||
return err instanceof VestNotSentError
|
||||
|| (err instanceof VestApiError && err.status >= 400 && err.status < 500 && err.status !== 408);
|
||||
}
|
||||
|
||||
/** The account token a request needs, from `mint`. Any failure to mint one —
|
||||
* a timeout, a 5xx, an answer without a token — is the request not sent,
|
||||
* never an outcome that could have gone in. */
|
||||
export async function tokenForRequest(path: string, mint: () => Promise<string>): Promise<string> {
|
||||
try {
|
||||
return await mint();
|
||||
} catch (err) {
|
||||
throw new VestNotSentError(`${path} not sent — no account token: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Vest's trading day ──────────────────────────────────────────────────────
|
||||
|
||||
const ET_PARTS = new Intl.DateTimeFormat('en-US', {
|
||||
@@ -101,6 +143,12 @@ export function nextVestResetMs(now: number): number {
|
||||
return NaN;
|
||||
}
|
||||
|
||||
/** Vest's stopSlippageFraction: 0.02% of the position, about 6 NDX points.
|
||||
* Measured 2026-10-27 on 9 bot stop-outs (Me, $74k–$132k): fills 0.9 points
|
||||
* past the trigger on average, 2.25 at worst — the 0.1% default held back
|
||||
* about 30. The flat 10% of the limit still applies under it. */
|
||||
export const VEST_STOP_SLIPPAGE_FRACTION = 0.0002;
|
||||
|
||||
/**
|
||||
* What to do with an open position before the daily reset so the reset can't
|
||||
* breach it. At 20:00 ET Vest captures the realized balance (collateral in,
|
||||
@@ -112,15 +160,20 @@ export function nextVestResetMs(now: number): number {
|
||||
* 'none' — its stop is already at least that tight;
|
||||
* 'stop' — move (or place) the stop at `price`, on the tick grid, rounded
|
||||
* toward the position's safe side;
|
||||
* 'close' — no room at all, or the mark is already past that price.
|
||||
* 'close' — the mark is already past that price (with no room left by
|
||||
* the account's other positions, it sits on the profit side),
|
||||
* or no room and no mark to tell.
|
||||
*/
|
||||
export function resetSafeStop(o: {
|
||||
side: 'long' | 'short';
|
||||
quantity: number;
|
||||
openPrice: number;
|
||||
mark: number;
|
||||
/** Realized balance, collateral included — what the reset captures. */
|
||||
/** Realized balance, collateral included — what the reset captures —
|
||||
* less what the account's other positions may lose at their stops. */
|
||||
balance: number;
|
||||
/** The realized balance the floor is set from; default `balance`. */
|
||||
floorBalance?: number;
|
||||
dailyLossPct: number;
|
||||
maxDrawdownFloor: number | undefined;
|
||||
reserve: number;
|
||||
@@ -129,10 +182,14 @@ export function resetSafeStop(o: {
|
||||
currentStop: number | null;
|
||||
tick: number;
|
||||
}): { action: 'none' } | { action: 'stop'; price: number } | { action: 'close' } {
|
||||
const floor = Math.max(o.balance * (1 - o.dailyLossPct), o.maxDrawdownFloor ?? -Infinity);
|
||||
const floor = Math.max((o.floorBalance ?? o.balance) * (1 - o.dailyLossPct), o.maxDrawdownFloor ?? -Infinity);
|
||||
const exitFee = o.exitFeeRate * o.quantity * (Number.isFinite(o.mark) && o.mark > 0 ? o.mark : o.openPrice);
|
||||
const allowed = o.balance - floor - o.reserve - exitFee;
|
||||
if (!(allowed > 0) || !(o.quantity > 0)) return { action: 'close' };
|
||||
if (!(o.quantity > 0)) return { action: 'close' };
|
||||
// No room to lose (the account's other positions took it): the stop must
|
||||
// lock in at least the shortfall — on the profit side of the entry,
|
||||
// which a position already that far up can still carry.
|
||||
if (!(allowed > 0) && !Number.isFinite(o.mark)) return { action: 'close' };
|
||||
const long = o.side === 'long';
|
||||
const raw = long ? o.openPrice - allowed / o.quantity : o.openPrice + allowed / o.quantity;
|
||||
const price = o.tick > 0
|
||||
@@ -154,7 +211,15 @@ export interface VestCapitalAccount {
|
||||
productType: string;
|
||||
/** 1-based stage; stages[stage-1] is the current one. */
|
||||
stage: number;
|
||||
/** Evaluation steps the plan has (a 1-Step's is 1, a 2-Step's 2). */
|
||||
stepsTotal: number;
|
||||
/** The evaluation step this account is (1-based; a funded one's is
|
||||
* stepsTotal + 1, as the Vest app counts it when Vest leaves it out). */
|
||||
step: number;
|
||||
/** Vest's account_type: 2 an evaluation, 3 a funded account. */
|
||||
accountType: number;
|
||||
/** Vest blocked the account (blocked_at set): nothing it offers applies. */
|
||||
blocked: boolean;
|
||||
/** The current stage: 'eval' or 'funded'. */
|
||||
kind: string;
|
||||
status: number;
|
||||
@@ -179,13 +244,18 @@ export function parseCapitalAccount(raw: unknown): VestCapitalAccount | null {
|
||||
const stages = Array.isArray(a.stages) ? (a.stages as Record<string, unknown>[]) : [];
|
||||
const stage = num(a.stage) ?? 1;
|
||||
const current = stages.find((s) => num(s.ordinal) === stage) ?? stages[stage - 1] ?? {};
|
||||
// The Vest app's own fallbacks, for an answer that leaves these out.
|
||||
const stepsTotal = num(a.steps_total) ?? (a.plan_product_type === 'eval' ? 1 : 0);
|
||||
return {
|
||||
id: a.id,
|
||||
planId: typeof a.plan_id === 'string' ? a.plan_id : '',
|
||||
planName: typeof a.plan_name === 'string' ? a.plan_name : '',
|
||||
productType: typeof a.plan_product_type === 'string' ? a.plan_product_type : '',
|
||||
stage,
|
||||
stepsTotal: num(a.steps_total) ?? 0,
|
||||
stepsTotal,
|
||||
step: num(a.step) ?? (stage === VEST_STAGE_FUNDED ? stepsTotal + 1 : 1),
|
||||
accountType: num(a.account_type) ?? 0,
|
||||
blocked: Boolean(a.blocked_at),
|
||||
kind: typeof current.kind === 'string' ? current.kind : '',
|
||||
status: num(a.status) ?? -1,
|
||||
attemptIndex: num(a.attempt_index) ?? 0,
|
||||
@@ -202,9 +272,43 @@ export function parseCapitalAccount(raw: unknown): VestCapitalAccount | null {
|
||||
};
|
||||
}
|
||||
|
||||
/** The capital account status every listed live account showed (2026-10-26).
|
||||
* Anything else is treated as unable to trade until seen and named. */
|
||||
export const VEST_STATUS_ACTIVE = 2;
|
||||
/** Vest's capital account statuses, as the Vest app names them (its bundle,
|
||||
* 2026-10-29). Only an ACTIVE account trades. A passed evaluation sits at
|
||||
* GOAL_PASSED until its funded account is activated, and is CLAIMED after. */
|
||||
export const VEST_STATUS = {
|
||||
CREATED: 1,
|
||||
ACTIVE: 2,
|
||||
GOAL_PASSED: 3,
|
||||
FAILED_MAX_DRAWDOWN: 4,
|
||||
FAILED_MAX_DAILY_LOSS: 5,
|
||||
CLAIMED: 6,
|
||||
EXPIRED: 7,
|
||||
} as const;
|
||||
export const VEST_STATUS_ACTIVE = VEST_STATUS.ACTIVE;
|
||||
|
||||
/** Vest's stage field: an evaluation's, then the funded account's. */
|
||||
const VEST_STAGE_EVAL = 1;
|
||||
const VEST_STAGE_FUNDED = 2;
|
||||
/** Vest's account_type of an evaluation. */
|
||||
const VEST_ACCOUNT_TYPE_EVAL = 2;
|
||||
|
||||
/** A status as the log says it: Vest's own name, with its number. */
|
||||
export function vestStatusName(status: number): string {
|
||||
const name = Object.entries(VEST_STATUS).find(([, v]) => v === status)?.[0];
|
||||
return `${name ? name.toLowerCase().replace(/_/g, ' ') : 'unknown'} (status ${status})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A passed evaluation whose funded account Vest activates on request (POST
|
||||
* /v3/capital/activate-funded) — the Vest app's own test for its "Go to
|
||||
* funded account" button: an evaluation at GOAL_PASSED, not blocked, on its
|
||||
* plan's last step. An earlier step of a 2-Step passes on to the next step
|
||||
* by itself.
|
||||
*/
|
||||
export function awaitingActivation(a: VestCapitalAccount): boolean {
|
||||
const evaluation = a.accountType === VEST_ACCOUNT_TYPE_EVAL || a.stage === VEST_STAGE_EVAL;
|
||||
return evaluation && a.status === VEST_STATUS.GOAL_PASSED && !a.blocked && a.stepsTotal > 0 && a.step === a.stepsTotal;
|
||||
}
|
||||
|
||||
const pct = (fraction: number) => Math.round(fraction * 1000) / 10;
|
||||
|
||||
@@ -313,9 +417,12 @@ export function parseHistoryPosition(raw: unknown): VestTrade | null {
|
||||
if (!p || typeof id !== 'string' || typeof p.symbol !== 'string') return null;
|
||||
const openedAt = toMs(p.openDate ?? p.open_date ?? p.createdAt ?? p.created_at);
|
||||
if (!Number.isFinite(openedAt)) return null;
|
||||
const closedAt = toMs(p.closeDate ?? p.close_date);
|
||||
const closedMs = toMs(p.closeDate ?? p.close_date);
|
||||
const closedAt = Number.isFinite(closedMs) && closedMs > 0 ? closedMs : null;
|
||||
const openPrice = num(p.openPrice ?? p.open_price);
|
||||
const closePrice = num(p.closePrice ?? p.close_price);
|
||||
// An open position comes with a placeholder close price and a $0 P&L
|
||||
// (seen: 290.25 on an NDX short at 29,538.96): only a close has them.
|
||||
const closePrice = closedAt === null ? undefined : num(p.closePrice ?? p.close_price);
|
||||
const reason = p.closeReason ?? p.close_reason;
|
||||
return {
|
||||
positionId: id,
|
||||
@@ -325,20 +432,75 @@ export function parseHistoryPosition(raw: unknown): VestTrade | null {
|
||||
leverage: num(p.leverage) ?? null,
|
||||
openPrice: openPrice !== undefined && openPrice > 0 ? openPrice : null,
|
||||
closePrice: closePrice !== undefined && closePrice > 0 ? closePrice : null,
|
||||
pnl: num(p.pnl) ?? null,
|
||||
pnl: closedAt === null ? null : num(p.pnl) ?? null,
|
||||
fee: num(p.fee) ?? null,
|
||||
funding: num(p.funding) ?? null,
|
||||
openedAt,
|
||||
closedAt: Number.isFinite(closedAt) && closedAt > 0 ? closedAt : null,
|
||||
closedAt,
|
||||
closeReason: typeof reason === 'string' && reason !== '' ? reason : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Every string value in a JWT's payload, lower-case ([] when it isn't
|
||||
* one). Used only to compare against account ids — never logged. */
|
||||
export function jwtClaimStrings(token: string): string[] {
|
||||
const part = token.split('.')[1];
|
||||
if (!part) return [];
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(Buffer.from(part.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const out: string[] = [];
|
||||
const walk = (v: unknown, depth: number): void => {
|
||||
if (typeof v === 'string') out.push(v.toLowerCase());
|
||||
else if (v && typeof v === 'object' && depth < 4) for (const x of Object.values(v)) walk(x, depth + 1);
|
||||
};
|
||||
walk(payload, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a token the extension relayed is one Vest account's own token, not
|
||||
* the login's. The Vest app renews both through POST /v3/auth/refresh — the
|
||||
* login's every ~15 minutes, an account's while someone trades in it — and
|
||||
* an account's token sees only that account: /v3/capital/accounts/active
|
||||
* lists it alone, and /v3/accounts has no primary (wallet) account. The
|
||||
* login sees every account and its primary one.
|
||||
*/
|
||||
export function accountScopedToken(o: { capitalIds: string[]; hasPrimary: boolean; claims: string[] }): boolean {
|
||||
if (o.capitalIds.length > 1) return false;
|
||||
if (!o.hasPrimary) return true;
|
||||
return o.capitalIds.some((id) => o.claims.includes(id.toLowerCase()));
|
||||
}
|
||||
|
||||
/** Exit legs still waiting to trigger. */
|
||||
export function awaitingLegs(p: VestPosition): VestLeg[] {
|
||||
return [...p.stopLosses, ...p.takeProfits].filter((l) => l.status === 'awaiting');
|
||||
}
|
||||
|
||||
/**
|
||||
* The positions an open of unknown outcome (a timeout, a reset, a 5xx after
|
||||
* the send) may have made, as a read shows them — the bot's only when there
|
||||
* is exactly one. The one the open's answer named, when it named one. Else
|
||||
* on its account, in its symbol and side, with no exit legs (none went on
|
||||
* it), opened between `from` and `until` — the send and the error, widened
|
||||
* for the gateway's clock: a market open is IOC, so a later one (an outside
|
||||
* copy-trader's, which opens the same symbol on every account at once) isn't
|
||||
* it. More than one of those can't be told apart.
|
||||
*/
|
||||
export function unknownOpenFill(
|
||||
sent: { accountId: string; symbol: string; side: 'long' | 'short'; from: number; until: number; positionId?: string },
|
||||
positions: VestPosition[],
|
||||
): VestPosition[] {
|
||||
const mine = positions.filter((p) => p.accountId === sent.accountId);
|
||||
if (sent.positionId) return mine.filter((p) => p.positionId === sent.positionId);
|
||||
return mine.filter((p) => p.symbol === sent.symbol && p.side === sent.side
|
||||
&& p.stopLosses.length === 0 && p.takeProfits.length === 0
|
||||
&& p.openedAt >= sent.from && p.openedAt <= sent.until);
|
||||
}
|
||||
|
||||
export interface VestOpenOrder {
|
||||
orderId: string;
|
||||
accountId: string;
|
||||
@@ -450,6 +612,130 @@ export function bookTxns(txns: VestTxn[]): { daily: Map<string, { pnl: number; f
|
||||
return { daily, funds };
|
||||
}
|
||||
|
||||
// ── profit claims ───────────────────────────────────────────────────────────
|
||||
|
||||
/** A claim shows in Vest's transactions as a WITHDRAW of the same amount
|
||||
* within this long of it. */
|
||||
export const CLAIM_MATCH_MS = 30 * 60_000;
|
||||
/** A claim of unknown outcome (the request failed in transit) that no
|
||||
* WITHDRAW matches in a history read this long after it never happened. */
|
||||
export const UNKNOWN_CLAIM_EXPIRES_MS = 60 * 60_000;
|
||||
|
||||
/** A claim this client sent: the amount, when (at = once it was answered;
|
||||
* sentAt = before it went out, absent on claims saved before it was kept),
|
||||
* and the balance it left. unknown = the request failed in transit, so it
|
||||
* may or may not have gone in — it holds further claims, but nothing is
|
||||
* booked on it. */
|
||||
export interface OwnClaim { at: number; sentAt?: number; amount: number; base: number; unknown?: true }
|
||||
|
||||
/** A WITHDRAW transaction: when, and its (negative) amount. */
|
||||
export interface VestWithdrawal { at: number; amount: number }
|
||||
|
||||
/** The WITHDRAW that shows the claim — its amount, within CLAIM_MATCH_MS,
|
||||
* the nearest in time — or undefined while Vest's transactions don't. */
|
||||
export function claimWithdrawal(withdrawals: VestWithdrawal[], c: OwnClaim): VestWithdrawal | undefined {
|
||||
let best: VestWithdrawal | undefined;
|
||||
for (const w of withdrawals) {
|
||||
if (Math.abs(Math.abs(w.amount) - c.amount) >= 0.01 || Math.abs(w.at - c.at) >= CLAIM_MATCH_MS) continue;
|
||||
if (!best || Math.abs(w.at - c.at) < Math.abs(best.at - c.at)) best = w;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** The claims Vest's transactions, as read at `readAt`, don't show: those
|
||||
* still pending, and those that lapse — of unknown outcome and still not
|
||||
* shown an hour on, so never made. One Vest accepted never lapses. */
|
||||
export function settleClaims(claims: OwnClaim[], withdrawals: VestWithdrawal[], readAt: number): { pending: OwnClaim[]; lapsed: OwnClaim[] } {
|
||||
const pending: OwnClaim[] = [];
|
||||
const lapsed: OwnClaim[] = [];
|
||||
for (const c of claims) {
|
||||
if (claimWithdrawal(withdrawals, c)) continue;
|
||||
if (c.unknown && readAt - c.at > UNKNOWN_CLAIM_EXPIRES_MS) lapsed.push(c);
|
||||
else pending.push(c);
|
||||
}
|
||||
return { pending, lapsed };
|
||||
}
|
||||
|
||||
/**
|
||||
* The account's fund rows, one per Vest day: Vest's own (bookTxns' funds),
|
||||
* plus each claim this client made that the transactions don't show yet, on
|
||||
* the day it was booked. And the stored days to take back: a day a claim
|
||||
* was booked on that Vest's transactions leave empty, its WITHDRAW being
|
||||
* dated another day (the request spanned 20:00 ET, or the clocks differ) —
|
||||
* kept, the payout would count twice.
|
||||
*/
|
||||
export function claimFundRows(o: {
|
||||
funds: Map<string, number>; claims: OwnClaim[]; withdrawals: VestWithdrawal[]; stored: { date: string }[];
|
||||
}): { rows: { date: string; amount: number }[]; dropped: string[] } {
|
||||
const funds = new Map(o.funds);
|
||||
const made = o.claims.filter((c) => !c.unknown);
|
||||
for (const c of made) {
|
||||
if (claimWithdrawal(o.withdrawals, c)) continue;
|
||||
const day = vestDayOf(c.at);
|
||||
funds.set(day, (funds.get(day) ?? 0) - c.amount);
|
||||
}
|
||||
const rows = [...funds.entries()]
|
||||
.map(([date, amount]) => ({ date, amount: Math.round(amount * 100) / 100 }))
|
||||
.filter((f) => f.amount !== 0)
|
||||
.sort((x, y) => x.date.localeCompare(y.date));
|
||||
const claimDays = new Set(made.map((c) => vestDayOf(c.at)));
|
||||
const dropped = o.stored.map((f) => f.date).filter((d) => claimDays.has(d) && !rows.some((r) => r.date === d));
|
||||
return { rows, dropped };
|
||||
}
|
||||
|
||||
/**
|
||||
* What a history read makes of this client's claims (VestClient.bookHistory):
|
||||
* Vest's WITHDRAWs; the claims that lapse and those still outstanding
|
||||
* (settleClaims); the fund rows and the stored days to take back
|
||||
* (claimFundRows); and the balance the transactions add up to — `opening`
|
||||
* (the capital of an account Vest funded without a deposit row) included,
|
||||
* the claims they don't show yet taken out — that the books check holds the
|
||||
* account to. A lapsed claim is out of it: counted, one that never went in
|
||||
* would hold the account for good.
|
||||
*/
|
||||
export function readClaims(o: {
|
||||
txns: VestTxn[]; funds: Map<string, number>; claims: OwnClaim[]; stored: { date: string }[]; readAt: number; opening: number;
|
||||
}): {
|
||||
withdrawals: VestWithdrawal[]; lapsed: OwnClaim[]; outstanding: OwnClaim[];
|
||||
rows: { date: string; amount: number }[]; dropped: string[]; expected: number;
|
||||
} {
|
||||
const withdrawals = o.txns.filter((t) => t.type === VEST_TXN.WITHDRAW).map((t) => ({ at: t.time, amount: t.amount }));
|
||||
const { pending, lapsed } = settleClaims(o.claims, withdrawals, o.readAt);
|
||||
const { rows, dropped } = claimFundRows({ funds: o.funds, claims: o.claims.filter((c) => !lapsed.includes(c)), withdrawals, stored: o.stored });
|
||||
const unbooked = pending.reduce((s, c) => s + c.amount, 0);
|
||||
const expected = o.txns.reduce((s, t) => s + (t.kind === 'margin' ? 0 : t.amount), 0) + o.opening - unbooked;
|
||||
return { withdrawals, lapsed, outstanding: pending, rows, dropped, expected };
|
||||
}
|
||||
|
||||
/** Each payout taken from the account, by its Vest day, one entry per
|
||||
* payout: every WITHDRAW Vest's transactions show (claims made by hand
|
||||
* among them), and each claim of this client's they don't show yet, on the
|
||||
* day it was booked — a fund row can't say how many, it holds a whole Vest
|
||||
* day's. A day whose fund rows net negative with neither still counts once,
|
||||
* as it did before. Sorted. */
|
||||
export function payoutDaysOf(withdrawals: VestWithdrawal[], claims: OwnClaim[], fundRows: { date: string; amount: number }[]): string[] {
|
||||
const days = [
|
||||
...withdrawals.map((w) => vestDayOf(w.at)),
|
||||
...claims.filter((c) => !c.unknown && !claimWithdrawal(withdrawals, c)).map((c) => vestDayOf(c.at)),
|
||||
];
|
||||
for (const f of fundRows) if (f.amount < 0 && !days.includes(f.date)) days.push(f.date);
|
||||
return days.sort();
|
||||
}
|
||||
|
||||
/** Payouts since `since` (the last 20:00 ET): this client's claims — each at
|
||||
* its WITHDRAW's time once Vest shows it, Vest's day being the one it
|
||||
* counts on, and until then at when it was sent — or the WITHDRAWs Vest
|
||||
* shows, whichever counts more (a claim made by hand shows only there).
|
||||
* Sent, not answered: Vest dates it no earlier (clocks in sync), so a
|
||||
* claim answered just after 20:00 ET can't put the fresh day on a payout
|
||||
* stage before Vest says it's that day's; one Vest dates after the turn
|
||||
* reads as the day before until then, the cautious way round. */
|
||||
export function payoutsSince(withdrawals: VestWithdrawal[], claims: OwnClaim[], since: number): number {
|
||||
const mine = claims.filter((c) => !c.unknown && (claimWithdrawal(withdrawals, c)?.at ?? c.sentAt ?? c.at) >= since).length;
|
||||
const seen = withdrawals.filter((w) => w.at >= since).length;
|
||||
return Math.max(mine, seen);
|
||||
}
|
||||
|
||||
// ── market data ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Fewest hourly returns a volatility is measured from. */
|
||||
@@ -488,7 +774,19 @@ export function hourlyVolatility(klines: unknown[]): number | null {
|
||||
*/
|
||||
export function marketOpen(periods: unknown, now: number): { open: boolean; closesAt: number | null } {
|
||||
if (!Array.isArray(periods) || periods.length === 0) return { open: true, closesAt: null };
|
||||
const list = periods.flatMap((p) => {
|
||||
const list = hoursPeriods(periods);
|
||||
const i = list.findIndex((p) => p.from <= now && now < p.to);
|
||||
if (i < 0 || !list[i].open) return { open: false, closesAt: null };
|
||||
// Consecutive open periods (overnight → pre-market → regular …) are one stretch.
|
||||
let end = list[i].to;
|
||||
for (let j = i + 1; j < list.length && list[j].open && list[j].from <= end + 1; j++) end = list[j].to;
|
||||
return { open: true, closesAt: end };
|
||||
}
|
||||
|
||||
/** The market-hours periods, read and in order; open = trading, with no
|
||||
* closure (see marketOpen). */
|
||||
function hoursPeriods(periods: unknown[]): { from: number; to: number; open: boolean }[] {
|
||||
return periods.flatMap((p) => {
|
||||
const r = p as {
|
||||
fromMs?: unknown; toMs?: unknown;
|
||||
state?: { tradingStatus?: unknown; closure?: unknown; price?: { allow?: unknown } };
|
||||
@@ -501,10 +799,20 @@ export function marketOpen(periods: unknown, now: number): { open: boolean; clos
|
||||
&& (closure === undefined || closure === 'unspecified');
|
||||
return [{ from, to, open }];
|
||||
}).sort((a, b) => a.from - b.from);
|
||||
const i = list.findIndex((p) => p.from <= now && now < p.to);
|
||||
if (i < 0 || !list[i].open) return { open: false, closesAt: null };
|
||||
// Consecutive open periods (overnight → pre-market → regular …) are one stretch.
|
||||
let end = list[i].to;
|
||||
for (let j = i + 1; j < list.length && list[j].open && list[j].from <= end + 1; j++) end = list[j].to;
|
||||
return { open: true, closesAt: end };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the closure that starts at `closesAt` (marketOpen's) keeps the
|
||||
* market shut across one of Vest's 8 PM ET resets — a weekend, a holiday —
|
||||
* or for as far as the periods go. A position held into it can't be exited
|
||||
* or fitted to that reset until the market reopens, and meets the reopen's
|
||||
* gap with its stop; the daily maintenance hour reopens before the reset.
|
||||
* A close the periods don't show (they just end there) isn't one.
|
||||
*/
|
||||
export function longClosure(periods: unknown, closesAt: number): boolean {
|
||||
if (!Array.isArray(periods)) return false;
|
||||
const list = hoursPeriods(periods);
|
||||
if (!list.some((p) => p.to > closesAt)) return false;
|
||||
const reopensAt = list.find((p) => p.open && p.from >= closesAt)?.from;
|
||||
return reopensAt === undefined || nextVestResetMs(closesAt) < reopensAt;
|
||||
}
|
||||
|
||||
+36
-1
@@ -32,12 +32,30 @@ export interface VestSessionInfo {
|
||||
receivedAt: number;
|
||||
}
|
||||
|
||||
const g = globalThis as typeof globalThis & { __vestSessions?: Map<string, VestSession> };
|
||||
const g = globalThis as typeof globalThis & {
|
||||
__vestSessions?: Map<string, VestSession>;
|
||||
__vestPrevious?: Map<string, VestSession>;
|
||||
__vestRefused?: Map<string, number>;
|
||||
};
|
||||
|
||||
function store(): Map<string, VestSession> {
|
||||
return (g.__vestSessions ??= new Map());
|
||||
}
|
||||
|
||||
/** The session each wallet's current one replaced — what a refused token
|
||||
* falls back to. */
|
||||
function previous(): Map<string, VestSession> {
|
||||
return (g.__vestPrevious ??= new Map());
|
||||
}
|
||||
|
||||
/** Tokens the client found to be one account's, not the login's (by their
|
||||
* tail — enough to tell them apart, never the whole token), with when. */
|
||||
function refused(): Map<string, number> {
|
||||
return (g.__vestRefused ??= new Map());
|
||||
}
|
||||
|
||||
const REFUSED_KEEP = 50;
|
||||
|
||||
/** Shortest life a relayed token must have left to be taken. */
|
||||
const MIN_REMAINING_MS = 30_000;
|
||||
/** Longest a relayed token may claim to live — a Vest access token is
|
||||
@@ -54,8 +72,10 @@ export function putVestSession(input: { accessToken: unknown; expiresAt: unknown
|
||||
if (!/^0x[0-9a-f]{40}$/.test(addr)) return 'primaryAddr is not a wallet address';
|
||||
if (!Number.isFinite(expiresAt) || expiresAt - now < MIN_REMAINING_MS) return 'token already expired';
|
||||
if (expiresAt - now > MAX_REMAINING_MS) return 'expiresAt is implausibly far away';
|
||||
if (refused().has(token.slice(-32))) return "that is one Vest account's token, not the login's — reload the Vest tab, or update the extension";
|
||||
const prev = store().get(addr);
|
||||
if (prev && prev.expiresAt > expiresAt && prev.accessToken !== token) return null; // keep the fresher one
|
||||
if (prev && prev.accessToken !== token) previous().set(addr, prev);
|
||||
store().set(addr, {
|
||||
accessToken: token,
|
||||
expiresAt,
|
||||
@@ -73,6 +93,21 @@ export function vestSessionFor(primaryAddr: string, now = Date.now(), minRemaini
|
||||
return s && s.expiresAt - now >= minRemainingMs ? s : null;
|
||||
}
|
||||
|
||||
/** The client found the wallet's token to be one account's own (see
|
||||
* accountScopedToken): drop it, and take it no more if it's relayed again —
|
||||
* the login's own token, when the Vest app next renews it, takes its place. */
|
||||
export function refuseVestSession(primaryAddr: string, token: string, now = Date.now()): void {
|
||||
const key = primaryAddr.trim().toLowerCase();
|
||||
refused().set(token.slice(-32), now);
|
||||
while (refused().size > REFUSED_KEEP) refused().delete(refused().keys().next().value as string);
|
||||
if (store().get(key)?.accessToken !== token) return;
|
||||
store().delete(key);
|
||||
// Back to the token it replaced, if that one is still good.
|
||||
const before = previous().get(key);
|
||||
previous().delete(key);
|
||||
if (before && !refused().has(before.accessToken.slice(-32)) && before.expiresAt - now >= MIN_REMAINING_MS) store().set(key, before);
|
||||
}
|
||||
|
||||
/** Drop a wallet's session — the gateway refused it. */
|
||||
export function dropVestSession(primaryAddr: string, token: string): void {
|
||||
const key = primaryAddr.trim().toLowerCase();
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"check": "node --experimental-strip-types --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/check-trading-logic.mjs scripts/check-instruments.mjs scripts/check-account-status.mjs scripts/check-kept-rules.mjs scripts/check-request-user-agent.mjs scripts/check-vest.mjs"
|
||||
"check": "node --experimental-strip-types --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/check-trading-logic.mjs scripts/check-instruments.mjs scripts/check-account-status.mjs scripts/check-kept-rules.mjs scripts/check-request-user-agent.mjs scripts/check-vest.mjs scripts/check-vest-client.mjs scripts/check-trades.mjs scripts/check-catalog.mjs scripts/check-extension-vest.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@microsoft/signalr": "^10.0.11",
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// Checks for the firm catalog sync in lib/firm-catalog.ts, against a stand-in
|
||||
// master on a local port and a throwaway database — never the live DB or the
|
||||
// real master.
|
||||
//
|
||||
// npm run check
|
||||
import { after, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { createServer } from 'node:http';
|
||||
import { once } from 'node:events';
|
||||
|
||||
// Resolve the extensionless local imports used by Next, without a build.
|
||||
function resolveTypes(specifier, context, nextResolve) {
|
||||
try {
|
||||
return nextResolve(specifier, context);
|
||||
} catch (err) {
|
||||
if (err.code !== 'ERR_MODULE_NOT_FOUND' || !specifier.startsWith('.')) throw err;
|
||||
return nextResolve(`${specifier}.ts`, context);
|
||||
}
|
||||
}
|
||||
registerHooks({ resolve: resolveTypes });
|
||||
|
||||
const directory = mkdtempSync(join(tmpdir(), 'autofirmer-catalog-'));
|
||||
process.env.AUTOFIRMER_DB = join(directory, 'test.sqlite');
|
||||
after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
|
||||
const { getSetting, setSetting } = await import('../lib/db.ts');
|
||||
const { syncCatalog, getCachedCatalog, getKeptRuleIdsForFirm, CATALOG_PARSER_VERSION } = await import('../lib/firm-catalog.ts');
|
||||
|
||||
const firm = (id, name, platform) => ({
|
||||
id, name, platform, keptAccountRules: [], updatedAt: '2026-10-26T19:00:00.000Z',
|
||||
accountTypes: [{
|
||||
id: `${id}-50k`, firmId: id, label: '50k', prefix: `${id.toUpperCase()}-`, updatedAt: '2026-10-26T19:00:00.000Z',
|
||||
accountSize: 50000, profitTarget: 3000, consistency: 0, minDayPnL: 0, minTradingDays: 0, maxLoss: 3000,
|
||||
maxPositionSize: 0, withdrawalStages: [],
|
||||
}],
|
||||
});
|
||||
const apex = firm('apex', 'Apex', 'tradovate');
|
||||
const vest = firm('vest', 'VestMarkets', 'vest');
|
||||
|
||||
// The master's catalog, answering ?since= as app/api/firms/catalog does, and
|
||||
// the `since` each sync sent (null = a full fetch).
|
||||
let served = { version: 41, updatedAt: '2026-10-26T19:20:00.000Z', firms: [apex, vest] };
|
||||
const sinceSent = [];
|
||||
const server = createServer((req, res) => {
|
||||
const since = new URL(req.url, 'http://master').searchParams.get('since');
|
||||
sinceSent.push(since);
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(since !== null && Number(since) === served.version
|
||||
? { version: served.version, updatedAt: served.updatedAt, unchanged: true }
|
||||
: served));
|
||||
});
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
after(() => server.close());
|
||||
const master = `http://127.0.0.1:${server.address().port}`;
|
||||
|
||||
/** This machine's cache as some build stored it, read afresh as after a restart. */
|
||||
function storedCache(payload) {
|
||||
setSetting('master_dashboard_url', master);
|
||||
setSetting('catalog_source_url', master);
|
||||
setSetting('catalog_version', String(payload.version));
|
||||
setSetting('catalog_payload', JSON.stringify(payload));
|
||||
globalThis.__firmCatalog = undefined;
|
||||
sinceSent.length = 0;
|
||||
}
|
||||
|
||||
const names = () => getCachedCatalog().map((f) => f.name);
|
||||
|
||||
test('a cache a pre-Vest build stored is fetched in full by a build that knows Vest', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
// Parser 2 skipped the Vest firm and said nothing of it; the master's
|
||||
// catalog hasn't changed since, so ?since= would answer "unchanged".
|
||||
storedCache({ version: 41, updatedAt: served.updatedAt, firms: [apex], parser: 2 });
|
||||
assert.equal((await syncCatalog()).ok, true);
|
||||
assert.deepEqual(sinceSent, [null]);
|
||||
assert.deepEqual(names(), ['Apex', 'VestMarkets']);
|
||||
// Current from then on: the next sync asks ?since= again.
|
||||
await syncCatalog();
|
||||
assert.deepEqual(sinceSent, [null, '41']);
|
||||
});
|
||||
|
||||
test('a skipped platform is named in the cache, and a build that knows it fetches in full', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
// The master adds a firm on a platform this build doesn't know.
|
||||
served = { version: 42, updatedAt: '2026-10-27T15:00:00.000Z', firms: [apex, vest, firm('px', 'PX Firm', 'projectx')] };
|
||||
storedCache({ version: 41, updatedAt: '2026-10-26T19:20:00.000Z', firms: [apex, vest], parser: CATALOG_PARSER_VERSION });
|
||||
await syncCatalog();
|
||||
assert.deepEqual(names(), ['Apex', 'VestMarkets']);
|
||||
assert.deepEqual(JSON.parse(getSetting('catalog_payload')).skippedPlatforms, ['projectx']);
|
||||
// While this build still doesn't know it, syncs stay incremental, and the
|
||||
// cache still vouches for the kept rule sets of the firms it holds.
|
||||
globalThis.__firmCatalog = undefined;
|
||||
sinceSent.length = 0;
|
||||
await syncCatalog();
|
||||
assert.deepEqual(sinceSent, ['42']);
|
||||
assert.deepEqual(getKeptRuleIdsForFirm('apex'), []);
|
||||
// A build that knows the platform it skipped (Vest here) fetches in full.
|
||||
storedCache({ version: 42, updatedAt: served.updatedAt, firms: [apex], parser: CATALOG_PARSER_VERSION, skippedPlatforms: ['vest'] });
|
||||
await syncCatalog();
|
||||
assert.deepEqual(sinceSent, [null]);
|
||||
assert.ok(names().includes('VestMarkets'));
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
// Checks for the extension's Vest relay: the hook in the Vest tab
|
||||
// (extension/vest-relay.js) and the choice of which tab's token the background
|
||||
// relays (extension/background.js pageVestSession / pickVestSession).
|
||||
//
|
||||
// npm run check
|
||||
//
|
||||
// The hook runs in a sandbox standing in for the Vest page — its own XHR and
|
||||
// fetch, answered here; the background's functions are lifted out of its
|
||||
// source, since it is a service worker and not a module. Nothing reaches Vest
|
||||
// or Chrome. The answers are shaped like the Vest app's own (index bundle,
|
||||
// 2026-10-29): a renewal's carries refreshTokenId, a sign-in's the login's
|
||||
// refreshToken too, an account's renewal a refreshToken in its request.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const read = (path) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8');
|
||||
const HOOK = read('extension/vest-relay.js');
|
||||
const BACKGROUND = read('extension/background.js');
|
||||
|
||||
const GATEWAY = 'https://api-gateway.hz.vestmarkets.com';
|
||||
const WALLET = '0x7ce10771819f80130e204ddb5ca1522e48a9b451';
|
||||
const answer = (extra) => JSON.stringify({ accessToken: 'eyJ.login.sig', accessExpiresAtMs: 1_800_000_000_000, primaryAddr: WALLET, userId: 'u', ...extra });
|
||||
|
||||
/** A Vest page with the hook run in it (after `before`, a hook already
|
||||
* there): `xhr(url, body, status, text)` and `fetch(...)` play one of the
|
||||
* app's requests through it. */
|
||||
function page(before = {}) {
|
||||
class FakeXHR {
|
||||
constructor() { this.listeners = []; this.responseType = ''; }
|
||||
open(method, url) { this.url = url; }
|
||||
send() {}
|
||||
addEventListener(type, fn) { if (type === 'loadend') this.listeners.push(fn); }
|
||||
}
|
||||
const answers = [];
|
||||
const sandbox = {
|
||||
XMLHttpRequest: FakeXHR, URL,
|
||||
fetch: async () => answers.shift(),
|
||||
...before,
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(HOOK, sandbox);
|
||||
return {
|
||||
sandbox,
|
||||
run: () => vm.runInContext(HOOK, sandbox),
|
||||
xhr(url, body, status, text) {
|
||||
const x = new sandbox.XMLHttpRequest();
|
||||
x.open('POST', url);
|
||||
x.send(body);
|
||||
x.status = status;
|
||||
x.responseText = text;
|
||||
for (const fn of x.listeners) fn();
|
||||
},
|
||||
async fetch(url, body, status, text) {
|
||||
answers.push({ status, clone: () => ({ text: async () => text }) });
|
||||
await sandbox.window.fetch(url, { method: 'POST', body });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("the hook keeps the login's renewed token with its session id, and an account's never", async () => {
|
||||
const p = page();
|
||||
assert.equal(p.sandbox.__afVestHook, 2);
|
||||
p.xhr(`${GATEWAY}/v3/auth/refresh`, JSON.stringify({ requestId: 'r1', rememberMe: true }), 200, answer({ refreshTokenId: 'rt-1' }));
|
||||
assert.equal(p.sandbox.__afVestLogin.accessToken, 'eyJ.login.sig');
|
||||
assert.equal(p.sandbox.__afVestLogin.refreshTokenId, 'rt-1');
|
||||
assert.equal(p.sandbox.__afVestLogin.primaryAddr, WALLET);
|
||||
|
||||
// An account's renewal, by its request, then by its answer alone.
|
||||
p.xhr(`${GATEWAY}/v3/auth/refresh`, JSON.stringify({ refreshToken: 'acct-rt', requestId: 'r2' }), 200, answer({ accessToken: 'eyJ.acct.sig', refreshTokenId: 'rt-a' }));
|
||||
await p.fetch(`${GATEWAY}/v3/auth/refresh`, undefined, 200, answer({ accessToken: 'eyJ.acct2.sig', refreshToken: 'acct-rt', refreshTokenId: 'rt-b' }));
|
||||
assert.equal(p.sandbox.__afVestLogin.accessToken, 'eyJ.login.sig');
|
||||
|
||||
// The login's next renewal, through fetch; a refused one leaves it be.
|
||||
await p.fetch(`${GATEWAY}/v3/auth/refresh`, JSON.stringify({ requestId: 'r3' }), 200, answer({ accessToken: 'eyJ.login2.sig', refreshTokenId: 'rt-2' }));
|
||||
p.xhr(`${GATEWAY}/v3/auth/refresh`, JSON.stringify({ requestId: 'r4' }), 401, answer({ accessToken: 'eyJ.no.sig', refreshTokenId: 'rt-x' }));
|
||||
assert.equal(p.sandbox.__afVestLogin.accessToken, 'eyJ.login2.sig');
|
||||
assert.equal(p.sandbox.__afVestLogin.refreshTokenId, 'rt-2');
|
||||
});
|
||||
|
||||
test("a sign-in's token is taken, though its answer carries the login's refresh token", () => {
|
||||
const p = page();
|
||||
p.xhr(`${GATEWAY}/v3/auth/register`, JSON.stringify({ primaryAddr: WALLET }), 200, answer({ accessToken: 'eyJ.signin.sig', refreshToken: 'login-rt', refreshTokenId: 'rt-s' }));
|
||||
assert.equal(p.sandbox.__afVestLogin.accessToken, 'eyJ.signin.sig');
|
||||
assert.equal(p.sandbox.__afVestLogin.refreshTokenId, 'rt-s');
|
||||
});
|
||||
|
||||
test('the hook installs once, and beside a version-1 hook', () => {
|
||||
const p = page({ __afVestHook: true, __afVestSession: { accessToken: 'eyJ.old.sig', expiresAt: 1 } });
|
||||
assert.equal(p.sandbox.__afVestHook, 2);
|
||||
const send = p.sandbox.XMLHttpRequest.prototype.send;
|
||||
p.run();
|
||||
assert.equal(p.sandbox.XMLHttpRequest.prototype.send, send);
|
||||
});
|
||||
|
||||
// The background's own functions, out of its source.
|
||||
function lift(name) {
|
||||
const m = BACKGROUND.match(new RegExp(`^function ${name}\\([\\s\\S]*?^}`, 'm'));
|
||||
assert.ok(m, `${name} not found in background.js`);
|
||||
return m[0];
|
||||
}
|
||||
const { isCurrentVestSession, pickVestSession } = new Function(`${lift('isCurrentVestSession')}\n${lift('pickVestSession')}\nreturn { isCurrentVestSession, pickVestSession };`)();
|
||||
const pageVestSession = (window, stored) => new Function('window', 'localStorage', `${lift('pageVestSession')}\nreturn pageVestSession();`)(
|
||||
window, { getItem: (key) => (key === 'vest-auth-session' ? stored : null) },
|
||||
);
|
||||
|
||||
const later = (min) => Date.now() + min * 60_000;
|
||||
const session = (token, expiresAt, refreshTokenId) => ({ accessToken: token, expiresAt, primaryAddr: WALLET, userId: 'u', ...(refreshTokenId !== undefined ? { refreshTokenId } : {}) });
|
||||
const record = (refreshTokenId, accessTokenExpiresAt) => JSON.stringify({ refreshTokenId, accessTokenExpiresAt, refreshTokenExpiresAt: later(43200), rememberMe: true });
|
||||
|
||||
test("a tab reports its live token (version 2's, else version 1's) and the app's current session", () => {
|
||||
const expiresAt = later(12);
|
||||
let out = pageVestSession({ __afVestHook: 2, __afVestLogin: session('eyJ.v2.sig', expiresAt, 'rt-2'), __afVestSession: session('eyJ.v1.sig', expiresAt) }, record('rt-2', expiresAt));
|
||||
assert.equal(out.hooked, true);
|
||||
assert.equal(out.session.accessToken, 'eyJ.v2.sig');
|
||||
assert.deepEqual(out.current, { refreshTokenId: 'rt-2', accessTokenExpiresAt: expiresAt });
|
||||
|
||||
// Hooked by version 1 only: its token, and the tab asks for version 2.
|
||||
out = pageVestSession({ __afVestHook: true, __afVestSession: session('eyJ.v1.sig', later(9)) }, record('rt-3', later(14)));
|
||||
assert.equal(out.hooked, false);
|
||||
assert.equal(out.session.accessToken, 'eyJ.v1.sig');
|
||||
|
||||
// A 0.17.0 hook kept an account's renewal too: version 2's login token
|
||||
// wins over it, though it expires sooner.
|
||||
out = pageVestSession({ __afVestHook: 2, __afVestLogin: session('eyJ.v2.sig', later(5), 'rt-1'), __afVestSession: session('eyJ.acct.sig', later(13)) }, null);
|
||||
assert.equal(out.session.accessToken, 'eyJ.v2.sig');
|
||||
assert.equal(out.current, null);
|
||||
|
||||
// Nearly expired, or a record that doesn't read.
|
||||
out = pageVestSession({ __afVestHook: 2, __afVestLogin: session('eyJ.v2.sig', Date.now() + 20_000, 'rt-1') }, '{nope');
|
||||
assert.equal(out.session, null);
|
||||
assert.equal(out.current, null);
|
||||
});
|
||||
|
||||
test("the token relayed is the one the current session holds, whichever tab has it — not the first tab's", () => {
|
||||
// Tab 1 renewed first; tab 2 renewed after it, so the session is tab 2's.
|
||||
const tab1 = { hooked: true, session: session('eyJ.tab1.sig', later(6), 'rt-1'), current: { refreshTokenId: 'rt-2', accessTokenExpiresAt: later(13) } };
|
||||
const tab2 = { hooked: true, session: session('eyJ.tab2.sig', later(13), 'rt-2'), current: { refreshTokenId: 'rt-2', accessTokenExpiresAt: later(13) } };
|
||||
assert.deepEqual(pickVestSession([tab1, tab2]), { session: tab2.session, current: true });
|
||||
assert.equal(isCurrentVestSession(tab1.session, tab1.current), false);
|
||||
|
||||
// Tab 1 renews again: now its token is the current one.
|
||||
const rt3 = { refreshTokenId: 'rt-3', accessTokenExpiresAt: later(15) };
|
||||
const tab1b = { ...tab1, session: session('eyJ.tab1b.sig', later(15), 'rt-3'), current: rt3 };
|
||||
assert.equal(pickVestSession([{ ...tab2, current: rt3 }, tab1b]).session.accessToken, 'eyJ.tab1b.sig');
|
||||
|
||||
// Version-1 hooks keep no id: matched by the expiry the app keeps with it.
|
||||
const v1a = { hooked: false, session: session('eyJ.v1a.sig', later(4)), current: { refreshTokenId: 'rt-9', accessTokenExpiresAt: later(11) } };
|
||||
const v1b = { hooked: false, session: session('eyJ.v1b.sig', v1a.current.accessTokenExpiresAt), current: v1a.current };
|
||||
assert.deepEqual(pickVestSession([v1a, v1b]), { session: v1b.session, current: true });
|
||||
});
|
||||
|
||||
test('with no tab holding the current session the newest is relayed, and said; with no record, unjudged', () => {
|
||||
const current = { refreshTokenId: 'rt-5', accessTokenExpiresAt: later(14) };
|
||||
const a = { hooked: true, session: session('eyJ.a.sig', later(3), 'rt-3'), current };
|
||||
const b = { hooked: true, session: session('eyJ.b.sig', later(8), 'rt-4'), current };
|
||||
assert.deepEqual(pickVestSession([a, b]), { session: b.session, current: false });
|
||||
assert.deepEqual(pickVestSession([{ ...a, current: null }, { ...b, current: null }]), { session: b.session, current: null });
|
||||
// A tab with no token yet still shows the record.
|
||||
assert.equal(pickVestSession([{ hooked: true, session: null, current }, { ...b, current: null }]).current, false);
|
||||
assert.equal(pickVestSession([{ hooked: true, session: null, current }]), null);
|
||||
assert.equal(pickVestSession([]), null);
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
// Checks for building trades out of fills (lib/fill-trades.ts — TopstepX and
|
||||
// Tradovate) and out of DXtrade's order history (lib/dxtrade-helpers.ts).
|
||||
//
|
||||
// npm run check
|
||||
//
|
||||
// The files import only Node built-ins, so Node strips their types and loads
|
||||
// them directly; .mjs keeps this file out of Next's type check.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { fillsToTrades, startingNet, contractRoot, byFillTime } from '../lib/fill-trades.ts';
|
||||
import { topstepxSymbol } from '../lib/topstep-fees.ts';
|
||||
import { parseHistoryOrder, dxTrades } from '../lib/dxtrade-helpers.ts';
|
||||
|
||||
let n = 0;
|
||||
const fill = (side, qty, price, extra = {}) => {
|
||||
n += 1;
|
||||
return { id: String(1000 + n), orderId: String(5000 + n), at: 1_000 + n * 1_000, side, qty, price, fee: 1, pnl: null, ...extra };
|
||||
};
|
||||
|
||||
test('a round trip, with the platform’s P&L', () => {
|
||||
const a = fill('buy', 2, 100);
|
||||
const b = fill('sell', 2, 101, { pnl: 40 });
|
||||
const [t, ...rest] = fillsToTrades([b, a], { startNet: 0, pointValue: null });
|
||||
assert.equal(rest.length, 0);
|
||||
assert.deepEqual(t, {
|
||||
id: a.id, side: 'long', quantity: 2, openPrice: 100, closePrice: 101, pnl: 40, fee: 2,
|
||||
openedAt: a.at, closedAt: b.at, openOrderId: a.orderId, closeOrderId: b.orderId, carried: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('P&L from the prices when the platform gives none; adds and partial closes stay one trade', () => {
|
||||
const fills = [fill('buy', 1, 100), fill('buy', 1, 102), fill('sell', 1, 103), fill('sell', 1, 104)];
|
||||
const [t, ...rest] = fillsToTrades(fills, { startNet: 0, pointValue: 20 });
|
||||
assert.equal(rest.length, 0);
|
||||
assert.equal(t.quantity, 2);
|
||||
assert.equal(t.openPrice, 101);
|
||||
assert.equal(t.closePrice, 103.5);
|
||||
assert.equal(t.pnl, 100);
|
||||
assert.equal(t.fee, 4);
|
||||
assert.equal(t.closeOrderId, fills[3].orderId);
|
||||
// No point value and no platform P&L: unknown, not zero.
|
||||
assert.equal(fillsToTrades(fills, { startNet: 0, pointValue: null })[0].pnl, null);
|
||||
});
|
||||
|
||||
test('a short', () => {
|
||||
const fills = [fill('sell', 3, 50.5), fill('buy', 3, 50)];
|
||||
const [t] = fillsToTrades(fills, { startNet: 0, pointValue: 1000 });
|
||||
assert.equal(t.side, 'short');
|
||||
assert.equal(t.pnl, 1500);
|
||||
});
|
||||
|
||||
test('a fill through zero closes one trade and opens the next', () => {
|
||||
const a = fill('buy', 1, 100);
|
||||
const b = fill('sell', 3, 105, { fee: 3, pnl: 100 });
|
||||
const [first, second, ...rest] = fillsToTrades([a, b], { startNet: 0, pointValue: 20 });
|
||||
assert.equal(rest.length, 0);
|
||||
assert.equal(first.side, 'long');
|
||||
assert.equal(first.pnl, 100);
|
||||
assert.equal(first.fee, 2); // its own buy, and a third of the reversing fill
|
||||
assert.equal(first.closeOrderId, b.orderId);
|
||||
assert.equal(second.id, b.id);
|
||||
assert.equal(second.side, 'short');
|
||||
assert.equal(second.quantity, 2);
|
||||
assert.equal(second.openPrice, 105);
|
||||
assert.equal(second.fee, 2);
|
||||
assert.equal(second.closedAt, null);
|
||||
assert.equal(second.pnl, null);
|
||||
assert.equal(second.closePrice, null);
|
||||
});
|
||||
|
||||
test('fills that start inside a position: passed over, or carried on', () => {
|
||||
const close = fill('sell', 1, 110, { pnl: 200 });
|
||||
const a = fill('buy', 1, 100);
|
||||
const b = fill('sell', 1, 101, { pnl: 20 });
|
||||
// Nothing stored: the leading close belongs to nothing on record.
|
||||
const plain = fillsToTrades([close, a, b], { startNet: 1, pointValue: 20 });
|
||||
assert.equal(plain.length, 1);
|
||||
assert.equal(plain[0].id, a.id);
|
||||
// The stored open trade carries on and closes.
|
||||
const carry = { id: 'tsx:42', side: 'long', quantity: 1, openPrice: 100, fee: 0.5, openedAt: 10 };
|
||||
const [carried, next] = fillsToTrades([close, a, b], { startNet: 1, pointValue: 20, carry });
|
||||
assert.equal(carried.id, 'tsx:42');
|
||||
assert.equal(carried.carried, true);
|
||||
assert.equal(carried.openedAt, 10);
|
||||
assert.equal(carried.openOrderId, null);
|
||||
assert.equal(carried.pnl, 200);
|
||||
assert.equal(carried.fee, 1.5);
|
||||
assert.equal(carried.closedAt, close.at);
|
||||
assert.equal(next.id, a.id);
|
||||
// A carry on the other side isn't this position.
|
||||
assert.equal(fillsToTrades([close], { startNet: 1, pointValue: 20, carry: { ...carry, side: 'short' } }).length, 0);
|
||||
});
|
||||
|
||||
test('the position before the fills', () => {
|
||||
const buy = fill('buy', 1, 1);
|
||||
const sell = fill('sell', 1, 1);
|
||||
assert.equal(startingNet([buy, sell], 0), 0);
|
||||
assert.equal(startingNet([buy], 1), 0);
|
||||
assert.equal(startingNet([sell], 0), 1);
|
||||
assert.equal(startingNet([], -2), -2);
|
||||
});
|
||||
|
||||
test('fills sort by time, then numerically by id', () => {
|
||||
const a = { id: '9', at: 5 };
|
||||
const b = { id: '10', at: 5 };
|
||||
const c = { id: '1', at: 6 };
|
||||
assert.deepEqual([c, b, a].sort(byFillTime).map((f) => f.id), ['9', '10', '1']);
|
||||
});
|
||||
|
||||
test('contract roots and TopstepX names', () => {
|
||||
assert.equal(contractRoot('MNQZ6'), 'MNQ');
|
||||
assert.equal(contractRoot('ZNZ6'), 'ZN');
|
||||
assert.equal(contractRoot('NGZ6'), 'NG');
|
||||
assert.equal(contractRoot('6EZ6'), '6E');
|
||||
assert.equal(contractRoot('M2KH27'), 'M2K');
|
||||
assert.equal(contractRoot('CL'), 'CL');
|
||||
assert.equal(topstepxSymbol('CON.F.US.ENQ.Z26'), 'NQZ6');
|
||||
assert.equal(topstepxSymbol('CON.F.US.MNQ.H27'), 'MNQH7');
|
||||
assert.equal(topstepxSymbol('CON.F.US.XYZ.M26'), 'XYZM6');
|
||||
assert.equal(topstepxSymbol('something'), 'something');
|
||||
});
|
||||
|
||||
// DXtrade: a bot round trip on BTC/USD, as /orders/history and /transfers
|
||||
// answer (position-based account: the opening order's id is the position).
|
||||
const openOrder = {
|
||||
account: 'default:T247-1', orderId: 555, orderCode: 'af-mh2-abc-e', type: 'MARKET', instrument: 'BTC/USD',
|
||||
status: 'COMPLETED', finalStatus: true,
|
||||
legs: [{ instrument: 'BTC/USD', positionEffect: 'OPEN', side: 'BUY', quantity: 0.5, filledQuantity: 0.5, averagePrice: 60000 }],
|
||||
issueTime: '2026-10-26T01:00:00.000Z', transactionTime: '2026-10-26T01:00:01.000Z',
|
||||
executions: [{ transactionTime: '2026-10-26T01:00:00.500Z', lastQuantity: 0.5, averagePrice: 60000 }],
|
||||
};
|
||||
const stopOrder = {
|
||||
account: 'default:T247-1', orderId: 556, orderCode: 'af-mh3-def-sl', type: 'STOP', instrument: 'BTC/USD',
|
||||
status: 'COMPLETED', finalStatus: true,
|
||||
legs: [{ instrument: 'BTC/USD', positionEffect: 'CLOSE', positionCode: '555', side: 'SELL', filledQuantity: 0.5, averagePrice: 59000 }],
|
||||
issueTime: '2026-10-26T01:00:02.000Z', transactionTime: '2026-10-26T02:00:00.000Z', executions: [],
|
||||
};
|
||||
const txn = (type, value, orderCode) => ({ key: `${orderCode}|${type}`, account: 'default:T247-1', type, value, time: 0, orderCode });
|
||||
|
||||
test('DXtrade order history rows', () => {
|
||||
const o = parseHistoryOrder(openOrder);
|
||||
assert.equal(o.orderCode, 'af-mh2-abc-e');
|
||||
assert.equal(o.effect, 'OPEN');
|
||||
assert.equal(o.side, 'BUY');
|
||||
assert.equal(o.positionCode, undefined);
|
||||
assert.equal(o.qty, 0.5);
|
||||
assert.equal(o.price, 60000);
|
||||
assert.equal(o.at, Date.parse('2026-10-26T01:00:00.500Z'));
|
||||
const s = parseHistoryOrder(stopOrder);
|
||||
assert.equal(s.positionCode, '555');
|
||||
assert.equal(s.at, Date.parse('2026-10-26T02:00:00.000Z'));
|
||||
// Never filled: not a trade's.
|
||||
assert.equal(parseHistoryOrder({ ...openOrder, legs: [{ ...openOrder.legs[0], filledQuantity: 0 }] }), null);
|
||||
assert.equal(parseHistoryOrder({}), null);
|
||||
});
|
||||
|
||||
test('DXtrade positions from their orders and cash', () => {
|
||||
const orders = [parseHistoryOrder(stopOrder), parseHistoryOrder(openOrder)];
|
||||
const txns = [txn('SETTLEMENT', -500, 'af-mh3-def-sl'), txn('COMMISSION', -12, 'af-mh2-abc-e'), txn('COMMISSION', -11.8, 'af-mh3-def-sl')];
|
||||
const [t, ...rest] = dxTrades(orders, txns, new Map(), new Map());
|
||||
assert.equal(rest.length, 0);
|
||||
assert.equal(t.positionCode, '555');
|
||||
assert.equal(t.side, 'long');
|
||||
assert.equal(t.quantity, 0.5);
|
||||
assert.equal(t.openPrice, 60000);
|
||||
assert.equal(t.closePrice, 59000);
|
||||
assert.equal(t.pnl, -500);
|
||||
assert.equal(t.fee, 23.8);
|
||||
assert.equal(t.openCode, 'af-mh2-abc-e');
|
||||
assert.equal(t.closeCode, 'af-mh3-def-sl');
|
||||
assert.equal(t.closeType, 'STOP');
|
||||
|
||||
// Still held: open, whatever the history says.
|
||||
const held = new Map([['555', { symbol: 'BTC/USD', side: 'long', quantity: 0.5, openPrice: 60000, openedAt: 1 }]]);
|
||||
const [open] = dxTrades([parseHistoryOrder(openOrder)], [], held, new Map());
|
||||
assert.equal(open.closedAt, null);
|
||||
assert.equal(open.pnl, null);
|
||||
|
||||
// Opened before the history: completed from what's stored, or left out.
|
||||
const [late] = dxTrades([parseHistoryOrder(stopOrder)], txns, new Map(), held);
|
||||
assert.equal(late.openedAt, 1);
|
||||
assert.equal(late.openCode, null);
|
||||
assert.equal(late.pnl, -500);
|
||||
assert.equal(dxTrades([parseHistoryOrder(stopOrder)], txns, new Map(), new Map()).length, 0);
|
||||
|
||||
// A position open with no orders in the window still shows.
|
||||
const [quiet] = dxTrades([], [], held, new Map());
|
||||
assert.equal(quiet.positionCode, '555');
|
||||
assert.equal(quiet.openedAt, 1);
|
||||
});
|
||||
@@ -11,10 +11,11 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
percentFee, takeProfitPlan, onTick, obfuscationStopDollars, dllRoom, dllStopDollars, dllViable, stopTicks,
|
||||
botStopsActive, sizeNotional, isNoTradeTime, isPastSessionClose, nextTradingDate, effectiveCombine, DEFAULT_TARGET_HOLD_HOURS,
|
||||
projectPerfectPath, cycleCompletion, DLL_RESERVE_FRACTION, DLL_MIN_STOP, notionalCap, expectedSwapToWin, MARGIN_HEADROOM,
|
||||
projectPerfectPath, cycleCompletion, endedCycleCompletion, DLL_RESERVE_FRACTION, DLL_MIN_STOP, notionalCap, expectedSwapToWin, MARGIN_HEADROOM,
|
||||
dllReserve, DLL_SLIPPAGE_FRACTION, maxLossRoom, lastStand, breachDistance, LAST_STAND_FRACTION, planLegSize, rankForTarget,
|
||||
currentTradeDateCT, foldReason, withinFirmCaps, personSizeFactor, personHoldHours, PERSON_SIZE_SPREAD, machineSide,
|
||||
resolveEffectiveConfig, stageInputsFor, floorRoom, computeDayPlan,
|
||||
resolveEffectiveConfig, stageInputsFor, stagePayouts, floorRoom, resetClearStop, shortestNextLeg, computeDayPlan, clearsClose, RESET_CLEAR_HOLDS,
|
||||
legCloseNote,
|
||||
} from '../lib/trading-logic.ts';
|
||||
|
||||
// Deterministic PRNG so a failure reproduces.
|
||||
@@ -305,6 +306,22 @@ test('dllViable and botStopsActive', () => {
|
||||
assert.equal(botStopsActive({ firstDayStop: true, minDayStop: false, dailyLossLimit: 0 }), true);
|
||||
});
|
||||
|
||||
test('botStopsActive: the current stage\'s daily loss limit, which a stage may set on a type with none', () => {
|
||||
const cfg = { firstDayStop: false, minDayStop: false, dailyLossLimit: 0 };
|
||||
const stages = [{ profit: 3000, consistency: 0, minTradingDays: 0, dailyLossLimit: 3000 }];
|
||||
const paidOnce = [{ date: '2026-10-25', amount: -1500 }];
|
||||
// Stage 2 places its $3,000 stop: a red day there is the personal DLL.
|
||||
const stage2 = resolveEffectiveConfig(1500, 0, 0, false, stages, 0, paidOnce, 0, { dailyLossLimit: 0 });
|
||||
assert.equal(botStopsActive(cfg, stage2), true);
|
||||
// Stage 1 of the same type places none.
|
||||
assert.equal(botStopsActive(cfg, resolveEffectiveConfig(1500, 0, 0, false, stages, 0, [], 0, { dailyLossLimit: 0 })), false);
|
||||
// A stage that sets 0 lifts the type's limit.
|
||||
const lifted = resolveEffectiveConfig(1500, 0, 0, false, [{ ...stages[0], dailyLossLimit: 0 }], 0, paidOnce, 0, { dailyLossLimit: 1229 });
|
||||
assert.equal(botStopsActive({ ...cfg, dailyLossLimit: 1229 }, lifted), false);
|
||||
// The obfuscation stops count whatever the stage.
|
||||
assert.equal(botStopsActive({ ...cfg, minDayStop: true }, lifted), true);
|
||||
});
|
||||
|
||||
// ── sizing ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const btcSigma = 0.03 / Math.sqrt(24); // 3%/day
|
||||
@@ -419,6 +436,21 @@ test('the daily-loss reserve grows with the position past 10% of the limit', ()
|
||||
assert.equal(dllStopDollars({ room: 3000, dailyLossLimit: 3000, entryFee: 0, commissionPct: 0 }), 2699.99);
|
||||
});
|
||||
|
||||
test('a platform whose stops fill closer reserves its own share of the position', () => {
|
||||
// Me's Vest eval on 2026-10-27: $1,400 of room on $495,439 of NDX. At the
|
||||
// 0.1% default the reserve is $495.44; at 0.02% the flat $140 binds.
|
||||
const n = 495439;
|
||||
assert.ok(Math.abs(dllReserve(1400, n) - 495.439) < 1e-9);
|
||||
assert.equal(dllReserve(1400, n, 0.0002), 140);
|
||||
assert.ok(Math.abs(dllReserve(1400, 900000, 0.0002) - 180) < 1e-9, 'past $700k the share binds again');
|
||||
// Left out, or not a share at all: the default.
|
||||
for (const bad of [undefined, NaN, -0.0002, Infinity]) assert.equal(dllReserve(1400, n, bad), dllReserve(1400, n), String(bad));
|
||||
assert.equal(dllReserve(1400, n, 0), 140, 'no slippage at all still keeps the flat 10%');
|
||||
const stop = (slippageFraction) => dllStopDollars({ room: 1400, dailyLossLimit: 1400, entryFee: 12.45, commissionPct: 0, notional: n, slippageFraction });
|
||||
assert.ok(Math.abs(stop(undefined) - (1400 - 495.439 - 24.9 - 0.01)) < 1e-9);
|
||||
assert.ok(Math.abs(stop(0.0002) - (1400 - 140 - 24.9 - 0.01)) < 1e-9);
|
||||
});
|
||||
|
||||
test('max-drawdown room: the static floor, the broker floor, the higher', () => {
|
||||
assert.equal(maxLossRoom({ balance: 99757.39, accountSize: 100000, maxLoss: 6000 }), 5757.39);
|
||||
assert.equal(maxLossRoom({ balance: 99757.39, accountSize: 100000, maxLoss: -6000 }), 5757.39);
|
||||
@@ -441,6 +473,13 @@ test('last stand: no daily-loss stop once hitting the daily limit would leave 15
|
||||
assert.equal(lastStand({ dllRoom: 1000, dailyLossLimit: 3000, maxLossRoom: 1200 }), false);
|
||||
assert.equal(lastStand({ dllRoom: 1000, dailyLossLimit: 3000, maxLossRoom: 1150 }), true);
|
||||
assert.equal(lastStand({ dllRoom: Infinity, dailyLossLimit: 0, maxLossRoom: 500 }), false, 'no daily limit, no daily stop to drop');
|
||||
// A green day's widened room is measured against a full day's limit: a
|
||||
// $1,400 eval starting $1,700 over its floor, up $1,000 — leg 2's $2,400
|
||||
// stop-out leaves $300, over 15% of $1,400, so it keeps its stop.
|
||||
assert.equal(lastStand({ dllRoom: 2400, dailyLossLimit: 1400, maxLossRoom: 2700 }), false);
|
||||
assert.equal(lastStand({ dllRoom: 2400, dailyLossLimit: 1400, maxLossRoom: 2610 }), true);
|
||||
// The room past the floor is a last stand however it was widened.
|
||||
assert.equal(lastStand({ dllRoom: 2400, dailyLossLimit: 1400, maxLossRoom: 2300 }), true);
|
||||
// The far side of the walk is the nearer floor, net of the entry fee.
|
||||
assert.equal(breachDistance({ dllRoom: 3000, maxLossRoom: 2900, entryFee: 120 }), 2780);
|
||||
assert.equal(breachDistance({ dllRoom: 2757.39, maxLossRoom: null, entryFee: 0 }), 2757.39);
|
||||
@@ -476,6 +515,111 @@ test('planLegSize: the stop, a last stand, the margin cap and the cost, as fireL
|
||||
assert.equal(planLegSize({ ...base, maxLossRoom: null, contract: {} }).sizing, null);
|
||||
});
|
||||
|
||||
test('resetClearStop: the widest stop a leg may carry and still close before the reset', () => {
|
||||
const h = 3_600_000;
|
||||
const near = (a, b) => Math.abs(a - b) < 1e-6;
|
||||
// Exactly four holds off: a 2:1 leg, no more.
|
||||
assert.ok(near(resetClearStop({ leg: 2395.76, resetInMs: 6 * h, holdHours: 1.5 }), 2 * 2395.76));
|
||||
// Under four holds (the 5:30 PM CT open batch, 1.5 h before Vest's
|
||||
// reset): no widening at all.
|
||||
assert.equal(resetClearStop({ leg: 2395.76, resetInMs: 1.5 * h, holdHours: 1.5 }), 0);
|
||||
assert.equal(resetClearStop({ leg: 2395.76, resetInMs: 6 * h - 1, holdHours: 1.5 }), 0);
|
||||
// Dingo's second leg at ~4 AM ET, 16 h off: far past the $4,004 the day allows.
|
||||
assert.ok(resetClearStop({ leg: 2395.76, resetInMs: 16 * h, holdHours: 1.5 }) > 20000);
|
||||
// A $500 final leg 10 h off may carry about $2,656, not the $5,900 a
|
||||
// $4,500 gain would give: g = (A+B)²/(AB) stays at 4.5 × 10/6.
|
||||
const lopsided = resetClearStop({ leg: 500, resetInMs: 10 * h, holdHours: 1.5 });
|
||||
assert.ok(Math.abs(lopsided - 2656) < 1, `${lopsided}`);
|
||||
assert.ok(near((500 + lopsided) ** 2 / (500 * lopsided), 4.5 * 10 / 6));
|
||||
assert.equal(resetClearStop({ leg: 0, resetInMs: 16 * h, holdHours: 1.5 }), 0);
|
||||
assert.equal(resetClearStop({ leg: 500, resetInMs: 16 * h, holdHours: 0 }), 0);
|
||||
assert.equal(resetClearStop({ leg: 500, resetInMs: NaN, holdHours: 1.5 }), 0);
|
||||
// The cap is held to the shortest leg the day may carve: a split day's
|
||||
// $2,395.76 can come out a $500 leg.
|
||||
assert.equal(shortestNextLeg({ remainingToday: 2395.76, splitAllowed: true }), 500);
|
||||
assert.equal(shortestNextLeg({ remainingToday: 320, splitAllowed: true }), 320);
|
||||
assert.equal(shortestNextLeg({ remainingToday: 2395.76, splitAllowed: false }), 2395.76);
|
||||
// Dingo at ~4 AM ET still gets the whole $2,604.24: a $500 leg 16 h off
|
||||
// may carry ~$4,950.
|
||||
assert.ok(resetClearStop({ leg: 500, resetInMs: 16 * h, holdHours: 1.5 }) - 1400 > 2604.24);
|
||||
});
|
||||
|
||||
test('clearsClose: no leg starts under four holds of its market\'s close', () => {
|
||||
const h = 3_600_000;
|
||||
assert.equal(RESET_CLEAR_HOLDS, 4);
|
||||
// The weekly close 7.5 h off, legs sized for 2 h: no entry.
|
||||
assert.equal(clearsClose({ closeInMs: 7.5 * h, holdHours: 2 }), false);
|
||||
// Exactly four holds off, or more: the clearance the daily reset gets.
|
||||
assert.equal(clearsClose({ closeInMs: 8 * h, holdHours: 2 }), true);
|
||||
assert.equal(clearsClose({ closeInMs: 8 * h - 1, holdHours: 2 }), false);
|
||||
assert.equal(clearsClose({ closeInMs: 20 * h, holdHours: 2 }), true);
|
||||
// A leg its leverage cap holds to 3.5 h needs 14 h, where a 2 h one is clear.
|
||||
assert.equal(clearsClose({ closeInMs: 10 * h, holdHours: 3.5 }), false);
|
||||
// It agrees with resetClearStop, which gives no stop at all under four holds.
|
||||
for (const off of [5.9, 6, 6.1, 12]) {
|
||||
assert.equal(clearsClose({ closeInMs: off * h, holdHours: 1.5 }), resetClearStop({ leg: 500, resetInMs: off * h, holdHours: 1.5 }) > 0);
|
||||
}
|
||||
// An unknown hold never clears.
|
||||
assert.equal(clearsClose({ closeInMs: 20 * h, holdHours: NaN }), false);
|
||||
});
|
||||
|
||||
test('legCloseNote: each account\'s leg is held to its market\'s close at its own size', () => {
|
||||
const h = 3_600_000;
|
||||
const now = Date.parse('2026-10-30T12:30:00Z');
|
||||
const close = now + 9 * h;
|
||||
// The batch's lead leg, 2 h: clear. Another account's, sized to 3.5 h: not.
|
||||
assert.equal(legCloseNote(close, 2, now), null);
|
||||
assert.equal(legCloseNote(close, 3.5, now), 'closes in 9.0h, under 4 holds of 3.5h');
|
||||
// A contract that names no close (futures, crypto) never holds a leg back.
|
||||
assert.equal(legCloseNote(undefined, 3.5, now), null);
|
||||
assert.equal(legCloseNote(null, 3.5, now), null);
|
||||
// An unknown hold doesn't open into a known close.
|
||||
assert.notEqual(legCloseNote(close, NaN, now), null);
|
||||
});
|
||||
|
||||
test('planLegSize: a split day\'s next leg, with the day\'s gain on its stop, sizes up for the same hold', () => {
|
||||
// Dingo's 2026-10-27 evals: up $2,604.24 on a $1,400 day, $2,395.76 to
|
||||
// pass, NDX at ~$29.5k on Vest (1.31%/day), sized for 1.5 h.
|
||||
const ndx = { stepNotional: 2.95, volatilityPerHour: 0.0131 / Math.sqrt(24), spreadFraction: 0.0000085, marginRate: 0.02 };
|
||||
const leg = {
|
||||
leg: 2395.76, contract: ndx, commissionPct: 0.0025, targetHoldHours: 1.5, financingPctPerDay: 0,
|
||||
dailyLossLimit: 1400, obfuscationDollars: Infinity, availableFunds: 52604.24, maxLossRoom: 5604.24,
|
||||
};
|
||||
const capped = planLegSize({ ...leg, dllRoom: 1400 });
|
||||
const room = floorRoom({ dailyLossLimit: 1400, closedBalance: 52604.24, floor: 48500, realizedToday: 2604.24, gain: 2604.24 });
|
||||
const widened = planLegSize({ ...leg, dllRoom: room });
|
||||
assert.equal(widened.lastStand, false);
|
||||
for (const p of [capped, widened]) assert.ok(Math.abs(p.sizing.predictedHoldHours - 1.5) < 0.05, `hold ${p.sizing.predictedHoldHours}`);
|
||||
assert.ok(widened.sizing.notional > 1.5 * capped.sizing.notional, `${widened.sizing.notional} vs ${capped.sizing.notional}`);
|
||||
// The stop gives back the day's gain plus the limit, less the reserve.
|
||||
const n = widened.sizing.notional;
|
||||
assert.ok(widened.walkAt(n) > 2604.24 && widened.walkAt(n) < 4004.24, `stop ${widened.walkAt(n)}`);
|
||||
});
|
||||
|
||||
test('planLegSize: a platform\'s own slippage share widens the stop and sizes the leg up for the same hold', () => {
|
||||
// Me's first-day Vest eval leg on 2026-10-27: $5,000 to pass, $1,400 of
|
||||
// room, NDX at ~$29.8k, sized for 2.6 h.
|
||||
const ndx = { stepNotional: 2.98, volatilityPerHour: 0.0131 / Math.sqrt(24), spreadFraction: 0.0000085, marginRate: 0.02 };
|
||||
const leg = {
|
||||
leg: 5000, contract: ndx, commissionPct: 0.0025, targetHoldHours: 2.6, financingPctPerDay: 0,
|
||||
dailyLossLimit: 1400, dllRoom: 1400, obfuscationDollars: Infinity, availableFunds: 50000, maxLossRoom: 3000,
|
||||
};
|
||||
const deflt = planLegSize(leg);
|
||||
const vest = planLegSize({ ...leg, slippageFraction: 0.0002 });
|
||||
assert.equal(planLegSize({ ...leg, slippageFraction: undefined }).sizing.notional, deflt.sizing.notional);
|
||||
for (const p of [deflt, vest]) {
|
||||
assert.equal(p.lastStand, false);
|
||||
assert.ok(Math.abs(p.sizing.predictedHoldHours - 2.6) < 0.05, `hold ${p.sizing.predictedHoldHours}`);
|
||||
}
|
||||
const r = 0.0025 / 100;
|
||||
const nd = deflt.sizing.notional, nv = vest.sizing.notional;
|
||||
assert.ok(Math.abs(deflt.walkAt(nd) - (1400 - 0.001 * nd - 2 * r * nd - 0.01) / (1 + r)) < 1e-9);
|
||||
assert.ok(Math.abs(vest.walkAt(nv) - (1400 - 140 - 2 * r * nv - 0.01) / (1 + r)) < 1e-9, 'the flat $140 binds');
|
||||
assert.ok(vest.walkAt(nv) > 1200 && deflt.walkAt(nd) < 950, `stops ${vest.walkAt(nv)} vs ${deflt.walkAt(nd)}`);
|
||||
assert.ok(nv > 1.1 * nd, `${nv} vs ${nd}`);
|
||||
assert.ok(vest.sizing.winProbability > deflt.sizing.winProbability);
|
||||
});
|
||||
|
||||
test('personSizeFactor: one draw per person and trade date, within the spread', () => {
|
||||
const people = Array.from({ length: 300 }, (_, i) => `0b6f${i.toString(16).padStart(4, '0')}-5c1e-4a7e-9d2b-${i}`);
|
||||
const draws = people.map((p) => personSizeFactor(p, '2026-10-20'));
|
||||
@@ -621,6 +765,39 @@ test('an every-day firm can finish its cycle on a Saturday', () => {
|
||||
assert.equal(cycleCompletion(opts), 'none');
|
||||
});
|
||||
|
||||
import { completionPlace } from '../lib/trading-logic.ts';
|
||||
|
||||
test('completionPlace: each payout counts, and a day\'s second cycle has a key of its own', () => {
|
||||
// Vest: activated on the 20th; claims c1 and c2 on the 27th share one
|
||||
// fund row; c3 on the 28th.
|
||||
const activated = { date: '2026-10-20', amount: 50000 };
|
||||
// Cycle A, the first, completes on the 27th ahead of any claim.
|
||||
assert.deepEqual(completionPlace({ payoutDays: [], fundTransactions: [activated], cycle: '2026-10-20' }), { stage: 1, cycleSeq: 0 });
|
||||
// c1 starts cycle B, which completes the same day.
|
||||
const b = completionPlace({ payoutDays: ['2026-10-27'], fundTransactions: [activated, { date: '2026-10-27', amount: -2250 }], cycle: '2026-10-27' });
|
||||
assert.deepEqual(b, { stage: 2, cycleSeq: 0 });
|
||||
// c2 starts cycle C on the same fund row, which completes on the 28th:
|
||||
// payout 3, and not B's key — the master would fold C into B.
|
||||
const both = [activated, { date: '2026-10-27', amount: -3750 }];
|
||||
const c = completionPlace({ payoutDays: ['2026-10-27', '2026-10-27'], fundTransactions: both, cycle: '2026-10-27' });
|
||||
assert.deepEqual(c, { stage: 3, cycleSeq: 1 });
|
||||
// C caught up after c3 ended it: the same stage and key as its final day.
|
||||
assert.deepEqual(completionPlace({
|
||||
payoutDays: ['2026-10-27', '2026-10-27', '2026-10-28'], fundTransactions: [...both, { date: '2026-10-28', amount: -1500 }],
|
||||
cycle: '2026-10-27', end: '2026-10-28',
|
||||
}), c);
|
||||
// One fund row per payout (every other platform): 1 + the rows before, no
|
||||
// cycleSeq — as the stage always read.
|
||||
const rows = [{ date: '2026-09-01', amount: -500 }, { date: '2026-09-15', amount: -800 }];
|
||||
const days = rows.map((f) => f.date);
|
||||
assert.deepEqual(completionPlace({ payoutDays: days, fundTransactions: rows, cycle: '2026-09-15' }), { stage: 3, cycleSeq: 0 });
|
||||
assert.deepEqual(completionPlace({ payoutDays: days, fundTransactions: rows, cycle: '2026-09-01', end: '2026-09-15' }), { stage: 2, cycleSeq: 0 });
|
||||
// The account's first cycle; and a claim on the day it was activated,
|
||||
// whose deposit row names the cycle it started.
|
||||
assert.deepEqual(completionPlace({ payoutDays: [], fundTransactions: [], cycle: '' }), { stage: 1, cycleSeq: 0 });
|
||||
assert.deepEqual(completionPlace({ payoutDays: ['2026-10-20'], fundTransactions: [{ date: '2026-10-20', amount: 47750 }], cycle: '2026-10-20' }), { stage: 2, cycleSeq: 1 });
|
||||
});
|
||||
|
||||
// ── DXTrade helpers (lib/dxtrade-helpers.ts) ─────────────────────────────────
|
||||
|
||||
import {
|
||||
@@ -999,14 +1176,26 @@ test('same-day stages: today\'s payouts pick the stage, and a stage may carry it
|
||||
assert.deepEqual(stageInputsFor({}, 7, { stagesSameDay: true }), { dailyLossLimit: 0, stagesSameDay: true, sameDayWithdrawals: 0 });
|
||||
});
|
||||
|
||||
test('floorRoom: the platform\'s own floor, never more than what\'s left of the stage\'s limit', () => {
|
||||
test('floorRoom: what\'s left of the stage\'s limit, widened by the day\'s gain the client allows, never past the platform\'s floor', () => {
|
||||
// Fresh day: $50k, Vest floor $48,500, the type's $1,229.
|
||||
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 50000, floor: 48500, realizedToday: 0 }), 1229);
|
||||
// Lost $1,111.65 today: Vest still allows $388.35, but the bot's own
|
||||
// $1,229 day has only $117.35 left.
|
||||
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 48888.35, floor: 48500, realizedToday: -1111.65 }), 117.35);
|
||||
// A green day doesn't widen the limit.
|
||||
// A green day alone doesn't widen the limit...
|
||||
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 50600, floor: 48500, realizedToday: 600 }), 1229);
|
||||
// ...the gain the client allows does: the next leg may give it back plus
|
||||
// the limit, as on Dingo's 2026-10-27 evals ($1,400 a day, up $2,604.24)...
|
||||
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 50600, floor: 48500, realizedToday: 600, gain: 600 }), 1829);
|
||||
assert.equal(floorRoom({ dailyLossLimit: 1400, closedBalance: 52604.24, floor: 48500, realizedToday: 2604.24, gain: 2604.24 }), 4004.24);
|
||||
// ...never past Vest's own floor...
|
||||
assert.equal(floorRoom({ dailyLossLimit: 1400, closedBalance: 52604.24, floor: 49000, realizedToday: 2604.24, gain: 2604.24 }), 3604.24);
|
||||
// ...and never by more than the day realized: $2,250 claimed by hand
|
||||
// leaves realized at $2,250 (added back) but none of it in the account.
|
||||
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 50000, floor: 46250, realizedToday: 2250, gain: 0 }), 1229);
|
||||
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 50600, floor: 48500, realizedToday: 600, gain: 5000 }), 1829);
|
||||
// A red day still counts in full, whatever gain is offered.
|
||||
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 49500, floor: 48500, realizedToday: -500, gain: 300 }), 729);
|
||||
// Won $1,500 and claimed it: balance $50k, Vest's floor lowered to $47k,
|
||||
// the day's P&L counted from the claim. Stage 2's $3,000 gets all of it.
|
||||
assert.equal(floorRoom({ dailyLossLimit: 3000, closedBalance: 50000, floor: 47000, realizedToday: 0 }), 3000);
|
||||
@@ -1025,3 +1214,276 @@ test('the day after a claim: a fresh cycle that aims at the next stage\'s target
|
||||
assert.ok(plan.dayTarget >= 3000 && plan.dayTarget <= 3025, `dayTarget ${plan.dayTarget}`);
|
||||
assert.equal(plan.remainingToday, plan.dayTarget);
|
||||
});
|
||||
|
||||
// ── what an order is for (lib/platform-client.ts orderEffect) ────────────────
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { orderEffect, OrderNotPlacedError, NothingToCloseError, isOrderNotPlaced, isNothingToClose } from '../lib/platform-client.ts';
|
||||
|
||||
test('an entry never closes a position, a flatten never opens one (Vest, DXtrade)', () => {
|
||||
/** The route, or the refusal thrown. `recorded`: the client's own record
|
||||
* of the symbol, by default what the fresh read shows. */
|
||||
const effect = (held, side, intent, recorded = held) => {
|
||||
try {
|
||||
return orderEffect({ symbol: 'NDX-USD-PERP', held, recorded, side, intent });
|
||||
} catch (err) {
|
||||
return err;
|
||||
}
|
||||
};
|
||||
// No intent: the position decides, as before (the manual order route).
|
||||
assert.equal(effect(null, 'long'), 'open');
|
||||
assert.equal(effect('short', 'long'), 'close');
|
||||
assert.match(effect('long', 'long').message, /adding to an open NDX-USD-PERP position/);
|
||||
// An entry on an account that already holds the symbol — a hand short the
|
||||
// engine's synced positions didn't show yet — is refused (the leg is
|
||||
// un-counted), not turned into a close of that short.
|
||||
assert.equal(effect(null, 'long', 'open'), 'open');
|
||||
for (const recorded of ['short', null]) {
|
||||
const entry = effect('short', 'long', 'open', recorded);
|
||||
assert.equal(isOrderNotPlaced(entry), true);
|
||||
assert.equal(isNothingToClose(entry), false);
|
||||
assert.match(entry.message, /short NDX-USD-PERP position is already open/);
|
||||
}
|
||||
assert.match(effect('long', 'long', 'open').message, /adding to an open/);
|
||||
// A flatten closes the leg it was sent for; on a flat account it opens
|
||||
// nothing, and says there is nothing to close when the record agrees.
|
||||
assert.equal(effect('long', 'short', 'close'), 'close');
|
||||
const flat = effect(null, 'short', 'close');
|
||||
assert.ok(flat instanceof NothingToCloseError);
|
||||
assert.equal(isOrderNotPlaced(flat), true);
|
||||
assert.equal(isNothingToClose(flat), true);
|
||||
assert.match(flat.message, /no NDX-USD-PERP position to close/);
|
||||
// A flat read just after a fill the client saw (the leg still on record)
|
||||
// may be the gateway lagging: nothing sent, but not "flat" — the flatten
|
||||
// keeps its alarm.
|
||||
const lagging = effect(null, 'short', 'close', 'long');
|
||||
assert.ok(lagging instanceof OrderNotPlacedError);
|
||||
assert.equal(isNothingToClose(lagging), false);
|
||||
assert.match(lagging.message, /fresh read shows no NDX-USD-PERP position, but this client last saw a long one open/);
|
||||
// Holding the flatten's own side is not the leg: refused, and not "flat".
|
||||
const wrong = effect('short', 'short', 'close');
|
||||
assert.ok(wrong instanceof OrderNotPlacedError);
|
||||
assert.equal(isNothingToClose(wrong), false);
|
||||
assert.match(wrong.message, /holds a short NDX-USD-PERP position, not one to close/);
|
||||
|
||||
assert.equal(isNothingToClose(new OrderNotPlacedError('Vest refused the order: 400')), false);
|
||||
assert.equal(isNothingToClose(new Error('outcome unknown')), false);
|
||||
assert.equal(isNothingToClose(undefined), false);
|
||||
});
|
||||
|
||||
test('the engine says what each order is for, and both position-based clients go by it', () => {
|
||||
const source = (file) => readFileSync(new URL(`../lib/${file}`, import.meta.url), 'utf8');
|
||||
// An order sent without its intent lets the position decide again: an
|
||||
// entry closing a hand trade, a flatten opening a position with no exits.
|
||||
const calls = [...source('auto-trade.ts').matchAll(/client\.sendOrder\(([^;]+?)\);/g)].map((m) => m[1].trim());
|
||||
assert.ok(calls.length >= 4, `found ${calls.length} sendOrder calls`);
|
||||
for (const args of calls) assert.match(args, /, '(open|close)'$/, `sendOrder(${args}) names no intent`);
|
||||
for (const file of ['vest-class.ts', 'dxtrade-class.ts']) {
|
||||
const src = source(file);
|
||||
assert.match(src, /return await this\.sendOrderAndWait\([^)]*, intent\);/, `${file}: sendOrder drops the intent`);
|
||||
assert.match(src, /= orderEffect\(\{[^}]*\bintent\b[^}]*\}\) === 'close' \? held : undefined;/, `${file}: orderEffect doesn't pick the route`);
|
||||
}
|
||||
});
|
||||
|
||||
// The Vest 50k Funded type: $1,500 and claim it all, then $3,000 on the room
|
||||
// the claim freed — and every new day back at $1,500.
|
||||
const vestFunded = {
|
||||
profitTarget: 1500, consistency: 0, minDayPnL: 0, minTradingDays: 0, accountSize: 50000,
|
||||
targetSameEquity: false, splitEnabled: true, payoutable: true, tradesEveryDay: true, combineCmeHolidays: false,
|
||||
withdrawalStages: [{ profit: 3000, consistency: 0, minTradingDays: 0, dailyLossLimit: 3000 }],
|
||||
};
|
||||
const claimedBefore = [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-25', amount: -1500 }];
|
||||
|
||||
test('projectPerfectPath: a same-day type trades each projected day from the base stage, then its next stages that day', () => {
|
||||
const opts = {
|
||||
accountId: 7, cfg: vestFunded, cycleDailyPnL: [], fundTransactions: claimedBefore,
|
||||
balance: 50000, realizedPnL: 0, payout: { withdrawalPct: 1 }, now: cdt('2026-10-27T12:00:00'),
|
||||
};
|
||||
const firstOf = (path, n) => path.slice(0, n).map((d) => [d.date, d.stage, d.withdrawal]);
|
||||
|
||||
// An earlier day's claim doesn't count: today and every day after it
|
||||
// make $1,500 on stage 1 and claim it, then $3,000 on stage 2 and claim
|
||||
// that — once through the stages a day.
|
||||
const fresh = projectPerfectPath({ ...opts, stageInputs: { dailyLossLimit: 1229, stagesSameDay: true, sameDayWithdrawals: 0 } });
|
||||
assert.deepEqual(firstOf(fresh, 5), [
|
||||
['2026-10-27', 1, -1500], ['2026-10-27', 2, -3000],
|
||||
['2026-10-28', 1, -1500], ['2026-10-28', 2, -3000],
|
||||
['2026-10-29', 1, -1500],
|
||||
]);
|
||||
|
||||
// Claimed once already today: today's cycle is stage 2's $3,000, tomorrow $1,500 and $3,000 again.
|
||||
const claimedToday = projectPerfectPath({ ...opts, stageInputs: { dailyLossLimit: 1229, stagesSameDay: true, sameDayWithdrawals: 1 } });
|
||||
assert.deepEqual(firstOf(claimedToday, 3), [['2026-10-27', 2, -3000], ['2026-10-28', 1, -1500], ['2026-10-28', 2, -3000]]);
|
||||
|
||||
// A cycle that stood complete yesterday is claimed today, so today trades stage 2.
|
||||
const unclaimed = projectPerfectPath({
|
||||
...opts, cycleDailyPnL: [{ date: '2026-10-26', pnl: 1500 }], balance: 51500,
|
||||
stageInputs: { dailyLossLimit: 1229, stagesSameDay: true, sameDayWithdrawals: 0 },
|
||||
});
|
||||
assert.deepEqual(firstOf(unclaimed, 3), [['2026-10-26', 1, -1500], ['2026-10-27', 2, -3000], ['2026-10-28', 1, -1500]]);
|
||||
|
||||
// Two listed stages: three payouts a day, and the day after starts over.
|
||||
const twoStages = projectPerfectPath({
|
||||
...opts, cfg: { ...vestFunded, withdrawalStages: [...vestFunded.withdrawalStages, { profit: 2000, consistency: 0, minTradingDays: 0 }] },
|
||||
maxWithdrawals: 10, stageInputs: { stagesSameDay: true, sameDayWithdrawals: 0 },
|
||||
});
|
||||
assert.deepEqual(firstOf(twoStages, 4).map(([date, stage]) => [date, stage]), [
|
||||
['2026-10-27', 1], ['2026-10-27', 2], ['2026-10-27', 3], ['2026-10-28', 1],
|
||||
]);
|
||||
|
||||
// Any other type counts every withdrawal, as it always has.
|
||||
const lifetime = projectPerfectPath(opts);
|
||||
assert.deepEqual(firstOf(lifetime, 3), [['2026-10-27', 2, -3000], ['2026-10-28', 3, -3000], ['2026-10-29', 4, -3000]]);
|
||||
assert.deepEqual(projectPerfectPath({ ...opts, stageInputs: { dailyLossLimit: 1229, stagesSameDay: false, sameDayWithdrawals: 0 } }), lifetime);
|
||||
});
|
||||
|
||||
test('projectPerfectPath: today is the raw trade date it is given — Vest\'s, still the day before from 5 PM CT to 20:00 ET', () => {
|
||||
const firstOf = (path, n) => path.slice(0, n).map((d) => [d.date, d.stage, d.withdrawal]);
|
||||
// Claimed once on Vest day 10-27 and $1,000 made since.
|
||||
const claimed = {
|
||||
accountId: 7, cfg: vestFunded, cycleDailyPnL: [], fundTransactions: [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-27', amount: -1500 }],
|
||||
balance: 51000, realizedPnL: 1000, payout: { withdrawalPct: 1 }, stageInputs: { stagesSameDay: true, sameDayWithdrawals: 1 },
|
||||
};
|
||||
const before = projectPerfectPath({ ...claimed, now: cdt('2026-10-27T16:30:00') });
|
||||
assert.deepEqual(firstOf(before, 3), [['2026-10-27', 2, -3000], ['2026-10-28', 1, -1500], ['2026-10-28', 2, -3000]]);
|
||||
// 5:30 PM CT: the Central date has rolled, Vest's hasn't — the day's
|
||||
// claim stays on it, and 10-28 keeps its stage-1 payout.
|
||||
assert.deepEqual(projectPerfectPath({ ...claimed, now: cdt('2026-10-27T17:30:00'), rawTradeDate: '2026-10-27' }), before);
|
||||
// No date given: the Central one, as before.
|
||||
assert.equal(projectPerfectPath({ ...claimed, now: cdt('2026-10-27T17:30:00') })[0].date, '2026-10-28');
|
||||
|
||||
// No claim yet, Vest day 10-27's row already booked: counted once, as today's.
|
||||
const open = {
|
||||
...claimed, fundTransactions: claimedBefore, cycleDailyPnL: [{ date: '2026-10-27', pnl: 1000 }],
|
||||
stageInputs: { stagesSameDay: true, sameDayWithdrawals: 0 },
|
||||
};
|
||||
const earlier = projectPerfectPath({ ...open, now: cdt('2026-10-27T16:30:00') });
|
||||
assert.deepEqual(firstOf(earlier, 2), [['2026-10-27', 1, -1500], ['2026-10-27', 2, -3000]]);
|
||||
assert.deepEqual(projectPerfectPath({ ...open, now: cdt('2026-10-27T17:30:00'), rawTradeDate: '2026-10-27' }), earlier);
|
||||
});
|
||||
|
||||
test('stages without the same-day reset count each payout, where one fund row holds a day\'s two (Vest)', () => {
|
||||
// Two listed stages; claims of $1,500 and $3,000 on Vest day 10-27 share its row.
|
||||
const stages = [{ profit: 3000, consistency: 0, minTradingDays: 0 }, { profit: 2000, consistency: 0, minTradingDays: 0 }];
|
||||
const rows = [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-27', amount: -4500 }];
|
||||
const resolve = (inputs) => resolveEffectiveConfig(1500, 0, 0, false, stages, 0, rows, 0, inputs);
|
||||
assert.equal(resolve({ payouts: 2 }).profitTarget, 2000);
|
||||
// No payout list: one per negative fund row, as always.
|
||||
assert.equal(resolve({}).profitTarget, 3000);
|
||||
// A same-day type still counts only today's.
|
||||
assert.equal(resolve({ stagesSameDay: true, sameDayWithdrawals: 0, payouts: 2 }).profitTarget, 1500);
|
||||
|
||||
// stageInputsFor takes the count from the client's payout list, when it has read one.
|
||||
const client = { sameDayClaims: () => 2, payoutDays: () => ['2026-10-27', '2026-10-27'] };
|
||||
assert.deepEqual(stageInputsFor(client, 7, {}), { dailyLossLimit: 0, stagesSameDay: false, sameDayWithdrawals: 0, payouts: 2 });
|
||||
assert.deepEqual(stageInputsFor({ payoutDays: () => undefined }, 7, {}), { dailyLossLimit: 0, stagesSameDay: false, sameDayWithdrawals: 0 });
|
||||
assert.deepEqual(stageInputsFor(client, 7, { stagesSameDay: true }), { dailyLossLimit: 0, stagesSameDay: true, sameDayWithdrawals: 2 });
|
||||
// The state route's stage label counts the same way.
|
||||
assert.equal(stagePayouts(rows, stageInputsFor(client, 7, {})), 2);
|
||||
assert.equal(stagePayouts(rows, stageInputsFor({}, 7, {})), 1);
|
||||
|
||||
// The projection starts from the same count: stage 3's $2,000 next, then it repeats.
|
||||
const opts = {
|
||||
accountId: 7, cfg: { ...vestFunded, withdrawalStages: stages }, cycleDailyPnL: [], fundTransactions: rows,
|
||||
balance: 50000, realizedPnL: 0, payout: { withdrawalPct: 1 }, now: cdt('2026-10-27T12:00:00'),
|
||||
};
|
||||
const firstOf = (path, n) => path.slice(0, n).map((d) => [d.date, d.stage, d.withdrawal]);
|
||||
assert.deepEqual(firstOf(projectPerfectPath({ ...opts, stageInputs: stageInputsFor(client, 7, {}) }), 2), [['2026-10-27', 3, -2000], ['2026-10-28', 4, -2000]]);
|
||||
assert.deepEqual(firstOf(projectPerfectPath(opts), 2), [['2026-10-27', 2, -3000], ['2026-10-28', 3, -2000]]);
|
||||
});
|
||||
|
||||
test('endedCycleCompletion: a same-day type\'s ended cycle is judged on its day\'s base stage', () => {
|
||||
// 2026-10-26 made $1,500 and the auto-claim took it before the reporter
|
||||
// saw the final day; the account had claimed the day before too. The
|
||||
// claim ended that day's stage 1, not a $3,000 stage 2.
|
||||
const opts = {
|
||||
accountId: 7, rawTradeDate: '2026-10-26',
|
||||
fullDailyPnL: [{ date: '2026-10-25', pnl: 1500 }, { date: '2026-10-26', pnl: 1500 }],
|
||||
cycleDailyPnL: [],
|
||||
fundTransactions: [...claimedBefore, { date: '2026-10-26', amount: -1500 }],
|
||||
lastFundDate: '2026-10-26',
|
||||
endedCycleStart: { fundDate: '2026-10-25', includesDay: false },
|
||||
rawRealizedPnL: 0, equityProfit: 0,
|
||||
cfg: { ...vestFunded, stagesSameDay: true },
|
||||
now: cdt('2026-10-26T12:00:00'),
|
||||
};
|
||||
// Its stage stays the payout's number among the account's withdrawals,
|
||||
// which the master clears the entry by.
|
||||
assert.deepEqual(endedCycleCompletion(opts), { date: '2026-10-26', stage: 2, drain: false, cycle: '2026-10-25' });
|
||||
// Any other type: judged on stage 2's $3,000, as before.
|
||||
assert.equal(endedCycleCompletion({ ...opts, cfg: vestFunded }), null);
|
||||
});
|
||||
|
||||
import { claimedCycle, withinEndedCycleWindow } from '../lib/trading-logic.ts';
|
||||
|
||||
test('claimedCycle: a claim names the complete cycle it ends, where the final day and the catch-up can\'t', () => {
|
||||
const cfg = { ...vestFunded, stagesSameDay: true, dailyLossLimit: 1500 };
|
||||
const activated = { date: '2026-10-20', amount: 50000 };
|
||||
/** A Vest client's numbers for account 7, just before a claim. */
|
||||
/** The cycle a claim names, its claim time checked and left out. */
|
||||
const claimed = (...args) => {
|
||||
const c = claimedCycle(...args);
|
||||
if (c) { assert.equal(c.at, args[3].getTime()); delete c.at; }
|
||||
return c;
|
||||
};
|
||||
const client = (o) => ({
|
||||
accountCashBalances: { 7: o.cash }, dailyPnL: { 7: o.cycle ?? [] }, fundTransactions: { 7: o.rows },
|
||||
lastFundDates: { 7: o.last }, priorProfit: { 7: 0 },
|
||||
tradeDate: () => o.day, sameDayClaims: () => o.today, payoutDays: o.payouts && (() => o.payouts),
|
||||
});
|
||||
|
||||
// Vest day 10-27, 6 PM ET: claim 1 took stage 1's $1,500 at 3 PM, and
|
||||
// stage 2 has made its $3,000 since. The auto-claim at the next tick
|
||||
// beats the 30 s report, and its payout books into 10-27's row: the
|
||||
// latest fund date never moves for the catch-up.
|
||||
const claimedOnce = [activated, { date: '2026-10-27', amount: -1500 }];
|
||||
const second = { cash: { amount: 53000, realizedPnL: 3000 }, rows: claimedOnce, last: '2026-10-27', day: '2026-10-27', today: 1, payouts: ['2026-10-27'] };
|
||||
assert.deepEqual(claimed(client(second), 7, cfg, cdt('2026-10-27T17:00:00')),
|
||||
{ date: '2026-10-27', stage: 2, drain: false, cycle: '2026-10-27', cycleSeq: 0 });
|
||||
// A Claim now short of stage 2's $3,000 names nothing.
|
||||
assert.equal(claimed(client({ ...second, cash: { amount: 51000, realizedPnL: 1000 } }), 7, cfg, cdt('2026-10-27T17:00:00')), null);
|
||||
// The auto-claim takes the day as done within $25 of the target (claimDue),
|
||||
// and names the cycle at the same tolerance — not further short.
|
||||
const shortBy10 = client({ ...second, cash: { amount: 52990, realizedPnL: 2990 } });
|
||||
assert.equal(claimed(shortBy10, 7, cfg, cdt('2026-10-27T17:00:00')), null);
|
||||
assert.deepEqual(claimed(shortBy10, 7, cfg, cdt('2026-10-27T17:00:00'), 25),
|
||||
{ date: '2026-10-27', stage: 2, drain: false, cycle: '2026-10-27', cycleSeq: 0 });
|
||||
assert.equal(claimed(client({ ...second, cash: { amount: 52900, realizedPnL: 2900 } }), 7, cfg, cdt('2026-10-27T17:00:00'), 25), null);
|
||||
// The day's third cycle, after that claim, has a key of its own.
|
||||
const twice = [activated, { date: '2026-10-27', amount: -4500 }];
|
||||
assert.deepEqual(claimed(client({ ...second, rows: twice, today: 2, payouts: ['2026-10-27', '2026-10-27'] }), 7, cfg, cdt('2026-10-27T17:00:00')),
|
||||
{ date: '2026-10-27', stage: 3, drain: false, cycle: '2026-10-27', cycleSeq: 1 });
|
||||
|
||||
// Stage 2 made only $1,500 of its $3,000 by the 20:00 ET reset, which
|
||||
// puts the account back on stage 1's $1,500: claimed at the first tick,
|
||||
// 8:00:30 PM ET.
|
||||
const afterReset = { cash: { amount: 51500, realizedPnL: 0 }, rows: claimedOnce, last: '2026-10-27', day: '2026-10-28', today: 0, payouts: ['2026-10-27'] };
|
||||
assert.deepEqual(claimed(client(afterReset), 7, cfg, cdt('2026-10-27T19:00:30')),
|
||||
{ date: '2026-10-28', stage: 2, drain: false, cycle: '2026-10-27', cycleSeq: 0 });
|
||||
// The catch-up after it reads none of that cycle's P&L, all in 10-27's
|
||||
// row, which the cycle before owns.
|
||||
assert.equal(endedCycleCompletion({
|
||||
accountId: 7, rawTradeDate: '2026-10-28', fullDailyPnL: [{ date: '2026-10-27', pnl: 3000 }], cycleDailyPnL: [],
|
||||
fundTransactions: [...claimedOnce, { date: '2026-10-28', amount: -1500 }], lastFundDate: '2026-10-28',
|
||||
endedCycleStart: { fundDate: '2026-10-27', includesDay: false }, rawRealizedPnL: 0, equityProfit: 0, cfg, now: cdt('2026-10-27T19:10:00'),
|
||||
}), null);
|
||||
|
||||
// Where the catch-up does name the cycle — its P&L on a day of its own —
|
||||
// the claim names it by the same key, so the master keeps one.
|
||||
const crossDay = { cash: { amount: 51500, realizedPnL: 1500 }, rows: claimedBefore, last: '2026-10-25', day: '2026-10-26', today: 0, payouts: ['2026-10-25'] };
|
||||
assert.deepEqual(claimed(client(crossDay), 7, cfg, cdt('2026-10-26T12:00:00')),
|
||||
{ date: '2026-10-26', stage: 2, drain: false, cycle: '2026-10-25', cycleSeq: 0 });
|
||||
// Without a payout list, one payout per negative fund row.
|
||||
assert.equal(claimed(client({ ...crossDay, payouts: undefined }), 7, cfg, cdt('2026-10-26T12:00:00')).stage, 2);
|
||||
// Numbers not read yet: nothing.
|
||||
assert.equal(claimed({ ...client(crossDay), accountCashBalances: {} }, 7, cfg, cdt('2026-10-26T12:00:00')), null);
|
||||
assert.equal(claimed({ ...client(crossDay), fundTransactions: {} }, 7, cfg, cdt('2026-10-26T12:00:00')), null);
|
||||
});
|
||||
|
||||
test('withinEndedCycleWindow: a week after the date, and never for one that doesn\'t parse', () => {
|
||||
assert.equal(withinEndedCycleWindow('2026-10-21', '2026-10-28'), true);
|
||||
assert.equal(withinEndedCycleWindow('2026-10-28', '2026-10-28'), true);
|
||||
assert.equal(withinEndedCycleWindow('2026-10-20', '2026-10-28'), false);
|
||||
assert.equal(withinEndedCycleWindow('', '2026-10-28'), false);
|
||||
assert.equal(withinEndedCycleWindow('10/27', '2026-10-28'), false);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,771 @@
|
||||
// Checks for the Vest client's own wiring (lib/vest-class.ts), past what its
|
||||
// pure helpers show: the market-close guard closing a leg whose open came back
|
||||
// unknown, and the candidate board keeping a symbol the close gate holds back;
|
||||
// a sync older than the bot's own fill, an open a restart cut off, an outside
|
||||
// trade beside an open of unknown outcome, and the client's day at 8 PM ET;
|
||||
// the completed cycles a Vest day's claims end, named at each claim; a
|
||||
// refused account-token mint, a new account's history read again within the
|
||||
// minute, and exits that wait out a lapse in the session.
|
||||
//
|
||||
// npm run check
|
||||
//
|
||||
// A scratch database, a fake clock, and every gateway call answered here —
|
||||
// nothing reaches Vest.
|
||||
import { after, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { registerHooks, stripTypeScriptTypes } from 'node:module';
|
||||
|
||||
// Resolve the extensionless local imports used by Next, without a build.
|
||||
function resolveTypes(specifier, context, nextResolve) {
|
||||
try {
|
||||
return nextResolve(specifier, context);
|
||||
} catch (err) {
|
||||
if (err.code !== 'ERR_MODULE_NOT_FOUND' || !specifier.startsWith('.')) throw err;
|
||||
return nextResolve(`${specifier}.ts`, context);
|
||||
}
|
||||
}
|
||||
// The client's classes use parameter properties, which plain type
|
||||
// stripping refuses: transform them instead.
|
||||
function loadTypes(url, context, nextLoad) {
|
||||
if (!url.startsWith('file:') || !url.endsWith('.ts')) return nextLoad(url, context);
|
||||
const source = stripTypeScriptTypes(readFileSync(fileURLToPath(url), 'utf8'), { mode: 'transform' });
|
||||
return { format: 'module', source, shortCircuit: true };
|
||||
}
|
||||
registerHooks({ resolve: resolveTypes, load: loadTypes });
|
||||
|
||||
const directory = mkdtempSync(join(tmpdir(), 'autofirmer-vest-client-'));
|
||||
process.env.AUTOFIRMER_DB = join(directory, 'test.sqlite');
|
||||
delete process.env.NEXT_PHASE;
|
||||
after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
|
||||
// No request leaves this process: axios refuses every one, and each client's
|
||||
// gateway is a function in this file — from construction on, since the
|
||||
// constructor starts a sync.
|
||||
const { default: axios } = await import('../lib/http-client.ts');
|
||||
axios.defaults.adapter = (config) => Promise.reject(new Error(`no network in this test: ${config.method} ${config.url}`));
|
||||
const { VestClient } = await import('../lib/vest-class.ts');
|
||||
// The client's own transport, for the checks of what it does with an answer.
|
||||
const realRequest = VestClient.prototype.request;
|
||||
let gateway = null;
|
||||
VestClient.prototype.request = async function (bucket, method, path, opts = {}) {
|
||||
// Calls on an account's token reach the gateway here without one, so its
|
||||
// mint is answered here too.
|
||||
if (opts.mint) return { data: { accessToken: `eyJ.${opts.body.accountId}.sig`, accessExpiresAtMs: Date.now() + 600_000 } };
|
||||
if (!gateway) throw new Error(`no gateway in this test: ${method} ${path}`);
|
||||
return gateway(method, path, opts);
|
||||
};
|
||||
const { putVestSession, vestSessionFor, dropVestSession } = await import('../lib/vest-session.ts');
|
||||
const {
|
||||
botOpenedPositions, loadAccountMeta, saveAccountMeta, saveFundTransactions, wipeAccountData, loadClaimedCycles, saveClaimedCycles,
|
||||
} = await import('../lib/db.ts');
|
||||
const { vestDayOf, vestAccountId, VestApiError, VEST_STATUS_ACTIVE } = await import('../lib/vest-helpers.ts');
|
||||
const { encodeContractName } = await import('../lib/dxtrade-helpers.ts');
|
||||
const { tradeDateFor, stageInputsFor, resolveEffectiveConfig, claimedCycle, endedCycleCompletion } = await import('../lib/trading-logic.ts');
|
||||
const { isClientReady, isOrderNotPlaced } = await import('../lib/platform-client.ts');
|
||||
|
||||
const iso = (s) => Date.parse(s);
|
||||
const WALLET = '0x7ce10771819f80130e204ddb5ca1522e48a9b451';
|
||||
|
||||
// The clock the client reads (Date.now), set to an instant and running on.
|
||||
const realNow = Date.now;
|
||||
after(() => { Date.now = realNow; });
|
||||
let tokens = 0;
|
||||
function clockAt(when) {
|
||||
const start = realNow();
|
||||
const t0 = iso(when);
|
||||
Date.now = () => t0 + (realNow() - start);
|
||||
tokens += 1;
|
||||
assert.equal(putVestSession({ accessToken: `eyJ.test${tokens}.sig`, expiresAt: Date.now() + 3_600_000, primaryAddr: WALLET, userId: 'u' }), null);
|
||||
}
|
||||
|
||||
// The week of NDX hours from check-vest.mjs: maintenance at 21:00 UTC
|
||||
// (17:00 ET) Thursday, the weekly close at 21:00 UTC Friday.
|
||||
const open = (from, to) => ({ fromMs: iso(from), toMs: iso(to), state: { session: 'overnight', closure: 'unspecified', tradingStatus: 'OPEN', price: { allow: true } } });
|
||||
const shut = (from, to, closure) => ({ fromMs: iso(from), toMs: iso(to), state: { session: 'unspecified', closure, tradingStatus: closure === 'weekly' ? 'CLOSE' : 'OPEN', price: { allow: closure !== 'weekly' } } });
|
||||
const week = [
|
||||
open('2026-10-28T22:00:00Z', '2026-10-29T21:00:00Z'),
|
||||
shut('2026-10-29T21:00:00Z', '2026-10-29T22:00:00Z', 'maintenance'),
|
||||
open('2026-10-29T22:00:00Z', '2026-10-30T21:00:00Z'),
|
||||
shut('2026-10-30T21:00:00Z', '2026-11-01T23:00:00Z', 'weekly'),
|
||||
open('2026-11-01T23:00:00Z', '2026-11-02T22:00:00Z'),
|
||||
];
|
||||
const spec = (symbol, assetId) => ({
|
||||
symbol, assetId, sizeDecimals: 3, priceDecimals: 2, tick: 0.01, takerFee: 0.0001, marginRate: 0.05, tradingStatus: 'TRADING', capitalTradingStatus: 'TRADING',
|
||||
});
|
||||
const specs = new Map([['NDX-USD', spec('NDX-USD', 7)], ['BTC-PERP', spec('BTC-PERP', 9)]]);
|
||||
|
||||
/** A client with no sync running, its gateway answered by `answer`, holding
|
||||
* acc-1 and acc-2 under `ids` (the ids a sync gives them: A and B). */
|
||||
function testClient(answer, ids = [1, 2]) {
|
||||
gateway = null;
|
||||
const client = new VestClient({ firmId: 'vest-test', firmName: 'Vest test', wallet: WALLET }, async () => {});
|
||||
clearInterval(client.syncInterval);
|
||||
clearInterval(client.watchdogInterval);
|
||||
gateway = answer;
|
||||
client.specs = specs;
|
||||
client.symbolSpecs = async () => specs;
|
||||
client.marketHours = async (assetId) => (assetId === 7 ? week : []);
|
||||
client.accountList = ids.map((id, i) => ({ id, name: `V50K-F-D3-${i + 1}`, active: true }));
|
||||
ids.forEach((id, i) => {
|
||||
client.uuidById.set(id, `acc-${i + 1}`);
|
||||
client.capital.set(id, { status: VEST_STATUS_ACTIVE });
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
const A = vestAccountId('acc-1');
|
||||
const B = vestAccountId('acc-2');
|
||||
const NDX = encodeContractName('NDX-USD', 0.001);
|
||||
const rawPosition = (positionId, accountId, openDate, extra = {}) => ({
|
||||
positionId, accountId, symbol: 'NDX-USD', side: 'LONG', quantity: '0.500', openPrice: '20000', collateral: '500', leverage: '20', openDate,
|
||||
stopLosses: [], takeProfits: [], ...extra,
|
||||
});
|
||||
|
||||
/** What a sync reads (acc-1 and acc-2 funded, $50k each, and the login's
|
||||
* main account; `positions` open), and the history reads after it. */
|
||||
function syncReads(method, path, positions) {
|
||||
if (method !== 'GET') return null;
|
||||
if (path === '/v3/capital/accounts/active') {
|
||||
return { data: { accounts: ['acc-1', 'acc-2'].map((id, i) => ({
|
||||
id, status: VEST_STATUS_ACTIVE, attempt_index: i + 1, initial_capital: '50000', max_daily_loss_pct: '0.03', stage: 1, stages: [{ ordinal: 1, kind: 'funded' }],
|
||||
})) } };
|
||||
}
|
||||
if (path === '/v3/accounts') {
|
||||
return { data: { accounts: [
|
||||
{ account_id: 'acc-1', amount: '50000', account_type: 2 }, { account_id: 'acc-2', amount: '50000', account_type: 2 }, { account_id: 'main', amount: '0', account_type: 1 },
|
||||
] } };
|
||||
}
|
||||
if (path === '/v3/positions/opened') return { data: { positions } };
|
||||
if (path === '/v3/positions/opened-orders') return { data: { orders: [] } };
|
||||
if (path === '/v3/ticker/latest') return { data: { tickers: [] } };
|
||||
if (path === '/v3/positions') return { data: { positions: [] } };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** testClient on the ids a sync gives, once the constructor's own sync
|
||||
* (which reaches no gateway) is over, with its history taken as read — so
|
||||
* each requestSync here is one good sync, and starts nothing else. */
|
||||
async function syncingClient(answer) {
|
||||
const client = testClient(answer, [A, B]);
|
||||
await client.syncRunning;
|
||||
client.historyLoaded = true;
|
||||
client.historyLoadedIds = new Set([A, B]);
|
||||
client.lastDaysFetchStartedAt = Date.now();
|
||||
client.lastDaysFetchSucceededAt = Infinity;
|
||||
return client;
|
||||
}
|
||||
|
||||
const unanswered = (method, path) => { throw new Error(`no gateway in this test: ${method} ${path}`); };
|
||||
|
||||
test('the market-close guard closes a bot leg whose open came back unknown, not a hand trade', async () => {
|
||||
let positions = [];
|
||||
const closes = [];
|
||||
const client = testClient((method, path, opts) => {
|
||||
if (method === 'GET' && path === '/v3/positions/opened') return { data: { positions } };
|
||||
if (method === 'POST' && path === '/v3/positions/open') throw new Error('socket hang up');
|
||||
if (method === 'POST' && path === '/v3/positions/close') { closes.push({ account: opts.account, ...opts.body }); return { data: {} }; }
|
||||
throw new Error(`no gateway in this test: ${method} ${path}`);
|
||||
});
|
||||
try {
|
||||
// Friday 8:30 AM ET: the entry's send fails in transit, but it filled.
|
||||
clockAt('2026-10-30T12:30:00Z');
|
||||
await assert.rejects(client.sendOrder(1, encodeContractName('NDX-USD', 0.001), 500, 'Buy', 'Market'), /outcome unknown/);
|
||||
const raw = (positionId, accountId, openDate) => ({
|
||||
positionId, accountId, symbol: 'NDX-USD', side: 'LONG', quantity: '0.500', openPrice: '20000', collateral: '500', leverage: '20', openDate,
|
||||
stopLosses: [], takeProfits: [],
|
||||
});
|
||||
positions = [raw('9001', 'acc-1', iso('2026-10-30T12:30:01Z')), raw('9002', 'acc-2', iso('2026-10-30T11:00:00Z'))];
|
||||
client.positionsDetail.set(1, []);
|
||||
client.positionsDetail.set(2, [{ positionId: '9002', accountId: 'acc-2', symbol: 'NDX-USD', side: 'long', quantity: 0.5 }]);
|
||||
client.syncComplete = true;
|
||||
|
||||
// 4:56 PM ET: four minutes to the weekly close.
|
||||
clockAt('2026-10-30T20:56:00Z');
|
||||
await client.guardClose();
|
||||
assert.deepEqual(closes, [{ account: 'acc-1', positionId: '9001', symbol: 'NDX-USD', orderType: 'market' }]);
|
||||
assert.deepEqual([...botOpenedPositions(['9001', '9002'])], ['9001']);
|
||||
// A close just sent isn't sent again while a read may not show it.
|
||||
await client.guardClose();
|
||||
assert.equal(closes.length, 1);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('a symbol the close gate holds back stays on the board, and isn\'t picked; the maintenance hour is ridden', async () => {
|
||||
const client = testClient((method, path) => {
|
||||
throw new Error(`no gateway in this test: ${method} ${path}`);
|
||||
});
|
||||
client.refreshMarks = async () => {};
|
||||
client.volatility = async (symbols) => new Map(symbols.map((s) => [s, { vol: 0.004, candles: 168 }]));
|
||||
const marked = () => { for (const s of ['NDX-USD', 'BTC-PERP']) client.marks.set(s, { mark: s === 'NDX-USD' ? 20000 : 70000, at: Date.now() }); };
|
||||
try {
|
||||
// Thursday 2 PM ET: NDX's maintenance hour is 3 h off, and reopens
|
||||
// before the reset — no gate.
|
||||
clockAt('2026-10-29T18:00:00Z');
|
||||
marked();
|
||||
await client.pickCandidate('Auto', ['NDX-USD', 'BTC-PERP'], false);
|
||||
const thursday = client.lastCandidates.find((c) => c.id === 'NDX-USD');
|
||||
assert.equal(thursday.closesAt, undefined);
|
||||
|
||||
// Friday 2 PM ET: the weekly close is 3 h off; crypto never closes.
|
||||
clockAt('2026-10-30T18:00:00Z');
|
||||
marked();
|
||||
const pick = await client.pickCandidate('Auto', ['NDX-USD', 'BTC-PERP'], false);
|
||||
assert.equal(pick.id, 'BTC-PERP');
|
||||
assert.equal(pick.closesAt, undefined);
|
||||
assert.deepEqual(client.lastCandidates.map((c) => c.id).sort(), ['BTC-PERP', 'NDX-USD']);
|
||||
const ndx = client.lastCandidates.find((c) => c.id === 'NDX-USD');
|
||||
assert.equal(ndx.closesAt, iso('2026-10-30T21:00:00Z'));
|
||||
// The survey keeps it up for the dashboard, though nothing is picked.
|
||||
assert.equal(await client.pickCandidate('survey', ['NDX-USD'], false), null);
|
||||
assert.deepEqual(client.lastCandidates.map((c) => c.id), ['NDX-USD']);
|
||||
// And the client's day is Vest's.
|
||||
assert.equal(client.tradeDate(iso('2026-10-27T22:30:00Z')), vestDayOf(iso('2026-10-27T22:30:00Z')));
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test("a sync whose positions read predates the bot's own fill leaves the fill's record alone", async () => {
|
||||
let positions = [];
|
||||
let holdRead = false;
|
||||
let releaseRead = null;
|
||||
clockAt('2026-10-28T14:00:00Z');
|
||||
const client = await syncingClient((method, path) => {
|
||||
if (method === 'GET' && path === '/v3/positions/opened' && holdRead) {
|
||||
// Read now, flat — answered once released.
|
||||
holdRead = false;
|
||||
const served = positions;
|
||||
return new Promise((resolve) => { releaseRead = () => resolve({ data: { positions: served } }); });
|
||||
}
|
||||
const read = syncReads(method, path, positions);
|
||||
if (read) return read;
|
||||
if (method === 'POST' && path === '/v3/positions/open') {
|
||||
positions = [rawPosition('9101', 'acc-1', Date.now() + 300)];
|
||||
return { data: { orderId: 'o-9101', positionId: '9101' } };
|
||||
}
|
||||
return unanswered(method, path);
|
||||
});
|
||||
try {
|
||||
await client.requestSync();
|
||||
// A sync reads acc-1 flat; the entry fills while it is still out.
|
||||
holdRead = true;
|
||||
const sync = client.requestSync();
|
||||
await client.sendOrder(A, NDX, 500, 'Buy', 'Market', undefined, 'open');
|
||||
// Filled: the note kept against a restart goes.
|
||||
assert.equal(loadAccountMeta(A, 'vest_pending_open'), 'null');
|
||||
releaseRead();
|
||||
await sync;
|
||||
assert.deepEqual(client.positionsDetail.get(A).map((p) => p.positionId), ['9101']);
|
||||
assert.equal(client.positions[A].netPos, 0.5);
|
||||
// A read lagging the fill: the leg's flatten is refused as a position
|
||||
// still out there (FLATTEN FAILED), not taken as nothing to flatten.
|
||||
positions = [];
|
||||
await assert.rejects(client.sendOrder(A, NDX, 500, 'Sell', 'Market', undefined, 'close'), /last saw a long one open/);
|
||||
// A sync that went out after the fill writes what it reads.
|
||||
await client.requestSync();
|
||||
assert.deepEqual(client.positionsDetail.get(A), []);
|
||||
assert.equal(client.positions[A], undefined);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test("an open a restart cut off is marked the bot's once a read after the restart shows it", async () => {
|
||||
let positions = [];
|
||||
let posted;
|
||||
const postedAt = new Promise((resolve) => { posted = resolve; });
|
||||
clockAt('2026-10-28T15:00:00Z');
|
||||
const before = await syncingClient((method, path) => {
|
||||
const read = syncReads(method, path, positions);
|
||||
if (read) return read;
|
||||
// The app stops with the open out.
|
||||
if (method === 'POST' && path === '/v3/positions/open') { posted(Date.now()); return new Promise(() => {}); }
|
||||
return unanswered(method, path);
|
||||
});
|
||||
void before.sendOrder(A, NDX, 500, 'Buy', 'Market', undefined, 'open').catch(() => {});
|
||||
const sentAt = await postedAt;
|
||||
before.disconnect();
|
||||
|
||||
// Vest filled it, and an outside long opened on acc-2 with it. acc-2 has
|
||||
// an older note, of a short that never showed.
|
||||
positions = [rawPosition('9201', 'acc-1', sentAt + 400), rawPosition('9202', 'acc-2', sentAt + 400)];
|
||||
saveAccountMeta(B, 'vest_pending_open', JSON.stringify({ accountId: 'acc-2', symbol: 'NDX-USD', side: 'short', sentAt: sentAt - 10 * 60_000 }));
|
||||
clockAt('2026-10-28T15:02:00Z');
|
||||
let refuse = false;
|
||||
const after = await syncingClient((method, path) => {
|
||||
const read = syncReads(method, path, positions);
|
||||
if (read) return read;
|
||||
if (method === 'POST' && path === '/v3/positions/open' && refuse) throw new VestApiError('/v3/positions/open: refused (HTTP 400)', 400, 'REFUSED');
|
||||
return unanswered(method, path);
|
||||
});
|
||||
try {
|
||||
await after.requestSync();
|
||||
assert.deepEqual([...botOpenedPositions(['9201', '9202'])], ['9201']);
|
||||
assert.equal(loadAccountMeta(A, 'vest_pending_open'), 'null');
|
||||
assert.equal(loadAccountMeta(B, 'vest_pending_open'), 'null');
|
||||
// An open Vest refuses leaves no note behind.
|
||||
positions = [];
|
||||
refuse = true;
|
||||
await assert.rejects(after.sendOrder(A, NDX, 500, 'Buy', 'Market', undefined, 'open'), /refused/);
|
||||
assert.equal(loadAccountMeta(A, 'vest_pending_open'), 'null');
|
||||
} finally {
|
||||
after.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test("an open of unknown outcome isn't matched to an outside trade after its error, nor to one of several", async () => {
|
||||
let positions = [];
|
||||
let open = null;
|
||||
let onRead = () => {};
|
||||
clockAt('2026-10-28T16:00:00Z');
|
||||
const client = await syncingClient((method, path) => {
|
||||
if (method === 'GET' && path === '/v3/positions/opened') onRead();
|
||||
const read = syncReads(method, path, positions);
|
||||
if (read) return read;
|
||||
if (method === 'POST' && path === '/v3/positions/open') return open();
|
||||
return unanswered(method, path);
|
||||
});
|
||||
try {
|
||||
await client.requestSync();
|
||||
// The entry's send fails in transit, and it never went in. Half a
|
||||
// minute on, an outside copy-trader opens NDX long on every account.
|
||||
open = () => { throw new Error('socket hang up'); };
|
||||
await assert.rejects(client.sendOrder(A, NDX, 500, 'Buy', 'Market', undefined, 'open'), /outcome unknown/);
|
||||
const errorAt = Date.now();
|
||||
positions = [rawPosition('9301', 'acc-1', errorAt + 30_000), rawPosition('9302', 'acc-2', errorAt + 30_000)];
|
||||
clockAt('2026-10-28T16:01:00Z');
|
||||
await client.requestSync();
|
||||
assert.deepEqual([...botOpenedPositions(['9301', '9302'])], []);
|
||||
assert.equal(loadAccountMeta(A, 'vest_pending_open'), 'null');
|
||||
|
||||
// Another on acc-2, and two positions show from that moment: either
|
||||
// could be the bot's, so neither is marked.
|
||||
positions = [];
|
||||
clockAt('2026-10-28T16:05:00Z');
|
||||
open = () => {
|
||||
positions = [rawPosition('9303', 'acc-2', Date.now() + 100), rawPosition('9304', 'acc-2', Date.now() + 150)];
|
||||
throw new Error('socket hang up');
|
||||
};
|
||||
await assert.rejects(client.sendOrder(B, NDX, 500, 'Buy', 'Market', undefined, 'open'), /outcome unknown/);
|
||||
await client.requestSync();
|
||||
assert.deepEqual([...botOpenedPositions(['9303', '9304'])], []);
|
||||
|
||||
// An open whose answer named its position, though no read showed it
|
||||
// within the wait: that position is the bot's, even with a stop a
|
||||
// hand has put on it since.
|
||||
positions = [];
|
||||
clockAt('2026-10-28T16:10:00Z');
|
||||
let answered = false;
|
||||
open = () => { answered = true; return { data: { orderId: 'o-9305', positionId: '9305' } }; };
|
||||
// The wait's first read comes back past its deadline.
|
||||
onRead = () => { if (answered) { answered = false; clockAt('2026-10-28T16:10:45Z'); } };
|
||||
await assert.rejects(client.sendOrder(A, NDX, 500, 'Buy', 'Market', undefined, 'open'), /not seen filled/);
|
||||
const stop = { id: 'sl-9305', triggerPrice: '19900', executionType: 'market', status: 'awaiting' };
|
||||
positions = [rawPosition('9305', 'acc-1', iso('2026-10-28T16:10:00Z') + 500, { stopLosses: [stop] })];
|
||||
await client.requestSync();
|
||||
assert.deepEqual([...botOpenedPositions(['9305'])], ['9305']);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test("the client's Vest day turns with its numbers, and a sync that stalls stops the trading it holds back", async () => {
|
||||
clockAt('2026-10-27T23:58:00Z');
|
||||
const client = await syncingClient((method, path) => syncReads(method, path, []) ?? unanswered(method, path));
|
||||
// The day the engine plans the lane on, at this test's clock.
|
||||
const engineDay = () => tradeDateFor(client, new Date(Date.now()));
|
||||
try {
|
||||
// 7:58 PM ET, before any sync: the day now.
|
||||
assert.equal(engineDay(), '2026-10-27');
|
||||
await client.requestSync();
|
||||
// 8:00:20 PM ET: Vest's day has turned, but the numbers the last sync
|
||||
// worked out are still the ended day's — and so is the engine's day.
|
||||
clockAt('2026-10-28T00:00:20Z');
|
||||
assert.equal(vestDayOf(Date.now()), '2026-10-28');
|
||||
assert.equal(engineDay(), '2026-10-27');
|
||||
assert.equal(isClientReady(client), true);
|
||||
// Nothing opens on the ended day's numbers.
|
||||
const opening = await client.sendOrder(A, encodeContractName('NDX-USD', 0.001), 500, 'Buy', 'Market', undefined, 'open').catch((err) => err);
|
||||
assert.equal(isOrderNotPlaced(opening), true);
|
||||
assert.match(opening.message, /no sync has read it yet/);
|
||||
await client.requestSync();
|
||||
assert.equal(engineDay(), '2026-10-28');
|
||||
// No sync after the next reset: the day holds back only while the
|
||||
// scheduler still trades on the last sync's numbers.
|
||||
clockAt('2026-10-28T23:59:00Z');
|
||||
await client.requestSync();
|
||||
clockAt('2026-10-29T00:03:00Z');
|
||||
assert.equal(engineDay(), '2026-10-28');
|
||||
assert.equal(isClientReady(client), true);
|
||||
clockAt('2026-10-29T00:05:00Z');
|
||||
assert.equal(engineDay(), '2026-10-28');
|
||||
assert.equal(isClientReady(client), false);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// A timeout: an ask let through twice leaves the first one waiting forever.
|
||||
test("a passed evaluation doesn't hold the sync, and its funded account is asked for once", { timeout: 10_000 }, async () => {
|
||||
clockAt('2026-10-29T15:31:00Z');
|
||||
// Vest's status for each account (acc-2 passed), and the balance Vest's
|
||||
// active list leaves out — a passed one's.
|
||||
const status = { 'acc-1': 2, 'acc-2': 3 };
|
||||
let missingBalance = 'acc-2';
|
||||
let activation = null;
|
||||
const sent = [];
|
||||
const client = await syncingClient((method, path, opts) => {
|
||||
if (method === 'GET' && path === '/v3/capital/accounts/active') {
|
||||
return { data: { accounts: Object.keys(status).map((id, i) => ({
|
||||
id, status: status[id], account_type: 2, stage: 1, step: 1, steps_total: 1, plan_product_type: 'eval',
|
||||
attempt_index: i + 80, initial_capital: '50000', max_daily_loss_pct: '0.03',
|
||||
stages: [{ ordinal: 1, kind: 'eval', profit_target: '5000' }, { ordinal: 2, kind: 'funded', profit_target: '' }],
|
||||
})) } };
|
||||
}
|
||||
if (method === 'GET' && path === '/v3/accounts') {
|
||||
return { data: { accounts: [
|
||||
{ account_id: 'acc-1', amount: '50000', account_type: 2 }, { account_id: 'acc-2', amount: '55100', account_type: 2 }, { account_id: 'main', amount: '0', account_type: 1 },
|
||||
].filter((b) => b.account_id !== missingBalance) } };
|
||||
}
|
||||
if (method === 'POST' && path === '/v3/capital/activate-funded') {
|
||||
sent.push({ account: opts.account, body: opts.body });
|
||||
return activation();
|
||||
}
|
||||
return syncReads(method, path, []) ?? unanswered(method, path);
|
||||
});
|
||||
const funded = { id: 'acc-2-funded', status: 2, account_type: 3, stage: 2, attempt_index: 81, initial_capital: '50000', max_daily_loss_pct: '0.03', stages: [{ ordinal: 1, kind: 'eval' }, { ordinal: 2, kind: 'funded' }] };
|
||||
// The sync an ask starts behind it, run to its end.
|
||||
const settled = async () => {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await client.syncRunning;
|
||||
};
|
||||
try {
|
||||
await client.requestSync();
|
||||
assert.ok(client.lastSyncAt > 0, "the passed account's missing balance didn't make the sync short of good");
|
||||
assert.equal(client.accountList.find((a) => a.id === B).active, false);
|
||||
assert.deepEqual(client.activatable(), [B]);
|
||||
await assert.rejects(client.activateFunded(A), /only a passed evaluation/);
|
||||
|
||||
// While the ask is out, a second caller is refused, and a sync reads
|
||||
// a list from before Vest's answer: it isn't asked again on it.
|
||||
let answer;
|
||||
activation = () => new Promise((resolve) => { answer = resolve; });
|
||||
const asking = client.activateFunded(B);
|
||||
await assert.rejects(client.activateFunded(B), /hasn't been read back/);
|
||||
clockAt('2026-10-29T15:31:30Z');
|
||||
await client.requestSync();
|
||||
assert.deepEqual(client.activatable(), []);
|
||||
answer({ data: { account: funded } });
|
||||
assert.deepEqual(await asking, { funded: 'V50K-F-D3-81' });
|
||||
assert.deepEqual(sent, [{ account: 'acc-2', body: { eval_account_id: 'acc-2' } }]);
|
||||
|
||||
// Answered: never asked again, while Vest's list is slow to move it
|
||||
// or once it's claimed.
|
||||
await settled();
|
||||
clockAt('2026-10-29T15:40:00Z');
|
||||
await client.requestSync();
|
||||
assert.deepEqual(client.activatable(), []);
|
||||
status['acc-2'] = 6;
|
||||
await client.requestSync();
|
||||
assert.deepEqual(client.activatable(), []);
|
||||
assert.equal(sent.length, 1);
|
||||
|
||||
// acc-1 passes too. A timeout may have gone in: held until a list
|
||||
// read a minute after it. A refusal went nowhere: free at once.
|
||||
status['acc-1'] = 3;
|
||||
missingBalance = 'acc-1';
|
||||
await client.requestSync();
|
||||
assert.deepEqual(client.activatable(), [A]);
|
||||
activation = () => { throw new Error('/v3/capital/activate-funded timed out after 15s'); };
|
||||
await assert.rejects(client.activateFunded(A), /timed out/);
|
||||
await settled();
|
||||
assert.deepEqual(client.activatable(), []);
|
||||
clockAt('2026-10-29T15:40:30Z');
|
||||
await client.requestSync();
|
||||
assert.deepEqual(client.activatable(), []);
|
||||
clockAt('2026-10-29T15:41:10Z');
|
||||
await client.requestSync();
|
||||
assert.deepEqual(client.activatable(), [A]);
|
||||
activation = () => { throw new VestApiError('/v3/capital/activate-funded: funded limit reached (HTTP 400)', 400); };
|
||||
await assert.rejects(client.activateFunded(A), /funded limit/);
|
||||
assert.deepEqual(client.activatable(), [A]);
|
||||
assert.equal(sent.length, 3);
|
||||
|
||||
// A live account's missing balance still holds the sync.
|
||||
status['acc-1'] = 2;
|
||||
const good = client.lastSyncAt;
|
||||
clockAt('2026-10-29T15:45:00Z');
|
||||
await client.requestSync();
|
||||
assert.equal(client.lastSyncAt, good);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('two payouts on one Vest day share its fund row, and still count two toward the stage', async () => {
|
||||
clockAt('2026-10-27T23:00:00Z');
|
||||
const client = await syncingClient(unanswered);
|
||||
try {
|
||||
// $1,500 then $3,000 taken on Vest day 10-27: Vest's two WITHDRAWs, one row.
|
||||
client.withdrawals.set(A, [{ at: iso('2026-10-27T15:00:00Z'), amount: -1500 }, { at: iso('2026-10-27T19:00:00Z'), amount: -3000 }]);
|
||||
client.fundTransactions[A] = [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-27', amount: -4500 }];
|
||||
assert.deepEqual(client.payoutDays(A), ['2026-10-27', '2026-10-27']);
|
||||
// A type whose stages don't reset each day trades stage 3's $2,000, not stage 2's again.
|
||||
const stages = [{ profit: 3000, consistency: 0, minTradingDays: 0 }, { profit: 2000, consistency: 0, minTradingDays: 0 }];
|
||||
const inputs = stageInputsFor(client, A, { stagesSameDay: false });
|
||||
assert.equal(inputs.payouts, 2);
|
||||
assert.equal(resolveEffectiveConfig(1500, 0, 0, false, stages, 0, client.fundTransactions[A], 0, inputs).profitTarget, 2000);
|
||||
// Before its history is read: one per negative fund row.
|
||||
client.historyLoadedIds.delete(A);
|
||||
assert.equal(stageInputsFor(client, A, {}).payouts, undefined);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('each claim of a Vest day names the completed cycle it ends, the second and the one after the reset too', async () => {
|
||||
// acc-1, funded $50k, Vest's own numbers: its balance, and its reset balance since 20:00 ET.
|
||||
let balance = 51500;
|
||||
let reset = { at: '2026-10-27T00:00:00Z', balance: 50000 };
|
||||
clockAt('2026-10-27T19:00:00Z'); // 3 PM ET, Vest day 10-27
|
||||
wipeAccountData(A);
|
||||
const client = await syncingClient((method, path, opts) => {
|
||||
const read = syncReads(method, path, []);
|
||||
if (read && path === '/v3/capital/accounts/active') {
|
||||
read.data.accounts[0] = { ...read.data.accounts[0], daily_reset_at: reset.at, daily_reset_balance: String(reset.balance) };
|
||||
}
|
||||
if (read && path === '/v3/accounts') read.data.accounts[0] = { ...read.data.accounts[0], amount: String(balance) };
|
||||
if (read) return read;
|
||||
if (method === 'POST' && path === '/v3/capital/withdraw' && opts.body.account_id === 'acc-1') {
|
||||
balance = Math.round((balance - Number(opts.body.amount)) * 100) / 100;
|
||||
return { data: { trader_amount: opts.body.amount } };
|
||||
}
|
||||
return unanswered(method, path);
|
||||
});
|
||||
// The history read a claim starts isn't this test's: what it books is set here.
|
||||
client.fetchDaysTraded = async () => ({ failedAccounts: 0 });
|
||||
const cfg = {
|
||||
profitTarget: 1500, consistency: 0, minDayPnL: 0, minTradingDays: 0, accountSize: 50000, targetSameEquity: false,
|
||||
splitEnabled: true, payoutable: true, tradesEveryDay: true, combineCmeHolidays: false, stagesSameDay: true, dailyLossLimit: 1500,
|
||||
withdrawalStages: [{ profit: 3000, consistency: 0, minTradingDays: 0, dailyLossLimit: 3000 }],
|
||||
};
|
||||
/** The day's trading so far, as the history read books it, and Vest's WITHDRAWs for the claims made. */
|
||||
const history = (dayPnl) => {
|
||||
client.fullDailyPnL[A] = [{ date: '2026-10-27', pnl: dayPnl }];
|
||||
client.applyCycleSlice(A);
|
||||
client.withdrawals.set(A, client.claimsOf(A).map((c) => ({ at: c.at, amount: -c.amount })));
|
||||
};
|
||||
/** The cycle a claim now would name, stamped with the claim's time. */
|
||||
const named = () => {
|
||||
const now = Date.now();
|
||||
const c = claimedCycle(client, A, cfg, new Date(now));
|
||||
if (c) { assert.equal(c.at, now); delete c.at; }
|
||||
return c;
|
||||
};
|
||||
try {
|
||||
saveFundTransactions(A, 'V50K-F-D3-1', [{ date: '2026-10-20', amount: 50000 }]);
|
||||
saveAccountMeta(A, 'last_fund_date', '2026-10-20');
|
||||
client.fundTransactions[A] = [{ date: '2026-10-20', amount: 50000 }];
|
||||
client.lastFundDates[A] = '2026-10-20';
|
||||
history(1500);
|
||||
await client.requestSync();
|
||||
|
||||
// Stage 1's $1,500, claimed: the account's first cycle.
|
||||
assert.deepEqual(named(), { date: '2026-10-27', stage: 1, drain: false, cycle: '2026-10-20', cycleSeq: 0 });
|
||||
await client.claimProfit(A, 1500);
|
||||
await client.syncRunning; // the sync the claim starts
|
||||
|
||||
// 6 PM ET: stage 2's $3,000 made, and claimed the same Vest day — its
|
||||
// payout books into 10-27's row, and the latest fund date stays.
|
||||
clockAt('2026-10-27T22:00:00Z');
|
||||
balance = 53000;
|
||||
history(4500);
|
||||
await client.requestSync();
|
||||
assert.deepEqual(named(), { date: '2026-10-27', stage: 2, drain: false, cycle: '2026-10-27', cycleSeq: 0 });
|
||||
await client.claimProfit(A, 3000);
|
||||
await client.syncRunning;
|
||||
assert.equal(client.lastFundDates[A], '2026-10-27');
|
||||
assert.deepEqual(client.fundTransactions[A].at(-1), { date: '2026-10-27', amount: -4500 });
|
||||
|
||||
// 6:30 PM ET: the day's third cycle, $1,500 of stage 2's $3,000 — not complete.
|
||||
clockAt('2026-10-27T22:30:00Z');
|
||||
balance = 51500;
|
||||
history(6000);
|
||||
await client.requestSync();
|
||||
assert.equal(named(), null);
|
||||
|
||||
// 8:00:30 PM ET: the reset puts it back on stage 1's $1,500, met —
|
||||
// claimed at the first tick.
|
||||
clockAt('2026-10-28T00:00:30Z');
|
||||
reset = { at: '2026-10-28T00:00:00Z', balance: 51500 };
|
||||
await client.requestSync();
|
||||
assert.equal(tradeDateFor(client, new Date(Date.now())), '2026-10-28');
|
||||
assert.deepEqual(named(), { date: '2026-10-28', stage: 3, drain: false, cycle: '2026-10-27', cycleSeq: 1 });
|
||||
await client.claimProfit(A, 1500);
|
||||
await client.syncRunning;
|
||||
assert.equal(client.lastFundDates[A], '2026-10-28');
|
||||
|
||||
// The catch-up names none of it: the cycle's P&L sits in 10-27's row,
|
||||
// which the cycle before owns.
|
||||
clockAt('2026-10-28T00:10:00Z');
|
||||
await client.requestSync();
|
||||
assert.equal(endedCycleCompletion({
|
||||
accountId: A, rawTradeDate: tradeDateFor(client, new Date(Date.now())), client,
|
||||
fullDailyPnL: client.fullDailyPnL[A], cycleDailyPnL: client.dailyPnL[A], fundTransactions: client.fundTransactions[A],
|
||||
lastFundDate: client.lastFundDates[A], endedCycleStart: JSON.parse(loadAccountMeta(A, 'ended_cycle_start')),
|
||||
rawRealizedPnL: client.accountCashBalances[A].realizedPnL, equityProfit: client.accountCashBalances[A].amount - 50000, cfg,
|
||||
now: new Date(Date.now()),
|
||||
}), null);
|
||||
|
||||
// Kept as the claims leave them; an entry that doesn't read is left out.
|
||||
const kept = [{ date: '2026-10-27', stage: 2, drain: false, cycle: '2026-10-27', cycleSeq: 0, at: iso('2026-10-27T22:00:00Z') }];
|
||||
saveClaimedCycles(A, [...kept, { date: '2026-10-28', stage: '3', drain: false, cycle: '2026-10-27', cycleSeq: 1 }]);
|
||||
assert.deepEqual(loadClaimedCycles(A), kept);
|
||||
saveAccountMeta(A, 'claimed_cycles', '{');
|
||||
assert.deepEqual(loadClaimedCycles(A), []);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test("a refused account-token mint fails only that account: the login's session stays, and the next account's reads go out", async () => {
|
||||
clockAt('2026-10-29T05:41:00Z');
|
||||
const client = testClient(unanswered, [A, B]);
|
||||
await client.syncRunning;
|
||||
// The client's own transport, on a gateway that refuses acc-1's mint as
|
||||
// Vest did all night on 2026-10-28 (401, code 1002).
|
||||
client.request = realRequest;
|
||||
const adapter = axios.defaults.adapter;
|
||||
const sent = [];
|
||||
axios.defaults.adapter = async (config) => {
|
||||
const path = config.url.replace(/^https:\/\/[^/]+/, '');
|
||||
const body = typeof config.data === 'string' ? JSON.parse(config.data) : undefined;
|
||||
sent.push(`${config.method.toUpperCase()} ${path}${body?.accountId ? ` ${body.accountId}` : ''}`);
|
||||
const reply = (status, data) => ({ status, statusText: '', headers: {}, config, data });
|
||||
if (path === '/v3/auth/account-token') {
|
||||
return body.accountId === 'acc-1'
|
||||
? reply(401, { detail: { code: 1002, msg: 'Unauthorized' } })
|
||||
: reply(200, { accessToken: `eyJ.${body.accountId}.sig`, accessExpiresAtMs: Date.now() + 600_000 });
|
||||
}
|
||||
if (path === '/v3/transactions') return reply(200, { transactions: [] });
|
||||
if (path === '/v3/positions') return reply(200, { positions: [] });
|
||||
return reply(404, {});
|
||||
};
|
||||
try {
|
||||
const { failedAccounts } = await client.fetchDaysTraded([A, B]);
|
||||
assert.equal(failedAccounts, 1);
|
||||
assert.notEqual(vestSessionFor(WALLET), null);
|
||||
assert.equal(client.historyLoadedIds.has(A), false);
|
||||
assert.equal(client.historyLoadedIds.has(B), true);
|
||||
assert.match(client.lastFetchErrors['V50K-F-D3-1'], /code 1002/);
|
||||
assert.ok(sent.includes('POST /v3/auth/account-token acc-2'));
|
||||
assert.ok(sent.includes('GET /v3/transactions'));
|
||||
} finally {
|
||||
axios.defaults.adapter = adapter;
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('an active account with no history is read again within a minute, while a whole pass waits out its retry', async () => {
|
||||
clockAt('2026-10-29T05:41:00Z');
|
||||
const C = vestAccountId('acc-3');
|
||||
const client = testClient(unanswered, [A, B, C]);
|
||||
await client.syncRunning;
|
||||
// acc-3 isn't active: it waits for a whole pass.
|
||||
client.capital.set(C, { status: 3 });
|
||||
client.accountList[2].active = false;
|
||||
client.historyLoaded = true;
|
||||
client.historyLoadedIds = new Set([A]);
|
||||
client.lastDaysFetchStartedAt = Date.now() - 120_000;
|
||||
// A whole pass failed: its retry is four minutes off.
|
||||
client.nextDaysFetchRetryAt = Date.now() + 4 * 60_000;
|
||||
const reads = [];
|
||||
client.fetchDaysTraded = async (ids) => {
|
||||
reads.push(ids);
|
||||
return { failedAccounts: ids ? ids.length : 0 };
|
||||
};
|
||||
const settled = () => new Promise((resolve) => setImmediate(resolve));
|
||||
try {
|
||||
client.maybeFetchDaysTraded();
|
||||
await settled();
|
||||
assert.deepEqual(reads, [[B]]);
|
||||
// It failed again: not before the minute is up, and the whole pass's
|
||||
// retry stays where it was.
|
||||
client.maybeFetchDaysTraded();
|
||||
await settled();
|
||||
assert.deepEqual(reads, [[B]]);
|
||||
clockAt('2026-10-29T05:42:01Z');
|
||||
client.maybeFetchDaysTraded();
|
||||
await settled();
|
||||
assert.deepEqual(reads, [[B], [B]]);
|
||||
clockAt('2026-10-29T05:45:01Z');
|
||||
client.maybeFetchDaysTraded();
|
||||
await settled();
|
||||
assert.deepEqual(reads, [[B], [B], undefined]);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test("a pass reads the accounts with no history first and the inactive last, and an inactive one's failure isn't the pass's", async () => {
|
||||
clockAt('2026-10-29T05:41:00Z');
|
||||
const C = vestAccountId('acc-3');
|
||||
const client = testClient(unanswered, [A, B, C]);
|
||||
await client.syncRunning;
|
||||
client.capital.set(C, { status: 3 });
|
||||
client.accountList[2].active = false;
|
||||
client.historyLoadedIds = new Set([A]);
|
||||
const order = [];
|
||||
client.fetchTxns = async (uuid) => {
|
||||
order.push(uuid);
|
||||
if (uuid === 'acc-3') throw new Error('/v3/transactions not sent — no account token: Unauthorized (HTTP 401, code 1002)');
|
||||
return [];
|
||||
};
|
||||
client.fetchTrades = async () => {};
|
||||
try {
|
||||
const { failedAccounts } = await client.fetchDaysTraded([A, B, C]);
|
||||
assert.deepEqual(order, ['acc-2', 'acc-1', 'acc-3']);
|
||||
assert.equal(failedAccounts, 0);
|
||||
assert.match(client.lastFetchErrors['V50K-F-D3-3'], /code 1002/);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('exits wait for the login session to be relayed again; an open goes now or not at all', async () => {
|
||||
clockAt('2026-10-29T06:00:00Z');
|
||||
const legs = [];
|
||||
const client = testClient((method, path, opts) => {
|
||||
if (method === 'GET' && path === '/v3/positions/opened') return { data: { positions: [rawPosition('9101', 'acc-1', iso('2026-10-29T05:59:00Z'))] } };
|
||||
if (method === 'POST' && (path === '/v3/positions/stop-loss' || path === '/v3/positions/take-profit')) {
|
||||
legs.push(`${path.slice('/v3/positions/'.length)} @ ${opts.body.triggerPrice}`);
|
||||
return { data: {} };
|
||||
}
|
||||
throw new Error(`no gateway in this test: ${method} ${path}`);
|
||||
}, [A, B]);
|
||||
try {
|
||||
// The session lapses (Vest refused it) just after an entry filled.
|
||||
dropVestSession(WALLET, vestSessionFor(WALLET).accessToken);
|
||||
assert.equal(vestSessionFor(WALLET), null);
|
||||
const opening = await client.sendOrder(B, NDX, 500, 'Buy', 'Market', undefined, 'open').catch((err) => err);
|
||||
assert.equal(isOrderNotPlaced(opening), true);
|
||||
assert.match(opening.message, /no Vest session/);
|
||||
|
||||
const exits = client.placeOcoExit(A, NDX, 500, 'Sell', 20100, 19900);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_200));
|
||||
assert.deepEqual(legs, []);
|
||||
// The extension relays it again: the exits go, the stop first.
|
||||
tokens += 1;
|
||||
assert.equal(putVestSession({ accessToken: `eyJ.test${tokens}.sig`, expiresAt: Date.now() + 3_600_000, primaryAddr: WALLET, userId: 'u' }), null);
|
||||
await exits;
|
||||
assert.deepEqual(legs, ['stop-loss @ 19900', 'take-profit @ 20100']);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
+358
-5
@@ -1,9 +1,10 @@
|
||||
// Checks for the Vest client's pure helpers (lib/vest-helpers.ts) and the
|
||||
// relayed-session store (lib/vest-session.ts).
|
||||
// Checks for the Vest client's pure helpers (lib/vest-helpers.ts), the
|
||||
// relayed-session store (lib/vest-session.ts), and the trade date the engine
|
||||
// plans Vest accounts on (lib/trading-logic.ts).
|
||||
//
|
||||
// npm run check
|
||||
//
|
||||
// Both files import only Node built-ins, so Node strips their types and loads
|
||||
// These files import only Node built-ins, so Node strips their types and loads
|
||||
// them directly; .mjs keeps this file out of Next's type check. The samples are
|
||||
// shaped like the gateway's real answers (captured 2026-10-26).
|
||||
import { test } from 'node:test';
|
||||
@@ -11,9 +12,13 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
isVestWalletAddress, vestAccountId, num, toMs, formatDecimal, vestDayOf, lastVestResetMs, parseCapitalAccount,
|
||||
vestAccountName, parsePosition, awaitingLegs, parseOpenOrder, unrealizedPnl, parseTxn, bookTxns, txnKind, VEST_TXN,
|
||||
hourlyVolatility, marketOpen, MIN_HOURLY_RETURNS, nextVestResetMs, resetSafeStop, parseHistoryPosition,
|
||||
hourlyVolatility, marketOpen, MIN_HOURLY_RETURNS, nextVestResetMs, resetSafeStop, parseHistoryPosition, jwtClaimStrings,
|
||||
accountScopedToken, VestApiError, VestNotSentError, nothingWentIn, tokenForRequest, claimWithdrawal, settleClaims,
|
||||
claimFundRows, readClaims, payoutDaysOf, payoutsSince, longClosure, unknownOpenFill, awaitingActivation, vestStatusName,
|
||||
VEST_STATUS,
|
||||
} from '../lib/vest-helpers.ts';
|
||||
import { putVestSession, vestSessionFor, dropVestSession, listVestSessions } from '../lib/vest-session.ts';
|
||||
import { putVestSession, vestSessionFor, dropVestSession, listVestSessions, refuseVestSession } from '../lib/vest-session.ts';
|
||||
import { computeDayPlan, currentTradeDateCT, tradeDateFor, cycleCompletion } from '../lib/trading-logic.ts';
|
||||
|
||||
const iso = (s) => Date.parse(s);
|
||||
|
||||
@@ -116,6 +121,41 @@ test('capital accounts parse, and are named for their rules', () => {
|
||||
assert.equal(parseCapitalAccount({ nope: true }), null);
|
||||
});
|
||||
|
||||
test('a passed evaluation waits on its funded account, as the Vest app decides', () => {
|
||||
const passed = { ...evalAccount, status: VEST_STATUS.GOAL_PASSED };
|
||||
const a = parseCapitalAccount(passed);
|
||||
assert.equal(a.step, 1);
|
||||
assert.equal(a.stepsTotal, 1);
|
||||
assert.equal(a.accountType, 2);
|
||||
assert.equal(a.blocked, false);
|
||||
assert.equal(awaitingActivation(a), true);
|
||||
|
||||
// Still trading, already claimed, failed or expired: nothing to activate.
|
||||
for (const status of [1, 2, 4, 5, 6, 7]) assert.equal(awaitingActivation(parseCapitalAccount({ ...evalAccount, status })), false, `status ${status}`);
|
||||
// Blocked by Vest.
|
||||
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, blocked_at: 1793287000000 })), false);
|
||||
// A funded account that reached its goal isn't an evaluation — on the
|
||||
// last step's number too, so only the evaluation test can say so.
|
||||
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, account_type: 3, stage: 2, step: 1 })), false);
|
||||
// Either of Vest's marks makes it one.
|
||||
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, account_type: 3 })), true);
|
||||
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, stage: 2, step: 1 })), true);
|
||||
// A 2-Step's first step passes on to its second by itself; the last one waits.
|
||||
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, step: 1, steps_total: 2 })), false);
|
||||
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, step: 2, steps_total: 2 })), true);
|
||||
|
||||
// Without step or steps_total, the Vest app's own fallbacks.
|
||||
const { step, steps_total, ...bare } = passed;
|
||||
assert.equal(awaitingActivation(parseCapitalAccount(bare)), true);
|
||||
assert.equal(parseCapitalAccount({ ...bare, stage: 2 }).step, 2);
|
||||
assert.equal(parseCapitalAccount({ ...bare, plan_product_type: 'instant_funded' }).stepsTotal, 0);
|
||||
assert.equal(awaitingActivation(parseCapitalAccount({ ...bare, plan_product_type: 'instant_funded' })), false);
|
||||
|
||||
assert.equal(vestStatusName(3), 'goal passed (status 3)');
|
||||
assert.equal(vestStatusName(6), 'claimed (status 6)');
|
||||
assert.equal(vestStatusName(9), 'unknown (status 9)');
|
||||
});
|
||||
|
||||
const openPosition = {
|
||||
positionId: 'afe96d7e-f5ac-4b8a-aae8-f5b63b74e508', accountId: '843f60e2-e013-5a0e-8b6c-face84bc5509', accountType: 2,
|
||||
symbol: 'NDX-USD-PERP', side: 'short', quantity: '42.6545', openPrice: '29529.250000000', closePrice: '290.250000000',
|
||||
@@ -125,6 +165,35 @@ const openPosition = {
|
||||
takeProfits: [],
|
||||
};
|
||||
|
||||
test('an open of unknown outcome is the position on its account, symbol and side opened around it', () => {
|
||||
const pos = (positionId, accountId, side, openedAt, symbol = 'NDX-USD', stopLosses = []) => ({
|
||||
positionId, accountId, symbol, side, quantity: 0.5, openPrice: 20000, collateral: 500, leverage: 20, openedAt, stopLosses, takeProfits: [],
|
||||
});
|
||||
// Sent at 12:30:00, its error at 12:30:15: the window is a few seconds of clock either side.
|
||||
const sent = { accountId: 'acc-1', symbol: 'NDX-USD', side: 'long', from: iso('2026-10-30T12:29:55Z'), until: iso('2026-10-30T12:30:20Z') };
|
||||
const filled = pos('9001', 'acc-1', 'long', iso('2026-10-30T12:30:01Z'));
|
||||
assert.deepEqual(unknownOpenFill(sent, [pos('9002', 'acc-2', 'long', iso('2026-10-30T12:30:01Z')), filled]), [filled]);
|
||||
// Another side or symbol, a position from before it, or one opened after
|
||||
// the window (a hand trade, or an outside copy-trader, half a minute on): not it.
|
||||
assert.deepEqual(unknownOpenFill(sent, [pos('9003', 'acc-1', 'short', iso('2026-10-30T12:30:01Z'))]), []);
|
||||
assert.deepEqual(unknownOpenFill(sent, [pos('9004', 'acc-1', 'long', iso('2026-10-30T12:30:01Z'), 'SPX-USD')]), []);
|
||||
assert.deepEqual(unknownOpenFill(sent, [pos('9005', 'acc-1', 'long', iso('2026-10-30T11:00:00Z'))]), []);
|
||||
assert.deepEqual(unknownOpenFill(sent, [pos('9006', 'acc-1', 'long', iso('2026-10-30T12:30:45Z'))]), []);
|
||||
// No open time: can't be told from a hand trade.
|
||||
assert.deepEqual(unknownOpenFill(sent, [pos('9007', 'acc-1', 'long', NaN)]), []);
|
||||
// One with exit legs isn't the bot's open — none went on it.
|
||||
const leg = { id: 'sl-1', triggerPrice: 19900, executionType: 'market', status: 'awaiting' };
|
||||
assert.deepEqual(unknownOpenFill(sent, [pos('9008', 'acc-1', 'long', iso('2026-10-30T12:30:01Z'), 'NDX-USD', [leg])]), []);
|
||||
// Two inside the window: both come back, for the caller to adopt neither.
|
||||
const copy = pos('9009', 'acc-1', 'long', iso('2026-10-30T12:30:02Z'));
|
||||
assert.deepEqual(unknownOpenFill(sent, [filled, copy]), [filled, copy]);
|
||||
// The position id the open's answer named settles it, legs and window aside.
|
||||
const named = pos('9010', 'acc-1', 'long', iso('2026-10-30T12:31:00Z'), 'NDX-USD', [leg]);
|
||||
assert.deepEqual(unknownOpenFill({ ...sent, positionId: '9010' }, [filled, copy, named]), [named]);
|
||||
assert.deepEqual(unknownOpenFill({ ...sent, positionId: '9010' }, [filled, copy]), []);
|
||||
assert.deepEqual(unknownOpenFill({ ...sent, positionId: '9010' }, [{ ...named, accountId: 'acc-2' }]), []);
|
||||
});
|
||||
|
||||
test('positions, their legs and unrealized P&L', () => {
|
||||
const p = parsePosition(openPosition);
|
||||
assert.equal(p.side, 'short');
|
||||
@@ -170,6 +239,143 @@ test('transactions book into daily P&L and fund rows by Vest day', () => {
|
||||
assert.ok(Math.abs(daily.get('2026-10-27').fees - 31.2) < 1e-9);
|
||||
});
|
||||
|
||||
const withdrawalsOf = (txns) => txns.filter((t) => t.type === VEST_TXN.WITHDRAW).map((t) => ({ at: t.time, amount: t.amount }));
|
||||
const deposit = { action_id: 'd', amount: '50000', created_at: iso('2026-10-20T14:00:00Z'), transaction_type: 1 };
|
||||
|
||||
test('a claim of unknown outcome that never shows lapses an hour on; one Vest accepted never does', () => {
|
||||
const at = iso('2026-10-27T15:00:00Z');
|
||||
const lost = { at, amount: 2250, base: 50000, unknown: true };
|
||||
// Read just after the request failed: it may yet show, so it holds.
|
||||
assert.deepEqual(settleClaims([lost], [], at + 60_000), { pending: [lost], lapsed: [] });
|
||||
// Still not shown by a read an hour on: it never went in.
|
||||
assert.deepEqual(settleClaims([lost], [], at + 61 * 60_000), { pending: [], lapsed: [lost] });
|
||||
// It went in after all: a WITHDRAW of its amount near it settles it.
|
||||
assert.deepEqual(settleClaims([lost], [{ at: at + 3_000, amount: -2250 }], at + 61 * 60_000), { pending: [], lapsed: [] });
|
||||
assert.equal(claimWithdrawal([{ at: at + 3_000, amount: -2249 }], lost), undefined, 'another amount');
|
||||
assert.equal(claimWithdrawal([{ at: at + 31 * 60_000, amount: -2250 }], lost), undefined, 'too far off');
|
||||
assert.deepEqual(claimWithdrawal([{ at: at + 20 * 60_000, amount: -2250 }, { at: at - 2_000, amount: -2250 }], lost), { at: at - 2_000, amount: -2250 });
|
||||
// A claim Vest accepted holds for as long as it doesn't show.
|
||||
const accepted = { at, amount: 2250, base: 50000 };
|
||||
assert.deepEqual(settleClaims([accepted], [], at + 48 * 3_600_000), { pending: [accepted], lapsed: [] });
|
||||
});
|
||||
|
||||
test('the books stop counting a claim that never went in once it lapses, so it holds the account no longer', () => {
|
||||
const at = iso('2026-10-27T15:00:00Z');
|
||||
const lost = { at, sentAt: at - 20_000, amount: 2250, base: 50150, unknown: true };
|
||||
const txns = [deposit, { action_id: 'r1', amount: '2400', created_at: at - 3_600_000, transaction_type: 9 }].map(parseTxn);
|
||||
const read = (claims, readAt) => readClaims({ txns, funds: bookTxns(txns).funds, claims, stored: [], readAt, opening: 0 });
|
||||
// It never went in: the balance is what the transactions add up to.
|
||||
const cash = 52400;
|
||||
// Half an hour on it may yet show, so the books still take it out (and hold).
|
||||
const early = read([lost], at + 30 * 60_000);
|
||||
assert.deepEqual([early.lapsed, early.outstanding], [[], [lost]]);
|
||||
assert.equal(cash - early.expected, 2250);
|
||||
// A read an hour on lapses it, and the books agree again.
|
||||
const late = read([lost], at + 61 * 60_000);
|
||||
assert.deepEqual([late.lapsed, late.outstanding], [[lost], []]);
|
||||
assert.equal(late.expected, cash);
|
||||
assert.deepEqual(late.rows, [{ date: '2026-10-20', amount: 50000 }]);
|
||||
// One Vest accepted that doesn't show yet: out of the balance and on its
|
||||
// day's row, however long it takes.
|
||||
const accepted = { at, sentAt: at - 20_000, amount: 2250, base: 50150 };
|
||||
const waiting = read([accepted], at + 48 * 3_600_000);
|
||||
assert.deepEqual([waiting.lapsed, waiting.outstanding], [[], [accepted]]);
|
||||
assert.equal(waiting.expected, cash - 2250);
|
||||
assert.deepEqual(waiting.rows, [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-27', amount: -2250 }]);
|
||||
// Once it shows, the WITHDRAW itself is in the sum.
|
||||
const shown = [...txns, parseTxn({ action_id: 'w1', amount: '2250', created_at: at + 2_000, transaction_type: 2 })];
|
||||
const settled = readClaims({ txns: shown, funds: bookTxns(shown).funds, claims: [accepted], stored: [], readAt: at + 3_600_000, opening: 0 });
|
||||
assert.deepEqual(settled.withdrawals, [{ at: at + 2_000, amount: -2250 }]);
|
||||
assert.deepEqual(settled.outstanding, []);
|
||||
assert.equal(settled.expected, cash - 2250);
|
||||
// An account Vest funded without a deposit row: its capital counts in.
|
||||
assert.equal(readClaims({ txns: txns.slice(1), funds: new Map(), claims: [], stored: [], readAt: at, opening: 50000 }).expected, cash);
|
||||
});
|
||||
|
||||
test('an account token that can\'t be minted means the order or claim was never sent', async () => {
|
||||
const path = '/v3/capital/withdraw';
|
||||
assert.equal(await tokenForRequest(path, async () => 'tok'), 'tok');
|
||||
const failures = [
|
||||
new Error('/v3/auth/account-token timed out after 20s'),
|
||||
new VestApiError('/v3/auth/account-token: HTTP 502 (HTTP 502)', 502, undefined),
|
||||
new Error('/v3/auth/account-token answered without a token'),
|
||||
];
|
||||
for (const failure of failures) {
|
||||
// As the request's own answer each could have gone in...
|
||||
assert.equal(nothingWentIn(failure), false);
|
||||
// ...but from the mint, the request never went out.
|
||||
await assert.rejects(tokenForRequest(path, async () => { throw failure; }), (err) =>
|
||||
err instanceof VestNotSentError && nothingWentIn(err) && err.message.startsWith(`${path} not sent`));
|
||||
}
|
||||
// The request's own answer: only a refusal proves nothing went in.
|
||||
assert.equal(nothingWentIn(new VestApiError('x (HTTP 400, code 1001)', 400, '1001')), true);
|
||||
assert.equal(nothingWentIn(new VestApiError('x: no Vest session', 401, 'NO_SESSION')), true);
|
||||
assert.equal(nothingWentIn(new VestApiError('x (HTTP 408)', 408, undefined)), false);
|
||||
assert.equal(nothingWentIn(new VestApiError('x (HTTP 503)', 503, undefined)), false);
|
||||
assert.equal(nothingWentIn(new Error('x: socket hang up')), false);
|
||||
});
|
||||
|
||||
test('a claim Vest dates on the other side of 20:00 ET keeps no fund row of its own', () => {
|
||||
// Sent at 19:59:59.8 ET on the 26th and answered after 20:00: Vest dates
|
||||
// its WITHDRAW on the 26th's day, the client booked it on the 27th's.
|
||||
const claim = { at: iso('2026-10-27T00:00:00.300Z'), sentAt: iso('2026-10-26T23:59:59.800Z'), amount: 2250, base: 50000 };
|
||||
const txns = [deposit, { action_id: 'w1', amount: '2250', created_at: iso('2026-10-26T23:59:59.900Z'), transaction_type: 2 }].map(parseTxn);
|
||||
const stored = [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-27', amount: -2250 }];
|
||||
// Before Vest shows it, it stands where it was booked.
|
||||
assert.deepEqual(claimFundRows({ funds: bookTxns(txns.slice(0, 1)).funds, claims: [claim], withdrawals: [], stored }), {
|
||||
rows: [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-27', amount: -2250 }], dropped: [],
|
||||
});
|
||||
// Once it shows, it's on Vest's day, and the 27th's row goes.
|
||||
assert.deepEqual(claimFundRows({ funds: bookTxns(txns).funds, claims: [claim], withdrawals: withdrawalsOf(txns), stored }), {
|
||||
rows: [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-26', amount: -2250 }], dropped: ['2026-10-27'],
|
||||
});
|
||||
// The 27th starts with no payout on it, before Vest shows the claim too:
|
||||
// it went out on the 26th, and Vest can't date it earlier.
|
||||
const reset = lastVestResetMs(iso('2026-10-27T15:00:00Z'));
|
||||
assert.equal(payoutsSince(withdrawalsOf(txns), [claim], reset), 0);
|
||||
assert.equal(payoutsSince([], [claim], reset), 0);
|
||||
// Dated after the turn instead, it's the 27th's once Vest shows it.
|
||||
const after = [deposit, { action_id: 'w3', amount: '2250', created_at: iso('2026-10-27T00:00:00.100Z'), transaction_type: 2 }].map(parseTxn);
|
||||
assert.equal(payoutsSince(withdrawalsOf(after), [claim], reset), 1);
|
||||
// One saved before the send time was kept counts from its answer.
|
||||
assert.equal(payoutsSince([], [{ at: claim.at, amount: claim.amount, base: claim.base }], reset), 1);
|
||||
|
||||
// Dated the same day: its row stays. A day Vest has other fund rows on
|
||||
// is Vest's, with a claim it doesn't show yet added.
|
||||
const sameDay = { at: iso('2026-10-26T18:00:00Z'), amount: 1000, base: 49000 };
|
||||
const shown = [deposit, { action_id: 'w2', amount: '1000', created_at: sameDay.at + 2_000, transaction_type: 2 }].map(parseTxn);
|
||||
assert.deepEqual(claimFundRows({ funds: bookTxns(shown).funds, claims: [sameDay], withdrawals: withdrawalsOf(shown), stored: [{ date: '2026-10-26' }] }).dropped, []);
|
||||
const credit = parseTxn({ action_id: 'c1', amount: '40', created_at: sameDay.at - 3_600_000, transaction_type: 22 });
|
||||
assert.deepEqual(claimFundRows({ funds: bookTxns([credit]).funds, claims: [sameDay], withdrawals: [], stored: [] }).rows, [{ date: '2026-10-26', amount: -960 }]);
|
||||
// One of unknown outcome was never booked, so it's no day's.
|
||||
assert.deepEqual(claimFundRows({ funds: new Map(), claims: [{ ...claim, unknown: true }], withdrawals: [], stored }), { rows: [], dropped: [] });
|
||||
});
|
||||
|
||||
test('two claims on one Vest day share a fund row but count as two payouts', () => {
|
||||
const first = { at: iso('2026-10-27T14:00:00Z'), amount: 2250, base: 50000 };
|
||||
const second = { at: iso('2026-10-27T18:30:00Z'), amount: 1500, base: 50000 };
|
||||
const txns = [
|
||||
deposit,
|
||||
{ action_id: 'w1', amount: '2250', created_at: first.at + 2_000, transaction_type: 2 },
|
||||
{ action_id: 'w2', amount: '1500', created_at: second.at + 2_000, transaction_type: 2 },
|
||||
].map(parseTxn);
|
||||
const withdrawals = withdrawalsOf(txns);
|
||||
const { rows } = claimFundRows({ funds: bookTxns(txns).funds, claims: [first, second], withdrawals, stored: [] });
|
||||
assert.deepEqual(rows.filter((f) => f.amount < 0), [{ date: '2026-10-27', amount: -3750 }]);
|
||||
assert.deepEqual(payoutDaysOf(withdrawals, [first, second], rows), ['2026-10-27', '2026-10-27']);
|
||||
// The second not shown yet: still two, on the day it was booked.
|
||||
assert.deepEqual(payoutDaysOf(withdrawals.slice(0, 1), [first, second], rows), ['2026-10-27', '2026-10-27']);
|
||||
// Made by hand, they show only as WITHDRAWs.
|
||||
assert.equal(payoutDaysOf(withdrawals, [], rows).length, 2);
|
||||
// Of unknown outcome, one counts only once it shows.
|
||||
assert.deepEqual(payoutDaysOf([], [{ ...first, unknown: true }], []), []);
|
||||
assert.equal(payoutDaysOf(withdrawals.slice(0, 1), [{ ...first, unknown: true }], rows).length, 1);
|
||||
// A day that nets negative with no WITHDRAW still counts, as it did.
|
||||
assert.deepEqual(payoutDaysOf([], [], [{ date: '2026-10-21', amount: -75 }, { date: '2026-10-22', amount: 20 }]), ['2026-10-21']);
|
||||
assert.deepEqual(payoutDaysOf(withdrawals, [], [...rows, { date: '2026-10-21', amount: -75 }]), ['2026-10-21', '2026-10-27', '2026-10-27']);
|
||||
assert.equal(payoutsSince(withdrawals, [first, second], lastVestResetMs(second.at)), 2);
|
||||
});
|
||||
|
||||
test('hourly volatility leaves out the gaps', () => {
|
||||
const start = iso('2026-10-19T00:00:00Z');
|
||||
const kl = [];
|
||||
@@ -204,6 +410,81 @@ test('market hours: open stretches, closures and crypto', () => {
|
||||
assert.deepEqual(marketOpen([], 600), { open: true, closesAt: null });
|
||||
});
|
||||
|
||||
test('market hours: a closure past an 8 PM ET reset is a long one, the maintenance hour is not', () => {
|
||||
const open = (from, to) => ({ fromMs: iso(from), toMs: iso(to), state: { session: 'overnight', closure: 'unspecified', tradingStatus: 'OPEN', price: { allow: true } } });
|
||||
const shut = (from, to, closure) => ({ fromMs: iso(from), toMs: iso(to), state: { session: 'unspecified', closure, tradingStatus: closure === 'weekly' ? 'CLOSE' : 'OPEN', price: { allow: closure !== 'weekly' } } });
|
||||
const week = [
|
||||
open('2026-10-28T22:00:00Z', '2026-10-29T21:00:00Z'),
|
||||
shut('2026-10-29T21:00:00Z', '2026-10-29T22:00:00Z', 'maintenance'),
|
||||
open('2026-10-29T22:00:00Z', '2026-10-30T21:00:00Z'),
|
||||
shut('2026-10-30T21:00:00Z', '2026-11-01T23:00:00Z', 'weekly'),
|
||||
open('2026-11-01T23:00:00Z', '2026-11-02T22:00:00Z'),
|
||||
shut('2026-11-02T22:00:00Z', '2026-11-02T23:00:00Z', 'maintenance'),
|
||||
open('2026-11-02T23:00:00Z', '2026-11-03T22:00:00Z'),
|
||||
];
|
||||
// The weekend: shut through Friday's and Saturday's 8 PM ET resets.
|
||||
const friday = marketOpen(week, iso('2026-10-30T20:57:00Z'));
|
||||
assert.deepEqual(friday, { open: true, closesAt: iso('2026-10-30T21:00:00Z') });
|
||||
assert.equal(longClosure(week, friday.closesAt), true);
|
||||
// The maintenance hour reopens before the evening's reset, in summer and winter.
|
||||
const thursday = marketOpen(week, iso('2026-10-29T20:57:00Z'));
|
||||
assert.equal(thursday.closesAt, iso('2026-10-29T21:00:00Z'));
|
||||
assert.equal(longClosure(week, thursday.closesAt), false);
|
||||
assert.equal(longClosure(week, iso('2026-11-02T22:00:00Z')), false);
|
||||
// A gap in the periods is shut too (a holiday), and this one outlasts the reset.
|
||||
const early = [open('2026-11-25T14:00:00Z', '2026-11-25T18:00:00Z'), open('2026-11-26T23:00:00Z', '2026-11-27T18:00:00Z')];
|
||||
assert.equal(longClosure(early, iso('2026-11-25T18:00:00Z')), true);
|
||||
// Shut as far as the periods go: long. Periods that just end: no close known.
|
||||
assert.equal(longClosure(week.slice(0, 4), iso('2026-10-30T21:00:00Z')), true);
|
||||
assert.equal(longClosure(week.slice(0, 3), iso('2026-10-30T21:00:00Z')), false);
|
||||
assert.equal(longClosure(undefined, iso('2026-10-30T21:00:00Z')), false);
|
||||
});
|
||||
|
||||
test("a Vest account plans on Vest's day: the 5 PM CT roll doesn't count today's P&L twice", () => {
|
||||
const vest = { tradeDate: vestDayOf };
|
||||
// 3:30 and 5:30 PM CT (4:30 / 6:30 PM ET): the Central date rolls at
|
||||
// 5 PM CT, Vest's day only at 8 PM ET.
|
||||
const before = new Date('2026-10-27T20:30:00Z');
|
||||
const after = new Date('2026-10-27T22:30:00Z');
|
||||
assert.equal(currentTradeDateCT(after), '2026-10-28');
|
||||
assert.equal(tradeDateFor(vest, after), '2026-10-27');
|
||||
assert.equal(tradeDateFor(vest, new Date('2026-10-28T00:00:00Z')), '2026-10-28');
|
||||
// Every other platform keeps the Central date.
|
||||
assert.equal(tradeDateFor(undefined, after), '2026-10-28');
|
||||
assert.equal(tradeDateFor({}, after), '2026-10-28');
|
||||
// $3,000 to go, 3 min days of $150, $800 made today: Vest's row for its
|
||||
// day, and the realized P&L since its reset.
|
||||
const rows = [{ date: '2026-10-27', pnl: 800 }];
|
||||
const plan = (date) => computeDayPlan(7, date, 3000, 0, rows, 150, 3, 800, 800);
|
||||
assert.equal(plan(currentTradeDateCT(before)).remainingToday, 1900);
|
||||
assert.deepEqual(plan(tradeDateFor(vest, after)), plan(currentTradeDateCT(before)));
|
||||
// On the Central date the $800 is a prior min day and today's P&L both.
|
||||
assert.equal(plan(currentTradeDateCT(after)).remainingToday, 2050);
|
||||
});
|
||||
|
||||
test("a Vest account's completion is judged on Vest's day, and waits out Vest's own roll", () => {
|
||||
const vest = { tradeDate: vestDayOf };
|
||||
// $3,000 in 2 min days: day one made $3,100 and is flat. At 5:30 PM CT
|
||||
// its row is booked and its P&L is still today's realized P&L.
|
||||
const eff = { profitTarget: 3000, consistency: 0, minTradingDays: 2, minDayPnL: 0, drain: false, dailyLossLimit: 0 };
|
||||
const opts = {
|
||||
accountId: 7, cycleDailyPnL: [{ date: '2026-10-27', pnl: 3100 }], rawRealizedPnL: 3100, combineCmeHolidays: false,
|
||||
tradesEveryDay: true, effective: eff, equityProfit: 3100, splitEnabled: true, client: vest,
|
||||
};
|
||||
const evening = new Date('2026-10-27T22:30:00Z');
|
||||
// The Central date counts the one day twice and reports a pass.
|
||||
assert.equal(cycleCompletion({ ...opts, client: undefined, rawTradeDate: currentTradeDateCT(evening), now: evening }), 'today');
|
||||
assert.equal(cycleCompletion({ ...opts, rawTradeDate: tradeDateFor(vest, evening), now: evening }), 'none');
|
||||
// Two minutes past Vest's 8 PM ET roll the last sync's realized P&L can
|
||||
// still be the ended day's: no 'today' until the settle window passes.
|
||||
const justRolled = new Date('2026-10-28T00:02:00Z');
|
||||
assert.equal(tradeDateFor(vest, justRolled), '2026-10-28');
|
||||
assert.equal(cycleCompletion({ ...opts, rawTradeDate: '2026-10-28', now: justRolled }), 'none');
|
||||
// A real second day, once settled, finishes it.
|
||||
const settled = new Date('2026-10-28T00:10:00Z');
|
||||
assert.equal(cycleCompletion({ ...opts, rawTradeDate: '2026-10-28', rawRealizedPnL: 50, equityProfit: 3150, now: settled }), 'today');
|
||||
});
|
||||
|
||||
test('relayed sessions: taken, kept fresher, dropped, never listed with tokens', () => {
|
||||
const now = iso('2026-10-26T18:40:00Z');
|
||||
const jwt = (n) => `eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIke${n}In0.sig${n}`;
|
||||
@@ -262,6 +543,24 @@ test('resetSafeStop: fit an open position into the room the reset will leave', (
|
||||
const dd = resetSafeStop({ ...base, maxDrawdownFloor: 49000, currentStop: null });
|
||||
assert.equal(dd.action, 'stop');
|
||||
assert.ok(dd.price > want);
|
||||
// A second position on the account gets what the first leaves: $500
|
||||
// already committed at the first one's stop, from the same $48,500 floor.
|
||||
const second = resetSafeStop({ ...base, balance: 49500, floorBalance: 50000, currentStop: null });
|
||||
assert.equal(second.action, 'stop');
|
||||
assert.equal(second.price, Math.ceil((29500 - (allowed - 500) / 4) / 0.25) * 0.25);
|
||||
assert.ok(second.price > want);
|
||||
// ...and nothing when the first takes it all.
|
||||
assert.deepEqual(resetSafeStop({ ...base, balance: 48600, floorBalance: 50000, currentStop: null }), { action: 'close' });
|
||||
// With no room left, a position already up keeps going on a stop that
|
||||
// locks in the shortfall: one already locking in more is left alone...
|
||||
const up = { ...base, mark: 29560, balance: 48600, floorBalance: 50000 };
|
||||
assert.deepEqual(resetSafeStop({ ...up, currentStop: 29520 }), { action: 'none' });
|
||||
// ...one with none gets it, on the profit side, still under the mark...
|
||||
const shortfall = 48600 - 48500 - 150 - 0.000025 * 4 * 29560;
|
||||
assert.deepEqual(resetSafeStop({ ...up, currentStop: null }), { action: 'stop', price: Math.ceil((29500 - shortfall / 4) / 0.25) * 0.25 });
|
||||
assert.ok(resetSafeStop({ ...up, currentStop: null }).price > 29500);
|
||||
// ...and with no mark to tell, it closes.
|
||||
assert.deepEqual(resetSafeStop({ ...up, mark: NaN, currentStop: null }), { action: 'close' });
|
||||
});
|
||||
|
||||
test('parseHistoryPosition: a closed position from GET /v3/positions', () => {
|
||||
@@ -292,7 +591,61 @@ test('parseHistoryPosition: open, snake_case, seconds, and junk', () => {
|
||||
assert.equal(open.openedAt, 1793065590000);
|
||||
assert.equal(open.closedAt, null);
|
||||
assert.equal(open.closeReason, null);
|
||||
const held = parseHistoryPosition({
|
||||
positionId: 'p3', symbol: 'NDX-USD-PERP', side: 'short', quantity: '85.7363', openPrice: '29538.96', closePrice: '290.25',
|
||||
pnl: '0.0', fee: '63.314059', funding: '0.0', openDate: 1793065422000, closeDate: null,
|
||||
});
|
||||
assert.equal(held.closePrice, null, "an open position's placeholder close price is dropped");
|
||||
assert.equal(held.pnl, null, 'and its $0 P&L');
|
||||
assert.equal(held.fee, 63.314059, 'its entry fee is real');
|
||||
assert.equal(parseHistoryPosition({ symbol: 'GC-PERP', openDate: 1 }), null, 'no id');
|
||||
assert.equal(parseHistoryPosition({ positionId: 'p2', symbol: 'GC-PERP' }), null, 'no open time');
|
||||
assert.equal(parseHistoryPosition(null), null);
|
||||
});
|
||||
|
||||
test('an account\'s own token is told from the login\'s', () => {
|
||||
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
|
||||
const acct = '275e765a-2d3d-5de3-8386-71aaa847c0e8';
|
||||
const accountToken = `${b64({ alg: 'ES256' })}.${b64({ sub: '3cab1794-47bc-523b-91e6-64a9ad26dcbf', acc: { id: acct.toUpperCase() } })}.sig`;
|
||||
assert.ok(jwtClaimStrings(accountToken).includes(acct));
|
||||
assert.deepEqual(jwtClaimStrings('not.a-jwt.at-all'), []);
|
||||
assert.deepEqual(jwtClaimStrings('nodots'), []);
|
||||
// The login sees every account and its primary one.
|
||||
assert.equal(accountScopedToken({ capitalIds: [acct, 'b', 'c'], hasPrimary: true, claims: [] }), false);
|
||||
// One account and no primary: an account's token, whatever its claims.
|
||||
assert.equal(accountScopedToken({ capitalIds: [acct], hasPrimary: false, claims: [] }), true);
|
||||
// One account with a primary: only when the token names that account.
|
||||
assert.equal(accountScopedToken({ capitalIds: [acct], hasPrimary: true, claims: jwtClaimStrings(accountToken) }), true);
|
||||
assert.equal(accountScopedToken({ capitalIds: [acct], hasPrimary: true, claims: ['3cab1794-47bc-523b-91e6-64a9ad26dcbf'] }), false);
|
||||
// A login with no capital accounts yet.
|
||||
assert.equal(accountScopedToken({ capitalIds: [], hasPrimary: true, claims: [] }), false);
|
||||
});
|
||||
|
||||
test('a refused account token falls back to the login\'s and stays refused', () => {
|
||||
const now = iso('2026-10-27T01:00:00Z');
|
||||
const wallet = '0x00000000000000000000000000000000000000aa';
|
||||
const login = 'eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJsb2dpbiJ9.loginsignature-loginsignature-loginsig';
|
||||
const account = 'eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJhY2N0In0.accountsignature-accountsignature-acct';
|
||||
assert.equal(putVestSession({ accessToken: login, expiresAt: now + 600_000, primaryAddr: wallet, userId: 'u' }, now), null);
|
||||
// The account's token lives longer, so it replaces the login's...
|
||||
assert.equal(putVestSession({ accessToken: account, expiresAt: now + 900_000, primaryAddr: wallet, userId: 'u' }, now), null);
|
||||
assert.equal(vestSessionFor(wallet, now).accessToken, account);
|
||||
// ...until the client finds it sees one account: back to the login's.
|
||||
refuseVestSession(wallet, account, now);
|
||||
assert.equal(vestSessionFor(wallet, now).accessToken, login);
|
||||
// Relayed again (the old extension posts it every minute): refused.
|
||||
assert.match(putVestSession({ accessToken: account, expiresAt: now + 900_000, primaryAddr: wallet, userId: 'u' }, now), /one Vest account's token/);
|
||||
assert.equal(vestSessionFor(wallet, now).accessToken, login);
|
||||
// The login's next renewal is taken as usual.
|
||||
const renewed = 'eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJsb2dpbiJ9.renewedsignature-renewedsignature-rene';
|
||||
assert.equal(putVestSession({ accessToken: renewed, expiresAt: now + 1_200_000, primaryAddr: wallet, userId: 'u' }, now), null);
|
||||
assert.equal(vestSessionFor(wallet, now).accessToken, renewed);
|
||||
// Refusing a token that isn't held changes nothing held.
|
||||
refuseVestSession(wallet, 'x.y.zzz', now);
|
||||
assert.equal(vestSessionFor(wallet, now).accessToken, renewed);
|
||||
// No good token to fall back to: none at all.
|
||||
const wallet2 = '0x00000000000000000000000000000000000000bb';
|
||||
assert.equal(putVestSession({ accessToken: account.replace('acct', 'accu'), expiresAt: now + 900_000, primaryAddr: wallet2, userId: 'u' }, now), null);
|
||||
refuseVestSession(wallet2, account.replace('acct', 'accu'), now);
|
||||
assert.equal(vestSessionFor(wallet2, now), null);
|
||||
});
|
||||
|
||||
@@ -38,6 +38,9 @@ export interface AccountConfig {
|
||||
/** Hard daily loss limit in dollars (0/absent = none): every leg carries a
|
||||
* stop that keeps the firm's day inside it, less a reserve and fees. */
|
||||
dailyLossLimit?: number;
|
||||
/** The stage is picked by the payouts since the firm's last daily reset,
|
||||
* and every new day starts back at the base stage (Vest). */
|
||||
stagesSameDay?: boolean;
|
||||
/** The firm trades every calendar day, weekends and CME holidays included. */
|
||||
tradesEveryDay?: boolean;
|
||||
}
|
||||
@@ -81,6 +84,9 @@ export interface AccountState {
|
||||
active: boolean;
|
||||
amount: number;
|
||||
realizedPnL: number;
|
||||
/** The firm's raw trade date (tradeDateFor): Vest's turns at 20:00 ET,
|
||||
* every other firm's at 5 PM CT. */
|
||||
tradeDate: string;
|
||||
daysTraded: number;
|
||||
positionDirection: 'long' | 'short' | null;
|
||||
/** Balance floor from Tradovate's auto-liquidation profile (0 = not set) */
|
||||
@@ -96,7 +102,9 @@ export interface AccountState {
|
||||
* consistency and minimum days all met (cycleCompletion 'today'). Only
|
||||
* the final day; the firm may lock or remove the account afterwards. */
|
||||
completed: boolean;
|
||||
/** Current stage = 1 + number of withdrawals (negative fund transactions). Stage 1 = fresh account. */
|
||||
/** Current stage = 1 + number of withdrawals (negative fund transactions,
|
||||
* or the platform's own list where a row holds several; today's for a
|
||||
* type whose stages reset each day). Stage 1 = fresh account. */
|
||||
stage: number;
|
||||
/** True when the current stage is a drain stage (spam-withdraw mode). */
|
||||
stageDrain: boolean;
|
||||
@@ -136,6 +144,10 @@ export interface AccountState {
|
||||
lastStand: boolean;
|
||||
/** Actual profit target to hit — max(stage profit target, consistency realTarget = maxDay/consistency). */
|
||||
effectiveProfitTarget: number | null;
|
||||
/** The current stage's hard daily loss limit (0 = none) and minimum
|
||||
* trading days — a withdrawal stage may set its own. null = unassigned. */
|
||||
effectiveDailyLossLimit: number | null;
|
||||
effectiveMinTradingDays: number | null;
|
||||
/** Current cycle daily P&L — used for target calculations. */
|
||||
dailyPnL: { date: string; pnl: number }[];
|
||||
/** Full P&L history across all cycles — used for calendar and equity curve display. */
|
||||
|
||||
Reference in New Issue
Block a user