Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0899620d4 | ||
|
|
98016b3eb3 | ||
|
|
d7d1e2cdae |
@@ -17,6 +17,7 @@ import {
|
||||
import type { FirmConfig, FirmState, AccountState, AccountConfig } from '@/types';
|
||||
import { stagePillClasses } from '@/lib/stage-colors';
|
||||
import { projectPerfectPath } from '@/lib/trading-logic';
|
||||
import { RulesDate } from '@/app/components/RulesDate';
|
||||
|
||||
/** Reassign this account to another of its firm's account types, or hand it
|
||||
* back to the auto-resolver. Mirrors the control on the dashboard. */
|
||||
@@ -578,6 +579,7 @@ export default function AccountPage() {
|
||||
onAssigned={() => { void load(); }}
|
||||
/>
|
||||
)}
|
||||
<RulesDate account={account} firm={firm} onSaved={() => { void load(); }} />
|
||||
<span className="text-slate-300">/</span>
|
||||
<h1 className="text-slate-900 font-bold text-sm">{account.name}</h1>
|
||||
{isDead && (
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCachedFirm } from '@/lib/firm-catalog';
|
||||
import { getCachedFirm, getKeptRulesFor } from '@/lib/firm-catalog';
|
||||
import { firmInstruments, instrumentsSource } from '@/lib/instruments';
|
||||
import { getCredentials } from '@/lib/db';
|
||||
|
||||
@@ -25,7 +25,11 @@ export async function GET(
|
||||
hasCredentials: !!creds,
|
||||
instruments: firmInstruments(firm),
|
||||
instrumentsSource: instrumentsSource(firm),
|
||||
accounts: firm.accountTypes,
|
||||
// Same shape as GET /api/firms: each type with its old rule sets' windows.
|
||||
accounts: firm.accountTypes.map((a) => ({
|
||||
...a,
|
||||
kept: getKeptRulesFor(a.id).map((k) => ({ id: k.id, openedAt: k.openedAt, closedAt: k.closedAt })),
|
||||
})),
|
||||
catalogUpdatedAt: firm.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
+11
-2
@@ -1,8 +1,13 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { firmInstruments, instrumentsSource } from '@/lib/instruments';
|
||||
import { getCachedCatalog, hasPlatformCredentials } from '@/lib/firm-catalog';
|
||||
import { getCachedCatalog, getKeptRulesFor, hasPlatformCredentials } from '@/lib/firm-catalog';
|
||||
import { getCredentials } from '@/lib/db';
|
||||
import type { FirmConfig } from '@/types';
|
||||
import type { FirmConfig, KeptRulesWindow } from '@/types';
|
||||
|
||||
/** The windows of a live account type's old rule sets, by closedAt. */
|
||||
function keptWindows(accountTypeId: string): KeptRulesWindow[] {
|
||||
return getKeptRulesFor(accountTypeId).map((k) => ({ id: k.id, openedAt: k.openedAt, closedAt: k.closedAt }));
|
||||
}
|
||||
|
||||
/**
|
||||
* The firms this machine knows about — the master's catalog, annotated with
|
||||
@@ -14,6 +19,9 @@ import type { FirmConfig } from '@/types';
|
||||
*
|
||||
* The stored password is deliberately not returned. It used to be, and nothing
|
||||
* ever read it.
|
||||
*
|
||||
* Each account type carries the windows of the old rule sets it keeps (`kept`)
|
||||
* so the dashboard's rules-date control can say which rules a date lands on.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
@@ -48,6 +56,7 @@ export async function GET() {
|
||||
withdrawalStages: a.withdrawalStages,
|
||||
dailyLossLimit: a.dailyLossLimit ?? 0,
|
||||
tradesEveryDay: a.tradesEveryDay === true,
|
||||
kept: keptWindows(a.id),
|
||||
})),
|
||||
catalogUpdatedAt: firm.updatedAt,
|
||||
};
|
||||
|
||||
+25
-2
@@ -1,7 +1,8 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { loadDailyPnL } from '@/lib/db';
|
||||
import { listAssignments, loadDailyPnL } from '@/lib/db';
|
||||
import { getClients } from '@/lib/clients';
|
||||
import { getCachedCatalog } from '@/lib/firm-catalog';
|
||||
import { getAccountTypeById, getCachedCatalog } from '@/lib/firm-catalog';
|
||||
import { ruleDiffs } from '@/lib/kept-rules';
|
||||
import { getAccountConfig, getInferredSize, matchAccountType } from '@/lib/account-type-resolver';
|
||||
import { historyFresh } from '@/lib/platform-client';
|
||||
import { rankNextLeg } from '@/lib/leg-estimate';
|
||||
@@ -29,6 +30,11 @@ export async function GET() {
|
||||
if (!client || client.accountList.length === 0) {
|
||||
return { firmId: f.id, firm: f.name, connected, accounts: [] };
|
||||
}
|
||||
// Accounts whose rules date was set by hand — one read per firm
|
||||
// rather than one per account.
|
||||
const rulesDateByHand = new Set(listAssignments(f.id)
|
||||
.filter((row) => row.rules_date_manual === 1)
|
||||
.map((row) => row.account_id));
|
||||
const accounts = client.accountList.map((acc) => {
|
||||
const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
|
||||
|
||||
@@ -84,6 +90,20 @@ export async function GET() {
|
||||
const ambiguous = cfg
|
||||
? matchAccountType(acc.name, inferredAccountSize, f.accountTypes).ambiguous
|
||||
: false;
|
||||
// An account on one of its type's old rule sets: cfg already
|
||||
// carries those rules; say which set, and how it differs from
|
||||
// the type's current rules.
|
||||
const live = resolved.keptRules && resolved.accountTypeId
|
||||
? getAccountTypeById(resolved.accountTypeId)
|
||||
: undefined;
|
||||
const keptRules: AccountState['keptRules'] = resolved.keptRules && cfg && live
|
||||
? {
|
||||
id: resolved.keptRules.id,
|
||||
openedAt: resolved.keptRules.openedAt,
|
||||
closedAt: resolved.keptRules.closedAt,
|
||||
differs: ruleDiffs(cfg, live),
|
||||
}
|
||||
: null;
|
||||
let targetHit = false;
|
||||
let dailyTarget: { amount: number; path: 'first_day' | 'normal_day' | 'reduced_day' } | null = null;
|
||||
let remainingToday: number | null = null;
|
||||
@@ -289,6 +309,9 @@ export async function GET() {
|
||||
accountType: cfg,
|
||||
inferredAccountSize,
|
||||
accountTypeAmbiguous: ambiguous,
|
||||
rulesDate: resolved.rulesDate,
|
||||
rulesDateManual: rulesDateByHand.has(acc.id),
|
||||
keptRules,
|
||||
};
|
||||
});
|
||||
return { firmId: f.id, firm: f.name, connected, accounts };
|
||||
|
||||
@@ -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
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import type { FirmConfig, FirmState, AccountState } from '@/types';
|
||||
import { stagePillClasses } from '@/lib/stage-colors';
|
||||
import { RulesDate } from '@/app/components/RulesDate';
|
||||
|
||||
type SortKey = 'name' | 'balance' | 'dayPnL' | 'daysTraded' | 'target' | 'status';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
@@ -342,12 +343,14 @@ function WipeAllButton() {
|
||||
);
|
||||
}
|
||||
|
||||
function AccountRow({ account, firm, hideDead, privacy, onAssigned }: {
|
||||
function AccountRow({ account, firm, hideDead, privacy, onAssigned, onStateChanged }: {
|
||||
account: AccountState;
|
||||
firm: FirmConfig;
|
||||
hideDead: boolean;
|
||||
privacy: boolean;
|
||||
onAssigned: () => void;
|
||||
/** Refetches /api/state — after a rules date is saved. */
|
||||
onStateChanged: () => void;
|
||||
}) {
|
||||
const cfg = account.accountType;
|
||||
const dead = isAccountDead(account);
|
||||
@@ -359,8 +362,9 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned }: {
|
||||
return (
|
||||
<tr className={`border-b border-slate-100 hover:bg-slate-50 transition-colors${!account.active || dead ? ' opacity-50' : ''}`}>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2 font-medium text-slate-800 pl-4 font-mono">
|
||||
{maskName(account.name, privacy)}
|
||||
<div className="flex items-center gap-2 pl-4">
|
||||
<span className="font-medium text-slate-800 font-mono">{maskName(account.name, privacy)}</span>
|
||||
<RulesDate account={account} firm={firm} onSaved={onStateChanged} />
|
||||
</div>
|
||||
</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;
|
||||
firm: FirmConfig;
|
||||
hideDead: boolean;
|
||||
@@ -449,6 +453,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
||||
sortDir: SortDir;
|
||||
tradeSymbol: string;
|
||||
onChanged: () => void;
|
||||
onStateChanged: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [confirmCreds, setConfirmCreds] = useState(false);
|
||||
@@ -601,6 +606,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
||||
hideDead={hideDead}
|
||||
privacy={privacy}
|
||||
onAssigned={onChanged}
|
||||
onStateChanged={onStateChanged}
|
||||
/>
|
||||
))
|
||||
)
|
||||
@@ -673,6 +679,18 @@ export default function Home() {
|
||||
fetchConfig();
|
||||
};
|
||||
|
||||
const fetchState = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/state');
|
||||
if (res.ok) {
|
||||
const data: FirmState[] = await res.json();
|
||||
setFirms(data);
|
||||
}
|
||||
} catch {
|
||||
// server not running yet
|
||||
}
|
||||
};
|
||||
|
||||
const fetchScheduler = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/auto-trade');
|
||||
@@ -701,19 +719,6 @@ export default function Home() {
|
||||
fetchConfig();
|
||||
fetchCatalog();
|
||||
fetchScheduler();
|
||||
|
||||
const fetchState = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/state');
|
||||
if (res.ok) {
|
||||
const data: FirmState[] = await res.json();
|
||||
setFirms(data);
|
||||
}
|
||||
} catch {
|
||||
// server not running yet
|
||||
}
|
||||
};
|
||||
|
||||
fetchState();
|
||||
const stateInterval = setInterval(fetchState, 5000);
|
||||
const schedulerInterval = setInterval(fetchScheduler, 5000);
|
||||
@@ -965,6 +970,7 @@ export default function Home() {
|
||||
sortDir={sortDir}
|
||||
tradeSymbol={tradeSymbol}
|
||||
onChanged={fetchConfig}
|
||||
onStateChanged={fetchState}
|
||||
/>
|
||||
);
|
||||
})
|
||||
|
||||
+165
-19
@@ -9,31 +9,58 @@
|
||||
*
|
||||
* An Unassigned account does not trade: every caller already guards on a
|
||||
* missing config, which is the behaviour we want.
|
||||
*
|
||||
* Which of its type's rule sets an account trades on is the other half (see
|
||||
* lib/kept-rules.ts): each assignment row carries a rules date, and an
|
||||
* account dated inside one of the type's kept rule sets trades that set's
|
||||
* rules — getAccountConfig applies it, so every trading read sees it.
|
||||
*/
|
||||
|
||||
import type { CatalogAccountType } from './master-protocol';
|
||||
import type { CatalogAccountType, KeptAccountRules } from './master-protocol';
|
||||
import type { PlatformClient } from './platform-client';
|
||||
import { firstFundDate } from './platform-client';
|
||||
import {
|
||||
getAssignment,
|
||||
setAssignment,
|
||||
setAutoAssignment,
|
||||
clearAssignment,
|
||||
unpinAssignment,
|
||||
lowerAutoRulesDate,
|
||||
stampMissingRulesDate,
|
||||
listAssignments,
|
||||
loadAccountMeta,
|
||||
saveAccountMeta,
|
||||
} from './db';
|
||||
import { getAccountTypeById, getCachedFirm } from './firm-catalog';
|
||||
import { getAccountTypeById, getCachedFirm, getCatalogUpdatedAt, getKeptRulesFor, getMasterOffsetMs } from './firm-catalog';
|
||||
import {
|
||||
identityStillMatches, lowersRulesDate, openingEvidence, overlayKept, pickKeptRules, stampRulesDate,
|
||||
type EffectiveAccountType, type KeptRulesWindow,
|
||||
} from './kept-rules';
|
||||
|
||||
const INFERRED_SIZE_KEY = 'inferred_account_size';
|
||||
|
||||
/** Accounts this process has run the evidence step on with their fund
|
||||
* history loaded (see ensureRulesEvidence). On `global` like the clients,
|
||||
* so the trade path and the resolver share it whichever bundle loads them. */
|
||||
const ge = global as typeof globalThis & { __rulesEvidenceApplied?: Set<number> };
|
||||
function evidenceApplied(): Set<number> {
|
||||
return (ge.__rulesEvidenceApplied ??= new Set());
|
||||
}
|
||||
|
||||
export type AssignmentSource = 'manual' | 'auto' | 'unassigned';
|
||||
|
||||
export interface ResolvedAssignment {
|
||||
/** Always the live account type's id — a kept rule set's never is one. */
|
||||
accountTypeId: string | null;
|
||||
accountType: CatalogAccountType | null;
|
||||
/** The rules the account trades on: its live type, or the live type with
|
||||
* a kept rule set's rules on top (then keptRules says which). */
|
||||
accountType: EffectiveAccountType | null;
|
||||
source: AssignmentSource;
|
||||
/** True when more than one account type matched and the tie-break decided. */
|
||||
ambiguous: boolean;
|
||||
/** The account's rules date (ISO); null = none recorded, current rules. */
|
||||
rulesDate: string | null;
|
||||
/** The kept rule set the account trades on; null = its type's current rules. */
|
||||
keptRules: KeptRulesWindow | null;
|
||||
}
|
||||
|
||||
const UNASSIGNED: ResolvedAssignment = {
|
||||
@@ -41,8 +68,23 @@ const UNASSIGNED: ResolvedAssignment = {
|
||||
accountType: null,
|
||||
source: 'unassigned',
|
||||
ambiguous: false,
|
||||
rulesDate: null,
|
||||
keptRules: null,
|
||||
};
|
||||
|
||||
/** One overlay per kept set and live type, so a tick's many lookups share one
|
||||
* object the way they share the live type. Both come from the same catalog
|
||||
* index, so a sync replaces them together. */
|
||||
const overlays = new WeakMap<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.
|
||||
* Returns null while the reports fetch hasn't produced one yet, which is the
|
||||
* "wait until it is findable" case rather than an error. */
|
||||
@@ -93,13 +135,20 @@ export function matchAccountType(
|
||||
}
|
||||
|
||||
/** THE account→config lookup. Reads the assignment table; never writes.
|
||||
* Replaces the longest-prefix helper that used to be copied into five files. */
|
||||
* Replaces the longest-prefix helper that used to be copied into five files.
|
||||
* Every trading read of an account's rules comes through here, so this is
|
||||
* where an account dated inside a kept rule set gets that set's rules —
|
||||
* manual pins and auto assignments alike. One row read, the rest from the
|
||||
* in-memory catalog: it runs per account per tick. */
|
||||
export function getAccountConfig(accountId: number): ResolvedAssignment {
|
||||
const row = getAssignment(accountId);
|
||||
if (!row?.account_type_id) return UNASSIGNED;
|
||||
if (!row) return UNASSIGNED;
|
||||
// `?? null`: a row read before the rules-date migration ran has no column.
|
||||
const rulesDate = row.rules_date ?? null;
|
||||
if (!row.account_type_id) return { ...UNASSIGNED, rulesDate };
|
||||
|
||||
const accountType = getAccountTypeById(row.account_type_id);
|
||||
if (!accountType) {
|
||||
const live = getAccountTypeById(row.account_type_id);
|
||||
if (!live) {
|
||||
// The account type was deleted on the master. A manual pin is kept so
|
||||
// that a master flap which recreates it restores the user's intent, but
|
||||
// it reports as unassigned until then, so the account stops trading.
|
||||
@@ -108,14 +157,20 @@ export function getAccountConfig(accountId: number): ResolvedAssignment {
|
||||
accountType: null,
|
||||
source: 'unassigned',
|
||||
ambiguous: false,
|
||||
rulesDate,
|
||||
keptRules: null,
|
||||
};
|
||||
}
|
||||
|
||||
const kept = pickKeptRules(getKeptRulesFor(live.id), rulesDate);
|
||||
const accountType: EffectiveAccountType = kept ? overlaid(live, kept) : live;
|
||||
return {
|
||||
accountTypeId: accountType.id,
|
||||
accountTypeId: live.id,
|
||||
accountType,
|
||||
source: row.manual === 1 ? 'manual' : 'auto',
|
||||
ambiguous: false,
|
||||
rulesDate,
|
||||
keptRules: accountType.keptRules ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -130,28 +185,106 @@ export function getInferredSize(accountId: number, client: PlatformClient): numb
|
||||
return inferAccountSize(client.fundTransactions?.[accountId] ?? []);
|
||||
}
|
||||
|
||||
/** Re-resolves every account on one firm's client. Skips manual overrides.
|
||||
* Called after each Tradovate sync and after the catalog changes. */
|
||||
/** The rules date a row first written now gets — on the master's clock when
|
||||
* this one runs behind, and never before the catalog's last change. */
|
||||
function freshRulesDate(): string {
|
||||
return stampRulesDate(Date.now(), getMasterOffsetMs(), getCatalogUpdatedAt());
|
||||
}
|
||||
|
||||
/** The broker's evidence of when the account opened (the report's
|
||||
* firstFundDate, read as lib/kept-rules.ts openingEvidence says); undefined
|
||||
* until its fund history has loaded, null when that history doesn't say. */
|
||||
function brokerEvidence(client: PlatformClient, accountId: number): string | null | undefined {
|
||||
const funds = client.fundTransactions?.[accountId];
|
||||
if (!funds) return undefined;
|
||||
return openingEvidence(firstFundDate(funds, undefined), client.brokerAccountMeta?.(accountId)?.registrationTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* The evidence step for one account, for the trade path: once its fund
|
||||
* history has loaded, lower an auto rules date to the broker's evidence
|
||||
* before anything reads the account's rules. The resolver does the same on
|
||||
* every pass, but only after a sync — and history lands between syncs, when
|
||||
* an account turns tradable (historyReady needs the fund rows loaded, on
|
||||
* every platform) and could trade on its first-seen date for up to a sync.
|
||||
* Once per account per process; a no-op until the history has loaded and the
|
||||
* account has an assignment row.
|
||||
*/
|
||||
export function ensureRulesEvidence(client: PlatformClient, accountId: number): void {
|
||||
if (evidenceApplied().has(accountId)) return;
|
||||
const evidence = brokerEvidence(client, accountId);
|
||||
if (evidence === undefined) return;
|
||||
const row = getAssignment(accountId);
|
||||
if (!row) return;
|
||||
if (evidence && lowersRulesDate(row, evidence)) lowerAutoRulesDate(accountId, evidence);
|
||||
evidenceApplied().add(accountId);
|
||||
}
|
||||
|
||||
/** Re-resolves every account on one firm's client. Skips manual overrides,
|
||||
* and auto ones on a kept rule set (see below). Lowers rules dates to the
|
||||
* broker's evidence on the way. Called after each sync and after the
|
||||
* catalog changes. */
|
||||
export function resolveFirmAssignments(
|
||||
firmId: string,
|
||||
client: PlatformClient,
|
||||
): { assigned: number; unassigned: number; manual: number } {
|
||||
const firm = getCachedFirm(firmId);
|
||||
const types = firm?.accountTypes ?? [];
|
||||
const stamp = freshRulesDate();
|
||||
|
||||
let assigned = 0;
|
||||
let unassigned = 0;
|
||||
let manual = 0;
|
||||
|
||||
for (const account of client.accountList) {
|
||||
if (getAssignment(account.id)?.manual === 1) {
|
||||
let row = getAssignment(account.id);
|
||||
// A row an older build wrote without a rules date (between a build's
|
||||
// migration and its restart, or after a rollback): dated now, as the
|
||||
// migration dates rows, before the evidence below can lower it.
|
||||
if (row && !row.rules_date) {
|
||||
stampMissingRulesDate(account.id, stamp);
|
||||
row = { ...row, rules_date: stamp };
|
||||
}
|
||||
|
||||
// Evidence next, for every account, pinned or not: once its fund
|
||||
// history has loaded, the broker shows when it opened (the report's
|
||||
// firstFundDate), and an auto rules date later than that comes down
|
||||
// to it. Ahead of the kept-set check below, which reads the date.
|
||||
const loadedFunds = client.fundTransactions?.[account.id];
|
||||
const evidence = brokerEvidence(client, account.id) ?? null;
|
||||
if (row && evidence && lowersRulesDate(row, evidence)) {
|
||||
lowerAutoRulesDate(account.id, evidence);
|
||||
row = { ...row, rules_date: evidence };
|
||||
}
|
||||
if (row && loadedFunds) evidenceApplied().add(account.id);
|
||||
|
||||
if (row?.manual === 1) {
|
||||
manual++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const fundTxns = client.fundTransactions?.[account.id] ?? [];
|
||||
const fundTxns = loadedFunds ?? [];
|
||||
const cached = loadAccountMeta(account.id, INFERRED_SIZE_KEY);
|
||||
|
||||
// Sticky: an account on one of its type's kept rule sets stays bound
|
||||
// to that type, so a tie-break flip to another matching type can never
|
||||
// move it off its old rules. Only while it still matches the type's
|
||||
// prefix and size, though: a kept set takes both from the live type,
|
||||
// so after a size edit it would trade on a size it doesn't have.
|
||||
// Unstuck, it re-matches as it did before kept rules existed —
|
||||
// usually to Unassigned, which stops it trading.
|
||||
if (row?.account_type_id) {
|
||||
const live = getAccountTypeById(row.account_type_id);
|
||||
if (
|
||||
live && live.firmId === firmId
|
||||
&& identityStillMatches(account.name, cached, live)
|
||||
&& pickKeptRules(getKeptRulesFor(live.id), row.rules_date ?? null)
|
||||
) {
|
||||
assigned++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let matched: { type: CatalogAccountType | null; ambiguous: boolean } = { type: null, ambiguous: false };
|
||||
let matchedSize: number | null = null;
|
||||
|
||||
@@ -194,24 +327,37 @@ export function resolveFirmAssignments(
|
||||
if (matchedSize !== null && cached === null) {
|
||||
saveAccountMeta(account.id, INFERRED_SIZE_KEY, String(matchedSize));
|
||||
}
|
||||
setAutoAssignment(account.id, firmId, matched.type.id);
|
||||
setAutoAssignment(account.id, firmId, matched.type.id, stamp);
|
||||
assigned++;
|
||||
} else {
|
||||
setAutoAssignment(account.id, firmId, null);
|
||||
setAutoAssignment(account.id, firmId, null, stamp);
|
||||
unassigned++;
|
||||
}
|
||||
// A row this pass created is dated "first seen now"; the evidence may
|
||||
// already date it earlier.
|
||||
if (!row && evidence) lowerAutoRulesDate(account.id, evidence);
|
||||
if (!row && loadedFunds) evidenceApplied().add(account.id);
|
||||
}
|
||||
|
||||
return { assigned, unassigned, manual };
|
||||
}
|
||||
|
||||
export function setManualAssignment(accountId: number, firmId: string, accountTypeId: string | null): void {
|
||||
setAssignment(accountId, firmId, accountTypeId, true);
|
||||
/** insertRulesDate dates the row when the pin creates it (default: now, as
|
||||
* stampRulesDate gives it); re-pinning keeps the account's rules date. */
|
||||
export function setManualAssignment(
|
||||
accountId: number,
|
||||
firmId: string,
|
||||
accountTypeId: string | null,
|
||||
insertRulesDate: string = freshRulesDate(),
|
||||
): void {
|
||||
setAssignment(accountId, firmId, accountTypeId, true, insertRulesDate);
|
||||
}
|
||||
|
||||
/** Drops the override so the auto-resolver takes the account back over. */
|
||||
/** Drops the override so the auto-resolver takes the account back over. The
|
||||
* row and its rules date stay: unpinning must not make an old account look
|
||||
* new. */
|
||||
export function clearManualAssignment(accountId: number): boolean {
|
||||
return clearAssignment(accountId);
|
||||
return unpinAssignment(accountId);
|
||||
}
|
||||
|
||||
export { listAssignments };
|
||||
|
||||
+19
-7
@@ -9,7 +9,7 @@
|
||||
|
||||
import { getClients } from './clients';
|
||||
import { getCachedFirm, getTradableFirms } from './firm-catalog';
|
||||
import { getAccountConfig } from './account-type-resolver';
|
||||
import { ensureRulesEvidence, getAccountConfig } from './account-type-resolver';
|
||||
import {
|
||||
combineCmeSessions, computeDayPlan, groupLegFraction, pickLegAmount, resolveEffectiveConfig, currentTradeDateCT,
|
||||
obfuscationStopDollars, takeProfitPlan, onTick, percentFee, dllRoom, dllStopDollars, dllViable, stopTicks,
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
isNoTradeTime, isPastSessionClose,
|
||||
BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY,
|
||||
FALLBACK_COMMISSION_PER_CONTRACT, MAX_COMMISSION_PER_CONTRACT, DLL_MIN_STOP,
|
||||
CME_EVENING_OPEN_MIN, foldReason,
|
||||
CME_EVENING_OPEN_MIN, foldReason, withinFirmCaps,
|
||||
fundRowsKey, markFinishedCycle, unbookedPayoutDrop,
|
||||
type DayPlan, type FinishedCycleMark,
|
||||
} from './trading-logic';
|
||||
@@ -666,6 +666,11 @@ function finishedCycleDrop(
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function evaluateAccountForTrade(client: any, acc: { id: number; name: string; active: boolean }, tradeDate: string): AccountEvaluation | null {
|
||||
// Which rule set the account trades on hangs on its rules date: bring it
|
||||
// down to the broker's evidence before reading the rules, so an account
|
||||
// whose history landed since the last sync never trades a leg on the
|
||||
// date this machine first saw it.
|
||||
ensureRulesEvidence(client, acc.id);
|
||||
const cfg = getAccountConfig(acc.id).accountType;
|
||||
if (!cfg) return null;
|
||||
|
||||
@@ -1037,7 +1042,9 @@ async function runTradeLocked(
|
||||
// (largest group first), never mixed across prefixes — a group's remainder
|
||||
// fires as a smaller batch on a later tick instead of being padded from
|
||||
// another prefix. E.g. 4×FTDFY, 5×MFFUEV, 7×MFFUSF at limit 5 fires as
|
||||
// batches of 5, 5, 4, 2. A group that can't trade anything right now
|
||||
// batches of 5, 5, 4, 2. A firm's own cap on accounts at once (the
|
||||
// master's Firms page) trims its share first: 6 Topstep accounts capped
|
||||
// at 3 fire as 3, 3. A group that can't trade anything right now
|
||||
// (nothing listed fits, no contract resolves) yields to the next.
|
||||
const groups = new Map<string, Candidate[]>();
|
||||
for (const item of allEligible) {
|
||||
@@ -1051,7 +1058,8 @@ async function runTradeLocked(
|
||||
let batchPrefix = '';
|
||||
let resolvedSymbol = '';
|
||||
for (const [prefix, members] of ordered) {
|
||||
const candidates = members.slice(0, maxConcurrent);
|
||||
// The lane is flat (see above), so no firm holds anything yet.
|
||||
const candidates = withinFirmCaps(members).slice(0, maxConcurrent);
|
||||
const lead = candidates.find((it) => !it.isExtraDay && !it.drain);
|
||||
const firms = [...new Map(candidates.map((it) => [it.firm.id, it])).values()];
|
||||
|
||||
@@ -1566,7 +1574,8 @@ async function fireLeg(
|
||||
// ── copy trade ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Copy the current trade direction to up to maxConcurrent accounts.
|
||||
* Copy the current trade direction to up to maxConcurrent accounts (and no
|
||||
* more of a firm's than its cap on accounts at once).
|
||||
* Finds accounts with open positions, determines direction, then fires
|
||||
* orders for eligible accounts that haven't traded yet — within the lane of
|
||||
* the first open position found, never across lanes.
|
||||
@@ -1617,12 +1626,15 @@ async function copyTradeLocked(lease: number, lane: string): Promise<unknown[]>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let positionedClient: any = null;
|
||||
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) {
|
||||
const pos = client.positions[acc.id];
|
||||
if (!pos) continue;
|
||||
positionedCount++;
|
||||
heldByFirm.set(firm.id, (heldByFirm.get(firm.id) ?? 0) + 1);
|
||||
const prefix = getAccountConfig(acc.id).accountType?.prefix;
|
||||
if (prefix) positionedPrefixes.add(prefix);
|
||||
if (!resolvedAction) {
|
||||
@@ -1718,7 +1730,7 @@ async function copyTradeLocked(lease: number, lane: string): Promise<unknown[]>
|
||||
// accounts before falling back to mismatched prefixes.
|
||||
const matchesPositioned = (item: LegItem) => positionedPrefixes.has(item.prefix);
|
||||
const ordered = [...eligible.filter(matchesPositioned), ...eligible.filter((i) => !matchesPositioned(i))];
|
||||
const batch = ordered.slice(0, slotsAvailable);
|
||||
const batch = withinFirmCaps(ordered, heldByFirm).slice(0, slotsAvailable);
|
||||
if (batch.length === 0) {
|
||||
console.log('[copy-trade] no eligible accounts to copy to');
|
||||
return [];
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
getSetting,
|
||||
setCredentials,
|
||||
setInstrumentEnabled,
|
||||
setAssignment,
|
||||
restoreAssignment,
|
||||
runInTransaction,
|
||||
} from './db';
|
||||
import type { BackupPayload } from './backup-sync';
|
||||
@@ -116,8 +116,10 @@ export function applyBackup(payload: BackupPayload, opts: { keepMasterUrl?: bool
|
||||
setInstrumentEnabled(i.symbol, i.enabled);
|
||||
counts.instruments++;
|
||||
}
|
||||
// Rules dates travel with them: a restored machine must not date
|
||||
// its accounts afresh, or old ones would leave their kept rules.
|
||||
for (const a of payload.assignments ?? []) {
|
||||
setAssignment(a.account_id, a.firm_id, a.account_type_id ?? null, a.manual === 1);
|
||||
restoreAssignment(a);
|
||||
counts.assignments++;
|
||||
}
|
||||
});
|
||||
|
||||
+4
-2
@@ -26,7 +26,7 @@ import {
|
||||
getInstruments,
|
||||
listAllAssignments,
|
||||
type FirmCredentialRow,
|
||||
type AssignmentRow,
|
||||
type BackupAssignmentRow,
|
||||
} from './db';
|
||||
|
||||
export interface BackupPayload {
|
||||
@@ -35,7 +35,9 @@ export interface BackupPayload {
|
||||
settings: Record<string, string>;
|
||||
credentials: FirmCredentialRow[];
|
||||
instruments: { symbol: string; enabled: boolean }[];
|
||||
assignments: AssignmentRow[];
|
||||
/** Whole rows (SELECT *), rules dates included — absent from a backup
|
||||
* an older build pushed. */
|
||||
assignments: BackupAssignmentRow[];
|
||||
}
|
||||
|
||||
const BACKUP_PATH = '/api/instances/backup';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdirSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { sortableIso, stampRulesDate } from './kept-rules';
|
||||
|
||||
// AUTOFIRMER_DB lets several instances share one checkout, each with its own
|
||||
// database. It is read once, at module load — schema creation and seeding are
|
||||
@@ -78,6 +79,62 @@ try {
|
||||
// Column already exists
|
||||
}
|
||||
|
||||
// Migration: each account's rules date — which of its account type's rule
|
||||
// sets it trades on (lib/kept-rules.ts): an account dated before a firm's
|
||||
// rule change keeps the rules from before it. Written when this machine first
|
||||
// records the account (or from a restored backup), lowered to the broker's
|
||||
// evidence of when the account opened, or set by hand (rules_date_manual,
|
||||
// which evidence never moves). Every writer stores a toISOString() UTC
|
||||
// string, so string order is time order.
|
||||
//
|
||||
// The run that adds the column dates every existing row at the migration
|
||||
// time, never NULL: no account changes rules merely because its machine
|
||||
// updated. The resolver then lowers those auto dates to the broker's
|
||||
// evidence, so an account the evidence shows opened before a rule change
|
||||
// goes back to the old rules. Column and backfill commit together, so
|
||||
// `next build` workers racing the migration can't leave rows undated.
|
||||
|
||||
/** The date an undated row gets: now, but never before the catalog's last
|
||||
* change — lib/kept-rules.ts stampRulesDate's floor (every build keeps
|
||||
* settings.catalog_updated_at current), so a machine whose clock runs
|
||||
* behind can't backdate an account first seen after a rule change into the
|
||||
* rule set that change closed. Call inside the writing transaction. */
|
||||
function undatedRowsDate(): string {
|
||||
let catalogUpdatedAt: string | null = null;
|
||||
try {
|
||||
const row = db.prepare("SELECT value FROM settings WHERE key = 'catalog_updated_at'").get() as { value?: unknown } | undefined;
|
||||
if (typeof row?.value === 'string') catalogUpdatedAt = row.value;
|
||||
} catch {
|
||||
// No settings table yet: a fresh database, with no rows to date.
|
||||
}
|
||||
return stampRulesDate(Date.now(), null, catalogUpdatedAt);
|
||||
}
|
||||
|
||||
try {
|
||||
db.transaction(() => {
|
||||
db.exec('ALTER TABLE account_assignments ADD COLUMN rules_date TEXT');
|
||||
db.prepare('UPDATE account_assignments SET rules_date = ? WHERE rules_date IS NULL').run(undatedRowsDate());
|
||||
}).immediate();
|
||||
} catch {
|
||||
// Column already exists
|
||||
}
|
||||
try {
|
||||
db.exec('ALTER TABLE account_assignments ADD COLUMN rules_date_manual INTEGER NOT NULL DEFAULT 0');
|
||||
} catch {
|
||||
// Column already exists
|
||||
}
|
||||
// Rows an older build wrote after that run have no date: it keeps serving
|
||||
// between a build's migration and the restart, and comes back in a rollback,
|
||||
// and its INSERT has no rules_date. Dated the same way at every start, so no
|
||||
// row stays undated (current rules forever, with no date to set by hand).
|
||||
try {
|
||||
db.transaction(() => {
|
||||
db.prepare('UPDATE account_assignments SET rules_date = ? WHERE rules_date IS NULL').run(undatedRowsDate());
|
||||
}).immediate();
|
||||
} catch {
|
||||
// No rules_date column — the migration above failed; nothing to date.
|
||||
}
|
||||
|
||||
// ── Firm credentials ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface FirmCredentialRow {
|
||||
@@ -133,8 +190,18 @@ export interface AssignmentRow {
|
||||
account_type_id: string | null;
|
||||
manual: number; // 0 | 1
|
||||
resolved_at: string;
|
||||
/** ISO (toISOString). Which of the type's rule sets the account trades
|
||||
* on; null only on a row an older build wrote after the migration —
|
||||
* the current rules until evidence dates it. */
|
||||
rules_date: string | null;
|
||||
rules_date_manual: number; // 0 | 1 — set by hand; evidence leaves it alone
|
||||
}
|
||||
|
||||
/** An assignment as a backup carries it: one from a build before rules
|
||||
* dates has neither column. */
|
||||
export type BackupAssignmentRow = Omit<AssignmentRow, 'rules_date' | 'rules_date_manual'>
|
||||
& Partial<Pick<AssignmentRow, 'rules_date' | 'rules_date_manual'>>;
|
||||
|
||||
export function getAssignment(accountId: number): AssignmentRow | undefined {
|
||||
return db.prepare('SELECT * FROM account_assignments WHERE account_id = ?').get(accountId) as AssignmentRow | undefined;
|
||||
}
|
||||
@@ -148,33 +215,116 @@ export function listAllAssignments(): AssignmentRow[] {
|
||||
return db.prepare('SELECT * FROM account_assignments').all() as AssignmentRow[];
|
||||
}
|
||||
|
||||
/** insertRulesDate dates a row this call creates (default now — callers pass
|
||||
* lib/kept-rules.ts stampRulesDate). An existing row keeps its rules date
|
||||
* and how it was set: re-resolving or re-pinning an account never makes it
|
||||
* look new. Only a row with no date at all (an older build's) takes
|
||||
* insertRulesDate. */
|
||||
export function setAssignment(
|
||||
accountId: number,
|
||||
firmId: string,
|
||||
accountTypeId: string | null,
|
||||
manual: boolean,
|
||||
insertRulesDate?: string,
|
||||
): void {
|
||||
const now = new Date().toISOString();
|
||||
db.prepare(`
|
||||
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at, rules_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(account_id) DO UPDATE SET
|
||||
firm_id = excluded.firm_id,
|
||||
account_type_id = excluded.account_type_id,
|
||||
manual = excluded.manual,
|
||||
resolved_at = excluded.resolved_at
|
||||
`).run(accountId, firmId, accountTypeId, manual ? 1 : 0, new Date().toISOString());
|
||||
resolved_at = excluded.resolved_at,
|
||||
rules_date = COALESCE(account_assignments.rules_date, excluded.rules_date)
|
||||
`).run(accountId, firmId, accountTypeId, manual ? 1 : 0, now, insertRulesDate ?? now);
|
||||
}
|
||||
|
||||
/** Writes an auto-resolved assignment, leaving any manual override alone.
|
||||
* The `manual = 0` guard is what makes a user's pick stick across syncs. */
|
||||
export function setAutoAssignment(accountId: number, firmId: string, accountTypeId: string | null): void {
|
||||
export function setAutoAssignment(
|
||||
accountId: number,
|
||||
firmId: string,
|
||||
accountTypeId: string | null,
|
||||
insertRulesDate?: string,
|
||||
): void {
|
||||
const existing = getAssignment(accountId);
|
||||
if (existing?.manual === 1) return;
|
||||
setAssignment(accountId, firmId, accountTypeId, false);
|
||||
setAssignment(accountId, firmId, accountTypeId, false, insertRulesDate);
|
||||
}
|
||||
|
||||
export function clearAssignment(accountId: number): boolean {
|
||||
return db.prepare('DELETE FROM account_assignments WHERE account_id = ?').run(accountId).changes > 0;
|
||||
/** Drops a manual pin but keeps the row — and with it the rules date, so
|
||||
* unpinning can't make an old account look new. The auto-resolver takes the
|
||||
* account back on its next pass. False when there is no row. */
|
||||
export function unpinAssignment(accountId: number): boolean {
|
||||
return db.prepare('UPDATE account_assignments SET manual = 0, account_type_id = NULL, resolved_at = ? WHERE account_id = ?')
|
||||
.run(new Date().toISOString(), accountId).changes > 0;
|
||||
}
|
||||
|
||||
/** Sets an account's rules date (an ISO toISOString() string); manual marks
|
||||
* it set by hand, which broker evidence never moves. False when the account
|
||||
* has no row. */
|
||||
export function setRulesDate(accountId: number, iso: string, manual: boolean): boolean {
|
||||
return db.prepare('UPDATE account_assignments SET rules_date = ?, rules_date_manual = ? WHERE account_id = ?')
|
||||
.run(iso, manual ? 1 : 0, accountId).changes > 0;
|
||||
}
|
||||
|
||||
/** Dates a row that has none (one an older build wrote) at `iso`; a dated
|
||||
* row is left alone. */
|
||||
export function stampMissingRulesDate(accountId: number, iso: string): void {
|
||||
db.prepare('UPDATE account_assignments SET rules_date = ? WHERE account_id = ? AND rules_date IS NULL').run(iso, accountId);
|
||||
}
|
||||
|
||||
/** Moves an auto rules date earlier to `iso` (the broker's evidence of when
|
||||
* the account opened, lib/kept-rules.ts openingEvidence); never later and
|
||||
* never a date set by hand. Compared as strings, which is time order only
|
||||
* because every rules date is a plain four-digit-year UTC toISOString()
|
||||
* string (lib/kept-rules.ts sortableIso) — keep it that way. */
|
||||
export function lowerAutoRulesDate(accountId: number, iso: string): void {
|
||||
db.prepare(`
|
||||
UPDATE account_assignments SET rules_date = ?
|
||||
WHERE account_id = ? AND rules_date_manual = 0 AND (rules_date IS NULL OR rules_date > ?)
|
||||
`).run(iso, accountId, iso);
|
||||
}
|
||||
|
||||
/**
|
||||
* One backed-up assignment onto this machine, every column (both restore
|
||||
* paths: lib/backup-restore.ts, and scripts/seed-settings.js by hand). The
|
||||
* backup's rules date and its manual flag win when the backup has one; a
|
||||
* backup from an older build has none, so an existing row keeps its own and
|
||||
* a new row is dated now, at the restore.
|
||||
*/
|
||||
export function restoreAssignment(row: BackupAssignmentRow): void {
|
||||
const now = new Date().toISOString();
|
||||
const resolvedAt = typeof row.resolved_at === 'string' ? row.resolved_at : now;
|
||||
// Only a date that sorts as a string counts as the backup's (anything
|
||||
// else is restored like an older build's undated row).
|
||||
const rulesDate = typeof row.rules_date === 'string' ? sortableIso(Date.parse(row.rules_date)) : null;
|
||||
if (rulesDate) {
|
||||
db.prepare(`
|
||||
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at, rules_date, rules_date_manual)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(account_id) DO UPDATE SET
|
||||
firm_id = excluded.firm_id,
|
||||
account_type_id = excluded.account_type_id,
|
||||
manual = excluded.manual,
|
||||
resolved_at = excluded.resolved_at,
|
||||
rules_date = excluded.rules_date,
|
||||
rules_date_manual = excluded.rules_date_manual
|
||||
`).run(row.account_id, row.firm_id, row.account_type_id ?? null, row.manual === 1 ? 1 : 0, resolvedAt,
|
||||
rulesDate, row.rules_date_manual === 1 ? 1 : 0);
|
||||
return;
|
||||
}
|
||||
db.prepare(`
|
||||
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at, rules_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(account_id) DO UPDATE SET
|
||||
firm_id = excluded.firm_id,
|
||||
account_type_id = excluded.account_type_id,
|
||||
manual = excluded.manual,
|
||||
resolved_at = excluded.resolved_at,
|
||||
rules_date = COALESCE(account_assignments.rules_date, excluded.rules_date)
|
||||
`).run(row.account_id, row.firm_id, row.account_type_id ?? null, row.manual === 1 ? 1 : 0, resolvedAt, now);
|
||||
}
|
||||
|
||||
// ── Settings ─────────────────────────────────────────────────────────────────
|
||||
@@ -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.
|
||||
const DEFAULT_ENABLED = new Set(['NQ', 'GC', 'CL']);
|
||||
|
||||
// Seed instruments table if empty
|
||||
// Seed instruments table if empty. Check and insert share one IMMEDIATE
|
||||
// transaction: `next build` imports this module from several workers at once,
|
||||
// and on a fresh machine they all saw an empty table and raced to insert the
|
||||
// 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) {
|
||||
const ins = db.prepare('INSERT INTO instruments (symbol, enabled) VALUES (?, ?)');
|
||||
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);
|
||||
}
|
||||
}).immediate();
|
||||
|
||||
export interface InstrumentRow {
|
||||
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 }[];
|
||||
}
|
||||
|
||||
/** 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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
db.exec(`
|
||||
|
||||
+152
-47
@@ -19,8 +19,10 @@
|
||||
|
||||
import { isFirmPlatform } from './master-protocol';
|
||||
import { sanitizeInstruments, sanitizeInstrumentLimits, DEFAULT_MAX_ENTRY_SPREAD } from './instrument-specs';
|
||||
import { normalizeAccountType } from './kept-rules';
|
||||
import type {
|
||||
CatalogAccountType, CatalogFirm, CatalogTradingSettings, FirmCatalogPayload, FirmPlatform, SymbolMap, SymbolMapCandidate,
|
||||
CatalogAccountType, CatalogFirm, CatalogTradingSettings, FirmCatalogPayload, FirmPlatform, KeptAccountRules, SymbolMap,
|
||||
SymbolMapCandidate,
|
||||
} from './master-protocol';
|
||||
import {
|
||||
getSetting,
|
||||
@@ -39,24 +41,50 @@ const FETCH_TIMEOUT_MS = 10_000;
|
||||
const COLD_RETRY_MIN_MS = 10_000;
|
||||
const COLD_RETRY_MAX_MS = 60_000;
|
||||
|
||||
/** The version of validatePayload below, stamped on the payload it caches.
|
||||
* Bump it whenever the parser starts keeping a field an older one dropped:
|
||||
* a cache from before then lacks the field however often it is re-read, and
|
||||
* `?since=` would answer "unchanged" until the master next changes, so the
|
||||
* first sync on a newer parser fetches the whole catalog.
|
||||
* 2: kept rule sets (keptAccountRules) and the firm's maxConcurrentAccounts.
|
||||
* Stamped only on a payload from a master that sends keptAccountRules: one
|
||||
* from an older master can't vouch for kept sets it never sent. */
|
||||
export const CATALOG_PARSER_VERSION = 2;
|
||||
|
||||
/** A Date header further off than this is somebody else's clock, not the master's. */
|
||||
const MAX_MASTER_OFFSET_MS = 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
interface CatalogCache {
|
||||
payload: FirmCatalogPayload | null;
|
||||
/** Index over every firm's account types, for the id lookup. */
|
||||
typesById: Map<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 & {
|
||||
__firmCatalog?: CatalogCache;
|
||||
__firmCatalogTimer?: ReturnType<typeof setTimeout>;
|
||||
__firmCatalogColdDelay?: number;
|
||||
/** The master's clock minus this one, from the last sync's Date header. */
|
||||
__masterOffsetMs?: number;
|
||||
};
|
||||
|
||||
function indexPayload(payload: FirmCatalogPayload | null): CatalogCache {
|
||||
const typesById = new Map<string, CatalogAccountType>();
|
||||
const keptByLiveId = new Map<string, KeptAccountRules[]>();
|
||||
for (const firm of payload?.firms ?? []) {
|
||||
for (const type of firm.accountTypes) typesById.set(type.id, type);
|
||||
for (const kept of firm.keptAccountRules ?? []) {
|
||||
const list = keptByLiveId.get(kept.keptFrom);
|
||||
if (list) list.push(kept);
|
||||
else keptByLiveId.set(kept.keptFrom, [kept]);
|
||||
}
|
||||
return { payload, typesById };
|
||||
}
|
||||
for (const list of keptByLiveId.values()) list.sort((a, b) => Date.parse(a.closedAt) - Date.parse(b.closedAt));
|
||||
return { payload, typesById, keptByLiveId };
|
||||
}
|
||||
|
||||
function cache(): CatalogCache {
|
||||
@@ -66,7 +94,14 @@ function cache(): CatalogCache {
|
||||
const raw = getSetting('catalog_payload');
|
||||
if (raw) {
|
||||
try {
|
||||
payload = validatePayload(JSON.parse(raw));
|
||||
const stored = JSON.parse(raw);
|
||||
payload = validatePayload(stored);
|
||||
// Whatever parsed the blob when it was stored, not this parser: a
|
||||
// field an older build dropped on the way in can't come back from
|
||||
// re-reading the blob.
|
||||
const parser = (stored as { parser?: unknown }).parser;
|
||||
if (typeof parser === 'number') payload.parser = parser;
|
||||
else delete payload.parser;
|
||||
} catch {
|
||||
console.error('[catalog] cached payload is unreadable — ignoring it');
|
||||
}
|
||||
@@ -87,6 +122,37 @@ export function getAccountTypeById(typeId: string): CatalogAccountType | undefin
|
||||
return cache().typesById.get(typeId);
|
||||
}
|
||||
|
||||
/** The kept rule sets of one live account type, oldest window first ([] when
|
||||
* it keeps none). lib/account-type-resolver.ts picks an account's by its
|
||||
* rules date. */
|
||||
export function getKeptRulesFor(liveTypeId: string): KeptAccountRules[] {
|
||||
return cache().keptByLiveId.get(liveTypeId) ?? [];
|
||||
}
|
||||
|
||||
/** The kept rule set ids loaded for a firm, for the report — null while the
|
||||
* cached catalog came from a parser older than CATALOG_PARSER_VERSION, which
|
||||
* can't be trusted to hold them (the master then reads this machine as not
|
||||
* applying kept rules yet). */
|
||||
export function getKeptRuleIdsForFirm(firmId: string): string[] | null {
|
||||
const payload = cache().payload;
|
||||
if (!payload || (payload.parser ?? 0) < CATALOG_PARSER_VERSION) return null;
|
||||
return (payload.firms.find((f) => f.id === firmId)?.keptAccountRules ?? []).map((k) => k.id);
|
||||
}
|
||||
|
||||
/** The master's clock minus this machine's, as of the last good sync (null
|
||||
* before one this process). Dates new accounts on the master's clock when
|
||||
* this one runs behind (lib/kept-rules.ts stampRulesDate). */
|
||||
export function getMasterOffsetMs(): number | null {
|
||||
return g.__masterOffsetMs ?? null;
|
||||
}
|
||||
|
||||
/** When the cached catalog last changed on the master (its payload's
|
||||
* updatedAt); null with no cache. No account first seen after a change can
|
||||
* be dated before it. */
|
||||
export function getCatalogUpdatedAt(): string | null {
|
||||
return cache().payload?.updatedAt ?? null;
|
||||
}
|
||||
|
||||
export function getCatalogMeta(): {
|
||||
version: number;
|
||||
updatedAt: string | null;
|
||||
@@ -131,7 +197,10 @@ export function getTradableFirms(): (CatalogFirm & { username: string; password:
|
||||
|
||||
// ── Sync ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const g2 = global as typeof globalThis & { __catalogUnknownPlatformWarned?: Set<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. */
|
||||
function warnUnknownPlatform(id: string, name: string, platform: unknown): void {
|
||||
@@ -164,12 +233,41 @@ function sanitizeSymbolMap(raw: unknown): SymbolMap {
|
||||
return map;
|
||||
}
|
||||
|
||||
function isStages(value: unknown): value is { profit: number; consistency: number; minTradingDays: number }[] {
|
||||
return Array.isArray(value) && value.every((s) =>
|
||||
typeof s === 'object' && s !== null
|
||||
&& typeof (s as Record<string, unknown>).profit === 'number'
|
||||
&& typeof (s as Record<string, unknown>).consistency === 'number'
|
||||
&& typeof (s as Record<string, unknown>).minTradingDays === 'number');
|
||||
function isDateString(value: unknown): value is string {
|
||||
return typeof value === 'string' && Number.isFinite(Date.parse(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* A firm's kept rule sets, each as strict as an account type: a malformed
|
||||
* entry throws and the whole sync is rejected. One that names no live type
|
||||
* of this firm is skipped instead (warned once) — no account can map to it.
|
||||
*/
|
||||
function parseKeptRules(
|
||||
firm: { id: string; name: string },
|
||||
raw: unknown[],
|
||||
accountTypes: CatalogAccountType[],
|
||||
tradesEveryDay: boolean,
|
||||
): KeptAccountRules[] {
|
||||
const liveIds = new Set(accountTypes.map((t) => t.id));
|
||||
const kept: KeptAccountRules[] = [];
|
||||
raw.forEach((k: unknown, j: number) => {
|
||||
const where = `kept rules ${j} of ${firm.name}`;
|
||||
const type = normalizeAccountType(k, { tradesEveryDay, where });
|
||||
const entry = k as Record<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
|
||||
@@ -204,39 +302,13 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
|
||||
const tradesEveryDay = dx && firm.tradesEveryDay === true;
|
||||
if (!Array.isArray(firm.accountTypes)) throw new Error(`firm ${firm.name} is missing accountTypes`);
|
||||
|
||||
const accountTypes: CatalogAccountType[] = firm.accountTypes.map((t: unknown, j: number) => {
|
||||
if (typeof t !== 'object' || t === null) throw new Error(`account type ${j} is not an object`);
|
||||
const type = t as Record<string, unknown>;
|
||||
for (const key of ['id', 'firmId', 'label', 'prefix', 'updatedAt']) {
|
||||
if (typeof type[key] !== 'string') throw new Error(`account type ${j} is missing ${key}`);
|
||||
}
|
||||
for (const key of [
|
||||
'accountSize', 'profitTarget', 'consistency', 'minDayPnL',
|
||||
'minTradingDays', 'maxLoss', 'maxPositionSize',
|
||||
]) {
|
||||
if (typeof type[key] !== 'number') throw new Error(`account type ${j} is missing ${key}`);
|
||||
}
|
||||
if (!isStages(type.withdrawalStages)) throw new Error(`account type ${j} has bad withdrawalStages`);
|
||||
// Lenient like platform: a master from before the split toggle
|
||||
// doesn't send the field, and those account types all split. Same
|
||||
// for CME holiday combining, where the old behavior was "off".
|
||||
return {
|
||||
...type,
|
||||
splitEnabled: type.splitEnabled !== false,
|
||||
// An every-day firm has no CME calendar to fold holidays by.
|
||||
combineCmeHolidays: type.combineCmeHolidays === true && !tradesEveryDay,
|
||||
// Lenient: a master from before the min-legs field means "no minimum".
|
||||
minLegs: typeof type.minLegs === 'number' ? type.minLegs : 0,
|
||||
// Lenient: the obfuscation stops are opt-in, so absent means off.
|
||||
firstDayStop: type.firstDayStop === true,
|
||||
minDayStop: type.minDayStop === true,
|
||||
// Lenient: no hard daily limit, no notional cap.
|
||||
dailyLossLimit: nonNegative(type.dailyLossLimit),
|
||||
// The firm's calendar, stamped on every type so the cfg-based
|
||||
// paths (projections, completion, the dashboard) all see it.
|
||||
tradesEveryDay,
|
||||
} as unknown as CatalogAccountType;
|
||||
});
|
||||
const accountTypes: CatalogAccountType[] = firm.accountTypes.map((t: unknown, j: number) =>
|
||||
normalizeAccountType(t, { tradesEveryDay, where: `account type ${j}` }));
|
||||
// Only when the master sent the list: absent = a master from before
|
||||
// kept rules, [] = every account on the current rules.
|
||||
const keptAccountRules = Array.isArray(firm.keptAccountRules)
|
||||
? parseKeptRules({ id: firm.id, name: firm.name }, firm.keptAccountRules, accountTypes, tradesEveryDay)
|
||||
: undefined;
|
||||
|
||||
firms.push({
|
||||
id: firm.id,
|
||||
@@ -248,6 +320,7 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
|
||||
...(platform !== 'dxtrade' ? { instrumentLimits: sanitizeInstrumentLimits(firm.instrumentLimits) } : {}),
|
||||
bannedSymbols: Array.isArray(firm.bannedSymbols) ? (firm.bannedSymbols as string[]) : [],
|
||||
accountTypes,
|
||||
...(keptAccountRules ? { keptAccountRules } : {}),
|
||||
serverUrl: typeof firm.serverUrl === 'string' ? firm.serverUrl : '',
|
||||
loginDomain: typeof firm.loginDomain === 'string' ? firm.loginDomain : '',
|
||||
commissionPct: dx ? nonNegative(firm.commissionPct) : 0,
|
||||
@@ -255,12 +328,24 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
|
||||
symbolMap: sanitizeSymbolMap(firm.symbolMap),
|
||||
targetHoldHours: nonNegative(firm.targetHoldHours),
|
||||
financingPctPerDay: nonNegative(firm.financingPctPerDay),
|
||||
// Lenient: absent or malformed = no firm cap.
|
||||
maxConcurrentAccounts: Math.floor(nonNegative(firm.maxConcurrentAccounts)),
|
||||
updatedAt: typeof firm.updatedAt === 'string' ? firm.updatedAt : new Date(0).toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
const trading = parseTradingSettings(body.trading);
|
||||
return { version: body.version, updatedAt: body.updatedAt, firms, ...(trading ? { trading } : {}) };
|
||||
// A kept-aware master sends keptAccountRules on every firm (even []). A
|
||||
// payload without it is a master from before kept rules: cached without
|
||||
// a parser version, so the cache isn't trusted to hold kept sets — every
|
||||
// sync fetches in full (no `since`), and the first one after a kept-aware
|
||||
// master is back brings them, though its catalog version never moved.
|
||||
const keptAware = body.firms.every((f: unknown) =>
|
||||
typeof f === 'object' && f !== null && Array.isArray((f as Record<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
|
||||
@@ -293,6 +378,17 @@ function recordError(message: string): void {
|
||||
setSetting('catalog_last_sync_at', new Date().toISOString());
|
||||
}
|
||||
|
||||
/** The master's clock off a response's Date header (whole seconds, read on
|
||||
* arrival — close enough to date accounts by). An unreadable header, or one
|
||||
* a year off, leaves the last offset standing. */
|
||||
function recordMasterClock(header: string | null): void {
|
||||
const at = header ? Date.parse(header) : NaN;
|
||||
if (!Number.isFinite(at)) return;
|
||||
const offset = at - Date.now();
|
||||
if (Math.abs(offset) > MAX_MASTER_OFFSET_MS) return;
|
||||
g.__masterOffsetMs = offset;
|
||||
}
|
||||
|
||||
/** If the master's database was rebuilt, its firms come back with fresh ids and
|
||||
* every credential row orphans. Match the survivors up by name (once) rather
|
||||
* than making someone retype every password. */
|
||||
@@ -323,14 +419,17 @@ export async function syncCatalog(): Promise<{ ok: boolean; changed: boolean; ve
|
||||
// before anyone has got round to naming it.
|
||||
// `since` only against the master the cache came from: two masters'
|
||||
// version numbers can collide, and an 'unchanged' from the wrong one
|
||||
// would keep the other's catalog.
|
||||
// would keep the other's catalog. Nor for a cache an older parser wrote:
|
||||
// it lacks what that parser dropped, and 'unchanged' would keep it so.
|
||||
const hasCache = getCachedCatalog().length > 0;
|
||||
const source = url.origin;
|
||||
if (hasCache && getSetting('catalog_source_url') === source) url.searchParams.set('since', String(version));
|
||||
const cacheCurrent = (cache().payload?.parser ?? 0) >= CATALOG_PARSER_VERSION;
|
||||
if (hasCache && cacheCurrent && getSetting('catalog_source_url') === source) url.searchParams.set('since', String(version));
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
||||
if (!res.ok) throw new Error(`master returned ${res.status}`);
|
||||
recordMasterClock(res.headers.get('date'));
|
||||
const body = await res.json();
|
||||
|
||||
if (body && typeof body === 'object' && (body as Record<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
|
||||
// unassigns all of its accounts. Keep the previous types and flag it.
|
||||
// unassigns all of its accounts. Keep the previous types and flag it,
|
||||
// with the kept rule sets that went with them (whose keptFrom named
|
||||
// types this payload lacks, so none of its own survived parsing). An
|
||||
// empty kept list on its own is never suspicious: it is every account
|
||||
// on the current rules.
|
||||
const previous = new Map(getCachedCatalog().map((f) => [f.id, f]));
|
||||
const suspicious: string[] = [];
|
||||
for (const firm of payload.firms) {
|
||||
const before = previous.get(firm.id);
|
||||
if (firm.accountTypes.length === 0 && before && before.accountTypes.length > 0) {
|
||||
firm.accountTypes = before.accountTypes;
|
||||
if (before.keptAccountRules) firm.keptAccountRules = before.keptAccountRules;
|
||||
else delete firm.keptAccountRules;
|
||||
suspicious.push(firm.name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -81,6 +81,10 @@ export interface AccountTypeStats {
|
||||
stage: number; drain?: boolean;
|
||||
}[];
|
||||
}[];
|
||||
/** Live accounts of this slice on each kept rule set — the slice itself
|
||||
* stays keyed by the live account type, whose id is the only one ever
|
||||
* reported as an accountTypeId. Optional. */
|
||||
keptRules?: { id: string; accounts: number }[];
|
||||
}
|
||||
|
||||
export interface FirmStats {
|
||||
@@ -139,6 +143,10 @@ export interface FirmStats {
|
||||
/** The catalog firm's uuid (statusVersion builds), so the master needn't
|
||||
* resolve the name. Optional. */
|
||||
firmId?: string;
|
||||
/** Kept rule set ids this machine has loaded for the firm. Present (even
|
||||
* []) = this build applies kept rules and its cached catalog was parsed
|
||||
* by a parser that understands them. Absent = old build or stale cache. */
|
||||
keptRuleIds?: string[];
|
||||
/** ISO time the firm's last good sync finished (statusVersion builds), sent
|
||||
* once synced. The statuses count only while it is recent. Optional. */
|
||||
syncedAt?: string;
|
||||
@@ -381,6 +389,22 @@ export interface CatalogAccountType {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Old rules an account type keeps for its accounts whose rules date is
|
||||
* inside [openedAt, closedAt) — set when the owner changes the type's rules
|
||||
* for new accounts only. Built as: live type X's fields, overwritten by the
|
||||
* snapshot's RULE fields, plus these. Never an account type itself: nothing
|
||||
* is pinned or reported by its id as an accountTypeId (lib/kept-rules.ts). */
|
||||
export interface KeptAccountRules extends CatalogAccountType {
|
||||
/** This kept set's own uuid — never a live account type id. */
|
||||
id: string;
|
||||
/** The live account type id (X). */
|
||||
keptFrom: string;
|
||||
/** ISO; inclusive lower bound on the rules date; null = none. */
|
||||
openedAt: string | null;
|
||||
/** ISO; exclusive upper bound. */
|
||||
closedAt: string;
|
||||
}
|
||||
|
||||
/** Where a firm's accounts trade. A build only knows the platforms listed
|
||||
* here; the catalog sync skips a firm on any other rather than guess. */
|
||||
export const FIRM_PLATFORMS = ['tradovate', 'topstepx', 'dxtrade'] as const;
|
||||
@@ -421,10 +445,17 @@ export interface CatalogFirm {
|
||||
/** The firm's website ('' = none; absent from older masters). The master
|
||||
* matches the extension's tab against it to find a person's 2FA code. */
|
||||
siteUrl?: string;
|
||||
/** The most of the firm's accounts this machine trades at once, on top of
|
||||
* its own max_concurrent_accounts (0 = no firm cap; absent from older
|
||||
* masters). */
|
||||
maxConcurrentAccounts?: number;
|
||||
/** Older masters' way to exclude roots; newer ones derive it from
|
||||
* `instruments`. */
|
||||
bannedSymbols: string[];
|
||||
accountTypes: CatalogAccountType[];
|
||||
/** Old rule sets the firm's account types keep for older accounts.
|
||||
* Optional; old builds drop it. */
|
||||
keptAccountRules?: KeptAccountRules[];
|
||||
/** DXTrade REST base, e.g. https://dx.tradeify247.co/dxsca-web ('' otherwise). */
|
||||
serverUrl?: string;
|
||||
/** DXTrade login domain ('' = 'default'). */
|
||||
@@ -458,6 +489,11 @@ export interface FirmCatalogPayload {
|
||||
firms: CatalogFirm[];
|
||||
/** Absent from a master from before its Settings page. */
|
||||
trading?: CatalogTradingSettings;
|
||||
/** Instance-local, never sent by the master: the catalog parser version
|
||||
* (lib/firm-catalog.ts CATALOG_PARSER_VERSION) that produced a cached
|
||||
* payload. An older parser dropped fields it didn't know, so a cache
|
||||
* without it, or below the current one, isn't trusted to hold them. */
|
||||
parser?: number;
|
||||
}
|
||||
|
||||
export interface FirmCatalogUnchanged {
|
||||
|
||||
+35
-12
@@ -302,30 +302,43 @@ const cents = (n: number) => Math.round(n * 100) / 100;
|
||||
/** Epoch ms a Date can hold — formatting anything else throws. */
|
||||
const validTime = (ms: number | undefined): ms is number => ms !== undefined && Number.isFinite(ms) && Math.abs(ms) <= 8.64e15;
|
||||
|
||||
/**
|
||||
* When the account opened, as far as the broker shows: its earliest positive
|
||||
* fund row's date (YYYY-MM-DD), or for a DXTrade account with none its
|
||||
* registration date, in Chicago. Undefined until the fund history has loaded
|
||||
* (`fundTransactions` undefined) and when neither says. The report's
|
||||
* firstFundDate. The resolver's evidence for an account's rules date reads
|
||||
* the fund rows through here (registeredAt undefined) but takes the
|
||||
* registration instant itself, not its day (lib/kept-rules.ts openingEvidence).
|
||||
*/
|
||||
export function firstFundDate(
|
||||
fundTransactions: { date: string; amount: number }[] | undefined,
|
||||
registeredAt: number | undefined,
|
||||
): string | undefined {
|
||||
if (!fundTransactions) return undefined;
|
||||
const positive = fundTransactions.filter((f) => f.amount > 0);
|
||||
if (positive.length > 0) return positive.reduce((min, f) => (f.date < min ? f.date : min), positive[0].date).slice(0, 10);
|
||||
return validTime(registeredAt) ? CT_DATE.format(new Date(registeredAt)) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The status fields one accounts[] entry adds. Every one is left out when it
|
||||
* is null or at its default — 2000 accounts must stay well under the master
|
||||
* socket's 1 MB frame. firstFundDate is the earliest positive fund row (a
|
||||
* DXTrade account with none falls back to its registration date, in Chicago);
|
||||
* positiveFunds counts those rows, and both wait for the fund history.
|
||||
* socket's 1 MB frame. firstFundDate: see the function of that name;
|
||||
* positiveFunds counts the positive fund rows, and both wait for the fund
|
||||
* history.
|
||||
*/
|
||||
export function accountStatusFields(input: AccountReportInput): Omit<ReportedAccount, 'name' | 'size' | 'payoutable' | 'withdrawals'> {
|
||||
const { status, reason } = accountStatus(input);
|
||||
const funds = input.fundTransactions;
|
||||
const positive = funds?.filter((f) => f.amount > 0);
|
||||
let firstFundDate: string | undefined;
|
||||
if (positive && positive.length > 0) {
|
||||
firstFundDate = positive.reduce((min, f) => (f.date < min ? f.date : min), positive[0].date).slice(0, 10);
|
||||
} else if (funds && validTime(input.registeredAt)) {
|
||||
firstFundDate = CT_DATE.format(new Date(input.registeredAt));
|
||||
}
|
||||
const positive = input.fundTransactions?.filter((f) => f.amount > 0);
|
||||
const firstFund = firstFundDate(input.fundTransactions, input.registeredAt);
|
||||
return {
|
||||
status,
|
||||
...(reason ? { reason } : {}),
|
||||
...(typeof input.cash === 'number' && Number.isFinite(input.cash) ? { balance: cents(input.cash) } : {}),
|
||||
...(Number.isFinite(input.floor) && input.floor !== 0 ? { floor: cents(input.floor) } : {}),
|
||||
...(input.accountTypeId ? { accountTypeId: input.accountTypeId } : {}),
|
||||
...(firstFundDate ? { firstFundDate } : {}),
|
||||
...(firstFund ? { firstFundDate: firstFund } : {}),
|
||||
...(positive ? { positiveFunds: positive.length } : {}),
|
||||
...(input.brokerStatus ? { brokerStatus: input.brokerStatus } : {}),
|
||||
};
|
||||
@@ -342,6 +355,14 @@ export function firmStatusFields(firmId: string, syncComplete: boolean, lastSync
|
||||
};
|
||||
}
|
||||
|
||||
/** FirmStats.keptRuleIds: the firm's kept rule set ids when this machine's
|
||||
* catalog can be trusted to hold them (lib/firm-catalog.ts
|
||||
* getKeptRuleIdsForFirm), else left out — the master then counts this
|
||||
* machine as trading every account on the newest rules. */
|
||||
export function keptRuleIdsField(keptRuleIds: string[] | null): Pick<FirmStats, 'keptRuleIds'> {
|
||||
return keptRuleIds ? { keptRuleIds } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* FirmStats for a firm with no accounts to walk — no client yet, or an empty
|
||||
* broker list. accounts is [] only once synced: before that, a firm whose
|
||||
@@ -352,6 +373,7 @@ export function firmStatusFields(firmId: string, syncComplete: boolean, lastSync
|
||||
export function emptyFirmStats(
|
||||
firm: { id: string; name: string },
|
||||
client: { syncComplete: boolean; lastSyncAt: number } | undefined,
|
||||
keptRuleIds: string[] | null = null,
|
||||
): FirmStats {
|
||||
const synced = client?.syncComplete === true;
|
||||
return {
|
||||
@@ -360,6 +382,7 @@ export function emptyFirmStats(
|
||||
...(synced ? { accounts: [] } : {}),
|
||||
accountTypes: [],
|
||||
...firmStatusFields(firm.id, synced, client?.lastSyncAt ?? 0),
|
||||
...keptRuleIdsField(keptRuleIds),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+20
-7
@@ -17,14 +17,14 @@ import { getBuildVersion } from './version';
|
||||
import { tradeMixFor } from './trade-mix';
|
||||
import { updaterStatus } from './updater-status';
|
||||
import { getClients } from './clients';
|
||||
import { getTradableFirms } from './firm-catalog';
|
||||
import { getAccountTypeById, getKeptRuleIdsForFirm, getTradableFirms } from './firm-catalog';
|
||||
import { getAccountConfig } from './account-type-resolver';
|
||||
import { getSchedulerStatus } from './auto-trade';
|
||||
import { botStopsActive, combineCmeSessions, computeDayPlan, currentTradeDateCT, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig, DEFAULT_TARGET_HOLD_HOURS } from './trading-logic';
|
||||
import type { AccountTypeStats, FirmStats, InstrumentStats, SymbolStats } from './master-protocol';
|
||||
import { accountNextLeg, accountLegShape } from './leg-estimate';
|
||||
import { instrumentStatsFor, isFuturesFirm } from './instruments';
|
||||
import { accountStatusFields, brokerFacts, emptyFirmStats, firmStatusFields, historyFresh } from './platform-client';
|
||||
import { accountStatusFields, brokerFacts, emptyFirmStats, firmStatusFields, historyFresh, keptRuleIdsField } from './platform-client';
|
||||
|
||||
function collectFirmStats() {
|
||||
// Only firms this machine has credentials for. A firm it cannot log into
|
||||
@@ -44,8 +44,10 @@ function collectFirmStats() {
|
||||
|
||||
return firms.map((f) => {
|
||||
const client = clients.get(f.id);
|
||||
// Absent (null) while this machine's catalog can't vouch for them.
|
||||
const keptRuleIds = getKeptRuleIdsForFirm(f.id);
|
||||
// accounts: [] only once synced (see emptyFirmStats).
|
||||
if (!client || client.accountList.length === 0) return emptyFirmStats(f, client);
|
||||
if (!client || client.accountList.length === 0) return emptyFirmStats(f, client, keptRuleIds);
|
||||
|
||||
let totalAccounts = 0;
|
||||
let accountsTraded = 0;
|
||||
@@ -63,16 +65,19 @@ function collectFirmStats() {
|
||||
// Per account type, so the master can say what the accounts still to
|
||||
// trade actually are. Keyed by account type id; unassigned accounts
|
||||
// share the null bucket — they inflate "left to trade" indefinitely,
|
||||
// which is precisely the thing worth being able to see.
|
||||
// which is precisely the thing worth being able to see. Always the
|
||||
// live type's id, label and size: accounts on a kept rule set count
|
||||
// in their live type's slice (and in its keptRules).
|
||||
const byType = new Map<string | null, AccountTypeStats>();
|
||||
const bucket = (cfg: { id: string; label: string; accountSize: number } | null): AccountTypeStats => {
|
||||
const key = cfg?.id ?? null;
|
||||
let entry = byType.get(key);
|
||||
if (!entry) {
|
||||
const live = key ? getAccountTypeById(key) ?? cfg : null;
|
||||
entry = {
|
||||
accountTypeId: key,
|
||||
label: cfg?.label ?? 'Unassigned',
|
||||
accountSize: cfg?.accountSize ?? 0,
|
||||
label: live?.label ?? 'Unassigned',
|
||||
accountSize: live?.accountSize ?? 0,
|
||||
totalAccounts: 0,
|
||||
accountsTraded: 0,
|
||||
inTrade: 0,
|
||||
@@ -89,7 +94,8 @@ function collectFirmStats() {
|
||||
const reportedCash = client.accountCashBalances[acc.id];
|
||||
const cash = reportedCash ?? { amount: 0, realizedPnL: 0 };
|
||||
const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0;
|
||||
const cfg = getAccountConfig(acc.id).accountType;
|
||||
const resolved = getAccountConfig(acc.id);
|
||||
const cfg = resolved.accountType;
|
||||
// undefined until the account's fund history has loaded. The master
|
||||
// clears a completed cycle once the withdrawal it was waiting for
|
||||
// shows up here, so an account that hasn't loaded reports no count
|
||||
@@ -122,6 +128,12 @@ function collectFirmStats() {
|
||||
|
||||
totalAccounts++;
|
||||
slice.totalAccounts++;
|
||||
if (resolved.keptRules) {
|
||||
const keptId = resolved.keptRules.id;
|
||||
const kept = (slice.keptRules ??= []).find((k) => k.id === keptId);
|
||||
if (kept) kept.accounts++;
|
||||
else slice.keptRules.push({ id: keptId, accounts: 1 });
|
||||
}
|
||||
|
||||
// Same combined-day view as the trade path: for a combining firm,
|
||||
// "today" spans the holiday stretch. Computed for every live
|
||||
@@ -399,6 +411,7 @@ function collectFirmStats() {
|
||||
...instrumentStatsBlock(f, client, rawToday),
|
||||
mix: tradeMixFor(f, client),
|
||||
...firmStatusFields(f.id, client.syncComplete, client.lastSyncAt),
|
||||
...keptRuleIdsField(keptRuleIds),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
+85
-24
@@ -24,10 +24,10 @@
|
||||
|
||||
import axios from 'axios';
|
||||
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 { 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 { topstepxProductCode, topstepxRoot, topstepCommission, TOPSTEP_COMMISSION_FALLBACK } from './topstep-fees';
|
||||
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
|
||||
* it only bites where that one can't. */
|
||||
const REST_DEADLINE_MS = 90_000;
|
||||
/** Balance the books don't explain past this is a fund event (reconcileFunds
|
||||
* books it); under it, fee noise and rounding. The smallest real withdrawal
|
||||
* in the drain flow is well above it. */
|
||||
const FUND_EVENT_MIN = 200;
|
||||
/** account_meta: what the day's first fund booking replaced (its date, and
|
||||
* last_fund_date and ended_cycle_start as they stood), so a reversal that
|
||||
* nets the day to fee noise can put them back. */
|
||||
const FUND_DAY_BEFORE_META = 'fund_day_before';
|
||||
|
||||
/** Rejects if `promise` hasn't settled within `ms`. The underlying operation
|
||||
* 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
|
||||
* settle — one line per episode, not one per tick. */
|
||||
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. */
|
||||
public lastSyncStartedAt = 0;
|
||||
/** 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
|
||||
* net withdrawal in one lump (fine — the last stage repeats).
|
||||
*
|
||||
* FUND_EVENT_MIN keeps fee noise and rounding out. Skipped while a
|
||||
* position is open (fills in flight) or while the trade history is
|
||||
* known-stale; until it books, unbookedBalanceChange holds the account.
|
||||
* FUND_EVENT_MIN keeps fee noise and rounding out. An amount is booked
|
||||
* only once a later check still sees it: for one pass, a fill landing
|
||||
* 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
|
||||
* 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 {
|
||||
if (this.positions[account.id]) return false;
|
||||
if (this.lastFetchErrors[account.name]) return false;
|
||||
const delta = this.unbookedBalance(account.id);
|
||||
if (delta === null) return false;
|
||||
const delta = this.positions[account.id] || this.lastFetchErrors[account.name]
|
||||
? null
|
||||
: this.unbookedBalance(account.id);
|
||||
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 startBal = getAccountConfig(account.id).accountType!.accountSize;
|
||||
const today = currentTradeDateCT();
|
||||
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
|
||||
// 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.
|
||||
const firstCheck = loadAccountMeta(account.id, 'funds_reconciled') === null;
|
||||
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
|
||||
// applyCycleSlice's rule for that date's own row, for the reporter's
|
||||
// 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;
|
||||
saveEndedCycleStart(account.id, { fundDate: prevFundDate ?? '', includesDay: !(prevAmt !== null && prevAmt < 0) });
|
||||
}
|
||||
// Same-day events merge into one row (the table keys on account+date),
|
||||
// so add today's delta onto whatever is already booked for today.
|
||||
const existingToday = txns.find((f) => f.date === today)?.amount ?? 0;
|
||||
saveFundTransactions(account.id, account.name, [
|
||||
{ date: today, amount: Math.round((existingToday + delta) * 100) / 100 },
|
||||
]);
|
||||
// Same-day events merge into one row (the table keys on
|
||||
// account+date): step.amount is today's delta on top of whatever
|
||||
// is already booked for today.
|
||||
saveFundTransactions(account.id, account.name, [{ date: today, amount: step.amount }]);
|
||||
saveAccountMeta(account.id, 'last_fund_date', today);
|
||||
this.fundTransactions[account.id] = loadFundTransactions(account.id);
|
||||
this.lastFundDates[account.id] = today;
|
||||
}
|
||||
this.fundTransactions[account.id] = loadFundTransactions(account.id);
|
||||
this.applyCycleSlice(account.id);
|
||||
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
|
||||
* ($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
|
||||
* 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
|
||||
* 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
|
||||
@@ -1421,6 +1481,7 @@ export class TopstepXClient implements PlatformClient {
|
||||
public forgetHistory(accountId: number, accountName: string): void {
|
||||
this.historyLoadedIds.delete(accountId);
|
||||
this.reconcilePendingIds.delete(accountId);
|
||||
this.fundSightings.delete(accountId);
|
||||
delete this.dailyPnL[accountId];
|
||||
delete this.fullDailyPnL[accountId];
|
||||
delete this.daysTraded[accountId];
|
||||
|
||||
@@ -1107,6 +1107,74 @@ export function unbookedPayoutDrop(
|
||||
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 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 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;
|
||||
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
@@ -7,7 +7,7 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"check": "node --experimental-strip-types --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/check-trading-logic.mjs scripts/check-instruments.mjs scripts/check-account-status.mjs"
|
||||
"check": "node --experimental-strip-types --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/check-trading-logic.mjs scripts/check-instruments.mjs scripts/check-account-status.mjs scripts/check-kept-rules.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@microsoft/signalr": "^10.0.11",
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
accountStatus, accountStatusFields, brokerFacts, emptyFirmStats, firmStatusFields, legacyDead,
|
||||
accountStatus, accountStatusFields, brokerFacts, emptyFirmStats, firmStatusFields, firstFundDate, keptRuleIdsField, legacyDead,
|
||||
REPORT_STATUS_VERSION, SYNC_STALE_MS,
|
||||
} from '../lib/platform-client.ts';
|
||||
|
||||
@@ -146,6 +146,22 @@ test('DXtrade: the registration date (Chicago) stands in for the first fund date
|
||||
assert.deepEqual(firmStatusFields('f', true, 1e17), { statusVersion: 1, firmId: 'f' });
|
||||
});
|
||||
|
||||
test('firstFundDate: the helper the report and the rules-date evidence share', () => {
|
||||
const registeredAt = Date.parse('2026-10-03T03:00:00Z'); // Oct 2, 22:00 CDT
|
||||
assert.equal(firstFundDate(undefined, registeredAt), undefined);
|
||||
assert.equal(firstFundDate([], registeredAt), '2026-10-02');
|
||||
assert.equal(firstFundDate([], undefined), undefined);
|
||||
assert.equal(firstFundDate([{ date: '2026-10-05T00:00:00', amount: 100_000 }, { date: '2026-10-04', amount: -50 }], registeredAt), '2026-10-05');
|
||||
assert.equal(firstFundDate([{ date: '2026-10-09', amount: 25 }, { date: '2026-10-05', amount: 100_000 }], undefined), '2026-10-05');
|
||||
// Always what accountStatusFields reports.
|
||||
for (const funds of [undefined, [], [{ date: '2026-09-25', amount: 50_000 }], [{ date: '2026-09-30', amount: -1 }]]) {
|
||||
for (const reg of [undefined, registeredAt, NaN]) {
|
||||
const fields = accountStatusFields({ cash: 1, floor: 0, active: true, historyReady: true, accountTypeId: 't', fundTransactions: funds, registeredAt: reg });
|
||||
assert.equal(fields.firstFundDate, firstFundDate(funds, reg));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('firm-level fields: statusVersion and firmId always, syncedAt once synced', () => {
|
||||
const at = Date.parse('2026-10-06T14:00:00Z');
|
||||
assert.deepEqual(firmStatusFields('f-1', true, at), { statusVersion: 1, firmId: 'f-1', syncedAt: '2026-10-06T14:00:00.000Z' });
|
||||
@@ -155,6 +171,12 @@ test('firm-level fields: statusVersion and firmId always, syncedAt once synced',
|
||||
assert.equal(SYNC_STALE_MS, 5 * 60 * 1000);
|
||||
});
|
||||
|
||||
test('keptRuleIds: present (even empty) when the catalog vouches for them, absent otherwise', () => {
|
||||
assert.deepEqual(keptRuleIdsField(null), {});
|
||||
assert.deepEqual(keptRuleIdsField([]), { keptRuleIds: [] });
|
||||
assert.deepEqual(keptRuleIdsField(['k1', 'k2']), { keptRuleIds: ['k1', 'k2'] });
|
||||
});
|
||||
|
||||
test('the no-accounts branch keeps every existing key and value', () => {
|
||||
const firm = { id: '71016d02-752f-4078-9e24-e74c9d75d1c8', name: 'Tradeify' };
|
||||
// What reporter.ts sent before (its :46-56 branch), for each client state.
|
||||
@@ -166,20 +188,26 @@ test('the no-accounts branch keeps every existing key and value', () => {
|
||||
});
|
||||
const at = Date.parse('2026-10-06T14:00:00Z');
|
||||
const states = [undefined, { syncComplete: false, lastSyncAt: 0 }, { syncComplete: false, lastSyncAt: at }, { syncComplete: true, lastSyncAt: 0 }, { syncComplete: true, lastSyncAt: at }];
|
||||
// No kept-rule ids (an older cache: null, or not passed), none, some.
|
||||
const keptCases = [undefined, null, [], ['3f0c2b7e-0000-4000-8000-000000000001']];
|
||||
for (const client of states) {
|
||||
const got = emptyFirmStats(firm, client);
|
||||
for (const kept of keptCases) {
|
||||
const got = kept === undefined ? emptyFirmStats(firm, client) : emptyFirmStats(firm, client, kept);
|
||||
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 synced = client?.syncComplete === true;
|
||||
assert.equal(got.statusVersion, 1);
|
||||
assert.equal(got.firmId, firm.id);
|
||||
assert.equal('accounts' in got, synced);
|
||||
assert.equal('syncedAt' in got, synced && client.lastSyncAt > 0);
|
||||
assert.deepEqual(added, ['firmId', 'statusVersion', ...(got.syncedAt ? ['syncedAt'] : [])].sort());
|
||||
assert.deepEqual(got.keptRuleIds, Array.isArray(kept) ? kept : undefined, label);
|
||||
assert.deepEqual(added, ['firmId', 'statusVersion', ...(got.syncedAt ? ['syncedAt'] : []), ...(Array.isArray(kept) ? ['keptRuleIds'] : [])].sort(), label);
|
||||
// The old keys stay first and in their order: only new keys follow.
|
||||
assert.deepEqual(Object.keys(got).slice(0, Object.keys(old).length), Object.keys(old));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Size: 4 firms × 500 accounts against the master socket's 1 MB frame ──────
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
botStopsActive, sizeNotional, isNoTradeTime, isPastSessionClose, nextTradingDate, effectiveCombine, DEFAULT_TARGET_HOLD_HOURS,
|
||||
projectPerfectPath, cycleCompletion, DLL_RESERVE_FRACTION, DLL_MIN_STOP, notionalCap, expectedSwapToWin, MARGIN_HEADROOM,
|
||||
dllReserve, DLL_SLIPPAGE_FRACTION, maxLossRoom, lastStand, breachDistance, LAST_STAND_FRACTION, planLegSize, rankForTarget,
|
||||
currentTradeDateCT, foldReason,
|
||||
currentTradeDateCT, foldReason, withinFirmCaps,
|
||||
} from '../lib/trading-logic.ts';
|
||||
|
||||
// Deterministic PRNG so a failure reproduces.
|
||||
@@ -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('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);
|
||||
});
|
||||
|
||||
@@ -71,7 +71,9 @@ db.exec(`
|
||||
firm_id TEXT NOT NULL,
|
||||
account_type_id TEXT,
|
||||
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 {
|
||||
@@ -79,6 +81,36 @@ try {
|
||||
} catch {
|
||||
// Column already exists
|
||||
}
|
||||
// The rules-date migration, as lib/db.ts runs it — including its backfill:
|
||||
// adding the column here means lib/db.ts's own ALTER never runs on this file,
|
||||
// and rows the restore doesn't overwrite must not stay undated. Dated as
|
||||
// lib/db.ts undatedRowsDate dates them: now, never before the catalog's last
|
||||
// change (a clock running behind must not date a row into an old rule set).
|
||||
function undatedRowsDate() {
|
||||
const row = db.prepare("SELECT value FROM settings WHERE key = 'catalog_updated_at'").get();
|
||||
const floor = row && typeof row.value === 'string' ? Date.parse(row.value) : NaN;
|
||||
return new Date(Math.max(Date.now(), Number.isFinite(floor) ? floor : 0)).toISOString();
|
||||
}
|
||||
// lib/kept-rules.ts sortableIso: the only rules dates stored are plain
|
||||
// four-digit-year ISO strings from 2000 on, so string order is time order.
|
||||
function sortableIso(ms) {
|
||||
if (!Number.isFinite(ms) || ms < Date.UTC(2000, 0, 1) || Math.abs(ms) > 8.64e15) return null;
|
||||
const iso = new Date(ms).toISOString();
|
||||
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(iso) ? iso : null;
|
||||
}
|
||||
try {
|
||||
db.transaction(() => {
|
||||
db.exec('ALTER TABLE account_assignments ADD COLUMN rules_date TEXT');
|
||||
db.prepare('UPDATE account_assignments SET rules_date = ? WHERE rules_date IS NULL').run(undatedRowsDate());
|
||||
}).immediate();
|
||||
} catch {
|
||||
// Column already exists
|
||||
}
|
||||
try {
|
||||
db.exec('ALTER TABLE account_assignments ADD COLUMN rules_date_manual INTEGER NOT NULL DEFAULT 0');
|
||||
} catch {
|
||||
// Column already exists
|
||||
}
|
||||
|
||||
const upsert = db.prepare(
|
||||
'INSERT INTO settings (key, value) VALUES (?, ?) ' +
|
||||
@@ -134,14 +166,29 @@ async function restore() {
|
||||
'INSERT INTO instruments (symbol, enabled) VALUES (?, ?) ' +
|
||||
'ON CONFLICT(symbol) DO UPDATE SET enabled = excluded.enabled'
|
||||
);
|
||||
const setAssign = db.prepare(`
|
||||
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
// lib/db.ts restoreAssignment, by hand: the backup's rules date (and how
|
||||
// it was set) wins; a backup from an older build has none, so an existing
|
||||
// row keeps its own and a new one is dated at the restore.
|
||||
const setAssignDated = db.prepare(`
|
||||
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at, rules_date, rules_date_manual)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(account_id) DO UPDATE SET
|
||||
firm_id = excluded.firm_id,
|
||||
account_type_id = excluded.account_type_id,
|
||||
manual = excluded.manual,
|
||||
resolved_at = excluded.resolved_at
|
||||
resolved_at = excluded.resolved_at,
|
||||
rules_date = excluded.rules_date,
|
||||
rules_date_manual = excluded.rules_date_manual
|
||||
`);
|
||||
const setAssignUndated = db.prepare(`
|
||||
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at, rules_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(account_id) DO UPDATE SET
|
||||
firm_id = excluded.firm_id,
|
||||
account_type_id = excluded.account_type_id,
|
||||
manual = excluded.manual,
|
||||
resolved_at = excluded.resolved_at,
|
||||
rules_date = COALESCE(account_assignments.rules_date, excluded.rules_date)
|
||||
`);
|
||||
|
||||
db.transaction(() => {
|
||||
@@ -162,7 +209,15 @@ async function restore() {
|
||||
setInstr.run(i.symbol, i.enabled ? 1 : 0);
|
||||
}
|
||||
for (const a of payload.assignments ?? []) {
|
||||
setAssign.run(a.account_id, a.firm_id, a.account_type_id ?? null, a.manual ? 1 : 0, a.resolved_at ?? new Date().toISOString());
|
||||
const now = new Date().toISOString();
|
||||
const resolvedAt = a.resolved_at ?? now;
|
||||
const rulesDate = typeof a.rules_date === 'string' ? sortableIso(Date.parse(a.rules_date)) : null;
|
||||
if (rulesDate) {
|
||||
setAssignDated.run(a.account_id, a.firm_id, a.account_type_id ?? null, a.manual ? 1 : 0, resolvedAt,
|
||||
rulesDate, a.rules_date_manual === 1 ? 1 : 0);
|
||||
} else {
|
||||
setAssignUndated.run(a.account_id, a.firm_id, a.account_type_id ?? null, a.manual ? 1 : 0, resolvedAt, now);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { FirmPlatform } from './lib/master-protocol';
|
||||
import type { KeptRulesWindow } from './lib/kept-rules';
|
||||
|
||||
/** When one of an account type's old rule sets applies: accounts whose rules
|
||||
* date is inside [openedAt, closedAt) keep those rules (openedAt null = no
|
||||
* lower bound). `id` is the kept set's own, never an account type id. */
|
||||
export type { KeptRulesWindow };
|
||||
|
||||
export interface AccountConfig {
|
||||
/** Master's account type id — what a manual assignment pins to. */
|
||||
@@ -36,6 +42,14 @@ export interface AccountConfig {
|
||||
tradesEveryDay?: boolean;
|
||||
}
|
||||
|
||||
/** An account type as the firm listing (/api/firms) carries it: the current
|
||||
* rules, plus the windows of the old rule sets it keeps, so the dashboard can
|
||||
* preview which rules a rules date lands on. */
|
||||
export interface FirmAccountType extends AccountConfig {
|
||||
/** Sorted by closedAt; empty when every account is on the current rules. */
|
||||
kept: KeptRulesWindow[];
|
||||
}
|
||||
|
||||
export interface FirmConfig {
|
||||
/** Master's firm id (uuid). */
|
||||
id: string;
|
||||
@@ -57,7 +71,7 @@ export interface FirmConfig {
|
||||
instrumentsSource: 'master' | 'legacy';
|
||||
/** The firm's account types, straight from the catalog. Doubles as the
|
||||
* option list for a manual account-type override. */
|
||||
accounts: AccountConfig[];
|
||||
accounts: FirmAccountType[];
|
||||
catalogUpdatedAt: string;
|
||||
}
|
||||
|
||||
@@ -138,6 +152,20 @@ export interface AccountState {
|
||||
inferredAccountSize: number | null;
|
||||
/** True when more than one account type matched and updatedAt decided it. */
|
||||
accountTypeAmbiguous: boolean;
|
||||
/** ISO. Decides which of the type's rule sets the account trades on: one
|
||||
* dated before a firm's rule change keeps the old rules. Defaults to when
|
||||
* the account started (broker's first deposit / registration), else when
|
||||
* this machine first saw it. null = no assignment row yet. */
|
||||
rulesDate: string | null;
|
||||
/** True when the rules date was set by hand — broker evidence never moves it. */
|
||||
rulesDateManual: boolean;
|
||||
/** The old rule set the account trades on; null = the type's current
|
||||
* rules. accountType above already carries these rules. */
|
||||
keptRules: (KeptRulesWindow & {
|
||||
/** Rule fields where the kept set differs from the type's current
|
||||
* rules — raw values; the dashboard formats them. */
|
||||
differs: { field: string; kept: unknown; now: unknown }[];
|
||||
}) | null;
|
||||
}
|
||||
|
||||
export interface FirmState {
|
||||
|
||||
Reference in New Issue
Block a user