Files
autofirmer-expanded/lib/dxtrade-class.ts
T
Brandon LiandClaude Opus 5.5 0c3a36845a P1.1: Report each account's status to the master's ledger
Every FirmStats now carries statusVersion 1, the catalog firmId and, once
synced, syncedAt (from lastSyncAt), including the no-accounts branch, which
is now built by emptyFirmStats(). Each accounts[] entry adds status, reason,
balance, floor, accountTypeId, firstFundDate, positiveFunds and brokerStatus,
each left out when null or at its default.

accountStatus() in platform-client.ts is pure: a missing balance is unknown
(cash_missing), never dead; with a balance, dead is exactly the old floor
test (legacyDead); then history_pending, locked, live. Only the reporter
uses it. The aggregates, totals and every trading decision keep their own
dead test, unchanged.

The reporter reads history readiness through a new Tradovate
peekHistoryReady(), because historyReady() there can start a hold and move
the balance baseline. DXTrade exposes accountStatus and registrationTime via
brokerAccountMeta(). A throw in the new fields reports the account as
unknown instead of losing the report.

sendReport skips the socket for a firms payload over 900,000 chars (the
master's frame cap is 1,000,000 bytes) and POSTs it, logging once per switch.

scripts/check-account-status.mjs proves the legacy invariant over the full
matrix and the size budget (worst 228 B added per account, 154 B typical
live; 2000 accounts 592,501 B).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:36:55 -05:00

1793 lines
93 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.
/**
* 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://<broker>/dxsca-web (the firm's server URL on the master).
* POST /login {username, domain, password} → a session token, sent as
* `Authorization: DXAPI <token>` 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 instrument list (master Firms page)
* says which DXtrade symbols it trades, whatever root a batch drew, and
* findFrontMonthContract picks among them (rankForTarget / 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. Each is dated by the firm's day (firmDayOf), which in winter rolls
* an hour before our 5 PM CT trade date.
* - 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, rankForTarget } 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 { firmInstruments } from './instruments';
import type { SymbolMapCandidate } from './master-protocol';
import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth, PickContext } from './platform-client';
import { OrderNotPlacedError, isOrderNotPlaced, errorMessage } from './platform-client';
import {
dxAccountId, accountDisplayName, accountCodeOf, encodeContractName, decodeContractName, roundToStep,
lastUtcReset, ctTradeDayStart, firmDayOf, timeOf, parseDurationMs, ciNum, isFundType, signedFundValue, newOrderCode,
inTradingHours, realizedVolPerHour, scoreCandidate, candidateRejection, parseTransfers, marginRateOf,
} from './dxtrade-helpers';
import type { DxTxn } 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;
/** How often the dashboard's candidates (quotes, spreads) are refreshed. */
const SURVEY_INTERVAL_MS = 30 * 60_000;
const INSTRUMENT_TTL_MS = 60 * 60_000;
const ACCOUNT_INSTRUMENT_TTL_MS = 10 * 60_000;
const CANDLE_TTL_MS = 15 * 60_000;
/** Volatility lookback: a week, so one quiet weekend (or one busy day) can't
* swing a multi-day hold estimate — the week's rhythm is priced in. */
const CANDLE_HOURS = 7 * 24;
/** 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;
}
/** 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<string, unknown>;
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<string, unknown>;
const account = accountCodeOf(o?.account);
if (!account || typeof o.orderCode !== 'string') return null;
const legs = Array.isArray(o.legs) ? (o.legs as Record<string, unknown>[]) : [];
const leg = legs[0] ?? {};
const executions = Array.isArray(o.executions) ? (o.executions as Record<string, unknown>[]) : [];
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 chunks<T>(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; availableFunds?: 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<string, string> = {};
public lastFetchRaw: Record<string, string> = {};
public recentEntityEvents: { entityType: string; eventType: string; entity: unknown; ts: number }[] = [];
/** Tradovate-only concept. */
public products: { id: number; name: string }[] = [];
private readonly onSync: () => Promise<void>;
private codeById = new Map<number, string>();
private idByCode = new Map<string, number>();
private accountMeta = new Map<number, { status: string; positionBased: boolean; registrationTime: number; currency: string }>();
private positionsDetail = new Map<number, DxPosition[]>();
private openOrders = new Map<number, DxOrder[]>();
/** 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<number, Map<string, DxTxn>>();
/** Accounts whose balance and day's transactions came back this pass. */
private dayFresh = new Set<number>();
/** When each open position was first seen, for hold-time logging. */
private seenOpenAt = new Map<number, { positionCode: string; at: number; naggedAt: number }>();
private syncInterval: ReturnType<typeof setInterval> | null = null;
private pingInterval: ReturnType<typeof setInterval> | null = null;
private watchdogInterval: ReturnType<typeof setInterval> | null = null;
private ocoPollInterval: ReturnType<typeof setInterval> | null = null;
private loginRetryTimer: ReturnType<typeof setTimeout> | null = null;
private intentionalDisconnect = false;
private loginPromise: Promise<void> | 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<void> = Promise.resolve();
private daysFetchInFlight = false;
private daysFetchInFlightSince = 0;
private lastDaysFetchStartedAt = 0;
private lastDaysFetchSucceededAt = 0;
private nextDaysFetchRetryAt = 0;
private historyLoaded = false;
private historyLoadedIds = new Set<number>();
private booksHeld = new Set<number>();
private fetchingIds = new Set<number>();
private gateNext: Record<Bucket, number> = { read: 0, trade: 0, history: 0 };
/** Accounts whose last order ended with its outcome unknown, and since when. */
private suspectAccounts = new Map<number, number>();
/** Opens in flight per symbol, for the hedge guard. */
private openingSides = new Map<string, { side: 'BUY' | 'SELL'; count: number }>();
private fillWaiters: { orderId: number; callback: (fill: Record<string, unknown>) => 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<string, { contract: PlatformContract; at: number }>();
private instrumentCache = new Map<string, { inst: Record<string, unknown>; at: number }>();
private accountInstrumentCache = new Map<string, { det: Record<string, unknown>; at: number }>();
/** Volatility per symbol, measured once per trade date (see volatility). */
private candleCache = new Map<string, { vol: number | null; candles: number; at: number; tradeDate: string }>();
/** Every mapped instrument that could trade at the last full look — what
* the dashboard ranks each account's next leg across. */
public lastCandidates: PlatformContract[] = [];
private lastSurveyTradeDate = '';
private lastSurveyAt = 0;
private unmappedLoggedAt = new Map<string, number>();
constructor(cfg: DXTradeConfig, onSync: () => Promise<void>) {
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<void> {
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<T>(
bucket: Bucket,
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
path: string,
opts: { params?: Record<string, string | number>; body?: unknown; headers?: Record<string, string>; auth?: boolean; retried?: boolean } = {},
): Promise<{ data: T; headers: Record<string, string> }> {
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<T>(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<T>(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<void> {
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<void> {
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<void> {
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<string, unknown>;
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<T>(path: string, codes: string[], pick: (data: unknown) => unknown[], params: Record<string, string | number> = {}, bucket: Bucket = 'read'): Promise<T[]> {
const out: T[] = [];
for (const chunk of chunks(codes, ACCOUNT_CHUNK)) {
const { data } = await this.request<unknown>(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<void> {
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<void>((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<string, Record<string, unknown>>();
const positions = new Map<string, DxPosition[]>();
const orders = new Map<string, DxOrder[]>();
let txns: DxTxn[] = [];
const now = new Date();
// From the firm's day that today's trade date names (in winter it
// began an hour before 5 PM CT) and the current reset, whichever
// is earlier.
const windowStart = new Date(Math.min(
lastUtcReset(ctTradeDayStart(now), FIRM_RESET_UTC_HOUR).getTime(),
lastUtcReset(now, FIRM_RESET_UTC_HOUR).getTime(),
) - 5 * 60_000);
if (codes.length > 0) {
try {
for (const m of await this.perAccounts<Record<string, unknown>>('/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<unknown>('/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<unknown>('/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<unknown>('/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<string, DxTxn>();
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) && firmDayOf(new Date(t.time), FIRM_RESET_UTC_HOUR) === today) realized += t.value;
}
this.accountCashBalances[acc.id] = {
amount: balance, realizedPnL: round2(realized), equity: ciNum(m, 'equity'), availableFunds: ciNum(m, 'availableFunds'),
};
// 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 at the first good sync of each trade
// date, and refresh the candidates the dashboard sizes against
// every half hour, whether or not a batch fires.
if (this.historyLoaded && (this.lastSurveyTradeDate !== currentTradeDateCT() || Date.now() - this.lastSurveyAt >= SURVEY_INTERVAL_MS)) {
this.lastSurveyTradeDate = currentTradeDateCT();
this.lastSurveyAt = Date.now();
void this.surveyCandidates().catch((err) =>
console.warn(`${this.tag} candidate survey 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<void> {
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<DxTxn[]> {
const all = new Map<string, DxTxn>();
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<string, { pnl: number; fees: number }>();
const funds = new Map<string, number>();
for (const t of mine) {
const date = firmDayOf(new Date(t.time), FIRM_RESET_UTC_HOUR);
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<string, number>();
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;
}
/** The account's accountStatus and registration time, for the report. */
public brokerAccountMeta(accountId: number): { status: string; registrationTime: number } | undefined {
const meta = this.accountMeta.get(accountId);
return meta ? { status: meta.status, registrationTime: meta.registrationTime } : 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<Map<string, Record<string, unknown>>> {
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<string, unknown>[] }>('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<Map<string, Record<string, unknown>>> {
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<string, unknown>[] }>('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<string, unknown>): Promise<Record<string, unknown>[]> {
const { data } = await this.request<{ events?: Record<string, unknown>[] }>('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<Map<string, string>> {
const coins = await hyperliquidCoins();
const out = new Map<string, string>();
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<Map<string, { bid?: number; ask?: number; time: number }>> {
const out = new Map<string, { bid?: number; ask?: number; time: number }>();
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<Map<string, { vol: number | null; candles: number }>> {
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<string, number[]>();
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<string, { time: number; close: number }[]>();
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 this firm trades right now, from its instrument list —
* the root is ignored. With a pick context (the batch's leg), each candidate is
* sized as the leg would be — at its own leverage — and the cheapest one
* that reaches the target hold wins (else the quickest); without one, the
* cheapest per unit of volatility (see scoreCandidate). Sticky while an
* account holds one of the candidates; a batch's pick is reused briefly
* so its other lookups (drain legs) agree.
*/
public async findFrontMonthContract(_root: string, ctx?: PickContext): Promise<PlatformContract | null> {
if (!this.token) return null;
// The firm's own list: whatever root the batch drew, a DXtrade firm
// chooses among its instruments. One cache key for every root.
const root = '*';
const firm = getCachedFirm(this.cfg.firmId);
const candidates: SymbolMapCandidate[] = firm ? firmInstruments(firm).map((symbol) => ({ symbol })) : [];
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 instruments listed — skipping; add them 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 && !ctx) {
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, ctx);
if (pick && !sticky) this.pickCache.set(root, { contract: pick, at: now });
return pick;
}
/** Every listed symbol, looked at afresh whatever the accounts hold: the
* day's volatility, and the candidates the dashboard ranks. */
private async surveyCandidates(): Promise<void> {
const firm = getCachedFirm(this.cfg.firmId);
const list = firm ? firmInstruments(firm) : [];
if (list.length > 0) await this.pickCandidate('survey', list.map((symbol) => ({ symbol })), false);
}
private async pickCandidate(root: string, candidates: SymbolMapCandidate[], sticky: boolean, ctx?: PickContext): Promise<PlatformContract | null> {
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 marginRate = marginRateOf(det);
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 = 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` : ''}`
+ (marginRate ? `, ${+(1 / marginRate).toFixed(1)}:1` : ''),
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,
marginRate,
},
});
}
let ranked = board.filter((b) => b.contract).sort((a, b) => a.score! - b.score!);
if (ctx) {
// Size the batch's leg on each: cheapest that reaches the target
// hold, else quickest.
const sized = ranked.flatMap((b) => {
const ev = ctx.evaluate(b.contract!);
if (!ev) { b.note += ', can\'t size'; return []; }
b.note += `, $${Math.round(ev.notional / 1000)}k → ${ev.holdHours.toFixed(1)}h, ~$${Math.round(ev.cost)}`;
return [{ b, holdHours: ev.holdHours, cost: ev.cost }];
});
ranked = rankForTarget(sized, ctx.targetHoldHours).map((i) => sized[i].b);
}
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 (!sticky) this.lastCandidates = board.flatMap((b) => (b.contract ? [b.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<DxPosition[]> {
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<DxOrder[]> {
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<DxOrder | null> {
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<string, unknown>, 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<string, unknown> {
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<Record<string, unknown>> {
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<Record<string, unknown>> {
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<string, unknown>;
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<void> {
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<void> {
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<string, unknown>) => 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 */ });
}
}
}