/** * Pure helpers for the DXTrade client (lib/dxtrade-class.ts): account ids, * contract names, the firm-day clock, parsing the API's loosely typed * answers, and the instrument selector's volatility and cost scoring. * * No imports beyond node built-ins, so scripts/check-trading-logic.mjs can * test them directly. */ import { createHash } from 'crypto'; // ── account ids ────────────────────────────────────────────────────────────── /** * A stable numeric id for a DXTrade account code ("clearing:account"). Every * account table, route and backup keys on a number, and DXTrade codes are * strings, so the code is hashed — deterministically, which is what lets a * backup restored onto a new machine find its manual assignments again (a * per-machine id table would renumber them). * * 52 bits of SHA-256 folded into [2^50, 2^53): safe integers, far above any * Tradovate or TopstepX id. The host is part of the key, so the same code on * two DXTrade servers never collides — and changing a firm's server URL * re-keys its accounts. */ export function dxAccountId(host: string, code: string): number { const digest = createHash('sha256').update(`dxtrade|${host.toLowerCase()}|${code}`).digest(); return 2 ** 50 + digest.readUIntBE(0, 6) * 16 + (digest[6] >> 4); } /** "default:TFY-50K-123" → "TFY-50K-123": what the dashboard shows and what * account-type prefixes match against. */ export function accountDisplayName(code: string): string { const i = code.indexOf(':'); return i >= 0 && i < code.length - 1 ? code.slice(i + 1) : code; } /** DXTrade answers carry account codes either as "clearing:account" or as an * {clearing, account} object, depending on the endpoint's serializer. */ export function accountCodeOf(raw: unknown): string | null { if (typeof raw === 'string' && raw) return raw; if (raw && typeof raw === 'object') { const r = raw as { clearing?: unknown; account?: unknown }; if (typeof r.account === 'string') return typeof r.clearing === 'string' ? `${r.clearing}:${r.account}` : r.account; } return null; } // ── contract names ─────────────────────────────────────────────────────────── /** An engine "contract" on DXTrade is one quantity step of a symbol; the * step rides in the name the engine hands back to sendOrder, which keeps * order placement stateless. Symbols never contain '|' (the master refuses * them). */ export function encodeContractName(symbol: string, step: number): string { return `${symbol}|${step}`; } export function decodeContractName(name: string): { symbol: string; step: number } | null { const i = name.lastIndexOf('|'); if (i <= 0) return null; const step = Number(name.slice(i + 1)); if (!Number.isFinite(step) || step <= 0) return null; return { symbol: name.slice(0, i), step }; } /** Decimal places of a step like 0.001 (or 1e-7), for exact rounding. */ export function decimalsOf(step: number): number { if (!Number.isFinite(step) || step <= 0) return 0; const s = step.toString(); const exp = s.match(/e-(\d+)$/); if (exp) return Number(exp[1]) + (s.split('e')[0].split('.')[1]?.length ?? 0); return s.split('.')[1]?.length ?? 0; } /** A multiple of `step` without float fuzz (0.1 + 0.2 style). */ export function roundToStep(value: number, step: number, mode: 'nearest' | 'down' | 'up' = 'nearest'): number { if (!(step > 0)) return value; const n = value / step; const k = mode === 'down' ? Math.floor(n + 1e-9) : mode === 'up' ? Math.ceil(n - 1e-9) : Math.round(n); return Number((k * step).toFixed(decimalsOf(step))); } // ── clocks ─────────────────────────────────────────────────────────────────── /** The most recent 22:00 UTC at or before `now` — when Tradeify (and DXtrade * brokers generally) snapshot the closed balance and start a new day. */ export function lastUtcReset(now: Date, hourUtc = 22): Date { const reset = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), hourUtc)); if (reset.getTime() > now.getTime()) reset.setUTCDate(reset.getUTCDate() - 1); return reset; } /** The trading day the firm books a moment to: the day that began at the * reset before it, labelled by the calendar date that follows — the label * currentTradeDateCT gives the same day. In summer the two roll together * (22:00 UTC = 5 PM CDT); in winter the firm's rolls an hour earlier, and a * close in that hour belongs to the new day, as the firm counts it. */ export function firmDayOf(at: Date, hourUtc = 22): string { return new Date(at.getTime() + (24 - hourUtc) * 3_600_000).toISOString().slice(0, 10); } /** When the current Central trade date began (5 PM CT) — the window today's * realized P&L covers, matching currentTradeDateCT. */ export function ctTradeDayStart(now: Date): Date { const parts = new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: 'numeric', second: 'numeric', hour12: false, timeZone: 'America/Chicago', }).formatToParts(now); const get = (t: string) => Number(parts.find((p) => p.type === t)?.value ?? '0'); const secondsIntoDay = (get('hour') % 24) * 3600 + get('minute') * 60 + get('second'); const sinceRoll = (secondsIntoDay - 17 * 3600 + 86_400) % 86_400; return new Date(Math.floor(now.getTime() / 1000) * 1000 - sinceRoll * 1000); } /** DXTrade timestamps: ISO strings on the wire (the schema calls them * objects); epoch ms tolerated. NaN when unreadable. */ export function timeOf(raw: unknown): number { if (typeof raw === 'number') return raw; if (typeof raw === 'string') return Date.parse(raw); return NaN; } /** The login answer's inactivity timeout, in ms: "PT30M", "01:05:08", a * {seconds} object or a number of seconds. Falls back to 30 minutes. */ export function parseDurationMs(raw: unknown): number { const fallback = 30 * 60_000; if (typeof raw === 'number' && raw > 0) return raw * 1000; if (raw && typeof raw === 'object') { const s = (raw as { seconds?: unknown }).seconds; if (typeof s === 'number' && s > 0) return s * 1000; } if (typeof raw === 'string') { const iso = raw.match(/^P(?:T)?(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?$/i); if (iso && (iso[1] || iso[2] || iso[3])) { return ((Number(iso[1] ?? 0) * 60 + Number(iso[2] ?? 0)) * 60 + Number(iso[3] ?? 0)) * 1000; } const hms = raw.match(/^(\d+):(\d{2}):(\d{2})$/); if (hms) return ((Number(hms[1]) * 60 + Number(hms[2])) * 60 + Number(hms[3])) * 1000; } return fallback; } // ── loose parsing ──────────────────────────────────────────────────────────── /** A number field read case-insensitively — the live server says openPL * where the docs say openPl. */ export function ciNum(obj: unknown, key: string): number | undefined { if (!obj || typeof obj !== 'object') return undefined; const want = key.toLowerCase(); for (const [k, v] of Object.entries(obj as Record)) { if (k.toLowerCase() === want && typeof v === 'number' && Number.isFinite(v)) return v; } return undefined; } /** Deposits and withdrawals move money in or out; every other cash * transaction (SETTLEMENT, COMMISSION, FINANCING, ADJUSTMENT…) is trading. */ export function isFundType(type: unknown): boolean { return type === 'DEPOSIT' || type === 'WITHDRAWAL'; } /** A withdrawal always takes money out, whatever sign the server files it with. */ export function signedFundValue(type: string, value: number): number { return type === 'WITHDRAWAL' ? -Math.abs(value) : Math.abs(value); } export interface DxTxn { key: string; account: string; type: string; value: number; time: number; orderCode: string | undefined; } /** * The cash transactions in a /transfers answer, flattened. A close books its * SETTLEMENT (the trade's P&L) and its COMMISSION under one transaction code, * so the key carries the type — and a running count, should a transfer ever * hold two of one type — or one would overwrite the other. */ export function parseTransfers(body: unknown): DxTxn[] { const transfers = (body as { cashTransfers?: unknown[] })?.cashTransfers ?? []; const out: DxTxn[] = []; for (const t of transfers) { const transfer = t as Record; const txns = Array.isArray(transfer.cashTransactions) ? transfer.cashTransactions : []; const seen = new Map(); for (const x of txns) { const tx = x as Record; const account = accountCodeOf(tx.account) ?? accountCodeOf(transfer.account); const value = ciNum(tx, 'value'); const time = timeOf(tx.transactionTime ?? transfer.transactionTime); if (!account || value === undefined || !Number.isFinite(time) || typeof tx.type !== 'string') continue; const code = typeof tx.transactionCode === 'string' ? tx.transactionCode : ''; const base = code ? `${code}|${tx.type}` : `${transfer.transferCode ?? ''}|${tx.type}|${time}|${value}|${tx.orderCode ?? ''}|${tx.tradeCode ?? ''}`; const n = seen.get(base) ?? 0; seen.set(base, n + 1); out.push({ key: n === 0 ? base : `${base}|${n}`, account, type: tx.type, value, time, orderCode: typeof tx.orderCode === 'string' ? tx.orderCode : undefined, }); } } return out; } /** * The margin an account posts per dollar of notional on an instrument — its * leverage cap, inverted (0.2 = 5:1). A tiered rate reads as its steepest * tier. undefined when the details don't say. */ export function marginRateOf(details: unknown): number | undefined { const rate = (details as { marginRate?: { tiers?: { value?: unknown }[]; value?: unknown } } | undefined)?.marginRate; const values = [...(rate?.tiers ?? []).map((t) => Number(t?.value)), Number(rate?.value)] .filter((v) => Number.isFinite(v) && v > 0 && v <= 1); return values.length > 0 ? Math.max(...values) : undefined; } /** A client order code: unique, ≤ 64 chars, and recognisably ours. */ export function newOrderCode(tag: string): string { const rand = Math.random().toString(36).slice(2, 8); return `af-${Date.now().toString(36)}-${rand}-${tag}`.slice(0, 64); } // ── trading hours ──────────────────────────────────────────────────────────── const WEEKDAYS = ['SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY']; /** * Whether an instrument's sessions have it open at `now`. Sessions are * SESSION_OPEN / SESSION_CLOSE events on a UTC week; the latest event at or * before now (wrapping around the week) decides. No sessions, or any it * can't read, means always open — 24/7 crypto lists none. */ export function inTradingHours(sessions: unknown, now: Date): boolean { if (!Array.isArray(sessions) || sessions.length === 0) return true; const events: { at: number; open: boolean }[] = []; for (const s of sessions) { const e = s as { eventType?: unknown; weekDay?: { dayOfWeek?: unknown; time?: { hour?: unknown; minute?: unknown } } }; const day = WEEKDAYS.indexOf(String(e.weekDay?.dayOfWeek ?? '')); const hour = Number(e.weekDay?.time?.hour ?? NaN); const minute = Number(e.weekDay?.time?.minute ?? 0); if (day < 0 || !Number.isFinite(hour) || (e.eventType !== 'SESSION_OPEN' && e.eventType !== 'SESSION_CLOSE')) return true; events.push({ at: day * 1440 + hour * 60 + minute, open: e.eventType === 'SESSION_OPEN' }); } events.sort((a, b) => a.at - b.at); const t = now.getUTCDay() * 1440 + now.getUTCHours() * 60 + now.getUTCMinutes(); const past = events.filter((e) => e.at <= t); const latest = past.length > 0 ? past[past.length - 1] : events[events.length - 1]; return latest.open; } // ── instrument selector ────────────────────────────────────────────────────── /** Fewest hourly candles a volatility estimate is trusted on. */ export const MIN_VOL_CANDLES = 24; /** * σ of hourly log returns (per √hour) from hourly closes, oldest first, in * wall-clock time — flat hours count, so an instrument that sits still all * weekend really does read as quiet on a weekend. null on too few candles. */ export function realizedVolPerHour(closes: number[]): number | null { const clean = closes.filter((c) => Number.isFinite(c) && c > 0); if (clean.length < MIN_VOL_CANDLES + 1) return null; const returns: number[] = []; for (let i = 1; i < clean.length; i++) returns.push(Math.log(clean[i] / clean[i - 1])); const mean = returns.reduce((s, r) => s + r, 0) / returns.length; const variance = returns.reduce((s, r) => s + (r - mean) ** 2, 0) / (returns.length - 1); return Math.sqrt(variance); } /** * A candidate's cost score — lower is better. * * A leg sized for a hold of T hours needs notional ∝ 1/σ, so its cost — * spread plus both commissions plus financing over the hold, all * proportional to notional — is proportional to (spread + 2c + f·T/24) / σ. * A volatile instrument reaches the take-profit with less notional, which is * what makes a tight spread alone not enough. * * null = can't be scored (no σ). */ export function scoreCandidate(o: { spreadFraction: number; commissionPct: number; financingPctPerDay: number; targetHoldHours: number; volatilityPerHour: number | null; }): number | null { if (!o.volatilityPerHour || !(o.volatilityPerHour > 0)) return null; const financing = (o.financingPctPerDay / 100) * (Math.max(0, o.targetHoldHours) / 24); return (o.spreadFraction + 2 * (o.commissionPct / 100) + financing) / o.volatilityPerHour; } /** Most a spread may be, as a share of the mid price, for a candidate to trade. */ export const MAX_SPREAD_FRACTION = 0.005; /** A quote older than this doesn't count as live. */ export const MAX_QUOTE_AGE_MS = 5 * 60_000; /** Why a candidate can't trade right now, or null when it can. */ export function candidateRejection(o: { tradingStatus: string | undefined; bid: number | undefined; ask: number | undefined; quoteAgeMs: number | null; inHours: boolean; candles: number; minContractsOk: boolean; currency: string | undefined; accountCurrency: string; }): string | null { if (o.tradingStatus !== undefined && o.tradingStatus !== 'FULL') return `trading status ${o.tradingStatus}`; if (!o.inHours) return 'outside trading hours'; if (!(typeof o.bid === 'number' && o.bid > 0 && typeof o.ask === 'number' && o.ask >= o.bid)) return 'no live quote'; if (o.quoteAgeMs !== null && o.quoteAgeMs > MAX_QUOTE_AGE_MS) return `quote ${Math.round(o.quoteAgeMs / 60_000)}m old`; const mid = (o.bid + o.ask) / 2; if ((o.ask - o.bid) / mid > MAX_SPREAD_FRACTION) return `spread ${(((o.ask - o.bid) / mid) * 100).toFixed(2)}%`; if (o.candles < MIN_VOL_CANDLES + 1) return `only ${o.candles} hourly candles`; if (!o.minContractsOk) return 'minimum order size out of reach'; if (o.currency && o.currency !== o.accountCurrency) return `priced in ${o.currency}`; return null; } // ── trades ─────────────────────────────────────────────────────────────────── /** A filled order off /orders/history, as the trades record needs it. */ export interface DxHistoryOrder { orderCode: string; orderId: number | undefined; /** MARKET, LIMIT, STOP. */ type: string; instrument: string; positionCode: string | undefined; effect: 'OPEN' | 'CLOSE' | undefined; side: 'BUY' | 'SELL' | undefined; /** Filled. */ qty: number; /** Average fill price. */ price: number | undefined; /** The last fill, else the order's own time. */ at: number; } /** null for an order that never filled. */ export function parseHistoryOrder(raw: unknown): DxHistoryOrder | null { const o = raw as Record; if (!o || typeof o !== 'object' || typeof o.orderCode !== 'string') return null; const legs = Array.isArray(o.legs) ? (o.legs as Record[]) : []; const leg = legs[0] ?? {}; const qty = Math.abs(ciNum(leg, 'filledQuantity') ?? ciNum(o, 'filledQuantity') ?? 0); if (!(qty > 0)) return null; const executions = Array.isArray(o.executions) ? (o.executions as Record[]) : []; const fillTimes = executions .filter((e) => (ciNum(e, 'lastQuantity') ?? ciNum(e, 'filledQuantity') ?? 0) > 0) .map((e) => timeOf(e.transactionTime)) .filter(Number.isFinite); const at = fillTimes.length > 0 ? Math.max(...fillTimes) : [timeOf(o.transactionTime), timeOf(o.issueTime)].find(Number.isFinite) ?? NaN; if (!Number.isFinite(at)) return null; const effect = leg.positionEffect === 'OPEN' || leg.positionEffect === 'CLOSE' ? leg.positionEffect : undefined; const sideRaw = leg.side ?? o.side; return { orderCode: o.orderCode, orderId: ciNum(o, 'orderId'), type: String(o.type ?? ''), instrument: String(leg.instrument ?? o.instrument ?? ''), positionCode: typeof leg.positionCode === 'string' && leg.positionCode ? leg.positionCode : undefined, effect, side: sideRaw === 'BUY' || sideRaw === 'SELL' ? sideRaw : undefined, qty, price: ciNum(leg, 'averagePrice') ?? [...executions].reverse().map((e) => ciNum(e, 'averagePrice')).find((p) => p !== undefined && p > 0), at, }; } /** One position, from the orders that opened and closed it. */ export interface DxTrade { positionCode: string; symbol: string; side: 'long' | 'short'; quantity: number; openPrice: number | null; closePrice: number | null; /** The SETTLEMENT its closes booked; null while open or unseen. */ pnl: number | null; /** Its COMMISSION, opens and closes; null when none was seen. */ fee: number | null; openedAt: number; closedAt: number | null; /** The opening order's code; null when it opened before the history. */ openCode: string | null; /** The order that closed it, and its type. */ closeCode: string | null; closeType: string | null; } /** What the record knows of a position already: the open one on the * account now, or one stored from an earlier pass. */ export interface DxKnownPosition { symbol: string; side: 'long' | 'short'; quantity: number; openPrice: number | null; openedAt: number; } const vwap = (orders: DxHistoryOrder[]): number | null => { const priced = orders.filter((o) => o.price !== undefined); const qty = priced.reduce((s, o) => s + o.qty, 0); return qty > 0 ? priced.reduce((s, o) => s + o.price! * o.qty, 0) / qty : null; }; /** * The account's positions, out of its filled orders (one account) and its * cash transactions. On a position-based account an opening order's position * code is its order id, so an OPEN leg without one takes that. A position the * account still holds (`open`) stays open whatever closed part of it; one * whose opening is older than the orders at hand is completed from `known`, * and left out when nothing knows it. */ export function dxTrades(orders: DxHistoryOrder[], txns: DxTxn[], open: Map, known: Map): DxTrade[] { const groups = new Map(); for (const o of orders) { const code = o.positionCode ?? (o.effect === 'OPEN' && o.orderId !== undefined ? String(o.orderId) : undefined); if (!code) continue; groups.set(code, [...(groups.get(code) ?? []), o]); } for (const code of open.keys()) if (!groups.has(code)) groups.set(code, []); const settled = new Map(); const commission = new Map(); for (const t of txns) { if (!t.orderCode) continue; if (t.type === 'SETTLEMENT') settled.set(t.orderCode, (settled.get(t.orderCode) ?? 0) + t.value); else if (t.type === 'COMMISSION') commission.set(t.orderCode, (commission.get(t.orderCode) ?? 0) + Math.abs(t.value)); } const out: DxTrade[] = []; for (const [code, list] of groups) { const sorted = [...list].sort((a, b) => a.at - b.at); const now = open.get(code); const before = now ?? known.get(code); // An OPEN leg, or one on the same side as the opening. const firstOpen = sorted.find((o) => o.effect === 'OPEN') ?? (before ? undefined : sorted.find((o) => o.effect === undefined)); const openSide = firstOpen?.side ?? (before ? (before.side === 'long' ? 'BUY' : 'SELL') : undefined); if (!openSide) continue; const opens = sorted.filter((o) => o.effect === 'OPEN' || (o.effect === undefined && o.side === openSide)); const closes = sorted.filter((o) => !opens.includes(o)); if (opens.length === 0 && !before) continue; const openPrice = opens.length > 0 ? vwap(opens) : before!.openPrice; const closeQty = closes.reduce((s, o) => s + o.qty, 0); const closed = !now && closeQty > 0; const last = closed ? closes[closes.length - 1] : undefined; const codes = sorted.map((o) => o.orderCode); const fees = codes.filter((c) => commission.has(c)); const pnlCodes = closes.map((o) => o.orderCode).filter((c) => settled.has(c)); out.push({ positionCode: code, symbol: opens[0]?.instrument || before?.symbol || closes[0]?.instrument || '', side: openSide === 'BUY' ? 'long' : 'short', quantity: Math.max(opens.reduce((s, o) => s + o.qty, 0), now?.quantity ?? 0, before?.quantity ?? 0), openPrice: openPrice ?? now?.openPrice ?? null, closePrice: closed ? vwap(closes) : null, pnl: closed && pnlCodes.length > 0 ? Math.round(pnlCodes.reduce((s, c) => s + settled.get(c)!, 0) * 100) / 100 : null, fee: fees.length > 0 ? Math.round(fees.reduce((s, c) => s + commission.get(c)!, 0) * 100) / 100 : null, openedAt: opens.length > 0 ? opens[0].at : before!.openedAt, closedAt: last ? last.at : null, openCode: opens.length > 0 ? opens[0].orderCode : null, closeCode: last?.orderCode ?? null, closeType: last?.type ?? null, }); } return out; }