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:
co-authored by
Claude Opus 5.5
parent
49c4725e0c
commit
28f465508d
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user