/** * DXtrade client — the Devexperts DXtrade twin of TradovateClient and * TopstepXClient, first for Tradeify247 (crypto, tokenized stocks, index and * commodity CFDs, 24/7). * * REST only: https:///dxsca-web (the firm's server URL on the master). * POST /login {username, domain, password} → a session token, sent as * `Authorization: DXAPI ` and kept alive with POST /ping. Everything * is polled: a 60s sync of accounts, metrics, positions, open orders and the * day's cash transactions; a fast poll while an order waits for its fill or * an exit pair is live. (The Push API websocket would make fills quicker; it * waits until the smoke test has confirmed its message format.) * * Differences from the futures platforms that shape this file: * - Account codes are strings ("clearing:account"); ids are a stable hash of * them (dxAccountId) so every numeric account table keeps working. * - There are no CME products. The firm's symbol map (master Firms page) says * which DXtrade symbols a CME root may trade as, and findFrontMonthContract * picks the cheapest per unit of volatility right now (scoreCandidate). * One engine "contract" is one quantity step of the symbol, and the step * rides in the contract name, so the engine's target/stop math is unchanged. * - Quantities are units, and positions are closed by positionCode * (positionEffect CLOSE): an exit order can't open or flip a position. * - Commission is a percent of notional; the engine computes it itself. * - Cash transactions (SETTLEMENT, COMMISSION, FINANCING, DEPOSIT, * WITHDRAWAL…) are the books: daily P&L, fund transactions, today's * realized P&L and the balance at the firm's 22:00 UTC reset all come from * them. * - No auto-liquidation profile: autoLiqThresholds stays 0; a breached * account shows as accountStatus ≠ FULL_TRADING → active=false. */ import axios from 'axios'; import { currentTradeDateCT, FIRM_RESET_UTC_HOUR, DEFAULT_TARGET_HOLD_HOURS } from './trading-logic'; import { saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions, saveEndedCycleStart, } from './db'; import { getAccountConfig } from './account-type-resolver'; import { getCachedFirm } from './firm-catalog'; import type { SymbolMapCandidate } from './master-protocol'; import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth } from './platform-client'; import { OrderNotPlacedError, isOrderNotPlaced, errorMessage } from './platform-client'; import { dxAccountId, accountDisplayName, accountCodeOf, encodeContractName, decodeContractName, roundToStep, lastUtcReset, ctTradeDayStart, timeOf, parseDurationMs, ciNum, isFundType, signedFundValue, newOrderCode, inTradingHours, realizedVolPerHour, scoreCandidate, candidateRejection, } from './dxtrade-helpers'; import { hyperliquidCoins, hyperliquidCoinFor, hyperliquidQuote, hyperliquidHourlyCloses } from './hyperliquid-feed'; // ── timings ────────────────────────────────────────────────────────────────── const SYNC_INTERVAL_MS = 60_000; const SYNC_WEDGED_MS = 3 * 60_000; const WATCHDOG_INTERVAL_MS = 60_000; /** Logged in longer than this with no good sync for as long → re-login. */ const SESSION_STALE_MS = 10 * 60_000; /** Transport failures and 5xx back off 60s → 10 min, ±20%. */ const LOGIN_RETRY_BASE_MS = 60_000; const LOGIN_RETRY_MAX_MS = 10 * 60_000; /** Refused credentials: DXtrade locks an account after repeated failures, so * retry slowly and then stop until someone fixes the login. */ const CREDS_RETRY_MS = [15 * 60_000, 60 * 60_000]; const CREDS_MAX_ATTEMPTS = 3; /** Every DXTrade client on the machine shares one IP's login limit (1/s). */ const LOGIN_SPACING_MS = 1_200; /** Request spacing per bucket, under the 10/s read and trade limits and the * 1/s limit on large/historical queries. */ const SPACING_MS = { read: 125, trade: 125, history: 1_100 } as const; type Bucket = keyof typeof SPACING_MS; const TIMEOUT_MS = { read: 30_000, trade: 15_000, history: 60_000 } as const; /** Account codes per multi-account request. */ const ACCOUNT_CHUNK = 25; const FILL_POLL_MS = 750; const FILL_TIMEOUT_MS = 30_000; const OCO_POLL_MS = 5_000; /** A pick is reused this long, so a batch's lookups (Phase 1, drain legs) * all land on the same symbol. */ const PICK_TTL_MS = 45_000; const INSTRUMENT_TTL_MS = 60 * 60_000; const ACCOUNT_INSTRUMENT_TTL_MS = 10 * 60_000; const CANDLE_TTL_MS = 15 * 60_000; const CANDLE_HOURS = 48; /** History is fetched this far back at most (or from registration). */ const HISTORY_MAX_DAYS = 180; /** Balance the books can't explain by this much holds the account. */ const BOOKS_TOLERANCE = 200; /** An unknown-outcome order stops holding its account this long after, once * the account shows no working order. */ const SUSPECT_SETTLE_MS = 90_000; /** Our own orders (af-…) left working on a flat account this long are reaped. */ const REAP_AFTER_MS = 60_000; /** A position this old with no working order is flagged as unprotected. */ const NAKED_AFTER_MS = 2 * 60_000; /** accountStatus values that mean the firm has closed the account for good * (TERMINATED after a breach, EXPIRED, NO_TRADING when nothing may trade). * CLOSE_ONLY is left inactive rather than dead: it can still unwind. */ const DEAD_STATUSES = new Set(['TERMINATED', 'EXPIRED', 'NO_TRADING']); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const round2 = (n: number) => Math.round(n * 100) / 100; // ── wire types (loose: the server's serializer and the docs disagree) ─────── interface DxPosition { account: string; positionCode: string; symbol: string; quantity: number; side: 'BUY' | 'SELL'; openTime: number; openPrice: number; } interface DxOrder { account: string; orderId: number | undefined; orderCode: string; version: number | undefined; type: string; status: string; finalStatus: boolean; instrument: string; positionCode: string | undefined; filledQuantity: number; averagePrice: number | undefined; issueTime: number; rejectReason: string | undefined; } interface DxTxn { key: string; account: string; type: string; value: number; time: number; orderCode: string | undefined; } /** DXtrade answered with an error body. */ class DxApiError extends Error { constructor(message: string, readonly status: number, readonly code: number | undefined) { super(message); this.name = 'DxApiError'; } } function parsePosition(raw: unknown): DxPosition | null { const p = raw as Record; const account = accountCodeOf(p?.account); if (!account || typeof p.symbol !== 'string') return null; const quantity = Math.abs(ciNum(p, 'quantity') ?? 0); if (!(quantity > 0)) return null; return { account, positionCode: String(p.positionCode ?? ''), symbol: p.symbol, quantity, side: p.side === 'SELL' ? 'SELL' : 'BUY', openTime: timeOf(p.openTime), openPrice: ciNum(p, 'openPrice') ?? NaN, }; } function parseOrder(raw: unknown): DxOrder | null { const o = raw as Record; const account = accountCodeOf(o?.account); if (!account || typeof o.orderCode !== 'string') return null; const legs = Array.isArray(o.legs) ? (o.legs as Record[]) : []; const leg = legs[0] ?? {}; const executions = Array.isArray(o.executions) ? (o.executions as Record[]) : []; const rejected = executions.find((e) => typeof e.rejectReason === 'string'); return { account, orderId: ciNum(o, 'orderId'), orderCode: o.orderCode, version: ciNum(o, 'version'), type: String(o.type ?? ''), status: String(o.status ?? ''), finalStatus: o.finalStatus === true, instrument: String(o.instrument ?? leg.instrument ?? ''), positionCode: typeof leg.positionCode === 'string' ? leg.positionCode : undefined, filledQuantity: ciNum(leg, 'filledQuantity') ?? 0, // The leg's average, else the last execution's (a filled close may // carry it only there). averagePrice: ciNum(leg, 'averagePrice') ?? [...executions].reverse().map((e) => ciNum(e, 'averagePrice')).find((p) => p !== undefined && p > 0), issueTime: timeOf(o.issueTime), rejectReason: rejected ? String(rejected.rejectReason) : undefined, }; } function parseTransfers(body: unknown): DxTxn[] { const transfers = (body as { cashTransfers?: unknown[] })?.cashTransfers ?? []; const out: DxTxn[] = []; for (const t of transfers) { const transfer = t as Record; const txns = Array.isArray(transfer.cashTransactions) ? transfer.cashTransactions : []; for (const x of txns) { const tx = x as Record; const account = accountCodeOf(tx.account) ?? accountCodeOf(transfer.account); const value = ciNum(tx, 'value'); const time = timeOf(tx.transactionTime ?? transfer.transactionTime); if (!account || value === undefined || !Number.isFinite(time) || typeof tx.type !== 'string') continue; const code = typeof tx.transactionCode === 'string' ? tx.transactionCode : ''; out.push({ key: code || `${transfer.transferCode ?? ''}|${tx.type}|${time}|${value}|${tx.orderCode ?? ''}|${tx.tradeCode ?? ''}`, account, type: tx.type, value, time, orderCode: typeof tx.orderCode === 'string' ? tx.orderCode : undefined, }); } } return out; } function chunks(items: T[], size: number): T[][] { const out: T[][] = []; for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size)); return out; } const g = globalThis as typeof globalThis & { __dxLoginNextAt?: number }; export interface DXTradeConfig { firmId: string; firmName: string; /** REST base, e.g. https://dx.tradeify247.co/dxsca-web */ serverUrl: string; /** Login domain ('' = 'default'). */ loginDomain: string; username: string; password: string; } export class DXTradeClient implements PlatformClient { private readonly cfg: DXTradeConfig; private readonly base: string | null; private readonly host: string; private readonly domain: string; private token: string | null = null; private sessionTimeoutMs = 30 * 60_000; private lastRequestAt = 0; private lastResponseAt = 0; /** Which users path answered: "login@domain" or the bare login. */ private userPath: string | null = null; public accountList: PlatformAccount[] = []; public accountCashBalances: { [accountId: number]: { amount: number; realizedPnL: number; equity?: number } } = {}; public positions: { [accountId: number]: { contractId: string; netPos: number; netPrice: number; timestamp: Date }; } = {}; public daysTraded: { [accountId: number]: number } = {}; public dailyPnL: { [accountId: number]: { date: string; pnl: number }[] } = {}; public fullDailyPnL: { [accountId: number]: { date: string; pnl: number }[] } = {}; public lastFundDates: { [accountId: number]: string | null } = {}; public fundTransactions: { [accountId: number]: { date: string; amount: number }[] } = {}; public priorProfit: { [accountId: number]: number } = {}; public autoLiqThresholds: { [accountId: number]: number } = {}; public fetchDaysComplete = false; public syncComplete = false; public lastSyncAt = 0; public lastSyncStartedAt = 0; public lastFetchErrors: Record = {}; public lastFetchRaw: Record = {}; public recentEntityEvents: { entityType: string; eventType: string; entity: unknown; ts: number }[] = []; /** Tradovate-only concept. */ public products: { id: number; name: string }[] = []; private readonly onSync: () => Promise; private codeById = new Map(); private idByCode = new Map(); private accountMeta = new Map(); private positionsDetail = new Map(); private openOrders = new Map(); /** The day's cash transactions (since the earlier of the CT trade-date * start and the last firm reset), per account. */ private recentTxns = new Map>(); /** Accounts whose balance and day's transactions came back this pass. */ private dayFresh = new Set(); /** When each open position was first seen, for hold-time logging. */ private seenOpenAt = new Map(); private syncInterval: ReturnType | null = null; private pingInterval: ReturnType | null = null; private watchdogInterval: ReturnType | null = null; private ocoPollInterval: ReturnType | null = null; private loginRetryTimer: ReturnType | null = null; private intentionalDisconnect = false; private loginPromise: Promise | null = null; private lastLoginAt = 0; private lastLoginError: string | null = null; private consecutiveLoginFailures = 0; private credsFailures = 0; private credsRejected = false; private nextReconnectAt = 0; private syncInFlight = false; private syncInFlightSince = 0; private syncGeneration = 0; private syncRunning: Promise = Promise.resolve(); private daysFetchInFlight = false; private daysFetchInFlightSince = 0; private lastDaysFetchStartedAt = 0; private lastDaysFetchSucceededAt = 0; private nextDaysFetchRetryAt = 0; private historyLoaded = false; private historyLoadedIds = new Set(); private booksHeld = new Set(); private fetchingIds = new Set(); private gateNext: Record = { read: 0, trade: 0, history: 0 }; /** Accounts whose last order ended with its outcome unknown, and since when. */ private suspectAccounts = new Map(); /** Opens in flight per symbol, for the hedge guard. */ private openingSides = new Map(); private fillWaiters: { orderId: number; callback: (fill: Record) => void }[] = []; private pendingFills = 0; /** Exit pairs: when the position closes, whatever leg survives is cancelled. */ private ocoPairs: { accountId: number; account: string; positionCode: string; codes: string[]; native: boolean; done: boolean; placedAt: number }[] = []; /** Whether the server takes an OCO group ('no' after a definitive refusal). */ private nativeOco: 'unknown' | 'no' = 'unknown'; private pickCache = new Map(); private instrumentCache = new Map; at: number }>(); private accountInstrumentCache = new Map; at: number }>(); /** Volatility per symbol, measured once per trade date (see volatility). */ private candleCache = new Map(); /** The last instrument the selector picked — what the dashboard's hold * estimate sizes against. */ public lastPickedContract: PlatformContract | null = null; private lastPickTradeDate = ''; private unmappedLoggedAt = new Map(); constructor(cfg: DXTradeConfig, onSync: () => Promise) { this.cfg = cfg; this.onSync = onSync; this.domain = cfg.loginDomain.trim() || 'default'; let base: string | null = null; let host = ''; try { const url = new URL(cfg.serverUrl); if (url.protocol === 'https:') { base = `${url.origin}${url.pathname.replace(/\/+$/, '')}`; host = url.host; } } catch { /* reported below */ } this.base = base; this.host = host; if (!base) { this.lastLoginError = this.lastFetchErrors['login'] = 'DXtrade server URL not set on the master Firms page'; console.error(`[dxtrade] ${cfg.firmName}: ${this.lastLoginError}`); return; } this.watchdogInterval = setInterval(() => this.watchdog(), WATCHDOG_INTERVAL_MS); void this.connectAndAuth(); } private get tag(): string { return `[dxtrade] ${this.cfg.firmName}:`; } // ── transport ──────────────────────────────────────────────────────────── private async gate(bucket: Bucket): Promise { const now = Date.now(); const at = Math.max(now, this.gateNext[bucket]); this.gateNext[bucket] = at + SPACING_MS[bucket]; if (at > now) await sleep(at - now); } /** * One REST call. Non-2xx answers become DxApiError (status + DXtrade's * numeric error code); transport failures become a plain Error carrying * only the message — never the axios error, whose config holds the * session token (and, for /login, the password). An expired session is * renewed once and the call retried; a rate limit waits and retries once. */ private async request( bucket: Bucket, method: 'GET' | 'POST' | 'PUT' | 'DELETE', path: string, opts: { params?: Record; body?: unknown; headers?: Record; auth?: boolean; retried?: boolean } = {}, ): Promise<{ data: T; headers: Record }> { if (!this.base) throw new Error('DXtrade server URL not set'); const auth = opts.auth !== false; if (auth && !this.token) throw new DxApiError(`${path}: not logged in`, 401, 1); await this.gate(bucket); this.lastRequestAt = Date.now(); let res; try { res = await axios.request({ method, url: `${this.base}${path}`, params: opts.params, data: opts.body, headers: { ...(auth ? { Authorization: `DXAPI ${this.token}` } : {}), ...(opts.body !== undefined ? { 'Content-Type': 'application/json' } : {}), Accept: 'application/json', ...opts.headers, }, timeout: TIMEOUT_MS[bucket], signal: AbortSignal.timeout(TIMEOUT_MS[bucket] + 5_000), validateStatus: () => true, }); } catch (err) { if (axios.isCancel(err)) throw new Error(`${path} timed out after ${TIMEOUT_MS[bucket] / 1000}s`); throw new Error(`${path}: ${errorMessage(err)}`); } this.lastResponseAt = Date.now(); const headers = Object.fromEntries(Object.entries(res.headers ?? {}).map(([k, v]) => [k.toLowerCase(), String(v)])); if (res.status >= 200 && res.status < 300) return { data: res.data as T, headers }; const body = (res.data && typeof res.data === 'object' ? res.data : {}) as { errorCode?: unknown; description?: unknown }; const code = Number(body.errorCode); const description = typeof body.description === 'string' ? body.description : `HTTP ${res.status}`; const error = new DxApiError(`${path}: ${description} (HTTP ${res.status}${Number.isFinite(code) ? `, code ${code}` : ''})`, res.status, Number.isFinite(code) ? code : undefined); if (!opts.retried && auth && res.status === 401 && error.code !== 3) { // Session expired (code 1) or dropped: log in again, then retry // once. An order retries with its own orderCode, so a duplicate // is refused (409 code 100), never placed twice. this.token = null; await this.connectAndAuth(); if (this.token) return this.request(bucket, method, path, { ...opts, retried: true }); } if (!opts.retried && res.status === 429) { const wait = Math.min(10_000, Math.max(1_000, Number(headers['retry-after'] ?? 1.5) * 1000)); await sleep(wait); return this.request(bucket, method, path, { ...opts, retried: true }); } throw error; } // ── auth ───────────────────────────────────────────────────────────────── /** Log in, single-flight; a caller arriving mid-login waits for it. Never rejects. */ private connectAndAuth(): Promise { if (this.intentionalDisconnect || this.credsRejected || !this.base) return Promise.resolve(); if (this.loginPromise) return this.loginPromise; this.loginPromise = this.login().finally(() => { this.loginPromise = null; }); return this.loginPromise; } private async login(): Promise { if (this.loginRetryTimer) { clearTimeout(this.loginRetryTimer); this.loginRetryTimer = null; } this.nextReconnectAt = 0; // One IP's login limit is shared by every DXTrade client here. const now = Date.now(); const at = Math.max(now, g.__dxLoginNextAt ?? 0); g.__dxLoginNextAt = at + LOGIN_SPACING_MS; if (at > now) await sleep(at - now); try { const { data } = await this.request<{ sessionToken?: string; timeout?: unknown }>('trade', 'POST', '/login', { auth: false, body: { username: this.cfg.username, domain: this.domain, password: this.cfg.password }, }); if (this.intentionalDisconnect) return; if (!data?.sessionToken) throw new Error('/login answered without a session token'); this.token = data.sessionToken; this.sessionTimeoutMs = parseDurationMs(data.timeout); this.lastLoginAt = Date.now(); this.consecutiveLoginFailures = 0; this.credsFailures = 0; this.lastLoginError = null; delete this.lastFetchErrors['login']; console.log(`${this.tag} logged in as ${this.cfg.username}@${this.domain} (session idles out after ${Math.round(this.sessionTimeoutMs / 60_000)}m)`); if (this.pingInterval) clearInterval(this.pingInterval); const pingEvery = Math.max(30_000, Math.min(this.sessionTimeoutMs / 3, 4 * 60_000)); this.pingInterval = setInterval(() => void this.ping(), pingEvery); if (!this.syncInterval) { this.syncInterval = setInterval(() => void this.requestSync(), SYNC_INTERVAL_MS); } void this.requestSync(); } catch (err) { if (this.intentionalDisconnect) return; this.token = null; this.consecutiveLoginFailures++; const msg = errorMessage(err); if (err instanceof DxApiError && err.status === 401) { // Refused credentials (code 3) — or a locked account. Each // retry risks the lock, so go slow, then stop. this.credsFailures++; const locked = /lock/i.test(msg); if (locked || this.credsFailures >= CREDS_MAX_ATTEMPTS) { this.credsRejected = true; this.lastLoginError = this.lastFetchErrors['login'] = `DXtrade refused the login for ${this.cfg.username} (domain '${this.domain}')` + `${locked ? ' — the account is LOCKED' : ` ${this.credsFailures}×`}; stopped retrying.` + ' Check the login domain on the master Firms page and re-save the credentials here.'; console.error(`${this.tag} ${this.lastLoginError}`); return; } const delay = CREDS_RETRY_MS[Math.min(this.credsFailures - 1, CREDS_RETRY_MS.length - 1)]; this.lastLoginError = this.lastFetchErrors['login'] = `${msg} — refused credentials, retrying in ${Math.round(delay / 60_000)}m`; this.scheduleLogin(delay); console.error(`${this.tag} ${this.lastLoginError}`); return; } const backoff = Math.min(LOGIN_RETRY_BASE_MS * 2 ** Math.min(this.consecutiveLoginFailures - 1, 10), LOGIN_RETRY_MAX_MS); const delay = Math.round(backoff * (0.8 + Math.random() * 0.4)); this.lastLoginError = this.lastFetchErrors['login'] = msg; this.scheduleLogin(delay); console.error(`${this.tag} login failed: ${msg} — attempt ${this.consecutiveLoginFailures}, retrying in ${Math.round(delay / 1000)}s`); } } private scheduleLogin(delay: number): void { this.nextReconnectAt = Date.now() + delay; this.loginRetryTimer = setTimeout(() => { this.loginRetryTimer = null; this.nextReconnectAt = 0; void this.connectAndAuth(); }, delay); } private async ping(): Promise { if (!this.token || this.intentionalDisconnect) return; // Any request resets the idle timer; only ping when nothing has. if (Date.now() - this.lastRequestAt < this.sessionTimeoutMs / 3) return; try { await this.request('read', 'POST', '/ping'); } catch (err) { console.warn(`${this.tag} ping failed: ${errorMessage(err)}`); } } /** Every failure path leaves a retry scheduled; this catches the ones * that don't, and sessions that died without anything failing loudly. */ private watchdog(): void { if (this.intentionalDisconnect || this.credsRejected || this.loginPromise || this.loginRetryTimer) return; const now = Date.now(); if (!this.token) { console.warn(`${this.tag} watchdog: no session and no login pending — logging in`); void this.connectAndAuth(); return; } if (now - this.lastLoginAt > SESSION_STALE_MS && now - this.lastSyncAt > SESSION_STALE_MS) { console.warn(`${this.tag} watchdog: no good sync in ${Math.round((now - this.lastSyncAt) / 60_000)} min — re-logging in`); this.token = null; void this.connectAndAuth(); } } // ── sync ───────────────────────────────────────────────────────────────── private async fetchAccounts(): Promise<{ code: string; status: string; positionBased: boolean; registrationTime: number; currency: string }[]> { const paths = this.userPath ? [this.userPath] : [`${this.cfg.username}@${this.domain}`, this.cfg.username]; let lastErr: unknown = null; for (const p of paths) { try { const { data } = await this.request<{ userDetails?: { accounts?: unknown[] }[] }>('read', 'GET', `/users/${encodeURIComponent(p)}`); this.userPath = p; const accounts = (data?.userDetails ?? []).flatMap((u) => u.accounts ?? []); return accounts.flatMap((a) => { const acc = a as Record; const code = accountCodeOf(acc.account); if (!code) return []; return [{ code, status: String(acc.accountStatus ?? ''), positionBased: acc.positionBased !== false, registrationTime: timeOf(acc.registrationTime), currency: typeof acc.baseCurrency === 'string' ? acc.baseCurrency : 'USD', }]; }); } catch (err) { lastErr = err; if (!(err instanceof DxApiError) || (err.status !== 404 && err.status !== 400)) throw err; } } throw lastErr ?? new Error('no user details'); } /** One multi-account GET per chunk of account codes; the lists concatenated. */ private async perAccounts(path: string, codes: string[], pick: (data: unknown) => unknown[], params: Record = {}, bucket: Bucket = 'read'): Promise { const out: T[] = []; for (const chunk of chunks(codes, ACCOUNT_CHUNK)) { const { data } = await this.request(bucket, 'GET', path, { params: { ...params, accounts: chunk.join(',') } }); out.push(...(pick(data) as T[])); } return out; } /** * One REST sync. Single-flight, with the same wedge recovery as the other * clients. lastSyncAt — what the scheduler trades on — is certified only * when every account's balance, positions, open orders and the day's * transactions came back, and the first history pass has landed. */ private async requestSync(): Promise { if (!this.token || this.intentionalDisconnect) return; const startedAt = Date.now(); if (this.syncInFlight) { const runningMs = startedAt - this.syncInFlightSince; if (runningMs < SYNC_WEDGED_MS) return this.syncRunning; console.warn(`${this.tag} sync running for ${Math.round(runningMs / 1000)}s — looks wedged, starting a fresh one`); } const gen = ++this.syncGeneration; const superseded = () => gen !== this.syncGeneration || this.intentionalDisconnect; this.syncInFlight = true; this.syncInFlightSince = startedAt; let finished!: () => void; this.syncRunning = new Promise((resolve) => { finished = resolve; }); try { const accounts = await this.fetchAccounts(); if (superseded()) return; const list: PlatformAccount[] = []; for (const a of accounts) { const id = dxAccountId(this.host, a.code); const clash = this.codeById.get(id); if (clash && clash !== a.code) { console.error(`${this.tag} account id collision between ${clash} and ${a.code} — leaving ${a.code} out`); continue; } this.codeById.set(id, a.code); this.idByCode.set(a.code, id); this.accountMeta.set(id, { status: a.status, positionBased: a.positionBased, registrationTime: a.registrationTime, currency: a.currency }); list.push({ id, name: accountDisplayName(a.code), active: a.status === 'FULL_TRADING' }); } this.accountList = list; const codes = list.map((a) => this.codeById.get(a.id)!); let fresh = true; const metrics = new Map>(); const positions = new Map(); const orders = new Map(); let txns: DxTxn[] = []; const now = new Date(); const windowStart = new Date(Math.min(ctTradeDayStart(now).getTime(), lastUtcReset(now, FIRM_RESET_UTC_HOUR).getTime()) - 5 * 60_000); if (codes.length > 0) { try { for (const m of await this.perAccounts>('/accounts/metrics', codes, (d) => (d as { metrics?: unknown[] })?.metrics ?? [])) { const code = accountCodeOf(m.account); if (code) metrics.set(code, m); } for (const p of await this.perAccounts('/accounts/positions', codes, (d) => (d as { positions?: unknown[] })?.positions ?? [])) { const pos = parsePosition(p); if (pos) positions.set(pos.account, [...(positions.get(pos.account) ?? []), pos]); } for (const o of await this.perAccounts('/accounts/orders', codes, (d) => (d as { orders?: unknown[] })?.orders ?? [])) { const order = parseOrder(o); if (order && !order.finalStatus) orders.set(order.account, [...(orders.get(order.account) ?? []), order]); } const raw = await this.perAccounts('/accounts/transfers', codes, (d) => [d], { 'time-from': windowStart.toISOString(), limit: 1000 }); txns = raw.flatMap((body) => parseTransfers(body)); } catch (err) { fresh = false; console.error(`${this.tag} sync failed: ${errorMessage(err)}`); } } if (superseded()) return; const today = currentTradeDateCT(); const resetMs = lastUtcReset(now, FIRM_RESET_UTC_HOUR).getTime(); this.dayFresh.clear(); for (const acc of list) { const code = this.codeById.get(acc.id)!; if (!fresh) continue; const m = metrics.get(code); const balance = ciNum(m, 'balance'); if (balance === undefined) continue; // an account the metrics call left out: stays stale const accPositions = positions.get(code) ?? []; this.positionsDetail.set(acc.id, accPositions); this.openOrders.set(acc.id, orders.get(code) ?? []); this.applyPosition(acc, accPositions); const map = new Map(); for (const t of txns) if (t.account === code) map.set(t.key, t); this.recentTxns.set(acc.id, map); let realized = 0; for (const t of map.values()) { if (!isFundType(t.type) && currentTradeDateCT(new Date(t.time)) === today) realized += t.value; } this.accountCashBalances[acc.id] = { amount: balance, realizedPnL: round2(realized), equity: ciNum(m, 'equity') }; // No floor to report — except for an account the firm has // closed for good (a breach): the balance itself, so the // dashboards and the reporter count it dead, not just inactive. this.autoLiqThresholds[acc.id] = DEAD_STATUSES.has(this.accountMeta.get(acc.id)?.status ?? '') ? balance : 0; this.dayFresh.add(acc.id); // Keep the reset snapshot handy for the debug view. this.lastFetchRaw[`dayStart:${acc.name}`] = String(this.dayStartFromTxns(acc.id, balance, resetMs)); } this.settleSuspects(); await this.reapStrayOrders(); this.checkNakedPositions(); if (superseded()) return; console.log(`${this.tag} sync — ${list.length} account(s), ${Object.keys(this.positions).length} open position(s)`); this.maybeFetchDaysTraded(); // Measure the day's volatility (and so the hold estimate) at the // first good sync of each trade date, whether or not a batch fires. if (this.historyLoaded && this.lastPickTradeDate !== currentTradeDateCT()) { this.lastPickTradeDate = currentTradeDateCT(); void this.findFrontMonthContract('*').catch((err) => console.warn(`${this.tag} daily volatility check failed: ${errorMessage(err)}`)); } this.syncComplete = true; if (!fresh || this.dayFresh.size < list.length) { console.warn(`${this.tag} sync incomplete — not counted as a good sync`); } else if (this.historyLoaded) { this.lastSyncAt = Date.now(); this.lastSyncStartedAt = startedAt; } try { await this.onSync(); } catch (err) { console.error(`${this.tag} post-sync callback failed: ${errorMessage(err)}`); } } catch (err) { console.error(`${this.tag} sync failed: ${errorMessage(err)}`); } finally { if (gen === this.syncGeneration) { this.syncInFlight = false; this.syncInFlightSince = 0; } finished(); } } private applyPosition(acc: PlatformAccount, list: DxPosition[]): void { if (list.length > 1) console.warn(`${this.tag} ${acc.name} holds ${list.length} positions — tracking the first`); const pos = list[0]; if (!pos) { const seen = this.seenOpenAt.get(acc.id); if (seen) { console.log(`${this.tag} ${acc.name}: position closed after ${((Date.now() - seen.at) / 3_600_000).toFixed(2)}h`); this.seenOpenAt.delete(acc.id); } delete this.positions[acc.id]; return; } const seen = this.seenOpenAt.get(acc.id); if (!seen || seen.positionCode !== pos.positionCode) { this.seenOpenAt.set(acc.id, { positionCode: pos.positionCode, at: Number.isFinite(pos.openTime) ? pos.openTime : Date.now(), naggedAt: 0 }); } this.positions[acc.id] = { contractId: pos.symbol, netPos: pos.side === 'BUY' ? pos.quantity : -pos.quantity, netPrice: pos.openPrice, timestamp: new Date(Number.isFinite(pos.openTime) ? pos.openTime : Date.now()), }; } /** Closed balance at the last reset: today's balance less everything the * books moved since — trading and fund movements alike. A withdrawal * after the reset therefore narrows the day's room rather than hide * inside it, which is the safe reading until the firm says otherwise. */ private dayStartFromTxns(accountId: number, balance: number, resetMs: number): number { let since = 0; for (const t of this.recentTxns.get(accountId)?.values() ?? []) if (t.time >= resetMs) since += t.value; return round2(balance - since); } /** See PlatformClient. null until this pass read the account's balance * and its transactions since the reset. */ public dayStartBalance(accountId: number): number | null { if (!this.dayFresh.has(accountId)) return null; const cash = this.accountCashBalances[accountId]; if (!cash) return null; return this.dayStartFromTxns(accountId, cash.amount, lastUtcReset(new Date(), FIRM_RESET_UTC_HOUR).getTime()); } /** An unknown-outcome order stops holding its account once the account * shows no working order a while after it. */ private settleSuspects(): void { const now = Date.now(); for (const [id, since] of this.suspectAccounts) { if (now - since >= SUSPECT_SETTLE_MS && (this.openOrders.get(id) ?? []).length === 0) this.suspectAccounts.delete(id); } } public hasWorkingOrders(accountId: number): boolean { return this.suspectAccounts.has(accountId) || (this.openOrders.get(accountId) ?? []).length > 0; } /** Our own orders still working on a flat account are leftovers — an exit * whose position closed while no poll was watching, or one this client * lost track of in a restart. A CLOSE order can't open a position, but * it has no business sitting there. */ private async reapStrayOrders(): Promise { const now = Date.now(); for (const acc of this.accountList) { if (this.positions[acc.id]) continue; const stray = (this.openOrders.get(acc.id) ?? []) .filter((o) => o.orderCode.startsWith('af-') && Number.isFinite(o.issueTime) && now - o.issueTime > REAP_AFTER_MS); for (const o of stray) { console.warn(`${this.tag} ${acc.name}: cancelling stray order ${o.orderCode} (account flat)`); await this.cancelOrder(acc.id, o.orderCode).catch((err) => console.error(`${this.tag} failed to cancel stray ${o.orderCode}: ${errorMessage(err)}`)); } } } /** A position with nothing working to close it — a restart mid-exit, or * a leg that failed. Not closed automatically (it may be a manual trade), * but loud, and on the account's fetch-error note. */ private checkNakedPositions(): void { const now = Date.now(); for (const acc of this.accountList) { const seen = this.seenOpenAt.get(acc.id); if (!seen || !this.positions[acc.id]) continue; if ((this.openOrders.get(acc.id) ?? []).length > 0 || now - seen.at < NAKED_AFTER_MS) continue; if (now - seen.naggedAt < 15 * 60_000) continue; seen.naggedAt = now; console.error(`${this.tag} ${acc.name}: position ${seen.positionCode} has NO working exit or stop — close it or place exits at the broker`); } } // ── history ────────────────────────────────────────────────────────────── private maybeFetchDaysTraded(): void { if (this.daysFetchInFlight || this.intentionalDisconnect) 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_000; const waiting = !initialFetchDue && !retryPending ? this.accountList.filter((a) => !this.historyLoadedIds.has(a.id) && !this.fetchingIds.has(a.id)).map((a) => a.id) : []; const targeted = !retryDue && !hourlyRefreshDue && !initialFetchDue && waiting.length > 0; if (!initialFetchDue && !retryDue && !hourlyRefreshDue && !targeted) return; this.daysFetchInFlight = true; this.daysFetchInFlightSince = now; this.lastDaysFetchStartedAt = now; if (retryDue) this.nextDaysFetchRetryAt = 0; this.fetchDaysTraded(targeted ? waiting : undefined) .then(({ failedAccounts }) => { if (!this.historyLoaded && !this.intentionalDisconnect) { this.historyLoaded = true; void this.requestSync(); } if (failedAccounts > 0) { this.nextDaysFetchRetryAt = Date.now() + 5 * 60_000; console.warn(`${this.tag} ${failedAccounts} account(s) failed history fetch — retrying in 5 minutes`); return; } if (!targeted) this.lastDaysFetchSucceededAt = Date.now(); }) .catch((err) => { this.nextDaysFetchRetryAt = Date.now() + 5 * 60_000; console.error(`${this.tag} history fetch failed — retrying in 5 minutes: ${errorMessage(err)}`); }) .finally(() => { this.daysFetchInFlight = false; this.daysFetchInFlightSince = 0; }); } 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(ids); } finally { for (const id of ids) this.fetchingIds.delete(id); } } /** * Every cash transaction since the accounts opened (capped at * HISTORY_MAX_DAYS), paged. The page direction isn't documented, so it * follows nextPageTransactionTime whichever end of the page it names. */ private async fetchAllTransfers(codes: string[], from: Date): Promise { const all = new Map(); for (const chunk of chunks(codes, ACCOUNT_CHUNK)) { let timeFrom = from.toISOString(); let timeTo = new Date(Date.now() + 60 * 60_000).toISOString(); for (let page = 0; page < 30; page++) { const { data } = await this.request<{ nextPageTransactionTime?: unknown }>('history', 'GET', '/accounts/transfers', { params: { accounts: chunk.join(','), 'time-from': timeFrom, 'time-to': timeTo, limit: 1000 }, }); const txns = parseTransfers(data); const before = all.size; for (const t of txns) all.set(t.key, t); const next = timeOf(data?.nextPageTransactionTime); if (!Number.isFinite(next) || txns.length === 0 || all.size === before) break; const times = txns.map((t) => t.time); const oldest = Math.min(...times); const newest = Math.max(...times); if (Math.abs(next - oldest) <= Math.abs(next - newest)) timeTo = new Date(next).toISOString(); else timeFrom = new Date(next).toISOString(); } } return [...all.values()]; } private async fetchDaysTradedPass(ids: number[]): Promise<{ failedAccounts: number }> { if (!this.token || ids.length === 0) return { failedAccounts: 0 }; this.fetchDaysComplete = false; const accounts = this.accountList.filter((a) => ids.includes(a.id)); const codes = accounts.map((a) => this.codeById.get(a.id)!).filter(Boolean); const earliest = Math.min(...accounts.map((a) => this.accountMeta.get(a.id)?.registrationTime ?? NaN).filter(Number.isFinite)); const floor = Date.now() - HISTORY_MAX_DAYS * 86_400_000; const from = new Date(Math.max(floor, Number.isFinite(earliest) ? earliest - 86_400_000 : floor)); let txns: DxTxn[]; try { txns = await this.fetchAllTransfers(codes, from); } catch (err) { const msg = errorMessage(err); console.error(`${this.tag} history fetch failed: ${msg}`); for (const account of accounts) this.fallBackToCache(account, msg); this.fetchDaysComplete = true; return { failedAccounts: accounts.length }; } for (const account of accounts) { if (this.intentionalDisconnect) break; const code = this.codeById.get(account.id)!; const mine = txns.filter((t) => t.account === code).sort((a, b) => a.time - b.time); this.lastFetchRaw[account.name] = `${mine.length} transaction(s): ${[...new Set(mine.map((t) => t.type))].join(', ') || 'none'}`; const daily = new Map(); const funds = new Map(); for (const t of mine) { const date = currentTradeDateCT(new Date(t.time)); if (isFundType(t.type)) { funds.set(date, (funds.get(date) ?? 0) + signedFundValue(t.type, t.value)); continue; } const e = daily.get(date) ?? { pnl: 0, fees: 0 }; e.pnl += t.value; if (t.type === 'COMMISSION') e.fees += Math.abs(t.value); daily.set(date, e); } const fresh = [...daily.entries()] .map(([date, e]) => ({ date, pnl: round2(e.pnl), fees: Math.round(e.fees * 10000) / 10000 })) .sort((a, b) => a.date.localeCompare(b.date)); const merged = new Map(); for (const e of loadDailyPnL(account.id)) merged.set(e.date, e.pnl); for (const e of fresh) merged.set(e.date, e.pnl); this.fullDailyPnL[account.id] = [...merged.entries()] .map(([date, pnl]) => ({ date, pnl })) .sort((a, b) => a.date.localeCompare(b.date)); saveDailyPnL(account.id, account.name, fresh); // Fund transactions: the API has them, so they replace guesswork. const storedFundDate = loadAccountMeta(account.id, 'last_fund_date'); const fundRows = [...funds.entries()] .map(([date, amount]) => ({ date, amount: round2(amount) })) .filter((f) => f.amount !== 0) .sort((a, b) => a.date.localeCompare(b.date)); if (fundRows.length > 0) saveFundTransactions(account.id, account.name, fundRows); this.fundTransactions[account.id] = loadFundTransactions(account.id); const lastFundDate = fundRows.length > 0 ? fundRows[fundRows.length - 1].date : storedFundDate; if (lastFundDate && storedFundDate && lastFundDate > storedFundDate) { // A new cycle: note where the one it ends began, for the // reporter's catch-up (a withdrawal's own day stays behind). const prevAmt = this.fundTransactions[account.id]?.find((f) => f.date === storedFundDate)?.amount ?? null; saveEndedCycleStart(account.id, { fundDate: storedFundDate, includesDay: !(prevAmt !== null && prevAmt < 0) }); } if (lastFundDate) saveAccountMeta(account.id, 'last_fund_date', lastFundDate); this.lastFundDates[account.id] = lastFundDate ?? null; this.applyCycleSlice(account.id); // Books check: the balance must be what the transactions add up // to. A payout booked some unexpected way would otherwise leave the // account trading its finished cycle as if nothing happened. const cash = this.accountCashBalances[account.id]; const size = getAccountConfig(account.id).accountType?.accountSize ?? 0; const hasDeposit = mine.some((t) => t.type === 'DEPOSIT'); const expected = mine.reduce((s, t) => s + (isFundType(t.type) ? signedFundValue(t.type, t.value) : t.value), 0) + (hasDeposit ? 0 : size); const tradable = this.accountMeta.get(account.id)?.status === 'FULL_TRADING'; if (tradable && cash && !this.positions[account.id] && Math.abs(cash.amount - expected) >= BOOKS_TOLERANCE) { this.booksHeld.add(account.id); this.lastFetchErrors[account.name] = `balance ${round2(cash.amount)} vs transactions ${round2(expected)} — held until they agree`; console.warn(`${this.tag} ${account.name}: ${this.lastFetchErrors[account.name]}`); } else { this.booksHeld.delete(account.id); delete this.lastFetchErrors[account.name]; } this.historyLoadedIds.add(account.id); } this.fetchDaysComplete = true; return { failedAccounts: 0 }; } private fallBackToCache(account: PlatformAccount, msg: string): void { const cached = loadDailyPnL(account.id); this.fundTransactions[account.id] ??= loadFundTransactions(account.id); this.lastFundDates[account.id] ??= loadAccountMeta(account.id, 'last_fund_date'); if (cached.length > 0) { this.fullDailyPnL[account.id] = cached; this.applyCycleSlice(account.id); this.lastFetchErrors[account.name] = `${msg} (using ${cached.length} cached entries from database)`; } else { this.daysTraded[account.id] ??= 0; this.lastFetchErrors[account.name] = msg; } } /** The active cycle out of the full history: a withdrawal's day belongs to * the OLD cycle, a deposit's day to the new one (as on TopstepX). */ private applyCycleSlice(accountId: number): void { const merged = this.fullDailyPnL[accountId] ?? []; const fundDate = this.lastFundDates[accountId]; const txns = this.fundTransactions[accountId] ?? []; const lastAmt = fundDate ? (txns.find((f) => f.date === fundDate)?.amount ?? null) : null; const isWithdrawal = lastAmt !== null && lastAmt < 0; const active = fundDate ? merged.filter((d) => (isWithdrawal ? d.date > fundDate : d.date >= fundDate)) : merged; const prior = fundDate ? merged.filter((d) => (isWithdrawal ? d.date <= fundDate : d.date < fundDate)) : []; this.priorProfit[accountId] = round2(prior.reduce((s, d) => s + d.pnl, 0)); this.dailyPnL[accountId] = active; this.daysTraded[accountId] = active.filter((d) => d.pnl !== 0).length; } public historyReady(accountId: number): boolean { return this.historyLoadedIds.has(accountId) && !this.booksHeld.has(accountId) && this.dailyPnL[accountId] !== undefined && this.fundTransactions[accountId] !== undefined; } public forgetHistory(accountId: number, accountName: string): void { this.historyLoadedIds.delete(accountId); this.booksHeld.delete(accountId); 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]; } // ── instrument selector ────────────────────────────────────────────────── /** An account every lookup can use: the first that can trade. */ private referenceAccount(): { code: string; currency: string } | null { for (const acc of this.accountList) { if (!acc.active) continue; const code = this.codeById.get(acc.id); if (code) return { code, currency: this.accountMeta.get(acc.id)?.currency ?? 'USD' }; } return null; } private async instruments(symbols: string[]): Promise>> { const now = Date.now(); const missing = symbols.filter((s) => !(now - (this.instrumentCache.get(s)?.at ?? 0) < INSTRUMENT_TTL_MS)); if (missing.length > 0) { const { data } = await this.request<{ instruments?: Record[] }>('read', 'GET', '/instruments/query', { params: { symbols: missing.join(',') } }); for (const inst of data?.instruments ?? []) { if (typeof inst.symbol === 'string') this.instrumentCache.set(inst.symbol, { inst, at: now }); } } return new Map(symbols.flatMap((s) => { const c = this.instrumentCache.get(s); return c ? [[s, c.inst] as const] : []; })); } private async accountInstruments(account: string, symbols: string[]): Promise>> { const now = Date.now(); const missing = symbols.filter((s) => !(now - (this.accountInstrumentCache.get(s)?.at ?? 0) < ACCOUNT_INSTRUMENT_TTL_MS)); if (missing.length > 0) { const { data } = await this.request<{ instrumentDetails?: Record[] }>('read', 'GET', `/accounts/${encodeURIComponent(account)}/instruments/query`, { params: { symbols: missing.join(',') } }); for (const det of data?.instrumentDetails ?? []) { if (typeof det.symbol === 'string') this.accountInstrumentCache.set(det.symbol, { det, at: now }); } } return new Map(symbols.flatMap((s) => { const c = this.accountInstrumentCache.get(s); return c ? [[s, c.det] as const] : []; })); } private async marketData(account: string, symbols: string[], eventType: Record): Promise[]> { const { data } = await this.request<{ events?: Record[] }>('read', 'POST', '/marketdata', { body: { account, symbols, eventTypes: [eventType] }, }); return data?.events ?? []; } /** Whether DXtrade's own /marketdata is off limits to this login (it * answers code 34 "No proper permission is configured"), in which case * quotes and candles come from Hyperliquid instead (hyperliquid-feed). */ private dxMarketDataDenied = false; private isPermissionDenied(err: unknown): boolean { return err instanceof DxApiError && (err.code === 34 || err.status === 403); } private noteMarketDataDenied(err: unknown): void { if (this.dxMarketDataDenied) return; this.dxMarketDataDenied = true; console.warn(`${this.tag} DXtrade market data not permitted (${errorMessage(err)}) — using Hyperliquid's public prices for the instrument pick`); } /** DXtrade symbol → Hyperliquid coin for the symbols given (unmapped left out). */ private async hyperliquidCoins(symbols: string[]): Promise> { const coins = await hyperliquidCoins(); const out = new Map(); for (const s of symbols) { const coin = hyperliquidCoinFor(s, coins); if (coin) out.set(s, coin); } return out; } private async quotes(account: string, symbols: string[]): Promise> { const out = new Map(); if (!this.dxMarketDataDenied) { try { const events = await this.marketData(account, symbols, { type: 'Quote', format: 'COMPACT' }); for (const e of events) { if (typeof e.symbol !== 'string') continue; out.set(e.symbol, { bid: ciNum(e, 'bid'), ask: ciNum(e, 'ask'), time: timeOf(e.time ?? e.timestamp) }); } return out; } catch (err) { if (!this.isPermissionDenied(err)) throw err; this.noteMarketDataDenied(err); } } for (const [symbol, coin] of await this.hyperliquidCoins(symbols)) { out.set(symbol, await hyperliquidQuote(coin)); } return out; } private async volatility(account: string, symbols: string[]): Promise> { const now = Date.now(); // Measured once per trade date, so a day's sizing and hold estimate // stay put; a measurement that came back empty retries after a while. const today = currentTradeDateCT(); const missing = symbols.filter((s) => { const c = this.candleCache.get(s); return !c || c.tradeDate !== today || (c.vol === null && now - c.at >= CANDLE_TTL_MS); }); if (missing.length > 0) { const closes = new Map(); let fromDx = false; if (!this.dxMarketDataDenied) { try { const events = await this.marketData(account, missing, { type: 'Candle', format: 'COMPACT', candleType: '1h', fromTime: new Date(now - (CANDLE_HOURS + 1) * 3_600_000).toISOString(), toTime: new Date(now).toISOString(), }); const bySymbol = new Map(); for (const e of events) { const close = ciNum(e, 'close'); if (typeof e.symbol !== 'string' || close === undefined) continue; bySymbol.set(e.symbol, [...(bySymbol.get(e.symbol) ?? []), { time: timeOf(e.time ?? e.timestamp), close }]); } for (const [s, list] of bySymbol) closes.set(s, list.sort((a, b) => a.time - b.time).map((c) => c.close)); fromDx = true; } catch (err) { if (!this.isPermissionDenied(err)) throw err; this.noteMarketDataDenied(err); } } if (!fromDx) { for (const [symbol, coin] of await this.hyperliquidCoins(missing)) { closes.set(symbol, await hyperliquidHourlyCloses(coin, CANDLE_HOURS)); } } for (const s of missing) { const list = closes.get(s) ?? []; const vol = realizedVolPerHour(list); this.candleCache.set(s, { vol, candles: list.length, at: now, tradeDate: today }); if (vol !== null) console.log(`${this.tag} ${s} volatility for ${today}: ${(vol * Math.sqrt(24) * 100).toFixed(2)}%/day (${list.length} hourly closes)`); } } return new Map(symbols.map((s) => [s, this.candleCache.get(s) ?? { vol: null, candles: 0 }])); } /** * The contract a CME root trades as on this firm right now: the cheapest * tradable candidate from the symbol map, per unit of volatility (see * scoreCandidate). Sticky while an account * holds one of the candidates, cached briefly so a batch's lookups agree. */ public async findFrontMonthContract(root: string): Promise { if (!this.token) return null; const firm = getCachedFirm(this.cfg.firmId); const map = firm?.symbolMap ?? {}; const candidates = map[root.toUpperCase()] ?? map['*'] ?? []; const now = Date.now(); if (candidates.length === 0) { if (now - (this.unmappedLoggedAt.get(root) ?? 0) > 10 * 60_000) { this.unmappedLoggedAt.set(root, now); console.log(`${this.tag} no symbol mapped for ${root} (nor '*') — skipping; set the symbol map on the master Firms page`); } return null; } const held = new Set([...this.positionsDetail.values()].flat().map((p) => p.symbol)); const sticky = candidates.find((c) => held.has(c.symbol)); if (!sticky) { const cached = this.pickCache.get(root); if (cached && now - cached.at < PICK_TTL_MS) return cached.contract; } const pick = await this.pickCandidate(root, sticky ? [sticky] : candidates, sticky !== undefined); if (pick && !sticky) this.pickCache.set(root, { contract: pick, at: now }); return pick; } private async pickCandidate(root: string, candidates: SymbolMapCandidate[], sticky: boolean): Promise { const ref = this.referenceAccount(); if (!ref) return null; const firm = getCachedFirm(this.cfg.firmId); const targetHold = firm?.targetHoldHours || DEFAULT_TARGET_HOLD_HOURS; const symbols = [...new Set(candidates.map((c) => c.symbol))]; const [insts, dets, quotes, vols] = await Promise.all([ this.instruments(symbols), this.accountInstruments(ref.code, symbols), this.quotes(ref.code, symbols), this.volatility(ref.code, symbols), ]); const now = new Date(); const board: { symbol: string; score: number | null; note: string; contract?: PlatformContract }[] = []; for (const cand of candidates) { const inst = insts.get(cand.symbol); if (!inst) { board.push({ symbol: cand.symbol, score: null, note: 'unknown symbol' }); continue; } const det = dets.get(cand.symbol); const quote = quotes.get(cand.symbol); const vol = vols.get(cand.symbol); const increment = ciNum(inst, 'quantityIncrement') ?? 0; const detIncrement = ciNum(det, 'minOrderSizeIncrement') ?? 0; const step = Math.max(increment, detIncrement) || ciNum(inst, 'lotSize') || 1; const multiplier = ciNum(inst, 'multiplier') || 1; const tick = ciNum(inst, 'priceIncrement') ?? 0; const minOrder = ciNum(det, 'minOrderSize') ?? 0; const maxOrder = det?.maxOrderSize as { value?: unknown; limitType?: unknown } | undefined; const bid = quote?.bid; const ask = quote?.ask; const mid = bid !== undefined && ask !== undefined ? (bid + ask) / 2 : NaN; const spread = Number.isFinite(mid) && mid > 0 ? (ask! - bid!) / mid : NaN; const stepNotional = step * mid * multiplier; let maxContracts: number | undefined; if (maxOrder && typeof maxOrder.value === 'number' && maxOrder.value > 0) { maxContracts = maxOrder.limitType === 'NOTIONAL_CURRENCY' ? Math.floor(maxOrder.value / stepNotional) : Math.floor((maxOrder.value * (ciNum(inst, 'lotSize') || 1)) / step); } const minContracts = minOrder > 0 ? Math.ceil(minOrder / step - 1e-9) : 1; const rejection = !(tick > 0) ? 'no price increment' : candidateRejection({ tradingStatus: typeof det?.tradingStatus === 'string' ? det.tradingStatus : undefined, bid, ask, quoteAgeMs: quote && Number.isFinite(quote.time) ? Date.now() - quote.time : null, inHours: inTradingHours(inst.tradingHours, now), candles: vol?.candles ?? 0, minContractsOk: maxContracts === undefined || maxContracts >= minContracts, currency: typeof inst.currency === 'string' ? inst.currency : undefined, accountCurrency: ref.currency, }); if (rejection) { board.push({ symbol: cand.symbol, score: null, note: rejection }); continue; } const pct = cand.commissionPct ?? firm?.commissionPct ?? 0; const score = scoreCandidate({ spreadFraction: spread, commissionPct: pct, financingPctPerDay: firm?.financingPctPerDay ?? 0, targetHoldHours: targetHold, volatilityPerHour: vol?.vol ?? null, }); if (score === null) { board.push({ symbol: cand.symbol, score: null, note: 'no volatility' }); continue; } board.push({ symbol: cand.symbol, score, note: `spread ${(spread * 100).toFixed(3)}%${vol?.vol ? `, σ ${(vol.vol * Math.sqrt(24) * 100).toFixed(2)}%/day` : ''}`, contract: { id: cand.symbol, name: encodeContractName(cand.symbol, step), tickSize: tick, pointValue: step * multiplier, commissionPct: pct, stepNotional, volatilityPerHour: vol?.vol ?? undefined, spreadFraction: spread, maxContracts, minContracts, }, }); } const ranked = board.filter((b) => b.contract).sort((a, b) => a.score! - b.score!); const winner = ranked[0]; const summary = board .map((b) => `${b.symbol}${b.score !== null ? ` ${b.score.toFixed(4)}` : ''} (${b.note})`) .join('; '); this.lastFetchRaw[`pick:${root}`] = `${new Date().toISOString()} → ${winner?.symbol ?? 'none'} — ${summary}`; console.log(`${this.tag} ${root}${sticky ? ' (held)' : ''} → ${winner?.symbol ?? 'nothing tradable'} — ${summary}`); if (winner?.contract) this.lastPickedContract = winner.contract; return winner?.contract ?? null; } public async fetchInstrumentFees(): Promise<{ [symbol: string]: number }> { // Commission is a percent of notional, set per firm on the master. return {}; } // ── orders ─────────────────────────────────────────────────────────────── private async freshPositions(account: string): Promise { const { data } = await this.request<{ positions?: unknown[] }>('trade', 'GET', `/accounts/${encodeURIComponent(account)}/positions`); return (data?.positions ?? []).map(parsePosition).filter((p): p is DxPosition => p !== null); } private async freshOpenOrders(account: string): Promise { const { data } = await this.request<{ orders?: unknown[] }>('trade', 'GET', `/accounts/${encodeURIComponent(account)}/orders`); return (data?.orders ?? []).map(parseOrder).filter((o): o is DxOrder => o !== null && !o.finalStatus); } /** An order by its client code, from the open list or the day's history. */ private async findOrder(account: string, orderCode: string): Promise { const open = (await this.freshOpenOrders(account)).find((o) => o.orderCode === orderCode); if (open) return open; const { data } = await this.request<{ orders?: unknown[] }>('history', 'GET', `/accounts/${encodeURIComponent(account)}/orders/history`, { params: { 'with-client-id': orderCode, limit: 5 }, }); return (data?.orders ?? []).map(parseOrder).find((o) => o?.orderCode === orderCode) ?? null; } /** * POST one order (or an OCO group). Only an answer that proves nothing * was placed becomes OrderNotPlacedError; a transport failure or a 5xx is * checked by its orderCode, and stays a plain Error when that can't tell. */ private async submit(account: string, body: Record, codes: string[]): Promise<{ orderId?: number }> { try { const { data } = await this.request<{ orderId?: number; orders?: { orderId?: number }[] }>('trade', 'POST', `/accounts/${encodeURIComponent(account)}/orders`, { body }); return { orderId: data?.orderId ?? data?.orders?.[0]?.orderId }; } catch (err) { if (err instanceof DxApiError) { if (err.status === 409 && err.code === 100) return {}; // the retry found it placed if (err.status === 400 || err.status === 401 || err.status === 403 || err.status === 404 || err.status === 422 || err.status === 429) { throw new OrderNotPlacedError(`DXtrade refused the order: ${err.message}`); } } // Ambiguous: look for it before calling it either way. for (let i = 0; i < 3; i++) { await sleep(2_000); try { const found = await Promise.all(codes.map((c) => this.findOrder(account, c))); if (found.some((o) => o !== null)) return { orderId: found.find((o) => o)?.orderId }; } catch { /* try again */ } } throw new Error(`DXtrade order outcome unknown (it may still have been placed): ${errorMessage(err)}`); } } private orderBody(account: string, symbol: string, o: { code: string; type: 'MARKET' | 'LIMIT' | 'STOP'; side: 'BUY' | 'SELL'; quantity: number; positionEffect: 'OPEN' | 'CLOSE'; positionCode?: string; limitPrice?: number; stopPrice?: number; }): Record { return { account, orderCode: o.code, type: o.type, instrument: symbol, // A closing STOP or LIMIT closes the whole position by its code and // must not name a quantity (DXtrade: "Closing STOP/LIMIT orders // should not have specified quantity", code 33). A MARKET close // may, and the flatten path relies on it. ...(o.positionEffect === 'CLOSE' && o.type !== 'MARKET' ? {} : { quantity: o.quantity }), positionEffect: o.positionEffect, ...(o.positionCode ? { positionCode: o.positionCode } : {}), side: o.side, ...(o.limitPrice !== undefined ? { limitPrice: o.limitPrice } : {}), ...(o.stopPrice !== undefined ? { stopPrice: o.stopPrice } : {}), tif: 'GTC', }; } /** What an order needs before it is sent. Failing here reached nothing. */ private orderContext(accountId: number, contractName: string): { account: string; symbol: string; step: number } { if (!this.token) throw new OrderNotPlacedError('DXtrade not logged in — order not sent'); const account = this.codeById.get(accountId); if (!account) throw new OrderNotPlacedError(`DXtrade account ${accountId} unknown — order not sent`); const decoded = decodeContractName(contractName); if (!decoded) throw new OrderNotPlacedError(`DXtrade contract ${contractName} unreadable — order not sent`); return { account, symbol: decoded.symbol, step: decoded.step }; } private tickOf(symbol: string): number { return ciNum(this.instrumentCache.get(symbol)?.inst, 'priceIncrement') ?? 0; } /** An exit price on the tick grid, rounded toward the position's safe * side: a sell (closing a long) up, a buy (closing a short) down — the * take-profit never nets less, the stop never sits further out. */ private exitPrice(symbol: string, price: number, side: 'BUY' | 'SELL'): number { const tick = this.tickOf(symbol); return tick > 0 ? roundToStep(price, tick, side === 'SELL' ? 'up' : 'down') : price; } /** * Place an order and wait for its fill: an OPEN when the account is flat, * a CLOSE of the position when it holds the opposite side. Adding to a * position is refused, and so is an OPEN against another account's * opposite position in the same symbol (no hedging). Resolves with the * fill `price`. */ public async sendOrder( accountId: number, contractSymbol: string, quantity: number, action: 'Buy' | 'Sell', orderType: 'Market' | 'Limit', price?: number, ): Promise> { try { return await this.sendOrderAndWait(accountId, contractSymbol, quantity, action, orderType, price); } catch (err) { if (!isOrderNotPlaced(err)) this.suspectAccounts.set(accountId, Date.now()); throw err; } } private async sendOrderAndWait( accountId: number, contractSymbol: string, contracts: number, action: 'Buy' | 'Sell', orderType: 'Market' | 'Limit', price?: number, ): Promise> { const { account, symbol, step } = this.orderContext(accountId, contractSymbol); const side: 'BUY' | 'SELL' = action === 'Buy' ? 'BUY' : 'SELL'; let positions: DxPosition[]; try { positions = await this.freshPositions(account); } catch (err) { throw new OrderNotPlacedError(`DXtrade position check failed — order not sent: ${errorMessage(err)}`); } const existing = positions.find((p) => p.symbol === symbol); if (existing && existing.side === side) throw new OrderNotPlacedError(`adding to an open ${symbol} position is not supported`); const code = newOrderCode(existing ? 'x' : 'e'); let body: Record; let unitsOrdered: number; if (existing) { unitsOrdered = Math.min(existing.quantity, roundToStep(contracts * step, step)); body = this.orderBody(account, symbol, { code, type: orderType === 'Market' ? 'MARKET' : 'LIMIT', side, quantity: unitsOrdered, positionEffect: 'CLOSE', positionCode: existing.positionCode, ...(orderType === 'Limit' && price !== undefined ? { limitPrice: this.exitPrice(symbol, price, side) } : {}), }); } else { if (this.accountMeta.get(accountId)?.status !== 'FULL_TRADING') { throw new OrderNotPlacedError(`account is ${this.accountMeta.get(accountId)?.status ?? 'unknown'} — order not sent`); } const opposite = side === 'BUY' ? 'SELL' : 'BUY'; const hedged = [...this.positionsDetail.values()].flat().some((p) => p.symbol === symbol && p.side === opposite) || this.openingSides.get(symbol)?.side === opposite; if (hedged) throw new OrderNotPlacedError(`another account holds the opposite side of ${symbol} — no hedging`); unitsOrdered = roundToStep(contracts * step, step); body = this.orderBody(account, symbol, { code, type: orderType === 'Market' ? 'MARKET' : 'LIMIT', side, quantity: unitsOrdered, positionEffect: 'OPEN', ...(orderType === 'Limit' && price !== undefined ? { limitPrice: price } : {}), }); } const opening = !existing; if (opening) { const cur = this.openingSides.get(symbol); this.openingSides.set(symbol, { side, count: (cur?.side === side ? cur.count : 0) + 1 }); } this.pendingFills++; try { const { orderId } = await this.submit(account, body, [code]); console.log(`${this.tag} order ${code}${orderId ? ` (${orderId})` : ''}: ${side} ${unitsOrdered} ${symbol} ${orderType}${existing ? ' CLOSE' : ' OPEN'}`); const fill = await this.waitForFill(account, code, orderId, symbol, side, existing ?? null); // Visible at once — the batch gates ("already in trade", the // all-flat gate, the hedge guard) must not wait for the next sync. if (opening) { const pos = fill.position ?? { account, positionCode: String(orderId ?? ''), symbol, quantity: unitsOrdered, side, openTime: Date.now(), openPrice: fill.price }; this.positionsDetail.set(accountId, [pos]); this.positions[accountId] = { contractId: symbol, netPos: side === 'BUY' ? pos.quantity : -pos.quantity, netPrice: pos.openPrice, timestamp: new Date(), }; } else if (unitsOrdered >= existing.quantity) { this.positionsDetail.set(accountId, []); delete this.positions[accountId]; } const result = { orderId, orderCode: code, price: fill.price, qty: contracts, units: unitsOrdered, accountId, contractId: symbol }; for (const w of this.fillWaiters.filter((x) => x.orderId === orderId)) w.callback(result); this.fillWaiters = this.fillWaiters.filter((x) => x.orderId !== orderId); return result; } finally { this.pendingFills--; if (opening) { const cur = this.openingSides.get(symbol); if (cur && cur.count <= 1) this.openingSides.delete(symbol); else if (cur) this.openingSides.set(symbol, { ...cur, count: cur.count - 1 }); } } } /** * Poll until the order fills: an OPEN shows as a new position (its * positionCode is the order id on position-based accounts), a CLOSE as * the position gone. An order that ended unfilled was never placed; one * still unfilled after FILL_TIMEOUT_MS is cancelled and reported as * unknown, since it may fill yet. */ private async waitForFill( account: string, orderCode: string, orderId: number | undefined, symbol: string, side: 'BUY' | 'SELL', closing: DxPosition | null, ): Promise<{ price: number; position?: DxPosition }> { const deadline = Date.now() + FILL_TIMEOUT_MS; for (let poll = 1; Date.now() < deadline; poll++) { await sleep(FILL_POLL_MS); try { const positions = await this.freshPositions(account); if (closing) { if (!positions.some((p) => p.positionCode === closing.positionCode)) { const order = await this.findOrder(account, orderCode).catch(() => null); return { price: order?.averagePrice ?? NaN }; } } else { const pos = positions.find((p) => p.symbol === symbol && p.side === side && (orderId === undefined || p.positionCode === String(orderId) || positions.filter((q) => q.symbol === symbol).length === 1)); if (pos) { const order = await this.findOrder(account, orderCode).catch(() => null); return { price: order?.averagePrice ?? pos.openPrice, position: pos }; } } if (poll % 4 === 0) { const order = await this.findOrder(account, orderCode); if (order?.finalStatus && order.filledQuantity === 0) { throw new OrderNotPlacedError(`order ${orderCode} ended ${order.status} unfilled${order.rejectReason ? `: ${order.rejectReason}` : ''}`); } } } catch (err) { if (isOrderNotPlaced(err)) throw err; /* transient — the next poll tries again */ } } console.warn(`${this.tag} order ${orderCode} not filled within ${FILL_TIMEOUT_MS / 1000}s — cancelling it`); await this.cancelByCode(account, orderCode).catch((err) => console.error(`${this.tag} failed to cancel unfilled ${orderCode}: ${errorMessage(err)}`)); throw new Error(`order ${orderCode} placed but not filled within ${FILL_TIMEOUT_MS / 1000}s — cancel requested`); } /** A take-profit (or any exit) limit on the whole open position. Exit * only: with no opposite position there is nothing to close, so nothing * is sent — it can never open or hedge. */ public async placeOrderNoWait( accountId: number, contractSymbol: string, _quantity: number, action: 'Buy' | 'Sell', orderType: 'Market' | 'Limit', price?: number, ): Promise<{ orderId?: number }> { const { account, symbol } = this.orderContext(accountId, contractSymbol); const side: 'BUY' | 'SELL' = action === 'Buy' ? 'BUY' : 'SELL'; const pos = (await this.freshPositions(account).catch((err) => { throw new OrderNotPlacedError(`DXtrade position check failed — exit not sent: ${errorMessage(err)}`); })).find((p) => p.symbol === symbol && p.side !== side); if (!pos) throw new OrderNotPlacedError(`no ${symbol} position to exit — nothing sent`); const code = newOrderCode('tp'); const body = this.orderBody(account, symbol, { code, type: orderType === 'Market' ? 'MARKET' : 'LIMIT', side, quantity: pos.quantity, positionEffect: 'CLOSE', positionCode: pos.positionCode, ...(orderType === 'Limit' && price !== undefined ? { limitPrice: this.exitPrice(symbol, price, side) } : {}), }); const res = await this.submit(account, body, [code]); this.registerPair(accountId, account, pos.positionCode, [code], false); return res; } /** * Take-profit and stop on the open position, one cancelling the other. A * native OCO group first; where the server won't take one, two separate * CLOSE orders — the stop first, so the position is never without it — * and the survivor cancelled once the position is gone (poll). * * What the caller may do next depends on what is left at the broker: * OrderNotPlacedError = nothing of the pair is working; a plain Error = * something may be (a stop, at least) — place nothing more. */ public async placeOcoExit( accountId: number, contractSymbol: string, _quantity: number, exitAction: 'Buy' | 'Sell', limitPrice: number, stopPrice: number, ): Promise<{ orderId?: number }> { const { account, symbol } = this.orderContext(accountId, contractSymbol); const side: 'BUY' | 'SELL' = exitAction === 'Buy' ? 'BUY' : 'SELL'; const pos = (await this.freshPositions(account).catch((err) => { throw new OrderNotPlacedError(`DXtrade position check failed — exits not sent: ${errorMessage(err)}`); })).find((p) => p.symbol === symbol && p.side !== side); if (!pos) throw new OrderNotPlacedError(`no ${symbol} position to protect — nothing sent`); const limitCode = newOrderCode('tp'); const stopCode = newOrderCode('sl'); const limit = this.orderBody(account, symbol, { code: limitCode, type: 'LIMIT', side, quantity: pos.quantity, positionEffect: 'CLOSE', positionCode: pos.positionCode, limitPrice: this.exitPrice(symbol, limitPrice, side), }); const stop = this.orderBody(account, symbol, { code: stopCode, type: 'STOP', side, quantity: pos.quantity, positionEffect: 'CLOSE', positionCode: pos.positionCode, stopPrice: this.exitPrice(symbol, stopPrice, side), }); if (this.nativeOco !== 'no') { try { const res = await this.submit(account, { orders: [limit, stop], contingencyType: 'OCO' }, [limitCode, stopCode]); this.registerPair(accountId, account, pos.positionCode, [limitCode, stopCode], true); console.log(`${this.tag} OCO exit group placed — limit ${limitCode} @ ${limit.limitPrice}, stop ${stopCode} @ ${stop.stopPrice}`); return res; } catch (err) { if (!isOrderNotPlaced(err)) { // It may be working: see what went in before doing more. const open = await this.freshOpenOrders(account).catch(() => null); if (!open) throw err; const inGroup = open.filter((o) => o.orderCode === limitCode || o.orderCode === stopCode); if (inGroup.length === 2) { this.registerPair(accountId, account, pos.positionCode, [limitCode, stopCode], true); return {}; } for (const o of inGroup) await this.cancelByCode(account, o.orderCode).catch(() => { /* reaper backs it up */ }); } else { console.warn(`${this.tag} OCO groups refused (${errorMessage(err)}) — pairing exits by hand from now on`); this.nativeOco = 'no'; } } } await this.submit(account, stop, [stopCode]); // throws OrderNotPlacedError when nothing went in let limitRes: { orderId?: number }; try { limitRes = await this.submit(account, limit, [limitCode]); } catch (err) { this.registerPair(accountId, account, pos.positionCode, [stopCode], false); console.error(`${this.tag} ${symbol}: stop ${stopCode} is working but the take-profit failed (${errorMessage(err)}) — place it by hand`); throw new Error(`stop working, take-profit not placed: ${errorMessage(err)}`); } this.registerPair(accountId, account, pos.positionCode, [limitCode, stopCode], false); console.log(`${this.tag} exit pair placed — limit ${limitCode} @ ${limit.limitPrice}, stop ${stopCode} @ ${stop.stopPrice}`); return limitRes; } private registerPair(accountId: number, account: string, positionCode: string, codes: string[], native: boolean): void { this.ocoPairs.push({ accountId, account, positionCode, codes, native, done: false, placedAt: Date.now() }); this.ensureOcoPoll(); } /** While exit pairs are live: once a pair's position is gone, cancel * whatever leg is still working. */ private ensureOcoPoll(): void { if (this.ocoPollInterval) return; this.ocoPollInterval = setInterval(() => { void (async () => { const live = this.ocoPairs.filter((p) => !p.done); if (live.length === 0) { if (this.ocoPollInterval) { clearInterval(this.ocoPollInterval); this.ocoPollInterval = null; } this.ocoPairs = []; return; } for (const account of new Set(live.map((p) => p.account))) { try { const [positions, open] = await Promise.all([this.freshPositions(account), this.freshOpenOrders(account)]); const openCodes = new Set(open.map((o) => o.orderCode)); for (const pair of live.filter((p) => p.account === account)) { const working = pair.codes.filter((c) => openCodes.has(c)); if (working.length === 0) { pair.done = true; continue; } if (positions.some((p) => p.positionCode === pair.positionCode)) continue; pair.done = true; for (const c of working) { console.log(`${this.tag} position ${pair.positionCode} closed — cancelling leftover exit ${c}`); await this.cancelByCode(account, c).catch((err) => console.error(`${this.tag} failed to cancel leftover ${c}: ${errorMessage(err)}`)); } } } catch { /* transient — the next poll retries */ } } })(); }, OCO_POLL_MS); } private async cancelOrder(accountId: number, orderCode: string): Promise { const account = this.codeById.get(accountId); if (account) await this.cancelByCode(account, orderCode); } /** DELETE by client code. Servers that want If-Match get the order's * version on a second try; a 404 means it is already gone. */ private async cancelByCode(account: string, orderCode: string): Promise { const path = `/accounts/${encodeURIComponent(account)}/orders/${encodeURIComponent(orderCode)}`; try { await this.request('trade', 'DELETE', path); } catch (err) { if (err instanceof DxApiError && err.status === 404) return; if (!(err instanceof DxApiError) || ![403, 412, 428].includes(err.status)) throw err; const order = (await this.freshOpenOrders(account)).find((o) => o.orderCode === orderCode); if (!order) return; await this.request('trade', 'DELETE', path, { headers: order.version !== undefined ? { 'If-Match': String(order.version) } : {} }); } } public onFill(orderId: number, callback: (fill: Record) => void): void { this.fillWaiters.push({ orderId, callback }); } // ── lifecycle ──────────────────────────────────────────────────────────── public health(): ClientHealth { return { platform: 'dxtrade', authenticated: !!this.token, transport: this.base ? 'rest' : 'none', syncComplete: this.syncComplete, lastSyncAt: this.lastSyncAt, lastLoginAt: this.lastLoginAt, lastLoginError: this.lastLoginError, consecutiveLoginFailures: this.consecutiveLoginFailures, nextReconnectAt: this.nextReconnectAt, tokenExpiresAt: this.token ? this.lastRequestAt + this.sessionTimeoutMs : 0, lastInboundAt: this.lastResponseAt, pendingRequests: this.pendingFills, syncInFlightSince: this.syncInFlight ? this.syncInFlightSince : 0, daysFetchInFlightSince: this.daysFetchInFlight ? this.daysFetchInFlightSince : 0, }; } /** Whether a login can't happen until someone acts — the supervisor * doesn't rebuild those (a rebuild can't fix a URL or a password). */ public get needsAttention(): boolean { return !this.base || this.credsRejected; } /** Final: nothing this client scheduled fires afterwards. The session is * logged out best-effort, so a rebuilt client doesn't collide with it. */ public disconnect(): void { this.intentionalDisconnect = true; for (const t of [this.syncInterval, this.pingInterval, this.watchdogInterval, this.ocoPollInterval]) if (t) clearInterval(t); this.syncInterval = this.pingInterval = this.watchdogInterval = this.ocoPollInterval = null; if (this.loginRetryTimer) { clearTimeout(this.loginRetryTimer); this.loginRetryTimer = null; } this.nextReconnectAt = 0; this.daysFetchInFlight = false; this.daysFetchInFlightSince = 0; const token = this.token; this.token = null; if (token && this.base) { void axios.post(`${this.base}/logout`, {}, { headers: { Authorization: `DXAPI ${token}` }, timeout: 5_000, validateStatus: () => true, }).catch(() => { /* best effort */ }); } } }