#!/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= 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; // Exactly what lib/dxtrade-class.ts sends: a closing STOP/LIMIT names // the position, never a quantity (code 33 otherwise). const leg = (c, type, price) => ({ account: ORDER_ACCOUNT, orderCode: c, type, instrument: symbol, 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); if (group.status >= 400) { // The bot's fallback: the stop on its own first, then the take-profit. show('fallback: lone STOP CLOSE', (await call('POST', `/accounts/${acct}/orders`, { body: leg(`${code}-sl1`, 'STOP', far(px * 0.95)) }))); show('fallback: lone LIMIT CLOSE', (await call('POST', `/accounts/${acct}/orders`, { body: leg(`${code}-tp1`, 'LIMIT', far(px * 1.05)) }))); } show('open orders now (the exit pair should be here)', (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); // Tradeify: every trade must be held at least 20 seconds. console.log('holding 22 s before closing (minimum hold)…'); await sleep(22_000); 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= to also test a live minimum-size round trip)'); } await call('POST', '/logout');