The symbol map's fixed notional per contract is gone: every DXtrade leg is sized from the instrument's recent volatility for the firm's target hold (blank = 12 h), and a candidate without a volatility isn't picked. Percent commission and every-day trading are ignored on non-DXtrade firms, which keep measured per-contract fees and the CME calendar. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1686 lines
87 KiB
TypeScript
1686 lines
87 KiB
TypeScript
/**
|
||
* 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 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';
|
||
|
||
// ── 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;
|
||
|
||
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<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,
|
||
averagePrice: ciNum(leg, 'averagePrice'),
|
||
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<string, unknown>;
|
||
const txns = Array.isArray(transfer.cashTransactions) ? transfer.cashTransactions : [];
|
||
for (const x of txns) {
|
||
const tx = x as Record<string, unknown>;
|
||
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<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 } } = {};
|
||
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 }>();
|
||
private candleCache = new Map<string, { vol: number | null; candles: number; at: number }>();
|
||
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();
|
||
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<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) && currentTradeDateCT(new Date(t.time)) === today) realized += t.value;
|
||
}
|
||
this.accountCashBalances[acc.id] = { amount: balance, realizedPnL: round2(realized), equity: ciNum(m, 'equity') };
|
||
this.autoLiqThresholds[acc.id] = 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();
|
||
|
||
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 = 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<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);
|
||
if (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<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 ?? [];
|
||
}
|
||
|
||
private async quotes(account: string, symbols: string[]): Promise<Map<string, { bid?: number; ask?: number; time: number }>> {
|
||
const events = await this.marketData(account, symbols, { type: 'Quote', format: 'COMPACT' });
|
||
const out = new Map<string, { bid?: number; ask?: number; time: number }>();
|
||
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;
|
||
}
|
||
|
||
private async volatility(account: string, symbols: string[]): Promise<Map<string, { vol: number | null; candles: number }>> {
|
||
const now = Date.now();
|
||
const missing = symbols.filter((s) => !(now - (this.candleCache.get(s)?.at ?? 0) < CANDLE_TTL_MS));
|
||
if (missing.length > 0) {
|
||
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 of missing) {
|
||
const candles = (bySymbol.get(s) ?? []).sort((a, b) => a.time - b.time);
|
||
this.candleCache.set(s, { vol: realizedVolPerHour(candles.map((c) => c.close)), candles: candles.length, at: now });
|
||
}
|
||
}
|
||
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<PlatformContract | null> {
|
||
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<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 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}`);
|
||
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,
|
||
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 */ });
|
||
}
|
||
}
|
||
}
|