import { byFillTime, fillsToTrades, startingNet, type Fill, type CarriedTrade } from './fill-trades'; import { loadOwnOrders, ownOrdersSince, loadFillTrades, saveFillTrades, deleteTrades, closeTradesUnseen, type FillTradeRow, } from './db'; /** * Records the trades a futures platform's fills make (lib/fill-trades.ts) in * the trades table the dashboard lists, with who opened and closed each: the * bot when the fill's order is one it placed (own_orders), and how — its * stop, its take-profit, or a market close. * * Each pass hands over a window of fills (TopstepX: two days; Tradovate: the * session's) with the account's position now, and the window's trades are * rebuilt whole. A history and a position read a moment apart can disagree — * a fill landing between the two calls — and that pass would rebuild the * wrong trades, so a contract is written only when the position before its * window comes out the same two passes running. */ export interface ContractFills { /** As shown: "NQZ6". */ symbol: string; /** Dollars per point per contract, for P&L the platform didn't give. */ pointValue: number | null; fills: Fill[]; /** Signed contracts held now. */ currentNet: number; } /** The position before each window, last pass: prefix:account:symbol → net. */ const lastStart = new Map(); export function recordFillTrades(prefix: string, account: { id: number; name: string }, contracts: ContractFills[]): void { let own: Map | null = null; for (const c of contracts) { const fills = [...c.fills].sort(byFillTime); const startNet = startingNet(fills, c.currentNet); const key = `${prefix}:${account.id}:${c.symbol}`; const steady = lastStart.get(key) === startNet; lastStart.set(key, startNet); if (!steady) continue; const stored = loadFillTrades(account.id, prefix, c.symbol); const isOpen = (t: FillTradeRow) => t.closedAt === null && t.closeReason === null; const windowStart = fills.length > 0 ? fills[0].at : Infinity; if (fills.length === 0) { // Nothing in the window: an open trade stays open while the // position does, and is closed out of sight once it's gone. if (startNet === 0) closeTradesUnseen(stored.filter(isOpen).map((t) => t.positionId)); continue; } const carryRow = stored .filter((t) => isOpen(t) && t.openedAt < windowStart) .sort((a, b) => b.openedAt - a.openedAt)[0]; const carry: CarriedTrade | null = carryRow ? { id: carryRow.positionId, side: carryRow.side, quantity: carryRow.quantity, openPrice: carryRow.openPrice, fee: carryRow.fee, openedAt: carryRow.openedAt } : null; const trades = fillsToTrades(fills, { startNet, pointValue: c.pointValue, carry }); own ??= loadOwnOrders(account.id); const since = ownOrdersSince(); const rows: FillTradeRow[] = trades.map((t) => { const closeKind = t.closeOrderId === null ? undefined : own!.get(t.closeOrderId); const closed = t.closedAt !== null; return { positionId: t.carried ? t.id : `${prefix}:${t.id}`, accountId: account.id, accountName: account.name, symbol: c.symbol, side: t.side, quantity: t.quantity, openPrice: t.openPrice, closePrice: t.closePrice, pnl: t.pnl, fee: t.fee, openedAt: t.openedAt, closedAt: t.closedAt, closeReason: !closed ? null : closeKind === 'stop' ? 'stop_loss' : closeKind === 'limit' ? 'take_profit' : null, openedBy: t.carried ? null : t.openOrderId !== null && own!.has(t.openOrderId) ? 'bot' : t.openedAt >= since ? 'outside' : 'unknown', // A trade opened before own orders were noted had its exits // placed then too: whose close it was is unknown, not outside. closedBy: !closed || closeKind === 'stop' || closeKind === 'limit' ? null : closeKind === 'market' ? 'bot' : t.openedAt >= since ? 'outside' : 'unknown', }; }); saveFillTrades(rows); const ids = new Set(rows.map((r) => r.positionId)); // Built last time off a window that has since moved under it. deleteTrades(stored.filter((t) => t.openedAt >= windowStart && !ids.has(t.positionId)).map((t) => t.positionId)); // Open from before the window, and the window doesn't carry it on: // it closed somewhere the history no longer reaches. closeTradesUnseen(stored.filter((t) => isOpen(t) && t.openedAt < windowStart && !ids.has(t.positionId)).map((t) => t.positionId)); } }