diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index 9f192b6..6eb90df 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -19,9 +19,10 @@ import { FALLBACK_COMMISSION_PER_CONTRACT, MAX_COMMISSION_PER_CONTRACT, DLL_MIN_STOP, CME_EVENING_OPEN_MIN, foldReason, withinFirmCaps, fundRowsKey, markFinishedCycle, unbookedPayoutDrop, machineSide, stageInputsFor, floorRoom, shortestNextLeg, - type DayPlan, type FinishedCycleMark, + 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'; @@ -2011,10 +2012,11 @@ async function runClaims(lane: string, ready: FirmClient[]): Promise { const amount = claimDue(client, acc, Date.now()); if (amount === null) continue; try { + 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)); + 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`); @@ -2062,15 +2064,41 @@ async function claimTick(): Promise { } } +/** 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; + return cfg && !client.lastFetchErrors?.[acc.name] ? claimedCycle(client, acc.id, cfg) : 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. */ + * 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, + 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)}`); + } + } const stage = 1 + (client.sameDayClaims?.(acc.id) ?? 0); console.log(`${tag} ${firm.name} ${acc.name}: claimed $${claimed}${received !== null ? ` (you get $${received})` : ''}` + ` — trading stage ${stage} next (${tradeDate})`); @@ -2147,8 +2175,9 @@ export async function claimAccountNow(accountId: number, amount: number): Promis 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)); + 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); diff --git a/lib/db.ts b/lib/db.ts index 590d6f4..00981ab 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -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 }) => ({ date, stage, drain, cycle, cycleSeq })); + } 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 diff --git a/lib/master-protocol.ts b/lib/master-protocol.ts index 49324e2..8b92192 100644 --- a/lib/master-protocol.ts +++ b/lib/master-protocol.ts @@ -43,8 +43,11 @@ export interface AccountTypeStats { * 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. */ + * 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 diff --git a/lib/reporter.ts b/lib/reporter.ts index 617559c..24057b0 100644 --- a/lib/reporter.ts +++ b/lib/reporter.ts @@ -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, completionPlace, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig, stageInputsFor, tradeDateFor } 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'; @@ -92,6 +95,9 @@ 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>(); for (const acc of client.accountList) { const reportedCash = client.accountCashBalances[acc.id]; @@ -314,6 +320,28 @@ function collectFirmStats() { } } + // 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 = []); + list.push({ + name: acc.name, + date: c.date, + stage: c.stage, + kind: completionKind(cfg, c.drain), + cycle: c.cycle, + ...(c.cycleSeq > 0 ? { cycleSeq: c.cycleSeq } : {}), + }); + } + } + if (client.positions[acc.id]) { inTrade++; accountsTraded++; @@ -423,6 +451,22 @@ 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)); + for (const e of claimed.sort((a, b) => b.date.localeCompare(a.date))) { + 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. diff --git a/lib/trading-logic.ts b/lib/trading-logic.ts index 1def68a..cec57aa 100644 --- a/lib/trading-logic.ts +++ b/lib/trading-logic.ts @@ -742,6 +742,13 @@ const ROLL_SETTLE_MS = 5 * 60 * 1000; * 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( @@ -924,8 +931,7 @@ 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 (!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); @@ -975,6 +981,70 @@ export function endedCycleCompletion(opts: { }; } +/** A completed cycle a payout claim ended (claimedCycle). */ +export interface ClaimedCycle { date: string; stage: number; drain: boolean; cycle: string; cycleSeq: 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(), +): 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), + ); + const rawTradeDate = tradeDateFor(client, now); + const completion = cycleCompletion({ + accountId, rawTradeDate, client, cycleDailyPnL, rawRealizedPnL: cash.realizedPnL, + combineCmeHolidays: cfg.combineCmeHolidays === true, tradesEveryDay: cfg.tradesEveryDay === true, + effective, 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, + }; +} + /** * Compute the next trading day's profit target for an account. * diff --git a/scripts/check-trading-logic.mjs b/scripts/check-trading-logic.mjs index 4531f4f..bae7f42 100644 --- a/scripts/check-trading-logic.mjs +++ b/scripts/check-trading-logic.mjs @@ -1413,3 +1413,64 @@ test('endedCycleCompletion: a same-day type\'s ended cycle is judged on its day\ // 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. */ + 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(claimedCycle(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(claimedCycle(client({ ...second, cash: { amount: 51000, realizedPnL: 1000 } }), 7, cfg, cdt('2026-10-27T17:00:00')), 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(claimedCycle(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(claimedCycle(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(claimedCycle(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(claimedCycle(client({ ...crossDay, payouts: undefined }), 7, cfg, cdt('2026-10-26T12:00:00')).stage, 2); + // Numbers not read yet: nothing. + assert.equal(claimedCycle({ ...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); +}); + +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); +}); diff --git a/scripts/check-vest-client.mjs b/scripts/check-vest-client.mjs index 0c2d5e7..95b09cd 100644 --- a/scripts/check-vest-client.mjs +++ b/scripts/check-vest-client.mjs @@ -2,7 +2,8 @@ // 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. +// 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. // // npm run check // @@ -51,10 +52,12 @@ VestClient.prototype.request = async function (bucket, method, path, opts = {}) return gateway(method, path, opts); }; const { putVestSession } = await import('../lib/vest-session.ts'); -const { botOpenedPositions, loadAccountMeta, saveAccountMeta } = await import('../lib/db.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 } = await import('../lib/trading-logic.ts'); +const { tradeDateFor, stageInputsFor, resolveEffectiveConfig, claimedCycle, endedCycleCompletion } = await import('../lib/trading-logic.ts'); const { isClientReady } = await import('../lib/platform-client.ts'); const iso = (s) => Date.parse(s); @@ -417,3 +420,102 @@ test('two payouts on one Vest day share its fund row, and still count two toward 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 }))); + }; + const named = () => claimedCycle(client, A, cfg, new Date(Date.now())); + 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 }]; + 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(); + } +});