diff --git a/app/accounts/[id]/page.tsx b/app/accounts/[id]/page.tsx index b7f4cf1..d861c7c 100644 --- a/app/accounts/[id]/page.tsx +++ b/app/accounts/[id]/page.tsx @@ -617,8 +617,13 @@ export default function AccountPage() { fundTransactions, balance: account.amount, realizedPnL: account.realizedPnL, - // A type whose stages reset each day: its state stage counts today's payouts. - stageInputs: { stagesSameDay: cfg.stagesSameDay === true, sameDayWithdrawals: account.stage - 1 }, + // 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: { diff --git a/app/api/state/route.ts b/app/api/state/route.ts index 73457f1..fb1fa93 100644 --- a/app/api/state/route.ts +++ b/app/api/state/route.ts @@ -10,7 +10,7 @@ import { obfuscationDayBooked } from '@/lib/auto-trade'; import type { AccountState } from '@/types'; import { combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, tradeDateFor, - obfuscationStopDollars, dllStopDollars, botStopsActive, stageInputsFor, + obfuscationStopDollars, dllStopDollars, botStopsActive, stageInputsFor, stagePayouts, maxLossRoom, lastStand as isLastStand, shortestNextLeg, LEG_COMPLETION_EPSILON, } from '@/lib/trading-logic'; @@ -63,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( @@ -79,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. @@ -272,6 +272,9 @@ 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 diff --git a/lib/reporter.ts b/lib/reporter.ts index 55eccdf..617559c 100644 --- a/lib/reporter.ts +++ b/lib/reporter.ts @@ -221,6 +221,7 @@ function collectFirmStats() { fundTransactions: fundTxns, balance: cash.amount, realizedPnL: cash.realizedPnL, + rawTradeDate: rawToday, stageInputs: stageInputsFor(client, acc.id, cfg), payout: { withdrawalPct: cfg.withdrawalPct, diff --git a/lib/trading-logic.ts b/lib/trading-logic.ts index 2ab5012..1def68a 100644 --- a/lib/trading-logic.ts +++ b/lib/trading-logic.ts @@ -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 } @@ -480,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; @@ -490,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 @@ -551,11 +565,13 @@ export function projectPerfectPath(opts: { const sameDay = opts.stageInputs?.stagesSameDay === true; /** The path's own payouts, by the date they are claimed on. */ const paidOn = new Map(); + /** 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) - : funds.filter((f) => f.amount < 0).length); + : 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 @@ -566,7 +582,7 @@ export function projectPerfectPath(opts: { 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) } : {}, + sameDay ? { stagesSameDay: true, sameDayWithdrawals: paidBefore(day) } : { payouts: paidBefore(day) }, ); /** The payout parameters of the stage being completed: per-stage * overrides when set, else the type defaults. */ @@ -1429,17 +1445,21 @@ export function dllReserve(dailyLossLimit: number, notional = 0, slippageFractio 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. */ + * 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 }, + 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 } : {}), }; } diff --git a/scripts/check-trading-logic.mjs b/scripts/check-trading-logic.mjs index 2bee9da..4531f4f 100644 --- a/scripts/check-trading-logic.mjs +++ b/scripts/check-trading-logic.mjs @@ -14,7 +14,7 @@ import { 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, resetClearStop, shortestNextLeg, computeDayPlan, clearsClose, RESET_CLEAR_HOLDS, + resolveEffectiveConfig, stageInputsFor, stagePayouts, floorRoom, resetClearStop, shortestNextLeg, computeDayPlan, clearsClose, RESET_CLEAR_HOLDS, legCloseNote, } from '../lib/trading-logic.ts'; @@ -1337,6 +1337,61 @@ test('projectPerfectPath: a same-day type trades each projected day from the bas 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 diff --git a/scripts/check-vest-client.mjs b/scripts/check-vest-client.mjs index 2f7acd5..0c2d5e7 100644 --- a/scripts/check-vest-client.mjs +++ b/scripts/check-vest-client.mjs @@ -54,7 +54,7 @@ const { putVestSession } = await import('../lib/vest-session.ts'); const { botOpenedPositions, loadAccountMeta, saveAccountMeta } = 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 } = await import('../lib/trading-logic.ts'); +const { tradeDateFor, stageInputsFor, resolveEffectiveConfig } = await import('../lib/trading-logic.ts'); const { isClientReady } = await import('../lib/platform-client.ts'); const iso = (s) => Date.parse(s); @@ -396,3 +396,24 @@ test("the client's Vest day turns with its numbers, and a sync that stalls stops 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(); + } +}); diff --git a/types.ts b/types.ts index aac65e4..5fabf48 100644 --- a/types.ts +++ b/types.ts @@ -84,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) */ @@ -99,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;