228 lines
12 KiB
JavaScript
228 lines
12 KiB
JavaScript
#!/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;
|
||
// 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=<code> to also test a live minimum-size round trip)');
|
||
}
|
||
|
||
await call('POST', '/logout');
|