Files
autofirmer-expanded/lib/platform-client.ts
T
Brandon LiandClaude Opus 5.5 c3b58c8d8b Hold a payout that isn't booked yet, instead of re-earning it
On 2026-10-06 two Topstep Express accounts sat finished, waiting for their
payouts. The hub moved their balances the moment the payouts landed, but
reconcileFunds books a withdrawal only in the next sync. 28 seconds later
the plan read the drop as lost profit, reopened the cycle as stage 1 and
fired a full 3xNQ leg with no stop, on what should have been a drain day.
Both were liquidated against the $0 floor a payout leaves.

- TopstepX: unbookedBalanceChange holds an account while its balance is
  $200+ off its books (today's realized read fresh off the trades, so the
  5 PM roll doesn't trip it); the sync books it as before.
- Every platform: a finished cycle marks its books in account_meta; a later
  drop of $200+ with no fund row booked since holds the account until the
  payout books and moves it to its next stage. Relative to the mark, so
  history missing from before the cache cancels out; a wipe clears it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 12:13:48 -05:00

413 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
/**
* 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 is the earliest positive fund row (a
* DXTrade account with none falls back to its registration date, in Chicago);
* positiveFunds counts those 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 funds = input.fundTransactions;
const positive = funds?.filter((f) => f.amount > 0);
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 {
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 } : {}),
...(firstFundDate ? { firstFundDate } : {}),
...(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 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,
): 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),
};
}
/**
* 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;
}