diff --git a/app/accounts/[id]/page.tsx b/app/accounts/[id]/page.tsx index 4f22694..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) { @@ -513,9 +516,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. @@ -552,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, @@ -597,7 +609,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, @@ -605,7 +617,10 @@ export default function AccountPage() { fundTransactions, balance: account.amount, realizedPnL: account.realizedPnL, - maxWithdrawals: 2, + // A type whose stages reset each day: its state stage counts today's payouts. + stageInputs: { stagesSameDay: cfg.stagesSameDay === true, sameDayWithdrawals: 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: { withdrawalPct: cfg.withdrawalPct, payoutCap: cfg.payoutCap, @@ -621,23 +636,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])); @@ -652,14 +665,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); } @@ -739,7 +758,7 @@ export default function AccountPage() { {dailyTarget != null && (
@@ -892,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 ( diff --git a/app/api/state/route.ts b/app/api/state/route.ts index c80931b..73457f1 100644 --- a/app/api/state/route.ts +++ b/app/api/state/route.ts @@ -173,7 +173,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. @@ -276,7 +276,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] @@ -296,6 +296,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/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 3676dcd..49324e2 100644 --- a/lib/master-protocol.ts +++ b/lib/master-protocol.ts @@ -514,6 +514,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/lib/reporter.ts b/lib/reporter.ts index 6b90ac6..55eccdf 100644 --- a/lib/reporter.ts +++ b/lib/reporter.ts @@ -221,6 +221,7 @@ function collectFirmStats() { fundTransactions: fundTxns, balance: cash.amount, realizedPnL: cash.realizedPnL, + stageInputs: stageInputsFor(client, acc.id, cfg), payout: { withdrawalPct: cfg.withdrawalPct, payoutCap: cfg.payoutCap, @@ -399,11 +400,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 d1f5ce2..2ab5012 100644 --- a/lib/trading-logic.ts +++ b/lib/trading-logic.ts @@ -454,6 +454,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; @@ -540,17 +545,36 @@ 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 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) + (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, + 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 @@ -562,7 +586,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, @@ -591,7 +615,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); @@ -600,6 +624,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; @@ -613,14 +640,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 @@ -629,11 +656,15 @@ 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); - const eff = effectiveNow(); - const stage = 1 + funds.filter((f) => f.amount < 0).length; + 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( accountId, date, eff.profitTarget, eff.consistency, sim, eff.minDayPnL, eff.minTradingDays, balance - cfg.accountSize, 0, cfg.splitEnabled, eff.drain, @@ -646,6 +677,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; } @@ -659,7 +691,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, @@ -669,6 +701,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 }); } @@ -865,6 +898,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; @@ -898,10 +933,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. @@ -912,6 +951,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 ?? '', @@ -1599,10 +1640,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/package.json b/package.json index 3e3fed7..6b3f8bd 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-vest-client.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-vest-client.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')); +}); diff --git a/scripts/check-trading-logic.mjs b/scripts/check-trading-logic.mjs index 3a86875..2bee9da 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, clearsClose, RESET_CLEAR_HOLDS, @@ -306,6 +306,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 @@ -1268,3 +1284,77 @@ test('the engine says what each order is for, and both position-based clients go assert.match(src, /= orderEffect\(\{[^}]*\bintent\b[^}]*\}\) === 'close' \? held : undefined;/, `${file}: orderEffect doesn't pick the route`); } }); + +// 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, 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'), + }; + 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, 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, 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 and $3,000 again. + const claimedToday = projectPerfectPath({ ...opts, stageInputs: { dailyLossLimit: 1229, stagesSameDay: true, sameDayWithdrawals: 1 } }); + 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); + 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 bda98ca..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; } @@ -136,6 +139,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. */