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>
This commit is contained in:
Brandon Li
2026-09-26 16:04:15 -05:00
co-authored by Claude Opus 5.5
parent 49c4725e0c
commit 28f465508d
18 changed files with 3962 additions and 534 deletions
+270
View File
@@ -0,0 +1,270 @@
/**
* 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;
}