Vest: the payout projection runs on Vest's day, and stages count each payout

projectPerfectPath takes the raw trade date (default: the Central one):
the reporter passes its tradeDateFor day and the account page the one
/api/state now gives each account. From 5 PM CT to 20:00 ET it took
tomorrow as today while every input was Vest's, so the day's claim moved
a day late, the next day's stage-1 payout dropped, and an open Vest-day
row counted twice.

Without same-day stages, the stage comes from the client's payout list
(payoutDays, one per payout) when it has read one, not the negative fund
rows: Vest books a day's two claims as one row, so the engine, claimDue,
the stage label and the projection stayed a stage behind the report.
StageInputs.payouts carries the count; a client with no list counts rows
as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-10-27 15:37:44 -05:00
co-authored by Claude Opus 5.5
parent 032ba1ca9c
commit 4a8e027056
7 changed files with 130 additions and 20 deletions
+7 -2
View File
@@ -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: {
+10 -7
View File
@@ -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
+1
View File
@@ -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,
+28 -8
View File
@@ -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<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)
: 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 } : {}),
};
}
+56 -1
View File
@@ -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
+22 -1
View File
@@ -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();
}
});
+6 -1
View File
@@ -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;