Old rules for older accounts, a rules date per account, and a firm cap
When the master changes an account type with "New accounts only", it now serves the type's old rules as kept rule sets (firm.keptAccountRules), each for accounts dated inside [openedAt, closedAt). This build applies them: - getAccountConfig, the one rules lookup trading reads, puts a kept set's rule fields over the live type when the account's rules date falls in its window. Id, label, prefix, size and the projection fields stay the live type's, so every report still names the live type. - account_assignments gains rules_date and rules_date_manual. A new row is dated when the machine first sees the account, on the master's clock when this one runs behind and never before the catalog's last change. Auto dates come down to the broker's evidence: the end of the first fund day in Chicago, or a DXtrade registration instant. Evidence is applied before an account's first trade. Rows from before the migration get its time; unpinning keeps the row and its date. Backups carry both columns. - An account on a kept set stays on its type while its prefix and cached size still match, so a tie-break flip can't move it off its old rules. - Kept entries parse through the same normalizer as live types (now normalizeAccountType in lib/kept-rules.ts); a malformed one rejects the sync like a malformed type. The cached catalog records the parser that stored it, and a cache from an older parser is fetched in full rather than trusted with ?since= (this also brings in maxConcurrentAccounts). - Reports add keptRuleIds per firm and live counts per kept set per type. - The dashboard row and the account page show the rules date next to the account name, with an "old rules" chip saying what differs. Clicking the date edits it inline (PUT /api/accounts/:id/rules-date), previewing which rules the date lands on. Also: the master's per-firm cap on accounts traded at once (maxConcurrentAccounts), applied on top of the machine's own max concurrent when a batch is drawn and in copy trading. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
98016b3eb3
commit
c0899620d4
@@ -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 ─────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
+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),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1750,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,19 +188,25 @@ 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 old = legacy(client);
|
const got = kept === undefined ? emptyFirmStats(firm, client) : emptyFirmStats(firm, client, kept);
|
||||||
for (const [key, value] of Object.entries(old)) assert.deepEqual(got[key], value, `${JSON.stringify(client)}: ${key}`);
|
const old = legacy(client);
|
||||||
const added = Object.keys(got).filter((k) => !(k in old)).sort();
|
const label = `${JSON.stringify(client)} kept ${JSON.stringify(kept)}`;
|
||||||
const synced = client?.syncComplete === true;
|
for (const [key, value] of Object.entries(old)) assert.deepEqual(got[key], value, `${label}: ${key}`);
|
||||||
assert.equal(got.statusVersion, 1);
|
const added = Object.keys(got).filter((k) => !(k in old)).sort();
|
||||||
assert.equal(got.firmId, firm.id);
|
const synced = client?.syncComplete === true;
|
||||||
assert.equal('accounts' in got, synced);
|
assert.equal(got.statusVersion, 1);
|
||||||
assert.equal('syncedAt' in got, synced && client.lastSyncAt > 0);
|
assert.equal(got.firmId, firm.id);
|
||||||
assert.deepEqual(added, ['firmId', 'statusVersion', ...(got.syncedAt ? ['syncedAt'] : [])].sort());
|
assert.equal('accounts' in got, synced);
|
||||||
// The old keys stay first and in their order: only new keys follow.
|
assert.equal('syncedAt' in got, synced && client.lastSyncAt > 0);
|
||||||
assert.deepEqual(Object.keys(got).slice(0, Object.keys(old).length), Object.keys(old));
|
assert.deepEqual(got.keptRuleIds, Array.isArray(kept) ? kept : undefined, label);
|
||||||
|
assert.deepEqual(added, ['firmId', 'statusVersion', ...(got.syncedAt ? ['syncedAt'] : []), ...(Array.isArray(kept) ? ['keptRuleIds'] : [])].sort(), label);
|
||||||
|
// The old keys stay first and in their order: only new keys follow.
|
||||||
|
assert.deepEqual(Object.keys(got).slice(0, Object.keys(old).length), Object.keys(old));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -772,6 +772,33 @@ test('tick reasons fold their amounts, so like reasons count together', () => {
|
|||||||
assert.equal(foldReason('leg cap reached (5 today)'), 'leg cap reached (5 today)');
|
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) ──────────────────
|
// ── fund events from the balance (TopstepX reconcileFunds) ──────────────────
|
||||||
|
|
||||||
import { fundReconcileStep, FUND_CONFIRM_MS } from '../lib/trading-logic.ts';
|
import { fundReconcileStep, FUND_CONFIRM_MS } from '../lib/trading-logic.ts';
|
||||||
|
|||||||
+65
-10
@@ -67,11 +67,13 @@ db.exec(`
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS account_assignments (
|
CREATE TABLE IF NOT EXISTS account_assignments (
|
||||||
account_id INTEGER PRIMARY KEY,
|
account_id INTEGER PRIMARY KEY,
|
||||||
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