TopstepX and Tradovate report fills, so a trade is built from them, flat to flat (lib/fill-trades.ts), off the window each sync already reads: TopstepX's two days of Trade/search, Tradovate's sync snapshot. Neither says who placed an order, so every order the bot places is noted in own_orders, and a trade is the bot's when its opening fill's order is one; its stop or take-profit closing it reads as such. A contract is written only when the position before its window comes out the same two passes running, so a fill landing between the position and history reads can't build the wrong trades. DXtrade reports positions: its record groups the last two days' filled orders by position code, with each close's SETTLEMENT and COMMISSION. The bot's own orders carry af- codes, so nothing is kept locally. Only accounts with something going on are read, at most every 5 minutes. The Trades button shows on every firm with a login, and the account page lists any account's trades. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2433 lines
121 KiB
TypeScript
2433 lines
121 KiB
TypeScript
// Create a class with a constructor that takes a username and password and sets the credentials
|
||
|
||
import axios from './http-client';
|
||
import type { AccountItem, AuthLoginResponse, Contract } from './tradovate-helpers';
|
||
import { computeSec, randomUUIDV4 } from './tradovate-helpers';
|
||
import { currentTradeDateCT } from './trading-logic';
|
||
import { POINT_VALUES } from './instrument-specs';
|
||
import { getCachedContract, resolveContracts } from './contract-resolver';
|
||
import { saveDailyPnL, loadDailyPnL, saveAccountMeta, loadAccountMeta, saveFundTransactions, loadFundTransactions, saveEndedCycleStart, recordOwnOrder, type OwnOrderKind } from './db';
|
||
import { recordFillTrades, type ContractFills } from './trade-recorder';
|
||
import { contractRoot, type Fill } from './fill-trades';
|
||
import { brokerWebSocket } from './outbound-proxy';
|
||
import { FILLS_FALLBACK_NOTE, OrderNotPlacedError, SYNC_STALE_MS, errorMessage, type ClientHealth } from './platform-client';
|
||
|
||
/**
|
||
* Entity types Tradovate sends over the user-sync socket. Keep this as the
|
||
* single source of truth: the inline socket-payload type and the indirect
|
||
* callback list previously declared it separately and fell out of sync, which
|
||
* made the 'position' and 'cashBalance' handlers unreachable to the compiler.
|
||
*/
|
||
type TradovateEntityType =
|
||
| 'order'
|
||
| 'orderVersion'
|
||
| 'auditUserAction'
|
||
| 'command'
|
||
| 'commandReport'
|
||
| 'fill'
|
||
| 'executionReport'
|
||
| 'position'
|
||
| 'cashBalance';
|
||
|
||
/**
|
||
* What auth/accesstokenrequest can answer. HTTP 200 is not success on its
|
||
* own: a rate-limited login comes back 200 with no token and a penalty ticket
|
||
* instead (p-ticket, plus p-time in seconds to wait before presenting it, and
|
||
* p-captcha when Tradovate wants a human), and a refused one with errorText.
|
||
*/
|
||
type LoginResponseBody = Partial<AuthLoginResponse> & {
|
||
'p-ticket'?: string;
|
||
'p-time'?: number;
|
||
'p-captcha'?: boolean;
|
||
errorText?: string;
|
||
};
|
||
|
||
type LoginOutcome =
|
||
| { ok: true; info: AuthLoginResponse }
|
||
| { ok: false; error: string; retryInMs?: number };
|
||
|
||
/** A listener for pushed entity events. One-shot: it is removed once it fires.
|
||
* `cancel` (sendOrder's fill wait) rejects the waiter when the client is
|
||
* discarded, so no caller is left hanging on a dead instance. */
|
||
type IndirectCallback = {
|
||
entityType: TradovateEntityType;
|
||
eventType: 'Created' | 'Updated';
|
||
// Since the entity is not always the same, we need a validator to check if the response is the one we are looking for
|
||
validator: (response: any) => boolean;
|
||
callback: (response: any) => void;
|
||
cancel?: (reason: string) => void;
|
||
};
|
||
|
||
/*
|
||
* Connection supervision.
|
||
*
|
||
* This client used to be `login().then(...)` with exactly one retry scheduled
|
||
* from ws.onclose. One failed re-login — weekend maintenance, a network blip,
|
||
* a rate-limit penalty answered as HTTP 200 without a token — left it dead for
|
||
* the life of the process, with syncComplete frozen, and every firm on the
|
||
* instance waited on it until someone ran a pm2 restart. A session could also
|
||
* die without onclose ever firing (a socket stuck CLOSING, sync answers that
|
||
* just stop), and an order sent on a closed socket waited forever.
|
||
*
|
||
* Now: every failure path ends in scheduleReconnect(); a 15s watchdog notices
|
||
* sockets that went quiet, never authorized, or got stuck, and re-arms a
|
||
* reconnect if nothing is pending; nothing waits on the broker without a
|
||
* deadline.
|
||
*/
|
||
/** Reconnect after a socket drop on a still-good session. */
|
||
const SOCKET_DROP_RECONNECT_MS = 5_000;
|
||
/** Reconnect when the session itself is suspect — straight to a fresh login. */
|
||
const FORCED_RELOGIN_DELAY_MS = 1_000;
|
||
/** Login failures back off 5s, 10s, 20s, … capped at 5 min. */
|
||
const BACKOFF_BASE_MS = 5_000;
|
||
const BACKOFF_MAX_MS = 5 * 60_000;
|
||
/** A captcha demand means we've logged in too often — go quiet for a long while. */
|
||
const CAPTCHA_RETRY_MS = 15 * 60_000;
|
||
const PENALTY_MAX_WAIT_MS = 5 * 60_000;
|
||
const MAX_PENALTY_ROUNDS = 2;
|
||
/** Deadline for login / token renewal / contract lookups over REST. */
|
||
const HTTP_TIMEOUT_MS = 15_000;
|
||
/** Report calls answer with a p-ticket to poll when they're slow, so a single
|
||
* HTTP call taking this long is a hung connection, not a big report. */
|
||
const REPORT_TIMEOUT_MS = 60_000;
|
||
const LIVENESS_CHECK_MS = 15_000;
|
||
const HANDSHAKE_TIMEOUT_MS = 30_000;
|
||
const AUTHORIZE_TIMEOUT_MS = 30_000;
|
||
/** Tradovate heartbeats every few seconds and we sync every 60s — this much
|
||
* silence means the session is gone even if the socket says OPEN. */
|
||
const INBOUND_SILENCE_MS = 150_000;
|
||
const STUCK_CLOSING_MS = 15_000;
|
||
const TOKEN_RENEW_CHECK_MS = 2 * 60_000;
|
||
const TOKEN_RENEW_WINDOW_MS = 15 * 60_000;
|
||
/** A failed renewal this close to expiry logs in afresh instead of waiting. */
|
||
const TOKEN_RELOGIN_WINDOW_MS = 2 * 60_000;
|
||
/** A reconnect reuses the current token only if it has at least this long left. */
|
||
const TOKEN_REUSE_MIN_MS = 5 * 60_000;
|
||
/** An order ack later than this is treated as lost (ambiguous — it may have been placed). */
|
||
const ACK_TIMEOUT_MS = 15_000;
|
||
const FILL_TIMEOUT_MS = 30_000;
|
||
/** Order states that can no longer fill (Tradovate ordStatus); any other —
|
||
* Working, PendingNew/Replace/Cancel, Suspended, Unknown — still might. */
|
||
const FINISHED_ORDER_STATUSES = new Set(['Filled', 'Canceled', 'Rejected', 'Expired', 'Completed']);
|
||
/** How far the start-of-day balance may move without a fill before the
|
||
* account's history is treated as overtaken (balanceDrifted). Fills move
|
||
* amount and realizedPnL together, so this only has to absorb rounding;
|
||
* a payout is hundreds. */
|
||
const FUND_DRIFT_TOLERANCE = 25;
|
||
const CONTRACT_FIND_TIMEOUT_MS = 15_000;
|
||
/** How often a product's fees are asked for again, for the trades record. */
|
||
const FEES_REFRESH_MS = 6 * 60 * 60 * 1000;
|
||
/** On-demand front-month resolve (several Yahoo lookups, then Tradovate). */
|
||
const CONTRACT_RESOLVE_DEADLINE_MS = 30_000;
|
||
const READY_STATE_NAMES = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'];
|
||
|
||
function reconnectBackoffMs(failures: number): number {
|
||
return Math.min(BACKOFF_BASE_MS * 2 ** Math.max(0, failures - 1), BACKOFF_MAX_MS);
|
||
}
|
||
|
||
interface CashBaseline { dayStart: number; realized: number }
|
||
/** fundSig: the fund transactions (date:amount) held when the hold began — a
|
||
* Cash History "books" the change when they differ. checks counts Cash
|
||
* History reports, attempts every refetch. */
|
||
interface HistoryHold { reason: 'balance' | 'wipe'; fundSig: string; since: number; checks: number; attempts: number; retryAt: number }
|
||
|
||
/** A held account's first refetch comes after this, then doubling to an hour. */
|
||
const HOLD_RETRY_MS = 5 * 60_000;
|
||
const HOLD_RETRY_MAX_MS = 60 * 60_000;
|
||
/** A balance hold no Cash History ever explains with a fund transaction — a
|
||
* fee, or an adjustment the report files as trading — is let go after this
|
||
* many clean reports spanning at least HOLD_RELEASE_MS, with a warning. */
|
||
const HOLD_MAX_CHECKS = 3;
|
||
const HOLD_RELEASE_MS = 15 * 60_000;
|
||
/** One no Cash History can ever check (the account's report is refused for
|
||
* good — a passed account's 404s) is let go after this long, with a warning. */
|
||
const HOLD_UNCHECKED_RELEASE_MS = 60 * 60_000;
|
||
/** account_meta keys: the baseline a Cash History vouched for, and a wipe's
|
||
* hold (so a restart onto a failing report keeps it). */
|
||
const BASELINE_META = 'cash_baseline';
|
||
const HOLD_META = 'history_hold';
|
||
const ROLL_BASE_META = 'roll_base';
|
||
const WIPE_SIG_META = 'wipe_fund_sig';
|
||
/** A pre-wipe fund signature that wasn't kept: no rebuilt row counts as new. */
|
||
const UNKNOWN_SIG = '?';
|
||
|
||
function holdRetryMs(attempts: number): number {
|
||
return Math.min(HOLD_RETRY_MAX_MS, HOLD_RETRY_MS * 2 ** Math.max(0, attempts - 1));
|
||
}
|
||
|
||
/** Fund transactions as one comparable value: a second payout on a date that
|
||
* already has one changes the amount, not the row count. */
|
||
function fundSignature(txns: { date: string; amount: number }[] | undefined): string {
|
||
return (txns ?? []).map((f) => `${f.date}:${f.amount}`).join('|');
|
||
}
|
||
|
||
/** Whether `now` holds a fund transaction `before` didn't (a rebuild after a
|
||
* wipe can lose rows older than the report's window without booking any). */
|
||
function bookedNew(before: string, now: string): boolean {
|
||
if (before === UNKNOWN_SIG) return false;
|
||
const had = new Set(before ? before.split('|') : []);
|
||
return (now ? now.split('|') : []).some((row) => !had.has(row));
|
||
}
|
||
|
||
/** Whether cash is still what the baseline accounts for: the same start-of-day
|
||
* balance ('same'), or the daily reset having folded the day's realized P&L
|
||
* into the balance ('folded'). */
|
||
function cashExplained(base: CashBaseline, cash: { amount: number; realizedPnL: number }): 'same' | 'folded' | null {
|
||
const dayStart = cash.amount - cash.realizedPnL;
|
||
if (Math.abs(dayStart - base.dayStart) <= FUND_DRIFT_TOLERANCE) return 'same';
|
||
if (Math.abs(dayStart - (base.dayStart + base.realized)) <= FUND_DRIFT_TOLERANCE) return 'folded';
|
||
return null;
|
||
}
|
||
|
||
/** A Tradovate cashBalance entity, as the sync snapshot and socket carry it. */
|
||
interface CashBalanceEntity {
|
||
accountId: number;
|
||
amount: number;
|
||
realizedPnL: number;
|
||
tradeDate?: { year: number; month: number; day: number } | null;
|
||
archived?: boolean;
|
||
}
|
||
|
||
/** A stored cash record. realizedPnL and pendingRoll are judged when read. */
|
||
interface StoredCash {
|
||
amount: number;
|
||
realizedPnL: number;
|
||
tradeDate: string | null;
|
||
archived: boolean;
|
||
/** For a record the broker hasn't rolled past its trade date: its
|
||
* realizedPnL when first read after the roll window — what later fills
|
||
* on the same record are counted from. */
|
||
rollBase: number | undefined;
|
||
/** Inside the roll window with the ended day's record still in place:
|
||
* the account waits for the broker to roll it. */
|
||
pendingRoll: boolean;
|
||
}
|
||
|
||
/** Tradovate rolled every account seen on 2026-09-24 at 17:17–17:21 CT. Until
|
||
* this long after 5 PM, an account still on the ended day's record waits. */
|
||
const ROLL_WINDOW_END_MIN = 17 * 60 + 45;
|
||
|
||
function inRollWindow(now: Date = new Date()): boolean {
|
||
const parts = new Intl.DateTimeFormat('en-US', {
|
||
hour: 'numeric', minute: 'numeric', hour12: false, timeZone: 'America/Chicago',
|
||
}).formatToParts(now);
|
||
const get = (type: string) => parseInt(parts.find((p) => p.type === type)?.value ?? '0', 10);
|
||
const minutes = (get('hour') % 24) * 60 + get('minute');
|
||
return minutes >= 17 * 60 && minutes < ROLL_WINDOW_END_MIN;
|
||
}
|
||
|
||
/**
|
||
* An account's cash as the plan must read it. Tradovate rolls each account's
|
||
* cash record to the new trade date on its own schedule — archiving the old
|
||
* record and opening one with realizedPnL 0 — well after the 17:00 CT roll
|
||
* (17-21 minutes on 2026-09-24), and an idle account perhaps not until it
|
||
* trades. Until then the latest record still carries the ended day's
|
||
* realizedPnL, which the plan would read as today's: legs sized off
|
||
* yesterday, one-and-done accounts read as already traded, the
|
||
* entry-commission delta swallowing a whole day's P&L.
|
||
*
|
||
* So inside the roll window an account on the ended day's record waits
|
||
* (pendingRoll) — nothing is traded on it, so nothing is booked onto it that
|
||
* the roll could lose. Past the window, a record still not rolled counts from
|
||
* its value when first read then: 0 now, and any fill booked onto it later
|
||
* shows. Its amount already holds the ended day.
|
||
*/
|
||
function todaysCash(cb: CashBalanceEntity, prev: StoredCash | undefined): StoredCash {
|
||
const t = cb.tradeDate;
|
||
const tradeDate = t?.year
|
||
? `${t.year}-${String(t.month).padStart(2, '0')}-${String(t.day).padStart(2, '0')}`
|
||
: null;
|
||
const recorded = cb.realizedPnL;
|
||
const ended = () => tradeDate !== null && tradeDate < currentTradeDateCT();
|
||
const entry: StoredCash = {
|
||
amount: cb.amount,
|
||
tradeDate,
|
||
archived: cb.archived === true,
|
||
rollBase: prev && prev.tradeDate === tradeDate ? prev.rollBase : undefined,
|
||
get realizedPnL(): number {
|
||
if (!ended()) return recorded;
|
||
if (inRollWindow()) return 0;
|
||
entry.rollBase ??= latchRollBase(cb.accountId, tradeDate!, recorded);
|
||
return Math.round((recorded - entry.rollBase) * 100) / 100;
|
||
},
|
||
get pendingRoll(): boolean {
|
||
return ended() && inRollWindow();
|
||
},
|
||
};
|
||
return entry;
|
||
}
|
||
|
||
/** The roll base for an unrolled record, kept on record per trade date: a
|
||
* restart later the same trade date must count from the same point, not
|
||
* from a value that already includes the day's fills. */
|
||
function latchRollBase(accountId: number, recordDate: string, recorded: number): number {
|
||
const day = currentTradeDateCT();
|
||
try {
|
||
const saved = JSON.parse(loadAccountMeta(accountId, ROLL_BASE_META) ?? 'null') as
|
||
{ record?: string; day?: string; base?: number } | null;
|
||
if (saved?.record === recordDate && saved.day === day && Number.isFinite(saved.base)) return saved.base!;
|
||
} catch { /* unreadable: latch afresh */ }
|
||
saveAccountMeta(accountId, ROLL_BASE_META, JSON.stringify({ record: recordDate, day, base: recorded }));
|
||
return recorded;
|
||
}
|
||
|
||
/**
|
||
* Store a cash record unless the one already held is newer. At the roll the
|
||
* snapshot and the socket deliver the ended day's record (archived) and the
|
||
* new one together, in either order; the older must never win back. carried
|
||
* is the entry a fresh snapshot map replaces: it hands on the roll base, and
|
||
* a snapshot requested before the socket delivered a newer record can't
|
||
* undo it.
|
||
*/
|
||
function storeCash(into: { [accountId: number]: StoredCash }, cb: CashBalanceEntity, carried?: StoredCash): void {
|
||
const held = into[cb.accountId] ?? carried;
|
||
const next = todaysCash(cb, held);
|
||
if (held?.tradeDate && next.tradeDate) {
|
||
const older = next.tradeDate < held.tradeDate
|
||
|| (next.tradeDate === held.tradeDate && next.archived && !held.archived);
|
||
if (older) {
|
||
into[cb.accountId] ??= held;
|
||
return;
|
||
}
|
||
}
|
||
into[cb.accountId] = next;
|
||
}
|
||
|
||
/** ±20%, so several clients on one IP that failed together don't retry in lockstep. */
|
||
function jitter(ms: number): number {
|
||
return Math.round(ms * (0.8 + Math.random() * 0.4));
|
||
}
|
||
|
||
function fmtDelay(ms: number): string {
|
||
return ms >= 60_000 ? `${(ms / 60_000).toFixed(1)}m` : `${(ms / 1000).toFixed(1)}s`;
|
||
}
|
||
|
||
/** Settles like `work`, or rejects once `ms` pass first. The work itself
|
||
* carries on — only the wait for it is cut short. */
|
||
function withDeadline<T>(work: Promise<T>, ms: number, what: string): Promise<T> {
|
||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||
const deadline = new Promise<never>((_, reject) => {
|
||
timer = setTimeout(() => reject(new Error(`${what} took longer than ${ms / 1000}s`)), ms);
|
||
});
|
||
return Promise.race([work, deadline]).finally(() => clearTimeout(timer));
|
||
}
|
||
|
||
export class TradovateClient {
|
||
private name: string;
|
||
private password: string;
|
||
private accessInfo!: AuthLoginResponse;
|
||
private deviceId = randomUUIDV4();
|
||
|
||
public accountList: AccountItem[] = [];
|
||
/** Per account, its latest cash record — today's realized P&L judged at
|
||
* read time (see todaysCash). */
|
||
public accountCashBalances: { [accountId: number]: StoredCash } = {};
|
||
public positions: {
|
||
[accountId: number]: {
|
||
contractId: number;
|
||
netPos: number;
|
||
netPrice: number;
|
||
timestamp: Date;
|
||
};
|
||
} = {};
|
||
|
||
public daysTraded: { [accountId: number]: number } = {};
|
||
public dailyPnL: { [accountId: number]: { date: string; pnl: number }[] } = {};
|
||
/** Full P&L history (all cycles) — used for calendar display */
|
||
public fullDailyPnL: { [accountId: number]: { date: string; pnl: number }[] } = {};
|
||
/** Date of the last fund transaction per account — days traded are counted from this date onwards */
|
||
public lastFundDates: { [accountId: number]: string | null } = {};
|
||
/** All fund transactions (deposits/withdrawals) per account */
|
||
public fundTransactions: { [accountId: number]: { date: string; amount: number }[] } = {};
|
||
/** Sum of daily P&L from before the last fund transaction — used for "Target Same Equity" mode */
|
||
public priorProfit: { [accountId: number]: number } = {};
|
||
/** Balance floor set by the prop firm — account is blown when amount <= this value (0 = not set) */
|
||
public autoLiqThresholds: { [accountId: number]: number } = {};
|
||
|
||
/** True once fetchDaysTraded() has finished its last full run */
|
||
public fetchDaysComplete = false;
|
||
/** Prevent overlapping report fetches and throttle them to hourly cadence with retry backoff. */
|
||
private daysFetchInFlight = false;
|
||
private lastDaysFetchStartedAt = 0;
|
||
private lastDaysFetchSucceededAt = 0;
|
||
private nextDaysFetchRetryAt = 0;
|
||
/** Accounts whose history this process has loaded: a report came back, or
|
||
* failed with a DB cache to fall back on. Until then the plan would read
|
||
* a fresh stage-1 cycle, so historyReady() keeps them out of trading. */
|
||
private historyLoadedIds = new Set<number>();
|
||
/** Accounts held out of trading until a Cash History books what their
|
||
* history is missing: 'balance' — a balance change it can't explain (a
|
||
* payout between the hourly reports); 'wipe' — a wipe that dropped their
|
||
* fund transactions, which only Cash History rebuilds. fundCount is the
|
||
* fund transactions held when the hold began; retryAt paces the refetch. */
|
||
private historyHolds = new Map<number, HistoryHold>();
|
||
/** Per account, the start-of-day balance (amount − realizedPnL) the loaded
|
||
* history was taken at, and the day's realized P&L as last seen — what the
|
||
* daily reset folds into the balance. */
|
||
private balanceBaseline: { [accountId: number]: CashBaseline } = {};
|
||
/** Accounts a fetchDaysTraded call is working through right now, so the
|
||
* targeted pass doesn't request the same reports again (a wipe fetches
|
||
* outside maybeFetchDaysTraded). */
|
||
private fetchingIds = new Set<number>();
|
||
/** Per account, since when every Cash History try has been refused
|
||
* outright (a 4xx other than auth/rate limits, or an answer without data
|
||
* — a passed account's 404s), as against merely failing. Any other
|
||
* outcome clears it. */
|
||
private cashHistoryRefusedSince = new Map<number, number>();
|
||
/** Interval handles tracked so they can be cleared on reconnect */
|
||
/** Contract id → name ("NQZ6"), for the trades record. */
|
||
private contractNames = new Map<number, string>();
|
||
private contractLookupInFlight = false;
|
||
/** Product → fees per contract per side, for the trades record. */
|
||
private feePerSide = new Map<string, number>();
|
||
private feesAskedAt = new Map<string, number>();
|
||
private feesInFlight = false;
|
||
private syncInterval: ReturnType<typeof setInterval> | null = null;
|
||
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
||
private tokenRenewalInterval: ReturnType<typeof setInterval> | null = null;
|
||
/** Set to true by disconnect() to suppress reconnect on close */
|
||
private intentionalDisconnect = false;
|
||
/** Last error per account name from fetchDaysTraded() */
|
||
public lastFetchErrors: Record<string, string> = {};
|
||
/** Raw reports API response sample per account (first 100 chars) for debugging */
|
||
public lastFetchRaw: Record<string, string> = {};
|
||
|
||
public products: { id: number; name: string }[] = [];
|
||
|
||
|
||
/** Rolling buffer of the last 50 raw entity events — useful for debugging */
|
||
public recentEntityEvents: { entityType: string; eventType: string; entity: any; ts: number }[] = [];
|
||
|
||
/** True once the first requestSync has completed and positions/balances are populated. */
|
||
public syncComplete = false;
|
||
/** Epoch ms of the last sync that came back good (0 = never). */
|
||
public lastSyncAt = 0;
|
||
|
||
// ── Connection supervisor state (see the comment above the constants) ──
|
||
/** Single-flight guard: true while connectAndAuth() is logging in / opening. */
|
||
private connecting = false;
|
||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||
/** When reconnectTimer (or a login penalty wait) fires; 0 = nothing scheduled. */
|
||
private nextReconnectAt = 0;
|
||
private consecutiveLoginFailures = 0;
|
||
/** Sessions in a row that died before producing a good sync (never
|
||
* opened, never authorized, sync refused) — they back off like login
|
||
* failures instead of reconnecting (or re-logging in) every few seconds. */
|
||
private consecutiveSocketFailures = 0;
|
||
/** Last successful login. */
|
||
private lastLoginAt = 0;
|
||
private lastLoginError: string | null = null;
|
||
/** The current token is suspect (refused, never authorized a socket, sync
|
||
* answers stopped): the next connect logs in instead of reusing it. */
|
||
private needsLogin = true;
|
||
/** Socket creation time; reset to the open time once it opens. */
|
||
private socketOpenedAt = 0;
|
||
/** The current socket got as far as presenting the token. */
|
||
private authorizeSent = false;
|
||
/** When the current socket's authorize was accepted (0 = not yet). */
|
||
private authorizedAt = 0;
|
||
private lastInboundAt = 0;
|
||
/** First time the watchdog saw the current socket CLOSING/CLOSED. */
|
||
private closingSeenAt = 0;
|
||
/** When the oldest still-unanswered syncrequest was sent (0 = none). */
|
||
private syncRequestedAt = 0;
|
||
/** See PlatformClient. */
|
||
public lastSyncStartedAt = 0;
|
||
/** Per account, orders the last sync showed still working. */
|
||
private workingOrders: { [accountId: number]: number } = {};
|
||
private livenessInterval: ReturnType<typeof setInterval> | null = null;
|
||
private renewInFlight = false;
|
||
/** The current token's expirationTime was (nearly) past on arrival: this
|
||
* machine's clock is off, so the expiry is ignored (noteTokenExpiry()). */
|
||
private tokenExpiryUntrusted = false;
|
||
/** A login rate-limit penalty wait in progress — disconnect() cuts it short. */
|
||
private penaltyWait: { timer: ReturnType<typeof setTimeout>; resolve: () => void } | null = null;
|
||
|
||
|
||
private ws: WebSocket | null = null;
|
||
private callbackOnSyncRequest: () => Promise<void>;
|
||
|
||
/** Incrementing ID for outgoing WebSocket messages — ensures concurrent orders don't clobber each other's callbacks. */
|
||
private nextMsgId = 100;
|
||
private getMsgId(): number { return this.nextMsgId++; }
|
||
|
||
// Events that we sent out, and tradovate gives us a response for the id we sent out.
|
||
// One-shot: removed when the answer arrives, and all dropped with the socket.
|
||
private directEventCallbacks: {
|
||
[id: number]: (response: any, status?: number) => void;
|
||
} = {};
|
||
/** Rejecters for awaited direct requests (acks, contract finds), so a socket
|
||
* going away fails them instead of leaving the caller waiting forever. */
|
||
private pendingRejecters: {
|
||
[id: number]: (reason: string) => void;
|
||
} = {};
|
||
private indirectEventCallbacks: IndirectCallback[] = [];
|
||
|
||
|
||
constructor(
|
||
name: string,
|
||
password: string,
|
||
callbackOnSyncRequest: () => Promise<void>
|
||
) {
|
||
this.name = name;
|
||
this.password = password;
|
||
this.callbackOnSyncRequest = callbackOnSyncRequest;
|
||
|
||
// One watchdog and one renewal check for the life of the client —
|
||
// they span reconnects, and only disconnect() stops them.
|
||
this.livenessInterval = setInterval(() => this.checkLiveness(), LIVENESS_CHECK_MS);
|
||
this.tokenRenewalInterval = setInterval(() => this.maybeRenewToken(), TOKEN_RENEW_CHECK_MS);
|
||
void this.connectAndAuth();
|
||
}
|
||
|
||
/** Log prefix — several Tradovate firms can share one instance's log. */
|
||
private get tag(): string {
|
||
return `[tradovate ${this.name}]`;
|
||
}
|
||
|
||
/**
|
||
* Logs in (unless the current token is still good) and opens a fresh
|
||
* socket. Single-flight, never throws, never leaves a rejection unhandled:
|
||
* every way it can fail ends in scheduleReconnect().
|
||
*/
|
||
private async connectAndAuth(): Promise<void> {
|
||
if (this.connecting || this.intentionalDisconnect) return;
|
||
this.connecting = true;
|
||
try {
|
||
// A reconnect after a plain socket drop reuses a token with life
|
||
// left in it — fewer logins, fewer rate-limit penalties while
|
||
// Tradovate flaps during maintenance. Anything that makes the token
|
||
// suspect sets needsLogin.
|
||
if (this.needsLogin || !this.tokenValidFor(TOKEN_REUSE_MIN_MS)) {
|
||
const outcome = await this.attemptLogin();
|
||
if (this.intentionalDisconnect) return;
|
||
if (!outcome.ok) {
|
||
this.consecutiveLoginFailures++;
|
||
this.lastLoginError = outcome.error;
|
||
this.scheduleReconnect(
|
||
`login failed: ${outcome.error} (${this.consecutiveLoginFailures} in a row)`,
|
||
outcome.retryInMs ?? reconnectBackoffMs(this.consecutiveLoginFailures),
|
||
);
|
||
return;
|
||
}
|
||
this.accessInfo = outcome.info;
|
||
this.noteTokenExpiry();
|
||
this.lastLoginAt = Date.now();
|
||
this.lastLoginError = null;
|
||
this.consecutiveLoginFailures = 0;
|
||
this.needsLogin = false;
|
||
console.log(`${this.tag} logged in — token expires ${outcome.info.expirationTime}`);
|
||
}
|
||
this.openSocket();
|
||
} catch (err) {
|
||
// Nothing above is expected to throw (brokerWebSocket can, on a bad
|
||
// proxy setting) — but a throw must never end the reconnect loop.
|
||
this.consecutiveSocketFailures++;
|
||
this.scheduleReconnect(`connect failed: ${errorMessage(err)}`, reconnectBackoffMs(this.consecutiveSocketFailures));
|
||
} finally {
|
||
this.connecting = false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* One login attempt, riding out up to MAX_PENALTY_ROUNDS rate-limit
|
||
* penalties: Tradovate answers a throttled login with a p-ticket to present
|
||
* again after p-time seconds. p-captcha means it wants a human — never try
|
||
* to solve it, back off for a long while instead.
|
||
*/
|
||
private async attemptLogin(): Promise<LoginOutcome> {
|
||
let pTicket: string | undefined;
|
||
for (let round = 0; ; round++) {
|
||
let body: LoginResponseBody | undefined;
|
||
try {
|
||
body = await this.login(pTicket);
|
||
} catch (err) {
|
||
// axios rejects every non-2xx (and timeouts); a penalty ticket
|
||
// could ride on one of those too.
|
||
const data = (err as { response?: { data?: unknown } })?.response?.data as LoginResponseBody | undefined;
|
||
if (data && typeof data === 'object' && data['p-ticket']) {
|
||
body = data;
|
||
} else {
|
||
const text = data && typeof data === 'object' && typeof data.errorText === 'string' ? `: ${data.errorText}` : '';
|
||
return { ok: false, error: `${errorMessage(err)}${text}` };
|
||
}
|
||
}
|
||
if (this.intentionalDisconnect) return { ok: false, error: 'client disconnected' };
|
||
|
||
if (body?.accessToken) return { ok: true, info: body as AuthLoginResponse };
|
||
if (body?.['p-captcha']) {
|
||
return { ok: false, error: 'Tradovate asked for a captcha (too many logins)', retryInMs: CAPTCHA_RETRY_MS };
|
||
}
|
||
const ticket = body?.['p-ticket'];
|
||
const pTime = Number(body?.['p-time']);
|
||
if (ticket && pTime > 0) {
|
||
if (round >= MAX_PENALTY_ROUNDS) {
|
||
return { ok: false, error: `still rate-limited after ${round} penalty waits` };
|
||
}
|
||
const waitMs = Math.min(pTime * 1000, PENALTY_MAX_WAIT_MS);
|
||
console.warn(`${this.tag} login rate-limited — waiting ${fmtDelay(waitMs)} to retry with the penalty ticket`);
|
||
pTicket = ticket;
|
||
await this.penaltySleep(waitMs);
|
||
if (this.intentionalDisconnect) return { ok: false, error: 'client disconnected' };
|
||
continue;
|
||
}
|
||
return { ok: false, error: body?.errorText ? String(body.errorText) : 'login answered without an access token' };
|
||
}
|
||
}
|
||
|
||
private penaltySleep(ms: number): Promise<void> {
|
||
return new Promise((resolve) => {
|
||
this.nextReconnectAt = Date.now() + ms;
|
||
const timer = setTimeout(() => {
|
||
this.penaltyWait = null;
|
||
this.nextReconnectAt = 0;
|
||
resolve();
|
||
}, ms);
|
||
this.penaltyWait = { timer, resolve };
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Arms the one reconnect timer. Never stacks: if one is already pending it
|
||
* stays as it is (a login backoff isn't cut short by a later socket event).
|
||
*/
|
||
private scheduleReconnect(reason: string, delayMs = SOCKET_DROP_RECONNECT_MS): void {
|
||
if (this.intentionalDisconnect) return;
|
||
if (this.reconnectTimer) {
|
||
console.warn(`${this.tag} ${reason} — reconnect already due in ${fmtDelay(Math.max(0, this.nextReconnectAt - Date.now()))}`);
|
||
return;
|
||
}
|
||
const wait = jitter(delayMs);
|
||
this.nextReconnectAt = Date.now() + wait;
|
||
console.warn(`${this.tag} ${reason} — reconnecting in ${fmtDelay(wait)}`);
|
||
this.reconnectTimer = setTimeout(() => {
|
||
this.reconnectTimer = null;
|
||
this.nextReconnectAt = 0;
|
||
void this.connectAndAuth();
|
||
}, wait);
|
||
}
|
||
|
||
private openSocket(): void {
|
||
// Only ever one live socket per client — a leftover would keep feeding
|
||
// callbacks and heartbeats alongside the new one.
|
||
if (this.ws) this.dropSocket('replaced by a new connection');
|
||
|
||
const randomnumber = Math.random().toString(36).substring(2, 15);
|
||
// Broker endpoint — dialled through the operator's proxy when one
|
||
// is configured (lib/outbound-proxy.ts).
|
||
const ws = brokerWebSocket(`wss://demo.tradovateapi.com/v1/websocket?r=${randomnumber}`);
|
||
this.ws = ws;
|
||
this.socketOpenedAt = Date.now();
|
||
this.authorizeSent = false;
|
||
this.authorizedAt = 0;
|
||
this.closingSeenAt = 0;
|
||
// Reset sync state so scheduler waits for the new sync to complete
|
||
this.syncComplete = false;
|
||
|
||
// Every handler first checks it still belongs to the current socket:
|
||
// a replaced socket's late events must not touch the new session.
|
||
ws.onopen = () => {
|
||
if (this.ws !== ws) return;
|
||
this.socketOpenedAt = Date.now(); // the authorize deadline runs from here
|
||
console.log(`${this.tag} websocket connected — authorizing`);
|
||
this.directEventCallbacks[2] = (_response: unknown, status?: number) => this.onAuthorized(ws, status);
|
||
if (this.sendFrame('authorize\n2\n\n' + this.accessInfo.accessToken)) {
|
||
this.authorizeSent = true;
|
||
} else {
|
||
this.abandonSocket('authorize could not be sent');
|
||
}
|
||
};
|
||
|
||
ws.onmessage = (event) => {
|
||
if (this.ws !== ws) return;
|
||
// Any frame ('o', 'h', 'a', …) proves the session is alive.
|
||
this.lastInboundAt = Date.now();
|
||
this.handleFrame(event.data);
|
||
};
|
||
|
||
ws.onerror = (event) => {
|
||
if (this.ws !== ws) return;
|
||
// onclose follows and does the recovery; just note it (no event dump).
|
||
const msg = 'message' in event ? (event as ErrorEvent).message : '';
|
||
console.warn(`${this.tag} websocket error${typeof msg === 'string' && msg ? `: ${msg}` : ''}`);
|
||
};
|
||
|
||
ws.onclose = (event) => {
|
||
if (this.ws !== ws) return;
|
||
this.abandonSocket(`websocket closed (code=${event.code})`);
|
||
};
|
||
}
|
||
|
||
private onAuthorized(ws: WebSocket, status?: number): void {
|
||
if (this.ws !== ws) return;
|
||
if (typeof status === 'number' && status !== 200) {
|
||
// The token was refused — another socket with it would be refused too.
|
||
this.abandonSocket(`websocket authorize refused (status ${status})`, true);
|
||
return;
|
||
}
|
||
this.authorizedAt = Date.now();
|
||
console.log(`${this.tag} websocket authorized — syncing`);
|
||
|
||
this.clearSessionIntervals();
|
||
this.requestSync();
|
||
this.syncInterval = setInterval(() => {
|
||
if (this.ws === ws) this.requestSync();
|
||
}, 60000);
|
||
|
||
// Every 2.5 seconds send a heartbeat
|
||
this.heartbeatInterval = setInterval(() => {
|
||
if (this.ws === ws) this.sendFrame('[]');
|
||
}, 2500);
|
||
}
|
||
|
||
private handleFrame(raw: unknown): void {
|
||
// 'o' open, 'h' heartbeat, 'c' close — nothing to do beyond liveness.
|
||
if (typeof raw !== 'string' || raw[0] !== 'a') return;
|
||
|
||
let data: (
|
||
| {
|
||
i?: number; // Id
|
||
s?: number; // Status
|
||
d?: any; // Data
|
||
}
|
||
| {
|
||
e?: string;
|
||
d?: {
|
||
entityType: TradovateEntityType;
|
||
eventType: 'Created' | 'Updated';
|
||
entity: any;
|
||
};
|
||
}
|
||
)[];
|
||
try {
|
||
data = JSON.parse(raw.slice(1));
|
||
} catch {
|
||
console.warn(`${this.tag} unparseable frame (${raw.length} bytes) ignored`);
|
||
return;
|
||
}
|
||
if (!Array.isArray(data)) return;
|
||
|
||
for (const response of data) {
|
||
if (!response || typeof response !== 'object') continue;
|
||
|
||
if (
|
||
'i' in response &&
|
||
response.i &&
|
||
this.directEventCallbacks[response.i]
|
||
) {
|
||
// One-shot: removed before it runs, so a handler that re-registers
|
||
// its id (the fixed ids 2/3/10/30 are per request) isn't undone.
|
||
const id = response.i;
|
||
const callback = this.directEventCallbacks[id];
|
||
delete this.directEventCallbacks[id];
|
||
delete this.pendingRejecters[id];
|
||
try {
|
||
callback(response.d, response.s);
|
||
} catch (err) {
|
||
console.error(`${this.tag} handler for request ${id} failed: ${errorMessage(err)}`);
|
||
}
|
||
}
|
||
|
||
if ('e' in response && response.e) {
|
||
// Handle callbacks — all one-shot (fill waits), so take them out
|
||
// of the list before running them.
|
||
const matched = this.indirectEventCallbacks.filter((info) =>
|
||
response.d?.entityType === info.entityType &&
|
||
response.d?.eventType === info.eventType &&
|
||
info.validator(response.d.entity)
|
||
);
|
||
if (matched.length > 0) {
|
||
this.indirectEventCallbacks = this.indirectEventCallbacks.filter((info) => !matched.includes(info));
|
||
for (const info of matched) {
|
||
try {
|
||
info.callback(response.d?.entity);
|
||
} catch (err) {
|
||
console.error(`${this.tag} ${info.entityType} handler failed: ${errorMessage(err)}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Buffer recent entity events (last 50)
|
||
if (response.d?.entityType) {
|
||
this.recentEntityEvents.push({ entityType: response.d.entityType, eventType: response.d.eventType, entity: response.d.entity, ts: Date.now() });
|
||
if (this.recentEntityEvents.length > 50) this.recentEntityEvents.shift();
|
||
}
|
||
|
||
// Update positions from WebSocket position events
|
||
if (response.d?.entityType === 'position' && response.d?.entity) {
|
||
const pos = response.d.entity;
|
||
if (pos.netPos !== 0) {
|
||
this.positions[pos.accountId] = {
|
||
contractId: pos.contractId,
|
||
netPos: pos.netPos,
|
||
netPrice: pos.netPrice,
|
||
timestamp: new Date(pos.timestamp),
|
||
};
|
||
} else {
|
||
delete this.positions[pos.accountId];
|
||
}
|
||
}
|
||
|
||
// Update cash balances from WebSocket cashBalance events
|
||
if (response.d?.entityType === 'cashBalance' && response.d?.entity) {
|
||
const cb = response.d.entity as CashBalanceEntity;
|
||
if (cb.accountId) storeCash(this.accountCashBalances, cb);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Gives up on the current socket (if any) and schedules the way back.
|
||
* Used for every way a session ends — onclose, and the watchdog for the
|
||
* ways that never fire onclose. `relogin` means the token itself is
|
||
* suspect: log in afresh, after a short delay.
|
||
*/
|
||
private abandonSocket(reason: string, relogin = false): void {
|
||
if (this.intentionalDisconnect) return;
|
||
if (relogin) this.needsLogin = true;
|
||
// A connect in flight opens a fresh socket anyway (and logs in first
|
||
// if needsLogin was just set before its login) — don't race it.
|
||
if (this.connecting) return;
|
||
|
||
const hadSocket = this.ws !== null;
|
||
const wasAuthorized = this.authorizedAt > 0;
|
||
const tokenPresented = this.authorizeSent;
|
||
// lastSyncAt only moves on a good sync, and one on this socket can't
|
||
// predate its open.
|
||
const synced = wasAuthorized && this.lastSyncAt >= this.socketOpenedAt;
|
||
this.dropSocket(reason);
|
||
|
||
let delay = relogin ? FORCED_RELOGIN_DELAY_MS : SOCKET_DROP_RECONNECT_MS;
|
||
if (hadSocket && !synced) {
|
||
// A session that died before it produced a single good sync backs
|
||
// off if it keeps happening, rather than reconnecting every few
|
||
// seconds — and, when it forces a login (a sync refused with 401
|
||
// on every fresh session), rather than logging in every second.
|
||
// If it presented the token and got no yes, the token is suspect
|
||
// too: log in afresh. A handshake that never completed says
|
||
// nothing about the token — keep it, so a websocket-host outage
|
||
// doesn't turn into a login every few seconds (and a rate-limit
|
||
// penalty on top).
|
||
if (tokenPresented && !wasAuthorized) this.needsLogin = true;
|
||
this.consecutiveSocketFailures++;
|
||
if (this.consecutiveSocketFailures > 1) delay = reconnectBackoffMs(this.consecutiveSocketFailures);
|
||
}
|
||
this.scheduleReconnect(reason, delay);
|
||
}
|
||
|
||
/** Detaches and closes the current socket and clears everything that
|
||
* belonged to its session. Pending requests fail with `reason`. */
|
||
private dropSocket(reason: string): void {
|
||
const ws = this.ws;
|
||
this.ws = null;
|
||
if (ws) {
|
||
ws.onopen = null;
|
||
ws.onmessage = null;
|
||
ws.onerror = null;
|
||
ws.onclose = null;
|
||
try { ws.close(); } catch { /* already closing */ }
|
||
}
|
||
this.clearSessionIntervals();
|
||
this.syncComplete = false;
|
||
this.authorizeSent = false;
|
||
this.authorizedAt = 0;
|
||
this.closingSeenAt = 0;
|
||
this.syncRequestedAt = 0;
|
||
this.failPendingRequests(reason);
|
||
}
|
||
|
||
private clearSessionIntervals(): void {
|
||
if (this.syncInterval) { clearInterval(this.syncInterval); this.syncInterval = null; }
|
||
if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); this.heartbeatInterval = null; }
|
||
}
|
||
|
||
/** Answers to requests sent on a socket can't arrive on the next one:
|
||
* reject the awaited ones, drop the internal ones (authorize, sync,
|
||
* product list — re-sent on the new socket). Fill watchers stay: fills
|
||
* pushed after the reconnect still arrive. */
|
||
private failPendingRequests(reason: string): void {
|
||
const rejecters = Object.values(this.pendingRejecters);
|
||
this.pendingRejecters = {};
|
||
this.directEventCallbacks = {};
|
||
for (const reject of rejecters) reject(reason);
|
||
}
|
||
|
||
/** Every 15s: catch the ways a session dies without onclose, and make sure
|
||
* something is always pending while the client is meant to be connected. */
|
||
private checkLiveness(): void {
|
||
// A connect in flight is bounded (HTTP deadlines, capped penalty waits).
|
||
if (this.intentionalDisconnect || this.connecting) return;
|
||
try {
|
||
const problem = this.livenessProblem(Date.now());
|
||
if (problem) {
|
||
this.abandonSocket(problem.reason, problem.relogin);
|
||
return;
|
||
}
|
||
if (!this.ws && !this.reconnectTimer) this.scheduleReconnect('nothing scheduled');
|
||
} catch (err) {
|
||
console.error(`${this.tag} liveness check failed: ${errorMessage(err)}`);
|
||
}
|
||
}
|
||
|
||
private livenessProblem(now: number): { reason: string; relogin?: boolean } | null {
|
||
const ws = this.ws;
|
||
if (!ws) return null;
|
||
|
||
const state = ws.readyState;
|
||
if (state === WebSocket.CLOSING || state === WebSocket.CLOSED) {
|
||
// onclose normally takes it from here — unless it never fires.
|
||
if (!this.closingSeenAt) this.closingSeenAt = now;
|
||
else if (now - this.closingSeenAt >= STUCK_CLOSING_MS) return { reason: 'websocket stuck closing' };
|
||
return null;
|
||
}
|
||
this.closingSeenAt = 0;
|
||
|
||
// Renewal normally keeps this from happening; a session on an expired
|
||
// token is dead whatever the socket says.
|
||
const expiresAt = this.tokenExpiresAt();
|
||
if (expiresAt > 0 && expiresAt <= now) return { reason: 'access token expired', relogin: true };
|
||
|
||
if (state === WebSocket.CONNECTING) {
|
||
return now - this.socketOpenedAt > HANDSHAKE_TIMEOUT_MS ? { reason: 'websocket handshake timed out' } : null;
|
||
}
|
||
|
||
if (!this.authorizedAt) {
|
||
return now - this.socketOpenedAt > AUTHORIZE_TIMEOUT_MS
|
||
? { reason: 'websocket authorize never answered', relogin: true }
|
||
: null;
|
||
}
|
||
if (now - Math.max(this.lastInboundAt, this.authorizedAt) > INBOUND_SILENCE_MS) {
|
||
return { reason: `no frames for ${INBOUND_SILENCE_MS / 1000}s` };
|
||
}
|
||
if (now - this.authorizedAt > SYNC_STALE_MS && now - this.lastSyncAt > SYNC_STALE_MS) {
|
||
return { reason: 'sync responses stopped', relogin: true };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Sends a frame on the current socket if it is open. Never throws. */
|
||
private sendFrame(frame: string): boolean {
|
||
const ws = this.ws;
|
||
if (!ws || ws.readyState !== WebSocket.OPEN) return false;
|
||
try {
|
||
ws.send(frame);
|
||
return true;
|
||
} catch (err) {
|
||
console.warn(`${this.tag} websocket send failed: ${errorMessage(err)}`);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/** Why `ws` can't carry a request right now, or null if it can. */
|
||
private socketProblem(ws: WebSocket | null): string | null {
|
||
if (!ws) return 'Tradovate socket not connected';
|
||
if (ws.readyState !== WebSocket.OPEN) return `Tradovate socket ${READY_STATE_NAMES[ws.readyState] ?? 'not open'}`;
|
||
if (!this.authorizedAt) return 'Tradovate socket not authorized yet';
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Sends one request on the socket and settles on its answer; it can't
|
||
* hang. No answer within `timeoutMs`, or the socket going away first,
|
||
* rejects with a plain Error — ambiguous, the broker may have acted on it.
|
||
* Failing before anything was sent rejects with OrderNotPlacedError.
|
||
* `onAnswer` runs synchronously inside the frame handler, so a fill
|
||
* watcher it registers still sees events later in the same frame.
|
||
*/
|
||
private sendRequest<T>(
|
||
endpoint: string,
|
||
rest: string,
|
||
timeoutMs: number,
|
||
onAnswer: (data: unknown, status?: number) => T | PromiseLike<T>,
|
||
): Promise<T> {
|
||
const ws = this.ws;
|
||
const problem = this.socketProblem(ws);
|
||
if (!ws || problem) return Promise.reject(new OrderNotPlacedError(`${endpoint} not sent — ${problem}`));
|
||
|
||
const id = this.getMsgId();
|
||
return new Promise<T>((resolve, reject) => {
|
||
const timer = setTimeout(() => {
|
||
delete this.directEventCallbacks[id];
|
||
delete this.pendingRejecters[id];
|
||
reject(new Error(`${endpoint}: no answer from Tradovate within ${timeoutMs / 1000}s — outcome unknown`));
|
||
}, timeoutMs);
|
||
this.directEventCallbacks[id] = (data: unknown, status?: number) => {
|
||
clearTimeout(timer);
|
||
try {
|
||
resolve(onAnswer(data, status));
|
||
} catch (err) {
|
||
reject(err);
|
||
}
|
||
};
|
||
this.pendingRejecters[id] = (reason: string) => {
|
||
clearTimeout(timer);
|
||
reject(new Error(`${endpoint}: ${reason} before Tradovate answered — outcome unknown`));
|
||
};
|
||
try {
|
||
ws.send(`${endpoint}\n${id}\n${rest}`);
|
||
} catch (err) {
|
||
clearTimeout(timer);
|
||
delete this.directEventCallbacks[id];
|
||
delete this.pendingRejecters[id];
|
||
reject(new OrderNotPlacedError(`${endpoint} not sent — ${errorMessage(err)}`));
|
||
}
|
||
});
|
||
}
|
||
|
||
/** Runs the sync-complete handler without letting it reject unhandled. */
|
||
private notifySyncComplete(): void {
|
||
try {
|
||
this.callbackOnSyncRequest().catch((err) => {
|
||
console.error(`${this.tag} sync-complete handler failed: ${errorMessage(err)}`);
|
||
});
|
||
} catch (err) {
|
||
console.error(`${this.tag} sync-complete handler failed: ${errorMessage(err)}`);
|
||
}
|
||
}
|
||
|
||
private requestSync(): void {
|
||
if (this.socketProblem(this.ws)) return;
|
||
this.directEventCallbacks[3] = (response: any, status?: number) => {
|
||
const requestedAt = this.syncRequestedAt;
|
||
this.syncRequestedAt = 0;
|
||
// A refused or empty answer must not wipe the accounts, positions
|
||
// and balances we have — nor pass for a fresh sync.
|
||
if (!response || typeof response === 'string' || (typeof status === 'number' && status !== 200)) {
|
||
console.error(`${this.tag} sync request failed (status ${status ?? '?'}): ${typeof response === 'string' && response ? response : 'empty response'}`);
|
||
// 401: the session is gone — no point waiting for the watchdog.
|
||
if (status === 401) this.abandonSocket('sync refused (status 401)', true);
|
||
return;
|
||
}
|
||
try {
|
||
// liquidateOnly flag lives in accountRiskStatuses
|
||
const riskStatusById: { [accountId: number]: { liquidateOnly?: string } } = (
|
||
response.accountRiskStatuses || []
|
||
).reduce((acc: any, item: any) => {
|
||
const key = item.accountId ?? item.id;
|
||
acc[key] = item;
|
||
return acc;
|
||
}, {});
|
||
|
||
// Auto-liquidation balance floor lives in userAccountAutoLiqs.
|
||
// item.id IS the account ID. The floor is: trailingMaxDrawdownLimit - trailingMaxDrawdown.
|
||
// Tradovate uses 999999999 as a sentinel for "no limit" — skip those.
|
||
for (const item of (response.userAccountAutoLiqs ?? [])) {
|
||
const accountId: number = item.id;
|
||
const limit: number = item.trailingMaxDrawdownLimit ?? 0;
|
||
const drawdown: number = item.trailingMaxDrawdown ?? 0;
|
||
const isSentinel = limit >= 999999999;
|
||
const floor = (!isSentinel && limit > 0 && drawdown > 0) ? limit - drawdown : 0;
|
||
this.autoLiqThresholds[accountId] = floor;
|
||
}
|
||
|
||
this.accountList = ((response.accounts ?? []) as AccountItem[]).map((account) => {
|
||
if (riskStatusById[account.id]?.liquidateOnly) {
|
||
return { ...account, active: false };
|
||
}
|
||
return account;
|
||
});
|
||
|
||
const working: { [accountId: number]: number } = {};
|
||
for (const order of (response.orders ?? []) as { accountId?: number; ordStatus?: string }[]) {
|
||
if (order.accountId && !FINISHED_ORDER_STATUSES.has(order.ordStatus ?? '')) {
|
||
working[order.accountId] = (working[order.accountId] ?? 0) + 1;
|
||
}
|
||
}
|
||
this.workingOrders = working;
|
||
|
||
const balances: TradovateClient['accountCashBalances'] = {};
|
||
for (const item of (response.cashBalances ?? []) as CashBalanceEntity[]) {
|
||
storeCash(balances, item, this.accountCashBalances[item.accountId]);
|
||
}
|
||
this.accountCashBalances = balances;
|
||
|
||
this.positions = (response.positions ?? [])
|
||
.filter((item: any) => item.netPos !== 0)
|
||
.reduce(
|
||
(
|
||
acc: { [accountId: number]: { contractId: number; netPos: number; netPrice: number; timestamp: Date } },
|
||
item: { accountId: number; contractId: number; netPos: number; netPrice: number; timestamp: Date }
|
||
) => {
|
||
acc[item.accountId] = {
|
||
contractId: item.contractId,
|
||
netPos: item.netPos,
|
||
netPrice: item.netPrice,
|
||
timestamp: new Date(item.timestamp),
|
||
};
|
||
return acc;
|
||
},
|
||
{} as { [accountId: number]: { contractId: number; netPos: number; netPrice: number; timestamp: Date } }
|
||
);
|
||
|
||
this.recordTrades(response);
|
||
|
||
this.lastSyncAt = Date.now();
|
||
this.lastSyncStartedAt = requestedAt || this.lastSyncAt;
|
||
// A session counts as working once it has synced (abandonSocket).
|
||
this.consecutiveSocketFailures = 0;
|
||
console.log(`${this.tag} sync:${this.accountList.length} account(s), ${Object.keys(this.accountCashBalances).length} balance(s)`);
|
||
|
||
// Ahead of the fetch, so a balance change flagged here is
|
||
// fetched on this same pass.
|
||
this.observeBalances();
|
||
this.maybeFetchDaysTraded();
|
||
|
||
// The product list only feeds fetchInstrumentFees. Wait for it
|
||
// on a session's first sync, but not for ever: still unanswered
|
||
// at the next sync, it is lost — sync without it (a late answer
|
||
// still loads it) rather than hold syncComplete false, and every
|
||
// firm on the instance with it, while the syncs themselves are good.
|
||
if (this.products.length > 0 || this.directEventCallbacks[30]) {
|
||
this.syncComplete = true;
|
||
this.notifySyncComplete();
|
||
return;
|
||
}
|
||
|
||
this.directEventCallbacks[30] = (products: any) => {
|
||
if (Array.isArray(products) && products.length > 0) {
|
||
this.products = products.map((p: any) => ({ id: p.id, name: p.name }));
|
||
console.log(`${this.tag} loaded ${this.products.length} products`);
|
||
}
|
||
this.syncComplete = true;
|
||
this.notifySyncComplete();
|
||
};
|
||
this.sendFrame('product/list\n30\n\n');
|
||
} catch (err) {
|
||
console.error(`${this.tag} error processing sync response: ${errorMessage(err)}`);
|
||
}
|
||
};
|
||
|
||
if (this.sendFrame('user/syncrequest\n3\n\n{"splitResponses":false}') && !this.syncRequestedAt) {
|
||
this.syncRequestedAt = Date.now();
|
||
}
|
||
}
|
||
|
||
private maybeFetchDaysTraded(): void {
|
||
if (this.daysFetchInFlight) return;
|
||
|
||
const now = Date.now();
|
||
const initialFetchDue = this.lastDaysFetchStartedAt === 0;
|
||
const retryPending = this.nextDaysFetchRetryAt > 0;
|
||
const retryDue = retryPending && now >= this.nextDaysFetchRetryAt;
|
||
const hourlyRefreshDue =
|
||
!retryPending &&
|
||
this.lastDaysFetchSucceededAt > 0 &&
|
||
now - this.lastDaysFetchSucceededAt >= 60 * 60 * 1_000;
|
||
// Accounts held out of trading for want of history — new since the
|
||
// last pass, wiped, or flagged by a balance change — get their own
|
||
// fetch now rather than wait out the hour. A failure falls back to
|
||
// the 5-minute retry like any other; a held account refetches on its
|
||
// own 5-minute pace; one already being fetched isn't asked for twice.
|
||
const waiting = !initialFetchDue && !retryPending
|
||
? this.accountList
|
||
.filter((a) => !this.fetchingIds.has(a.id))
|
||
.filter((a) => {
|
||
if (!this.historyLoadedIds.has(a.id)) return true;
|
||
const hold = this.historyHolds.get(a.id);
|
||
return hold !== undefined && now >= hold.retryAt;
|
||
})
|
||
.map((a) => a.id)
|
||
: [];
|
||
const targeted = !retryDue && !hourlyRefreshDue && !initialFetchDue && waiting.length > 0;
|
||
|
||
if (!initialFetchDue && !retryDue && !hourlyRefreshDue && !targeted) return;
|
||
|
||
const reason = retryDue
|
||
? 'retry after failure'
|
||
: initialFetchDue
|
||
? 'initial sync'
|
||
: hourlyRefreshDue
|
||
? 'hourly refresh'
|
||
: `history for ${waiting.length} waiting account(s)`;
|
||
|
||
this.daysFetchInFlight = true;
|
||
this.lastDaysFetchStartedAt = now;
|
||
if (retryDue) this.nextDaysFetchRetryAt = 0;
|
||
|
||
this.fetchDaysTraded(targeted ? waiting : undefined)
|
||
.then(({ failedAccounts }) => {
|
||
if (failedAccounts > 0) {
|
||
this.nextDaysFetchRetryAt = Date.now() + 5 * 60 * 1_000;
|
||
console.warn(`${this.tag} fetchDaysTraded: ${failedAccounts} account(s) failed during ${reason} — retrying in 5 minutes`);
|
||
return;
|
||
}
|
||
|
||
// A partial pass doesn't restart the hourly clock.
|
||
if (!targeted) this.lastDaysFetchSucceededAt = Date.now();
|
||
})
|
||
.catch((err) => {
|
||
this.nextDaysFetchRetryAt = Date.now() + 5 * 60 * 1_000;
|
||
// errorMessage(): an AxiosError logged whole dumps the Bearer token.
|
||
console.error(`${this.tag} fetchDaysTraded ${reason} failed — retrying in 5 minutes: ${errorMessage(err)}`);
|
||
})
|
||
.finally(() => {
|
||
this.daysFetchInFlight = false;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Returns true when a withdrawal happened before the first trade of the day,
|
||
* meaning that day's trades belong to the NEW cycle.
|
||
* Timestamp format from Tradovate: "MM/DD/YYYY HH:MM:SS" in Central Time.
|
||
* Falls back to 9:00 AM CT cutoff when firstTradeTimestamp is not available.
|
||
*/
|
||
private static isWithdrawalBeforeFirstTrade(
|
||
fundTimestamp: string | null,
|
||
firstTradeTimestamp: string | null
|
||
): boolean {
|
||
if (!fundTimestamp) return false;
|
||
if (firstTradeTimestamp) {
|
||
// Direct comparison — withdrawal before the day's first trade
|
||
return fundTimestamp < firstTradeTimestamp;
|
||
}
|
||
// Fallback heuristic: before 9:00 AM CT
|
||
const match = fundTimestamp.match(/\d{2}\/\d{2}\/\d{4}\s+(\d{2}):\d{2}:\d{2}/);
|
||
if (!match) return false;
|
||
return parseInt(match[1], 10) < 9;
|
||
}
|
||
|
||
/**
|
||
* Filters daily PnL entries for the current cycle based on fund date and withdrawal timing.
|
||
* - Deposit: include the fund date (trading can start same day)
|
||
* - Withdrawal before first trade: include the fund date (day's trades are new cycle)
|
||
* - Withdrawal during/after first trade: exclude the fund date (day's trades are old cycle)
|
||
*/
|
||
private static filterActivePnL(
|
||
entries: { date: string; pnl: number }[],
|
||
fundDate: string | null,
|
||
isWithdrawal: boolean,
|
||
fundTimestamp: string | null,
|
||
firstTradeTimestamp: string | null = null
|
||
): { active: { date: string; pnl: number }[]; prior: { date: string; pnl: number }[] } {
|
||
if (!fundDate) return { active: entries, prior: [] };
|
||
|
||
// Withdrawal before first trade → day belongs to NEW cycle (use >=)
|
||
// Withdrawal during/after first trade → day belongs to OLD cycle (use >)
|
||
// Deposit → always include the day (use >=)
|
||
const excludeFundDate = isWithdrawal && !TradovateClient.isWithdrawalBeforeFirstTrade(fundTimestamp, firstTradeTimestamp);
|
||
|
||
const active = entries.filter((d) => excludeFundDate ? d.date > fundDate : d.date >= fundDate);
|
||
const prior = entries.filter((d) => excludeFundDate ? d.date <= fundDate : d.date < fundDate);
|
||
return { active, prior };
|
||
}
|
||
|
||
/** Pass onlyAccountIds to refresh a subset (e.g. one wiped account) without
|
||
* paying for a report round-trip on every other account of the firm. */
|
||
public async fetchDaysTraded(onlyAccountIds?: number[]): Promise<{ failedAccounts: number }> {
|
||
const ids = this.accountList
|
||
.filter((a) => !onlyAccountIds || onlyAccountIds.includes(a.id))
|
||
.map((a) => a.id);
|
||
for (const id of ids) this.fetchingIds.add(id);
|
||
try {
|
||
return await this.fetchDaysTradedPass(onlyAccountIds);
|
||
} finally {
|
||
for (const id of ids) this.fetchingIds.delete(id);
|
||
}
|
||
}
|
||
|
||
private async fetchDaysTradedPass(onlyAccountIds?: number[]): Promise<{ failedAccounts: number }> {
|
||
if (!this.accessInfo?.accessToken) return { failedAccounts: 0 };
|
||
this.fetchDaysComplete = false;
|
||
let failedAccounts = 0;
|
||
|
||
const authHeaders = { Authorization: `Bearer ${this.accessInfo.accessToken}` };
|
||
|
||
const now = new Date();
|
||
now.setDate(now.getDate() + 1); // endDate must be tomorrow — report server excludes today's fills when endDate=today
|
||
const start = new Date();
|
||
start.setDate(start.getDate() - 27); // keep total window ≤ 28 days
|
||
|
||
const fmtDate = (d: Date) => {
|
||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||
const day = String(d.getDate()).padStart(2, '0');
|
||
return `${m}/${day}/${d.getFullYear()}`;
|
||
};
|
||
|
||
if (!this.lastFetchRaw) this.lastFetchRaw = {};
|
||
if (!this.lastFetchErrors) this.lastFetchErrors = {};
|
||
|
||
// Treat 5xx and network-level errors as retryable (Tradovate report server is flaky).
|
||
const isTransient = (err: unknown): boolean => {
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const status = (err as any)?.response?.status;
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const code = (err as any)?.code;
|
||
return (typeof status === 'number' && status >= 500 && status < 600)
|
||
|| code === 'ECONNRESET' || code === 'ETIMEDOUT' || code === 'ECONNABORTED';
|
||
};
|
||
|
||
const requestReport = async (name: string, accountName: string) => {
|
||
const maxAttempts = 3;
|
||
let lastErr: unknown = null;
|
||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||
// A discarded client stops polling rather than keep a report
|
||
// conversation going on a session nobody uses any more.
|
||
if (this.intentionalDisconnect) throw new Error('client disconnected');
|
||
try {
|
||
let reportData = (await axios.post(
|
||
'https://rpt-demo.tradovateapi.com/v1/reports/requestreport',
|
||
{
|
||
name, representationType: 'json', timezone: -300,
|
||
params: [
|
||
{ name: 'startDate', value: fmtDate(start) },
|
||
{ name: 'endDate', value: fmtDate(now) },
|
||
{ name: 'startTime', value: '00:00:00' },
|
||
{ name: 'endTime', value: '00:00:00' },
|
||
{ name: 'account', value: accountName },
|
||
],
|
||
},
|
||
{ headers: authHeaders, timeout: REPORT_TIMEOUT_MS }
|
||
)).data;
|
||
let pollAttempts = 0;
|
||
while (reportData?.['p-ticket'] && pollAttempts < 30) {
|
||
const pTicket: string = reportData['p-ticket'];
|
||
const pTime: number = Math.max(1, reportData['p-time'] ?? 1);
|
||
await new Promise((r) => setTimeout(r, pTime * 1000));
|
||
if (this.intentionalDisconnect) throw new Error('client disconnected');
|
||
reportData = (await axios.get(
|
||
'https://rpt-demo.tradovateapi.com/v1/reports/getreport',
|
||
{ params: { 'p-ticket': pTicket }, headers: authHeaders, timeout: REPORT_TIMEOUT_MS }
|
||
)).data;
|
||
pollAttempts++;
|
||
}
|
||
// Still queued after every poll, or an answer with no data
|
||
// (an errorText): a failure, not an empty history — read as
|
||
// '[]' it booked no rows and no fund transactions as fact.
|
||
if (reportData?.['p-ticket']) throw new Error(`${name} report still queued after ${pollAttempts} polls`);
|
||
if (typeof reportData?.data !== 'string') {
|
||
const why = typeof reportData?.errorText === 'string' ? `: ${reportData.errorText}` : '';
|
||
throw new Error(`${name} report came back without data${why}`);
|
||
}
|
||
return reportData.data;
|
||
} catch (err) {
|
||
lastErr = err;
|
||
if (!isTransient(err) || attempt === maxAttempts) throw err;
|
||
const backoffMs = 500 * Math.pow(2, attempt - 1); // 500ms, 1s, 2s
|
||
console.warn(`${this.tag} requestReport ${accountName} ${name}: transient error (${errorMessage(err)}, attempt ${attempt}/${maxAttempts}), retrying in ${backoffMs}ms`);
|
||
await new Promise((r) => setTimeout(r, backoffMs));
|
||
}
|
||
}
|
||
throw lastErr;
|
||
};
|
||
|
||
// Merge fresh API entries on top of cached historical entries (fresh takes precedence for overlapping dates)
|
||
const mergePnL = (
|
||
cached: { date: string; pnl: number }[],
|
||
fresh: { date: string; pnl: number }[]
|
||
): { date: string; pnl: number }[] => {
|
||
const map = new Map<string, number>();
|
||
for (const e of cached) map.set(e.date, e.pnl);
|
||
for (const e of fresh) map.set(e.date, e.pnl);
|
||
return Array.from(map.entries())
|
||
.map(([date, pnl]) => ({ date, pnl }))
|
||
.sort((a, b) => a.date.localeCompare(b.date));
|
||
};
|
||
|
||
for (const account of this.accountList) {
|
||
// A discarded client (credentials changed, firm removed) stops here
|
||
// rather than keep writing rows next to its replacement's.
|
||
if (this.intentionalDisconnect) break;
|
||
if (onlyAccountIds && !onlyAccountIds.includes(account.id)) continue;
|
||
// Load last known fund date and fund transactions from DB
|
||
const storedFundDate = loadAccountMeta(account.id, 'last_fund_date');
|
||
this.lastFundDates[account.id] = storedFundDate;
|
||
this.fundTransactions[account.id] = loadFundTransactions(account.id);
|
||
|
||
// Load cache first — serves as both the startup baseline and the fallback if API fails
|
||
const cached = loadDailyPnL(account.id);
|
||
const storedFundTimestamp = loadAccountMeta(account.id, 'last_fund_timestamp');
|
||
if (cached.length > 0) {
|
||
const storedFundTxns = this.fundTransactions[account.id] ?? [];
|
||
const storedLastFundAmt = storedFundDate ? (storedFundTxns.find((f) => f.date === storedFundDate)?.amount ?? null) : null;
|
||
const storedIsWithdrawal = storedLastFundAmt !== null && storedLastFundAmt < 0;
|
||
const { active, prior: priorEntries } = TradovateClient.filterActivePnL(
|
||
cached, storedFundDate, storedIsWithdrawal, storedFundTimestamp
|
||
);
|
||
this.priorProfit[account.id] = Math.round(priorEntries.reduce((s, d) => s + d.pnl, 0) * 100) / 100;
|
||
this.fullDailyPnL[account.id] = cached;
|
||
this.dailyPnL[account.id] = active;
|
||
this.daysTraded[account.id] = active.filter((d) => d.pnl !== 0).length;
|
||
}
|
||
|
||
// The balance as it stands before the report is asked for: the
|
||
// baseline this history is judged against, so a payout landing
|
||
// while the report is in flight isn't absorbed unexplained.
|
||
const cashNow = this.accountCashBalances[account.id];
|
||
const cashBefore = cashNow ? { amount: cashNow.amount, realizedPnL: cashNow.realizedPnL } : null;
|
||
const fundSigBefore = fundSignature(this.fundTransactions[account.id]);
|
||
const knownBefore = this.balanceBaseline[account.id] ? { ...this.balanceBaseline[account.id] } : null;
|
||
|
||
// --- Try Cash History first (true after-fee daily P&L) ---
|
||
try {
|
||
const raw = await requestReport('Cash History', account.name);
|
||
const fixed = raw.replace(/"Date":\s*(\d{4}-\d{2}-\d{2})/g, '"Date": "$1"');
|
||
const rows: { Date: string; Delta: string; 'Cash Change Type': string; [k: string]: any }[] = JSON.parse(fixed);
|
||
this.lastFetchRaw[account.name] = rows.length > 0 ? JSON.stringify(rows[0]) : '(empty)';
|
||
|
||
const fundMap: { [date: string]: number } = {};
|
||
const fundTimestampMap: { [date: string]: string } = {};
|
||
const dailyMap: { [date: string]: number } = {};
|
||
const firstTradeTsMap: { [date: string]: string } = {};
|
||
for (const row of rows) {
|
||
const changeType = (row['Cash Change Type'] ?? '').trim();
|
||
if (changeType === 'Fund Transaction' || changeType === 'Manual Adjustment' || changeType === 'Challenge Payout') {
|
||
const delta = parseFloat((row['Delta'] ?? '0').replace(/,/g, ''));
|
||
if (!isNaN(delta)) {
|
||
fundMap[row['Date']] = (fundMap[row['Date']] ?? 0) + delta;
|
||
if (row['Timestamp']) fundTimestampMap[row['Date']] = row['Timestamp'];
|
||
}
|
||
continue;
|
||
}
|
||
const delta = parseFloat((row['Delta'] ?? '0').replace(/,/g, ''));
|
||
if (isNaN(delta)) continue;
|
||
dailyMap[row['Date']] = (dailyMap[row['Date']] ?? 0) + delta;
|
||
// Track earliest trade timestamp per date
|
||
const ts = row['Timestamp'];
|
||
if (ts && (!firstTradeTsMap[row['Date']] || ts < firstTradeTsMap[row['Date']])) {
|
||
firstTradeTsMap[row['Date']] = ts;
|
||
}
|
||
}
|
||
|
||
// Persist fund transactions so they survive beyond the 28-day window
|
||
const freshFundTxns = Object.entries(fundMap)
|
||
.map(([date, amount]) => ({ date, amount: Math.round(amount * 100) / 100 }))
|
||
.sort((a, b) => a.date.localeCompare(b.date));
|
||
if (freshFundTxns.length > 0) {
|
||
saveFundTransactions(account.id, account.name, freshFundTxns);
|
||
this.fundTransactions[account.id] = loadFundTransactions(account.id); // reload merged full history
|
||
}
|
||
|
||
// Use the most recent fund transaction as the reset point — persist it so it survives beyond the 28-day window
|
||
const lastFundDate = [...Object.keys(fundMap)].sort().pop() ?? null;
|
||
if (lastFundDate) {
|
||
// A new cycle starts: note where the one it ends began —
|
||
// with filterActivePnL's call on that date's own row — for
|
||
// the reporter's catch-up. Only from a stored start: the
|
||
// first load after a wipe or on a new instance never saw
|
||
// the cycle before it begin.
|
||
if (storedFundDate && lastFundDate > storedFundDate) {
|
||
const prevAmt = this.fundTransactions[account.id]?.find((f) => f.date === storedFundDate)?.amount ?? null;
|
||
saveEndedCycleStart(account.id, {
|
||
fundDate: storedFundDate,
|
||
includesDay: !(prevAmt !== null && prevAmt < 0) || TradovateClient.isWithdrawalBeforeFirstTrade(
|
||
fundTimestampMap[storedFundDate] ?? storedFundTimestamp, firstTradeTsMap[storedFundDate] ?? null),
|
||
});
|
||
}
|
||
saveAccountMeta(account.id, 'last_fund_date', lastFundDate);
|
||
this.lastFundDates[account.id] = lastFundDate;
|
||
if (fundTimestampMap[lastFundDate]) {
|
||
saveAccountMeta(account.id, 'last_fund_timestamp', fundTimestampMap[lastFundDate]);
|
||
}
|
||
}
|
||
const fundDate = this.lastFundDates[account.id];
|
||
|
||
const fresh = Object.entries(dailyMap)
|
||
.map(([date, pnl]) => ({ date, pnl: Math.round(pnl * 100) / 100 }))
|
||
.sort((a, b) => a.date.localeCompare(b.date));
|
||
|
||
const merged = mergePnL(cached, fresh);
|
||
|
||
const lastFundAmt = fundDate ? (fundMap[fundDate] ?? null) : null;
|
||
const isWithdrawal = lastFundAmt !== null && lastFundAmt < 0;
|
||
const lastFundTs = fundDate ? (fundTimestampMap[fundDate] ?? loadAccountMeta(account.id, 'last_fund_timestamp')) : null;
|
||
const firstTradeTs = fundDate ? (firstTradeTsMap[fundDate] ?? null) : null;
|
||
const { active, prior: priorEntries } = TradovateClient.filterActivePnL(
|
||
merged, fundDate, isWithdrawal, lastFundTs, firstTradeTs
|
||
);
|
||
this.priorProfit[account.id] = Math.round(priorEntries.reduce((s, d) => s + d.pnl, 0) * 100) / 100;
|
||
|
||
this.fullDailyPnL[account.id] = merged;
|
||
this.dailyPnL[account.id] = active;
|
||
this.daysTraded[account.id] = active.filter((d) => d.pnl !== 0).length;
|
||
saveDailyPnL(account.id, account.name, fresh); // upsert only fresh entries — preserves older cached rows
|
||
delete this.lastFetchErrors[account.name];
|
||
this.cashHistoryRefusedSince.delete(account.id);
|
||
this.markHistoryLoaded(account.id, true, cashBefore, fundSigBefore, knownBefore);
|
||
continue;
|
||
} catch (err) {
|
||
// Refused outright (a 4xx, an errorText) — a passed account's
|
||
// Cash History 404s for good — as against a server that may
|
||
// answer next time (5xx, a timeout, still queued).
|
||
const status = (err as { response?: { status?: number } } | null)?.response?.status;
|
||
const refused = (typeof status === 'number' && status >= 400 && status < 500
|
||
&& ![401, 403, 408, 429].includes(status))
|
||
|| /without data/.test(errorMessage(err));
|
||
if (!refused) this.cashHistoryRefusedSince.delete(account.id);
|
||
else if (!this.cashHistoryRefusedSince.has(account.id)) this.cashHistoryRefusedSince.set(account.id, Date.now());
|
||
// fall through to Fills fallback
|
||
}
|
||
|
||
// --- Fallback: Fills report + FIFO (slightly pre-fee, used for passed accounts) ---
|
||
try {
|
||
const raw = await requestReport('Fills', account.name);
|
||
const fixed = raw.replace(/"_tradeDate":\s*(\d{4}-\d{2}-\d{2})/g, '"_tradeDate": "$1"');
|
||
type Fill = { _tradeDate: string; _timestamp: string; _action: number; _qty: number; _price: number; Product: string; commission: number; };
|
||
interface Lot { price: number; qty: number; commPerUnit: number; }
|
||
const fills: Fill[] = JSON.parse(fixed);
|
||
this.lastFetchRaw[account.name] = `[fills fallback] ${raw.slice(0, 100)}`;
|
||
|
||
const sorted = [...fills].sort((a, b) => a._timestamp.localeCompare(b._timestamp));
|
||
const longBook: Lot[] = [];
|
||
const shortBook: Lot[] = [];
|
||
const dailyMap: { [date: string]: number } = {};
|
||
|
||
for (const fill of sorted) {
|
||
const pointValue = POINT_VALUES[fill.Product] ?? 1;
|
||
const isBuy = fill._action === 0;
|
||
let remaining = fill._qty;
|
||
const commPerUnit = fill._qty > 0 ? fill.commission / fill._qty : 0;
|
||
if (isBuy) {
|
||
while (remaining > 0 && shortBook.length > 0) {
|
||
const lot = shortBook[0];
|
||
const closed = Math.min(lot.qty, remaining);
|
||
dailyMap[fill._tradeDate] = (dailyMap[fill._tradeDate] ?? 0)
|
||
+ (lot.price - fill._price) * closed * pointValue - (commPerUnit + lot.commPerUnit) * closed;
|
||
lot.qty -= closed; remaining -= closed;
|
||
if (lot.qty === 0) shortBook.shift();
|
||
}
|
||
if (remaining > 0) longBook.push({ price: fill._price, qty: remaining, commPerUnit });
|
||
} else {
|
||
while (remaining > 0 && longBook.length > 0) {
|
||
const lot = longBook[0];
|
||
const closed = Math.min(lot.qty, remaining);
|
||
dailyMap[fill._tradeDate] = (dailyMap[fill._tradeDate] ?? 0)
|
||
+ (fill._price - lot.price) * closed * pointValue - (commPerUnit + lot.commPerUnit) * closed;
|
||
lot.qty -= closed; remaining -= closed;
|
||
if (lot.qty === 0) longBook.shift();
|
||
}
|
||
if (remaining > 0) shortBook.push({ price: fill._price, qty: remaining, commPerUnit });
|
||
}
|
||
}
|
||
|
||
// Fills report has no fund transaction data — use stored fund date and stored fund transactions
|
||
const fundDate = this.lastFundDates[account.id];
|
||
const storedFundTxns = this.fundTransactions[account.id] ?? [];
|
||
const lastFundAmt = fundDate ? (storedFundTxns.find((f) => f.date === fundDate)?.amount ?? null) : null;
|
||
const isWithdrawalFills = lastFundAmt !== null && lastFundAmt < 0;
|
||
|
||
const fresh = Object.entries(dailyMap)
|
||
.map(([date, pnl]) => ({ date, pnl: Math.round(pnl * 100) / 100 }))
|
||
.sort((a, b) => a.date.localeCompare(b.date));
|
||
|
||
const merged = mergePnL(cached, fresh);
|
||
const fillsFundTs = loadAccountMeta(account.id, 'last_fund_timestamp');
|
||
const { active, prior: priorEntries } = TradovateClient.filterActivePnL(
|
||
merged, fundDate, isWithdrawalFills, fillsFundTs
|
||
);
|
||
this.priorProfit[account.id] = Math.round(priorEntries.reduce((s, d) => s + d.pnl, 0) * 100) / 100;
|
||
this.fullDailyPnL[account.id] = merged;
|
||
this.dailyPnL[account.id] = active;
|
||
this.daysTraded[account.id] = active.filter((d) => d.pnl !== 0).length;
|
||
saveDailyPnL(account.id, account.name, fresh); // upsert only fresh entries — preserves older cached rows
|
||
this.lastFetchErrors[account.name] = FILLS_FALLBACK_NOTE;
|
||
} catch (err) {
|
||
if (this.intentionalDisconnect) break;
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
console.error(`${this.tag} fetchDaysTraded: both reports failed for ${account.name}: ${msg}`);
|
||
failedAccounts++;
|
||
// Cache already loaded and filtered at top of loop — just log the error
|
||
if (cached.length > 0) {
|
||
this.lastFetchErrors[account.name] = `${msg} (using ${cached.length} cached entries from database)`;
|
||
console.log(`${this.tag} fetchDaysTraded: using ${cached.length} cached entries from DB for ${account.name}`);
|
||
} else {
|
||
// No history at all is not an empty one: leave dailyPnL
|
||
// unset so the account stays out of trading until a fetch
|
||
// lands, instead of trading as a fresh stage-1 cycle.
|
||
this.daysTraded[account.id] ??= 0;
|
||
this.lastFetchErrors[account.name] = msg;
|
||
}
|
||
}
|
||
// The Fills fallback, or the DB cache after both reports failed.
|
||
this.markHistoryLoaded(account.id, false);
|
||
}
|
||
this.fetchDaysComplete = true;
|
||
return { failedAccounts };
|
||
}
|
||
|
||
/**
|
||
* Whether this account's history can be planned on — see PlatformClient.
|
||
* Loaded by this process, not wiped since, not on hold, and no balance
|
||
* change the history can't explain.
|
||
*/
|
||
public historyReady(accountId: number): boolean {
|
||
if (!this.historyLoadedIds.has(accountId)) return false;
|
||
if (this.dailyPnL[accountId] === undefined || this.fundTransactions[accountId] === undefined) return false;
|
||
if (this.historyHolds.has(accountId)) return false;
|
||
return !this.balanceDrifted(accountId);
|
||
}
|
||
|
||
/** historyReady() without its drift check, which can start a hold and
|
||
* moves the balance baseline — for the reporter, which must change
|
||
* neither. A drift not yet held reads ready until the scheduler looks. */
|
||
public peekHistoryReady(accountId: number): boolean {
|
||
if (!this.historyLoadedIds.has(accountId)) return false;
|
||
if (this.dailyPnL[accountId] === undefined || this.fundTransactions[accountId] === undefined) return false;
|
||
return !this.historyHolds.has(accountId);
|
||
}
|
||
|
||
/** Drop the account's history from memory (a wipe): it reads unloaded, and
|
||
* stays out of trading, until a Cash History rebuilds it — the wipe took
|
||
* its fund transactions, which neither the Fills report nor the cache can
|
||
* bring back. Kept on record too, so a restart doesn't forget it. */
|
||
public forgetHistory(accountId: number, accountName: string): void {
|
||
this.historyLoadedIds.delete(accountId);
|
||
// The fund transactions and baseline from before the wipe stay as the
|
||
// yardstick: the rebuild must explain today's balance against them,
|
||
// so a payout the rebuilt report hasn't booked yet stays held.
|
||
// Called before wipeAccountData, so the stored rows and baseline are
|
||
// still there when this process hasn't loaded them yet.
|
||
// A wipe already pending (this process's, or one from before a
|
||
// restart) keeps its yardstick: the rows it left are no guide.
|
||
const pending = loadAccountMeta(accountId, HOLD_META) === 'wipe'
|
||
? loadAccountMeta(accountId, WIPE_SIG_META) ?? UNKNOWN_SIG
|
||
: undefined;
|
||
const preWipe = this.historyHolds.get(accountId)?.fundSig
|
||
?? pending
|
||
?? fundSignature(this.fundTransactions[accountId] ?? loadFundTransactions(accountId));
|
||
this.hold(accountId, 'wipe', preWipe);
|
||
saveAccountMeta(accountId, HOLD_META, 'wipe');
|
||
saveAccountMeta(accountId, WIPE_SIG_META, preWipe);
|
||
if (!this.balanceBaseline[accountId]) {
|
||
const saved = this.savedBaseline(accountId);
|
||
if (saved) this.balanceBaseline[accountId] = saved;
|
||
}
|
||
delete this.dailyPnL[accountId];
|
||
delete this.fullDailyPnL[accountId];
|
||
delete this.daysTraded[accountId];
|
||
delete this.priorProfit[accountId];
|
||
delete this.lastFundDates[accountId];
|
||
delete this.fundTransactions[accountId];
|
||
delete this.lastFetchErrors[accountName];
|
||
delete this.lastFetchRaw[accountName];
|
||
}
|
||
|
||
private hold(accountId: number, reason: HistoryHold['reason'], fundSig: string, retryAt = 0): void {
|
||
this.historyHolds.set(accountId, { reason, fundSig, since: Date.now(), checks: 0, attempts: 0, retryAt });
|
||
}
|
||
|
||
private accountName(accountId: number): string | number {
|
||
return this.accountList.find((a) => a.id === accountId)?.name ?? accountId;
|
||
}
|
||
|
||
/**
|
||
* A fetch pass reached the account. Cash History is the only source that
|
||
* books fund transactions, so only it can lift a hold — a balance hold
|
||
* only once its fund transactions differ from when the hold began. The
|
||
* Fills fallback and the DB cache still count as loaded.
|
||
*
|
||
* cashBefore / fundSigBefore are the balance and fund transactions when
|
||
* the Cash History was requested. The balance is judged against the
|
||
* baseline this history last vouched for: if it moved and this report
|
||
* booked nothing, the move is held for a later report rather than taken
|
||
* as the new baseline; if it moved while the report was in flight, this
|
||
* report may not have seen it, so it is held for the next one.
|
||
*/
|
||
private markHistoryLoaded(
|
||
accountId: number,
|
||
fromCashHistory: boolean,
|
||
cashBefore: { amount: number; realizedPnL: number } | null = null,
|
||
fundSigBefore = '',
|
||
knownBefore: CashBaseline | null = null,
|
||
): void {
|
||
if (this.dailyPnL[accountId] === undefined || this.fundTransactions[accountId] === undefined) {
|
||
this.historyLoadedIds.delete(accountId);
|
||
return;
|
||
}
|
||
this.historyLoadedIds.add(accountId);
|
||
const now = Date.now();
|
||
if (!this.historyHolds.has(accountId) && loadAccountMeta(accountId, HOLD_META) === 'wipe') {
|
||
// A wipe from before a restart, with its pre-wipe yardstick.
|
||
this.hold(accountId, 'wipe', loadAccountMeta(accountId, WIPE_SIG_META) ?? UNKNOWN_SIG);
|
||
}
|
||
const hold = this.historyHolds.get(accountId);
|
||
if (!this.balanceBaseline[accountId]) {
|
||
const saved = this.savedBaseline(accountId);
|
||
if (saved) this.balanceBaseline[accountId] = saved;
|
||
}
|
||
|
||
if (!fromCashHistory) {
|
||
if (hold) {
|
||
hold.attempts++;
|
||
hold.retryAt = now + holdRetryMs(hold.attempts);
|
||
// Only when Cash History is refused for good (a passed account):
|
||
// an outage that may end keeps the payout held for it.
|
||
const refusedSince = this.cashHistoryRefusedSince.get(accountId);
|
||
if (hold.reason === 'balance' && refusedSince !== undefined
|
||
&& now - refusedSince >= HOLD_UNCHECKED_RELEASE_MS && now - hold.since >= HOLD_UNCHECKED_RELEASE_MS) {
|
||
console.warn(`${this.tag} ${this.accountName(accountId)}: its balance change stayed unchecked for`
|
||
+ ` ${Math.round((now - hold.since) / 60_000)}m — its Cash History is refused; releasing it`);
|
||
this.historyHolds.delete(accountId);
|
||
this.setBaseline(accountId, this.accountCashBalances[accountId] ?? null);
|
||
}
|
||
return;
|
||
}
|
||
// Keep the baseline a Cash History set — this process's, or the
|
||
// last one on record — rather than take today's balance, which may
|
||
// hold an unbooked payout; the drift check then judges it.
|
||
this.balanceDrifted(accountId);
|
||
return;
|
||
}
|
||
|
||
const fundSigNow = fundSignature(this.fundTransactions[accountId]);
|
||
const cashAfter = this.accountCashBalances[accountId];
|
||
const before = cashBefore ?? cashAfter ?? null;
|
||
|
||
if (hold) {
|
||
hold.checks++;
|
||
hold.attempts++;
|
||
if (hold.reason === 'balance' && fundSigNow === hold.fundSig) {
|
||
if (hold.checks < HOLD_MAX_CHECKS || now - hold.since < HOLD_RELEASE_MS) {
|
||
hold.retryAt = now + holdRetryMs(hold.attempts);
|
||
return;
|
||
}
|
||
console.warn(`${this.tag} ${this.accountName(accountId)}: ${hold.checks} Cash History reports over`
|
||
+ ` ${Math.round((now - hold.since) / 60_000)}m booked no fund transaction for its balance change —`
|
||
+ ' releasing it (a fee, or an adjustment the report files as trading)');
|
||
this.historyHolds.delete(accountId);
|
||
this.setBaseline(accountId, cashAfter ?? null);
|
||
return;
|
||
}
|
||
if (hold.reason === 'wipe') {
|
||
saveAccountMeta(accountId, HOLD_META, '');
|
||
saveAccountMeta(accountId, WIPE_SIG_META, '');
|
||
// Rebuilt — but the balance must still be explained against
|
||
// the pre-wipe baseline, unless the rebuild booked a fund
|
||
// transaction the pre-wipe rows didn't have.
|
||
const known = knownBefore ?? this.balanceBaseline[accountId];
|
||
if (known && before && cashExplained(known, before) === null && !bookedNew(hold.fundSig, fundSigNow)) {
|
||
this.hold(accountId, 'balance', fundSigNow, now + 60_000);
|
||
console.warn(`${this.tag} ${this.accountName(accountId)}: rebuilt after the wipe, but its balance change`
|
||
+ ' is still unbooked — held for a later report');
|
||
return;
|
||
}
|
||
}
|
||
// Booked (balance), or rebuilt and explained (wipe).
|
||
this.historyHolds.delete(accountId);
|
||
this.setBaseline(accountId, before);
|
||
return;
|
||
}
|
||
|
||
// A balance the last vouched-for baseline can't explain, with nothing
|
||
// booked by this report: a payout it hasn't caught up with yet.
|
||
// Judged against the baseline as it stood when the report was asked
|
||
// for: a fold the sync saw while it was in flight is not a payout.
|
||
const known = knownBefore ?? this.balanceBaseline[accountId];
|
||
if (known && before && cashExplained(known, before) === null && fundSigNow === fundSigBefore) {
|
||
this.hold(accountId, 'balance', fundSigBefore, now + 60_000);
|
||
console.warn(`${this.tag} ${this.accountName(accountId)}: balance moved $`
|
||
+ `${Math.round((before.amount - before.realizedPnL - known.dayStart) * 100) / 100}`
|
||
+ ' and its Cash History booked nothing yet — held for a later report');
|
||
return;
|
||
}
|
||
const base = before ? { dayStart: before.amount - before.realizedPnL, realized: before.realizedPnL } : null;
|
||
if (base && cashAfter && cashExplained(base, cashAfter) === null) {
|
||
// Held against the fund transactions from before this report, so
|
||
// the next report lifts it if this one already booked the change.
|
||
this.hold(accountId, 'balance', fundSigBefore, now + 60_000);
|
||
this.balanceBaseline[accountId] = base;
|
||
console.warn(`${this.tag} ${this.accountName(accountId)}: balance moved while its Cash History was in flight — held for the next report`);
|
||
return;
|
||
}
|
||
this.setBaseline(accountId, before);
|
||
}
|
||
|
||
/** The baseline last vouched for, as kept on record across restarts. */
|
||
private savedBaseline(accountId: number): CashBaseline | null {
|
||
try {
|
||
const saved = loadAccountMeta(accountId, BASELINE_META);
|
||
const parsed = saved ? JSON.parse(saved) as Partial<CashBaseline> : null;
|
||
if (parsed && Number.isFinite(parsed.dayStart) && Number.isFinite(parsed.realized)) {
|
||
return { dayStart: parsed.dayStart!, realized: parsed.realized! };
|
||
}
|
||
} catch { /* unreadable: none */ }
|
||
return null;
|
||
}
|
||
|
||
/** Set the baseline and keep it on record, so a restart that can't reach
|
||
* Cash History judges the balance against it. */
|
||
private setBaseline(accountId: number, cash: { amount: number; realizedPnL: number } | null): void {
|
||
if (!cash) {
|
||
delete this.balanceBaseline[accountId];
|
||
return;
|
||
}
|
||
this.balanceBaseline[accountId] = { dayStart: cash.amount - cash.realizedPnL, realized: cash.realizedPnL };
|
||
this.saveBaseline(accountId);
|
||
}
|
||
|
||
private saveBaseline(accountId: number): void {
|
||
const base = this.balanceBaseline[accountId];
|
||
if (base) saveAccountMeta(accountId, BASELINE_META, JSON.stringify({ ...base, tradeDate: currentTradeDateCT() }));
|
||
}
|
||
|
||
/**
|
||
* True when the balance moved in a way the loaded history can't explain —
|
||
* a payout or other fund transaction posting between the hourly Cash
|
||
* History runs. Planned on as is, the ended cycle's rows against the
|
||
* post-payout balance re-open a completed account. Today's trades move
|
||
* amount and realizedPnL together, and the daily reset folds the day's
|
||
* realized P&L into the balance; any other move past a few dollars holds
|
||
* the account until a Cash History books it (maybeFetchDaysTraded fetches
|
||
* it on the next sync). What the baseline learns is kept on record, so a
|
||
* restart judges against the day's last realized P&L, not a stale one.
|
||
*/
|
||
private balanceDrifted(accountId: number): boolean {
|
||
const cash = this.accountCashBalances[accountId];
|
||
if (!cash) return false;
|
||
const base = this.balanceBaseline[accountId];
|
||
if (!base) {
|
||
this.setBaseline(accountId, cash);
|
||
return false;
|
||
}
|
||
const how = cashExplained(base, cash);
|
||
if (how === 'same') {
|
||
if (Math.abs(cash.realizedPnL - base.realized) >= 1) {
|
||
base.realized = cash.realizedPnL;
|
||
this.saveBaseline(accountId);
|
||
}
|
||
return false;
|
||
}
|
||
if (how === 'folded') {
|
||
this.setBaseline(accountId, cash);
|
||
return false;
|
||
}
|
||
if (!this.historyHolds.has(accountId)) {
|
||
this.hold(accountId, 'balance', fundSignature(this.fundTransactions[accountId]));
|
||
console.warn(`${this.tag} ${this.accountName(accountId)}: balance moved`
|
||
+ ` $${Math.round((cash.amount - cash.realizedPnL - base.dayStart) * 100) / 100}`
|
||
+ ' that its history does not explain — held out of trading until Cash History books it');
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/** Run the drift check on every loaded account each sync, so the day's
|
||
* last realized P&L is on record before the daily reset folds it in. */
|
||
private observeBalances(): void {
|
||
for (const accountId of this.historyLoadedIds) {
|
||
if (!this.historyHolds.has(accountId)) this.balanceDrifted(accountId);
|
||
}
|
||
}
|
||
|
||
private async login(pTicket?: string): Promise<LoginResponseBody> {
|
||
const chl = '' + (Date.now() - 1581e9);
|
||
const sec = computeSec(this.name, this.password, this.deviceId, chl);
|
||
|
||
const generateEncodedPassword = (e) => {
|
||
const { name: t, password: n } = e,
|
||
o = t.length % n.length,
|
||
r = (n.slice(o) + n.slice(0, o)).split('').reverse().join('');
|
||
return btoa(r);
|
||
};
|
||
|
||
// Non-2xx and timeouts reject (callers log errorMessage(), never the
|
||
// AxiosError itself — its config carries this body). The abort signal
|
||
// backs the timeout up for a connect that never gets a socket.
|
||
const res = await axios.post('https://live.tradovateapi.com/v1/auth/accesstokenrequest', {
|
||
name: this.name,
|
||
password: generateEncodedPassword({ name: this.name, password: this.password }),
|
||
environment: 'demo',
|
||
appId: 'tradovate_trader(web)',
|
||
appVersion: '3.251205.0',
|
||
deviceId: this.deviceId,
|
||
cid: '1',
|
||
chl: chl,
|
||
sec: sec,
|
||
enc: true,
|
||
...(pTicket ? { 'p-ticket': pTicket } : {}),
|
||
}, { timeout: HTTP_TIMEOUT_MS, signal: AbortSignal.timeout(HTTP_TIMEOUT_MS + 5_000) });
|
||
|
||
return res.data ?? {};
|
||
}
|
||
|
||
/** Access token expiry as epoch ms (0 = none, unparseable, or not
|
||
* comparable with this machine's clock — see noteTokenExpiry()). */
|
||
private tokenExpiresAt(): number {
|
||
if (this.tokenExpiryUntrusted) return 0;
|
||
const raw = this.accessInfo?.expirationTime;
|
||
if (!raw) return 0;
|
||
const t = new Date(raw).getTime();
|
||
return Number.isFinite(t) ? t : 0;
|
||
}
|
||
|
||
/**
|
||
* Called whenever a token arrives (login or renewal). Tradovate hands out
|
||
* tokens with well over an hour to live, so one that is already expired —
|
||
* or nearly — by our clock means this machine's clock is ahead of
|
||
* Tradovate's, not that the token is dead. Trusting that expiry would have
|
||
* the watchdog "expire" every fresh token and log in again every 15s (a
|
||
* rate-limit penalty, then a captcha, for every client on this IP).
|
||
* Treat the expiry as unknown instead: renew every 2 minutes and log in on
|
||
* reconnect, as the code did before it read the expiry at all.
|
||
*/
|
||
private noteTokenExpiry(): void {
|
||
const raw = this.accessInfo?.expirationTime;
|
||
const t = raw ? new Date(raw).getTime() : NaN;
|
||
const untrusted = Number.isFinite(t) && t - Date.now() < TOKEN_RENEW_WINDOW_MS;
|
||
if (untrusted && !this.tokenExpiryUntrusted) {
|
||
console.warn(`${this.tag} new token expires ${raw}, which is (nearly) past by this machine's clock — check the system clock; ignoring the expiry`);
|
||
}
|
||
this.tokenExpiryUntrusted = untrusted;
|
||
}
|
||
|
||
/** Holding a token known to stay valid for at least `ms` more. */
|
||
private tokenValidFor(ms: number): boolean {
|
||
return !!this.accessInfo?.accessToken && this.tokenExpiresAt() > Date.now() + ms;
|
||
}
|
||
|
||
/** The 2-minute renewal check. Never rejects, never overlaps itself. */
|
||
private maybeRenewToken(): void {
|
||
if (this.intentionalDisconnect || this.connecting || this.renewInFlight || !this.accessInfo?.accessToken) return;
|
||
const now = Date.now();
|
||
const expiresAt = this.tokenExpiresAt();
|
||
// An unparseable expiry renews anyway — an extra renewal beats a silent expiry.
|
||
if (expiresAt > 0 && expiresAt - now > TOKEN_RENEW_WINDOW_MS) return;
|
||
// Already expired: renewal can't save it; the watchdog / next connect logs in.
|
||
if (expiresAt > 0 && expiresAt <= now) return;
|
||
|
||
this.renewInFlight = true;
|
||
this.renewAccessToken()
|
||
.catch((err) => console.error(`${this.tag} token renewal failed: ${errorMessage(err)}`))
|
||
.finally(() => { this.renewInFlight = false; });
|
||
}
|
||
|
||
private async renewAccessToken(): Promise<void> {
|
||
const token = this.accessInfo?.accessToken;
|
||
if (!token) return;
|
||
try {
|
||
const res = await axios.get('https://live.tradovateapi.com/v1/auth/renewAccessToken', {
|
||
headers: {
|
||
Authorization: `Bearer ${token}`,
|
||
},
|
||
timeout: HTTP_TIMEOUT_MS,
|
||
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS + 5_000),
|
||
});
|
||
if (this.intentionalDisconnect) return;
|
||
// A re-login while this was in flight already replaced the token.
|
||
if (this.accessInfo?.accessToken !== token) return;
|
||
|
||
const body = res.data as LoginResponseBody | undefined;
|
||
if (!body?.accessToken) {
|
||
throw new Error(body?.errorText ? String(body.errorText) : 'renewal answered without an access token');
|
||
}
|
||
// Merge, so a renewal answer without expirationTime can't leave it undefined.
|
||
this.accessInfo = { ...this.accessInfo, ...body } as AuthLoginResponse;
|
||
this.noteTokenExpiry();
|
||
console.log(`${this.tag} renewed access token — expires ${this.accessInfo.expirationTime}`);
|
||
} catch (err) {
|
||
if (this.intentionalDisconnect || this.accessInfo?.accessToken !== token) return;
|
||
console.warn(`${this.tag} token renewal failed: ${errorMessage(err)}`);
|
||
const status = (err as { response?: { status?: unknown } })?.response?.status;
|
||
const expiresAt = this.tokenExpiresAt();
|
||
if (status === 401 || (expiresAt > 0 && expiresAt - Date.now() < TOKEN_RELOGIN_WINDOW_MS)) {
|
||
this.abandonSocket('token renewal failed', true);
|
||
}
|
||
}
|
||
}
|
||
|
||
async getAccountList(): Promise<AccountItem[]> {
|
||
if (!this.accessInfo?.accessToken) {
|
||
console.log(`${this.tag} not authenticated`);
|
||
return [];
|
||
}
|
||
|
||
const res = await axios.get('https://demo.tradovateapi.com/v1/account/list', {
|
||
headers: {
|
||
Authorization: `Bearer ${this.accessInfo.accessToken}`,
|
||
},
|
||
timeout: HTTP_TIMEOUT_MS,
|
||
});
|
||
|
||
return res.data;
|
||
}
|
||
|
||
async findFrontMonthContract(productName: string): Promise<{ id: number; name: string; tickSize: number } | null> {
|
||
if (!this.accessInfo?.accessToken) return null;
|
||
|
||
// Prefer the volume-based resolver so the trading path matches the settings page.
|
||
const cached = getCachedContract(productName);
|
||
if (cached) return { id: cached.id, name: cached.name, tickSize: cached.tickSize };
|
||
|
||
// Lookups fail (a 401 on an expiring token, a network blip) — that must
|
||
// come back as "no contract", not a throw that aborts every firm's trade.
|
||
try {
|
||
// If the cache is cold, resolve on demand with the same logic the settings page uses.
|
||
// Under a deadline: its lookups (Yahoo, then Tradovate) must not
|
||
// hold every firm's trade. A late answer still fills the cache.
|
||
const resolved = await withDeadline(
|
||
resolveContracts([productName], this.accessInfo.accessToken),
|
||
CONTRACT_RESOLVE_DEADLINE_MS,
|
||
'contract resolve',
|
||
);
|
||
const contract = resolved[productName];
|
||
if (contract) {
|
||
return { id: contract.id, name: contract.name, tickSize: contract.tickSize };
|
||
}
|
||
} catch (err) {
|
||
console.warn(`${this.tag} contract resolve for ${productName} failed: ${errorMessage(err)} — trying contract/suggest`);
|
||
}
|
||
|
||
try {
|
||
// Last resort: fall back to Tradovate's ordered suggestions.
|
||
const res = await axios.get(
|
||
`https://demo.tradovateapi.com/v1/contract/suggest?t=${encodeURIComponent(productName)}&l=20`,
|
||
{ headers: { Authorization: `Bearer ${this.accessInfo.accessToken}` }, timeout: HTTP_TIMEOUT_MS }
|
||
);
|
||
const contracts: Array<{ id: number; name: string; status: string; providerTickSize: number }> = res.data ?? [];
|
||
const match = contracts.find((c) => c.name.startsWith(productName));
|
||
if (!match) return null;
|
||
return { id: match.id, name: match.name, tickSize: match.providerTickSize ?? 0.25 };
|
||
} catch (err) {
|
||
console.error(`${this.tag} contract lookup for ${productName} failed: ${errorMessage(err)}`);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async fetchInstrumentFees(symbols: string[]): Promise<{ [symbol: string]: number }> {
|
||
if (!this.accessInfo?.accessToken || this.products.length === 0) return {};
|
||
|
||
const productIds = symbols
|
||
.map((sym) => this.products.find((p) => p.name === sym)?.id)
|
||
.filter((id): id is number => id !== undefined);
|
||
|
||
const res = await axios.post(
|
||
'https://demo.tradovateapi.com/v1/contract/getproductfeeparams',
|
||
{ productIds },
|
||
{ headers: { Authorization: `Bearer ${this.accessInfo.accessToken}` }, timeout: HTTP_TIMEOUT_MS }
|
||
);
|
||
|
||
const result: { [symbol: string]: number } = {};
|
||
for (const param of (res.data?.params ?? [])) {
|
||
const product = this.products.find((p) => p.id === param.productId);
|
||
if (product && symbols.includes(product.name)) {
|
||
const raw =
|
||
(param.clearingFee ?? 0) +
|
||
(param.exchangeFee ?? 0) +
|
||
(param.nfaFee ?? 0) +
|
||
(param.brokerageFee ?? 0) +
|
||
(param.ipFee ?? 0) +
|
||
(param.commission ?? 0) +
|
||
(param.orderRoutingFee ?? 0);
|
||
result[product.name] = parseFloat(raw.toFixed(4));
|
||
console.log(
|
||
`${this.tag} fees ${product.name}: clearing=${param.clearingFee ?? 0}` +
|
||
` exchange=${param.exchangeFee ?? 0}` +
|
||
` nfa=${param.nfaFee ?? 0}` +
|
||
` brokerage=${param.brokerageFee ?? 0}` +
|
||
` ip=${param.ipFee ?? 0}` +
|
||
` commission=${param.commission ?? 0}` +
|
||
` routing=${param.orderRoutingFee ?? 0}` +
|
||
` → total=${result[product.name]}`
|
||
);
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
async requestContractsFromSocket(names: string[]): Promise<{
|
||
[name: string]: Contract;
|
||
}> {
|
||
// Callers expect an object — a dead socket or a lost answer is {} with
|
||
// a warning, never a promise left pending.
|
||
try {
|
||
return await this.sendRequest('contract/finds', `names=${names.join(',')}`, CONTRACT_FIND_TIMEOUT_MS, (response) => {
|
||
const items: Contract[] = Array.isArray(response) ? response : [];
|
||
return items.reduce((acc: { [name: string]: Contract }, item: Contract) => {
|
||
return { ...acc, [item.name]: item };
|
||
}, {} as { [name: string]: Contract });
|
||
});
|
||
} catch (err) {
|
||
console.warn(`${this.tag} contract/finds failed: ${errorMessage(err)}`);
|
||
return {};
|
||
}
|
||
}
|
||
|
||
/** Notes an order as the bot's, so the trade its fill opens or closes is
|
||
* credited to the bot (lib/trade-recorder.ts). */
|
||
private noteOwnOrder(accountId: number, orderId: unknown, kind: OwnOrderKind): void {
|
||
if (typeof orderId !== 'number' || !(orderId > 0)) return;
|
||
try {
|
||
recordOwnOrder(accountId, orderId, kind);
|
||
} catch (err) {
|
||
console.error(`${this.tag} could not note order ${orderId} as the bot's: ${errorMessage(err)}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The trades the sync snapshot's fills make (the session's), in the
|
||
* trades table the dashboard lists (lib/trade-recorder.ts). A fill names
|
||
* its order, not its account, so the snapshot's orders say whose it is.
|
||
* Never fails the sync.
|
||
*/
|
||
private recordTrades(response: { contracts?: unknown[]; orders?: unknown[]; positions?: unknown[]; fills?: unknown[] }): void {
|
||
try {
|
||
for (const c of (response.contracts ?? []) as { id?: unknown; name?: unknown }[]) {
|
||
if (typeof c?.id === 'number' && typeof c.name === 'string') this.contractNames.set(c.id, c.name);
|
||
}
|
||
const accountOfOrder = new Map<number, number>();
|
||
for (const o of (response.orders ?? []) as { id?: unknown; accountId?: unknown }[]) {
|
||
if (typeof o?.id === 'number' && typeof o.accountId === 'number') accountOfOrder.set(o.id, o.accountId);
|
||
}
|
||
const books = new Map<number, Map<number, { fills: Fill[]; net: number; netAt: number }>>();
|
||
const book = (accountId: number, contractId: number) => {
|
||
const contracts = books.get(accountId) ?? new Map<number, { fills: Fill[]; net: number; netAt: number }>();
|
||
books.set(accountId, contracts);
|
||
const b = contracts.get(contractId) ?? { fills: [], net: 0, netAt: -Infinity };
|
||
contracts.set(contractId, b);
|
||
return b;
|
||
};
|
||
for (const p of (response.positions ?? []) as { accountId?: unknown; contractId?: unknown; netPos?: unknown; timestamp?: unknown }[]) {
|
||
if (typeof p?.accountId !== 'number' || typeof p.contractId !== 'number') continue;
|
||
const b = book(p.accountId, p.contractId);
|
||
const at = Date.parse(String(p.timestamp ?? '')) || 0;
|
||
if (at >= b.netAt) {
|
||
b.net = typeof p.netPos === 'number' ? p.netPos : 0;
|
||
b.netAt = at;
|
||
}
|
||
}
|
||
for (const raw of (response.fills ?? []) as Record<string, unknown>[]) {
|
||
const f = raw ?? {};
|
||
if (f.active === false || typeof f.id !== 'number' || typeof f.orderId !== 'number' || typeof f.contractId !== 'number') continue;
|
||
const qty = Number(f.qty);
|
||
const price = Number(f.price);
|
||
const at = Date.parse(String(f.timestamp ?? ''));
|
||
const accountId = accountOfOrder.get(f.orderId);
|
||
if (accountId === undefined || !(qty > 0) || !Number.isFinite(price) || !Number.isFinite(at)) continue;
|
||
book(accountId, f.contractId).fills.push({
|
||
id: String(f.id), orderId: String(f.orderId), at, side: f.action === 'Buy' ? 'buy' : 'sell', qty, price, fee: null, pnl: null,
|
||
});
|
||
}
|
||
|
||
const unnamed = new Set<number>();
|
||
const roots = new Set<string>();
|
||
for (const acc of this.accountList) {
|
||
const contracts = books.get(acc.id);
|
||
if (!contracts) continue;
|
||
const list: ContractFills[] = [];
|
||
for (const [contractId, b] of contracts) {
|
||
if (b.fills.length === 0 && b.net === 0) continue;
|
||
const name = this.contractNames.get(contractId);
|
||
if (!name) {
|
||
unnamed.add(contractId);
|
||
continue;
|
||
}
|
||
const root = contractRoot(name);
|
||
roots.add(root);
|
||
const perSide = this.feePerSide.get(root);
|
||
list.push({
|
||
symbol: name,
|
||
pointValue: POINT_VALUES[root] ?? null,
|
||
currentNet: b.net,
|
||
fills: b.fills.map((f) => ({ ...f, fee: perSide === undefined ? null : Math.round(perSide * f.qty * 100) / 100 })),
|
||
});
|
||
}
|
||
if (list.length > 0) recordFillTrades('tdv', acc, list);
|
||
}
|
||
if (unnamed.size > 0) void this.lookUpContractNames([...unnamed]);
|
||
this.refreshFees(roots);
|
||
} catch (err) {
|
||
console.error(`${this.tag} trade record failed: ${errorMessage(err)}`);
|
||
}
|
||
}
|
||
|
||
/** Names for contracts the snapshot didn't describe — the next sync's
|
||
* record uses them. */
|
||
private async lookUpContractNames(ids: number[]): Promise<void> {
|
||
if (this.contractLookupInFlight) return;
|
||
this.contractLookupInFlight = true;
|
||
try {
|
||
await this.sendRequest('contract/items', `ids=${ids.join(',')}`, CONTRACT_FIND_TIMEOUT_MS, (data) => {
|
||
for (const c of (Array.isArray(data) ? data : []) as { id?: unknown; name?: unknown }[]) {
|
||
if (typeof c?.id === 'number' && typeof c.name === 'string') this.contractNames.set(c.id, c.name);
|
||
}
|
||
});
|
||
} catch (err) {
|
||
console.warn(`${this.tag} contract/items failed: ${errorMessage(err)}`);
|
||
} finally {
|
||
this.contractLookupInFlight = false;
|
||
}
|
||
}
|
||
|
||
/** Each product's fees per contract per side (getproductfeeparams), for
|
||
* the trades record: asked for a product once, then every few hours. */
|
||
private refreshFees(roots: Set<string>): void {
|
||
const now = Date.now();
|
||
const due = [...roots].filter((r) => now - (this.feesAskedAt.get(r) ?? 0) >= FEES_REFRESH_MS);
|
||
if (due.length === 0 || this.feesInFlight || this.products.length === 0) return;
|
||
this.feesInFlight = true;
|
||
for (const r of due) this.feesAskedAt.set(r, now);
|
||
this.fetchInstrumentFees(due)
|
||
.then((fees) => {
|
||
for (const [root, fee] of Object.entries(fees)) this.feePerSide.set(root, fee);
|
||
})
|
||
.catch((err) => console.warn(`${this.tag} fee lookup failed: ${errorMessage(err)}`))
|
||
.finally(() => { this.feesInFlight = false; });
|
||
}
|
||
|
||
/** A string or empty ack is the broker refusing the order — nothing was placed. */
|
||
private static acceptedAck(response: unknown, endpoint: string): { orderId?: number; id?: number; commandStatus?: string } {
|
||
if (typeof response === 'string') throw new OrderNotPlacedError(response || `${endpoint} refused`);
|
||
if (!response) throw new OrderNotPlacedError(`Empty response from ${endpoint}`);
|
||
if (typeof response !== 'object') return {};
|
||
const ack = response as { orderId?: number; id?: number; commandStatus?: string; failureReason?: string; failureText?: string };
|
||
// PlaceOrderResult / PlaceOCOResult: a refusal (SessionClosed,
|
||
// LiquidationOnly, …) carries failureReason and no order id — no order
|
||
// exists. Silently taking it as placed left a position with no exit
|
||
// (placeoco) or waited 30s for a fill that could never come.
|
||
if (ack.orderId == null && ack.id == null
|
||
&& ((ack.failureReason && ack.failureReason !== 'Success') || ack.failureText)) {
|
||
throw new OrderNotPlacedError(`${endpoint} refused: ${ack.failureText || ack.failureReason}`);
|
||
}
|
||
return ack;
|
||
}
|
||
|
||
/** Resolves with the fill event for `orderId` (it carries the real
|
||
* execution price). Registered synchronously from the ack handler, so a
|
||
* fill later in the same frame as the ack is not missed. */
|
||
/** Cancel a working order (order/cancelorder); rejects unless Tradovate
|
||
* accepted the cancel. */
|
||
private cancelOrder(orderId: number): Promise<void> {
|
||
return this.sendRequest('order/cancelorder', `\n${JSON.stringify({ orderId })}`, ACK_TIMEOUT_MS, (response, status) => {
|
||
console.log(`${this.tag} cancelorder ${orderId}:`, JSON.stringify(response));
|
||
const failure = (response as { failureReason?: string; failureText?: string } | null)?.failureReason;
|
||
if ((typeof status === 'number' && status !== 200) || typeof response === 'string' || (failure && failure !== 'Success')) {
|
||
throw new Error(`cancel of order ${orderId} refused (status ${status ?? '?'}): ${typeof response === 'string' ? response : failure ?? ''}`);
|
||
}
|
||
});
|
||
}
|
||
|
||
/** See PlatformClient: the last sync's view of the account's working orders. */
|
||
public hasWorkingOrders(accountId: number): boolean {
|
||
return (this.workingOrders[accountId] ?? 0) > 0;
|
||
}
|
||
|
||
private waitForFill(orderId: number | undefined): Promise<Record<string, any>> {
|
||
// Fills are pushed on the event stream, independently of the ack: one
|
||
// that got ahead of it (an earlier frame, or earlier in the same one)
|
||
// is already in the event buffer. Missed, a filled entry came back
|
||
// "outcome unknown" and the caller placed no exit on an open position.
|
||
const early = orderId == null ? undefined : this.recentEntityEvents.find((ev) =>
|
||
ev.entityType === 'fill' && ev.eventType === 'Created' && ev.entity?.orderId === orderId);
|
||
if (early) {
|
||
console.log(`${this.tag} fill (ahead of its ack):`, JSON.stringify(early.entity));
|
||
return Promise.resolve(early.entity);
|
||
}
|
||
return new Promise((resolve, reject) => {
|
||
const watcher: IndirectCallback = {
|
||
entityType: 'fill',
|
||
eventType: 'Created',
|
||
validator: (item: any) => item?.orderId === orderId,
|
||
callback: (fill: any) => {
|
||
clearTimeout(timeout);
|
||
console.log(`${this.tag} fill:`, JSON.stringify(fill));
|
||
resolve(fill);
|
||
},
|
||
cancel: (reason: string) => {
|
||
clearTimeout(timeout);
|
||
reject(new Error(`Order ${orderId} acknowledged but ${reason} before its fill arrived — outcome unknown`));
|
||
},
|
||
};
|
||
const timeout = setTimeout(() => {
|
||
this.indirectEventCallbacks = this.indirectEventCallbacks.filter((info) => info !== watcher);
|
||
// The caller gives up on it and places no exit, so a late fill
|
||
// would open a position nothing manages — and a retry would
|
||
// add a second order beside it. Cancel it, best effort (a
|
||
// no-op if it filled after all), like TopstepX does.
|
||
if (orderId != null) {
|
||
this.cancelOrder(orderId).catch((err) =>
|
||
console.error(`${this.tag} failed to cancel unfilled order ${orderId}: ${errorMessage(err)}`));
|
||
}
|
||
reject(new Error(`Order ${orderId} acknowledged but no fill within 30s (market may be closed) — cancel requested`));
|
||
}, FILL_TIMEOUT_MS);
|
||
this.indirectEventCallbacks.push(watcher);
|
||
});
|
||
}
|
||
|
||
async sendOrder(
|
||
accountId: number,
|
||
contractSymbol: string, // e.g. "NQH6" — WebSocket placeorder requires "symbol"
|
||
quantity: number,
|
||
action: 'Buy' | 'Sell',
|
||
orderType: 'Market' | 'Limit',
|
||
price?: number
|
||
): Promise<Record<string, any>> {
|
||
// Checked before the audit frame so a dead socket is a clean
|
||
// "not placed" rather than a send into nothing.
|
||
const problem = this.socketProblem(this.ws);
|
||
if (problem) throw new OrderNotPlacedError(`order/placeorder not sent — ${problem}`);
|
||
|
||
if (!this.sendFrame(
|
||
`user/registeraudituseraction\n25\n\n${JSON.stringify({
|
||
accountId,
|
||
actionType: action + orderType,
|
||
details: `${action} ${quantity} ${contractSymbol} ${orderType}${price ? ` @ ${price}` : ''}, TIF Day`,
|
||
})}`
|
||
)) {
|
||
throw new OrderNotPlacedError('order/placeorder not sent — Tradovate socket rejected the audit frame');
|
||
}
|
||
|
||
return this.sendRequest(
|
||
'order/placeorder',
|
||
`\n${JSON.stringify({
|
||
accountId,
|
||
action,
|
||
symbol: contractSymbol,
|
||
orderQty: quantity,
|
||
orderType,
|
||
price,
|
||
timeInForce: 'Day',
|
||
text: 'DOM',
|
||
})}`,
|
||
ACK_TIMEOUT_MS,
|
||
(response) => {
|
||
console.log(`${this.tag} sendOrder raw ack:`, JSON.stringify(response));
|
||
const ack = TradovateClient.acceptedAck(response, 'order/placeorder');
|
||
// Tradovate returns the command object: { id, commandStatus, orderId, ... }
|
||
const orderId = ack.orderId ?? ack.id;
|
||
this.noteOwnOrder(accountId, orderId, orderType === 'Limit' ? 'limit' : 'market');
|
||
console.log(`${this.tag} sendOrder orderId=${orderId} status=${ack.commandStatus}`);
|
||
|
||
// Wait for the fill event — it carries the real execution price
|
||
return this.waitForFill(orderId);
|
||
},
|
||
);
|
||
}
|
||
|
||
/** Place an order and resolve as soon as the command is acknowledged (does not wait for fill). */
|
||
async placeOrderNoWait(
|
||
accountId: number,
|
||
contractSymbol: string,
|
||
quantity: number,
|
||
action: 'Buy' | 'Sell',
|
||
orderType: 'Market' | 'Limit',
|
||
price?: number
|
||
): Promise<{ orderId?: number }> {
|
||
return this.sendRequest(
|
||
'order/placeorder',
|
||
`\n${JSON.stringify({
|
||
accountId,
|
||
action,
|
||
symbol: contractSymbol,
|
||
orderQty: quantity,
|
||
orderType,
|
||
price,
|
||
timeInForce: 'Day',
|
||
text: 'DOM',
|
||
})}`,
|
||
ACK_TIMEOUT_MS,
|
||
(response) => {
|
||
const ack = TradovateClient.acceptedAck(response, 'order/placeorder');
|
||
this.noteOwnOrder(accountId, ack.orderId ?? ack.id, orderType === 'Limit' ? 'limit' : 'market');
|
||
return { orderId: ack.orderId ?? ack.id };
|
||
},
|
||
);
|
||
}
|
||
|
||
/** Place an OCO exit pair after an entry fill: a limit take-profit linked
|
||
* to a stop, via order/placeoco — Tradovate cancels the sibling when one
|
||
* fills. Resolves on command ack (does not wait for a fill), like
|
||
* placeOrderNoWait. */
|
||
async placeOcoExit(
|
||
accountId: number,
|
||
contractSymbol: string,
|
||
quantity: number,
|
||
exitAction: 'Buy' | 'Sell',
|
||
limitPrice: number,
|
||
stopPrice: number,
|
||
): Promise<{ orderId?: number }> {
|
||
return this.sendRequest(
|
||
'order/placeoco',
|
||
`\n${JSON.stringify({
|
||
accountId,
|
||
action: exitAction,
|
||
symbol: contractSymbol,
|
||
orderQty: quantity,
|
||
orderType: 'Limit',
|
||
price: limitPrice,
|
||
timeInForce: 'Day',
|
||
text: 'DOM',
|
||
other: {
|
||
action: exitAction,
|
||
orderType: 'Stop',
|
||
stopPrice,
|
||
timeInForce: 'Day',
|
||
text: 'DOM',
|
||
},
|
||
})}`,
|
||
ACK_TIMEOUT_MS,
|
||
(response) => {
|
||
const ack = TradovateClient.acceptedAck(response, 'order/placeoco');
|
||
// PlaceOCOResult: orderId is the take-profit limit, ocoId its stop.
|
||
this.noteOwnOrder(accountId, ack.orderId ?? ack.id, 'limit');
|
||
this.noteOwnOrder(accountId, (response as { ocoId?: unknown }).ocoId, 'stop');
|
||
return { orderId: ack.orderId ?? ack.id };
|
||
},
|
||
);
|
||
}
|
||
|
||
/** The market-data token, for the live quote feed (lib/live-quotes.ts);
|
||
* null while logged out or when the user has no market data. */
|
||
public quoteToken(): string | null {
|
||
const info = this.accessInfo;
|
||
if (!info?.mdAccessToken || info.hasMarketData === false) return null;
|
||
const expiresAt = this.tokenExpiresAt();
|
||
return expiresAt === 0 || expiresAt > Date.now() ? info.mdAccessToken : null;
|
||
}
|
||
|
||
/** Liveness snapshot for /api/health and the registry supervisor. No tokens. */
|
||
public health(): ClientHealth {
|
||
const now = Date.now();
|
||
const expiresAt = this.tokenExpiresAt();
|
||
return {
|
||
platform: 'tradovate',
|
||
authenticated: !!this.accessInfo?.accessToken && (expiresAt === 0 || expiresAt > now),
|
||
transport: this.ws ? (READY_STATE_NAMES[this.ws.readyState] ?? String(this.ws.readyState)) : 'none',
|
||
syncComplete: this.syncComplete,
|
||
lastSyncAt: this.lastSyncAt,
|
||
lastLoginAt: this.lastLoginAt,
|
||
lastLoginError: this.lastLoginError,
|
||
consecutiveLoginFailures: this.consecutiveLoginFailures,
|
||
nextReconnectAt: this.nextReconnectAt,
|
||
tokenExpiresAt: expiresAt,
|
||
lastInboundAt: this.lastInboundAt,
|
||
pendingRequests: Object.keys(this.directEventCallbacks).length + this.indirectEventCallbacks.length,
|
||
syncInFlightSince: this.syncRequestedAt,
|
||
daysFetchInFlightSince: this.daysFetchInFlight ? this.lastDaysFetchStartedAt : 0,
|
||
};
|
||
}
|
||
|
||
/** Close the WebSocket connection and stop all timers. Call before discarding
|
||
* the instance — nothing it scheduled fires afterwards. */
|
||
public disconnect(): void {
|
||
this.intentionalDisconnect = true;
|
||
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
|
||
this.nextReconnectAt = 0;
|
||
if (this.livenessInterval) { clearInterval(this.livenessInterval); this.livenessInterval = null; }
|
||
if (this.tokenRenewalInterval) { clearInterval(this.tokenRenewalInterval); this.tokenRenewalInterval = null; }
|
||
if (this.penaltyWait) {
|
||
// Cut the wait short; the login loop sees intentionalDisconnect and stops.
|
||
const { timer, resolve } = this.penaltyWait;
|
||
this.penaltyWait = null;
|
||
clearTimeout(timer);
|
||
resolve();
|
||
}
|
||
// Closes the socket with its handlers detached, clears sync/heartbeat,
|
||
// rejects pending acks (ambiguous: they may have been placed).
|
||
this.dropSocket('client disconnected');
|
||
const watchers = this.indirectEventCallbacks;
|
||
this.indirectEventCallbacks = [];
|
||
for (const info of watchers) info.cancel?.('client disconnected');
|
||
this.daysFetchInFlight = false;
|
||
this.nextDaysFetchRetryAt = 0;
|
||
}
|
||
|
||
/** Register a one-time callback for when a fill arrives for a given orderId. */
|
||
onFill(orderId: number, callback: (fill: any) => void): void {
|
||
this.indirectEventCallbacks.push({
|
||
entityType: 'fill',
|
||
eventType: 'Created',
|
||
validator: (item: any) => item?.orderId === orderId,
|
||
callback,
|
||
});
|
||
}
|
||
}
|