Files
autofirmer-expanded/lib/dxtrade-helpers.ts
T
Brandon LiandClaude Opus 5.5 28f465508d DXtrade platform for Tradeify247: percent fees, hard daily-loss stops, volatility sizing, every-day lanes
- DXtrade client (lib/dxtrade-class.ts): REST session login with lockout-safe
  backoff, 60s sync of accounts/metrics/positions/orders/transfers, history and
  fund transactions from cash transactions, day-start balance at the 22:00 UTC
  reset, CLOSE-by-positionCode exits (native OCO group, else a stop-first
  emulated pair), a cross-account hedge guard, stray-order reaper.
- Instrument selector: the firm's symbol map lists candidate symbols per CME
  root; the cheapest tradable one per unit of recent hourly volatility wins
  (spread + 2x commission [+ financing] / sigma).
- Fees: a firm's commission % of notional prices every fee off the fill; the
  take-profit is solved in closed form so the leg nets its target.
- Hard daily loss limit per account type: every leg carries a stop keeping the
  firm's day inside the limit less a 10% reserve and both fees (rounded in);
  a green day never widens it; no room or a refused bracket flattens.
- Notional sizing where the platform prices positions: target-hold volatility
  sizing or the symbol map's fixed notional, under a per-type notional cap.
- Every-day firms trade weekends/holidays; the scheduler runs lanes (futures,
  one per DXtrade firm) with their own hours, flat gate, mutex and batches.
- The catalog sync skips platforms this build doesn't know instead of treating
  them as Tradovate. One shared fireLeg replaces the duplicated batch/copy code.
- npm run check: fee/stop/sizing/hours/selector checks; scripts/dxtrade-smoke.mjs
  confirms the API against a real login before trading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 16:04:15 -05:00

271 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, unknown>)) {
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.
*
* Volatility sizing (targetHoldHours > 0): 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.
*
* Fixed sizing: the notional is set by the symbol map, so only the costs per
* dollar of notional matter.
*
* null = can't be scored (volatility sizing without a σ).
*/
export function scoreCandidate(o: {
spreadFraction: number;
commissionPct: number;
financingPctPerDay: number;
targetHoldHours: number;
volatilityPerHour: number | null;
}): number | null {
const perNotional = o.spreadFraction + 2 * (o.commissionPct / 100);
if (o.targetHoldHours > 0) {
if (!o.volatilityPerHour || !(o.volatilityPerHour > 0)) return null;
const financing = (o.financingPctPerDay / 100) * (o.targetHoldHours / 24);
return (perNotional + financing) / o.volatilityPerHour;
}
return perNotional;
}
/** 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;
needVolatility: boolean;
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.needVolatility && 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;
}