Compare commits

..
3 Commits
Author SHA1 Message Date
Brandon LiandClaude Opus 5.5 c0899620d4 Old rules for older accounts, a rules date per account, and a firm cap
When the master changes an account type with "New accounts only", it now
serves the type's old rules as kept rule sets (firm.keptAccountRules), each
for accounts dated inside [openedAt, closedAt). This build applies them:

- getAccountConfig, the one rules lookup trading reads, puts a kept set's
  rule fields over the live type when the account's rules date falls in
  its window. Id, label, prefix, size and the projection fields stay the
  live type's, so every report still names the live type.
- account_assignments gains rules_date and rules_date_manual. A new row is
  dated when the machine first sees the account, on the master's clock
  when this one runs behind and never before the catalog's last change.
  Auto dates come down to the broker's evidence: the end of the first fund
  day in Chicago, or a DXtrade registration instant. Evidence is applied
  before an account's first trade. Rows from before the migration get its
  time; unpinning keeps the row and its date. Backups carry both columns.
- An account on a kept set stays on its type while its prefix and cached
  size still match, so a tie-break flip can't move it off its old rules.
- Kept entries parse through the same normalizer as live types (now
  normalizeAccountType in lib/kept-rules.ts); a malformed one rejects the
  sync like a malformed type. The cached catalog records the parser that
  stored it, and a cache from an older parser is fetched in full rather
  than trusted with ?since= (this also brings in maxConcurrentAccounts).
- Reports add keptRuleIds per firm and live counts per kept set per type.
- The dashboard row and the account page show the rules date next to the
  account name, with an "old rules" chip saying what differs. Clicking
  the date edits it inline (PUT /api/accounts/:id/rules-date), previewing
  which rules the date lands on.

Also: the master's per-firm cap on accounts traded at once
(maxConcurrentAccounts), applied on top of the machine's own max
concurrent when a batch is drawn and in copy trading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:21:55 -05:00
Brandon LiandClaude Opus 5.5 98016b3eb3 Seed the instruments table in one immediate transaction
`next build` imports lib/db.ts from several workers at once. On a fresh
machine they all saw an empty instruments table and raced to insert the
same symbols, failing the build with "UNIQUE constraint failed:
instruments.symbol". The emptiness check and the inserts now share one
IMMEDIATE transaction, and the insert is OR IGNORE as a backstop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:21:28 -05:00
Brandon LiandClaude Opus 5.5 d7d1e2cdae TopstepX: book a fund event only once a later pass still sees it
A sync reads the balance first and each account's positions and trades
after it, so a fill landing in between shows in the trades but not yet in
the balance, and for that one pass the books are off by the whole fill.
reconcileFunds booked that as a fund event, and the next pass booked the
reversal. Same-day rows merge, so the two netted to the commission drift
of the day and left a tiny row:

- 2026-10-05, Express 43788281 and 23275136: +3,001.16 "deposit" (balance
  -2.16 against the -3,001.16 stop-out already in the trades), then
  -3,002.16, merged to -1.00.
- 2026-10-08, combine 99745612: +2,474.01, then -2,492.01, left -18.00.
- 2026-09-24, five 150K combines on another instance: +3,049.45, then
  -3,059.45, left -10.00 each.

A negative row is a withdrawal. It started a new cycle, dropped its day
out of the cycle (the -3,003.32 on 10-05) and counted toward the stage:
both Express accounts traded drain days from 10-06 on a $1 "payout".

- fundReconcileStep (trading-logic): an unexplained balance is booked
  only when a check at least 30s after the first sighting still sees it,
  to within $5. A transient is gone by the next pass and books nothing.
- A transient that outlasts that, and so books, is taken back whole once
  its reversal nets the day under FUND_EVENT_MIN. The day's first booking
  saves what it replaced (fund_day_before), and dropFundTransaction
  deletes the row and puts last_fund_date and ended_cycle_start back in
  one transaction. A row reconcileFunds didn't book (an import) merges as
  before.
- funds_reconciled is set only once a check settles, so a first-check
  lump that waits on a second pass is still booked as the first check.

