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