- Transfers: a close books its SETTLEMENT and COMMISSION under one transaction code, and keying by the code alone let the commission overwrite the P&L. Keys now carry the type, so the day-start balance, daily-loss room, realized P&L, daily history and books check all see closed-trade profit and loss. - Volatility from a week of hourly closes (was 48 h), still measured once per trade date, so one quiet weekend can't triple a hold estimate. - Each symbol's margin rate from the account's instrument details caps the position at available funds / rate (Tradeify: 5:1 crypto, 2:1 stocks); the account type's max notional stays as an optional ceiling. - Firms with overnight financing add the swap a winning leg is expected to pay (the winners' mean hold, (A²+2AB)/3σ²) to its take-profit, and the sizing and hold estimate count it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
229 lines
9.8 KiB
TypeScript
229 lines
9.8 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 } 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;
|
||
/** 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;
|
||
/**
|
||
* 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<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;
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|