Existing tiny rows are left as they are; cleaning them up is a separate,
one-time step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:24:29 -05:00
25 changed files with 2425 additions and 190 deletions
+2
View File
@@ -17,6 +17,7 @@ import {
import type { FirmConfig, FirmState, AccountState, AccountConfig } from '@/types'; import type { FirmConfig, FirmState, AccountState, AccountConfig } from '@/types';
import { stagePillClasses } from '@/lib/stage-colors'; import { stagePillClasses } from '@/lib/stage-colors';
import { projectPerfectPath } from '@/lib/trading-logic'; 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 /** 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. */ * back to the auto-resolver. Mirrors the control on the dashboard. */
@@ -578,6 +579,7 @@ export default function AccountPage() {
onAssigned={() => { void load(); }} onAssigned={() => { void load(); }}
/> />
)} )}
<RulesDate account={account} firm={firm} onSaved={() => { void load(); }} />
<span className="text-slate-300">/</span> <span className="text-slate-300">/</span>
<h1 className="text-slate-900 font-bold text-sm">{account.name}</h1> <h1 className="text-slate-900 font-bold text-sm">{account.name}</h1>
{isDead && ( {isDead && (
+46
View File
@@ -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 });
}
+6 -2
View File
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; 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 { firmInstruments, instrumentsSource } from '@/lib/instruments';
import { getCredentials } from '@/lib/db'; import { getCredentials } from '@/lib/db';
@@ -25,7 +25,11 @@ export async function GET(
hasCredentials: !!creds, hasCredentials: !!creds,
instruments: firmInstruments(firm), instruments: firmInstruments(firm),
instrumentsSource: instrumentsSource(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, catalogUpdatedAt: firm.updatedAt,
}); });
} }
+11 -2
View File
@@ -1,8 +1,13 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { firmInstruments, instrumentsSource } from '@/lib/instruments'; 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 { 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 * 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 * The stored password is deliberately not returned. It used to be, and nothing
* ever read it. * 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() { export async function GET() {
try { try {
@@ -48,6 +56,7 @@ export async function GET() {
withdrawalStages: a.withdrawalStages, withdrawalStages: a.withdrawalStages,
dailyLossLimit: a.dailyLossLimit ?? 0, dailyLossLimit: a.dailyLossLimit ?? 0,
tradesEveryDay: a.tradesEveryDay === true, tradesEveryDay: a.tradesEveryDay === true,
kept: keptWindows(a.id),
})), })),
catalogUpdatedAt: firm.updatedAt, catalogUpdatedAt: firm.updatedAt,
}; };
+25 -2
View File
@@ -1,7 +1,8 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { loadDailyPnL } from '@/lib/db'; import { listAssignments, loadDailyPnL } from '@/lib/db';
import { getClients } from '@/lib/clients'; 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 { getAccountConfig, getInferredSize, matchAccountType } from '@/lib/account-type-resolver';
import { historyFresh } from '@/lib/platform-client'; import { historyFresh } from '@/lib/platform-client';
import { rankNextLeg } from '@/lib/leg-estimate'; import { rankNextLeg } from '@/lib/leg-estimate';
@@ -29,6 +30,11 @@ export async function GET() {
if (!client || client.accountList.length === 0) { if (!client || client.accountList.length === 0) {
return { firmId: f.id, firm: f.name, connected, accounts: [] }; 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 accounts = client.accountList.map((acc) => {
const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 }; const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
@@ -84,6 +90,20 @@ export async function GET() {
const ambiguous = cfg const ambiguous = cfg
? matchAccountType(acc.name, inferredAccountSize, f.accountTypes).ambiguous ? matchAccountType(acc.name, inferredAccountSize, f.accountTypes).ambiguous
: false; : 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 targetHit = false;
let dailyTarget: { amount: number; path: 'first_day' | 'normal_day' | 'reduced_day' } | null = null; let dailyTarget: { amount: number; path: 'first_day' | 'normal_day' | 'reduced_day' } | null = null;
let remainingToday: number | null = null; let remainingToday: number | null = null;
@@ -289,6 +309,9 @@ export async function GET() {
accountType: cfg, accountType: cfg,
inferredAccountSize, inferredAccountSize,
accountTypeAmbiguous: ambiguous, accountTypeAmbiguous: ambiguous,
rulesDate: resolved.rulesDate,
rulesDateManual: rulesDateByHand.has(acc.id),
keptRules,
}; };
}); });
return { firmId: f.id, firm: f.name, connected, accounts }; return { firmId: f.id, firm: f.name, connected, accounts };
+317
View File
@@ -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<string, string> = {
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<AccountState['keptRules']> }) {
const lines = keptRules.differs.length > 0
? keptRules.differs.map(describeDiff)
: ['Same as the type\'s current rules'];
return (
<span
className="inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-100 text-amber-700 shrink-0 font-sans whitespace-nowrap"
title={`Old rules — ${windowText(keptRules)}\n${lines.join('\n')}`}
>
old rules
</span>
);
}
/** 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<KeptRulesWindow[] | null>(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 (
<span className="inline-flex items-center gap-1 font-sans font-normal" onClick={(e) => e.stopPropagation()}>
<input
type="date"
value={draft}
max={localDay(new Date())}
autoFocus
disabled={saving}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => void save()}
disabled={saving}
className="px-1.5 py-0.5 bg-blue-100 hover:bg-blue-200 disabled:opacity-50 text-blue-700 text-[11px] font-semibold rounded-md transition-colors"
title="Save the rules date"
>
{saving ? '…' : '✓'}
</button>
<button
type="button"
onClick={() => setEditing(false)}
disabled={saving}
className="px-1.5 py-0.5 bg-slate-100 hover:bg-slate-200 disabled:opacity-50 text-slate-600 text-[11px] font-semibold rounded-md transition-colors"
title="Cancel"
>
✕
</button>
{preview && <span className="text-[11px] text-slate-400 whitespace-nowrap">{preview}</span>}
{error && <span className="text-[11px] text-red-500 whitespace-nowrap">{error}</span>}
</span>
);
}
return (
<span className="inline-flex items-center gap-1.5 font-sans font-normal">
<button
type="button"
onClick={open}
className="inline-flex items-center gap-1 text-[11px] text-slate-400 hover:text-slate-600 tabular-nums whitespace-nowrap transition-colors"
title={'Rules date — accounts dated before a firm\'s rule change keep the old rules. Click to change.\n'
+ (brokerDay
? `End of ${dayLabel(brokerDay, true)} in Chicago${account.rulesDateManual ? '' : ' — when the broker shows it opened'}`
: fullWhen(rulesDate))
+ (account.rulesDateManual ? ' (set by hand)' : '')}
>
{brokerDay ? dayLabel(brokerDay) : shortWhen(rulesDate)}
{account.rulesDateManual && (
<span className="w-1 h-1 rounded-full bg-blue-400 shrink-0" title="Set by hand" />
)}
</button>
{account.keptRules && <OldRulesChip keptRules={account.keptRules} />}
</span>
);
}
+23 -17
View File
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import type { FirmConfig, FirmState, AccountState } from '@/types'; import type { FirmConfig, FirmState, AccountState } from '@/types';
import { stagePillClasses } from '@/lib/stage-colors'; import { stagePillClasses } from '@/lib/stage-colors';
import { RulesDate } from '@/app/components/RulesDate';
type SortKey = 'name' | 'balance' | 'dayPnL' | 'daysTraded' | 'target' | 'status'; type SortKey = 'name' | 'balance' | 'dayPnL' | 'daysTraded' | 'target' | 'status';
type SortDir = 'asc' | 'desc'; 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; account: AccountState;
firm: FirmConfig; firm: FirmConfig;
hideDead: boolean; hideDead: boolean;
privacy: boolean; privacy: boolean;
onAssigned: () => void; onAssigned: () => void;
/** Refetches /api/state — after a rules date is saved. */
onStateChanged: () => void;
}) { }) {
const cfg = account.accountType; const cfg = account.accountType;
const dead = isAccountDead(account); const dead = isAccountDead(account);
@@ -359,8 +362,9 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned }: {
return ( return (
<tr className={`border-b border-slate-100 hover:bg-slate-50 transition-colors${!account.active || dead ? ' opacity-50' : ''}`}> <tr className={`border-b border-slate-100 hover:bg-slate-50 transition-colors${!account.active || dead ? ' opacity-50' : ''}`}>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-2 font-medium text-slate-800 pl-4 font-mono"> <div className="flex items-center gap-2 pl-4">
{maskName(account.name, privacy)} <span className="font-medium text-slate-800 font-mono">{maskName(account.name, privacy)}</span>
<RulesDate account={account} firm={firm} onSaved={onStateChanged} />
</div> </div>
</td> </td>
<td className="px-4 py-3 text-slate-600 tabular-nums">${fmt(account.amount)}</td> <td className="px-4 py-3 text-slate-600 tabular-nums">${fmt(account.amount)}</td>
@@ -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; state: FirmState;
firm: FirmConfig; firm: FirmConfig;
hideDead: boolean; hideDead: boolean;
@@ -449,6 +453,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
sortDir: SortDir; sortDir: SortDir;
tradeSymbol: string; tradeSymbol: string;
onChanged: () => void; onChanged: () => void;
onStateChanged: () => void;
}) { }) {
const [open, setOpen] = useState(true); const [open, setOpen] = useState(true);
const [confirmCreds, setConfirmCreds] = useState(false); const [confirmCreds, setConfirmCreds] = useState(false);
@@ -601,6 +606,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
hideDead={hideDead} hideDead={hideDead}
privacy={privacy} privacy={privacy}
onAssigned={onChanged} onAssigned={onChanged}
onStateChanged={onStateChanged}
/> />
)) ))
) )
@@ -673,6 +679,18 @@ export default function Home() {
fetchConfig(); 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 () => { const fetchScheduler = async () => {
try { try {
const res = await fetch('/api/auto-trade'); const res = await fetch('/api/auto-trade');
@@ -701,19 +719,6 @@ export default function Home() {
fetchConfig(); fetchConfig();
fetchCatalog(); fetchCatalog();
fetchScheduler(); 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(); fetchState();
const stateInterval = setInterval(fetchState, 5000); const stateInterval = setInterval(fetchState, 5000);
const schedulerInterval = setInterval(fetchScheduler, 5000); const schedulerInterval = setInterval(fetchScheduler, 5000);
@@ -965,6 +970,7 @@ export default function Home() {
sortDir={sortDir} sortDir={sortDir}
tradeSymbol={tradeSymbol} tradeSymbol={tradeSymbol}
onChanged={fetchConfig} onChanged={fetchConfig}
onStateChanged={fetchState}
/> />
); );
}) })
+165 -19
View File
@@ -9,31 +9,58 @@
* *
* An Unassigned account does not trade: every caller already guards on a * An Unassigned account does not trade: every caller already guards on a
* missing config, which is the behaviour we want. * 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 type { PlatformClient } from './platform-client';
import { firstFundDate } from './platform-client';
import { import {
getAssignment, getAssignment,
setAssignment, setAssignment,
setAutoAssignment, setAutoAssignment,
clearAssignment, unpinAssignment,
lowerAutoRulesDate,
stampMissingRulesDate,
listAssignments, listAssignments,
loadAccountMeta, loadAccountMeta,
saveAccountMeta, saveAccountMeta,
} from './db'; } 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'; 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<number> };
function evidenceApplied(): Set<number> {
return (ge.__rulesEvidenceApplied ??= new Set());
}
export type AssignmentSource = 'manual' | 'auto' | 'unassigned'; export type AssignmentSource = 'manual' | 'auto' | 'unassigned';
export interface ResolvedAssignment { export interface ResolvedAssignment {
/** Always the live account type's id — a kept rule set's never is one. */
accountTypeId: string | null; 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; source: AssignmentSource;
/** True when more than one account type matched and the tie-break decided. */ /** True when more than one account type matched and the tie-break decided. */
ambiguous: boolean; 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 = { const UNASSIGNED: ResolvedAssignment = {
@@ -41,8 +68,23 @@ const UNASSIGNED: ResolvedAssignment = {
accountType: null, accountType: null,
source: 'unassigned', source: 'unassigned',
ambiguous: false, 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<KeptAccountRules, { live: CatalogAccountType; effective: EffectiveAccountType }>();
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. /** The earliest positive fund transaction — the account's opening balance.
* Returns null while the reports fetch hasn't produced one yet, which is the * Returns null while the reports fetch hasn't produced one yet, which is the
* "wait until it is findable" case rather than an error. */ * "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. /** 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 { export function getAccountConfig(accountId: number): ResolvedAssignment {
const row = getAssignment(accountId); 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); const live = getAccountTypeById(row.account_type_id);
if (!accountType) { if (!live) {
// The account type was deleted on the master. A manual pin is kept so // 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 // that a master flap which recreates it restores the user's intent, but
// it reports as unassigned until then, so the account stops trading. // it reports as unassigned until then, so the account stops trading.
@@ -108,14 +157,20 @@ export function getAccountConfig(accountId: number): ResolvedAssignment {
accountType: null, accountType: null,
source: 'unassigned', source: 'unassigned',
ambiguous: false, ambiguous: false,
rulesDate,
keptRules: null,
}; };
} }
const kept = pickKeptRules(getKeptRulesFor(live.id), rulesDate);
const accountType: EffectiveAccountType = kept ? overlaid(live, kept) : live;
return { return {
accountTypeId: accountType.id, accountTypeId: live.id,
accountType, accountType,
source: row.manual === 1 ? 'manual' : 'auto', source: row.manual === 1 ? 'manual' : 'auto',
ambiguous: false, ambiguous: false,
rulesDate,
keptRules: accountType.keptRules ?? null,
}; };
} }
@@ -130,28 +185,106 @@ export function getInferredSize(accountId: number, client: PlatformClient): numb
return inferAccountSize(client.fundTransactions?.[accountId] ?? []); return inferAccountSize(client.fundTransactions?.[accountId] ?? []);
} }
/** Re-resolves every account on one firm's client. Skips manual overrides. /** The rules date a row first written now gets — on the master's clock when
* Called after each Tradovate sync and after the catalog changes. */ * 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( export function resolveFirmAssignments(
firmId: string, firmId: string,
client: PlatformClient, client: PlatformClient,
): { assigned: number; unassigned: number; manual: number } { ): { assigned: number; unassigned: number; manual: number } {
const firm = getCachedFirm(firmId); const firm = getCachedFirm(firmId);
const types = firm?.accountTypes ?? []; const types = firm?.accountTypes ?? [];
const stamp = freshRulesDate();
let assigned = 0; let assigned = 0;
let unassigned = 0; let unassigned = 0;
let manual = 0; let manual = 0;
for (const account of client.accountList) { 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++; manual++;
continue; continue;
} }
const fundTxns = client.fundTransactions?.[account.id] ?? []; const fundTxns = loadedFunds ?? [];
const cached = loadAccountMeta(account.id, INFERRED_SIZE_KEY); 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 matched: { type: CatalogAccountType | null; ambiguous: boolean } = { type: null, ambiguous: false };
let matchedSize: number | null = null; let matchedSize: number | null = null;
@@ -194,24 +327,37 @@ export function resolveFirmAssignments(
if (matchedSize !== null && cached === null) { if (matchedSize !== null && cached === null) {
saveAccountMeta(account.id, INFERRED_SIZE_KEY, String(matchedSize)); saveAccountMeta(account.id, INFERRED_SIZE_KEY, String(matchedSize));
} }
setAutoAssignment(account.id, firmId, matched.type.id); setAutoAssignment(account.id, firmId, matched.type.id, stamp);
assigned++; assigned++;
} else { } else {
setAutoAssignment(account.id, firmId, null); setAutoAssignment(account.id, firmId, null, stamp);
unassigned++; 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 }; return { assigned, unassigned, manual };
} }
export function setManualAssignment(accountId: number, firmId: string, accountTypeId: string | null): void { /** insertRulesDate dates the row when the pin creates it (default: now, as
setAssignment(accountId, firmId, accountTypeId, true); * 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 { export function clearManualAssignment(accountId: number): boolean {
return clearAssignment(accountId); return unpinAssignment(accountId);
} }
export { listAssignments }; export { listAssignments };
+19 -7
View File
@@ -9,7 +9,7 @@
import { getClients } from './clients'; import { getClients } from './clients';
import { getCachedFirm, getTradableFirms } from './firm-catalog'; import { getCachedFirm, getTradableFirms } from './firm-catalog';
import { getAccountConfig } from './account-type-resolver'; import { ensureRulesEvidence, getAccountConfig } from './account-type-resolver';
import { import {
combineCmeSessions, computeDayPlan, groupLegFraction, pickLegAmount, resolveEffectiveConfig, currentTradeDateCT, combineCmeSessions, computeDayPlan, groupLegFraction, pickLegAmount, resolveEffectiveConfig, currentTradeDateCT,
obfuscationStopDollars, takeProfitPlan, onTick, percentFee, dllRoom, dllStopDollars, dllViable, stopTicks, obfuscationStopDollars, takeProfitPlan, onTick, percentFee, dllRoom, dllStopDollars, dllViable, stopTicks,
@@ -18,7 +18,7 @@ import {
isNoTradeTime, isPastSessionClose, isNoTradeTime, isPastSessionClose,
BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY, BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY,
FALLBACK_COMMISSION_PER_CONTRACT, MAX_COMMISSION_PER_CONTRACT, DLL_MIN_STOP, 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, fundRowsKey, markFinishedCycle, unbookedPayoutDrop,
type DayPlan, type FinishedCycleMark, type DayPlan, type FinishedCycleMark,
} from './trading-logic'; } from './trading-logic';
@@ -666,6 +666,11 @@ function finishedCycleDrop(
*/ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
function evaluateAccountForTrade(client: any, acc: { id: number; name: string; active: boolean }, tradeDate: string): AccountEvaluation | null { 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; const cfg = getAccountConfig(acc.id).accountType;
if (!cfg) return null; if (!cfg) return null;
@@ -1037,7 +1042,9 @@ async function runTradeLocked(
// (largest group first), never mixed across prefixes — a group's remainder // (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 // 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 // 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. // (nothing listed fits, no contract resolves) yields to the next.
const groups = new Map<string, Candidate[]>(); const groups = new Map<string, Candidate[]>();
for (const item of allEligible) { for (const item of allEligible) {
@@ -1051,7 +1058,8 @@ async function runTradeLocked(
let batchPrefix = ''; let batchPrefix = '';
let resolvedSymbol = ''; let resolvedSymbol = '';
for (const [prefix, members] of ordered) { 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 lead = candidates.find((it) => !it.isExtraDay && !it.drain);
const firms = [...new Map(candidates.map((it) => [it.firm.id, it])).values()]; const firms = [...new Map(candidates.map((it) => [it.firm.id, it])).values()];
@@ -1566,7 +1574,8 @@ async function fireLeg(
// ── copy trade ─────────────────────────────────────────────────────────────── // ── 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 * Finds accounts with open positions, determines direction, then fires
* orders for eligible accounts that haven't traded yet — within the lane of * orders for eligible accounts that haven't traded yet — within the lane of
* the first open position found, never across lanes. * the first open position found, never across lanes.
@@ -1617,12 +1626,15 @@ async function copyTradeLocked(lease: number, lane: string): Promise<unknown[]>
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
let positionedClient: any = null; let positionedClient: any = null;
const positionedPrefixes = new Set<string>(); const positionedPrefixes = new Set<string>();
// Open positions by firm, against each firm's cap on accounts at once.
const heldByFirm = new Map<string, number>();
for (const { client } of ready) { for (const { firm, client } of ready) {
for (const acc of client.accountList) { for (const acc of client.accountList) {
const pos = client.positions[acc.id]; const pos = client.positions[acc.id];
if (!pos) continue; if (!pos) continue;
positionedCount++; positionedCount++;
heldByFirm.set(firm.id, (heldByFirm.get(firm.id) ?? 0) + 1);
const prefix = getAccountConfig(acc.id).accountType?.prefix; const prefix = getAccountConfig(acc.id).accountType?.prefix;
if (prefix) positionedPrefixes.add(prefix); if (prefix) positionedPrefixes.add(prefix);
if (!resolvedAction) { if (!resolvedAction) {
@@ -1718,7 +1730,7 @@ async function copyTradeLocked(lease: number, lane: string): Promise<unknown[]>
// accounts before falling back to mismatched prefixes. // accounts before falling back to mismatched prefixes.
const matchesPositioned = (item: LegItem) => positionedPrefixes.has(item.prefix); const matchesPositioned = (item: LegItem) => positionedPrefixes.has(item.prefix);
const ordered = [...eligible.filter(matchesPositioned), ...eligible.filter((i) => !matchesPositioned(i))]; 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) { if (batch.length === 0) {
console.log('[copy-trade] no eligible accounts to copy to'); console.log('[copy-trade] no eligible accounts to copy to');
return []; return [];
+4 -2
View File
@@ -19,7 +19,7 @@ import {
getSetting, getSetting,
setCredentials, setCredentials,
setInstrumentEnabled, setInstrumentEnabled,
setAssignment, restoreAssignment,
runInTransaction, runInTransaction,
} from './db'; } from './db';
import type { BackupPayload } from './backup-sync'; import type { BackupPayload } from './backup-sync';
@@ -116,8 +116,10 @@ export function applyBackup(payload: BackupPayload, opts: { keepMasterUrl?: bool
setInstrumentEnabled(i.symbol, i.enabled); setInstrumentEnabled(i.symbol, i.enabled);
counts.instruments++; 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 ?? []) { 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++; counts.assignments++;
} }
}); });
+4 -2
View File
@@ -26,7 +26,7 @@ import {
getInstruments, getInstruments,
listAllAssignments, listAllAssignments,
type FirmCredentialRow, type FirmCredentialRow,
type AssignmentRow, type BackupAssignmentRow,
} from './db'; } from './db';
export interface BackupPayload { export interface BackupPayload {
@@ -35,7 +35,9 @@ export interface BackupPayload {
settings: Record<string, string>; settings: Record<string, string>;
credentials: FirmCredentialRow[]; credentials: FirmCredentialRow[];
instruments: { symbol: string; enabled: boolean }[]; 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'; const BACKUP_PATH = '/api/instances/backup';
+184 -13
View File
@@ -1,6 +1,7 @@
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import { mkdirSync } from 'fs'; import { mkdirSync } from 'fs';
import path from 'path'; import path from 'path';
import { sortableIso, stampRulesDate } from './kept-rules';
// AUTOFIRMER_DB lets several instances share one checkout, each with its own // 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 // database. It is read once, at module load — schema creation and seeding are
@@ -78,6 +79,62 @@ try {
// Column already exists // 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 ───────────────────────────────────────────────────────── // ── Firm credentials ─────────────────────────────────────────────────────────
export interface FirmCredentialRow { export interface FirmCredentialRow {
@@ -133,8 +190,18 @@ export interface AssignmentRow {
account_type_id: string | null; account_type_id: string | null;
manual: number; // 0 | 1 manual: number; // 0 | 1
resolved_at: string; 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<AssignmentRow, 'rules_date' | 'rules_date_manual'>
& Partial<Pick<AssignmentRow, 'rules_date' | 'rules_date_manual'>>;
export function getAssignment(accountId: number): AssignmentRow | undefined { export function getAssignment(accountId: number): AssignmentRow | undefined {
return db.prepare('SELECT * FROM account_assignments WHERE account_id = ?').get(accountId) as 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[]; 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( export function setAssignment(
accountId: number, accountId: number,
firmId: string, firmId: string,
accountTypeId: string | null, accountTypeId: string | null,
manual: boolean, manual: boolean,
insertRulesDate?: string,
): void { ): void {
const now = new Date().toISOString();
db.prepare(` db.prepare(`
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at) INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at, rules_date)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(account_id) DO UPDATE SET ON CONFLICT(account_id) DO UPDATE SET
firm_id = excluded.firm_id, firm_id = excluded.firm_id,
account_type_id = excluded.account_type_id, account_type_id = excluded.account_type_id,
manual = excluded.manual, manual = excluded.manual,
resolved_at = excluded.resolved_at resolved_at = excluded.resolved_at,
`).run(accountId, firmId, accountTypeId, manual ? 1 : 0, new Date().toISOString()); 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. /** Writes an auto-resolved assignment, leaving any manual override alone.
* The `manual = 0` guard is what makes a user's pick stick across syncs. */ * 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); const existing = getAssignment(accountId);
if (existing?.manual === 1) return; if (existing?.manual === 1) return;
setAssignment(accountId, firmId, accountTypeId, false); setAssignment(accountId, firmId, accountTypeId, false, insertRulesDate);
} }
export function clearAssignment(accountId: number): boolean { /** Drops a manual pin but keeps the row — and with it the rules date, so
return db.prepare('DELETE FROM account_assignments WHERE account_id = ?').run(accountId).changes > 0; * 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 ───────────────────────────────────────────────────────────────── // ── Settings ─────────────────────────────────────────────────────────────────
@@ -247,12 +397,17 @@ const SYMBOLS = ['NQ','MNQ','ES','MES','YM','MYM','RTY','M2K','GC','MGC','SI','C
// from the Instruments page, they just start off. // from the Instruments page, they just start off.
const DEFAULT_ENABLED = new Set(['NQ', 'GC', 'CL']); const DEFAULT_ENABLED = new Set(['NQ', 'GC', 'CL']);
// Seed instruments table if empty // Seed instruments table if empty. Check and insert share one IMMEDIATE
const instrCount = (db.prepare('SELECT COUNT(*) as count FROM instruments').get() as { count: number }).count; // transaction: `next build` imports this module from several workers at once,
if (instrCount === 0) { // and on a fresh machine they all saw an empty table and raced to insert the
const ins = db.prepare('INSERT INTO instruments (symbol, enabled) VALUES (?, ?)'); // same symbols (UNIQUE constraint failed: instruments.symbol). OR IGNORE is
// the backstop if the lock is ever skipped.
db.transaction(() => {
const instrCount = (db.prepare('SELECT COUNT(*) as count FROM instruments').get() as { count: number }).count;
if (instrCount > 0) return;
const ins = db.prepare('INSERT OR IGNORE INTO instruments (symbol, enabled) VALUES (?, ?)');
for (const s of SYMBOLS) ins.run(s, DEFAULT_ENABLED.has(s) ? 1 : 0); for (const s of SYMBOLS) ins.run(s, DEFAULT_ENABLED.has(s) ? 1 : 0);
} }).immediate();
export interface InstrumentRow { export interface InstrumentRow {
symbol: string; symbol: string;
@@ -328,6 +483,22 @@ export function loadFundTransactions(accountId: number): { date: string; amount:
return db.prepare('SELECT date, amount FROM fund_transactions WHERE account_id = ? ORDER BY date').all(accountId) as { date: string; amount: number }[]; return db.prepare('SELECT date, amount FROM fund_transactions WHERE account_id = ? ORDER BY date').all(accountId) as { date: string; amount: number }[];
} }
/** Deletes one day's fund transaction and sets account meta keys back in the
* same transaction (null deletes the key), so no reader sees the row gone
* with the cycle it started still in place. TopstepX's reconcile uses it to
* take back a day whose bookings netted to fee noise. */
export function dropFundTransaction(accountId: number, date: string, meta: { [key: string]: string | null }): void {
const del = db.prepare('DELETE FROM account_meta WHERE account_id = ? AND key = ?');
const txn = db.transaction(() => {
db.prepare('DELETE FROM fund_transactions WHERE account_id = ? AND date = ?').run(accountId, date);
for (const [key, value] of Object.entries(meta)) {
if (value === null) del.run(accountId, key);
else saveAccountMeta(accountId, key, value);
}
});
txn();
}
// ── Account Meta ───────────────────────────────────────────────────────────── // ── Account Meta ─────────────────────────────────────────────────────────────
db.exec(` db.exec(`
+152 -47
View File
@@ -19,8 +19,10 @@
import { isFirmPlatform } from './master-protocol'; import { isFirmPlatform } from './master-protocol';
import { sanitizeInstruments, sanitizeInstrumentLimits, DEFAULT_MAX_ENTRY_SPREAD } from './instrument-specs'; import { sanitizeInstruments, sanitizeInstrumentLimits, DEFAULT_MAX_ENTRY_SPREAD } from './instrument-specs';
import { normalizeAccountType } from './kept-rules';
import type { import type {
CatalogAccountType, CatalogFirm, CatalogTradingSettings, FirmCatalogPayload, FirmPlatform, SymbolMap, SymbolMapCandidate, CatalogAccountType, CatalogFirm, CatalogTradingSettings, FirmCatalogPayload, FirmPlatform, KeptAccountRules, SymbolMap,
SymbolMapCandidate,
} from './master-protocol'; } from './master-protocol';
import { import {
getSetting, getSetting,
@@ -39,24 +41,50 @@ const FETCH_TIMEOUT_MS = 10_000;
const COLD_RETRY_MIN_MS = 10_000; const COLD_RETRY_MIN_MS = 10_000;
const COLD_RETRY_MAX_MS = 60_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 { interface CatalogCache {
payload: FirmCatalogPayload | null; payload: FirmCatalogPayload | null;
/** Index over every firm's account types, for the id lookup. */ /** Index over every firm's account types, for the id lookup. */
typesById: Map<string, CatalogAccountType>; typesById: Map<string, CatalogAccountType>;
/** 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<string, KeptAccountRules[]>;
} }
const g = global as typeof globalThis & { const g = global as typeof globalThis & {
__firmCatalog?: CatalogCache; __firmCatalog?: CatalogCache;
__firmCatalogTimer?: ReturnType<typeof setTimeout>; __firmCatalogTimer?: ReturnType<typeof setTimeout>;
__firmCatalogColdDelay?: number; __firmCatalogColdDelay?: number;
/** The master's clock minus this one, from the last sync's Date header. */
__masterOffsetMs?: number;
}; };
function indexPayload(payload: FirmCatalogPayload | null): CatalogCache { function indexPayload(payload: FirmCatalogPayload | null): CatalogCache {
const typesById = new Map<string, CatalogAccountType>(); const typesById = new Map<string, CatalogAccountType>();
const keptByLiveId = new Map<string, KeptAccountRules[]>();
for (const firm of payload?.firms ?? []) { for (const firm of payload?.firms ?? []) {
for (const type of firm.accountTypes) typesById.set(type.id, type); 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 { function cache(): CatalogCache {
@@ -66,7 +94,14 @@ function cache(): CatalogCache {
const raw = getSetting('catalog_payload'); const raw = getSetting('catalog_payload');
if (raw) { if (raw) {
try { 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 { } catch {
console.error('[catalog] cached payload is unreadable — ignoring it'); 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); 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(): { export function getCatalogMeta(): {
version: number; version: number;
updatedAt: string | null; updatedAt: string | null;
@@ -131,7 +197,10 @@ export function getTradableFirms(): (CatalogFirm & { username: string; password:
// ── Sync ───────────────────────────────────────────────────────────────────── // ── Sync ─────────────────────────────────────────────────────────────────────
const g2 = global as typeof globalThis & { __catalogUnknownPlatformWarned?: Set<string> }; const g2 = global as typeof globalThis & {
__catalogUnknownPlatformWarned?: Set<string>;
__catalogKeptSkipWarned?: Set<string>;
};
/** Once per firm per process — the catalog is re-validated every 60s. */ /** Once per firm per process — the catalog is re-validated every 60s. */
function warnUnknownPlatform(id: string, name: string, platform: unknown): void { function warnUnknownPlatform(id: string, name: string, platform: unknown): void {
@@ -164,12 +233,41 @@ function sanitizeSymbolMap(raw: unknown): SymbolMap {
return map; return map;
} }
function isStages(value: unknown): value is { profit: number; consistency: number; minTradingDays: number }[] { function isDateString(value: unknown): value is string {
return Array.isArray(value) && value.every((s) => return typeof value === 'string' && Number.isFinite(Date.parse(value));
typeof s === 'object' && s !== null }
&& typeof (s as Record<string, unknown>).profit === 'number'
&& typeof (s as Record<string, unknown>).consistency === 'number' /**
&& typeof (s as Record<string, unknown>).minTradingDays === 'number'); * 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<string, unknown>;
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 /** 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; const tradesEveryDay = dx && firm.tradesEveryDay === true;
if (!Array.isArray(firm.accountTypes)) throw new Error(`firm ${firm.name} is missing accountTypes`); if (!Array.isArray(firm.accountTypes)) throw new Error(`firm ${firm.name} is missing accountTypes`);
const accountTypes: CatalogAccountType[] = firm.accountTypes.map((t: unknown, j: number) => { 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`); normalizeAccountType(t, { tradesEveryDay, where: `account type ${j}` }));
const type = t as Record<string, unknown>; // Only when the master sent the list: absent = a master from before
for (const key of ['id', 'firmId', 'label', 'prefix', 'updatedAt']) { // kept rules, [] = every account on the current rules.
if (typeof type[key] !== 'string') throw new Error(`account type ${j} is missing ${key}`); const keptAccountRules = Array.isArray(firm.keptAccountRules)
} ? parseKeptRules({ id: firm.id, name: firm.name }, firm.keptAccountRules, accountTypes, tradesEveryDay)
for (const key of [ : undefined;
'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;
});
firms.push({ firms.push({
id: firm.id, id: firm.id,
@@ -248,6 +320,7 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
...(platform !== 'dxtrade' ? { instrumentLimits: sanitizeInstrumentLimits(firm.instrumentLimits) } : {}), ...(platform !== 'dxtrade' ? { instrumentLimits: sanitizeInstrumentLimits(firm.instrumentLimits) } : {}),
bannedSymbols: Array.isArray(firm.bannedSymbols) ? (firm.bannedSymbols as string[]) : [], bannedSymbols: Array.isArray(firm.bannedSymbols) ? (firm.bannedSymbols as string[]) : [],
accountTypes, accountTypes,
...(keptAccountRules ? { keptAccountRules } : {}),
serverUrl: typeof firm.serverUrl === 'string' ? firm.serverUrl : '', serverUrl: typeof firm.serverUrl === 'string' ? firm.serverUrl : '',
loginDomain: typeof firm.loginDomain === 'string' ? firm.loginDomain : '', loginDomain: typeof firm.loginDomain === 'string' ? firm.loginDomain : '',
commissionPct: dx ? nonNegative(firm.commissionPct) : 0, commissionPct: dx ? nonNegative(firm.commissionPct) : 0,
@@ -255,12 +328,24 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
symbolMap: sanitizeSymbolMap(firm.symbolMap), symbolMap: sanitizeSymbolMap(firm.symbolMap),
targetHoldHours: nonNegative(firm.targetHoldHours), targetHoldHours: nonNegative(firm.targetHoldHours),
financingPctPerDay: nonNegative(firm.financingPctPerDay), 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(), updatedAt: typeof firm.updatedAt === 'string' ? firm.updatedAt : new Date(0).toISOString(),
}); });
}); });
const trading = parseTradingSettings(body.trading); 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<string, unknown>).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 /** 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()); 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 /** 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 * every credential row orphans. Match the survivors up by name (once) rather
* than making someone retype every password. */ * 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. // before anyone has got round to naming it.
// `since` only against the master the cache came from: two masters' // `since` only against the master the cache came from: two masters'
// version numbers can collide, and an 'unchanged' from the wrong one // 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 hasCache = getCachedCatalog().length > 0;
const source = url.origin; 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 { try {
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
if (!res.ok) throw new Error(`master returned ${res.status}`); if (!res.ok) throw new Error(`master returned ${res.status}`);
recordMasterClock(res.headers.get('date'));
const body = await res.json(); const body = await res.json();
if (body && typeof body === 'object' && (body as Record<string, unknown>).unchanged === true) { if (body && typeof body === 'object' && (body as Record<string, unknown>).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 // 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 previous = new Map(getCachedCatalog().map((f) => [f.id, f]));
const suspicious: string[] = []; const suspicious: string[] = [];
for (const firm of payload.firms) { for (const firm of payload.firms) {
const before = previous.get(firm.id); const before = previous.get(firm.id);
if (firm.accountTypes.length === 0 && before && before.accountTypes.length > 0) { if (firm.accountTypes.length === 0 && before && before.accountTypes.length > 0) {
firm.accountTypes = before.accountTypes; firm.accountTypes = before.accountTypes;
if (before.keptAccountRules) firm.keptAccountRules = before.keptAccountRules;
else delete firm.keptAccountRules;
suspicious.push(firm.name); suspicious.push(firm.name);
} }
} }
+403
View File
@@ -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<string, unknown>).profit === 'number'
&& typeof (s as Record<string, unknown>).consistency === 'number'
&& typeof (s as Record<string, unknown>).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<string, unknown>;
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<T extends { openedAt: string | null; closedAt: string }>(
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<string, unknown>;
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<string, unknown>;
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<string, unknown>).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<CatalogAccountType, RuleField>,
live: Pick<CatalogAccountType, RuleField>,
): { 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<string, number> = {};
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<CatalogAccountType, 'prefix' | 'accountSize'>,
): 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);
}
+36
View File
@@ -81,6 +81,10 @@ export interface AccountTypeStats {
stage: number; drain?: boolean; 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 { export interface FirmStats {
@@ -139,6 +143,10 @@ export interface FirmStats {
/** The catalog firm's uuid (statusVersion builds), so the master needn't /** The catalog firm's uuid (statusVersion builds), so the master needn't
* resolve the name. Optional. */ * resolve the name. Optional. */
firmId?: string; 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 /** ISO time the firm's last good sync finished (statusVersion builds), sent
* once synced. The statuses count only while it is recent. Optional. */ * once synced. The statuses count only while it is recent. Optional. */
syncedAt?: string; syncedAt?: string;
@@ -381,6 +389,22 @@ export interface CatalogAccountType {
updatedAt: string; 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 /** 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. */ * here; the catalog sync skips a firm on any other rather than guess. */
export const FIRM_PLATFORMS = ['tradovate', 'topstepx', 'dxtrade'] as const; 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 /** 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. */ * matches the extension's tab against it to find a person's 2FA code. */
siteUrl?: string; 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 /** Older masters' way to exclude roots; newer ones derive it from
* `instruments`. */ * `instruments`. */
bannedSymbols: string[]; bannedSymbols: string[];
accountTypes: CatalogAccountType[]; 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). */ /** DXTrade REST base, e.g. https://dx.tradeify247.co/dxsca-web ('' otherwise). */
serverUrl?: string; serverUrl?: string;
/** DXTrade login domain ('' = 'default'). */ /** DXTrade login domain ('' = 'default'). */
@@ -458,6 +489,11 @@ export interface FirmCatalogPayload {
firms: CatalogFirm[]; firms: CatalogFirm[];
/** Absent from a master from before its Settings page. */ /** Absent from a master from before its Settings page. */
trading?: CatalogTradingSettings; 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 { export interface FirmCatalogUnchanged {
+35 -12
View File
@@ -302,30 +302,43 @@ const cents = (n: number) => Math.round(n * 100) / 100;
/** Epoch ms a Date can hold — formatting anything else throws. */ /** 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; 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 * 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 * 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 * socket's 1 MB frame. firstFundDate: see the function of that name;
* DXTrade account with none falls back to its registration date, in Chicago); * positiveFunds counts the positive fund rows, and both wait for the fund
* positiveFunds counts those rows, and both wait for the fund history. * history.
*/ */
export function accountStatusFields(input: AccountReportInput): Omit<ReportedAccount, 'name' | 'size' | 'payoutable' | 'withdrawals'> { export function accountStatusFields(input: AccountReportInput): Omit<ReportedAccount, 'name' | 'size' | 'payoutable' | 'withdrawals'> {
const { status, reason } = accountStatus(input); const { status, reason } = accountStatus(input);
const funds = input.fundTransactions; const positive = input.fundTransactions?.filter((f) => f.amount > 0);
const positive = funds?.filter((f) => f.amount > 0); const firstFund = firstFundDate(input.fundTransactions, input.registeredAt);
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));
}
return { return {
status, status,
...(reason ? { reason } : {}), ...(reason ? { reason } : {}),
...(typeof input.cash === 'number' && Number.isFinite(input.cash) ? { balance: cents(input.cash) } : {}), ...(typeof input.cash === 'number' && Number.isFinite(input.cash) ? { balance: cents(input.cash) } : {}),
...(Number.isFinite(input.floor) && input.floor !== 0 ? { floor: cents(input.floor) } : {}), ...(Number.isFinite(input.floor) && input.floor !== 0 ? { floor: cents(input.floor) } : {}),
...(input.accountTypeId ? { accountTypeId: input.accountTypeId } : {}), ...(input.accountTypeId ? { accountTypeId: input.accountTypeId } : {}),
...(firstFundDate ? { firstFundDate } : {}), ...(firstFund ? { firstFundDate: firstFund } : {}),
...(positive ? { positiveFunds: positive.length } : {}), ...(positive ? { positiveFunds: positive.length } : {}),
...(input.brokerStatus ? { brokerStatus: input.brokerStatus } : {}), ...(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<FirmStats, 'keptRuleIds'> {
return keptRuleIds ? { keptRuleIds } : {};
}
/** /**
* FirmStats for a firm with no accounts to walk — no client yet, or an empty * 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 * 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( export function emptyFirmStats(
firm: { id: string; name: string }, firm: { id: string; name: string },
client: { syncComplete: boolean; lastSyncAt: number } | undefined, client: { syncComplete: boolean; lastSyncAt: number } | undefined,
keptRuleIds: string[] | null = null,
): FirmStats { ): FirmStats {
const synced = client?.syncComplete === true; const synced = client?.syncComplete === true;
return { return {
@@ -360,6 +382,7 @@ export function emptyFirmStats(
...(synced ? { accounts: [] } : {}), ...(synced ? { accounts: [] } : {}),
accountTypes: [], accountTypes: [],
...firmStatusFields(firm.id, synced, client?.lastSyncAt ?? 0), ...firmStatusFields(firm.id, synced, client?.lastSyncAt ?? 0),
...keptRuleIdsField(keptRuleIds),
}; };
} }
+20 -7
View File
@@ -17,14 +17,14 @@ import { getBuildVersion } from './version';
import { tradeMixFor } from './trade-mix'; import { tradeMixFor } from './trade-mix';
import { updaterStatus } from './updater-status'; import { updaterStatus } from './updater-status';
import { getClients } from './clients'; import { getClients } from './clients';
import { getTradableFirms } from './firm-catalog'; import { getAccountTypeById, getKeptRuleIdsForFirm, getTradableFirms } from './firm-catalog';
import { getAccountConfig } from './account-type-resolver'; import { getAccountConfig } from './account-type-resolver';
import { getSchedulerStatus } from './auto-trade'; 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 { 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 type { AccountTypeStats, FirmStats, InstrumentStats, SymbolStats } from './master-protocol';
import { accountNextLeg, accountLegShape } from './leg-estimate'; import { accountNextLeg, accountLegShape } from './leg-estimate';
import { instrumentStatsFor, isFuturesFirm } from './instruments'; 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() { function collectFirmStats() {
// Only firms this machine has credentials for. A firm it cannot log into // Only firms this machine has credentials for. A firm it cannot log into
@@ -44,8 +44,10 @@ function collectFirmStats() {
return firms.map((f) => { return firms.map((f) => {
const client = clients.get(f.id); 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). // 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 totalAccounts = 0;
let accountsTraded = 0; let accountsTraded = 0;
@@ -63,16 +65,19 @@ function collectFirmStats() {
// Per account type, so the master can say what the accounts still to // Per account type, so the master can say what the accounts still to
// trade actually are. Keyed by account type id; unassigned accounts // trade actually are. Keyed by account type id; unassigned accounts
// share the null bucket — they inflate "left to trade" indefinitely, // 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<string | null, AccountTypeStats>(); const byType = new Map<string | null, AccountTypeStats>();
const bucket = (cfg: { id: string; label: string; accountSize: number } | null): AccountTypeStats => { const bucket = (cfg: { id: string; label: string; accountSize: number } | null): AccountTypeStats => {
const key = cfg?.id ?? null; const key = cfg?.id ?? null;
let entry = byType.get(key); let entry = byType.get(key);
if (!entry) { if (!entry) {
const live = key ? getAccountTypeById(key) ?? cfg : null;
entry = { entry = {
accountTypeId: key, accountTypeId: key,
label: cfg?.label ?? 'Unassigned', label: live?.label ?? 'Unassigned',
accountSize: cfg?.accountSize ?? 0, accountSize: live?.accountSize ?? 0,
totalAccounts: 0, totalAccounts: 0,
accountsTraded: 0, accountsTraded: 0,
inTrade: 0, inTrade: 0,
@@ -89,7 +94,8 @@ function collectFirmStats() {
const reportedCash = client.accountCashBalances[acc.id]; const reportedCash = client.accountCashBalances[acc.id];
const cash = reportedCash ?? { amount: 0, realizedPnL: 0 }; const cash = reportedCash ?? { amount: 0, realizedPnL: 0 };
const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 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 // undefined until the account's fund history has loaded. The master
// clears a completed cycle once the withdrawal it was waiting for // clears a completed cycle once the withdrawal it was waiting for
// shows up here, so an account that hasn't loaded reports no count // shows up here, so an account that hasn't loaded reports no count
@@ -122,6 +128,12 @@ function collectFirmStats() {
totalAccounts++; totalAccounts++;
slice.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, // Same combined-day view as the trade path: for a combining firm,
// "today" spans the holiday stretch. Computed for every live // "today" spans the holiday stretch. Computed for every live
@@ -399,6 +411,7 @@ function collectFirmStats() {
...instrumentStatsBlock(f, client, rawToday), ...instrumentStatsBlock(f, client, rawToday),
mix: tradeMixFor(f, client), mix: tradeMixFor(f, client),
...firmStatusFields(f.id, client.syncComplete, client.lastSyncAt), ...firmStatusFields(f.id, client.syncComplete, client.lastSyncAt),
...keptRuleIdsField(keptRuleIds),
}; };
}); });
} }
+85 -24
View File
@@ -24,10 +24,10 @@
import axios from 'axios'; import axios from 'axios';
import * as signalR from '@microsoft/signalr'; import * as signalR from '@microsoft/signalr';
import { currentTradeDateCT } from './trading-logic'; import { currentTradeDateCT, FUND_EVENT_MIN, fundReconcileStep, type FundSighting } from './trading-logic';
import { getCachedContract } from './contract-resolver'; import { getCachedContract } from './contract-resolver';
import { signalrWebSocket } from './outbound-proxy'; import { signalrWebSocket } from './outbound-proxy';
import { saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions, saveEndedCycleStart } from './db'; import { saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions, saveEndedCycleStart, dropFundTransaction } from './db';
import { getAccountConfig } from './account-type-resolver'; import { getAccountConfig } from './account-type-resolver';
import { topstepxProductCode, topstepxRoot, topstepCommission, TOPSTEP_COMMISSION_FALLBACK } from './topstep-fees'; import { topstepxProductCode, topstepxRoot, topstepCommission, TOPSTEP_COMMISSION_FALLBACK } from './topstep-fees';
import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth } from './platform-client'; import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth } from './platform-client';
@@ -73,10 +73,10 @@ const TOKEN_LIFETIME_MS = 24 * 60 * 60 * 1000;
* minutes after its caller gave up on it. Longer than the idle timeout, so * minutes after its caller gave up on it. Longer than the idle timeout, so
* it only bites where that one can't. */ * it only bites where that one can't. */
const REST_DEADLINE_MS = 90_000; const REST_DEADLINE_MS = 90_000;
/** Balance the books don't explain past this is a fund event (reconcileFunds /** account_meta: what the day's first fund booking replaced (its date, and
* books it); under it, fee noise and rounding. The smallest real withdrawal * last_fund_date and ended_cycle_start as they stood), so a reversal that
* in the drain flow is well above it. */ * nets the day to fee noise can put them back. */
const FUND_EVENT_MIN = 200; const FUND_DAY_BEFORE_META = 'fund_day_before';
/** Rejects if `promise` hasn't settled within `ms`. The underlying operation /** Rejects if `promise` hasn't settled within `ms`. The underlying operation
* keeps running (callers discard whatever it belonged to); Promise.race * keeps running (callers discard whatever it belonged to); Promise.race
@@ -252,6 +252,9 @@ export class TopstepXClient implements PlatformClient {
/** Accounts unbookedBalanceChange has warned about, until their books /** Accounts unbookedBalanceChange has warned about, until their books
* settle — one line per episode, not one per tick. */ * settle — one line per episode, not one per tick. */
private unbookedWarned = new Set<number>(); private unbookedWarned = new Set<number>();
/** Unexplained balances reconcileFunds has seen but not booked yet: one
* is booked only once a later check still sees it (fundReconcileStep). */
private fundSightings = new Map<number, FundSighting>();
/** See PlatformClient: when the last good pass started reading. */ /** See PlatformClient: when the last good pass started reading. */
public lastSyncStartedAt = 0; public lastSyncStartedAt = 0;
/** Accounts whose last order ended with its outcome unknown: each sync /** Accounts whose last order ended with its outcome unknown: each sync
@@ -1039,30 +1042,73 @@ export class TopstepXClient implements PlatformClient {
* and starts a new cycle; the very first check also books any historical * and starts a new cycle; the very first check also books any historical
* net withdrawal in one lump (fine — the last stage repeats). * net withdrawal in one lump (fine — the last stage repeats).
* *
* FUND_EVENT_MIN keeps fee noise and rounding out. Skipped while a * FUND_EVENT_MIN keeps fee noise and rounding out. An amount is booked
* position is open (fills in flight) or while the trade history is * only once a later check still sees it: for one pass, a fill landing
* known-stale; until it books, unbookedBalanceChange holds the account. * between the balance read and the trade read looks just like a fund
* event (fundReconcileStep). Skipped while a position is open (fills in
* flight) or while the trade history is known-stale; until it books,
* unbookedBalanceChange holds the account.
*/ */
/** Books a withdrawal/deposit the trades can't explain. True once it has /** Books a withdrawal/deposit the trades can't explain. True once it has
* actually checked the books against the balance (false when it had to * actually checked the books against the balance (false when it had to
* skip: a position open, no history or balance yet, no account type). */ * skip: a position open, no history or balance yet, no account type; or
* when what it saw waits on a later check to be booked). */
private reconcileFunds(account: PlatformAccount): boolean { private reconcileFunds(account: PlatformAccount): boolean {
if (this.positions[account.id]) return false; const delta = this.positions[account.id] || this.lastFetchErrors[account.name]
if (this.lastFetchErrors[account.name]) return false; ? null
const delta = this.unbookedBalance(account.id); : this.unbookedBalance(account.id);
if (delta === null) return false; if (delta === null) {
// A sighting is confirmed by the next check, not one after a skip.
this.fundSightings.delete(account.id);
return false;
}
const cash = this.accountCashBalances[account.id]!; const cash = this.accountCashBalances[account.id]!;
const startBal = getAccountConfig(account.id).accountType!.accountSize; const startBal = getAccountConfig(account.id).accountType!.accountSize;
const today = currentTradeDateCT(); const today = currentTradeDateCT();
const txns = this.fundTransactions[account.id] ?? []; const txns = this.fundTransactions[account.id] ?? [];
const existingToday = txns.find((f) => f.date === today)?.amount ?? null;
const before = this.fundDayBefore(account.id, today);
const step = fundReconcileStep({
delta,
sighting: this.fundSightings.get(account.id) ?? null,
now: Date.now(),
existingToday,
undoable: before !== null,
});
const books = Math.round((cash.amount - startBal - delta) * 100) / 100;
if (step.kind === 'unconfirmed') {
this.fundSightings.set(account.id, step.sighting);
if (step.fresh) console.log(`[topstepx] ${account.name}: balance ${cash.amount} vs books ${books} — ${delta} unexplained; booked if a later pass still sees it`);
return false;
}
this.fundSightings.delete(account.id);
// The first check on this instance (or since a wipe) only has the // The first check on this instance (or since a wipe) only has the
// fetch window to explain the balance with: a lump it books is unseen // fetch window to explain the balance with: a lump it books is unseen
// P&L and past payouts, not the end of a cycle this instance watched. // P&L and past payouts, not the end of a cycle this instance watched.
const firstCheck = loadAccountMeta(account.id, 'funds_reconciled') === null; const firstCheck = loadAccountMeta(account.id, 'funds_reconciled') === null;
if (firstCheck) saveAccountMeta(account.id, 'funds_reconciled', today); if (firstCheck) saveAccountMeta(account.id, 'funds_reconciled', today);
if (Math.abs(delta) < FUND_EVENT_MIN) return true; if (step.kind === 'settled') return true;
console.log(`[topstepx] ${account.name}: balance ${cash.amount} vs books ${Math.round((cash.amount - startBal - delta) * 100) / 100} — recording ${delta < 0 ? 'withdrawal' : 'deposit'} of ${delta}`); if (step.kind === 'drop') {
// A transient booked today and then reversed: the cycle it started
// never happened, so the markers go back to what it replaced.
console.log(`[topstepx] ${account.name}: balance ${cash.amount} vs books ${books} — today's fund bookings net to ${step.net}, a transient and its reversal; taking today's row back`);
dropFundTransaction(account.id, today, {
last_fund_date: before!.lastFundDate,
ended_cycle_start: before!.endedCycleStart,
[FUND_DAY_BEFORE_META]: null,
});
this.lastFundDates[account.id] = before!.lastFundDate;
} else {
console.log(`[topstepx] ${account.name}: balance ${cash.amount} vs books ${books} — recording ${delta < 0 ? 'withdrawal' : 'deposit'} of ${delta}`);
// The day's first booking: keep what it replaces.
if (existingToday === null) {
saveAccountMeta(account.id, FUND_DAY_BEFORE_META, JSON.stringify({
date: today,
lastFundDate: loadAccountMeta(account.id, 'last_fund_date'),
endedCycleStart: loadAccountMeta(account.id, 'ended_cycle_start'),
}));
}
// A new cycle starts today: note where the one it ends began, by // A new cycle starts today: note where the one it ends began, by
// applyCycleSlice's rule for that date's own row, for the reporter's // applyCycleSlice's rule for that date's own row, for the reporter's
// catch-up. A second event today doesn't start another cycle. // catch-up. A second event today doesn't start another cycle.
@@ -1071,19 +1117,33 @@ export class TopstepXClient implements PlatformClient {
const prevAmt = prevFundDate ? (txns.find((f) => f.date === prevFundDate)?.amount ?? null) : null; const prevAmt = prevFundDate ? (txns.find((f) => f.date === prevFundDate)?.amount ?? null) : null;
saveEndedCycleStart(account.id, { fundDate: prevFundDate ?? '', includesDay: !(prevAmt !== null && prevAmt < 0) }); saveEndedCycleStart(account.id, { fundDate: prevFundDate ?? '', includesDay: !(prevAmt !== null && prevAmt < 0) });
} }
// Same-day events merge into one row (the table keys on account+date), // Same-day events merge into one row (the table keys on
// so add today's delta onto whatever is already booked for today. // account+date): step.amount is today's delta on top of whatever
const existingToday = txns.find((f) => f.date === today)?.amount ?? 0; // is already booked for today.
saveFundTransactions(account.id, account.name, [ saveFundTransactions(account.id, account.name, [{ date: today, amount: step.amount }]);
{ date: today, amount: Math.round((existingToday + delta) * 100) / 100 },
]);
saveAccountMeta(account.id, 'last_fund_date', today); saveAccountMeta(account.id, 'last_fund_date', today);
this.fundTransactions[account.id] = loadFundTransactions(account.id);
this.lastFundDates[account.id] = today; this.lastFundDates[account.id] = today;
}
this.fundTransactions[account.id] = loadFundTransactions(account.id);
this.applyCycleSlice(account.id); this.applyCycleSlice(account.id);
return true; return true;
} }
/** What today's first fund booking replaced — null when today has none
* that reconcileFunds made (none yet, or a row it didn't book). */
private fundDayBefore(accountId: number, today: string): { lastFundDate: string | null; endedCycleStart: string | null } | null {
const raw = loadAccountMeta(accountId, FUND_DAY_BEFORE_META);
if (raw === null) return null;
try {
const saved = JSON.parse(raw) as { date?: unknown; lastFundDate?: unknown; endedCycleStart?: unknown };
const strOrNull = (v: unknown) => v === null || typeof v === 'string';
if (saved.date !== today || !strOrNull(saved.lastFundDate) || !strOrNull(saved.endedCycleStart)) return null;
return { lastFundDate: saved.lastFundDate as string | null, endedCycleStart: saved.endedCycleStart as string | null };
} catch {
return null;
}
}
/** /**
* Balance the books don't explain: balance less the starting balance * Balance the books don't explain: balance less the starting balance
* ($0 for Express, the funded size otherwise), all trade P&L (today's off * ($0 for Express, the funded size otherwise), all trade P&L (today's off
@@ -1107,7 +1167,7 @@ export class TopstepXClient implements PlatformClient {
/** /**
* See PlatformClient. The hub moves the balance the moment a payout * See PlatformClient. The hub moves the balance the moment a payout
* lands, but reconcileFunds books it only in the next sync — and not * lands, but reconcileFunds books it only a sync or two later — and not
* while a position is open. Planned on in between, the payout reads as * while a position is open. Planned on in between, the payout reads as
* lost profit: on 2026-10-06 two Express accounts that had finished * lost profit: on 2026-10-06 two Express accounts that had finished
* their cycle re-planned as stage 1, fired a full 3×NQ leg 28 seconds * their cycle re-planned as stage 1, fired a full 3×NQ leg 28 seconds
@@ -1421,6 +1481,7 @@ export class TopstepXClient implements PlatformClient {
public forgetHistory(accountId: number, accountName: string): void { public forgetHistory(accountId: number, accountName: string): void {
this.historyLoadedIds.delete(accountId); this.historyLoadedIds.delete(accountId);
this.reconcilePendingIds.delete(accountId); this.reconcilePendingIds.delete(accountId);
this.fundSightings.delete(accountId);
delete this.dailyPnL[accountId]; delete this.dailyPnL[accountId];
delete this.fullDailyPnL[accountId]; delete this.fullDailyPnL[accountId];
delete this.daysTraded[accountId]; delete this.daysTraded[accountId];
+89
View File
@@ -1107,6 +1107,74 @@ export function unbookedPayoutDrop(
return drop >= PAYOUT_HOLD_MIN ? drop : null; return drop >= PAYOUT_HOLD_MIN ? drop : null;
} }
// ── fund events from the balance (TopstepX) ──────────────────────────────────
/** Balance the books don't explain past this is a fund event (TopstepX's
* reconcileFunds books it); under it, fee noise and rounding. The smallest
* real withdrawal in the drain flow is well above it. */
export const FUND_EVENT_MIN = 200;
/** A sighting is booked only once a later pass, at least this much later,
* sees the same amount... */
export const FUND_CONFIRM_MS = 30_000;
/** ...to within this (fee noise between the two passes). */
export const FUND_CONFIRM_TOLERANCE = 5;
/** An unexplained balance seen, not yet booked: the amount and when it was
* first seen at about that amount. */
export interface FundSighting { delta: number; since: number }
export type FundStep =
/** Under FUND_EVENT_MIN: nothing to book. */
| { kind: 'settled' }
/** Not yet seen twice far enough apart. `sighting` is what to keep for
* the next pass; `fresh` when it is new (or the amount moved). */
| { kind: 'unconfirmed'; sighting: FundSighting; fresh: boolean }
/** Book today's row as `amount` (today's existing row merged in). */
| { kind: 'book'; amount: number }
/** Today's bookings net under FUND_EVENT_MIN: a transient and its
* reversal. Take today's row back and leave `net` unbooked. */
| { kind: 'drop'; net: number };
/**
* One fresh pass's decision on the balance the books don't explain.
*
* The pass reads the balance first and the positions and trades after it, so
* a fill landing in between shows in the trades but not yet in the balance
* (or, the other way, in the balance before its trade row). For one pass the
* books are off by the whole fill. On 2026-10-05 two Express accounts booked a
* $3,001.16 "deposit" that way, and the next pass booked the $3,002.16
* "withdrawal" that undid it. The two merged into a −$1.00 row, the
* commission drift of the day. A negative row is a withdrawal: it started a
* new cycle, dropped the day's −$3,003.32 out of it and moved both accounts
* to their drain stage. A real fund event stays put, so an amount is booked
* only when a pass at least FUND_CONFIRM_MS after the first sighting still
* sees it.
*
* `undoable`: today's row was booked by this reconcile alone, with what it
* replaced saved (the first booking of a day saves it), so it can be taken
* back whole. A transient that outlasts the confirmation and is then
* reversed nets under FUND_EVENT_MIN, and the day is dropped, not left as a
* tiny row.
*/
export function fundReconcileStep(opts: {
delta: number;
sighting: FundSighting | null;
now: number;
/** Today's fund row, null when there is none. */
existingToday: number | null;
undoable: boolean;
}): FundStep {
const { delta, sighting, now, existingToday } = opts;
if (Math.abs(delta) < FUND_EVENT_MIN) return { kind: 'settled' };
if (sighting === null || Math.abs(sighting.delta - delta) > FUND_CONFIRM_TOLERANCE) {
return { kind: 'unconfirmed', sighting: { delta, since: now }, fresh: true };
}
if (now - sighting.since < FUND_CONFIRM_MS) return { kind: 'unconfirmed', sighting, fresh: false };
const amount = round2((existingToday ?? 0) + delta);
if (existingToday !== null && opts.undoable && Math.abs(amount) < FUND_EVENT_MIN) return { kind: 'drop', net: amount };
return { kind: 'book', amount };
}
// ── fees ───────────────────────────────────────────────────────────────────── // ── fees ─────────────────────────────────────────────────────────────────────
/** An entry's fee when the balance update hasn't shown it yet — at least one /** An entry's fee when the balance update hasn't shown it yet — at least one
@@ -1682,3 +1750,24 @@ export function isPastSessionClose(now: Date, everyDay: boolean): boolean {
if (day === 'Sat' || day === 'Sun') return false; if (day === 'Sat' || day === 'Sun') return false;
return minutes >= 14 * 60 + 55 && minutes < 17 * 60; 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<T extends { firm: { id: string; maxConcurrentAccounts?: number } }>(
items: readonly T[],
held: ReadonlyMap<string, number> = 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;
});
}
+1 -1
View File
@@ -7,7 +7,7 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint", "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": { "dependencies": {
"@microsoft/signalr": "^10.0.11", "@microsoft/signalr": "^10.0.11",
+32 -4
View File
@@ -10,7 +10,7 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import {
accountStatus, accountStatusFields, brokerFacts, emptyFirmStats, firmStatusFields, legacyDead, accountStatus, accountStatusFields, brokerFacts, emptyFirmStats, firmStatusFields, firstFundDate, keptRuleIdsField, legacyDead,
REPORT_STATUS_VERSION, SYNC_STALE_MS, REPORT_STATUS_VERSION, SYNC_STALE_MS,
} from '../lib/platform-client.ts'; } 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' }); 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', () => { test('firm-level fields: statusVersion and firmId always, syncedAt once synced', () => {
const at = Date.parse('2026-10-06T14:00:00Z'); 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' }); 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); 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', () => { test('the no-accounts branch keeps every existing key and value', () => {
const firm = { id: '71016d02-752f-4078-9e24-e74c9d75d1c8', name: 'Tradeify' }; const firm = { id: '71016d02-752f-4078-9e24-e74c9d75d1c8', name: 'Tradeify' };
// What reporter.ts sent before (its :46-56 branch), for each client state. // What reporter.ts sent before (its :46-56 branch), for each client state.
@@ -166,20 +188,26 @@ test('the no-accounts branch keeps every existing key and value', () => {
}); });
const at = Date.parse('2026-10-06T14:00:00Z'); 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 }]; 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) { for (const client of states) {
const got = emptyFirmStats(firm, client); for (const kept of keptCases) {
const got = kept === undefined ? emptyFirmStats(firm, client) : emptyFirmStats(firm, client, kept);
const old = legacy(client); const old = legacy(client);
for (const [key, value] of Object.entries(old)) assert.deepEqual(got[key], value, `${JSON.stringify(client)}: ${key}`); 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 added = Object.keys(got).filter((k) => !(k in old)).sort();
const synced = client?.syncComplete === true; const synced = client?.syncComplete === true;
assert.equal(got.statusVersion, 1); assert.equal(got.statusVersion, 1);
assert.equal(got.firmId, firm.id); assert.equal(got.firmId, firm.id);
assert.equal('accounts' in got, synced); assert.equal('accounts' in got, synced);
assert.equal('syncedAt' in got, synced && client.lastSyncAt > 0); assert.equal('syncedAt' in got, synced && client.lastSyncAt > 0);
assert.deepEqual(added, ['firmId', 'statusVersion', ...(got.syncedAt ? ['syncedAt'] : [])].sort()); 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. // 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)); assert.deepEqual(Object.keys(got).slice(0, Object.keys(old).length), Object.keys(old));
} }
}
}); });
// ── Size: 4 firms × 500 accounts against the master socket's 1 MB frame ────── // ── Size: 4 firms × 500 accounts against the master socket's 1 MB frame ──────
+516
View File
@@ -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);
}
});
+139 -1
View File
@@ -13,7 +13,7 @@ import {
botStopsActive, sizeNotional, isNoTradeTime, isPastSessionClose, nextTradingDate, effectiveCombine, DEFAULT_TARGET_HOLD_HOURS, botStopsActive, sizeNotional, isNoTradeTime, isPastSessionClose, nextTradingDate, effectiveCombine, DEFAULT_TARGET_HOLD_HOURS,
projectPerfectPath, cycleCompletion, DLL_RESERVE_FRACTION, DLL_MIN_STOP, notionalCap, expectedSwapToWin, MARGIN_HEADROOM, projectPerfectPath, cycleCompletion, DLL_RESERVE_FRACTION, DLL_MIN_STOP, notionalCap, expectedSwapToWin, MARGIN_HEADROOM,
dllReserve, DLL_SLIPPAGE_FRACTION, maxLossRoom, lastStand, breachDistance, LAST_STAND_FRACTION, planLegSize, rankForTarget, dllReserve, DLL_SLIPPAGE_FRACTION, maxLossRoom, lastStand, breachDistance, LAST_STAND_FRACTION, planLegSize, rankForTarget,
currentTradeDateCT, foldReason, currentTradeDateCT, foldReason, withinFirmCaps,
} from '../lib/trading-logic.ts'; } from '../lib/trading-logic.ts';
// Deterministic PRNG so a failure reproduces. // Deterministic PRNG so a failure reproduces.
@@ -771,3 +771,141 @@ test('tick reasons fold their amounts, so like reasons count together', () => {
assert.equal(foldReason("waiting for the broker to roll the account's day"), "waiting for the broker to roll the account's day"); assert.equal(foldReason("waiting for the broker to roll the account's day"), "waiting for the broker to roll the account's day");
assert.equal(foldReason('leg cap reached (5 today)'), 'leg cap reached (5 today)'); 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';
/** reconcileFunds' bookkeeping for one trade date, pass by pass: `balance`
* and `trades` (all trade P&L, today's included) as each pass read them.
* `existing` is a row already on the day that reconcileFunds didn't book. */
function reconcileDay(passes, existing = null) {
let row = existing;
let undoable = false; // the day's first booking saves what it replaces
let sighting = null;
const steps = [];
for (const { at, balance, trades } of passes) {
const delta = Math.round((balance - trades - (row ?? 0)) * 100) / 100;
const step = fundReconcileStep({ delta, sighting, now: at * 1000, existingToday: row, undoable });
steps.push(step);
if (step.kind === 'unconfirmed') { sighting = step.sighting; continue; }
sighting = null;
if (step.kind === 'book') { if (row === null) undoable = true; row = step.amount; }
if (step.kind === 'drop') { row = null; undoable = false; }
}
return { row, kinds: steps.map((s) => s.kind), steps };
}
test('fund reconcile: a balance read before a fill the trades already show books nothing', () => {
// 2026-10-05, EXPRESS-V2-CT-DLL-39029-23275136: the pass read the balance
// (−2.16), then the trades with the −3,001.16 stop-out in them. One pass
// later the balance had it (−3,004.32, $1.00 of commission off-book).
// Booked as it came, that was +3,001.16, then −3,002.16, merged to −1.00.
const express = reconcileDay([
{ at: 0, balance: -2.16, trades: -3003.32 },
{ at: 60, balance: -3004.32, trades: -3003.32 },
]);
assert.deepEqual(express.kinds, ['unconfirmed', 'settled']);
assert.equal(express.steps[0].sighting.delta, 3001.16);
assert.equal(express.row, null);
// 2026-10-08, 150KTC-SKU-V2-DLL-39029-99745612 (balance less the $150K
// start): +2,474.01 then −2,492.01, which left a −18.00 row.
const combine = reconcileDay([
{ at: 0, balance: 2413.43, trades: -60.58 },
{ at: 60, balance: -78.58, trades: -60.58 },
]);
assert.deepEqual(combine.kinds, ['unconfirmed', 'settled']);
assert.equal(combine.row, null);
});
test('fund reconcile: a real withdrawal books once a later pass still sees it', () => {
// 2026-10-14, EXPRESS-V2-CT-DLL-39029-43788281: a $4,620 payout.
const payout = reconcileDay([
{ at: 0, balance: 4601.42, trades: 9221.42 },
{ at: 10, balance: 4601.42, trades: 9221.42 }, // too soon to confirm
{ at: 60, balance: 4601.42, trades: 9221.42 },
]);
assert.deepEqual(payout.kinds, ['unconfirmed', 'unconfirmed', 'book']);
assert.equal(payout.steps[1].fresh, false);
assert.equal(payout.steps[1].sighting.since, 0); // the clock runs from the first sighting
assert.equal(payout.row, -4620);
// Fee noise between the passes still confirms; the later amount books.
assert.equal(reconcileDay([
{ at: 0, balance: 4601.42, trades: 9221.42 },
{ at: FUND_CONFIRM_MS / 1000, balance: 4598.42, trades: 9221.42 },
]).row, -4623);
// An amount that moves starts over: the second sighting is the new one.
const moved = reconcileDay([
{ at: 0, balance: 4601.42, trades: 9221.42 },
{ at: 60, balance: 4901.42, trades: 9221.42 },
{ at: 120, balance: 4901.42, trades: 9221.42 },
]);
assert.deepEqual(moved.kinds, ['unconfirmed', 'unconfirmed', 'book']);
assert.equal(moved.steps[1].fresh, true);
assert.equal(moved.row, -4320);
});
test('fund reconcile: a transient that outlasts the confirmation is taken back whole', () => {
// The balance lags for two passes, so +3,001.16 books; when it catches up,
// the reversal nets the day to −1.00 and the day is dropped, not left as
// a −1.00 "withdrawal" that splits the cycle.
const lagging = reconcileDay([
{ at: 0, balance: -2.16, trades: -3003.32 },
{ at: 60, balance: -2.16, trades: -3003.32 },
{ at: 120, balance: -3004.32, trades: -3003.32 },
{ at: 180, balance: -3004.32, trades: -3003.32 },
]);
assert.deepEqual(lagging.kinds, ['unconfirmed', 'book', 'unconfirmed', 'drop']);
assert.equal(lagging.steps[1].amount, 3001.16);
assert.equal(lagging.steps[3].net, -1);
assert.equal(lagging.row, null);
// A payout landing with the reversal is real: the day keeps its row.
const withPayout = reconcileDay([
{ at: 0, balance: -2.16, trades: -3003.32 },
{ at: 60, balance: -2.16, trades: -3003.32 },
{ at: 120, balance: -7624.32, trades: -3003.32 },
{ at: 180, balance: -7624.32, trades: -3003.32 },
]);
assert.deepEqual(withPayout.kinds, ['unconfirmed', 'book', 'unconfirmed', 'book']);
assert.equal(withPayout.row, -4621);
// Only a day reconcileFunds booked itself is taken back: a row it found
// there (an import) merges as before.
const imported = reconcileDay([
{ at: 0, balance: -1, trades: 0 },
{ at: 60, balance: -1, trades: 0 },
], -4620);
assert.deepEqual(imported.kinds, ['unconfirmed', 'book']);
assert.equal(imported.row, -1);
});
+61 -6
View File
@@ -71,7 +71,9 @@ db.exec(`
firm_id TEXT NOT NULL, firm_id TEXT NOT NULL,
account_type_id TEXT, account_type_id TEXT,
manual INTEGER NOT NULL DEFAULT 0, manual INTEGER NOT NULL DEFAULT 0,
resolved_at TEXT NOT NULL resolved_at TEXT NOT NULL,
rules_date TEXT,
rules_date_manual INTEGER NOT NULL DEFAULT 0
); );
`); `);
try { try {
@@ -79,6 +81,36 @@ try {
} catch { } catch {
// Column already exists // 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( const upsert = db.prepare(
'INSERT INTO settings (key, value) VALUES (?, ?) ' + 'INSERT INTO settings (key, value) VALUES (?, ?) ' +
@@ -134,14 +166,29 @@ async function restore() {
'INSERT INTO instruments (symbol, enabled) VALUES (?, ?) ' + 'INSERT INTO instruments (symbol, enabled) VALUES (?, ?) ' +
'ON CONFLICT(symbol) DO UPDATE SET enabled = excluded.enabled' 'ON CONFLICT(symbol) DO UPDATE SET enabled = excluded.enabled'
); );
const setAssign = db.prepare(` // lib/db.ts restoreAssignment, by hand: the backup's rules date (and how
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at) // it was set) wins; a backup from an older build has none, so an existing
VALUES (?, ?, ?, ?, ?) // 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 ON CONFLICT(account_id) DO UPDATE SET
firm_id = excluded.firm_id, firm_id = excluded.firm_id,
account_type_id = excluded.account_type_id, account_type_id = excluded.account_type_id,
manual = excluded.manual, 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(() => { db.transaction(() => {
@@ -162,7 +209,15 @@ async function restore() {
setInstr.run(i.symbol, i.enabled ? 1 : 0); setInstr.run(i.symbol, i.enabled ? 1 : 0);
} }
for (const a of payload.assignments ?? []) { 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);
}
} }
})(); })();
+29 -1
View File
@@ -1,4 +1,10 @@
import type { FirmPlatform } from './lib/master-protocol'; 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 { export interface AccountConfig {
/** Master's account type id — what a manual assignment pins to. */ /** Master's account type id — what a manual assignment pins to. */
@@ -36,6 +42,14 @@ export interface AccountConfig {
tradesEveryDay?: boolean; 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 { export interface FirmConfig {
/** Master's firm id (uuid). */ /** Master's firm id (uuid). */
id: string; id: string;
@@ -57,7 +71,7 @@ export interface FirmConfig {
instrumentsSource: 'master' | 'legacy'; instrumentsSource: 'master' | 'legacy';
/** The firm's account types, straight from the catalog. Doubles as the /** The firm's account types, straight from the catalog. Doubles as the
* option list for a manual account-type override. */ * option list for a manual account-type override. */
accounts: AccountConfig[]; accounts: FirmAccountType[];
catalogUpdatedAt: string; catalogUpdatedAt: string;
} }
@@ -138,6 +152,20 @@ export interface AccountState {
inferredAccountSize: number | null; inferredAccountSize: number | null;
/** True when more than one account type matched and updatedAt decided it. */ /** True when more than one account type matched and updatedAt decided it. */
accountTypeAmbiguous: boolean; 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 { export interface FirmState {