From 952e4412c4813f2c532e713f69d07f4abb6bda0a Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Tue, 27 Oct 2026 14:14:43 -0500 Subject: [PATCH 1/5] Catalog: a build that learns a platform fetches the firms an older one skipped A firm on a platform the build didn't know was skipped, yet the cache was stamped current, so after updating to a build that knows the platform the sync sent ?since= and the master answered "unchanged": the firm (Vest, on machines that synced before 96fdd9c) stayed missing until an unrelated catalog edit. The parser version goes to 3, so those caches fetch in full once, and a payload now names the platforms it skipped (skippedPlatforms): a cache whose skipped platform this build knows is no longer current. Kept rule ids still report from such a cache. scripts/check-catalog.mjs syncs against a stand-in master on a local port. Co-Authored-By: Claude Opus 5.5 --- lib/firm-catalog.ts | 22 ++++++-- lib/master-protocol.ts | 3 ++ package.json | 2 +- scripts/check-catalog.mjs | 107 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 scripts/check-catalog.mjs diff --git a/lib/firm-catalog.ts b/lib/firm-catalog.ts index e71ac6b..3fda3ab 100644 --- a/lib/firm-catalog.ts +++ b/lib/firm-catalog.ts @@ -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(); 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; @@ -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 { diff --git a/lib/master-protocol.ts b/lib/master-protocol.ts index 897ccf9..e062ecb 100644 --- a/lib/master-protocol.ts +++ b/lib/master-protocol.ts @@ -508,6 +508,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 { diff --git a/package.json b/package.json index 90e5328..9cab0cc 100644 --- a/package.json +++ b/package.json @@ -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 scripts/check-trades.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-trades.mjs scripts/check-catalog.mjs" }, "dependencies": { "@microsoft/signalr": "^10.0.11", diff --git a/scripts/check-catalog.mjs b/scripts/check-catalog.mjs new file mode 100644 index 0000000..28c4d55 --- /dev/null +++ b/scripts/check-catalog.mjs @@ -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')); +}); From 4add379c686f8e558feafa9c9cd8ffbc8d02fa98 Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Tue, 27 Oct 2026 14:15:02 -0500 Subject: [PATCH 2/5] Dashboard: a stage's own daily loss limit shows its stop, its personal DLL and its min days Since a withdrawal stage can carry its own daily loss limit, the engine places that stop, but the dashboard and report still asked the type's base limit whether the bot stops at all: with a base of 0 and no obfuscation options the row showed no "stop $X", and a stopped-out red day read "Flat" (and counted as a loss, not a personal DLL, in the report). botStopsActive now takes the stage-resolved config. The state also carries the stage's daily loss limit and min trading days, which the account page and the Days column show instead of the type's. Co-Authored-By: Claude Opus 5.5 --- app/accounts/[id]/page.tsx | 9 ++++++--- app/api/state/route.ts | 6 ++++-- app/page.tsx | 2 +- lib/reporter.ts | 4 ++-- lib/trading-logic.ts | 13 +++++++++---- scripts/check-trading-logic.mjs | 16 ++++++++++++++++ types.ts | 4 ++++ 7 files changed, 42 insertions(+), 12 deletions(-) diff --git a/app/accounts/[id]/page.tsx b/app/accounts/[id]/page.tsx index 4f22694..914613e 100644 --- a/app/accounts/[id]/page.tsx +++ b/app/accounts/[id]/page.tsx @@ -513,9 +513,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. @@ -739,7 +742,7 @@ export default function AccountPage() { {dailyTarget != null && (
diff --git a/app/api/state/route.ts b/app/api/state/route.ts index 7654ae1..19cd077 100644 --- a/app/api/state/route.ts +++ b/app/api/state/route.ts @@ -171,7 +171,7 @@ export async function GET() { }; } } - if (plan && !dayBooked && botStopsActive(cfg)) { + 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. @@ -273,7 +273,7 @@ export async function GET() { // 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] @@ -293,6 +293,8 @@ export async function GET() { lastStand: lastStandNext, remainingToday, effectiveProfitTarget, + effectiveDailyLossLimit: effective?.dailyLossLimit ?? null, + effectiveMinTradingDays: effective?.minTradingDays ?? null, dailyPnL, fullDailyPnL: combineCmeSessions( cfg?.combineCmeHolidays === true, diff --git a/app/page.tsx b/app/page.tsx index 94a184b..ff1663e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -453,7 +453,7 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned, onStateChang - {account.daysTraded} / {cfg?.minTradingDays ?? '—'} + {account.daysTraded} / {account.effectiveMinTradingDays ?? cfg?.minTradingDays ?? '—'}
diff --git a/lib/reporter.ts b/lib/reporter.ts index ef52e02..c5b3ad7 100644 --- a/lib/reporter.ts +++ b/lib/reporter.ts @@ -373,11 +373,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) { diff --git a/lib/trading-logic.ts b/lib/trading-logic.ts index 9c40fac..da9e6d0 100644 --- a/lib/trading-logic.ts +++ b/lib/trading-logic.ts @@ -1536,10 +1536,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; } /** diff --git a/scripts/check-trading-logic.mjs b/scripts/check-trading-logic.mjs index 5d828bd..5f25f50 100644 --- a/scripts/check-trading-logic.mjs +++ b/scripts/check-trading-logic.mjs @@ -305,6 +305,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 diff --git a/types.ts b/types.ts index bda98ca..86b6a8b 100644 --- a/types.ts +++ b/types.ts @@ -136,6 +136,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. */ From e33e890a835ad25980ea69524ecb9ecbac037bbc Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Tue, 27 Oct 2026 14:15:07 -0500 Subject: [PATCH 3/5] Vest: payout projections and the completion catch-up restart same-day stages each day projectPerfectPath and endedCycleCompletion counted every withdrawal ever made, so a Vest funded account that had claimed on any earlier day was projected at stage 2's $3,000 forever (labelled stage 3, 4, ...), and a day's stage-1 cycle that the auto-claim ended before the reporter saw it was judged against $3,000 and never reported. The projection now takes the stage inputs: a type whose stages reset each day trades today on today's payouts and every projected day from the base stage. The catch-up judges a same-day type's ended cycle on its day's base stage; the stage it reports stays the payout's number among the account's withdrawals, which the master clears the entry by. Other types project exactly as before. Co-Authored-By: Claude Opus 5.5 --- app/accounts/[id]/page.tsx | 2 ++ lib/reporter.ts | 1 + lib/trading-logic.ts | 52 +++++++++++++++++++++--------- scripts/check-trading-logic.mjs | 56 ++++++++++++++++++++++++++++++++- types.ts | 3 ++ 5 files changed, 99 insertions(+), 15 deletions(-) diff --git a/app/accounts/[id]/page.tsx b/app/accounts/[id]/page.tsx index 914613e..bb91408 100644 --- a/app/accounts/[id]/page.tsx +++ b/app/accounts/[id]/page.tsx @@ -608,6 +608,8 @@ 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 }, maxWithdrawals: 2, payout: { withdrawalPct: cfg.withdrawalPct, diff --git a/lib/reporter.ts b/lib/reporter.ts index c5b3ad7..089ab22 100644 --- a/lib/reporter.ts +++ b/lib/reporter.ts @@ -208,6 +208,7 @@ function collectFirmStats() { fundTransactions: fundTxns, balance: cash.amount, realizedPnL: cash.realizedPnL, + stageInputs: stageInputsFor(client, acc.id, cfg), payout: { withdrawalPct: cfg.withdrawalPct, payoutCap: cfg.payoutCap, diff --git a/lib/trading-logic.ts b/lib/trading-logic.ts index da9e6d0..af10716 100644 --- a/lib/trading-logic.ts +++ b/lib/trading-logic.ts @@ -442,6 +442,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; @@ -528,17 +533,28 @@ 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, and none on a projected day — + // the path pays out at most once a day, as the day's last entry, so + // every projected day trades the base stage. + const sameDay = opts.stageInputs?.stagesSameDay === true; + /** 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) + : funds.filter((f) => f.amount < 0).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) } : {}, ); - /** 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 +566,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 +595,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); @@ -601,14 +617,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 @@ -620,8 +636,8 @@ export function projectPerfectPath(opts: { 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; + 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, @@ -647,7 +663,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, @@ -819,6 +835,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; @@ -852,10 +870,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,6 +888,8 @@ 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 ?? '', diff --git a/scripts/check-trading-logic.mjs b/scripts/check-trading-logic.mjs index 5f25f50..07f0b61 100644 --- a/scripts/check-trading-logic.mjs +++ b/scripts/check-trading-logic.mjs @@ -11,7 +11,7 @@ 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, resetClearStop, shortestNextLeg, computeDayPlan, @@ -1147,3 +1147,57 @@ 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); }); + +// 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', () => { + 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. + const fresh = projectPerfectPath({ ...opts, stageInputs: { dailyLossLimit: 1229, stagesSameDay: true, sameDayWithdrawals: 0 } }); + assert.deepEqual(firstOf(fresh, 3), [['2026-10-27', 1, -1500], ['2026-10-28', 1, -1500], ['2026-10-29', 1, -1500]]); + assert.ok(fresh.every((d) => d.stage === 1)); + + // Claimed once already today: today's cycle is stage 2's $3,000, tomorrow $1,500 again. + const claimedToday = projectPerfectPath({ ...opts, stageInputs: { dailyLossLimit: 1229, stagesSameDay: true, sameDayWithdrawals: 1 } }); + assert.deepEqual(firstOf(claimedToday, 2), [['2026-10-27', 2, -3000], ['2026-10-28', 1, -1500]]); + + // 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('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); +}); diff --git a/types.ts b/types.ts index 86b6a8b..aac65e4 100644 --- a/types.ts +++ b/types.ts @@ -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; } From 266100b65cb645112ebdae6eecaf1a30732d5d0f Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Tue, 27 Oct 2026 14:34:30 -0500 Subject: [PATCH 4/5] Vest: projected days trade the next stage after each payout, as the bot does runClaims claims a same-day type's target and trades its next stage the same day on the room the claim freed, but the projection paid out once a day, so Vest's payout calendar showed $1,500 a day instead of $1,500 then $3,000. Each payout now counts toward its own date, and the same date is projected again on the next stage, once through the stages a day (the last one repeats). A cycle that stood complete before today counts toward today, when it is claimed. Other types project as before. The account page sums a date's cycles, cuts after the second payout day, and labels a same-day type's past days from that day's own payout. Co-Authored-By: Claude Opus 5.5 --- app/accounts/[id]/page.tsx | 45 ++++++++++++++++++--------------- lib/trading-logic.ts | 29 ++++++++++++++++----- scripts/check-trading-logic.mjs | 32 ++++++++++++++++++----- 3 files changed, 74 insertions(+), 32 deletions(-) diff --git a/app/accounts/[id]/page.tsx b/app/accounts/[id]/page.tsx index bb91408..a77f4b7 100644 --- a/app/accounts/[id]/page.tsx +++ b/app/accounts/[id]/page.tsx @@ -600,7 +600,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, @@ -610,7 +610,8 @@ export default function AccountPage() { 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 }, - maxWithdrawals: 2, + // 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, @@ -626,23 +627,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(); + const projFundMap = new Map(); + 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])); @@ -657,14 +656,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(); 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); } diff --git a/lib/trading-logic.ts b/lib/trading-logic.ts index af10716..7816806 100644 --- a/lib/trading-logic.ts +++ b/lib/trading-logic.ts @@ -534,15 +534,23 @@ export function projectPerfectPath(opts: { }; // A type whose stages reset each day (Vest) counts only the payouts made - // since its day began: today's real ones, and none on a projected day — - // the path pays out at most once a day, as the day's last entry, so - // every projected day trades the base stage. + // 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(); /** 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) + ? (day === today ? Math.max(0, Math.floor(opts.stageInputs?.sameDayWithdrawals ?? 0)) : 0) + (paidOn.get(day) ?? 0) : funds.filter((f) => f.amount < 0).length); + /** 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, @@ -604,6 +612,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; @@ -633,9 +644,13 @@ 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); + 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( @@ -650,6 +665,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; } @@ -673,6 +689,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 }); } diff --git a/scripts/check-trading-logic.mjs b/scripts/check-trading-logic.mjs index 07f0b61..1e6d15d 100644 --- a/scripts/check-trading-logic.mjs +++ b/scripts/check-trading-logic.mjs @@ -1157,7 +1157,7 @@ const vestFunded = { }; 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', () => { +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'), @@ -1165,14 +1165,34 @@ test('projectPerfectPath: a same-day type trades each projected day from the bas 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. + // 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, 3), [['2026-10-27', 1, -1500], ['2026-10-28', 1, -1500], ['2026-10-29', 1, -1500]]); - assert.ok(fresh.every((d) => d.stage === 1)); + 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 again. + // 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, 2), [['2026-10-27', 2, -3000], ['2026-10-28', 1, -1500]]); + 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); From f325b5e969c5a8d7d57dfca2daa8e066c90a466b Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Tue, 27 Oct 2026 14:34:30 -0500 Subject: [PATCH 5/5] Account page: clear its three lint errors The equity curve's running total is summed before the map that reads it, the first load runs from a timer like the polls, and the two recharts callbacks keep their any with the repo's usual disable line. No change in what the page shows. Co-Authored-By: Claude Opus 5.5 --- app/accounts/[id]/page.tsx | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/app/accounts/[id]/page.tsx b/app/accounts/[id]/page.tsx index a77f4b7..b7f4cf1 100644 --- a/app/accounts/[id]/page.tsx +++ b/app/accounts/[id]/page.tsx @@ -282,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 @@ -487,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) { @@ -555,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, @@ -902,6 +911,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 ; return (