565 lines
27 KiB
TypeScript
565 lines
27 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, 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;
|
||
/** When the instrument's market next closes for long (epoch ms; Vest: a
|
||
* weekend or holiday, not the maintenance hour), where the platform
|
||
* says: no leg starts that wouldn't be done by then (clearsClose), each
|
||
* account's at its own size (fireLeg). */
|
||
closesAt?: number;
|
||
}
|
||
|
||
/** What an order is for, where the caller knows (see sendOrder). */
|
||
export type OrderIntent = 'open' | 'close';
|
||
|
||
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<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;
|
||
/**
|
||
* 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;
|
||
/** The trade date at `now` (epoch ms) that this platform's daily rows and
|
||
* realized P&L follow, where its day turns at another hour than the
|
||
* 5 PM CT roll (Vest: 20:00 ET). The engine plans the accounts and
|
||
* counts their legs by it (tradeDateFor); absent = currentTradeDateCT. */
|
||
tradeDate?(now: number): string;
|
||
/** 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<PlatformContract | null>;
|
||
fetchInstrumentFees(symbols: string[]): Promise<{ [symbol: string]: number }>;
|
||
|
||
/** `intent`, where the caller knows what the order is for: an entry
|
||
* ('open') must never close a position it didn't know was there, a
|
||
* flatten ('close') must never open one on an account that turned out
|
||
* flat. A platform that checks it (orderEffect) refuses the order
|
||
* otherwise (NothingToCloseError for a close with nothing to close);
|
||
* left out, or on a platform that doesn't check, the position decides. */
|
||
sendOrder(
|
||
accountId: number,
|
||
contractSymbol: string,
|
||
quantity: number,
|
||
action: 'Buy' | 'Sell',
|
||
orderType: 'Market' | 'Limit',
|
||
price?: number,
|
||
intent?: OrderIntent,
|
||
): 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' }) };
|
||
}
|
||
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<ReportedAccount, 'name' | 'size' | 'payoutable' | 'withdrawals'> {
|
||
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<FirmStats, 'statusVersion' | 'firmId' | 'syncedAt'> {
|
||
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<FirmStats, 'keptRuleIds'> {
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* A close-only order (sendOrder's 'close') refused because a fresh read
|
||
* showed no position in the symbol to close, and the client's own record
|
||
* agrees: the account is flat there, so a flatten that gets this has nothing
|
||
* left to do — nothing rides without its stop.
|
||
*/
|
||
export class NothingToCloseError extends OrderNotPlacedError {
|
||
readonly nothingToClose = true;
|
||
constructor(message: string) {
|
||
super(message);
|
||
this.name = 'NothingToCloseError';
|
||
}
|
||
}
|
||
|
||
/** Duck-typed, like isOrderNotPlaced. */
|
||
export function isNothingToClose(err: unknown): boolean {
|
||
return !!err && typeof err === 'object' && (err as { nothingToClose?: unknown }).nothingToClose === true;
|
||
}
|
||
|
||
/**
|
||
* What an order on `side` does on a position-based platform (Vest, DXtrade),
|
||
* given a fresh read of the account's position in the symbol (`held`: its
|
||
* side, null when flat) and the client's own last record of it (`recorded`):
|
||
* open, or close (or reduce) — or it throws why nothing is sent. With no
|
||
* intent a flat account opens and the opposite side closes. An entry ('open')
|
||
* never closes: a hand trade the engine didn't know about would be closed in
|
||
* its place, and the leg's flatten would then open a position with no exits.
|
||
* A flatten ('close') never opens, and a flat read is NothingToCloseError only
|
||
* when the record agrees — one empty read just after a fill the client saw
|
||
* may be the gateway lagging, and a leg riding without its stop must still be
|
||
* shouted about. Adding to a position is never supported.
|
||
*/
|
||
export function orderEffect(o: {
|
||
symbol: string;
|
||
held: 'long' | 'short' | null;
|
||
recorded: 'long' | 'short' | null;
|
||
side: 'long' | 'short';
|
||
intent?: OrderIntent;
|
||
}): 'open' | 'close' {
|
||
const { symbol, held, recorded, side, intent } = o;
|
||
if (held === side) {
|
||
throw new OrderNotPlacedError(intent === 'close'
|
||
? `the account holds a ${held} ${symbol} position, not one to close — nothing sent`
|
||
: `adding to an open ${symbol} position is not supported`);
|
||
}
|
||
if (held && intent === 'open') {
|
||
throw new OrderNotPlacedError(`a ${held} ${symbol} position is already open — an entry never closes it, order not sent`);
|
||
}
|
||
if (!held && intent === 'close') {
|
||
if (recorded && recorded !== side) {
|
||
throw new OrderNotPlacedError(`a fresh read shows no ${symbol} position, but this client last saw a ${recorded} one open — nothing sent`);
|
||
}
|
||
throw new NothingToCloseError(`no ${symbol} position to close — nothing sent`);
|
||
}
|
||
return held ? 'close' : 'open';
|
||
}
|
||
|
||
/** 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;
|
||
}
|