/** * 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(); }