Files
autofirmer-expanded/lib/tradovate-class.ts
T
Brandon LiandClaude Opus 5.5 5eb0ea7760 Broker sessions rest while nothing needs them
A firm's client used to stay logged in around the clock, renewing on a fixed
timer overnight and at weekends. Now, once a firm has nothing open, nothing
pending and nothing left to trade before its next session for a random
15–45 minutes, its client disconnects and the dashboard shows it Resting.
It logs back in a random 20–50 minutes before the next session opens, at a
check-in every 3–7 Central daytime hours, or at once when trading starts, a
manual leg, copy trade, manual order or AutoBuyer run needs it. Token
renewals fall at random points (Tradovate 15–45 min before expiry, TopstepX
every 45–150 min). Settings → Trading → Rest idle broker sessions
(session_rest) turns it off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:28:47 -05:00

2325 lines
115 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 } from './db';
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;
/** Each token is renewed at a point drawn between these two lengths before
* its expiry, so renewals keep no fixed rhythm (TOKEN_RENEW_WINDOW_MS is the
* latest one may wait). */
const TOKEN_RENEW_EARLIEST_MS = 45 * 60_000;
/** With the expiry unknown or untrusted, renew this long after the token
* arrived instead (drawn) — well inside Tradovate's 90-minute tokens. */
const TOKEN_BLIND_RENEW_MIN_MS = 10 * 60_000;
const TOKEN_BLIND_RENEW_MAX_MS = 25 * 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;
/** 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 */
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;
/** When the current token is to be renewed (drawn in noteTokenExpiry()). */
private renewAt = 0;
/** 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.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 10–25 minutes after each
* token arrives (this clock's differences still hold) and log in on
* reconnect.
*
* Also draws when this token is renewed: a random 15–45 minutes before it
* expires, so the renewals don't tick like a clock.
*/
private noteTokenExpiry(): void {
const raw = this.accessInfo?.expirationTime;
const now = Date.now();
const t = raw ? new Date(raw).getTime() : NaN;
const untrusted = Number.isFinite(t) && t - 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;
const expiresAt = this.tokenExpiresAt();
this.renewAt = expiresAt > 0
? expiresAt - TOKEN_RENEW_WINDOW_MS - Math.random() * (TOKEN_RENEW_EARLIEST_MS - TOKEN_RENEW_WINDOW_MS)
: now + TOKEN_BLIND_RENEW_MIN_MS + Math.random() * (TOKEN_BLIND_RENEW_MAX_MS - TOKEN_BLIND_RENEW_MIN_MS);
}
/** 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: renews once the token's drawn renewal
* time (noteTokenExpiry) has passed. 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();
if (now < this.renewAt) 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 {};
}
}
/** 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;
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');
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');
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;
// Logged out for good: a snapshot left in the pool (session rest)
// must not be able to reach the broker. Blank, not removed — late
// readers use accessInfo?.accessToken and see a logged-out client.
if (this.accessInfo) this.accessInfo = { ...this.accessInfo, accessToken: '', mdAccessToken: '' };
}
/** See PlatformClient: what the session still waits on besides positions
* and working orders. */
public restBlocker(): string | null {
const pending = Object.keys(this.directEventCallbacks).length + this.indirectEventCallbacks.length;
if (pending > 0) return `${pending} request(s) awaiting an answer`;
if (this.daysFetchInFlight) return 'a report fetch is underway';
return null;
}
/** 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,
});
}
}