diff --git a/app/accounts/[id]/page.tsx b/app/accounts/[id]/page.tsx index cbd130f..3b74400 100644 --- a/app/accounts/[id]/page.tsx +++ b/app/accounts/[id]/page.tsx @@ -17,6 +17,7 @@ import { import type { FirmConfig, FirmState, AccountState, AccountConfig } from '@/types'; import { stagePillClasses } from '@/lib/stage-colors'; import { projectPerfectPath } from '@/lib/trading-logic'; +import { RulesDate } from '@/app/components/RulesDate'; /** Reassign this account to another of its firm's account types, or hand it * back to the auto-resolver. Mirrors the control on the dashboard. */ @@ -578,6 +579,7 @@ export default function AccountPage() { onAssigned={() => { void load(); }} /> )} + { void load(); }} /> /

{account.name}

{isDead && ( diff --git a/app/api/accounts/[id]/rules-date/route.ts b/app/api/accounts/[id]/rules-date/route.ts new file mode 100644 index 0000000..2e7cb4d --- /dev/null +++ b/app/api/accounts/[id]/rules-date/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { setRulesDate } from '@/lib/db'; +import { sortableIso } from '@/lib/kept-rules'; + +type Params = { params: Promise<{ id: string }> }; + +/** A rules date may run this far ahead of the server's clock — room for a + * browser whose clock or time zone is ahead, not for dating accounts later. */ +const MAX_FUTURE_MS = 24 * 60 * 60 * 1000; + +function parseAccountId(raw: string): number | null { + const id = parseInt(raw, 10); + return Number.isNaN(id) ? null : id; +} + +/** Set an account's rules date by hand. The date decides which of its type's + * rule sets it trades on — one dated before a firm's rule change keeps the + * old rules — and a date set here is never moved by broker evidence again. + * The trade path reads it per tick, so nothing needs re-resolving. */ +export async function PUT(req: NextRequest, { params }: Params) { + const { id: idStr } = await params; + const accountId = parseAccountId(idStr); + if (accountId === null) return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); + + const body = await req.json().catch(() => null) as { rulesDate?: unknown } | null; + const raw = body?.rulesDate; + const ms = typeof raw === 'string' ? Date.parse(raw) : NaN; + if (!Number.isFinite(ms)) { + return NextResponse.json({ error: 'rulesDate must be an ISO date' }, { status: 400 }); + } + if (ms > Date.now() + MAX_FUTURE_MS) { + return NextResponse.json({ error: 'A rules date cannot be more than a day ahead' }, { status: 400 }); + } + + // Stored as a plain four-digit-year toISOString(), the one format every + // rules-date writer uses — lowerAutoRulesDate compares them as strings, + // so nothing before 2000 (or that doesn't print as one) gets in. + const rulesDate = sortableIso(ms); + if (!rulesDate) { + return NextResponse.json({ error: 'A rules date cannot be before 2000' }, { status: 400 }); + } + if (!setRulesDate(accountId, rulesDate, true)) { + return NextResponse.json({ error: 'Unknown account' }, { status: 404 }); + } + return NextResponse.json({ ok: true, rulesDate }); +} diff --git a/app/api/firms/[id]/route.ts b/app/api/firms/[id]/route.ts index 61e9f56..0626c84 100644 --- a/app/api/firms/[id]/route.ts +++ b/app/api/firms/[id]/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getCachedFirm } from '@/lib/firm-catalog'; +import { getCachedFirm, getKeptRulesFor } from '@/lib/firm-catalog'; import { firmInstruments, instrumentsSource } from '@/lib/instruments'; import { getCredentials } from '@/lib/db'; @@ -25,7 +25,11 @@ export async function GET( hasCredentials: !!creds, instruments: firmInstruments(firm), instrumentsSource: instrumentsSource(firm), - accounts: firm.accountTypes, + // Same shape as GET /api/firms: each type with its old rule sets' windows. + accounts: firm.accountTypes.map((a) => ({ + ...a, + kept: getKeptRulesFor(a.id).map((k) => ({ id: k.id, openedAt: k.openedAt, closedAt: k.closedAt })), + })), catalogUpdatedAt: firm.updatedAt, }); } diff --git a/app/api/firms/route.ts b/app/api/firms/route.ts index 8d87271..d3fc6b5 100644 --- a/app/api/firms/route.ts +++ b/app/api/firms/route.ts @@ -1,8 +1,13 @@ import { NextResponse } from 'next/server'; import { firmInstruments, instrumentsSource } from '@/lib/instruments'; -import { getCachedCatalog, hasPlatformCredentials } from '@/lib/firm-catalog'; +import { getCachedCatalog, getKeptRulesFor, hasPlatformCredentials } from '@/lib/firm-catalog'; import { getCredentials } from '@/lib/db'; -import type { FirmConfig } from '@/types'; +import type { FirmConfig, KeptRulesWindow } from '@/types'; + +/** The windows of a live account type's old rule sets, by closedAt. */ +function keptWindows(accountTypeId: string): KeptRulesWindow[] { + return getKeptRulesFor(accountTypeId).map((k) => ({ id: k.id, openedAt: k.openedAt, closedAt: k.closedAt })); +} /** * The firms this machine knows about — the master's catalog, annotated with @@ -14,6 +19,9 @@ import type { FirmConfig } from '@/types'; * * The stored password is deliberately not returned. It used to be, and nothing * ever read it. + * + * Each account type carries the windows of the old rule sets it keeps (`kept`) + * so the dashboard's rules-date control can say which rules a date lands on. */ export async function GET() { try { @@ -48,6 +56,7 @@ export async function GET() { withdrawalStages: a.withdrawalStages, dailyLossLimit: a.dailyLossLimit ?? 0, tradesEveryDay: a.tradesEveryDay === true, + kept: keptWindows(a.id), })), catalogUpdatedAt: firm.updatedAt, }; diff --git a/app/api/state/route.ts b/app/api/state/route.ts index 8463600..d15f042 100644 --- a/app/api/state/route.ts +++ b/app/api/state/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from 'next/server'; -import { loadDailyPnL } from '@/lib/db'; +import { listAssignments, loadDailyPnL } from '@/lib/db'; import { getClients } from '@/lib/clients'; -import { getCachedCatalog } from '@/lib/firm-catalog'; +import { getAccountTypeById, getCachedCatalog } from '@/lib/firm-catalog'; +import { ruleDiffs } from '@/lib/kept-rules'; import { getAccountConfig, getInferredSize, matchAccountType } from '@/lib/account-type-resolver'; import { historyFresh } from '@/lib/platform-client'; import { rankNextLeg } from '@/lib/leg-estimate'; @@ -29,6 +30,11 @@ export async function GET() { if (!client || client.accountList.length === 0) { return { firmId: f.id, firm: f.name, connected, accounts: [] }; } + // Accounts whose rules date was set by hand — one read per firm + // rather than one per account. + const rulesDateByHand = new Set(listAssignments(f.id) + .filter((row) => row.rules_date_manual === 1) + .map((row) => row.account_id)); const accounts = client.accountList.map((acc) => { const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 }; @@ -84,6 +90,20 @@ export async function GET() { const ambiguous = cfg ? matchAccountType(acc.name, inferredAccountSize, f.accountTypes).ambiguous : false; + // An account on one of its type's old rule sets: cfg already + // carries those rules; say which set, and how it differs from + // the type's current rules. + const live = resolved.keptRules && resolved.accountTypeId + ? getAccountTypeById(resolved.accountTypeId) + : undefined; + const keptRules: AccountState['keptRules'] = resolved.keptRules && cfg && live + ? { + id: resolved.keptRules.id, + openedAt: resolved.keptRules.openedAt, + closedAt: resolved.keptRules.closedAt, + differs: ruleDiffs(cfg, live), + } + : null; let targetHit = false; let dailyTarget: { amount: number; path: 'first_day' | 'normal_day' | 'reduced_day' } | null = null; let remainingToday: number | null = null; @@ -289,6 +309,9 @@ export async function GET() { accountType: cfg, inferredAccountSize, accountTypeAmbiguous: ambiguous, + rulesDate: resolved.rulesDate, + rulesDateManual: rulesDateByHand.has(acc.id), + keptRules, }; }); return { firmId: f.id, firm: f.name, connected, accounts }; diff --git a/app/components/RulesDate.tsx b/app/components/RulesDate.tsx new file mode 100644 index 0000000..15b6f03 --- /dev/null +++ b/app/components/RulesDate.tsx @@ -0,0 +1,317 @@ +'use client'; + +import { useRef, useState, type MouseEvent } from 'react'; +import type { AccountState, FirmAccountType, FirmConfig, KeptRulesWindow } from '@/types'; +import { evidenceDay, pickedDayRulesDate, pickKeptRules, unchangedDaySaves } from '@/lib/kept-rules'; + +/** + * An account's rules date, shown next to its name, and the "old rules" chip. + * + * When a firm's rules change on the master with "New accounts only", accounts + * dated before the change keep the old rules. The date defaults to when the + * account started (or when this machine first saw it); clicking it edits it + * inline — a past day is stored as its start, today as now (the master's + * "today means from now") — with a preview of which rules that lands on. + * Used on the dashboard row and the account page. + */ + +/** What the master's Firms page calls each rule field (its lib/kept-rules.ts + * ruleLabel), capitalized — so an "old rules" difference reads the same on + * both. */ +const RULE_LABELS: Record = { + profitTarget: 'Profit target', + consistency: 'Consistency', + minDayPnL: 'Min day P&L', + minTradingDays: 'Min trading days', + maxLoss: 'Max drawdown', + maxPositionSize: 'Max contracts', + targetSameEquity: 'Same equity after first w/d', + splitEnabled: 'Split legs', + minLegs: 'Min legs', + firstDayStop: 'Day 1 $1k stop', + minDayStop: 'Min-day 2× stop', + combineCmeHolidays: 'CME holiday combining', + withdrawalStages: 'Withdrawal stages', + payoutable: 'Payoutable', + trailingDrawdown: 'Trailing drawdown', + dailyLossLimit: 'Hard daily loss', +}; + +function money(n: number): string { + return `$${n.toLocaleString('en-US', { maximumFractionDigits: 2 })}`; +} + +/** One rule value the way the Firms page shows it: money as $3,000, ratios as + * 50%, switches on/off, stages by count. */ +function formatRule(field: string, value: unknown): string { + if (value === undefined || value === null) return 'unset'; + if (typeof value === 'boolean') return value ? 'on' : 'off'; + if (Array.isArray(value)) { + return value.length === 0 ? 'none' : `${value.length} stage${value.length === 1 ? '' : 's'}`; + } + if (typeof value !== 'number') return JSON.stringify(value); + switch (field) { + case 'consistency': + return value === 0 ? 'none' : `${Math.round(value * 100)}%`; + case 'minDayPnL': + return value <= -999 ? 'none' : money(value); + case 'maxPositionSize': + return value === 0 ? 'no limit' : String(value); + case 'minLegs': + return value === 0 ? 'auto' : String(value); + case 'trailingDrawdown': + return value === 0 ? 'unset' : money(value); + case 'maxLoss': + case 'dailyLossLimit': + return value === 0 ? 'none' : money(Math.abs(value)); + case 'profitTarget': + return money(value); + default: + return String(value); + } +} + +/** "Consistency 50% (type now 40%)". Two edits that summarize alike — three + * stages either way — just say the field changed. */ +function describeDiff(d: { field: string; kept: unknown; now: unknown }): string { + const label = RULE_LABELS[d.field] ?? d.field; + const kept = formatRule(d.field, d.kept); + const now = formatRule(d.field, d.now); + return kept === now ? `${label} changed since` : `${label} ${kept} (type now ${now})`; +} + +/** "Oct 18, 2026, 2:05 PM". */ +function fullWhen(iso: string): string { + return new Date(iso).toLocaleString('en-US', { + month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', + }); +} + +/** "Oct 12", with the year when it isn't this year's; `time` adds "2:05 PM". */ +function shortWhen(iso: string, time = false): string { + const d = new Date(iso); + const opts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' }; + if (d.getFullYear() !== new Date().getFullYear()) opts.year = 'numeric'; + if (time) { + opts.hour = 'numeric'; + opts.minute = '2-digit'; + } + return d.toLocaleString('en-US', opts); +} + +/** Which rules dates a kept set covers, for the chip's tooltip. */ +function windowText(k: KeptRulesWindow): string { + return k.openedAt + ? `kept for accounts dated from ${fullWhen(k.openedAt)} to before ${fullWhen(k.closedAt)}` + : `kept for accounts dated before ${fullWhen(k.closedAt)}`; +} + +/** The same, short, for the editor's preview line. */ +function windowShort(k: KeptRulesWindow): string { + return k.openedAt + ? `${shortWhen(k.openedAt, true)} – ${shortWhen(k.closedAt, true)}` + : `before ${shortWhen(k.closedAt, true)}`; +} + +/** "Oct 12" for a calendar day (YYYY-MM-DD) itself, whatever the browser's + * zone, with the year when it isn't this year's; `year` always adds it. */ +function dayLabel(day: string, year = false): string { + const d = new Date(`${day}T12:00:00.000Z`); + const opts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', timeZone: 'UTC' }; + if (year || d.getUTCFullYear() !== new Date().getFullYear()) opts.year = 'numeric'; + return d.toLocaleString('en-US', opts); +} + +/** YYYY-MM-DD of a moment in the browser's time zone — a date input's value. */ +function localDay(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +/** A date input's value as that day's local midnight; null when unparsable. */ +function localMidnight(day: string): Date | null { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day); + if (!m) return null; + const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])); + return Number.isNaN(d.getTime()) ? null : d; +} + +/** The rules date a picked day stores (lib/kept-rules.ts pickedDayRulesDate): + * today as now, any other day as its start. null when unparsable. */ +function storedFor(day: string): string | null { + return pickedDayRulesDate(day, { + today: localDay(new Date()), + nowMs: Date.now(), + localMidnightMs: localMidnight(day)?.getTime() ?? NaN, + }); +} + +/** The amber chip on an account trading on one of its type's old rule sets. */ +export function OldRulesChip({ keptRules }: { keptRules: NonNullable }) { + const lines = keptRules.differs.length > 0 + ? keptRules.differs.map(describeDiff) + : ['Same as the type\'s current rules']; + return ( + + old rules + + ); +} + +/** The rules date and, when the account is on an old rule set, its chip. + * `firm` names the firm whose type's kept windows the editor's preview + * reads (none without it) — fetched fresh each time the editor opens, as a + * page's firm listing can predate a rule change on the master; `onSaved` + * should refresh /api/state. */ +export function RulesDate({ account, firm, onSaved }: { + account: AccountState; + firm: FirmConfig | null; + onSaved: () => void; +}) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(''); + // The date and day the editor opened on: the 5s state poll can move the + // date underneath (evidence lowering it), and an untouched ✓ must still + // mean "leave it", not "store the day it showed when opened". + const [opened, setOpened] = useState<{ date: string; day: string } | null>(null); + // The type's kept windows as the server has them now; null while loading + // or when unknown. + const [kept, setKept] = useState(null); + const openCount = useRef(0); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + // No assignment row yet (the resolver hasn't seen the account): nothing to + // show or set. + if (account.rulesDate === null) return null; + const rulesDate = account.rulesDate; + // Broker evidence is the end of a Chicago day: shown as that day in any + // zone, not as the next one east of Chicago. + const brokerDay = evidenceDay(rulesDate); + const currentDay = brokerDay ?? localDay(new Date(rulesDate)); + + // What ✓ would store, and the rules it lands on. + const chosen = editing ? storedFor(draft) : null; + const landsOn = kept && chosen ? pickKeptRules(kept, chosen) : null; + const preview = !kept || !chosen + ? null + : landsOn ? `→ old rules (${windowShort(landsOn)})` : '→ current rules'; + + function open(e: MouseEvent) { + e.stopPropagation(); + setDraft(currentDay); + setOpened({ date: rulesDate, day: currentDay }); + setError(''); + setKept(null); + setEditing(true); + const ticket = ++openCount.current; + const fallback = firm?.accounts.find((t) => t.id === account.accountTypeId)?.kept ?? null; + if (!firm || !account.accountTypeId) return; + void (async () => { + let windows: KeptRulesWindow[] | null = fallback; + try { + const res = await fetch(`/api/firms/${encodeURIComponent(firm.id)}`); + if (res.ok) { + const data = await res.json() as { accounts?: FirmAccountType[] }; + windows = data.accounts?.find((t) => t.id === account.accountTypeId)?.kept ?? null; + } + } catch { + // Offline: the page's copy is the best there is. + } + if (ticket === openCount.current) setKept(windows); + })(); + } + + async function save() { + const value = storedFor(draft); + if (!value || !opened) { + setError('Pick a date'); + return; + } + // An unchanged day keeps the exact date, unless storing it moves the + // account to other rules — what the preview then says ✓ does. + if (draft === opened.day && !unchangedDaySaves(kept, opened.date, value)) { + setEditing(false); + return; + } + setSaving(true); + setError(''); + try { + const res = await fetch(`/api/accounts/${account.id}/rules-date`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ rulesDate: value }), + }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? 'Failed'); + setEditing(false); + onSaved(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed'); + } finally { + setSaving(false); + } + } + + if (editing) { + return ( + e.stopPropagation()}> + setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void save(); + if (e.key === 'Escape') setEditing(false); + }} + className="border border-slate-200 rounded-md px-1 py-0.5 text-[11px] bg-white text-slate-700 focus:outline-none focus:ring-1 focus:ring-blue-400" + /> + + + {preview && {preview}} + {error && {error}} + + ); + } + + return ( + + + {account.keptRules && } + + ); +} diff --git a/app/page.tsx b/app/page.tsx index 701fb40..2fd82be 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'; import Link from 'next/link'; import type { FirmConfig, FirmState, AccountState } from '@/types'; import { stagePillClasses } from '@/lib/stage-colors'; +import { RulesDate } from '@/app/components/RulesDate'; type SortKey = 'name' | 'balance' | 'dayPnL' | 'daysTraded' | 'target' | 'status'; type SortDir = 'asc' | 'desc'; @@ -342,12 +343,14 @@ function WipeAllButton() { ); } -function AccountRow({ account, firm, hideDead, privacy, onAssigned }: { +function AccountRow({ account, firm, hideDead, privacy, onAssigned, onStateChanged }: { account: AccountState; firm: FirmConfig; hideDead: boolean; privacy: boolean; onAssigned: () => void; + /** Refetches /api/state — after a rules date is saved. */ + onStateChanged: () => void; }) { const cfg = account.accountType; const dead = isAccountDead(account); @@ -359,8 +362,9 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned }: { return ( -
- {maskName(account.name, privacy)} +
+ {maskName(account.name, privacy)} +
${fmt(account.amount)} @@ -440,7 +444,7 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned }: { ); } -function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbol, onChanged }: { +function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbol, onChanged, onStateChanged }: { state: FirmState; firm: FirmConfig; hideDead: boolean; @@ -449,6 +453,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo sortDir: SortDir; tradeSymbol: string; onChanged: () => void; + onStateChanged: () => void; }) { const [open, setOpen] = useState(true); const [confirmCreds, setConfirmCreds] = useState(false); @@ -601,6 +606,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo hideDead={hideDead} privacy={privacy} onAssigned={onChanged} + onStateChanged={onStateChanged} /> )) ) @@ -673,6 +679,18 @@ export default function Home() { fetchConfig(); }; + const fetchState = async () => { + try { + const res = await fetch('/api/state'); + if (res.ok) { + const data: FirmState[] = await res.json(); + setFirms(data); + } + } catch { + // server not running yet + } + }; + const fetchScheduler = async () => { try { const res = await fetch('/api/auto-trade'); @@ -701,19 +719,6 @@ export default function Home() { fetchConfig(); fetchCatalog(); fetchScheduler(); - - const fetchState = async () => { - try { - const res = await fetch('/api/state'); - if (res.ok) { - const data: FirmState[] = await res.json(); - setFirms(data); - } - } catch { - // server not running yet - } - }; - fetchState(); const stateInterval = setInterval(fetchState, 5000); const schedulerInterval = setInterval(fetchScheduler, 5000); @@ -965,6 +970,7 @@ export default function Home() { sortDir={sortDir} tradeSymbol={tradeSymbol} onChanged={fetchConfig} + onStateChanged={fetchState} /> ); }) diff --git a/lib/account-type-resolver.ts b/lib/account-type-resolver.ts index e244020..88f4504 100644 --- a/lib/account-type-resolver.ts +++ b/lib/account-type-resolver.ts @@ -9,31 +9,58 @@ * * An Unassigned account does not trade: every caller already guards on a * missing config, which is the behaviour we want. + * + * Which of its type's rule sets an account trades on is the other half (see + * lib/kept-rules.ts): each assignment row carries a rules date, and an + * account dated inside one of the type's kept rule sets trades that set's + * rules — getAccountConfig applies it, so every trading read sees it. */ -import type { CatalogAccountType } from './master-protocol'; +import type { CatalogAccountType, KeptAccountRules } from './master-protocol'; import type { PlatformClient } from './platform-client'; +import { firstFundDate } from './platform-client'; import { getAssignment, setAssignment, setAutoAssignment, - clearAssignment, + unpinAssignment, + lowerAutoRulesDate, + stampMissingRulesDate, listAssignments, loadAccountMeta, saveAccountMeta, } from './db'; -import { getAccountTypeById, getCachedFirm } from './firm-catalog'; +import { getAccountTypeById, getCachedFirm, getCatalogUpdatedAt, getKeptRulesFor, getMasterOffsetMs } from './firm-catalog'; +import { + identityStillMatches, lowersRulesDate, openingEvidence, overlayKept, pickKeptRules, stampRulesDate, + type EffectiveAccountType, type KeptRulesWindow, +} from './kept-rules'; const INFERRED_SIZE_KEY = 'inferred_account_size'; +/** Accounts this process has run the evidence step on with their fund + * history loaded (see ensureRulesEvidence). On `global` like the clients, + * so the trade path and the resolver share it whichever bundle loads them. */ +const ge = global as typeof globalThis & { __rulesEvidenceApplied?: Set }; +function evidenceApplied(): Set { + return (ge.__rulesEvidenceApplied ??= new Set()); +} + export type AssignmentSource = 'manual' | 'auto' | 'unassigned'; export interface ResolvedAssignment { + /** Always the live account type's id — a kept rule set's never is one. */ accountTypeId: string | null; - accountType: CatalogAccountType | null; + /** The rules the account trades on: its live type, or the live type with + * a kept rule set's rules on top (then keptRules says which). */ + accountType: EffectiveAccountType | null; source: AssignmentSource; /** True when more than one account type matched and the tie-break decided. */ ambiguous: boolean; + /** The account's rules date (ISO); null = none recorded, current rules. */ + rulesDate: string | null; + /** The kept rule set the account trades on; null = its type's current rules. */ + keptRules: KeptRulesWindow | null; } const UNASSIGNED: ResolvedAssignment = { @@ -41,8 +68,23 @@ const UNASSIGNED: ResolvedAssignment = { accountType: null, source: 'unassigned', ambiguous: false, + rulesDate: null, + keptRules: null, }; +/** One overlay per kept set and live type, so a tick's many lookups share one + * object the way they share the live type. Both come from the same catalog + * index, so a sync replaces them together. */ +const overlays = new WeakMap(); + +function overlaid(live: CatalogAccountType, kept: KeptAccountRules): EffectiveAccountType { + const hit = overlays.get(kept); + if (hit && hit.live === live) return hit.effective; + const effective = overlayKept(live, kept); + overlays.set(kept, { live, effective }); + return effective; +} + /** The earliest positive fund transaction — the account's opening balance. * Returns null while the reports fetch hasn't produced one yet, which is the * "wait until it is findable" case rather than an error. */ @@ -93,13 +135,20 @@ export function matchAccountType( } /** THE account→config lookup. Reads the assignment table; never writes. - * Replaces the longest-prefix helper that used to be copied into five files. */ + * Replaces the longest-prefix helper that used to be copied into five files. + * Every trading read of an account's rules comes through here, so this is + * where an account dated inside a kept rule set gets that set's rules — + * manual pins and auto assignments alike. One row read, the rest from the + * in-memory catalog: it runs per account per tick. */ export function getAccountConfig(accountId: number): ResolvedAssignment { const row = getAssignment(accountId); - if (!row?.account_type_id) return UNASSIGNED; + if (!row) return UNASSIGNED; + // `?? null`: a row read before the rules-date migration ran has no column. + const rulesDate = row.rules_date ?? null; + if (!row.account_type_id) return { ...UNASSIGNED, rulesDate }; - const accountType = getAccountTypeById(row.account_type_id); - if (!accountType) { + const live = getAccountTypeById(row.account_type_id); + if (!live) { // The account type was deleted on the master. A manual pin is kept so // that a master flap which recreates it restores the user's intent, but // it reports as unassigned until then, so the account stops trading. @@ -108,14 +157,20 @@ export function getAccountConfig(accountId: number): ResolvedAssignment { accountType: null, source: 'unassigned', ambiguous: false, + rulesDate, + keptRules: null, }; } + const kept = pickKeptRules(getKeptRulesFor(live.id), rulesDate); + const accountType: EffectiveAccountType = kept ? overlaid(live, kept) : live; return { - accountTypeId: accountType.id, + accountTypeId: live.id, accountType, source: row.manual === 1 ? 'manual' : 'auto', ambiguous: false, + rulesDate, + keptRules: accountType.keptRules ?? null, }; } @@ -130,28 +185,106 @@ export function getInferredSize(accountId: number, client: PlatformClient): numb return inferAccountSize(client.fundTransactions?.[accountId] ?? []); } -/** Re-resolves every account on one firm's client. Skips manual overrides. - * Called after each Tradovate sync and after the catalog changes. */ +/** The rules date a row first written now gets — on the master's clock when + * this one runs behind, and never before the catalog's last change. */ +function freshRulesDate(): string { + return stampRulesDate(Date.now(), getMasterOffsetMs(), getCatalogUpdatedAt()); +} + +/** The broker's evidence of when the account opened (the report's + * firstFundDate, read as lib/kept-rules.ts openingEvidence says); undefined + * until its fund history has loaded, null when that history doesn't say. */ +function brokerEvidence(client: PlatformClient, accountId: number): string | null | undefined { + const funds = client.fundTransactions?.[accountId]; + if (!funds) return undefined; + return openingEvidence(firstFundDate(funds, undefined), client.brokerAccountMeta?.(accountId)?.registrationTime); +} + +/** + * The evidence step for one account, for the trade path: once its fund + * history has loaded, lower an auto rules date to the broker's evidence + * before anything reads the account's rules. The resolver does the same on + * every pass, but only after a sync — and history lands between syncs, when + * an account turns tradable (historyReady needs the fund rows loaded, on + * every platform) and could trade on its first-seen date for up to a sync. + * Once per account per process; a no-op until the history has loaded and the + * account has an assignment row. + */ +export function ensureRulesEvidence(client: PlatformClient, accountId: number): void { + if (evidenceApplied().has(accountId)) return; + const evidence = brokerEvidence(client, accountId); + if (evidence === undefined) return; + const row = getAssignment(accountId); + if (!row) return; + if (evidence && lowersRulesDate(row, evidence)) lowerAutoRulesDate(accountId, evidence); + evidenceApplied().add(accountId); +} + +/** Re-resolves every account on one firm's client. Skips manual overrides, + * and auto ones on a kept rule set (see below). Lowers rules dates to the + * broker's evidence on the way. Called after each sync and after the + * catalog changes. */ export function resolveFirmAssignments( firmId: string, client: PlatformClient, ): { assigned: number; unassigned: number; manual: number } { const firm = getCachedFirm(firmId); const types = firm?.accountTypes ?? []; + const stamp = freshRulesDate(); let assigned = 0; let unassigned = 0; let manual = 0; for (const account of client.accountList) { - if (getAssignment(account.id)?.manual === 1) { + let row = getAssignment(account.id); + // A row an older build wrote without a rules date (between a build's + // migration and its restart, or after a rollback): dated now, as the + // migration dates rows, before the evidence below can lower it. + if (row && !row.rules_date) { + stampMissingRulesDate(account.id, stamp); + row = { ...row, rules_date: stamp }; + } + + // Evidence next, for every account, pinned or not: once its fund + // history has loaded, the broker shows when it opened (the report's + // firstFundDate), and an auto rules date later than that comes down + // to it. Ahead of the kept-set check below, which reads the date. + const loadedFunds = client.fundTransactions?.[account.id]; + const evidence = brokerEvidence(client, account.id) ?? null; + if (row && evidence && lowersRulesDate(row, evidence)) { + lowerAutoRulesDate(account.id, evidence); + row = { ...row, rules_date: evidence }; + } + if (row && loadedFunds) evidenceApplied().add(account.id); + + if (row?.manual === 1) { manual++; continue; } - const fundTxns = client.fundTransactions?.[account.id] ?? []; + const fundTxns = loadedFunds ?? []; const cached = loadAccountMeta(account.id, INFERRED_SIZE_KEY); + // Sticky: an account on one of its type's kept rule sets stays bound + // to that type, so a tie-break flip to another matching type can never + // move it off its old rules. Only while it still matches the type's + // prefix and size, though: a kept set takes both from the live type, + // so after a size edit it would trade on a size it doesn't have. + // Unstuck, it re-matches as it did before kept rules existed — + // usually to Unassigned, which stops it trading. + if (row?.account_type_id) { + const live = getAccountTypeById(row.account_type_id); + if ( + live && live.firmId === firmId + && identityStillMatches(account.name, cached, live) + && pickKeptRules(getKeptRulesFor(live.id), row.rules_date ?? null) + ) { + assigned++; + continue; + } + } + let matched: { type: CatalogAccountType | null; ambiguous: boolean } = { type: null, ambiguous: false }; let matchedSize: number | null = null; @@ -194,24 +327,37 @@ export function resolveFirmAssignments( if (matchedSize !== null && cached === null) { saveAccountMeta(account.id, INFERRED_SIZE_KEY, String(matchedSize)); } - setAutoAssignment(account.id, firmId, matched.type.id); + setAutoAssignment(account.id, firmId, matched.type.id, stamp); assigned++; } else { - setAutoAssignment(account.id, firmId, null); + setAutoAssignment(account.id, firmId, null, stamp); unassigned++; } + // A row this pass created is dated "first seen now"; the evidence may + // already date it earlier. + if (!row && evidence) lowerAutoRulesDate(account.id, evidence); + if (!row && loadedFunds) evidenceApplied().add(account.id); } return { assigned, unassigned, manual }; } -export function setManualAssignment(accountId: number, firmId: string, accountTypeId: string | null): void { - setAssignment(accountId, firmId, accountTypeId, true); +/** insertRulesDate dates the row when the pin creates it (default: now, as + * stampRulesDate gives it); re-pinning keeps the account's rules date. */ +export function setManualAssignment( + accountId: number, + firmId: string, + accountTypeId: string | null, + insertRulesDate: string = freshRulesDate(), +): void { + setAssignment(accountId, firmId, accountTypeId, true, insertRulesDate); } -/** Drops the override so the auto-resolver takes the account back over. */ +/** Drops the override so the auto-resolver takes the account back over. The + * row and its rules date stay: unpinning must not make an old account look + * new. */ export function clearManualAssignment(accountId: number): boolean { - return clearAssignment(accountId); + return unpinAssignment(accountId); } export { listAssignments }; diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index 6b70b29..505d121 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -9,7 +9,7 @@ import { getClients } from './clients'; import { getCachedFirm, getTradableFirms } from './firm-catalog'; -import { getAccountConfig } from './account-type-resolver'; +import { ensureRulesEvidence, getAccountConfig } from './account-type-resolver'; import { combineCmeSessions, computeDayPlan, groupLegFraction, pickLegAmount, resolveEffectiveConfig, currentTradeDateCT, obfuscationStopDollars, takeProfitPlan, onTick, percentFee, dllRoom, dllStopDollars, dllViable, stopTicks, @@ -18,7 +18,7 @@ import { isNoTradeTime, isPastSessionClose, BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY, FALLBACK_COMMISSION_PER_CONTRACT, MAX_COMMISSION_PER_CONTRACT, DLL_MIN_STOP, - CME_EVENING_OPEN_MIN, foldReason, + CME_EVENING_OPEN_MIN, foldReason, withinFirmCaps, fundRowsKey, markFinishedCycle, unbookedPayoutDrop, type DayPlan, type FinishedCycleMark, } from './trading-logic'; @@ -666,6 +666,11 @@ function finishedCycleDrop( */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function evaluateAccountForTrade(client: any, acc: { id: number; name: string; active: boolean }, tradeDate: string): AccountEvaluation | null { + // Which rule set the account trades on hangs on its rules date: bring it + // down to the broker's evidence before reading the rules, so an account + // whose history landed since the last sync never trades a leg on the + // date this machine first saw it. + ensureRulesEvidence(client, acc.id); const cfg = getAccountConfig(acc.id).accountType; if (!cfg) return null; @@ -1037,7 +1042,9 @@ async function runTradeLocked( // (largest group first), never mixed across prefixes — a group's remainder // fires as a smaller batch on a later tick instead of being padded from // another prefix. E.g. 4×FTDFY, 5×MFFUEV, 7×MFFUSF at limit 5 fires as - // batches of 5, 5, 4, 2. A group that can't trade anything right now + // batches of 5, 5, 4, 2. A firm's own cap on accounts at once (the + // master's Firms page) trims its share first: 6 Topstep accounts capped + // at 3 fire as 3, 3. A group that can't trade anything right now // (nothing listed fits, no contract resolves) yields to the next. const groups = new Map(); for (const item of allEligible) { @@ -1051,7 +1058,8 @@ async function runTradeLocked( let batchPrefix = ''; let resolvedSymbol = ''; for (const [prefix, members] of ordered) { - const candidates = members.slice(0, maxConcurrent); + // The lane is flat (see above), so no firm holds anything yet. + const candidates = withinFirmCaps(members).slice(0, maxConcurrent); const lead = candidates.find((it) => !it.isExtraDay && !it.drain); const firms = [...new Map(candidates.map((it) => [it.firm.id, it])).values()]; @@ -1566,7 +1574,8 @@ async function fireLeg( // ── copy trade ─────────────────────────────────────────────────────────────── /** - * Copy the current trade direction to up to maxConcurrent accounts. + * Copy the current trade direction to up to maxConcurrent accounts (and no + * more of a firm's than its cap on accounts at once). * Finds accounts with open positions, determines direction, then fires * orders for eligible accounts that haven't traded yet — within the lane of * the first open position found, never across lanes. @@ -1617,12 +1626,15 @@ async function copyTradeLocked(lease: number, lane: string): Promise // eslint-disable-next-line @typescript-eslint/no-explicit-any let positionedClient: any = null; const positionedPrefixes = new Set(); + // Open positions by firm, against each firm's cap on accounts at once. + const heldByFirm = new Map(); - for (const { client } of ready) { + for (const { firm, client } of ready) { for (const acc of client.accountList) { const pos = client.positions[acc.id]; if (!pos) continue; positionedCount++; + heldByFirm.set(firm.id, (heldByFirm.get(firm.id) ?? 0) + 1); const prefix = getAccountConfig(acc.id).accountType?.prefix; if (prefix) positionedPrefixes.add(prefix); if (!resolvedAction) { @@ -1718,7 +1730,7 @@ async function copyTradeLocked(lease: number, lane: string): Promise // accounts before falling back to mismatched prefixes. const matchesPositioned = (item: LegItem) => positionedPrefixes.has(item.prefix); const ordered = [...eligible.filter(matchesPositioned), ...eligible.filter((i) => !matchesPositioned(i))]; - const batch = ordered.slice(0, slotsAvailable); + const batch = withinFirmCaps(ordered, heldByFirm).slice(0, slotsAvailable); if (batch.length === 0) { console.log('[copy-trade] no eligible accounts to copy to'); return []; diff --git a/lib/backup-restore.ts b/lib/backup-restore.ts index 3e5abd9..ab7132f 100644 --- a/lib/backup-restore.ts +++ b/lib/backup-restore.ts @@ -19,7 +19,7 @@ import { getSetting, setCredentials, setInstrumentEnabled, - setAssignment, + restoreAssignment, runInTransaction, } from './db'; import type { BackupPayload } from './backup-sync'; @@ -116,8 +116,10 @@ export function applyBackup(payload: BackupPayload, opts: { keepMasterUrl?: bool setInstrumentEnabled(i.symbol, i.enabled); counts.instruments++; } + // Rules dates travel with them: a restored machine must not date + // its accounts afresh, or old ones would leave their kept rules. for (const a of payload.assignments ?? []) { - setAssignment(a.account_id, a.firm_id, a.account_type_id ?? null, a.manual === 1); + restoreAssignment(a); counts.assignments++; } }); diff --git a/lib/backup-sync.ts b/lib/backup-sync.ts index 8769d0b..a105080 100644 --- a/lib/backup-sync.ts +++ b/lib/backup-sync.ts @@ -26,7 +26,7 @@ import { getInstruments, listAllAssignments, type FirmCredentialRow, - type AssignmentRow, + type BackupAssignmentRow, } from './db'; export interface BackupPayload { @@ -35,7 +35,9 @@ export interface BackupPayload { settings: Record; credentials: FirmCredentialRow[]; instruments: { symbol: string; enabled: boolean }[]; - assignments: AssignmentRow[]; + /** Whole rows (SELECT *), rules dates included — absent from a backup + * an older build pushed. */ + assignments: BackupAssignmentRow[]; } const BACKUP_PATH = '/api/instances/backup'; diff --git a/lib/db.ts b/lib/db.ts index cb7464c..60dc78a 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -1,6 +1,7 @@ import Database from 'better-sqlite3'; import { mkdirSync } from 'fs'; import path from 'path'; +import { sortableIso, stampRulesDate } from './kept-rules'; // AUTOFIRMER_DB lets several instances share one checkout, each with its own // database. It is read once, at module load — schema creation and seeding are @@ -78,6 +79,62 @@ try { // Column already exists } +// Migration: each account's rules date — which of its account type's rule +// sets it trades on (lib/kept-rules.ts): an account dated before a firm's +// rule change keeps the rules from before it. Written when this machine first +// records the account (or from a restored backup), lowered to the broker's +// evidence of when the account opened, or set by hand (rules_date_manual, +// which evidence never moves). Every writer stores a toISOString() UTC +// string, so string order is time order. +// +// The run that adds the column dates every existing row at the migration +// time, never NULL: no account changes rules merely because its machine +// updated. The resolver then lowers those auto dates to the broker's +// evidence, so an account the evidence shows opened before a rule change +// goes back to the old rules. Column and backfill commit together, so +// `next build` workers racing the migration can't leave rows undated. + +/** The date an undated row gets: now, but never before the catalog's last + * change — lib/kept-rules.ts stampRulesDate's floor (every build keeps + * settings.catalog_updated_at current), so a machine whose clock runs + * behind can't backdate an account first seen after a rule change into the + * rule set that change closed. Call inside the writing transaction. */ +function undatedRowsDate(): string { + let catalogUpdatedAt: string | null = null; + try { + const row = db.prepare("SELECT value FROM settings WHERE key = 'catalog_updated_at'").get() as { value?: unknown } | undefined; + if (typeof row?.value === 'string') catalogUpdatedAt = row.value; + } catch { + // No settings table yet: a fresh database, with no rows to date. + } + return stampRulesDate(Date.now(), null, catalogUpdatedAt); +} + +try { + db.transaction(() => { + db.exec('ALTER TABLE account_assignments ADD COLUMN rules_date TEXT'); + db.prepare('UPDATE account_assignments SET rules_date = ? WHERE rules_date IS NULL').run(undatedRowsDate()); + }).immediate(); +} catch { + // Column already exists +} +try { + db.exec('ALTER TABLE account_assignments ADD COLUMN rules_date_manual INTEGER NOT NULL DEFAULT 0'); +} catch { + // Column already exists +} +// Rows an older build wrote after that run have no date: it keeps serving +// between a build's migration and the restart, and comes back in a rollback, +// and its INSERT has no rules_date. Dated the same way at every start, so no +// row stays undated (current rules forever, with no date to set by hand). +try { + db.transaction(() => { + db.prepare('UPDATE account_assignments SET rules_date = ? WHERE rules_date IS NULL').run(undatedRowsDate()); + }).immediate(); +} catch { + // No rules_date column — the migration above failed; nothing to date. +} + // ── Firm credentials ───────────────────────────────────────────────────────── export interface FirmCredentialRow { @@ -133,8 +190,18 @@ export interface AssignmentRow { account_type_id: string | null; manual: number; // 0 | 1 resolved_at: string; + /** ISO (toISOString). Which of the type's rule sets the account trades + * on; null only on a row an older build wrote after the migration — + * the current rules until evidence dates it. */ + rules_date: string | null; + rules_date_manual: number; // 0 | 1 — set by hand; evidence leaves it alone } +/** An assignment as a backup carries it: one from a build before rules + * dates has neither column. */ +export type BackupAssignmentRow = Omit + & Partial>; + export function getAssignment(accountId: number): AssignmentRow | undefined { return db.prepare('SELECT * FROM account_assignments WHERE account_id = ?').get(accountId) as AssignmentRow | undefined; } @@ -148,33 +215,116 @@ export function listAllAssignments(): AssignmentRow[] { return db.prepare('SELECT * FROM account_assignments').all() as AssignmentRow[]; } +/** insertRulesDate dates a row this call creates (default now — callers pass + * lib/kept-rules.ts stampRulesDate). An existing row keeps its rules date + * and how it was set: re-resolving or re-pinning an account never makes it + * look new. Only a row with no date at all (an older build's) takes + * insertRulesDate. */ export function setAssignment( accountId: number, firmId: string, accountTypeId: string | null, manual: boolean, + insertRulesDate?: string, ): void { + const now = new Date().toISOString(); db.prepare(` - INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at) - VALUES (?, ?, ?, ?, ?) + INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at, rules_date) + VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(account_id) DO UPDATE SET firm_id = excluded.firm_id, account_type_id = excluded.account_type_id, manual = excluded.manual, - resolved_at = excluded.resolved_at - `).run(accountId, firmId, accountTypeId, manual ? 1 : 0, new Date().toISOString()); + resolved_at = excluded.resolved_at, + rules_date = COALESCE(account_assignments.rules_date, excluded.rules_date) + `).run(accountId, firmId, accountTypeId, manual ? 1 : 0, now, insertRulesDate ?? now); } /** Writes an auto-resolved assignment, leaving any manual override alone. * The `manual = 0` guard is what makes a user's pick stick across syncs. */ -export function setAutoAssignment(accountId: number, firmId: string, accountTypeId: string | null): void { +export function setAutoAssignment( + accountId: number, + firmId: string, + accountTypeId: string | null, + insertRulesDate?: string, +): void { const existing = getAssignment(accountId); if (existing?.manual === 1) return; - setAssignment(accountId, firmId, accountTypeId, false); + setAssignment(accountId, firmId, accountTypeId, false, insertRulesDate); } -export function clearAssignment(accountId: number): boolean { - return db.prepare('DELETE FROM account_assignments WHERE account_id = ?').run(accountId).changes > 0; +/** Drops a manual pin but keeps the row — and with it the rules date, so + * unpinning can't make an old account look new. The auto-resolver takes the + * account back on its next pass. False when there is no row. */ +export function unpinAssignment(accountId: number): boolean { + return db.prepare('UPDATE account_assignments SET manual = 0, account_type_id = NULL, resolved_at = ? WHERE account_id = ?') + .run(new Date().toISOString(), accountId).changes > 0; +} + +/** Sets an account's rules date (an ISO toISOString() string); manual marks + * it set by hand, which broker evidence never moves. False when the account + * has no row. */ +export function setRulesDate(accountId: number, iso: string, manual: boolean): boolean { + return db.prepare('UPDATE account_assignments SET rules_date = ?, rules_date_manual = ? WHERE account_id = ?') + .run(iso, manual ? 1 : 0, accountId).changes > 0; +} + +/** Dates a row that has none (one an older build wrote) at `iso`; a dated + * row is left alone. */ +export function stampMissingRulesDate(accountId: number, iso: string): void { + db.prepare('UPDATE account_assignments SET rules_date = ? WHERE account_id = ? AND rules_date IS NULL').run(iso, accountId); +} + +/** Moves an auto rules date earlier to `iso` (the broker's evidence of when + * the account opened, lib/kept-rules.ts openingEvidence); never later and + * never a date set by hand. Compared as strings, which is time order only + * because every rules date is a plain four-digit-year UTC toISOString() + * string (lib/kept-rules.ts sortableIso) — keep it that way. */ +export function lowerAutoRulesDate(accountId: number, iso: string): void { + db.prepare(` + UPDATE account_assignments SET rules_date = ? + WHERE account_id = ? AND rules_date_manual = 0 AND (rules_date IS NULL OR rules_date > ?) + `).run(iso, accountId, iso); +} + +/** + * One backed-up assignment onto this machine, every column (both restore + * paths: lib/backup-restore.ts, and scripts/seed-settings.js by hand). The + * backup's rules date and its manual flag win when the backup has one; a + * backup from an older build has none, so an existing row keeps its own and + * a new row is dated now, at the restore. + */ +export function restoreAssignment(row: BackupAssignmentRow): void { + const now = new Date().toISOString(); + const resolvedAt = typeof row.resolved_at === 'string' ? row.resolved_at : now; + // Only a date that sorts as a string counts as the backup's (anything + // else is restored like an older build's undated row). + const rulesDate = typeof row.rules_date === 'string' ? sortableIso(Date.parse(row.rules_date)) : null; + if (rulesDate) { + db.prepare(` + INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at, rules_date, rules_date_manual) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(account_id) DO UPDATE SET + firm_id = excluded.firm_id, + account_type_id = excluded.account_type_id, + manual = excluded.manual, + resolved_at = excluded.resolved_at, + rules_date = excluded.rules_date, + rules_date_manual = excluded.rules_date_manual + `).run(row.account_id, row.firm_id, row.account_type_id ?? null, row.manual === 1 ? 1 : 0, resolvedAt, + rulesDate, row.rules_date_manual === 1 ? 1 : 0); + return; + } + db.prepare(` + INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at, rules_date) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(account_id) DO UPDATE SET + firm_id = excluded.firm_id, + account_type_id = excluded.account_type_id, + manual = excluded.manual, + resolved_at = excluded.resolved_at, + rules_date = COALESCE(account_assignments.rules_date, excluded.rules_date) + `).run(row.account_id, row.firm_id, row.account_type_id ?? null, row.manual === 1 ? 1 : 0, resolvedAt, now); } // ── Settings ───────────────────────────────────────────────────────────────── diff --git a/lib/firm-catalog.ts b/lib/firm-catalog.ts index 1a7f695..ac142b3 100644 --- a/lib/firm-catalog.ts +++ b/lib/firm-catalog.ts @@ -19,8 +19,10 @@ import { isFirmPlatform } from './master-protocol'; import { sanitizeInstruments, sanitizeInstrumentLimits, DEFAULT_MAX_ENTRY_SPREAD } from './instrument-specs'; +import { normalizeAccountType } from './kept-rules'; import type { - CatalogAccountType, CatalogFirm, CatalogTradingSettings, FirmCatalogPayload, FirmPlatform, SymbolMap, SymbolMapCandidate, + CatalogAccountType, CatalogFirm, CatalogTradingSettings, FirmCatalogPayload, FirmPlatform, KeptAccountRules, SymbolMap, + SymbolMapCandidate, } from './master-protocol'; import { getSetting, @@ -39,24 +41,50 @@ const FETCH_TIMEOUT_MS = 10_000; const COLD_RETRY_MIN_MS = 10_000; const COLD_RETRY_MAX_MS = 60_000; +/** The version of validatePayload below, stamped on the payload it caches. + * Bump it whenever the parser starts keeping a field an older one dropped: + * a cache from before then lacks the field however often it is re-read, and + * `?since=` would answer "unchanged" until the master next changes, so the + * 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; + +/** 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; + interface CatalogCache { payload: FirmCatalogPayload | null; /** Index over every firm's account types, for the id lookup. */ typesById: Map; + /** Each live account type's kept rule sets, by closedAt (oldest window + * first). Kept ids are deliberately not in typesById: nothing can be + * pinned to one. */ + keptByLiveId: Map; } const g = global as typeof globalThis & { __firmCatalog?: CatalogCache; __firmCatalogTimer?: ReturnType; __firmCatalogColdDelay?: number; + /** The master's clock minus this one, from the last sync's Date header. */ + __masterOffsetMs?: number; }; function indexPayload(payload: FirmCatalogPayload | null): CatalogCache { const typesById = new Map(); + const keptByLiveId = new Map(); for (const firm of payload?.firms ?? []) { for (const type of firm.accountTypes) typesById.set(type.id, type); + for (const kept of firm.keptAccountRules ?? []) { + const list = keptByLiveId.get(kept.keptFrom); + if (list) list.push(kept); + else keptByLiveId.set(kept.keptFrom, [kept]); + } } - return { payload, typesById }; + for (const list of keptByLiveId.values()) list.sort((a, b) => Date.parse(a.closedAt) - Date.parse(b.closedAt)); + return { payload, typesById, keptByLiveId }; } function cache(): CatalogCache { @@ -66,7 +94,14 @@ function cache(): CatalogCache { const raw = getSetting('catalog_payload'); if (raw) { try { - payload = validatePayload(JSON.parse(raw)); + const stored = JSON.parse(raw); + payload = validatePayload(stored); + // Whatever parsed the blob when it was stored, not this parser: a + // field an older build dropped on the way in can't come back from + // re-reading the blob. + const parser = (stored as { parser?: unknown }).parser; + if (typeof parser === 'number') payload.parser = parser; + else delete payload.parser; } catch { console.error('[catalog] cached payload is unreadable — ignoring it'); } @@ -87,6 +122,37 @@ export function getAccountTypeById(typeId: string): CatalogAccountType | undefin return cache().typesById.get(typeId); } +/** The kept rule sets of one live account type, oldest window first ([] when + * it keeps none). lib/account-type-resolver.ts picks an account's by its + * rules date. */ +export function getKeptRulesFor(liveTypeId: string): KeptAccountRules[] { + return cache().keptByLiveId.get(liveTypeId) ?? []; +} + +/** The kept rule set ids loaded for a firm, for the report — null while the + * cached catalog came from a parser older than CATALOG_PARSER_VERSION, which + * can't be trusted to hold them (the master then reads this machine as not + * applying kept rules yet). */ +export function getKeptRuleIdsForFirm(firmId: string): string[] | null { + const payload = cache().payload; + if (!payload || (payload.parser ?? 0) < CATALOG_PARSER_VERSION) return null; + return (payload.firms.find((f) => f.id === firmId)?.keptAccountRules ?? []).map((k) => k.id); +} + +/** The master's clock minus this machine's, as of the last good sync (null + * before one this process). Dates new accounts on the master's clock when + * this one runs behind (lib/kept-rules.ts stampRulesDate). */ +export function getMasterOffsetMs(): number | null { + return g.__masterOffsetMs ?? null; +} + +/** When the cached catalog last changed on the master (its payload's + * updatedAt); null with no cache. No account first seen after a change can + * be dated before it. */ +export function getCatalogUpdatedAt(): string | null { + return cache().payload?.updatedAt ?? null; +} + export function getCatalogMeta(): { version: number; updatedAt: string | null; @@ -131,7 +197,10 @@ export function getTradableFirms(): (CatalogFirm & { username: string; password: // ── Sync ───────────────────────────────────────────────────────────────────── -const g2 = global as typeof globalThis & { __catalogUnknownPlatformWarned?: Set }; +const g2 = global as typeof globalThis & { + __catalogUnknownPlatformWarned?: Set; + __catalogKeptSkipWarned?: Set; +}; /** Once per firm per process — the catalog is re-validated every 60s. */ function warnUnknownPlatform(id: string, name: string, platform: unknown): void { @@ -164,12 +233,41 @@ function sanitizeSymbolMap(raw: unknown): SymbolMap { return map; } -function isStages(value: unknown): value is { profit: number; consistency: number; minTradingDays: number }[] { - return Array.isArray(value) && value.every((s) => - typeof s === 'object' && s !== null - && typeof (s as Record).profit === 'number' - && typeof (s as Record).consistency === 'number' - && typeof (s as Record).minTradingDays === 'number'); +function isDateString(value: unknown): value is string { + return typeof value === 'string' && Number.isFinite(Date.parse(value)); +} + +/** + * A firm's kept rule sets, each as strict as an account type: a malformed + * entry throws and the whole sync is rejected. One that names no live type + * of this firm is skipped instead (warned once) — no account can map to it. + */ +function parseKeptRules( + firm: { id: string; name: string }, + raw: unknown[], + accountTypes: CatalogAccountType[], + tradesEveryDay: boolean, +): KeptAccountRules[] { + const liveIds = new Set(accountTypes.map((t) => t.id)); + const kept: KeptAccountRules[] = []; + raw.forEach((k: unknown, j: number) => { + const where = `kept rules ${j} of ${firm.name}`; + const type = normalizeAccountType(k, { tradesEveryDay, where }); + const entry = k as Record; + if (typeof entry.keptFrom !== 'string') throw new Error(`${where} is missing keptFrom`); + if (entry.openedAt !== null && !isDateString(entry.openedAt)) throw new Error(`${where} has bad openedAt`); + if (!isDateString(entry.closedAt)) throw new Error(`${where} has bad closedAt`); + if (!liveIds.has(entry.keptFrom) || type.firmId !== firm.id) { + const warned = (g2.__catalogKeptSkipWarned ??= new Set()); + if (!warned.has(type.id)) { + warned.add(type.id); + console.warn(`[catalog] skipping kept rules ${type.id} of "${firm.name}": it names no account type of this firm (keptFrom ${entry.keptFrom}, firmId ${type.firmId})`); + } + return; + } + kept.push({ ...type, keptFrom: entry.keptFrom, openedAt: entry.openedAt, closedAt: entry.closedAt }); + }); + return kept; } /** Rejects anything that isn't a complete catalog. A half-parsed payload would @@ -204,39 +302,13 @@ function validatePayload(raw: unknown): FirmCatalogPayload { const tradesEveryDay = dx && firm.tradesEveryDay === true; if (!Array.isArray(firm.accountTypes)) throw new Error(`firm ${firm.name} is missing accountTypes`); - const accountTypes: CatalogAccountType[] = firm.accountTypes.map((t: unknown, j: number) => { - if (typeof t !== 'object' || t === null) throw new Error(`account type ${j} is not an object`); - const type = t as Record; - for (const key of ['id', 'firmId', 'label', 'prefix', 'updatedAt']) { - if (typeof type[key] !== 'string') throw new Error(`account type ${j} is missing ${key}`); - } - for (const key of [ - 'accountSize', 'profitTarget', 'consistency', 'minDayPnL', - 'minTradingDays', 'maxLoss', 'maxPositionSize', - ]) { - if (typeof type[key] !== 'number') throw new Error(`account type ${j} is missing ${key}`); - } - if (!isStages(type.withdrawalStages)) throw new Error(`account type ${j} has bad withdrawalStages`); - // Lenient like platform: a master from before the split toggle - // doesn't send the field, and those account types all split. Same - // for CME holiday combining, where the old behavior was "off". - return { - ...type, - splitEnabled: type.splitEnabled !== false, - // An every-day firm has no CME calendar to fold holidays by. - combineCmeHolidays: type.combineCmeHolidays === true && !tradesEveryDay, - // Lenient: a master from before the min-legs field means "no minimum". - minLegs: typeof type.minLegs === 'number' ? type.minLegs : 0, - // Lenient: the obfuscation stops are opt-in, so absent means off. - firstDayStop: type.firstDayStop === true, - minDayStop: type.minDayStop === true, - // Lenient: no hard daily limit, no notional cap. - dailyLossLimit: nonNegative(type.dailyLossLimit), - // The firm's calendar, stamped on every type so the cfg-based - // paths (projections, completion, the dashboard) all see it. - tradesEveryDay, - } as unknown as CatalogAccountType; - }); + const accountTypes: CatalogAccountType[] = firm.accountTypes.map((t: unknown, j: number) => + normalizeAccountType(t, { tradesEveryDay, where: `account type ${j}` })); + // Only when the master sent the list: absent = a master from before + // kept rules, [] = every account on the current rules. + const keptAccountRules = Array.isArray(firm.keptAccountRules) + ? parseKeptRules({ id: firm.id, name: firm.name }, firm.keptAccountRules, accountTypes, tradesEveryDay) + : undefined; firms.push({ id: firm.id, @@ -248,6 +320,7 @@ function validatePayload(raw: unknown): FirmCatalogPayload { ...(platform !== 'dxtrade' ? { instrumentLimits: sanitizeInstrumentLimits(firm.instrumentLimits) } : {}), bannedSymbols: Array.isArray(firm.bannedSymbols) ? (firm.bannedSymbols as string[]) : [], accountTypes, + ...(keptAccountRules ? { keptAccountRules } : {}), serverUrl: typeof firm.serverUrl === 'string' ? firm.serverUrl : '', loginDomain: typeof firm.loginDomain === 'string' ? firm.loginDomain : '', commissionPct: dx ? nonNegative(firm.commissionPct) : 0, @@ -255,12 +328,24 @@ function validatePayload(raw: unknown): FirmCatalogPayload { symbolMap: sanitizeSymbolMap(firm.symbolMap), targetHoldHours: nonNegative(firm.targetHoldHours), financingPctPerDay: nonNegative(firm.financingPctPerDay), + // Lenient: absent or malformed = no firm cap. + maxConcurrentAccounts: Math.floor(nonNegative(firm.maxConcurrentAccounts)), updatedAt: typeof firm.updatedAt === 'string' ? firm.updatedAt : new Date(0).toISOString(), }); }); const trading = parseTradingSettings(body.trading); - return { version: body.version, updatedAt: body.updatedAt, firms, ...(trading ? { trading } : {}) }; + // A kept-aware master sends keptAccountRules on every firm (even []). A + // payload without it is a master from before kept rules: cached without + // a parser version, so the cache isn't trusted to hold kept sets — every + // sync fetches in full (no `since`), and the first one after a kept-aware + // master is back brings them, though its catalog version never moved. + const keptAware = body.firms.every((f: unknown) => + typeof f === 'object' && f !== null && Array.isArray((f as Record).keptAccountRules)); + return { + version: body.version, updatedAt: body.updatedAt, firms, ...(trading ? { trading } : {}), + ...(keptAware ? { parser: CATALOG_PARSER_VERSION } : {}), + }; } /** The master's trading settings; undefined when it sent none (or nothing @@ -293,6 +378,17 @@ function recordError(message: string): void { setSetting('catalog_last_sync_at', new Date().toISOString()); } +/** The master's clock off a response's Date header (whole seconds, read on + * arrival — close enough to date accounts by). An unreadable header, or one + * a year off, leaves the last offset standing. */ +function recordMasterClock(header: string | null): void { + const at = header ? Date.parse(header) : NaN; + if (!Number.isFinite(at)) return; + const offset = at - Date.now(); + if (Math.abs(offset) > MAX_MASTER_OFFSET_MS) return; + g.__masterOffsetMs = offset; +} + /** If the master's database was rebuilt, its firms come back with fresh ids and * every credential row orphans. Match the survivors up by name (once) rather * than making someone retype every password. */ @@ -323,14 +419,17 @@ export async function syncCatalog(): Promise<{ ok: boolean; changed: boolean; ve // before anyone has got round to naming it. // `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. + // 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. const hasCache = getCachedCatalog().length > 0; const source = url.origin; - if (hasCache && getSetting('catalog_source_url') === source) url.searchParams.set('since', String(version)); + const cacheCurrent = (cache().payload?.parser ?? 0) >= CATALOG_PARSER_VERSION; + if (hasCache && cacheCurrent && getSetting('catalog_source_url') === source) url.searchParams.set('since', String(version)); try { const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); if (!res.ok) throw new Error(`master returned ${res.status}`); + recordMasterClock(res.headers.get('date')); const body = await res.json(); if (body && typeof body === 'object' && (body as Record).unchanged === true) { @@ -353,13 +452,19 @@ export async function syncCatalog(): Promise<{ ok: boolean; changed: boolean; ve } // Same reasoning one level down: a firm losing every account type - // unassigns all of its accounts. Keep the previous types and flag it. + // unassigns all of its accounts. Keep the previous types and flag it, + // with the kept rule sets that went with them (whose keptFrom named + // types this payload lacks, so none of its own survived parsing). An + // empty kept list on its own is never suspicious: it is every account + // on the current rules. const previous = new Map(getCachedCatalog().map((f) => [f.id, f])); const suspicious: string[] = []; for (const firm of payload.firms) { const before = previous.get(firm.id); if (firm.accountTypes.length === 0 && before && before.accountTypes.length > 0) { firm.accountTypes = before.accountTypes; + if (before.keptAccountRules) firm.keptAccountRules = before.keptAccountRules; + else delete firm.keptAccountRules; suspicious.push(firm.name); } } diff --git a/lib/kept-rules.ts b/lib/kept-rules.ts new file mode 100644 index 0000000..e704e5a --- /dev/null +++ b/lib/kept-rules.ts @@ -0,0 +1,403 @@ +/** + * Kept rules — the old rule sets a firm's account type keeps for its older + * accounts. + * + * When the owner changes an account type's rules on the master's Firms page + * and picks "New accounts only", the master freezes the old rules as a kept + * rule set with a window [openedAt, closedAt). Every account here carries a + * rules date (account_assignments.rules_date): an account whose date falls + * inside a window trades its type's live fields with that set's RULE fields + * on top; every other account trades the current rules. The account keeps + * reporting the live type's id — a kept set is never an account type. + * + * Pure (only `import type`), so scripts/check-kept-rules.mjs loads it + * directly. The field lists must stay identical to the master's + * lib/kept-rules.ts. + */ + +import type { CatalogAccountType, KeptAccountRules } from './master-protocol'; + +/** The fields a kept rule set freezes: what the bot trades by. Changing one + * is what asks the owner "new accounts only, or all accounts?". */ +export const RULE_FIELDS = [ + 'profitTarget', 'consistency', 'minDayPnL', 'minTradingDays', 'maxLoss', + 'maxPositionSize', 'targetSameEquity', 'splitEnabled', 'minLegs', 'firstDayStop', 'minDayStop', + 'combineCmeHolidays', 'withdrawalStages', 'payoutable', 'trailingDrawdown', 'dailyLossLimit', +] as const satisfies readonly (keyof CatalogAccountType)[]; + +/** Always the live type's: accounts are matched to a type by them, so a kept + * set can't hold different ones (the master refuses "new accounts only" + * when either changes). */ +export const IDENTITY_FIELDS = ['prefix', 'accountSize'] as const satisfies readonly (keyof CatalogAccountType)[]; + +/** The payout calendar's projection inputs: always the live type's, never + * frozen. (Per-stage withdrawalPct / payoutCap ride inside withdrawalStages, + * so they are rules.) */ +export const PROJECTION_FIELDS = [ + 'expectedPayout', 'withdrawalPct', 'payoutCap', 'profitSplit', +] as const satisfies readonly (keyof CatalogAccountType)[]; + +export type RuleField = (typeof RULE_FIELDS)[number]; + +/** Which kept rule set an account trades on. */ +export interface KeptRulesWindow { + id: string; + openedAt: string | null; + closedAt: string; +} + +/** The rules an account trades on: its live account type, or — when its + * rules date falls in a kept set — the live type with that set's rules on + * top, marked with `keptRules`. */ +export type EffectiveAccountType = CatalogAccountType & { keptRules?: KeptRulesWindow }; + +// ── Catalog parsing ────────────────────────────────────────────────────────── + +/** Same as lib/firm-catalog.ts's: a positive finite number, else 0. */ +function nonNegative(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0; +} + +function isStages(value: unknown): value is { profit: number; consistency: number; minTradingDays: number }[] { + return Array.isArray(value) && value.every((s) => + typeof s === 'object' && s !== null + && typeof (s as Record).profit === 'number' + && typeof (s as Record).consistency === 'number' + && typeof (s as Record).minTradingDays === 'number'); +} + +/** + * One account type off the master's catalog, validated and normalized — the + * live types and the kept rule sets alike, so a kept set reads exactly the + * way its type did. Throws on anything malformed (`where` names it in the + * message): a half-parsed catalog would silently unassign accounts, so the + * whole sync is better rejected and the last good cache kept. + */ +export function normalizeAccountType( + raw: unknown, + ctx: { tradesEveryDay: boolean; where: string }, +): CatalogAccountType { + const { tradesEveryDay, where } = ctx; + if (typeof raw !== 'object' || raw === null) throw new Error(`${where} is not an object`); + const type = raw as Record; + for (const key of ['id', 'firmId', 'label', 'prefix', 'updatedAt']) { + if (typeof type[key] !== 'string') throw new Error(`${where} is missing ${key}`); + } + for (const key of [ + 'accountSize', 'profitTarget', 'consistency', 'minDayPnL', + 'minTradingDays', 'maxLoss', 'maxPositionSize', + ]) { + if (typeof type[key] !== 'number') throw new Error(`${where} is missing ${key}`); + } + if (!isStages(type.withdrawalStages)) throw new Error(`${where} has bad withdrawalStages`); + // Lenient like platform: a master from before the split toggle + // doesn't send the field, and those account types all split. Same + // for CME holiday combining, where the old behavior was "off". + return { + ...type, + splitEnabled: type.splitEnabled !== false, + // An every-day firm has no CME calendar to fold holidays by. + combineCmeHolidays: type.combineCmeHolidays === true && !tradesEveryDay, + // Lenient: a master from before the min-legs field means "no minimum". + minLegs: typeof type.minLegs === 'number' ? type.minLegs : 0, + // Lenient: the obfuscation stops are opt-in, so absent means off. + firstDayStop: type.firstDayStop === true, + minDayStop: type.minDayStop === true, + // Lenient: no hard daily limit, no notional cap. + dailyLossLimit: nonNegative(type.dailyLossLimit), + // The firm's calendar, stamped on every type so the cfg-based + // paths (projections, completion, the dashboard) all see it. + tradesEveryDay, + } as unknown as CatalogAccountType; +} + +// ── Which rules an account trades on ───────────────────────────────────────── + +/** + * The kept set whose window holds the rules date: openedAt (null = no lower + * bound) <= date < closedAt — an account dated exactly at a change's cutoff + * is on the newer rules. null when no window holds it, when the account has + * no rules date, or when the date can't be read: the current rules. + */ +export function pickKeptRules( + sets: T[] | undefined, + rulesDate: string | null, +): T | null { + if (!sets || sets.length === 0 || !rulesDate) return null; + const date = Date.parse(rulesDate); + if (!Number.isFinite(date)) return null; + for (const set of sets) { + // Comparisons written so an unreadable bound (NaN) never matches. + if (!(date < Date.parse(set.closedAt))) continue; + if (set.openedAt !== null && !(Date.parse(set.openedAt) <= date)) continue; + return set; + } + return null; +} + +/** + * The live type with a kept set's rules on top. Everything that isn't a rule + * stays the live type's — id, firmId, label, prefix, accountSize, updatedAt, + * tradesEveryDay and the projection fields — so the account still matches, + * groups and reports as its live type. A rule field the kept set doesn't + * carry at all keeps the live value. + */ +export function overlayKept(live: CatalogAccountType, kept: KeptAccountRules): EffectiveAccountType { + const effective: EffectiveAccountType = { + ...live, + keptRules: { id: kept.id, openedAt: kept.openedAt, closedAt: kept.closedAt }, + }; + const target = effective as unknown as Record; + for (const field of RULE_FIELDS) { + if (kept[field] !== undefined) target[field] = kept[field]; + } + return effective; +} + +/** A stage the way the master's parseAccountTypeInput builds it, so stored + * JSON from before drain / minDayPnL existed doesn't read as a change. */ +function normalizeStage(stage: unknown): unknown { + if (typeof stage !== 'object' || stage === null) return stage; + const s = stage as Record; + const drain = s.drain === true; + return { + profit: s.profit, + consistency: s.consistency, + minTradingDays: s.minTradingDays, + drain, + minDayPnL: typeof s.minDayPnL === 'number' ? s.minDayPnL : (drain ? 5 : 0), + ...(typeof s.withdrawalPct === 'number' ? { withdrawalPct: s.withdrawalPct } : {}), + ...(typeof s.payoutCap === 'number' ? { payoutCap: s.payoutCap } : {}), + }; +} + +/** A rule field's value as the bot reads it, for comparing: an absent + * optional flag or amount is its default. */ +function comparable(field: RuleField, value: unknown): unknown { + switch (field) { + case 'withdrawalStages': return Array.isArray(value) ? value.map(normalizeStage) : value; + case 'splitEnabled': return value !== false; + case 'targetSameEquity': + case 'firstDayStop': + case 'minDayStop': + case 'combineCmeHolidays': + case 'payoutable': return value === true; + case 'minLegs': + case 'trailingDrawdown': + case 'dailyLossLimit': return typeof value === 'number' ? value : 0; + default: return value; + } +} + +/** JSON with every object's keys sorted, so key order never reads as a change. */ +function canonical(value: unknown): string { + return JSON.stringify(value, (_key, v: unknown) => ( + typeof v === 'object' && v !== null && !Array.isArray(v) + ? Object.fromEntries(Object.entries(v as Record).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) + : v + )) ?? 'undefined'; +} + +/** The rule fields where an account's effective rules differ from its live + * type's, with both raw values (the dashboard formats them). Empty when the + * account is on the current rules, or its kept set happens to match them. */ +export function ruleDiffs( + effective: Pick, + live: Pick, +): { field: RuleField; kept: unknown; now: unknown }[] { + const diffs: { field: RuleField; kept: unknown; now: unknown }[] = []; + for (const field of RULE_FIELDS) { + if (canonical(comparable(field, effective[field])) === canonical(comparable(field, live[field]))) continue; + diffs.push({ field, kept: effective[field], now: live[field] }); + } + return diffs; +} + +// ── Rules dates ────────────────────────────────────────────────────────────── + +/** + * The rules date for an account this machine records for the first time: + * the latest of this clock, the master's clock (this clock plus the offset + * the catalog sync measured, when known) and the catalog's own last change. + * A machine whose clock runs behind must never date a brand-new account + * inside a rule set the master closed moments ago. + */ +export function stampRulesDate(nowMs: number, masterOffsetMs: number | null, catalogUpdatedAt: string | null): string { + let ms = nowMs; + if (masterOffsetMs !== null && Number.isFinite(masterOffsetMs)) ms = Math.max(ms, nowMs + masterOffsetMs); + const catalog = catalogUpdatedAt ? Date.parse(catalogUpdatedAt) : NaN; + if (Number.isFinite(catalog)) ms = Math.max(ms, catalog); + return new Date(ms).toISOString(); +} + +/** 2000-01-01T00:00:00.000Z — no rules date is earlier. */ +const EARLIEST_RULES_DATE_MS = Date.UTC(2000, 0, 1); + +/** The plain toISOString() shape — four-digit year, milliseconds, Z. String + * order is time order only between strings of exactly this shape. */ +const PLAIN_ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + +/** + * An instant as a storable rules date: its toISOString(), when that is the + * plain four-digit-year shape and not before 2000 — so every stored rules + * date sorts as a string (lib/db.ts lowerAutoRulesDate compares them so). + * null for anything else (NaN, a pre-2000 or a six-digit-year instant). + */ +export function sortableIso(ms: number): string | null { + if (!Number.isFinite(ms) || ms < EARLIEST_RULES_DATE_MS || Math.abs(ms) > 8.64e15) return null; + const iso = new Date(ms).toISOString(); + return PLAIN_ISO.test(iso) ? iso : null; +} + +// The fund rows' dates — CME trade dates (Tradovate, TopstepX) and DXtrade's +// firm days (ending 22:00Z) — are calendar days that end no later than +// midnight in Chicago, so that is the calendar evidence is read in. + +let chicagoClock: Intl.DateTimeFormat | undefined; + +/** Chicago's wall clock minus UTC at an instant, in ms: −5 h under CDT, + * −6 h under CST (Intl knows the DST rules; no dependency needed). */ +function chicagoOffsetMs(ms: number): number { + chicagoClock ??= new Intl.DateTimeFormat('en-US', { + timeZone: 'America/Chicago', hourCycle: 'h23', + year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric', + }); + const p: Record = {}; + for (const part of chicagoClock.formatToParts(new Date(ms))) { + if (part.type !== 'literal') p[part.type] = Number(part.value); + } + // Some engines spell midnight "24" even under h23. + const wall = Date.UTC(p.year, p.month - 1, p.day, p.hour === 24 ? 0 : p.hour, p.minute, p.second); + return wall - (ms - (((ms % 1000) + 1000) % 1000)); +} + +/** Whether `day` is YYYY-MM-DD naming a real calendar day (no Feb 31). */ +function isCalendarDay(day: unknown): day is string { + if (typeof day !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(day)) return false; + const ms = Date.parse(`${day}T00:00:00.000Z`); + return Number.isFinite(ms) && new Date(ms).toISOString().slice(0, 10) === day; +} + +/** The UTC instant of a wall-clock time on a Chicago calendar day. Exact for + * any time a DST change neither skips nor repeats (they happen at 2 AM), so + * for the day's first and last millisecond. NaN for a bad day. */ +function chicagoWallMs(day: string, time: string): number { + if (!isCalendarDay(day)) return NaN; + const naive = Date.parse(`${day}T${time}Z`); + // The offset where the answer lands, which a DST change between the naive + // instant and the real one can make differ from the naive instant's. + const first = naive - chicagoOffsetMs(naive); + return naive - chicagoOffsetMs(first); +} + +/** The Chicago calendar day (YYYY-MM-DD) an instant falls on; null when unreadable. */ +export function chicagoDay(iso: string): string | null { + const ms = Date.parse(iso); + if (!Number.isFinite(ms) || Math.abs(ms) > 8.64e15) return null; + return new Date(ms + chicagoOffsetMs(ms)).toISOString().slice(0, 10); +} + +/** The first millisecond of a Chicago calendar day, as a rules date — what a + * day picked by hand can never be earlier than, so it is never inside the + * evidence of the day before it. null for a bad or pre-2000 day. */ +export function chicagoDayStart(day: string): string | null { + return sortableIso(chicagoWallMs(day, '00:00:00.000')); +} + +/** + * The rules date the broker's evidence gives an account: the end of its first + * fund day (YYYY-MM-DD, platform-client.ts firstFundDate) in Chicago — + * D 23:59:59.999 America/Chicago, so 04:59:59.999Z the next day under CDT and + * 05:59:59.999Z under CST. Every source's day ends by then, so the evidence is + * never earlier than the moment it stands for, and an account opened on the + * day of a rule change — even that evening — counts as opened after it. null + * for anything else. + */ +export function evidenceRulesDate(firstFundDate: string | null | undefined): string | null { + if (!isCalendarDay(firstFundDate)) return null; + return sortableIso(chicagoWallMs(firstFundDate, '23:59:59.999')); +} + +/** The Chicago day a rules date stands for when it is exactly the end of one + * — the shape evidenceRulesDate stores — so the dashboard shows the broker's + * day in any time zone rather than the next one. null for any other date. */ +export function evidenceDay(rulesDate: string): string | null { + const day = chicagoDay(rulesDate); + return day !== null && evidenceRulesDate(day) === rulesDate ? day : null; +} + +/** + * The rules date a day picked by hand on the dashboard stores. Today is now + * (`nowMs`) — the master's default cutoff is "now", and today's start would + * sit before a change made earlier today, leaving no way onto the current + * rules. Any other day is its start: the browser's midnight + * (`localMidnightMs`), but never before Chicago's, so a day picked in a + * browser east of Chicago is never inside the evidence of the day before + * (which ends at Chicago midnight). null when unreadable. + */ +export function pickedDayRulesDate( + day: string, + at: { today: string; nowMs: number; localMidnightMs: number }, +): string | null { + if (day === at.today) return sortableIso(at.nowMs); + const chicago = chicagoDayStart(day); + if (!chicago || !Number.isFinite(at.localMidnightMs)) return null; + return sortableIso(Math.max(at.localMidnightMs, Date.parse(chicago))); +} + +/** + * Whether ✓ on the very day the editor opened on stores it. An unchanged day + * keeps the exact date — storing the day's start can land before a same-day + * rule change and put a running account back on old rules — unless storing + * it moves the account to other rules, which the editor's preview shows + * (today's "now" past a same-day change, say). With the type's windows + * unknown, never. `openedDate` is the date the editor opened on, not one a + * later poll brought. + */ +export function unchangedDaySaves( + kept: { id: string; openedAt: string | null; closedAt: string }[] | null, + openedDate: string, + stored: string, +): boolean { + if (!kept) return false; + return (pickKeptRules(kept, openedDate)?.id ?? null) !== (pickKeptRules(kept, stored)?.id ?? null); +} + +/** + * The broker's evidence of when an account opened, once its fund history has + * loaded: the end of its earliest positive fund row's day (evidenceRulesDate), + * or — with no such row — the DXtrade registration instant itself. Never the + * registration's calendar day: that is a Chicago date, and an evening + * registration must not read as earlier than it happened. null when neither + * says (or a value is unreadable). + */ +export function openingEvidence(firstFundDay: string | null | undefined, registeredAt: number | null | undefined): string | null { + if (typeof firstFundDay === 'string') return evidenceRulesDate(firstFundDay); + return typeof registeredAt === 'number' ? sortableIso(registeredAt) : null; +} + +/** + * Whether an account bound to a live type still matches that type's identity + * — its name carries the type's prefix and the size cached when it matched + * equals the type's size (0 = 0 for a $0-start type). An account on a kept + * rule set stays bound to its type only while this holds: kept sets take the + * live type's prefix and size, so after a size edit a "sticky" account would + * trade on a size it doesn't have. + */ +export function identityStillMatches( + accountName: string, + cachedSize: string | null, + live: Pick, +): boolean { + if (!accountName.startsWith(live.prefix)) return false; + if (cachedSize === null || cachedSize.trim() === '') return false; + const size = Number(cachedSize); + return Number.isFinite(size) && Math.abs(size - live.accountSize) < 0.01; +} + +/** Whether broker evidence moves an auto rules date: only earlier, never a + * date set by hand. The same test lib/db.ts lowerAutoRulesDate applies in + * SQL (string order is time order: every rules date is a sortableIso()). */ +export function lowersRulesDate(row: { rules_date: string | null; rules_date_manual: number }, evidence: string): boolean { + return row.rules_date_manual === 0 && (row.rules_date === null || row.rules_date > evidence); +} diff --git a/lib/master-protocol.ts b/lib/master-protocol.ts index 9226858..ae67125 100644 --- a/lib/master-protocol.ts +++ b/lib/master-protocol.ts @@ -81,6 +81,10 @@ export interface AccountTypeStats { stage: number; drain?: boolean; }[]; }[]; + /** Live accounts of this slice on each kept rule set — the slice itself + * stays keyed by the live account type, whose id is the only one ever + * reported as an accountTypeId. Optional. */ + keptRules?: { id: string; accounts: number }[]; } export interface FirmStats { @@ -139,6 +143,10 @@ export interface FirmStats { /** The catalog firm's uuid (statusVersion builds), so the master needn't * resolve the name. Optional. */ firmId?: string; + /** Kept rule set ids this machine has loaded for the firm. Present (even + * []) = this build applies kept rules and its cached catalog was parsed + * by a parser that understands them. Absent = old build or stale cache. */ + keptRuleIds?: string[]; /** ISO time the firm's last good sync finished (statusVersion builds), sent * once synced. The statuses count only while it is recent. Optional. */ syncedAt?: string; @@ -381,6 +389,22 @@ export interface CatalogAccountType { updatedAt: string; } +/** Old rules an account type keeps for its accounts whose rules date is + * inside [openedAt, closedAt) — set when the owner changes the type's rules + * for new accounts only. Built as: live type X's fields, overwritten by the + * snapshot's RULE fields, plus these. Never an account type itself: nothing + * is pinned or reported by its id as an accountTypeId (lib/kept-rules.ts). */ +export interface KeptAccountRules extends CatalogAccountType { + /** This kept set's own uuid — never a live account type id. */ + id: string; + /** The live account type id (X). */ + keptFrom: string; + /** ISO; inclusive lower bound on the rules date; null = none. */ + openedAt: string | null; + /** ISO; exclusive upper bound. */ + closedAt: string; +} + /** Where a firm's accounts trade. A build only knows the platforms listed * here; the catalog sync skips a firm on any other rather than guess. */ export const FIRM_PLATFORMS = ['tradovate', 'topstepx', 'dxtrade'] as const; @@ -421,10 +445,17 @@ export interface CatalogFirm { /** The firm's website ('' = none; absent from older masters). The master * matches the extension's tab against it to find a person's 2FA code. */ siteUrl?: string; + /** The most of the firm's accounts this machine trades at once, on top of + * its own max_concurrent_accounts (0 = no firm cap; absent from older + * masters). */ + maxConcurrentAccounts?: number; /** Older masters' way to exclude roots; newer ones derive it from * `instruments`. */ bannedSymbols: string[]; accountTypes: CatalogAccountType[]; + /** Old rule sets the firm's account types keep for older accounts. + * Optional; old builds drop it. */ + keptAccountRules?: KeptAccountRules[]; /** DXTrade REST base, e.g. https://dx.tradeify247.co/dxsca-web ('' otherwise). */ serverUrl?: string; /** DXTrade login domain ('' = 'default'). */ @@ -458,6 +489,11 @@ export interface FirmCatalogPayload { firms: CatalogFirm[]; /** Absent from a master from before its Settings page. */ trading?: CatalogTradingSettings; + /** Instance-local, never sent by the master: the catalog parser version + * (lib/firm-catalog.ts CATALOG_PARSER_VERSION) that produced a cached + * 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; } export interface FirmCatalogUnchanged { diff --git a/lib/platform-client.ts b/lib/platform-client.ts index a8291c6..fdd9197 100644 --- a/lib/platform-client.ts +++ b/lib/platform-client.ts @@ -302,30 +302,43 @@ const cents = (n: number) => Math.round(n * 100) / 100; /** Epoch ms a Date can hold — formatting anything else throws. */ const validTime = (ms: number | undefined): ms is number => ms !== undefined && Number.isFinite(ms) && Math.abs(ms) <= 8.64e15; +/** + * When the account opened, as far as the broker shows: its earliest positive + * fund row's date (YYYY-MM-DD), or for a DXTrade account with none its + * registration date, in Chicago. Undefined until the fund history has loaded + * (`fundTransactions` undefined) and when neither says. The report's + * firstFundDate. The resolver's evidence for an account's rules date reads + * the fund rows through here (registeredAt undefined) but takes the + * registration instant itself, not its day (lib/kept-rules.ts openingEvidence). + */ +export function firstFundDate( + fundTransactions: { date: string; amount: number }[] | undefined, + registeredAt: number | undefined, +): string | undefined { + if (!fundTransactions) return undefined; + const positive = fundTransactions.filter((f) => f.amount > 0); + if (positive.length > 0) return positive.reduce((min, f) => (f.date < min ? f.date : min), positive[0].date).slice(0, 10); + return validTime(registeredAt) ? CT_DATE.format(new Date(registeredAt)) : undefined; +} + /** * The status fields one accounts[] entry adds. Every one is left out when it * is null or at its default — 2000 accounts must stay well under the master - * socket's 1 MB frame. firstFundDate is the earliest positive fund row (a - * DXTrade account with none falls back to its registration date, in Chicago); - * positiveFunds counts those rows, and both wait for the fund history. + * socket's 1 MB frame. firstFundDate: see the function of that name; + * positiveFunds counts the positive fund rows, and both wait for the fund + * history. */ export function accountStatusFields(input: AccountReportInput): Omit { const { status, reason } = accountStatus(input); - const funds = input.fundTransactions; - const positive = funds?.filter((f) => f.amount > 0); - let firstFundDate: string | undefined; - if (positive && positive.length > 0) { - firstFundDate = positive.reduce((min, f) => (f.date < min ? f.date : min), positive[0].date).slice(0, 10); - } else if (funds && validTime(input.registeredAt)) { - firstFundDate = CT_DATE.format(new Date(input.registeredAt)); - } + const positive = input.fundTransactions?.filter((f) => f.amount > 0); + const firstFund = firstFundDate(input.fundTransactions, input.registeredAt); return { status, ...(reason ? { reason } : {}), ...(typeof input.cash === 'number' && Number.isFinite(input.cash) ? { balance: cents(input.cash) } : {}), ...(Number.isFinite(input.floor) && input.floor !== 0 ? { floor: cents(input.floor) } : {}), ...(input.accountTypeId ? { accountTypeId: input.accountTypeId } : {}), - ...(firstFundDate ? { firstFundDate } : {}), + ...(firstFund ? { firstFundDate: firstFund } : {}), ...(positive ? { positiveFunds: positive.length } : {}), ...(input.brokerStatus ? { brokerStatus: input.brokerStatus } : {}), }; @@ -342,6 +355,14 @@ export function firmStatusFields(firmId: string, syncComplete: boolean, lastSync }; } +/** FirmStats.keptRuleIds: the firm's kept rule set ids when this machine's + * catalog can be trusted to hold them (lib/firm-catalog.ts + * getKeptRuleIdsForFirm), else left out — the master then counts this + * machine as trading every account on the newest rules. */ +export function keptRuleIdsField(keptRuleIds: string[] | null): Pick { + return keptRuleIds ? { keptRuleIds } : {}; +} + /** * FirmStats for a firm with no accounts to walk — no client yet, or an empty * broker list. accounts is [] only once synced: before that, a firm whose @@ -352,6 +373,7 @@ export function firmStatusFields(firmId: string, syncComplete: boolean, lastSync export function emptyFirmStats( firm: { id: string; name: string }, client: { syncComplete: boolean; lastSyncAt: number } | undefined, + keptRuleIds: string[] | null = null, ): FirmStats { const synced = client?.syncComplete === true; return { @@ -360,6 +382,7 @@ export function emptyFirmStats( ...(synced ? { accounts: [] } : {}), accountTypes: [], ...firmStatusFields(firm.id, synced, client?.lastSyncAt ?? 0), + ...keptRuleIdsField(keptRuleIds), }; } diff --git a/lib/reporter.ts b/lib/reporter.ts index fa555fb..75fff3c 100644 --- a/lib/reporter.ts +++ b/lib/reporter.ts @@ -17,14 +17,14 @@ import { getBuildVersion } from './version'; import { tradeMixFor } from './trade-mix'; import { updaterStatus } from './updater-status'; import { getClients } from './clients'; -import { getTradableFirms } from './firm-catalog'; +import { getAccountTypeById, getKeptRuleIdsForFirm, getTradableFirms } from './firm-catalog'; import { getAccountConfig } from './account-type-resolver'; import { getSchedulerStatus } from './auto-trade'; import { botStopsActive, combineCmeSessions, computeDayPlan, currentTradeDateCT, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig, DEFAULT_TARGET_HOLD_HOURS } from './trading-logic'; import type { AccountTypeStats, FirmStats, InstrumentStats, SymbolStats } from './master-protocol'; import { accountNextLeg, accountLegShape } from './leg-estimate'; import { instrumentStatsFor, isFuturesFirm } from './instruments'; -import { accountStatusFields, brokerFacts, emptyFirmStats, firmStatusFields, historyFresh } from './platform-client'; +import { accountStatusFields, brokerFacts, emptyFirmStats, firmStatusFields, historyFresh, keptRuleIdsField } from './platform-client'; function collectFirmStats() { // Only firms this machine has credentials for. A firm it cannot log into @@ -44,8 +44,10 @@ function collectFirmStats() { return firms.map((f) => { const client = clients.get(f.id); + // Absent (null) while this machine's catalog can't vouch for them. + const keptRuleIds = getKeptRuleIdsForFirm(f.id); // accounts: [] only once synced (see emptyFirmStats). - if (!client || client.accountList.length === 0) return emptyFirmStats(f, client); + if (!client || client.accountList.length === 0) return emptyFirmStats(f, client, keptRuleIds); let totalAccounts = 0; let accountsTraded = 0; @@ -63,16 +65,19 @@ function collectFirmStats() { // Per account type, so the master can say what the accounts still to // trade actually are. Keyed by account type id; unassigned accounts // share the null bucket — they inflate "left to trade" indefinitely, - // which is precisely the thing worth being able to see. + // which is precisely the thing worth being able to see. Always the + // live type's id, label and size: accounts on a kept rule set count + // in their live type's slice (and in its keptRules). const byType = new Map(); const bucket = (cfg: { id: string; label: string; accountSize: number } | null): AccountTypeStats => { const key = cfg?.id ?? null; let entry = byType.get(key); if (!entry) { + const live = key ? getAccountTypeById(key) ?? cfg : null; entry = { accountTypeId: key, - label: cfg?.label ?? 'Unassigned', - accountSize: cfg?.accountSize ?? 0, + label: live?.label ?? 'Unassigned', + accountSize: live?.accountSize ?? 0, totalAccounts: 0, accountsTraded: 0, inTrade: 0, @@ -89,7 +94,8 @@ function collectFirmStats() { const reportedCash = client.accountCashBalances[acc.id]; const cash = reportedCash ?? { amount: 0, realizedPnL: 0 }; const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0; - const cfg = getAccountConfig(acc.id).accountType; + const resolved = getAccountConfig(acc.id); + const cfg = resolved.accountType; // undefined until the account's fund history has loaded. The master // clears a completed cycle once the withdrawal it was waiting for // shows up here, so an account that hasn't loaded reports no count @@ -122,6 +128,12 @@ function collectFirmStats() { totalAccounts++; slice.totalAccounts++; + if (resolved.keptRules) { + const keptId = resolved.keptRules.id; + const kept = (slice.keptRules ??= []).find((k) => k.id === keptId); + if (kept) kept.accounts++; + else slice.keptRules.push({ id: keptId, accounts: 1 }); + } // Same combined-day view as the trade path: for a combining firm, // "today" spans the holiday stretch. Computed for every live @@ -399,6 +411,7 @@ function collectFirmStats() { ...instrumentStatsBlock(f, client, rawToday), mix: tradeMixFor(f, client), ...firmStatusFields(f.id, client.syncComplete, client.lastSyncAt), + ...keptRuleIdsField(keptRuleIds), }; }); } diff --git a/lib/trading-logic.ts b/lib/trading-logic.ts index 4322d7d..ff6521d 100644 --- a/lib/trading-logic.ts +++ b/lib/trading-logic.ts @@ -1750,3 +1750,24 @@ export function isPastSessionClose(now: Date, everyDay: boolean): boolean { if (day === 'Sat' || day === 'Sun') return false; return minutes >= 14 * 60 + 55 && minutes < 17 * 60; } + +/** + * A firm's cap on how many of its accounts trade at once, on top of the + * machine's own max concurrent accounts: keeps `items` in order, at most + * `firm.maxConcurrentAccounts` per firm less what that firm already holds + * open (`held`, by firm id). A cap of 0 (or none) means no firm cap. + */ +export function withinFirmCaps( + items: readonly T[], + held: ReadonlyMap = new Map(), +): T[] { + const taken = new Map(held); + return items.filter(({ firm }) => { + const cap = firm.maxConcurrentAccounts ?? 0; + if (cap <= 0) return true; + const count = taken.get(firm.id) ?? 0; + if (count >= cap) return false; + taken.set(firm.id, count + 1); + return true; + }); +} diff --git a/package.json b/package.json index b2f1f6c..aaef155 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" + "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" }, "dependencies": { "@microsoft/signalr": "^10.0.11", diff --git a/scripts/check-account-status.mjs b/scripts/check-account-status.mjs index 5995e8b..fe5da95 100644 --- a/scripts/check-account-status.mjs +++ b/scripts/check-account-status.mjs @@ -10,7 +10,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { - accountStatus, accountStatusFields, brokerFacts, emptyFirmStats, firmStatusFields, legacyDead, + accountStatus, accountStatusFields, brokerFacts, emptyFirmStats, firmStatusFields, firstFundDate, keptRuleIdsField, legacyDead, REPORT_STATUS_VERSION, SYNC_STALE_MS, } from '../lib/platform-client.ts'; @@ -146,6 +146,22 @@ test('DXtrade: the registration date (Chicago) stands in for the first fund date assert.deepEqual(firmStatusFields('f', true, 1e17), { statusVersion: 1, firmId: 'f' }); }); +test('firstFundDate: the helper the report and the rules-date evidence share', () => { + const registeredAt = Date.parse('2026-10-03T03:00:00Z'); // Oct 2, 22:00 CDT + assert.equal(firstFundDate(undefined, registeredAt), undefined); + assert.equal(firstFundDate([], registeredAt), '2026-10-02'); + assert.equal(firstFundDate([], undefined), undefined); + assert.equal(firstFundDate([{ date: '2026-10-05T00:00:00', amount: 100_000 }, { date: '2026-10-04', amount: -50 }], registeredAt), '2026-10-05'); + assert.equal(firstFundDate([{ date: '2026-10-09', amount: 25 }, { date: '2026-10-05', amount: 100_000 }], undefined), '2026-10-05'); + // Always what accountStatusFields reports. + for (const funds of [undefined, [], [{ date: '2026-09-25', amount: 50_000 }], [{ date: '2026-09-30', amount: -1 }]]) { + for (const reg of [undefined, registeredAt, NaN]) { + const fields = accountStatusFields({ cash: 1, floor: 0, active: true, historyReady: true, accountTypeId: 't', fundTransactions: funds, registeredAt: reg }); + assert.equal(fields.firstFundDate, firstFundDate(funds, reg)); + } + } +}); + test('firm-level fields: statusVersion and firmId always, syncedAt once synced', () => { const at = Date.parse('2026-10-06T14:00:00Z'); assert.deepEqual(firmStatusFields('f-1', true, at), { statusVersion: 1, firmId: 'f-1', syncedAt: '2026-10-06T14:00:00.000Z' }); @@ -155,6 +171,12 @@ test('firm-level fields: statusVersion and firmId always, syncedAt once synced', assert.equal(SYNC_STALE_MS, 5 * 60 * 1000); }); +test('keptRuleIds: present (even empty) when the catalog vouches for them, absent otherwise', () => { + assert.deepEqual(keptRuleIdsField(null), {}); + assert.deepEqual(keptRuleIdsField([]), { keptRuleIds: [] }); + assert.deepEqual(keptRuleIdsField(['k1', 'k2']), { keptRuleIds: ['k1', 'k2'] }); +}); + test('the no-accounts branch keeps every existing key and value', () => { const firm = { id: '71016d02-752f-4078-9e24-e74c9d75d1c8', name: 'Tradeify' }; // What reporter.ts sent before (its :46-56 branch), for each client state. @@ -166,19 +188,25 @@ test('the no-accounts branch keeps every existing key and value', () => { }); const at = Date.parse('2026-10-06T14:00:00Z'); const states = [undefined, { syncComplete: false, lastSyncAt: 0 }, { syncComplete: false, lastSyncAt: at }, { syncComplete: true, lastSyncAt: 0 }, { syncComplete: true, lastSyncAt: at }]; + // No kept-rule ids (an older cache: null, or not passed), none, some. + const keptCases = [undefined, null, [], ['3f0c2b7e-0000-4000-8000-000000000001']]; for (const client of states) { - const got = emptyFirmStats(firm, client); - const old = legacy(client); - for (const [key, value] of Object.entries(old)) assert.deepEqual(got[key], value, `${JSON.stringify(client)}: ${key}`); - const added = Object.keys(got).filter((k) => !(k in old)).sort(); - const synced = client?.syncComplete === true; - assert.equal(got.statusVersion, 1); - assert.equal(got.firmId, firm.id); - assert.equal('accounts' in got, synced); - assert.equal('syncedAt' in got, synced && client.lastSyncAt > 0); - assert.deepEqual(added, ['firmId', 'statusVersion', ...(got.syncedAt ? ['syncedAt'] : [])].sort()); - // The old keys stay first and in their order: only new keys follow. - assert.deepEqual(Object.keys(got).slice(0, Object.keys(old).length), Object.keys(old)); + for (const kept of keptCases) { + const got = kept === undefined ? emptyFirmStats(firm, client) : emptyFirmStats(firm, client, kept); + const old = legacy(client); + const label = `${JSON.stringify(client)} kept ${JSON.stringify(kept)}`; + for (const [key, value] of Object.entries(old)) assert.deepEqual(got[key], value, `${label}: ${key}`); + const added = Object.keys(got).filter((k) => !(k in old)).sort(); + const synced = client?.syncComplete === true; + assert.equal(got.statusVersion, 1); + assert.equal(got.firmId, firm.id); + assert.equal('accounts' in got, synced); + assert.equal('syncedAt' in got, synced && client.lastSyncAt > 0); + assert.deepEqual(got.keptRuleIds, Array.isArray(kept) ? kept : undefined, label); + assert.deepEqual(added, ['firmId', 'statusVersion', ...(got.syncedAt ? ['syncedAt'] : []), ...(Array.isArray(kept) ? ['keptRuleIds'] : [])].sort(), label); + // The old keys stay first and in their order: only new keys follow. + assert.deepEqual(Object.keys(got).slice(0, Object.keys(old).length), Object.keys(old)); + } } }); diff --git a/scripts/check-kept-rules.mjs b/scripts/check-kept-rules.mjs new file mode 100644 index 0000000..17ff929 --- /dev/null +++ b/scripts/check-kept-rules.mjs @@ -0,0 +1,516 @@ +// Checks for kept rules — which of an account type's rule sets an account +// trades on (lib/kept-rules.ts). +// +// npm run check +// +// lib/kept-rules.ts has only `import type`s, so Node strips its types and +// loads it directly; .mjs keeps this file out of Next's type check. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + RULE_FIELDS, IDENTITY_FIELDS, PROJECTION_FIELDS, normalizeAccountType, pickKeptRules, overlayKept, ruleDiffs, + stampRulesDate, evidenceRulesDate, lowersRulesDate, sortableIso, chicagoDay, chicagoDayStart, evidenceDay, + openingEvidence, identityStillMatches, pickedDayRulesDate, unchangedDaySaves, +} from '../lib/kept-rules.ts'; + +const LIVE = { + id: 'live-x', firmId: 'firm-1', label: '50K Eval', prefix: 'TDFY', accountSize: 50_000, + profitTarget: 2500, consistency: 0.4, minDayPnL: 150, minTradingDays: 3, maxLoss: 2000, maxPositionSize: 5, + targetSameEquity: false, splitEnabled: true, minLegs: 2, firstDayStop: true, minDayStop: false, combineCmeHolidays: false, + withdrawalStages: [{ profit: 2000, consistency: 0.4, minTradingDays: 5, drain: false, minDayPnL: 0 }], + payoutable: true, trailingDrawdown: 2000, expectedPayout: 1000, withdrawalPct: 0.5, payoutCap: 3000, profitSplit: 0.9, + dailyLossLimit: 1000, tradesEveryDay: false, updatedAt: '2026-10-18T14:05:00.000Z', +}; + +/** `obj` without `keys`. */ +const omit = (obj, keys) => Object.fromEntries(Object.entries(obj).filter(([k]) => !keys.includes(k))); + +/** A kept set as the master builds it: the live type, the old rules on top. */ +function keptOf(rules, window, id = 'kept-1') { + return { + ...LIVE, ...rules, id, keptFrom: LIVE.id, createdAt: '2026-01-01T00:00:00.000Z', + openedAt: window.openedAt, closedAt: window.closedAt, + }; +} + +// ── field classes ─────────────────────────────────────────────────────────── + +test('field classes: the spec\'s lists, identical to the master\'s, and disjoint', () => { + assert.deepEqual([...RULE_FIELDS], [ + 'profitTarget', 'consistency', 'minDayPnL', 'minTradingDays', 'maxLoss', 'maxPositionSize', 'targetSameEquity', + 'splitEnabled', 'minLegs', 'firstDayStop', 'minDayStop', 'combineCmeHolidays', 'withdrawalStages', 'payoutable', + 'trailingDrawdown', 'dailyLossLimit', + ]); + assert.deepEqual([...IDENTITY_FIELDS], ['prefix', 'accountSize']); + assert.deepEqual([...PROJECTION_FIELDS], ['expectedPayout', 'withdrawalPct', 'payoutCap', 'profitSplit']); + const all = [...RULE_FIELDS, ...IDENTITY_FIELDS, ...PROJECTION_FIELDS, 'label']; + assert.equal(new Set(all).size, all.length); + // Every field of a catalog account type is in exactly one class, or is + // bookkeeping the live type always owns. + const bookkeeping = ['id', 'firmId', 'updatedAt', 'tradesEveryDay']; + assert.deepEqual(Object.keys(LIVE).filter((k) => !all.includes(k) && !bookkeeping.includes(k)), []); +}); + +// ── normalizeAccountType: exactly the old validatePayload callback ────────── + +/** lib/firm-catalog.ts validatePayload's per-type callback before the move + * (git show HEAD:lib/firm-catalog.ts), verbatim but for the types. */ +function legacyNormalize(t, j, tradesEveryDay) { + const nonNegative = (value) => (typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0); + const isStages = (value) => Array.isArray(value) && value.every((s) => + typeof s === 'object' && s !== null + && typeof s.profit === 'number' + && typeof s.consistency === 'number' + && typeof s.minTradingDays === 'number'); + if (typeof t !== 'object' || t === null) throw new Error(`account type ${j} is not an object`); + const type = t; + for (const key of ['id', 'firmId', 'label', 'prefix', 'updatedAt']) { + if (typeof type[key] !== 'string') throw new Error(`account type ${j} is missing ${key}`); + } + for (const key of [ + 'accountSize', 'profitTarget', 'consistency', 'minDayPnL', + 'minTradingDays', 'maxLoss', 'maxPositionSize', + ]) { + if (typeof type[key] !== 'number') throw new Error(`account type ${j} is missing ${key}`); + } + if (!isStages(type.withdrawalStages)) throw new Error(`account type ${j} has bad withdrawalStages`); + return { + ...type, + splitEnabled: type.splitEnabled !== false, + combineCmeHolidays: type.combineCmeHolidays === true && !tradesEveryDay, + minLegs: typeof type.minLegs === 'number' ? type.minLegs : 0, + firstDayStop: type.firstDayStop === true, + minDayStop: type.minDayStop === true, + dailyLossLimit: nonNegative(type.dailyLossLimit), + tradesEveryDay, + }; +} + +function outcome(fn) { + try { + return { ok: JSON.stringify(fn()) }; + } catch (err) { + return { error: err.message }; + } +} + +test('normalizeAccountType is the old per-type validation, byte for byte (values, key order, errors)', () => { + const wire = omit(LIVE, ['tradesEveryDay']); + const variants = [ + wire, + // An old master: none of the lenient fields. + omit(wire, ['splitEnabled', 'minLegs', 'firstDayStop', 'minDayStop', 'combineCmeHolidays', 'dailyLossLimit']), + { ...wire, splitEnabled: false, combineCmeHolidays: true, minLegs: '3', firstDayStop: 'yes', dailyLossLimit: -5 }, + { ...wire, dailyLossLimit: NaN, minDayStop: true, extra: { nested: [1, 2] } }, + { ...wire, withdrawalStages: [] }, + // Malformed: each must throw the same message. + null, 'type', 42, + { ...wire, id: 7 }, { ...wire, label: undefined }, { ...wire, updatedAt: null }, + { ...wire, accountSize: '50000' }, { ...wire, maxPositionSize: undefined }, + { ...wire, withdrawalStages: [{ profit: 1, consistency: 0.5 }] }, { ...wire, withdrawalStages: 'none' }, + { ...wire, withdrawalStages: [null] }, + ]; + let errors = 0; + for (const tradesEveryDay of [false, true]) { + variants.forEach((v, j) => { + const legacy = outcome(() => legacyNormalize(v, j, tradesEveryDay)); + const now = outcome(() => normalizeAccountType(v, { tradesEveryDay, where: `account type ${j}` })); + assert.deepEqual(now, legacy, `variant ${j}, every day ${tradesEveryDay}`); + if (legacy.error) errors++; + }); + } + assert.equal(errors, 2 * 11); + // The kept-set wording only changes the name in the message. + assert.throws(() => normalizeAccountType({ ...wire, prefix: 1 }, { tradesEveryDay: false, where: 'kept rules 0 of Tradeify' }), + { message: 'kept rules 0 of Tradeify is missing prefix' }); +}); + +// ── pickKeptRules ─────────────────────────────────────────────────────────── + +const G1 = { id: 'g1', openedAt: null, closedAt: '2026-10-02T15:00:00.000Z' }; +const G2 = { id: 'g2', openedAt: '2026-10-02T15:00:00.000Z', closedAt: '2026-10-18T14:05:00.000Z' }; +const G3 = { id: 'g3', openedAt: '2026-10-18T14:05:00.000Z', closedAt: '2026-10-18T19:30:00.000Z' }; +const GENERATIONS = [G1, G2, G3]; + +test('pickKeptRules: the window holding the date, [openedAt, closedAt)', () => { + const pick = (d, sets = GENERATIONS) => pickKeptRules(sets, d)?.id ?? null; + // No lower bound on the first generation. + assert.equal(pick('2020-01-01T00:00:00.000Z'), 'g1'); + assert.equal(pick('2026-10-02T14:59:59.999Z'), 'g1'); + // A date exactly at a cutoff is on the newer side: closedAt excluded, + // openedAt included. + assert.equal(pick('2026-10-02T15:00:00.000Z'), 'g2'); + assert.equal(pick('2026-10-18T14:04:59.999Z'), 'g2'); + assert.equal(pick('2026-10-18T14:05:00.000Z'), 'g3'); + assert.equal(pick('2026-10-18T19:29:59.999Z'), 'g3'); + // From the last cutoff on: the current rules. + assert.equal(pick('2026-10-18T19:30:00.000Z'), null); + assert.equal(pick('2027-01-01T00:00:00.000Z'), null); + // Compared as times, not strings: other ISO spellings of the same instant. + assert.equal(pick('2026-10-02T10:00:00-05:00'), 'g2'); + assert.equal(pick('2026-10-02T09:59:59.999-05:00'), 'g1'); + // Order of the list doesn't matter for disjoint windows. + assert.equal(pick('2026-10-10T00:00:00.000Z', [G3, G1, G2]), 'g2'); + // Returns the set itself (the generic carries through). + assert.equal(pickKeptRules(GENERATIONS, '2026-10-10T00:00:00.000Z'), G2); +}); + +test('pickKeptRules: no date, no sets, or an unreadable date — the current rules', () => { + assert.equal(pickKeptRules(GENERATIONS, null), null); + assert.equal(pickKeptRules(GENERATIONS, ''), null); + assert.equal(pickKeptRules(GENERATIONS, 'not a date'), null); + assert.equal(pickKeptRules(undefined, '2026-10-10T00:00:00.000Z'), null); + assert.equal(pickKeptRules([], '2026-10-10T00:00:00.000Z'), null); + // An unreadable bound never matches (the catalog parse rejects them anyway). + assert.equal(pickKeptRules([{ id: 'bad', openedAt: null, closedAt: 'soon' }], '2020-01-01T00:00:00.000Z'), null); + assert.equal(pickKeptRules([{ id: 'bad', openedAt: 'once', closedAt: '2030-01-01T00:00:00.000Z' }], '2026-01-01T00:00:00.000Z'), null); + // An empty window (openedAt == closedAt) holds nothing. + assert.equal(pickKeptRules([{ id: 'empty', openedAt: G2.closedAt, closedAt: G2.closedAt }], G2.closedAt), null); +}); + +// ── overlayKept ───────────────────────────────────────────────────────────── + +test('overlayKept: the kept rules on top; identity, label, projections and bookkeeping stay live', () => { + const oldRules = { + profitTarget: 3000, consistency: 0.5, minDayPnL: 200, minTradingDays: 5, maxLoss: 2500, maxPositionSize: 3, + targetSameEquity: true, splitEnabled: false, minLegs: 0, firstDayStop: false, minDayStop: true, combineCmeHolidays: true, + withdrawalStages: [], payoutable: false, trailingDrawdown: 2500, dailyLossLimit: 0, + }; + // A master that sent other identity/projection values on the kept set: + // they must not leak through. + const kept = keptOf({ ...oldRules, label: 'OLD', prefix: 'OLD', accountSize: 1, expectedPayout: 1, withdrawalPct: 1, + payoutCap: 1, profitSplit: 1, firmId: 'other', updatedAt: '1999-01-01T00:00:00.000Z', tradesEveryDay: true }, G2); + const liveBefore = structuredClone(LIVE); + const keptBefore = structuredClone(kept); + const eff = overlayKept(LIVE, kept); + + for (const field of RULE_FIELDS) assert.deepEqual(eff[field], oldRules[field], field); + for (const field of ['id', 'firmId', 'label', 'prefix', 'accountSize', 'updatedAt', 'tradesEveryDay', ...PROJECTION_FIELDS]) { + assert.deepEqual(eff[field], LIVE[field], field); + } + assert.deepEqual(eff.keptRules, { id: 'kept-1', openedAt: G2.openedAt, closedAt: G2.closedAt }); + // Nothing of the kept set's own bookkeeping comes along. + for (const key of ['keptFrom', 'openedAt', 'closedAt', 'createdAt']) assert.equal(key in eff, false, key); + assert.deepEqual(Object.keys(eff).sort(), [...Object.keys(LIVE), 'keptRules'].sort()); + // Inputs untouched. + assert.deepEqual(LIVE, liveBefore); + assert.deepEqual(kept, keptBefore); +}); + +test('overlayKept: a rule field the kept set lacks keeps the live value', () => { + const kept = keptOf({ consistency: 0.5 }, G1); + delete kept.payoutable; + kept.trailingDrawdown = undefined; + const eff = overlayKept(LIVE, kept); + assert.equal(eff.consistency, 0.5); + assert.equal(eff.payoutable, LIVE.payoutable); + assert.equal(eff.trailingDrawdown, LIVE.trailingDrawdown); +}); + +// ── ruleDiffs ─────────────────────────────────────────────────────────────── + +test('ruleDiffs: the rule fields that differ, raw values both sides', () => { + assert.deepEqual(ruleDiffs(LIVE, LIVE), []); + const eff = overlayKept(LIVE, keptOf({ consistency: 0.5, profitTarget: 3000 }, G1)); + assert.deepEqual(ruleDiffs(eff, LIVE), [ + { field: 'profitTarget', kept: 3000, now: 2500 }, + { field: 'consistency', kept: 0.5, now: 0.4 }, + ]); + // Non-rule fields never count. + assert.deepEqual(ruleDiffs({ ...LIVE, label: 'x', prefix: 'y', profitSplit: 0.1 }, LIVE), []); + const stages = [{ profit: 3000, consistency: 0.4, minTradingDays: 5, drain: false, minDayPnL: 0 }]; + assert.deepEqual(ruleDiffs({ ...LIVE, withdrawalStages: stages }, LIVE), [ + { field: 'withdrawalStages', kept: stages, now: LIVE.withdrawalStages }, + ]); +}); + +test('ruleDiffs: stored JSON from before a field existed is not a change', () => { + // A stage without drain / minDayPnL is what parseAccountTypeInput fills + // with drain false, minDayPnL 0 (5 for a drain stage); key order is free. + const bare = { ...LIVE, withdrawalStages: [{ minTradingDays: 5, consistency: 0.4, profit: 2000 }] }; + assert.deepEqual(ruleDiffs(bare, LIVE), []); + const drainFilled = { ...LIVE, withdrawalStages: [{ profit: 0, consistency: 0, minTradingDays: 5, drain: true, minDayPnL: 5 }] }; + const drainBare = { ...LIVE, withdrawalStages: [{ profit: 0, consistency: 0, minTradingDays: 5, drain: true }] }; + assert.deepEqual(ruleDiffs(drainBare, drainFilled), []); + // A per-stage payout override is a rule. + assert.equal(ruleDiffs({ ...LIVE, withdrawalStages: [{ ...LIVE.withdrawalStages[0], payoutCap: 1500 }] }, LIVE).length, 1); + // An absent optional flag or amount is its default. + const noOptionals = omit(LIVE, ['payoutable', 'trailingDrawdown']); + assert.deepEqual(ruleDiffs(noOptionals, { ...LIVE, payoutable: false, trailingDrawdown: 0 }), []); + assert.deepEqual(ruleDiffs({ ...LIVE, splitEnabled: undefined }, LIVE), []); + assert.deepEqual(ruleDiffs({ ...LIVE, splitEnabled: undefined }, { ...LIVE, splitEnabled: false }).map((d) => d.field), ['splitEnabled']); +}); + +// ── rules dates ───────────────────────────────────────────────────────────── + +test('stampRulesDate: the latest of this clock, the master\'s and the catalog\'s last change', () => { + const now = Date.parse('2026-10-18T14:00:00.000Z'); + assert.equal(stampRulesDate(now, null, null), '2026-10-18T14:00:00.000Z'); + // This clock 5 minutes behind the master's. + assert.equal(stampRulesDate(now, 5 * 60_000, null), '2026-10-18T14:05:00.000Z'); + // This clock ahead: its own time stands. + assert.equal(stampRulesDate(now, -5 * 60_000, null), '2026-10-18T14:00:00.000Z'); + // The catalog changed after both: no new account dates before it. + assert.equal(stampRulesDate(now, 60_000, '2026-10-18T14:07:30.000Z'), '2026-10-18T14:07:30.000Z'); + assert.equal(stampRulesDate(now, null, '2026-10-01T00:00:00.000Z'), '2026-10-18T14:00:00.000Z'); + // Unreadable inputs are ignored. + assert.equal(stampRulesDate(now, NaN, 'never'), '2026-10-18T14:00:00.000Z'); + assert.equal(stampRulesDate(now, null, ''), '2026-10-18T14:00:00.000Z'); +}); + +test('stampRulesDate: a machine whose clock is behind never dates a new account inside the set just closed', () => { + // The owner saves "new accounts only" at 14:05 master time; this machine's + // clock reads 14:01 when it first sees a brand-new account at 14:05. + const cutoff = '2026-10-18T14:05:00.000Z'; + const kept = [{ id: 'g', openedAt: null, closedAt: cutoff }]; + const local = Date.parse('2026-10-18T14:01:00.000Z'); + // Unclamped, the account would land in the old rules. + assert.equal(pickKeptRules(kept, new Date(local).toISOString())?.id, 'g'); + // With the master's offset, or just the catalog's updatedAt, it doesn't. + assert.equal(pickKeptRules(kept, stampRulesDate(local, 4 * 60_000, null)), null); + assert.equal(pickKeptRules(kept, stampRulesDate(local, null, cutoff)), null); + // A machine whose master offset isn't known yet still clamps to the catalog. + assert.equal(pickKeptRules(kept, stampRulesDate(local, null, '2026-10-18T14:05:00.250Z')), null); +}); + +test('evidenceRulesDate: the end of the first fund day in Chicago — CDT and CST', () => { + // CDT (UTC−5): 23:59:59.999 CDT is 04:59:59.999Z the next day. + assert.equal(evidenceRulesDate('2026-10-18'), '2026-10-19T04:59:59.999Z'); + assert.equal(evidenceRulesDate('2026-07-04'), '2026-07-05T04:59:59.999Z'); + // CST (UTC−6): 05:59:59.999Z the next day. + assert.equal(evidenceRulesDate('2026-01-15'), '2026-01-16T05:59:59.999Z'); + assert.equal(evidenceRulesDate('2026-12-31'), '2027-01-01T05:59:59.999Z'); + assert.equal(evidenceRulesDate('2028-02-29'), '2028-03-01T05:59:59.999Z'); + for (const bad of [null, undefined, '', '2026-10-18T00:00:00Z', '18/10/2026', '2026-1-8', '2026-02-31', '2026-13-01', 20261018]) { + assert.equal(evidenceRulesDate(bad), null, String(bad)); + } +}); + +test('evidenceRulesDate: DST boundary days end on the right side of the change', () => { + // 2026: CST→CDT at 2 AM on Sunday Mar 8, CDT→CST at 2 AM on Sunday Nov 1. + assert.equal(evidenceRulesDate('2026-03-07'), '2026-03-08T05:59:59.999Z'); // last CST day + assert.equal(evidenceRulesDate('2026-03-08'), '2026-03-09T04:59:59.999Z'); // springs forward: ends in CDT + assert.equal(evidenceRulesDate('2026-03-09'), '2026-03-10T04:59:59.999Z'); + assert.equal(evidenceRulesDate('2026-10-31'), '2026-11-01T04:59:59.999Z'); // last CDT day + assert.equal(evidenceRulesDate('2026-11-01'), '2026-11-02T05:59:59.999Z'); // falls back: ends in CST + assert.equal(evidenceRulesDate('2026-11-02'), '2026-11-03T05:59:59.999Z'); + // The day before each boundary and the boundary day are 23 h / 25 h long + // in Chicago, measured end to end. + const len = (a, b) => (Date.parse(evidenceRulesDate(b)) - Date.parse(evidenceRulesDate(a))) / 3_600_000; + assert.equal(len('2026-03-07', '2026-03-08'), 23); + assert.equal(len('2026-10-31', '2026-11-01'), 25); + assert.equal(len('2026-06-01', '2026-06-02'), 24); +}); + +test('evidenceRulesDate: every day of 2026–2027 ends 1 ms before the next one starts, in Chicago', () => { + for (let ms = Date.UTC(2026, 0, 1); ms < Date.UTC(2028, 0, 1); ms += 86_400_000) { + const day = new Date(ms).toISOString().slice(0, 10); + const next = new Date(ms + 86_400_000).toISOString().slice(0, 10); + const end = evidenceRulesDate(day); + assert.equal(Date.parse(chicagoDayStart(next)) - Date.parse(end), 1, day); + // Both read back as their own Chicago day, and the end as evidence. + assert.equal(chicagoDay(end), day); + assert.equal(chicagoDay(chicagoDayStart(next)), next); + assert.equal(evidenceDay(end), day); + // Under either offset the end is 04:59:59.999Z or 05:59:59.999Z the next UTC day. + assert.match(end, new RegExp(`^${next}T0[45]:59:59\\.999Z$`)); + } +}); + +test('evidenceRulesDate: an account opened on the day of a change — even that evening — counts as after it', () => { + // A change at 14:05 CDT on Oct 18: an account first funded that day is + // after it; one funded the day before keeps the old rules. + const afternoon = [{ id: 'g', openedAt: null, closedAt: '2026-10-18T19:05:00.000Z' }]; + assert.equal(pickKeptRules(afternoon, evidenceRulesDate('2026-10-18')), null); + assert.equal(pickKeptRules(afternoon, evidenceRulesDate('2026-10-17'))?.id, 'g'); + // A change at 8 PM CDT (01:00Z the next UTC day): a UTC end of day + // (Oct 18 23:59:59.999Z = 6:59 PM CDT) would have put an account that + // opened that evening before it. + const evening = [{ id: 'g', openedAt: null, closedAt: '2026-10-19T01:00:00.000Z' }]; + assert.equal(pickKeptRules(evening, evidenceRulesDate('2026-10-18')), null); + assert.equal(pickKeptRules(evening, evidenceRulesDate('2026-10-17'))?.id, 'g'); + // Same under CST: a change at 11:30 PM CST Jan 15 (05:30Z Jan 16). + const winter = [{ id: 'g', openedAt: null, closedAt: '2026-01-16T05:30:00.000Z' }]; + assert.equal(pickKeptRules(winter, evidenceRulesDate('2026-01-15')), null); + assert.equal(pickKeptRules(winter, evidenceRulesDate('2026-01-14'))?.id, 'g'); +}); + +test('chicagoDayStart / chicagoDay / evidenceDay', () => { + assert.equal(chicagoDayStart('2026-10-18'), '2026-10-18T05:00:00.000Z'); + assert.equal(chicagoDayStart('2026-01-15'), '2026-01-15T06:00:00.000Z'); + assert.equal(chicagoDayStart('2026-03-08'), '2026-03-08T06:00:00.000Z'); // midnight is still CST + assert.equal(chicagoDayStart('2026-11-01'), '2026-11-01T05:00:00.000Z'); // midnight is still CDT + assert.equal(chicagoDayStart('2026-02-31'), null); + assert.equal(chicagoDayStart('1999-12-31'), null); // before 2000: not a rules date + assert.equal(chicagoDay('2026-10-19T04:59:59.999Z'), '2026-10-18'); + assert.equal(chicagoDay('2026-10-19T05:00:00.000Z'), '2026-10-19'); + assert.equal(chicagoDay('nope'), null); + // Only the exact end of a Chicago day reads as evidence. + assert.equal(evidenceDay('2026-10-19T04:59:59.999Z'), '2026-10-18'); + assert.equal(evidenceDay('2026-10-19T04:59:59.998Z'), null); + assert.equal(evidenceDay('2026-10-18T23:59:59.999Z'), null); // the old UTC shape + assert.equal(evidenceDay('2026-10-18T19:05:00.000Z'), null); +}); + +test('sortableIso: plain four-digit-year ISO from 2000 on, else null', () => { + assert.equal(sortableIso(Date.parse('2026-10-18T14:05:00Z')), '2026-10-18T14:05:00.000Z'); + assert.equal(sortableIso(Date.UTC(2000, 0, 1)), '2000-01-01T00:00:00.000Z'); + assert.equal(sortableIso(Date.UTC(2000, 0, 1) - 1), null); + assert.equal(sortableIso(0), null); + assert.equal(sortableIso(Date.UTC(9999, 11, 31, 23, 59, 59, 999)), '9999-12-31T23:59:59.999Z'); + assert.equal(sortableIso(Date.parse('+010000-01-01T00:00:00Z')), null); // would print as +010000-… + assert.equal(sortableIso(8.64e15 + 1), null); + assert.equal(sortableIso(NaN), null); + assert.equal(sortableIso(Infinity), null); +}); + +test('openingEvidence: a fund day ends in Chicago; the registration fallback is the instant itself', () => { + // A positive fund row's day: the end of it in Chicago, whatever the registration. + assert.equal(openingEvidence('2026-10-02', Date.parse('2026-09-01T00:00:00Z')), '2026-10-03T04:59:59.999Z'); + // Positive rows present but unreadable: no evidence (never the registration). + assert.equal(openingEvidence('2026-02-31', Date.parse('2026-09-01T00:00:00Z')), null); + // No positive rows: the registration instant, exactly. + assert.equal(openingEvidence(undefined, Date.parse('2026-10-19T02:00:00Z')), '2026-10-19T02:00:00.000Z'); + // Unreadable or implausible registrations: none. + for (const bad of [undefined, null, NaN, 0, 5, -1, Infinity, Date.parse('1999-12-31T23:00:00Z'), 9e15]) { + assert.equal(openingEvidence(undefined, bad), null, String(bad)); + } +}); + +test('openingEvidence: a DXtrade account registered after an evening cutoff is never lowered before it', () => { + // "New accounts only" saved at 8 PM CDT Oct 18 (01:00Z Oct 19); a DX + // account registered at 9 PM CDT (02:00Z) with no DEPOSIT row. + const kept = [{ id: 'g', openedAt: null, closedAt: '2026-10-19T01:00:00.000Z' }]; + const stamped = '2026-10-19T02:00:05.000Z'; + const evidence = openingEvidence(undefined, Date.parse('2026-10-19T02:00:00Z')); + const lowered = lowersRulesDate({ rules_date: stamped, rules_date_manual: 0 }, evidence) ? evidence : stamped; + assert.equal(pickKeptRules(kept, lowered), null); + // And at 9 PM CDT Oct 20 against an 8 PM cutoff that day. + const kept2 = [{ id: 'g', openedAt: null, closedAt: '2026-10-21T01:00:00.000Z' }]; + assert.equal(pickKeptRules(kept2, openingEvidence(undefined, Date.parse('2026-10-21T02:00:00Z'))), null); + // One registered before the cutoff, first seen after it, goes back to the old rules. + const before = openingEvidence(undefined, Date.parse('2026-10-19T00:30:00Z')); + assert.equal(lowersRulesDate({ rules_date: stamped, rules_date_manual: 0 }, before), true); + assert.equal(pickKeptRules(kept, before)?.id, 'g'); +}); + +test('identityStillMatches: prefix and the cached size against the live type', () => { + const live = { prefix: 'TDFY', accountSize: 50_000 }; + assert.equal(identityStillMatches('TDFY1234', '50000', live), true); + assert.equal(identityStillMatches('TDFY1234', '50000.004', live), true); + // A size edit on the type (50K → 100K, or → $0-start): no longer its size. + assert.equal(identityStillMatches('TDFY1234', '50000', { ...live, accountSize: 100_000 }), false); + assert.equal(identityStillMatches('TDFY1234', '50000', { ...live, accountSize: 0 }), false); + // A prefix edit. + assert.equal(identityStillMatches('TDFY1234', '50000', { ...live, prefix: 'TDFX' }), false); + // A $0-start type caches 0 when it matches. + assert.equal(identityStillMatches('XFA77', '0', { prefix: 'XFA', accountSize: 0 }), true); + assert.equal(identityStillMatches('XFA77', '150000', { prefix: 'XFA', accountSize: 0 }), false); + // Nothing cached, or junk: not provably the same account. + assert.equal(identityStillMatches('TDFY1234', null, live), false); + assert.equal(identityStillMatches('TDFY1234', 'abc', live), false); + assert.equal(identityStillMatches('TDFY1234', '', { ...live, accountSize: 0 }), false); +}); + +test('pickedDayRulesDate: today is now; another day its start, never before Chicago\'s', () => { + const nowMs = Date.parse('2026-10-18T19:30:00.000Z'); + const at = (localMidnightMs, today = '2026-10-18') => ({ today, nowMs, localMidnightMs }); + // Today: now, whatever the zone. + assert.equal(pickedDayRulesDate('2026-10-18', at(Date.parse('2026-10-18T05:00:00Z'))), '2026-10-18T19:30:00.000Z'); + // Chicago browser: its own midnight. + assert.equal(pickedDayRulesDate('2026-10-15', at(Date.parse('2026-10-15T05:00:00Z'))), '2026-10-15T05:00:00.000Z'); + // Pacific browser: local midnight is later than Chicago's — stands. + assert.equal(pickedDayRulesDate('2026-10-15', at(Date.parse('2026-10-15T07:00:00Z'))), '2026-10-15T07:00:00.000Z'); + // London (BST), Eastern and Karachi browsers: raised to Chicago's midnight. + for (const local of ['2026-10-14T23:00:00Z', '2026-10-15T04:00:00Z', '2026-10-14T19:00:00Z']) { + assert.equal(pickedDayRulesDate('2026-10-15', at(Date.parse(local))), '2026-10-15T05:00:00.000Z', local); + } + assert.equal(pickedDayRulesDate('2026-02-31', at(0)), null); + assert.equal(pickedDayRulesDate('2026-10-15', at(NaN)), null); +}); + +test('pickedDayRulesDate: a picked day never lands inside the evidence of the day before', () => { + // A browser at UTC+1 picks Oct 15 for a set closing at Chicago midnight + // of Oct 15: an account first funded Oct 14 stays on the old rules, the + // hand-dated one on Oct 15 does not. + const kept = [{ id: 'g', openedAt: null, closedAt: chicagoDayStart('2026-10-15') }]; + const picked = pickedDayRulesDate('2026-10-15', { + today: '2026-10-18', nowMs: Date.parse('2026-10-18T12:00:00Z'), localMidnightMs: Date.parse('2026-10-14T23:00:00Z'), + }); + assert.equal(pickKeptRules(kept, evidenceRulesDate('2026-10-14'))?.id, 'g'); + assert.equal(pickKeptRules(kept, picked), null); + for (let ms = Date.UTC(2026, 0, 2); ms < Date.UTC(2027, 0, 1); ms += 86_400_000) { + const day = new Date(ms).toISOString().slice(0, 10); + const prev = new Date(ms - 86_400_000).toISOString().slice(0, 10); + for (const offsetH of [-10, -8, -6, -5, -4, 0, 1, 5, 9, 14]) { + const localMidnightMs = Date.parse(`${day}T00:00:00Z`) - offsetH * 3_600_000; + const stored = pickedDayRulesDate(day, { today: '2030-01-01', nowMs: 0, localMidnightMs }); + assert.ok(stored > evidenceRulesDate(prev), `${day} at UTC${offsetH}`); + } + } +}); + +test('unchangedDaySaves: an untouched day saves only when it moves the account to other rules', () => { + // A change at 2:05 PM CDT today (19:05Z) with the default cutoff "now". + const kept = [{ id: 'g', openedAt: null, closedAt: '2026-10-18T19:05:00.000Z' }]; + const at = { today: '2026-10-18', nowMs: Date.parse('2026-10-18T19:30:00Z'), localMidnightMs: Date.parse('2026-10-18T05:00:00Z') }; + // Dated this morning (old rules): ✓ on today stores now → current rules. + const now = pickedDayRulesDate('2026-10-18', at); + assert.equal(unchangedDaySaves(kept, '2026-10-18T15:00:00.000Z', now), true); + assert.equal(pickKeptRules(kept, now), null); + // Already after the change: nothing to save. + assert.equal(unchangedDaySaves(kept, '2026-10-18T19:10:00.000Z', now), false); + // A past day's start lands where the date already is: nothing to save. + const oct2 = pickedDayRulesDate('2026-10-02', at); + assert.equal(unchangedDaySaves(kept, evidenceRulesDate('2026-10-02'), oct2), false); + // Windows not known (still loading, no firm): never. + assert.equal(unchangedDaySaves(null, '2026-10-18T15:00:00.000Z', now), false); + // The editor opened on Oct 18 (current rules) and a poll since lowered the + // date to Oct 2: an untouched ✓ compares against the opened date, so it + // doesn't pin Oct 18 by hand over the evidence. + const sets = [{ id: 'g2', openedAt: null, closedAt: '2026-10-10T00:00:00.000Z' }]; + const oct18 = pickedDayRulesDate('2026-10-18', { ...at, today: '2026-10-19' }); + assert.equal(unchangedDaySaves(sets, '2026-10-18T15:00:00.000Z', oct18), false); +}); + +test('lowersRulesDate: only earlier, only auto dates — the test lowerAutoRulesDate runs in SQL', () => { + const evidence = '2026-10-01T23:59:59.999Z'; + assert.equal(lowersRulesDate({ rules_date: '2026-10-18T14:00:00.000Z', rules_date_manual: 0 }, evidence), true); + assert.equal(lowersRulesDate({ rules_date: null, rules_date_manual: 0 }, evidence), true); + assert.equal(lowersRulesDate({ rules_date: evidence, rules_date_manual: 0 }, evidence), false); + assert.equal(lowersRulesDate({ rules_date: '2026-09-01T00:00:00.000Z', rules_date_manual: 0 }, evidence), false); + // Set by hand: never moved. + assert.equal(lowersRulesDate({ rules_date: '2026-10-18T14:00:00.000Z', rules_date_manual: 1 }, evidence), false); + assert.equal(lowersRulesDate({ rules_date: null, rules_date_manual: 1 }, evidence), false); + // String order is time order for the two formats that are ever stored. + assert.equal('2026-10-01T23:59:59.999Z' < '2026-10-02T00:00:00.000Z', true); + assert.equal(evidenceRulesDate('2026-10-02') > new Date(Date.parse('2026-10-02T23:59:59.998Z')).toISOString(), true); +}); + +// ── the whole selection, as getAccountConfig runs it ──────────────────────── + +test('selection: generations, boundaries and clamps end to end', () => { + const sets = [ + keptOf({ profitTarget: 3000, consistency: 0.5 }, G1, 'g1'), + keptOf({ profitTarget: 2800, consistency: 0.45 }, G2, 'g2'), + ]; + const rulesFor = (rulesDate) => { + const kept = pickKeptRules(sets, rulesDate); + return kept ? overlayKept(LIVE, kept) : LIVE; + }; + const cases = [ + [null, 2500, undefined], + ['2026-09-01T00:00:00.000Z', 3000, 'g1'], + [G1.closedAt, 2800, 'g2'], + [G2.closedAt, 2500, undefined], + [evidenceRulesDate('2026-10-02'), 2800, 'g2'], + [evidenceRulesDate('2026-10-01'), 3000, 'g1'], + ]; + for (const [date, target, keptId] of cases) { + const eff = rulesFor(date); + assert.equal(eff.profitTarget, target, String(date)); + assert.equal(eff.keptRules?.id, keptId, String(date)); + // Always reported as the live type. + assert.equal(eff.id, LIVE.id); + } +}); diff --git a/scripts/check-trading-logic.mjs b/scripts/check-trading-logic.mjs index 9b122ae..d758d54 100644 --- a/scripts/check-trading-logic.mjs +++ b/scripts/check-trading-logic.mjs @@ -13,7 +13,7 @@ import { botStopsActive, sizeNotional, isNoTradeTime, isPastSessionClose, nextTradingDate, effectiveCombine, DEFAULT_TARGET_HOLD_HOURS, projectPerfectPath, cycleCompletion, DLL_RESERVE_FRACTION, DLL_MIN_STOP, notionalCap, expectedSwapToWin, MARGIN_HEADROOM, dllReserve, DLL_SLIPPAGE_FRACTION, maxLossRoom, lastStand, breachDistance, LAST_STAND_FRACTION, planLegSize, rankForTarget, - currentTradeDateCT, foldReason, + currentTradeDateCT, foldReason, withinFirmCaps, } from '../lib/trading-logic.ts'; // Deterministic PRNG so a failure reproduces. @@ -772,6 +772,33 @@ test('tick reasons fold their amounts, so like reasons count together', () => { assert.equal(foldReason('leg cap reached (5 today)'), 'leg cap reached (5 today)'); }); +test('withinFirmCaps: a firm\'s cap on accounts at once, in order, less what it holds', () => { + const topstep = { id: 'ts', maxConcurrentAccounts: 3 }; + const apex = { id: 'apex', maxConcurrentAccounts: 0 }; + const lucid = { id: 'lucid' }; + const items = [ + ...[1, 2, 3, 4, 5, 6].map((n) => ({ firm: topstep, n: `ts${n}` })), + ...[1, 2, 3, 4].map((n) => ({ firm: apex, n: `apex${n}` })), + { firm: lucid, n: 'lucid1' }, + ]; + const names = (xs) => xs.map((x) => x.n); + // 6 Topstep capped at 3 keep the first 3; uncapped firms keep everything. + assert.deepEqual(names(withinFirmCaps(items)), ['ts1', 'ts2', 'ts3', 'apex1', 'apex2', 'apex3', 'apex4', 'lucid1']); + // Two already open: one more slot. + assert.deepEqual(names(withinFirmCaps(items, new Map([['ts', 2]]))), ['ts1', 'apex1', 'apex2', 'apex3', 'apex4', 'lucid1']); + // At (or past) the cap: none. + assert.deepEqual(names(withinFirmCaps(items, new Map([['ts', 4]]))), ['apex1', 'apex2', 'apex3', 'apex4', 'lucid1']); + // Held counts don't touch an uncapped firm, and the input isn't mutated. + const held = new Map([['apex', 9]]); + assert.equal(withinFirmCaps(items, held).filter((x) => x.firm === apex).length, 4); + assert.equal(held.get('apex'), 9); + assert.equal(held.has('ts'), false); + // Interleaved order is kept. + const mixed = [{ firm: topstep, n: 'a' }, { firm: apex, n: 'b' }, { firm: topstep, n: 'c' }, { firm: topstep, n: 'd' }, { firm: topstep, n: 'e' }]; + assert.deepEqual(names(withinFirmCaps(mixed)), ['a', 'b', 'c', 'd']); + assert.deepEqual(withinFirmCaps([]), []); +}); + // ── fund events from the balance (TopstepX reconcileFunds) ────────────────── import { fundReconcileStep, FUND_CONFIRM_MS } from '../lib/trading-logic.ts'; diff --git a/scripts/seed-settings.js b/scripts/seed-settings.js index abd0662..c543c12 100644 --- a/scripts/seed-settings.js +++ b/scripts/seed-settings.js @@ -67,11 +67,13 @@ db.exec(` ); CREATE TABLE IF NOT EXISTS account_assignments ( - account_id INTEGER PRIMARY KEY, - firm_id TEXT NOT NULL, - account_type_id TEXT, - manual INTEGER NOT NULL DEFAULT 0, - resolved_at TEXT NOT NULL + account_id INTEGER PRIMARY KEY, + firm_id TEXT NOT NULL, + account_type_id TEXT, + manual INTEGER NOT NULL DEFAULT 0, + resolved_at TEXT NOT NULL, + rules_date TEXT, + rules_date_manual INTEGER NOT NULL DEFAULT 0 ); `); try { @@ -79,6 +81,36 @@ try { } catch { // Column already exists } +// The rules-date migration, as lib/db.ts runs it — including its backfill: +// adding the column here means lib/db.ts's own ALTER never runs on this file, +// and rows the restore doesn't overwrite must not stay undated. Dated as +// lib/db.ts undatedRowsDate dates them: now, never before the catalog's last +// change (a clock running behind must not date a row into an old rule set). +function undatedRowsDate() { + const row = db.prepare("SELECT value FROM settings WHERE key = 'catalog_updated_at'").get(); + const floor = row && typeof row.value === 'string' ? Date.parse(row.value) : NaN; + return new Date(Math.max(Date.now(), Number.isFinite(floor) ? floor : 0)).toISOString(); +} +// lib/kept-rules.ts sortableIso: the only rules dates stored are plain +// four-digit-year ISO strings from 2000 on, so string order is time order. +function sortableIso(ms) { + if (!Number.isFinite(ms) || ms < Date.UTC(2000, 0, 1) || Math.abs(ms) > 8.64e15) return null; + const iso = new Date(ms).toISOString(); + return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(iso) ? iso : null; +} +try { + db.transaction(() => { + db.exec('ALTER TABLE account_assignments ADD COLUMN rules_date TEXT'); + db.prepare('UPDATE account_assignments SET rules_date = ? WHERE rules_date IS NULL').run(undatedRowsDate()); + }).immediate(); +} catch { + // Column already exists +} +try { + db.exec('ALTER TABLE account_assignments ADD COLUMN rules_date_manual INTEGER NOT NULL DEFAULT 0'); +} catch { + // Column already exists +} const upsert = db.prepare( 'INSERT INTO settings (key, value) VALUES (?, ?) ' + @@ -134,14 +166,29 @@ async function restore() { 'INSERT INTO instruments (symbol, enabled) VALUES (?, ?) ' + 'ON CONFLICT(symbol) DO UPDATE SET enabled = excluded.enabled' ); - const setAssign = db.prepare(` - INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at) - VALUES (?, ?, ?, ?, ?) + // lib/db.ts restoreAssignment, by hand: the backup's rules date (and how + // it was set) wins; a backup from an older build has none, so an existing + // row keeps its own and a new one is dated at the restore. + const setAssignDated = db.prepare(` + INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at, rules_date, rules_date_manual) + VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(account_id) DO UPDATE SET firm_id = excluded.firm_id, account_type_id = excluded.account_type_id, manual = excluded.manual, - resolved_at = excluded.resolved_at + resolved_at = excluded.resolved_at, + rules_date = excluded.rules_date, + rules_date_manual = excluded.rules_date_manual + `); + const setAssignUndated = db.prepare(` + INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at, rules_date) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(account_id) DO UPDATE SET + firm_id = excluded.firm_id, + account_type_id = excluded.account_type_id, + manual = excluded.manual, + resolved_at = excluded.resolved_at, + rules_date = COALESCE(account_assignments.rules_date, excluded.rules_date) `); db.transaction(() => { @@ -162,7 +209,15 @@ async function restore() { setInstr.run(i.symbol, i.enabled ? 1 : 0); } for (const a of payload.assignments ?? []) { - setAssign.run(a.account_id, a.firm_id, a.account_type_id ?? null, a.manual ? 1 : 0, a.resolved_at ?? new Date().toISOString()); + const now = new Date().toISOString(); + const resolvedAt = a.resolved_at ?? now; + const rulesDate = typeof a.rules_date === 'string' ? sortableIso(Date.parse(a.rules_date)) : null; + if (rulesDate) { + setAssignDated.run(a.account_id, a.firm_id, a.account_type_id ?? null, a.manual ? 1 : 0, resolvedAt, + rulesDate, a.rules_date_manual === 1 ? 1 : 0); + } else { + setAssignUndated.run(a.account_id, a.firm_id, a.account_type_id ?? null, a.manual ? 1 : 0, resolvedAt, now); + } } })(); diff --git a/types.ts b/types.ts index 97fba4e..f68e9f0 100644 --- a/types.ts +++ b/types.ts @@ -1,4 +1,10 @@ import type { FirmPlatform } from './lib/master-protocol'; +import type { KeptRulesWindow } from './lib/kept-rules'; + +/** When one of an account type's old rule sets applies: accounts whose rules + * date is inside [openedAt, closedAt) keep those rules (openedAt null = no + * lower bound). `id` is the kept set's own, never an account type id. */ +export type { KeptRulesWindow }; export interface AccountConfig { /** Master's account type id — what a manual assignment pins to. */ @@ -36,6 +42,14 @@ export interface AccountConfig { tradesEveryDay?: boolean; } +/** An account type as the firm listing (/api/firms) carries it: the current + * rules, plus the windows of the old rule sets it keeps, so the dashboard can + * preview which rules a rules date lands on. */ +export interface FirmAccountType extends AccountConfig { + /** Sorted by closedAt; empty when every account is on the current rules. */ + kept: KeptRulesWindow[]; +} + export interface FirmConfig { /** Master's firm id (uuid). */ id: string; @@ -57,7 +71,7 @@ export interface FirmConfig { instrumentsSource: 'master' | 'legacy'; /** The firm's account types, straight from the catalog. Doubles as the * option list for a manual account-type override. */ - accounts: AccountConfig[]; + accounts: FirmAccountType[]; catalogUpdatedAt: string; } @@ -138,6 +152,20 @@ export interface AccountState { inferredAccountSize: number | null; /** True when more than one account type matched and updatedAt decided it. */ accountTypeAmbiguous: boolean; + /** ISO. Decides which of the type's rule sets the account trades on: one + * dated before a firm's rule change keeps the old rules. Defaults to when + * the account started (broker's first deposit / registration), else when + * this machine first saw it. null = no assignment row yet. */ + rulesDate: string | null; + /** True when the rules date was set by hand — broker evidence never moves it. */ + rulesDateManual: boolean; + /** The old rule set the account trades on; null = the type's current + * rules. accountType above already carries these rules. */ + keptRules: (KeptRulesWindow & { + /** Rule fields where the kept set differs from the type's current + * rules — raw values; the dashboard formats them. */ + differs: { field: string; kept: unknown; now: unknown }[]; + }) | null; } export interface FirmState {