TopstepX and Tradovate report fills, so a trade is built from them, flat to flat (lib/fill-trades.ts), off the window each sync already reads: TopstepX's two days of Trade/search, Tradovate's sync snapshot. Neither says who placed an order, so every order the bot places is noted in own_orders, and a trade is the bot's when its opening fill's order is one; its stop or take-profit closing it reads as such. A contract is written only when the position before its window comes out the same two passes running, so a fill landing between the position and history reads can't build the wrong trades. DXtrade reports positions: its record groups the last two days' filled orders by position code, with each close's SETTLEMENT and COMMISSION. The bot's own orders carry af- codes, so nothing is kept locally. Only accounts with something going on are read, at most every 5 minutes. The Trades button shows on every firm with a login, and the account page lists any account's trades. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
191 lines
8.8 KiB
JavaScript
191 lines
8.8 KiB
JavaScript
// Checks for building trades out of fills (lib/fill-trades.ts — TopstepX and
|
||
// Tradovate) and out of DXtrade's order history (lib/dxtrade-helpers.ts).
|
||
//
|
||
// npm run check
|
||
//
|
||
// The files import only Node built-ins, so Node strips their types and loads
|
||
// them directly; .mjs keeps this file out of Next's type check.
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { fillsToTrades, startingNet, contractRoot, byFillTime } from '../lib/fill-trades.ts';
|
||
import { topstepxSymbol } from '../lib/topstep-fees.ts';
|
||
import { parseHistoryOrder, dxTrades } from '../lib/dxtrade-helpers.ts';
|
||
|
||
let n = 0;
|
||
const fill = (side, qty, price, extra = {}) => {
|
||
n += 1;
|
||
return { id: String(1000 + n), orderId: String(5000 + n), at: 1_000 + n * 1_000, side, qty, price, fee: 1, pnl: null, ...extra };
|
||
};
|
||
|
||
test('a round trip, with the platform’s P&L', () => {
|
||
const a = fill('buy', 2, 100);
|
||
const b = fill('sell', 2, 101, { pnl: 40 });
|
||
const [t, ...rest] = fillsToTrades([b, a], { startNet: 0, pointValue: null });
|
||
assert.equal(rest.length, 0);
|
||
assert.deepEqual(t, {
|
||
id: a.id, side: 'long', quantity: 2, openPrice: 100, closePrice: 101, pnl: 40, fee: 2,
|
||
openedAt: a.at, closedAt: b.at, openOrderId: a.orderId, closeOrderId: b.orderId, carried: false,
|
||
});
|
||
});
|
||
|
||
test('P&L from the prices when the platform gives none; adds and partial closes stay one trade', () => {
|
||
const fills = [fill('buy', 1, 100), fill('buy', 1, 102), fill('sell', 1, 103), fill('sell', 1, 104)];
|
||
const [t, ...rest] = fillsToTrades(fills, { startNet: 0, pointValue: 20 });
|
||
assert.equal(rest.length, 0);
|
||
assert.equal(t.quantity, 2);
|
||
assert.equal(t.openPrice, 101);
|
||
assert.equal(t.closePrice, 103.5);
|
||
assert.equal(t.pnl, 100);
|
||
assert.equal(t.fee, 4);
|
||
assert.equal(t.closeOrderId, fills[3].orderId);
|
||
// No point value and no platform P&L: unknown, not zero.
|
||
assert.equal(fillsToTrades(fills, { startNet: 0, pointValue: null })[0].pnl, null);
|
||
});
|
||
|
||
test('a short', () => {
|
||
const fills = [fill('sell', 3, 50.5), fill('buy', 3, 50)];
|
||
const [t] = fillsToTrades(fills, { startNet: 0, pointValue: 1000 });
|
||
assert.equal(t.side, 'short');
|
||
assert.equal(t.pnl, 1500);
|
||
});
|
||
|
||
test('a fill through zero closes one trade and opens the next', () => {
|
||
const a = fill('buy', 1, 100);
|
||
const b = fill('sell', 3, 105, { fee: 3, pnl: 100 });
|
||
const [first, second, ...rest] = fillsToTrades([a, b], { startNet: 0, pointValue: 20 });
|
||
assert.equal(rest.length, 0);
|
||
assert.equal(first.side, 'long');
|
||
assert.equal(first.pnl, 100);
|
||
assert.equal(first.fee, 2); // its own buy, and a third of the reversing fill
|
||
assert.equal(first.closeOrderId, b.orderId);
|
||
assert.equal(second.id, b.id);
|
||
assert.equal(second.side, 'short');
|
||
assert.equal(second.quantity, 2);
|
||
assert.equal(second.openPrice, 105);
|
||
assert.equal(second.fee, 2);
|
||
assert.equal(second.closedAt, null);
|
||
assert.equal(second.pnl, null);
|
||
assert.equal(second.closePrice, null);
|
||
});
|
||
|
||
test('fills that start inside a position: passed over, or carried on', () => {
|
||
const close = fill('sell', 1, 110, { pnl: 200 });
|
||
const a = fill('buy', 1, 100);
|
||
const b = fill('sell', 1, 101, { pnl: 20 });
|
||
// Nothing stored: the leading close belongs to nothing on record.
|
||
const plain = fillsToTrades([close, a, b], { startNet: 1, pointValue: 20 });
|
||
assert.equal(plain.length, 1);
|
||
assert.equal(plain[0].id, a.id);
|
||
// The stored open trade carries on and closes.
|
||
const carry = { id: 'tsx:42', side: 'long', quantity: 1, openPrice: 100, fee: 0.5, openedAt: 10 };
|
||
const [carried, next] = fillsToTrades([close, a, b], { startNet: 1, pointValue: 20, carry });
|
||
assert.equal(carried.id, 'tsx:42');
|
||
assert.equal(carried.carried, true);
|
||
assert.equal(carried.openedAt, 10);
|
||
assert.equal(carried.openOrderId, null);
|
||
assert.equal(carried.pnl, 200);
|
||
assert.equal(carried.fee, 1.5);
|
||
assert.equal(carried.closedAt, close.at);
|
||
assert.equal(next.id, a.id);
|
||
// A carry on the other side isn't this position.
|
||
assert.equal(fillsToTrades([close], { startNet: 1, pointValue: 20, carry: { ...carry, side: 'short' } }).length, 0);
|
||
});
|
||
|
||
test('the position before the fills', () => {
|
||
const buy = fill('buy', 1, 1);
|
||
const sell = fill('sell', 1, 1);
|
||
assert.equal(startingNet([buy, sell], 0), 0);
|
||
assert.equal(startingNet([buy], 1), 0);
|
||
assert.equal(startingNet([sell], 0), 1);
|
||
assert.equal(startingNet([], -2), -2);
|
||
});
|
||
|
||
test('fills sort by time, then numerically by id', () => {
|
||
const a = { id: '9', at: 5 };
|
||
const b = { id: '10', at: 5 };
|
||
const c = { id: '1', at: 6 };
|
||
assert.deepEqual([c, b, a].sort(byFillTime).map((f) => f.id), ['9', '10', '1']);
|
||
});
|
||
|
||
test('contract roots and TopstepX names', () => {
|
||
assert.equal(contractRoot('MNQZ6'), 'MNQ');
|
||
assert.equal(contractRoot('ZNZ6'), 'ZN');
|
||
assert.equal(contractRoot('NGZ6'), 'NG');
|
||
assert.equal(contractRoot('6EZ6'), '6E');
|
||
assert.equal(contractRoot('M2KH27'), 'M2K');
|
||
assert.equal(contractRoot('CL'), 'CL');
|
||
assert.equal(topstepxSymbol('CON.F.US.ENQ.Z26'), 'NQZ6');
|
||
assert.equal(topstepxSymbol('CON.F.US.MNQ.H27'), 'MNQH7');
|
||
assert.equal(topstepxSymbol('CON.F.US.XYZ.M26'), 'XYZM6');
|
||
assert.equal(topstepxSymbol('something'), 'something');
|
||
});
|
||
|
||
// DXtrade: a bot round trip on BTC/USD, as /orders/history and /transfers
|
||
// answer (position-based account: the opening order's id is the position).
|
||
const openOrder = {
|
||
account: 'default:T247-1', orderId: 555, orderCode: 'af-mh2-abc-e', type: 'MARKET', instrument: 'BTC/USD',
|
||
status: 'COMPLETED', finalStatus: true,
|
||
legs: [{ instrument: 'BTC/USD', positionEffect: 'OPEN', side: 'BUY', quantity: 0.5, filledQuantity: 0.5, averagePrice: 60000 }],
|
||
issueTime: '2026-10-26T01:00:00.000Z', transactionTime: '2026-10-26T01:00:01.000Z',
|
||
executions: [{ transactionTime: '2026-10-26T01:00:00.500Z', lastQuantity: 0.5, averagePrice: 60000 }],
|
||
};
|
||
const stopOrder = {
|
||
account: 'default:T247-1', orderId: 556, orderCode: 'af-mh3-def-sl', type: 'STOP', instrument: 'BTC/USD',
|
||
status: 'COMPLETED', finalStatus: true,
|
||
legs: [{ instrument: 'BTC/USD', positionEffect: 'CLOSE', positionCode: '555', side: 'SELL', filledQuantity: 0.5, averagePrice: 59000 }],
|
||
issueTime: '2026-10-26T01:00:02.000Z', transactionTime: '2026-10-26T02:00:00.000Z', executions: [],
|
||
};
|
||
const txn = (type, value, orderCode) => ({ key: `${orderCode}|${type}`, account: 'default:T247-1', type, value, time: 0, orderCode });
|
||
|
||
test('DXtrade order history rows', () => {
|
||
const o = parseHistoryOrder(openOrder);
|
||
assert.equal(o.orderCode, 'af-mh2-abc-e');
|
||
assert.equal(o.effect, 'OPEN');
|
||
assert.equal(o.side, 'BUY');
|
||
assert.equal(o.positionCode, undefined);
|
||
assert.equal(o.qty, 0.5);
|
||
assert.equal(o.price, 60000);
|
||
assert.equal(o.at, Date.parse('2026-10-26T01:00:00.500Z'));
|
||
const s = parseHistoryOrder(stopOrder);
|
||
assert.equal(s.positionCode, '555');
|
||
assert.equal(s.at, Date.parse('2026-10-26T02:00:00.000Z'));
|
||
// Never filled: not a trade's.
|
||
assert.equal(parseHistoryOrder({ ...openOrder, legs: [{ ...openOrder.legs[0], filledQuantity: 0 }] }), null);
|
||
assert.equal(parseHistoryOrder({}), null);
|
||
});
|
||
|
||
test('DXtrade positions from their orders and cash', () => {
|
||
const orders = [parseHistoryOrder(stopOrder), parseHistoryOrder(openOrder)];
|
||
const txns = [txn('SETTLEMENT', -500, 'af-mh3-def-sl'), txn('COMMISSION', -12, 'af-mh2-abc-e'), txn('COMMISSION', -11.8, 'af-mh3-def-sl')];
|
||
const [t, ...rest] = dxTrades(orders, txns, new Map(), new Map());
|
||
assert.equal(rest.length, 0);
|
||
assert.equal(t.positionCode, '555');
|
||
assert.equal(t.side, 'long');
|
||
assert.equal(t.quantity, 0.5);
|
||
assert.equal(t.openPrice, 60000);
|
||
assert.equal(t.closePrice, 59000);
|
||
assert.equal(t.pnl, -500);
|
||
assert.equal(t.fee, 23.8);
|
||
assert.equal(t.openCode, 'af-mh2-abc-e');
|
||
assert.equal(t.closeCode, 'af-mh3-def-sl');
|
||
assert.equal(t.closeType, 'STOP');
|
||
|
||
// Still held: open, whatever the history says.
|
||
const held = new Map([['555', { symbol: 'BTC/USD', side: 'long', quantity: 0.5, openPrice: 60000, openedAt: 1 }]]);
|
||
const [open] = dxTrades([parseHistoryOrder(openOrder)], [], held, new Map());
|
||
assert.equal(open.closedAt, null);
|
||
assert.equal(open.pnl, null);
|
||
|
||
// Opened before the history: completed from what's stored, or left out.
|
||
const [late] = dxTrades([parseHistoryOrder(stopOrder)], txns, new Map(), held);
|
||
assert.equal(late.openedAt, 1);
|
||
assert.equal(late.openCode, null);
|
||
assert.equal(late.pnl, -500);
|
||
assert.equal(dxTrades([parseHistoryOrder(stopOrder)], txns, new Map(), new Map()).length, 0);
|
||
|
||
// A position open with no orders in the window still shows.
|
||
const [quiet] = dxTrades([], [], held, new Map());
|
||
assert.equal(quiet.positionCode, '555');
|
||
assert.equal(quiet.openedAt, 1);
|
||
});
|