Vest: a claim within $25 of target names its cycle, the newest goes first, and a batch on a turned day stops

The auto-claim takes a cycle as done within LEG_COMPLETION_EPSILON of its
target, so the cycle it names is judged at that tolerance too. Each saved
cycle keeps its claim's time, and the report lists a day's claims newest
first, so the master's per-slice cap can't hold the latest back. A batch
or copy whose client's day turned while it chose (Vest, at the first sync
after 8 PM ET) is abandoned rather than sized and counted on the old day.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-10-27 16:27:07 -05:00
co-authored by Claude Opus 5.5
parent dc210ffcb7
commit bf7e147507
6 changed files with 59 additions and 18 deletions
+13 -1
View File
@@ -1224,6 +1224,13 @@ async function runTradeLocked(
console.warn('[auto-trade] superseded while collecting accounts — abandoning this batch'); console.warn('[auto-trade] superseded while collecting accounts — abandoning this batch');
return { outcome: 'skipped-in-flight', results: [], detail: 'superseded by another batch while choosing' }; 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}`); 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(); runtime().lastFireAt = Date.now();
@@ -1826,6 +1833,10 @@ async function copyTradeLocked(lease: number, lane: string): Promise<unknown[]>
console.warn('[copy-trade] superseded while collecting accounts — abandoning this copy'); console.warn('[copy-trade] superseded while collecting accounts — abandoning this copy');
return []; 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)`); console.log(`[copy-trade] copying ${resolvedAction} ${resolvedSymbol} to ${batch.length} account(s)`);
runtime().lastFireAt = Date.now(); runtime().lastFireAt = Date.now();
@@ -2074,7 +2085,8 @@ async function claimTick(): Promise<void> {
function claimEnds(client: PlatformClient, acc: { id: number; name: string }): ClaimedCycle | null { function claimEnds(client: PlatformClient, acc: { id: number; name: string }): ClaimedCycle | null {
try { try {
const cfg = getAccountConfig(acc.id).accountType; const cfg = getAccountConfig(acc.id).accountType;
return cfg && !client.lastFetchErrors?.[acc.name] ? claimedCycle(client, acc.id, cfg) : null; // 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) { } catch (err) {
console.error(`[claim] ${acc.name}: the cycle the claim ends couldn't be read — ${errorMessage(err)}`); console.error(`[claim] ${acc.name}: the cycle the claim ends couldn't be read — ${errorMessage(err)}`);
return null; return null;
+1 -1
View File
@@ -574,7 +574,7 @@ export function loadClaimedCycles(accountId: number): ClaimedCycle[] {
if (!Array.isArray(list)) return []; if (!Array.isArray(list)) return [];
return list.filter((c): c is ClaimedCycle => typeof c?.date === 'string' && typeof c.cycle === 'string' 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)) && Number.isInteger(c.stage) && typeof c.drain === 'boolean' && Number.isInteger(c.cycleSeq))
.map(({ date, stage, drain, cycle, cycleSeq }) => ({ date, stage, drain, cycle, cycleSeq })); .map(({ date, stage, drain, cycle, cycleSeq, at }) => ({ date, stage, drain, cycle, cycleSeq, ...(Number.isFinite(at) ? { at } : {}) }));
} catch { } catch {
return []; return [];
} }
+8 -3
View File
@@ -98,6 +98,8 @@ function collectFirmStats() {
// The completed cycles this instance's own claims ended, by slice // The completed cycles this instance's own claims ended, by slice
// (listed after the accounts — see below). // (listed after the accounts — see below).
const claimedBySlice = new Map<AccountTypeStats, NonNullable<AccountTypeStats['completedAccounts']>>(); 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) { for (const acc of client.accountList) {
const reportedCash = client.accountCashBalances[acc.id]; const reportedCash = client.accountCashBalances[acc.id];
@@ -331,14 +333,16 @@ function collectFirmStats() {
if (!withinEndedCycleWindow(c.date, rawToday)) continue; if (!withinEndedCycleWindow(c.date, rawToday)) continue;
let list = claimedBySlice.get(slice); let list = claimedBySlice.get(slice);
if (!list) claimedBySlice.set(slice, list = []); if (!list) claimedBySlice.set(slice, list = []);
list.push({ const entry = {
name: acc.name, name: acc.name,
date: c.date, date: c.date,
stage: c.stage, stage: c.stage,
kind: completionKind(cfg, c.drain), kind: completionKind(cfg, c.drain),
cycle: c.cycle, cycle: c.cycle,
...(c.cycleSeq > 0 ? { cycleSeq: c.cycleSeq } : {}), ...(c.cycleSeq > 0 ? { cycleSeq: c.cycleSeq } : {}),
}); };
claimedAt.set(entry, c.at ?? 0);
list.push(entry);
} }
} }
@@ -459,7 +463,8 @@ function collectFirmStats() {
const list = slice.completedAccounts ?? []; const list = slice.completedAccounts ?? [];
const key = (e: (typeof list)[number]) => `${e.name}\n${e.cycle}\n${e.cycleSeq ?? 0}`; const key = (e: (typeof list)[number]) => `${e.name}\n${e.cycle}\n${e.cycleSeq ?? 0}`;
const named = new Set(list.map(key)); const named = new Set(list.map(key));
for (const e of claimed.sort((a, b) => b.date.localeCompare(a.date))) { // 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; if (named.has(key(e))) continue;
named.add(key(e)); named.add(key(e));
list.push(e); list.push(e);
+8 -3
View File
@@ -982,7 +982,7 @@ export function endedCycleCompletion(opts: {
} }
/** A completed cycle a payout claim ended (claimedCycle). */ /** A completed cycle a payout claim ended (claimedCycle). */
export interface ClaimedCycle { date: string; stage: number; drain: boolean; cycle: string; cycleSeq: number } 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), * The cycle a payout claim about to go out ends (PlatformClient.claimProfit),
@@ -1020,6 +1020,7 @@ export function claimedCycle(
combineCmeHolidays?: boolean; tradesEveryDay?: boolean; dailyLossLimit?: number; stagesSameDay?: boolean; combineCmeHolidays?: boolean; tradesEveryDay?: boolean; dailyLossLimit?: number; stagesSameDay?: boolean;
}, },
now: Date = new Date(), now: Date = new Date(),
tolerance = 0,
): ClaimedCycle | null { ): ClaimedCycle | null {
const cash = client.accountCashBalances[accountId]; const cash = client.accountCashBalances[accountId];
const cycleDailyPnL = client.dailyPnL[accountId]; const cycleDailyPnL = client.dailyPnL[accountId];
@@ -1029,11 +1030,15 @@ export function claimedCycle(
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages, cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages,
client.priorProfit?.[accountId] ?? 0, fundTransactions, cfg.minDayPnL, stageInputsFor(client, accountId, cfg), 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 rawTradeDate = tradeDateFor(client, now);
const completion = cycleCompletion({ const completion = cycleCompletion({
accountId, rawTradeDate, client, cycleDailyPnL, rawRealizedPnL: cash.realizedPnL, accountId, rawTradeDate, client, cycleDailyPnL, rawRealizedPnL: cash.realizedPnL,
combineCmeHolidays: cfg.combineCmeHolidays === true, tradesEveryDay: cfg.tradesEveryDay === true, combineCmeHolidays: cfg.combineCmeHolidays === true, tradesEveryDay: cfg.tradesEveryDay === true,
effective, equityProfit: cash.amount - cfg.accountSize, splitEnabled: cfg.splitEnabled, now, effective: judged, equityProfit: cash.amount - cfg.accountSize, splitEnabled: cfg.splitEnabled, now,
}); });
if (completion === 'none') return null; if (completion === 'none') return null;
const cycle = client.lastFundDates?.[accountId] ?? ''; const cycle = client.lastFundDates?.[accountId] ?? '';
@@ -1041,7 +1046,7 @@ export function claimedCycle(
const place = completionPlace({ payoutDays, fundTransactions, cycle }); const place = completionPlace({ payoutDays, fundTransactions, cycle });
return { return {
date: combineCmeSessions(effectiveCombine(cfg), [], rawTradeDate, 0).tradeDate, date: combineCmeSessions(effectiveCombine(cfg), [], rawTradeDate, 0).tradeDate,
stage: place.stage, drain: effective.drain, cycle, cycleSeq: place.cycleSeq, stage: place.stage, drain: effective.drain, cycle, cycleSeq: place.cycleSeq, at: now.getTime(),
}; };
} }
+21 -8
View File
@@ -1420,6 +1420,12 @@ test('claimedCycle: a claim names the complete cycle it ends, where the final da
const cfg = { ...vestFunded, stagesSameDay: true, dailyLossLimit: 1500 }; const cfg = { ...vestFunded, stagesSameDay: true, dailyLossLimit: 1500 };
const activated = { date: '2026-10-20', amount: 50000 }; const activated = { date: '2026-10-20', amount: 50000 };
/** A Vest client's numbers for account 7, just before a claim. */ /** 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) => ({ const client = (o) => ({
accountCashBalances: { 7: o.cash }, dailyPnL: { 7: o.cycle ?? [] }, fundTransactions: { 7: o.rows }, accountCashBalances: { 7: o.cash }, dailyPnL: { 7: o.cycle ?? [] }, fundTransactions: { 7: o.rows },
lastFundDates: { 7: o.last }, priorProfit: { 7: 0 }, lastFundDates: { 7: o.last }, priorProfit: { 7: 0 },
@@ -1432,20 +1438,27 @@ test('claimedCycle: a claim names the complete cycle it ends, where the final da
// latest fund date never moves for the catch-up. // latest fund date never moves for the catch-up.
const claimedOnce = [activated, { date: '2026-10-27', amount: -1500 }]; 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'] }; 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(claimedCycle(client(second), 7, cfg, cdt('2026-10-27T17:00:00')), 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 }); { 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. // A Claim now short of stage 2's $3,000 names nothing.
assert.equal(claimedCycle(client({ ...second, cash: { amount: 51000, realizedPnL: 1000 } }), 7, cfg, cdt('2026-10-27T17:00:00')), null); 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. // The day's third cycle, after that claim, has a key of its own.
const twice = [activated, { date: '2026-10-27', amount: -4500 }]; const twice = [activated, { date: '2026-10-27', amount: -4500 }];
assert.deepEqual(claimedCycle(client({ ...second, rows: twice, today: 2, payouts: ['2026-10-27', '2026-10-27'] }), 7, cfg, cdt('2026-10-27T17:00:00')), 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 }); { 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 // 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, // puts the account back on stage 1's $1,500: claimed at the first tick,
// 8:00:30 PM ET. // 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'] }; 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(claimedCycle(client(afterReset), 7, cfg, cdt('2026-10-27T19:00:30')), 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 }); { 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 // The catch-up after it reads none of that cycle's P&L, all in 10-27's
// row, which the cycle before owns. // row, which the cycle before owns.
@@ -1458,13 +1471,13 @@ test('claimedCycle: a claim names the complete cycle it ends, where the final da
// Where the catch-up does name the cycle — its P&L on a day of its own — // 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. // 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'] }; 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(claimedCycle(client(crossDay), 7, cfg, cdt('2026-10-26T12:00:00')), 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 }); { date: '2026-10-26', stage: 2, drain: false, cycle: '2026-10-25', cycleSeq: 0 });
// Without a payout list, one payout per negative fund row. // Without a payout list, one payout per negative fund row.
assert.equal(claimedCycle(client({ ...crossDay, payouts: undefined }), 7, cfg, cdt('2026-10-26T12:00:00')).stage, 2); assert.equal(claimed(client({ ...crossDay, payouts: undefined }), 7, cfg, cdt('2026-10-26T12:00:00')).stage, 2);
// Numbers not read yet: nothing. // Numbers not read yet: nothing.
assert.equal(claimedCycle({ ...client(crossDay), accountCashBalances: {} }, 7, cfg, cdt('2026-10-26T12:00:00')), null); assert.equal(claimed({ ...client(crossDay), accountCashBalances: {} }, 7, cfg, cdt('2026-10-26T12:00:00')), null);
assert.equal(claimedCycle({ ...client(crossDay), fundTransactions: {} }, 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', () => { test('withinEndedCycleWindow: a week after the date, and never for one that doesn\'t parse', () => {
+8 -2
View File
@@ -457,7 +457,13 @@ test('each claim of a Vest day names the completed cycle it ends, the second and
client.applyCycleSlice(A); client.applyCycleSlice(A);
client.withdrawals.set(A, client.claimsOf(A).map((c) => ({ at: c.at, amount: -c.amount }))); client.withdrawals.set(A, client.claimsOf(A).map((c) => ({ at: c.at, amount: -c.amount })));
}; };
const named = () => claimedCycle(client, A, cfg, new Date(Date.now())); /** 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 { try {
saveFundTransactions(A, 'V50K-F-D3-1', [{ date: '2026-10-20', amount: 50000 }]); saveFundTransactions(A, 'V50K-F-D3-1', [{ date: '2026-10-20', amount: 50000 }]);
saveAccountMeta(A, 'last_fund_date', '2026-10-20'); saveAccountMeta(A, 'last_fund_date', '2026-10-20');
@@ -514,7 +520,7 @@ test('each claim of a Vest day names the completed cycle it ends, the second and
}), null); }), null);
// Kept as the claims leave them; an entry that doesn't read is left out. // 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 }]; 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 }]); saveClaimedCycles(A, [...kept, { date: '2026-10-28', stage: '3', drain: false, cycle: '2026-10-27', cycleSeq: 1 }]);
assert.deepEqual(loadClaimedCycles(A), kept); assert.deepEqual(loadClaimedCycles(A), kept);
saveAccountMeta(A, 'claimed_cycles', '{'); saveAccountMeta(A, 'claimed_cycles', '{');