Files
autofirmer-expanded/lib/platform-client.ts
T
Brandon LiandClaude Opus 5.5 77d8c2ecdb Trade only on loaded history; catch early TopstepX fills; fix SI/M2K point values
- Both clients track which accounts this process has loaded history for
  (historyReady). An account restarted, rebuilt, wiped or newly listed is
  ineligible until its fetch lands, instead of trading as a fresh stage-1
  first day; a failed fetch with no cache no longer reads as empty history.
  Waiting accounts get a targeted fetch on the next sync.
- Tradovate holds an account whose start-of-day balance moves without a fill
  (a payout between hourly Cash History runs) until a Cash History books it,
  so a payout-pending account isn't re-traded on its ended cycle.
- Wipe routes forget history through the client and count accounts whose
  history didn't come back as failures.
- TopstepX sendOrder picks up a fill that arrived before the place response,
  and its REST backstop uses the documented Trade/search and Order/search.
- POINT_VALUES: SI 5000 (was 50), M2K 5 (was 10).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:04:09 -05:00

186 lines
7.5 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` (e.g.
* "CON.F.US.ENQ.Z25") on TopstepX — everything that compares them only ever
* compares within one client, so the union is safe.
*/
export interface PlatformAccount {
id: number;
name: string;
active: boolean;
}
export interface PlatformContract {
id: number | string;
name: string;
tickSize: number;
}
export interface PlatformClient {
accountList: PlatformAccount[];
accountCashBalances: { [accountId: number]: { amount: number; realizedPnL: 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;
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;
/** 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<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: 'tradovate' | 'topstepx';
/** Holding a session token right now. */
authenticated: boolean;
/** Tradovate: socket readyState name; TopstepX: hub 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;
}