/** * 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; } /** 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); } /** 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; }