/** * 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 } from './master-protocol'; export interface PlatformAccount { id: number; name: string; active: boolean; } 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; } 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. */ accountCashBalances: { [accountId: number]: { amount: number; realizedPnL: number; pendingRoll?: boolean; equity?: 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; 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; /** * 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; findFrontMonthContract(productName: string): 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; } /** * 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; }