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>
436 lines
20 KiB
TypeScript
436 lines
20 KiB
TypeScript
/**
|
||
* The surface a platform trading client must expose. TradovateClient grew this
|
||
* shape organically; TopstepXClient implements the same one so the scheduler,
|
||
* reporter, state route and resolver can drive either platform without caring
|
||
* which is behind the map entry.
|
||
*
|
||
* Contract ids are `number` on Tradovate and `string` on TopstepX (e.g.
|
||
* "CON.F.US.ENQ.Z25") and DXTrade (the symbol, e.g. "BTC/USD") — everything
|
||
* that compares them only ever compares within one client, so the union is
|
||
* safe.
|
||
*/
|
||
|
||
import type { FirmPlatform, FirmStats } from './master-protocol';
|
||
|
||
export interface PlatformAccount {
|
||
id: number;
|
||
name: string;
|
||
active: boolean;
|
||
}
|
||
|
||
/** How the batch's leg would size on a candidate instrument (see planLegSize):
|
||
* null when it can't be sized there. */
|
||
export interface PickContext {
|
||
targetHoldHours: number;
|
||
evaluate(contract: PlatformContract): { notional: number; holdHours: number; cost: number } | null;
|
||
}
|
||
|
||
export interface PlatformContract {
|
||
id: number | string;
|
||
name: string;
|
||
tickSize: number;
|
||
/** Dollars per 1.0 of price per engine contract. Absent = the CME
|
||
* product's POINT_VALUES entry (futures). */
|
||
pointValue?: number;
|
||
/** Commission as % of notional per side, where the firm charges that way. */
|
||
commissionPct?: number;
|
||
/** Notional of one engine contract at the current mid — present where the
|
||
* platform prices positions, which is what enables notional sizing. */
|
||
stepNotional?: number;
|
||
/** σ of log returns per √hour, from recent candles. */
|
||
volatilityPerHour?: number;
|
||
/** (ask − bid) / mid when the contract was picked. */
|
||
spreadFraction?: number;
|
||
/** Largest order the broker takes, in engine contracts. */
|
||
maxContracts?: number;
|
||
/** Smallest order the broker takes, in engine contracts. */
|
||
minContracts?: number;
|
||
/** Margin posted per dollar of notional (0.2 = 5:1 leverage), where the
|
||
* broker says — caps a position at available funds ÷ this. */
|
||
marginRate?: number;
|
||
}
|
||
|
||
export interface PlatformClient {
|
||
accountList: PlatformAccount[];
|
||
/** realizedPnL is today's. pendingRoll (Tradovate): the broker still shows
|
||
* the ended day's record and today's share of it can't be told apart —
|
||
* the account waits for the broker to roll it. equity: balance plus open
|
||
* P&L, where the platform reports it; availableFunds: what margin can
|
||
* still draw on, where the platform reports it. */
|
||
accountCashBalances: { [accountId: number]: { amount: number; realizedPnL: number; pendingRoll?: boolean; equity?: number; availableFunds?: number } };
|
||
positions: {
|
||
[accountId: number]: {
|
||
contractId: number | string;
|
||
netPos: number;
|
||
netPrice: number;
|
||
timestamp: Date;
|
||
};
|
||
};
|
||
|
||
daysTraded: { [accountId: number]: number };
|
||
dailyPnL: { [accountId: number]: { date: string; pnl: number }[] };
|
||
fullDailyPnL: { [accountId: number]: { date: string; pnl: number }[] };
|
||
lastFundDates: { [accountId: number]: string | null };
|
||
fundTransactions: { [accountId: number]: { date: string; amount: number }[] };
|
||
priorProfit: { [accountId: number]: number };
|
||
autoLiqThresholds: { [accountId: number]: number };
|
||
|
||
fetchDaysComplete: boolean;
|
||
syncComplete: boolean;
|
||
/** Epoch ms of the last sync that came back good (0 = never). */
|
||
lastSyncAt: number;
|
||
/** When that good sync started reading: everything it reported is at
|
||
* least this recent (a sequential pass can read an account long before
|
||
* it ends). */
|
||
lastSyncStartedAt?: number;
|
||
/** Whether the broker still shows a working order on the account. */
|
||
hasWorkingOrders?(accountId: number): boolean;
|
||
lastFetchErrors: Record<string, string>;
|
||
lastFetchRaw: Record<string, string>;
|
||
recentEntityEvents: { entityType: string; eventType: string; entity: unknown; ts: number }[];
|
||
products: { id: number; name: string }[];
|
||
|
||
fetchDaysTraded(onlyAccountIds?: number[]): Promise<{ failedAccounts: number }>;
|
||
/**
|
||
* Whether the account's history can be planned on. False until this
|
||
* client has loaded it (right after a restart, a client rebuild, a wipe,
|
||
* or for an account that just appeared), and on Tradovate while a balance
|
||
* change the history can't explain — a payout between reports — waits
|
||
* for a Cash History. The missing rows and withdrawals would otherwise
|
||
* read as a fresh stage-1 cycle, so such an account must not trade.
|
||
*/
|
||
historyReady(accountId: number): boolean;
|
||
/** historyReady() as it stands, for a reader that must not change it: no
|
||
* new hold, no baseline moved. Only where historyReady() has side effects
|
||
* (Tradovate's balance-drift check); elsewhere historyReady() is the read. */
|
||
peekHistoryReady?(accountId: number): boolean;
|
||
/** Dollars of balance the account's books don't explain yet, or null when
|
||
* they do. Only where balances arrive live but fund events are booked
|
||
* later (TopstepX: the hub, then the next sync); the engine holds the
|
||
* account until they're booked, or a payout plans as lost profit. */
|
||
unbookedBalanceChange?(accountId: number): number | null;
|
||
/** What the broker says about the account beyond active (DXTrade: its
|
||
* accountStatus and registration time, epoch ms or NaN). */
|
||
brokerAccountMeta?(accountId: number): { status: string; registrationTime: number } | undefined;
|
||
/**
|
||
* The account's closed balance at the firm's last daily reset — what a
|
||
* hard daily loss limit is measured from. Only platforms that know the
|
||
* firm's own day implement it (DXTrade: 22:00 UTC); the rest leave it out
|
||
* and the engine falls back to the session's realized P&L. null = the
|
||
* platform knows it but can't tell yet (history not fetched): hold the
|
||
* account rather than size a stop on a guess.
|
||
*/
|
||
dayStartBalance?(accountId: number): number | null;
|
||
/** Drop the account's history from memory (the wipe routes), so it reads
|
||
* unloaded until the next fetch rebuilds it. */
|
||
forgetHistory(accountId: number, accountName: string): void;
|
||
/** The contract a product trades as. A platform that chooses between
|
||
* instruments (DXTrade's symbol map) ranks them with `pick` when given —
|
||
* how a leg would size on each — and platforms that don't, ignore it. */
|
||
findFrontMonthContract(productName: string, pick?: PickContext): Promise<PlatformContract | null>;
|
||
fetchInstrumentFees(symbols: string[]): Promise<{ [symbol: string]: number }>;
|
||
|
||
sendOrder(
|
||
accountId: number,
|
||
contractSymbol: string,
|
||
quantity: number,
|
||
action: 'Buy' | 'Sell',
|
||
orderType: 'Market' | 'Limit',
|
||
price?: number
|
||
): Promise<Record<string, unknown>>;
|
||
|
||
placeOrderNoWait(
|
||
accountId: number,
|
||
contractSymbol: string,
|
||
quantity: number,
|
||
action: 'Buy' | 'Sell',
|
||
orderType: 'Market' | 'Limit',
|
||
price?: number
|
||
): Promise<{ orderId?: number }>;
|
||
|
||
placeOcoExit(
|
||
accountId: number,
|
||
contractSymbol: string,
|
||
quantity: number,
|
||
exitAction: 'Buy' | 'Sell',
|
||
limitPrice: number,
|
||
stopPrice: number,
|
||
): Promise<{ orderId?: number }>;
|
||
|
||
disconnect(): void;
|
||
onFill(orderId: number, callback: (fill: Record<string, unknown>) => void): void;
|
||
health(): ClientHealth;
|
||
}
|
||
|
||
/**
|
||
* Liveness snapshot of one client — what /api/health shows, what the registry
|
||
* supervisor judges, and the numbers to read before restarting a broken box.
|
||
* Timestamps are epoch ms, 0 = never. Never carries tokens or credentials.
|
||
*/
|
||
export interface ClientHealth {
|
||
platform: FirmPlatform;
|
||
/** Holding a session token right now. */
|
||
authenticated: boolean;
|
||
/** Tradovate: socket readyState name; TopstepX: hub state; DXTrade: 'rest'
|
||
* or its push socket's state. 'none' = no transport. */
|
||
transport: string;
|
||
syncComplete: boolean;
|
||
lastSyncAt: number;
|
||
lastLoginAt: number;
|
||
lastLoginError: string | null;
|
||
consecutiveLoginFailures: number;
|
||
/** When the next reconnect/login attempt fires (0 = none scheduled). */
|
||
nextReconnectAt: number;
|
||
tokenExpiresAt: number;
|
||
/** Last frame from the broker's realtime channel (socket or hub). */
|
||
lastInboundAt: number;
|
||
/** Requests still waiting on a broker answer (acks, fills). */
|
||
pendingRequests: number;
|
||
/** When the in-flight sync started (0 = none in flight). */
|
||
syncInFlightSince: number;
|
||
/** When the in-flight daily P&L report fetch started (0 = none in flight). */
|
||
daysFetchInFlightSince: number;
|
||
}
|
||
|
||
/** A sync older than this means positions and balances can't be traded on. */
|
||
export const SYNC_STALE_MS = 5 * 60 * 1000;
|
||
|
||
/** The one readiness rule shared by the scheduler, the supervisor and /api/health:
|
||
* synced, and synced recently. */
|
||
export function isClientReady(client: PlatformClient, now = Date.now()): boolean {
|
||
return client.syncComplete && client.lastSyncAt > 0 && now - client.lastSyncAt < SYNC_STALE_MS;
|
||
}
|
||
|
||
// ── Account status, as the master's ledger reads it ──────────────────────────
|
||
//
|
||
// Pure, so scripts/check-account-status.mjs can load this file. The reporter
|
||
// is the only caller: the scheduler, the dashboards and the trading decisions
|
||
// keep their own copies of the dead test for now.
|
||
|
||
/** FirmStats.statusVersion: this build sends the status fields. The master
|
||
* reads a firm without it as an older build — unknown, never dead. */
|
||
export const REPORT_STATUS_VERSION = 1 as const;
|
||
|
||
export type ReportedAccount = NonNullable<FirmStats['accounts']>[number];
|
||
export type AccountReportStatus = NonNullable<ReportedAccount['status']>;
|
||
|
||
export interface AccountStatusInput {
|
||
/** The cash balance; null or undefined while the platform has none. */
|
||
cash: number | null | undefined;
|
||
/** Auto-liquidation floor; 0 = none known. */
|
||
floor: number;
|
||
active: boolean;
|
||
historyReady: boolean;
|
||
/** Why the platform has the account inactive, where it can say. */
|
||
inactiveReason?: string;
|
||
/** Why a dead account is dead, where the platform can say. */
|
||
deadReason?: string;
|
||
}
|
||
|
||
/** The dead test the reporter has always applied: a floor (0 = none, negative
|
||
* = a reconstructed TopstepX MLL) the cash is at or below. It read a missing
|
||
* balance as 0, so pass `cash ?? 0` for the old answer. */
|
||
export function legacyDead(cashOrZero: number, floor: number): boolean {
|
||
return floor !== 0 && cashOrZero <= floor;
|
||
}
|
||
|
||
/**
|
||
* The account's status, first match wins:
|
||
* 1. no balance → unknown (cash_missing) — never dead, never 0;
|
||
* 2. the legacy dead test → dead;
|
||
* 3. history not loaded → history_pending;
|
||
* 4. inactive → locked: the firm stopped it trading (DLL, liquidate-only,
|
||
* canTrade=false, CLOSE_ONLY — a pass lock looks the same);
|
||
* 5. live.
|
||
* So with a balance, 'dead' is exactly legacyDead(cash, floor).
|
||
*/
|
||
export function accountStatus(input: AccountStatusInput): { status: AccountReportStatus; reason?: string } {
|
||
const { cash, floor } = input;
|
||
if (cash === null || cash === undefined) return { status: 'unknown', reason: 'cash_missing' };
|
||
if (legacyDead(cash, floor)) return input.deadReason ? { status: 'dead', reason: input.deadReason } : { status: 'dead' };
|
||
if (!input.historyReady) return { status: 'history_pending' };
|
||
if (!input.active) return { status: 'locked', reason: input.inactiveReason || 'inactive' };
|
||
return { status: 'live' };
|
||
}
|
||
|
||
export interface BrokerFacts {
|
||
brokerStatus?: string;
|
||
inactiveReason?: string;
|
||
deadReason?: string;
|
||
registeredAt?: number;
|
||
}
|
||
|
||
/**
|
||
* The broker's own words for an account, per platform. Tradovate's list
|
||
* entries carry closed/archived/restricted, and an inactive one there is
|
||
* liquidate-only (a DLL lockout and a pass look alike). TopstepX's active is
|
||
* canTrade. DXTrade's accountStatus decides both — CLOSE_ONLY inactive, the
|
||
* terminal ones dead (dxtrade-class.ts DEAD_STATUSES) — and FULL_TRADING is
|
||
* the normal case, so it isn't repeated. Each field is left out when there is
|
||
* nothing to say.
|
||
*/
|
||
export function brokerFacts(
|
||
platform: FirmPlatform,
|
||
account: { active: boolean; closed?: unknown; archived?: unknown; restricted?: unknown },
|
||
meta?: { status: string; registrationTime: number },
|
||
): BrokerFacts {
|
||
if (platform === 'tradovate') {
|
||
const flags = (['closed', 'archived', 'restricted'] as const).filter((k) => account[k] === true);
|
||
return { inactiveReason: 'liquidate_only', ...(flags.length > 0 ? { brokerStatus: flags.join(',') } : {}) };
|
||
}
|
||
if (platform === 'topstepx') {
|
||
return { inactiveReason: 'cannot_trade', ...(account.active ? {} : { brokerStatus: 'canTrade:false' }) };
|
||
}
|
||
const status = meta?.status || undefined;
|
||
return {
|
||
inactiveReason: status === 'CLOSE_ONLY' ? 'close_only' : status ? `status:${status}` : 'inactive',
|
||
...(status ? { deadReason: `terminal:${status}` } : {}),
|
||
...(status && status !== 'FULL_TRADING' ? { brokerStatus: status } : {}),
|
||
...(meta && validTime(meta.registrationTime) ? { registeredAt: meta.registrationTime } : {}),
|
||
};
|
||
}
|
||
|
||
export interface AccountReportInput extends AccountStatusInput, BrokerFacts {
|
||
/** The account's resolved catalog type; null or undefined = unassigned. */
|
||
accountTypeId: string | null | undefined;
|
||
/** The account's fund rows; undefined until its fund history has loaded. */
|
||
fundTransactions: { date: string; amount: number }[] | undefined;
|
||
}
|
||
|
||
const CT_DATE = new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Chicago', year: 'numeric', month: '2-digit', day: '2-digit' });
|
||
const cents = (n: number) => Math.round(n * 100) / 100;
|
||
/** Epoch ms a Date can hold — formatting anything else throws. */
|
||
const validTime = (ms: number | undefined): ms is number => ms !== undefined && Number.isFinite(ms) && Math.abs(ms) <= 8.64e15;
|
||
|
||
/**
|
||
* When the account opened, as far as the broker shows: its earliest positive
|
||
* fund row's date (YYYY-MM-DD), or for a DXTrade account with none its
|
||
* registration date, in Chicago. Undefined until the fund history has loaded
|
||
* (`fundTransactions` undefined) and when neither says. The report's
|
||
* firstFundDate. The resolver's evidence for an account's rules date reads
|
||
* the fund rows through here (registeredAt undefined) but takes the
|
||
* registration instant itself, not its day (lib/kept-rules.ts openingEvidence).
|
||
*/
|
||
export function firstFundDate(
|
||
fundTransactions: { date: string; amount: number }[] | undefined,
|
||
registeredAt: number | undefined,
|
||
): string | undefined {
|
||
if (!fundTransactions) return undefined;
|
||
const positive = fundTransactions.filter((f) => f.amount > 0);
|
||
if (positive.length > 0) return positive.reduce((min, f) => (f.date < min ? f.date : min), positive[0].date).slice(0, 10);
|
||
return validTime(registeredAt) ? CT_DATE.format(new Date(registeredAt)) : undefined;
|
||
}
|
||
|
||
/**
|
||
* The status fields one accounts[] entry adds. Every one is left out when it
|
||
* is null or at its default — 2000 accounts must stay well under the master
|
||
* socket's 1 MB frame. firstFundDate: see the function of that name;
|
||
* positiveFunds counts the positive fund rows, and both wait for the fund
|
||
* history.
|
||
*/
|
||
export function accountStatusFields(input: AccountReportInput): Omit<ReportedAccount, 'name' | 'size' | 'payoutable' | 'withdrawals'> {
|
||
const { status, reason } = accountStatus(input);
|
||
const positive = input.fundTransactions?.filter((f) => f.amount > 0);
|
||
const firstFund = firstFundDate(input.fundTransactions, input.registeredAt);
|
||
return {
|
||
status,
|
||
...(reason ? { reason } : {}),
|
||
...(typeof input.cash === 'number' && Number.isFinite(input.cash) ? { balance: cents(input.cash) } : {}),
|
||
...(Number.isFinite(input.floor) && input.floor !== 0 ? { floor: cents(input.floor) } : {}),
|
||
...(input.accountTypeId ? { accountTypeId: input.accountTypeId } : {}),
|
||
...(firstFund ? { firstFundDate: firstFund } : {}),
|
||
...(positive ? { positiveFunds: positive.length } : {}),
|
||
...(input.brokerStatus ? { brokerStatus: input.brokerStatus } : {}),
|
||
};
|
||
}
|
||
|
||
/** The firm-level keys every FirmStats from this build adds. syncedAt only
|
||
* once the client has synced: the master trusts the statuses while it is
|
||
* recent (SYNC_STALE_MS). */
|
||
export function firmStatusFields(firmId: string, syncComplete: boolean, lastSyncAt: number): Pick<FirmStats, 'statusVersion' | 'firmId' | 'syncedAt'> {
|
||
return {
|
||
statusVersion: REPORT_STATUS_VERSION,
|
||
firmId,
|
||
...(syncComplete && validTime(lastSyncAt) && lastSyncAt > 0 ? { syncedAt: new Date(lastSyncAt).toISOString() } : {}),
|
||
};
|
||
}
|
||
|
||
/** FirmStats.keptRuleIds: the firm's kept rule set ids when this machine's
|
||
* catalog can be trusted to hold them (lib/firm-catalog.ts
|
||
* getKeptRuleIdsForFirm), else left out — the master then counts this
|
||
* machine as trading every account on the newest rules. */
|
||
export function keptRuleIdsField(keptRuleIds: string[] | null): Pick<FirmStats, 'keptRuleIds'> {
|
||
return keptRuleIds ? { keptRuleIds } : {};
|
||
}
|
||
|
||
/**
|
||
* FirmStats for a firm with no accounts to walk — no client yet, or an empty
|
||
* broker list. accounts is [] only once synced: before that, a firm whose
|
||
* accounts haven't arrived would read as every saved account erased. A synced
|
||
* firm with none also says when it synced, so the master sees a fresh, empty
|
||
* list rather than an unknown one.
|
||
*/
|
||
export function emptyFirmStats(
|
||
firm: { id: string; name: string },
|
||
client: { syncComplete: boolean; lastSyncAt: number } | undefined,
|
||
keptRuleIds: string[] | null = null,
|
||
): FirmStats {
|
||
const synced = client?.syncComplete === true;
|
||
return {
|
||
firm: firm.name, totalAccounts: 0, accountsTraded: 0, inTrade: 0,
|
||
targetHit: 0, partial: 0, loss: 0, personalDll: 0, completed: 0,
|
||
...(synced ? { accounts: [] } : {}),
|
||
accountTypes: [],
|
||
...firmStatusFields(firm.id, synced, client?.lastSyncAt ?? 0),
|
||
...keptRuleIdsField(keptRuleIds),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Thrown when a client KNOWS an order never reached the broker: no live
|
||
* connection, the broker refused it, or it failed before anything was sent.
|
||
* Anything ambiguous (a timeout waiting for an ack or a fill — it may still
|
||
* have filled) must stay a plain Error. The scheduler un-counts a leg only for
|
||
* this one, so a wrong "not placed" could let an account trade twice.
|
||
*/
|
||
export class OrderNotPlacedError extends Error {
|
||
readonly orderNotPlaced = true;
|
||
constructor(message: string) {
|
||
super(message);
|
||
this.name = 'OrderNotPlacedError';
|
||
}
|
||
}
|
||
|
||
/** Duck-typed so it survives module duplication across route bundles. */
|
||
export function isOrderNotPlaced(err: unknown): boolean {
|
||
return !!err && typeof err === 'object' && (err as { orderNotPlaced?: unknown }).orderNotPlaced === true;
|
||
}
|
||
|
||
/** An error's message without the rest of it. AxiosErrors carry the request
|
||
* config — Tradovate's login body (the password) and Bearer tokens — so they
|
||
* must never be logged whole. */
|
||
export function errorMessage(err: unknown): string {
|
||
if (!err || typeof err !== 'object') return String(err);
|
||
const e = err as { message?: unknown; code?: unknown; response?: { status?: unknown } };
|
||
const status = typeof e.response?.status === 'number' ? ` (HTTP ${e.response.status})` : '';
|
||
const code = typeof e.code === 'string' && !String(e.message ?? '').includes(e.code) ? ` [${e.code}]` : '';
|
||
return `${typeof e.message === 'string' ? e.message : String(err)}${status}${code}`;
|
||
}
|
||
|
||
/** lastFetchErrors note for a Tradovate account whose Cash History is
|
||
* unavailable (passed accounts) and whose rows came from the Fills report —
|
||
* a fresh fetch, not a failure. */
|
||
export const FILLS_FALLBACK_NOTE = 'cash history unavailable (using fills fallback)';
|
||
|
||
/**
|
||
* Can this account's daily P&L be trusted for a verdict the master keeps
|
||
* for good (cycleCompletion 'today')? Not while a fetch run is in flight —
|
||
* at startup Tradovate serves the DB cache before each account's fetch —
|
||
* and not when the account's last fetch failed and left cached rows behind.
|
||
*/
|
||
export function historyFresh(client: PlatformClient, account: PlatformAccount): boolean {
|
||
if (!client.fetchDaysComplete) return false;
|
||
const error = client.lastFetchErrors?.[account.name];
|
||
return !error || error === FILLS_FALLBACK_NOTE;
|
||
}
|