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
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env node
// DXtrade smoke test — run against a real login before the scheduler trades a
// DXtrade firm. Confirms the API details lib/dxtrade-class.ts assumes.
//
// DX_BASE=https://dx.tradeify247.co/dxsca-web DX_USER=... DX_PASS=... \
// DX_DOMAIN=default DX_SYMBOLS=BTC/USD,ETH/USD,XYZ100 node scripts/dxtrade-smoke.mjs
//
// Read-only unless DX_CONFIRM=yes AND DX_ACCOUNT=<account code> are set, which
// adds a minimum-size round trip on that account (a real order: it pays two
// commissions). Credentials come from the environment only; the password and
// session token are never printed.
//
// Run it while the instance's client for the firm is disconnected (remove the
// credentials there, or stop the instance): two sessions on one login can
// collide, and logins are rate-limited per IP.
const BASE = (process.env.DX_BASE ?? '').replace(/\/+$/, '');
const USER = process.env.DX_USER ?? '';
const PASS = process.env.DX_PASS ?? '';
const DOMAIN = process.env.DX_DOMAIN || 'default';
const SYMBOLS = (process.env.DX_SYMBOLS ?? 'BTC/USD').split(',').map((s) => s.trim()).filter(Boolean);
const ORDER_ACCOUNT = process.env.DX_ACCOUNT ?? '';
const CONFIRM = process.env.DX_CONFIRM === 'yes';
if (!BASE.startsWith('https://') || !USER || !PASS) {
console.error('Set DX_BASE (https://…/dxsca-web), DX_USER and DX_PASS.');
process.exit(1);
}
let token = null;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const section = (title) => console.log(`\n── ${title} ${'─'.repeat(Math.max(0, 70 - title.length))}`);
const show = (label, value) => console.log(`${label}:`, typeof value === 'string' ? value : JSON.stringify(value, null, 2)?.slice(0, 4000));
async function call(method, path, { params, body, headers } = {}) {
const url = new URL(`${BASE}${path}`);
for (const [k, v] of Object.entries(params ?? {})) url.searchParams.set(k, String(v));
const res = await fetch(url, {
method,
headers: {
Accept: 'application/json',
...(token ? { Authorization: `DXAPI ${token}` } : {}),
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
...headers,
},
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(30_000),
});
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { data = text.slice(0, 500); }
return { status: res.status, headers: Object.fromEntries(res.headers), data };
}
async function step(label, fn) {
try {
await fn();
} catch (err) {
console.log(`${label} FAILED: ${err instanceof Error ? err.message : String(err)}`);
}
}
const codeOf = (a) => (typeof a === 'string' ? a : a && typeof a === 'object' ? `${a.clearing}:${a.account}` : String(a));
section('login');
{
const res = await call('POST', '/login', { body: { username: USER, domain: DOMAIN, password: PASS } });
if (res.status !== 200 || !res.data?.sessionToken) {
show(`login refused (HTTP ${res.status})`, res.data);
console.log('Check DX_DOMAIN (try "default", then the broker name) — and stop after a couple of tries: repeated failures lock the account.');
process.exit(1);
}
token = res.data.sessionToken;
show('session timeout field (format check)', res.data.timeout);
}
let accounts = [];
section('accounts (/users)');
for (const path of [`${USER}@${DOMAIN}`, USER]) {
const res = await call('GET', `/users/${encodeURIComponent(path)}`);
console.log(`/users/${path} → HTTP ${res.status}`);
if (res.status === 200) {
accounts = (res.data?.userDetails ?? []).flatMap((u) => u.accounts ?? []);
for (const a of accounts) {
console.log(` ${codeOf(a.account)} status=${a.accountStatus} positionBased=${a.positionBased} currency=${a.baseCurrency} registered=${a.registrationTime}`);
}
break;
}
}
const codes = accounts.map((a) => codeOf(a.account));
const ref = codes[0];
if (!ref) {
console.log('No accounts found — nothing more to check.');
process.exit(0);
}
section('metrics (key casing: openPL vs openPl)');
await step('metrics', async () => {
const res = await call('GET', '/accounts/metrics', { params: { accounts: codes.join(',') } });
show(`HTTP ${res.status}`, res.data?.metrics?.[0] ?? res.data);
});
section('positions / open orders');
await step('positions', async () => show('positions', (await call('GET', '/accounts/positions', { params: { accounts: codes.join(',') } })).data));
await step('orders', async () => show('open orders', (await call('GET', '/accounts/orders', { params: { accounts: codes.join(',') } })).data));
section('transfers — types, signs, paging, the opening DEPOSIT, FINANCING');
await step('transfers', async () => {
const from = new Date(Date.now() - 180 * 86_400_000).toISOString();
const res = await call('GET', '/accounts/transfers', { params: { accounts: codes.join(','), 'time-from': from, limit: 50 } });
const txns = (res.data?.cashTransfers ?? []).flatMap((t) => (t.cashTransactions ?? []).map((x) => ({ ...x, transferTime: t.transactionTime })));
const byType = {};
for (const t of txns) {
const e = (byType[t.type] ??= { count: 0, min: Infinity, max: -Infinity });
e.count++; e.min = Math.min(e.min, t.value); e.max = Math.max(e.max, t.value);
}
show('by type (value range → sign convention)', byType);
show('first / last transaction (ordering)', [txns[0], txns[txns.length - 1]]);
show('nextPageTransactionTime', res.data?.nextPageTransactionTime ?? '(none)');
});
section('instruments');
await step('instruments', async () => {
const res = await call('GET', '/instruments/query', { params: { symbols: SYMBOLS.join(',') } });
for (const i of res.data?.instruments ?? []) {
console.log(` ${i.symbol}: type=${i.type} priceIncrement=${i.priceIncrement} multiplier=${i.multiplier} lotSize=${i.lotSize} quantityIncrement=${i.quantityIncrement} currency=${i.currency} sessions=${(i.tradingHours ?? []).length}`);
}
show('first instrument, raw', res.data?.instruments?.[0]);
});
await step('account instrument details', async () => {
const res = await call('GET', `/accounts/${encodeURIComponent(ref)}/instruments/query`, { params: { symbols: SYMBOLS.join(',') } });
show('details', res.data?.instrumentDetails ?? res.data);
});
section('quotes and hourly candles (σ)');
await step('quotes', async () => {
const res = await call('POST', '/marketdata', { body: { account: ref, symbols: SYMBOLS, eventTypes: [{ type: 'Quote', format: 'COMPACT' }] } });
show(`Quote events (HTTP ${res.status})`, res.data?.events ?? res.data);
});
await step('candles', async () => {
const now = Date.now();
const res = await call('POST', '/marketdata', {
body: {
account: ref, symbols: SYMBOLS,
eventTypes: [{ type: 'Candle', format: 'COMPACT', candleType: '1h', fromTime: new Date(now - 49 * 3_600_000).toISOString(), toTime: new Date(now).toISOString() }],
},
});
const events = res.data?.events ?? [];
console.log(`Candle events: HTTP ${res.status}, ${events.length} event(s); first: ${JSON.stringify(events[0])}`);
for (const symbol of SYMBOLS) {
const closes = events.filter((e) => e.symbol === symbol && typeof e.close === 'number').map((e) => e.close);
const rets = closes.slice(1).map((c, i) => Math.log(c / closes[i]));
const mean = rets.reduce((s, r) => s + r, 0) / Math.max(1, rets.length);
const sd = Math.sqrt(rets.reduce((s, r) => s + (r - mean) ** 2, 0) / Math.max(1, rets.length - 1));
console.log(` ${symbol}: ${closes.length} closes, σ ≈ ${(sd * 100).toFixed(3)}%/h ≈ ${(sd * Math.sqrt(24) * 100).toFixed(2)}%/day`);
}
});
section('end-of-day metrics vs the 22:00 UTC reset');
await step('eodmetrics', async () => {
const d = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
const res = await call('GET', `/accounts/eodmetrics/${d}`, { params: { accounts: ref } });
show(`eodmetrics ${d} (HTTP ${res.status}) — compare balance with the instance's dayStart debug value`, res.data);
});
section('rate limits (two history calls back to back)');
await step('rate', async () => {
const a = await call('GET', '/accounts/transfers', { params: { accounts: ref, limit: 1 } });
const b = await call('GET', '/accounts/transfers', { params: { accounts: ref, limit: 1 } });
console.log(`HTTP ${a.status}, ${b.status}${b.status === 429 ? ' — history calls are limited to 1/s' : ''}`);
});
section('ping');
console.log(`POST /ping → HTTP ${(await call('POST', '/ping')).status}`);
if (CONFIRM && ORDER_ACCOUNT) {
section(`LIVE ORDERS on ${ORDER_ACCOUNT}`);
const acct = encodeURIComponent(ORDER_ACCOUNT);
const symbol = SYMBOLS[0];
const inst = (await call('GET', '/instruments/query', { params: { symbols: symbol } })).data?.instruments?.[0] ?? {};
const det = (await call('GET', `/accounts/${acct}/instruments/query`, { params: { symbols: symbol } })).data?.instrumentDetails?.[0] ?? {};
const qty = Math.max(det.minOrderSize ?? 0, inst.quantityIncrement ?? 0, det.minOrderSizeIncrement ?? 0) || 1;
const code = `af-smoke-${Date.now().toString(36)}`;
const open = { account: ORDER_ACCOUNT, orderCode: code, type: 'MARKET', instrument: symbol, quantity: qty, positionEffect: 'OPEN', side: 'BUY', tif: 'GTC' };
const placed = await call('POST', `/accounts/${acct}/orders`, { body: open });
show(`MARKET BUY ${qty} ${symbol} (HTTP ${placed.status})`, placed.data);
show('duplicate orderCode (expect 409 / code 100)', (await call('POST', `/accounts/${acct}/orders`, { body: open })));
await sleep(3_000);
const pos = ((await call('GET', `/accounts/${acct}/positions`)).data?.positions ?? []).find((p) => p.symbol === symbol);
show('position (positionCode should equal the orderId on position-based accounts)', pos);
show('order history row (commission, averagePrice)', (await call('GET', `/accounts/${acct}/orders/history`, { params: { 'with-client-id': code, limit: 5 } })).data);
if (pos) {
const px = pos.openPrice;
const tick = inst.priceIncrement || 0.01;
const far = (p) => Math.round(p / tick) * tick;
const leg = (c, type, price) => ({
account: ORDER_ACCOUNT, orderCode: c, type, instrument: symbol, quantity: pos.quantity, positionEffect: 'CLOSE',
positionCode: pos.positionCode, side: 'SELL', tif: 'GTC', ...(type === 'LIMIT' ? { limitPrice: price } : { stopPrice: price }),
});
const group = await call('POST', `/accounts/${acct}/orders`, {
body: { orders: [leg(`${code}-tp`, 'LIMIT', far(px * 1.05)), leg(`${code}-sl`, 'STOP', far(px * 0.95))], contingencyType: 'OCO' },
});
show(`native OCO group of CLOSE legs (HTTP ${group.status})`, group.data);
show('open orders now', (await call('GET', `/accounts/${acct}/orders`)).data);
const del = await call('DELETE', `/accounts/${acct}/orders/${encodeURIComponent(`${code}-tp`)}`);
show(`cancel without If-Match (HTTP ${del.status}; 403/412/428 means it wants one)`, del.data);
const flatten = await call('POST', `/accounts/${acct}/close`, { body: { account: ORDER_ACCOUNT, closePositions: true, cancelOrders: true } });
show(`flatten via /close (HTTP ${flatten.status})`, flatten.data);
}
await sleep(3_000);
const recent = await call('GET', `/accounts/${acct}/transfers`, { params: { 'time-from': new Date(Date.now() - 10 * 60_000).toISOString() } });
show('transactions of the last 10 minutes (commission = 0.04% × notional per side?)', recent.data);
} else {
console.log('\n(read-only: set DX_CONFIRM=yes and DX_ACCOUNT=<code> to also test a live minimum-size round trip)');
}
await call('POST', '/logout');