/** * 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 }; /** Share of a position's notional a stop may fill past its price — the * part of the daily-loss reserve that grows with size (dllReserve). * Only where this platform's stops are measured to fill closer than * DLL_SLIPPAGE_FRACTION, the default. */ stopSlippageFraction?: 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; lastFetchRaw: Record; 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; /** * The equity the account may not fall below today, where the platform * states its daily-loss floor itself and moves it with the day's * transfers (Vest: reset balance × (1 − rate), lowered by a payout). The * engine's room is then what's left of the stage's limit (widened by * dayGainForRoom), never more than balance − floor (floorRoom), so a * same-day payout that lowers the floor frees room to trade again. null = * the platform has one but it isn't known yet (hold); undefined = none * here (the engine falls back to dayStartBalance / realized P&L). */ dailyLossFloor?(accountId: number): number | null | undefined; /** * With dailyLossFloor: how much of the day's gain a leg starting now, its * take-profit `leg` away and its room `limit` before any gain, may add to * its daily-loss room (floorRoom's `gain`) — the gain still in the account * since the day's base, no more than keeps the leg all but sure to close * before the platform's daily reset (resetClearStop). */ dayGainForRoom?(accountId: number, o: { leg: number; limit: number }): number; /** Payouts made since the firm's last daily reset — what picks the stage * of an account type whose stages reset each day. */ sameDayClaims?(accountId: number): number; /** Each payout taken from the account, by the day it counts on — one * entry per payout, where a fund row can hold several (Vest books a * whole day's claims as one); undefined until the platform's own record * of them has been read. Without it, each negative fund row is one, on * its date. */ payoutDays?(accountId: number): string[] | undefined; /** Request a payout of `amount` from the account to the login's own main * account, where the platform takes one by API (Vest's Claim Profit). * Resolves once the platform accepted it and the books were updated; * rejects with the platform's reason otherwise. */ claimProfit?(accountId: number, amount: number): Promise<{ claimed: number; received: number | null }>; /** A claim this client made that no good sync has read past yet: the * balance on hand may not show it, so no claim may be judged on it. */ claimPending?(accountId: number): boolean; /** 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; fetchInstrumentFees(symbols: string[]): Promise<{ [symbol: string]: number }>; sendOrder( accountId: number, contractSymbol: string, quantity: number, action: 'Buy' | 'Sell', orderType: 'Market' | 'Limit', price?: number ): Promise>; 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) => 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[number]; export type AccountReportStatus = NonNullable; 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' }) }; } if (platform === 'vest') { // Vest's capital account status is a number; 2 is the live one // (vest-helpers VEST_STATUS_ACTIVE). A breached account leaves the // active list, so no status here reads as terminal. const status = meta?.status; return { inactiveReason: status ? `status:${status}` : 'inactive', ...(status && status !== '2' ? { brokerStatus: `status:${status}` } : {}), ...(meta && validTime(meta.registrationTime) ? { registeredAt: meta.registrationTime } : {}), }; } 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 { 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 { 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 { 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; }