Trades: record every firm's trades, not just Vest's, and list them on each

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>
This commit is contained in:
Brandon Li
2026-10-27 01:27:07 -05:00
co-authored by Claude Opus 5.5
parent 6f6660fdcc
commit 1e844178de
13 changed files with 1149 additions and 16 deletions
+243
View File
@@ -0,0 +1,243 @@
/**
* Trades out of fills, for the futures platforms (TopstepX, Tradovate). A
* trade runs from flat back to flat: adds and partial closes stay inside it,
* and a fill that crosses zero closes one trade and opens the next. Vest and
* DXtrade report whole positions themselves.
*
* No imports, so scripts/check-fill-trades.mjs can test it directly.
*/
export interface Fill {
id: string;
orderId: string;
at: number;
side: 'buy' | 'sell';
qty: number;
price: number;
/** What the fill cost (positive); null when unknown. */
fee: number | null;
/** The P&L the platform booked on this fill, before fees (TopstepX's
* closing half-turns); null to work it out from the prices. */
pnl: number | null;
}
/** A trade already stored as open, from before the fills at hand begin —
* continued when they start part-way through a position. */
export interface CarriedTrade {
id: string;
side: 'long' | 'short';
quantity: number;
openPrice: number | null;
fee: number | null;
openedAt: number;
}
export interface FillTrade {
/** The first fill's id, or the carried trade's. */
id: string;
side: 'long' | 'short';
/** The most held at once. */
quantity: number;
/** Average of the fills that opened or added to it. */
openPrice: number | null;
/** Average of the fills that reduced it; null while open. */
closePrice: number | null;
/** Before fees; null while open, or when it can't be told. */
pnl: number | null;
fee: number | null;
openedAt: number;
closedAt: number | null;
/** The order whose fill opened it; null for a carried trade. */
openOrderId: string | null;
/** The order whose fill took it flat; null while open. */
closeOrderId: string | null;
carried: boolean;
}
const EPS = 1e-9;
const round2 = (n: number) => Math.round(n * 100) / 100;
const signed = (f: Fill) => (f.side === 'buy' ? f.qty : -f.qty);
/** Oldest first; ties by id, numerically when both are numbers. */
export function byFillTime(a: Fill, b: Fill): number {
if (a.at !== b.at) return a.at - b.at;
const an = Number(a.id);
const bn = Number(b.id);
if (Number.isFinite(an) && Number.isFinite(bn)) return an - bn;
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
}
/** The position before the first of `fills`, given the one after the last. */
export function startingNet(fills: Fill[], currentNet: number): number {
const before = currentNet - fills.reduce((s, f) => s + signed(f), 0);
return Math.abs(before) < EPS ? 0 : before;
}
interface Building {
id: string;
dir: 1 | -1;
net: number;
maxAbs: number;
openQty: number;
openNotional: number | null;
closeQty: number;
closeNotional: number;
platformPnl: number;
pnlFromPlatform: boolean;
fee: number | null;
openedAt: number;
openOrderId: string | null;
carried: boolean;
}
/**
* The trades `fills` (one account, one contract) make. `startNet` is the
* position before the first fill (startingNet): when it isn't flat, the
* fills start inside a position — `carry` continues the stored trade when it
* is on the same side, and without one those fills are passed over until the
* account is flat. `pointValue` (dollars per point per contract) works out
* the P&L of a fill the platform gave none for.
*/
export function fillsToTrades(fills: Fill[], o: { startNet: number; pointValue: number | null; carry?: CarriedTrade | null }): FillTrade[] {
const out: FillTrade[] = [];
let net = Math.abs(o.startNet) < EPS ? 0 : o.startNet;
let cur: Building | null = null;
if (net !== 0 && o.carry && (o.carry.side === 'long') === net > 0) {
const qty = Math.abs(net);
cur = {
id: o.carry.id,
dir: net > 0 ? 1 : -1,
net,
maxAbs: Math.max(qty, o.carry.quantity),
openQty: qty,
openNotional: o.carry.openPrice === null ? null : o.carry.openPrice * qty,
closeQty: 0,
closeNotional: 0,
platformPnl: 0,
pnlFromPlatform: true,
fee: o.carry.fee,
openedAt: o.carry.openedAt,
openOrderId: null,
carried: true,
};
}
const open = (f: Fill, qty: number, feeShare: number | null): Building => ({
id: f.id,
dir: f.side === 'buy' ? 1 : -1,
net: f.side === 'buy' ? qty : -qty,
maxAbs: qty,
openQty: qty,
openNotional: f.price * qty,
closeQty: 0,
closeNotional: 0,
platformPnl: 0,
pnlFromPlatform: true,
fee: feeShare,
openedAt: f.at,
openOrderId: f.orderId,
carried: false,
});
const addFee = (b: Building, fee: number | null) => {
b.fee = b.fee === null || fee === null ? null : b.fee + fee;
};
for (const f of [...fills].sort(byFillTime)) {
if (!(f.qty > 0)) continue;
const s = signed(f);
if (cur === null && net !== 0) {
// Inside a position whose start isn't on record: wait for flat.
if (Math.sign(s) === Math.sign(net)) {
net += s;
continue;
}
const closing = Math.min(Math.abs(s), Math.abs(net));
net += Math.sign(s) * closing;
if (Math.abs(net) < EPS) net = 0;
const rest = f.qty - closing;
if (net === 0 && rest > EPS) {
cur = open(f, rest, f.fee === null ? null : f.fee * (rest / f.qty));
net = cur.net;
}
continue;
}
if (cur === null) {
cur = open(f, f.qty, f.fee);
net = cur.net;
continue;
}
if (Math.sign(s) === cur.dir) {
cur.net += s;
net = cur.net;
cur.maxAbs = Math.max(cur.maxAbs, Math.abs(cur.net));
cur.openQty += f.qty;
cur.openNotional = cur.openNotional === null ? null : cur.openNotional + f.price * f.qty;
addFee(cur, f.fee);
continue;
}
const closing = Math.min(f.qty, Math.abs(cur.net));
const rest = f.qty - closing;
addFee(cur, f.fee === null ? null : f.fee * (closing / f.qty));
cur.closeQty += closing;
cur.closeNotional += f.price * closing;
if (f.pnl === null) cur.pnlFromPlatform = false;
else cur.platformPnl += f.pnl;
cur.net += Math.sign(s) * closing;
if (Math.abs(cur.net) < EPS) cur.net = 0;
net = cur.net;
if (cur.net !== 0) continue;
out.push(finish(cur, f, o.pointValue));
cur = null;
if (rest > EPS) {
cur = open(f, rest, f.fee === null ? null : f.fee * (rest / f.qty));
net = cur.net;
}
}
if (cur !== null) {
out.push({
id: cur.id,
side: cur.dir === 1 ? 'long' : 'short',
quantity: cur.maxAbs,
openPrice: cur.openNotional === null ? null : cur.openNotional / cur.openQty,
closePrice: null,
pnl: null,
fee: cur.fee === null ? null : round2(cur.fee),
openedAt: cur.openedAt,
closedAt: null,
openOrderId: cur.openOrderId,
closeOrderId: null,
carried: cur.carried,
});
}
return out;
}
function finish(b: Building, last: Fill, pointValue: number | null): FillTrade {
// Flat to flat, so as much was bought as sold: the P&L is the difference
// in what each side came to.
const computed = b.openNotional === null || pointValue === null
? null
: b.dir * (b.closeNotional - b.openNotional) * pointValue;
const pnl = b.pnlFromPlatform ? b.platformPnl : computed;
return {
id: b.id,
side: b.dir === 1 ? 'long' : 'short',
quantity: b.maxAbs,
openPrice: b.openNotional === null ? null : b.openNotional / b.openQty,
closePrice: b.closeQty > 0 ? b.closeNotional / b.closeQty : null,
pnl: pnl === null ? null : round2(pnl),
fee: b.fee === null ? null : round2(b.fee),
openedAt: b.openedAt,
closedAt: last.at,
openOrderId: b.openOrderId,
closeOrderId: last.orderId,
carried: b.carried,
};
}
/** "MNQZ6" → "MNQ"; a name with no month code comes back as it is. */
export function contractRoot(name: string): string {
const m = /^([A-Z0-9]+?)[FGHJKMNQUVXZ]\d{1,2}$/.exec(name.trim().toUpperCase());
return m ? m[1] : name.trim().toUpperCase();
}