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
+153
View File
@@ -328,3 +328,156 @@ export function candidateRejection(o: {
if (o.currency && o.currency !== o.accountCurrency) return `priced in ${o.currency}`;
return null;
}
// ── trades ───────────────────────────────────────────────────────────────────
/** A filled order off /orders/history, as the trades record needs it. */
export interface DxHistoryOrder {
orderCode: string;
orderId: number | undefined;
/** MARKET, LIMIT, STOP. */
type: string;
instrument: string;
positionCode: string | undefined;
effect: 'OPEN' | 'CLOSE' | undefined;
side: 'BUY' | 'SELL' | undefined;
/** Filled. */
qty: number;
/** Average fill price. */
price: number | undefined;
/** The last fill, else the order's own time. */
at: number;
}
/** null for an order that never filled. */
export function parseHistoryOrder(raw: unknown): DxHistoryOrder | null {
const o = raw as Record<string, unknown>;
if (!o || typeof o !== 'object' || typeof o.orderCode !== 'string') return null;
const legs = Array.isArray(o.legs) ? (o.legs as Record<string, unknown>[]) : [];
const leg = legs[0] ?? {};
const qty = Math.abs(ciNum(leg, 'filledQuantity') ?? ciNum(o, 'filledQuantity') ?? 0);
if (!(qty > 0)) return null;
const executions = Array.isArray(o.executions) ? (o.executions as Record<string, unknown>[]) : [];
const fillTimes = executions
.filter((e) => (ciNum(e, 'lastQuantity') ?? ciNum(e, 'filledQuantity') ?? 0) > 0)
.map((e) => timeOf(e.transactionTime))
.filter(Number.isFinite);
const at = fillTimes.length > 0 ? Math.max(...fillTimes) : [timeOf(o.transactionTime), timeOf(o.issueTime)].find(Number.isFinite) ?? NaN;
if (!Number.isFinite(at)) return null;
const effect = leg.positionEffect === 'OPEN' || leg.positionEffect === 'CLOSE' ? leg.positionEffect : undefined;
const sideRaw = leg.side ?? o.side;
return {
orderCode: o.orderCode,
orderId: ciNum(o, 'orderId'),
type: String(o.type ?? ''),
instrument: String(leg.instrument ?? o.instrument ?? ''),
positionCode: typeof leg.positionCode === 'string' && leg.positionCode ? leg.positionCode : undefined,
effect,
side: sideRaw === 'BUY' || sideRaw === 'SELL' ? sideRaw : undefined,
qty,
price: ciNum(leg, 'averagePrice')
?? [...executions].reverse().map((e) => ciNum(e, 'averagePrice')).find((p) => p !== undefined && p > 0),
at,
};
}
/** One position, from the orders that opened and closed it. */
export interface DxTrade {
positionCode: string;
symbol: string;
side: 'long' | 'short';
quantity: number;
openPrice: number | null;
closePrice: number | null;
/** The SETTLEMENT its closes booked; null while open or unseen. */
pnl: number | null;
/** Its COMMISSION, opens and closes; null when none was seen. */
fee: number | null;
openedAt: number;
closedAt: number | null;
/** The opening order's code; null when it opened before the history. */
openCode: string | null;
/** The order that closed it, and its type. */
closeCode: string | null;
closeType: string | null;
}
/** What the record knows of a position already: the open one on the
* account now, or one stored from an earlier pass. */
export interface DxKnownPosition {
symbol: string;
side: 'long' | 'short';
quantity: number;
openPrice: number | null;
openedAt: number;
}
const vwap = (orders: DxHistoryOrder[]): number | null => {
const priced = orders.filter((o) => o.price !== undefined);
const qty = priced.reduce((s, o) => s + o.qty, 0);
return qty > 0 ? priced.reduce((s, o) => s + o.price! * o.qty, 0) / qty : null;
};
/**
* The account's positions, out of its filled orders (one account) and its
* cash transactions. On a position-based account an opening order's position
* code is its order id, so an OPEN leg without one takes that. A position the
* account still holds (`open`) stays open whatever closed part of it; one
* whose opening is older than the orders at hand is completed from `known`,
* and left out when nothing knows it.
*/
export function dxTrades(orders: DxHistoryOrder[], txns: DxTxn[], open: Map<string, DxKnownPosition>, known: Map<string, DxKnownPosition>): DxTrade[] {
const groups = new Map<string, DxHistoryOrder[]>();
for (const o of orders) {
const code = o.positionCode ?? (o.effect === 'OPEN' && o.orderId !== undefined ? String(o.orderId) : undefined);
if (!code) continue;
groups.set(code, [...(groups.get(code) ?? []), o]);
}
for (const code of open.keys()) if (!groups.has(code)) groups.set(code, []);
const settled = new Map<string, number>();
const commission = new Map<string, number>();
for (const t of txns) {
if (!t.orderCode) continue;
if (t.type === 'SETTLEMENT') settled.set(t.orderCode, (settled.get(t.orderCode) ?? 0) + t.value);
else if (t.type === 'COMMISSION') commission.set(t.orderCode, (commission.get(t.orderCode) ?? 0) + Math.abs(t.value));
}
const out: DxTrade[] = [];
for (const [code, list] of groups) {
const sorted = [...list].sort((a, b) => a.at - b.at);
const now = open.get(code);
const before = now ?? known.get(code);
// An OPEN leg, or one on the same side as the opening.
const firstOpen = sorted.find((o) => o.effect === 'OPEN') ?? (before ? undefined : sorted.find((o) => o.effect === undefined));
const openSide = firstOpen?.side ?? (before ? (before.side === 'long' ? 'BUY' : 'SELL') : undefined);
if (!openSide) continue;
const opens = sorted.filter((o) => o.effect === 'OPEN' || (o.effect === undefined && o.side === openSide));
const closes = sorted.filter((o) => !opens.includes(o));
if (opens.length === 0 && !before) continue;
const openPrice = opens.length > 0 ? vwap(opens) : before!.openPrice;
const closeQty = closes.reduce((s, o) => s + o.qty, 0);
const closed = !now && closeQty > 0;
const last = closed ? closes[closes.length - 1] : undefined;
const codes = sorted.map((o) => o.orderCode);
const fees = codes.filter((c) => commission.has(c));
const pnlCodes = closes.map((o) => o.orderCode).filter((c) => settled.has(c));
out.push({
positionCode: code,
symbol: opens[0]?.instrument || before?.symbol || closes[0]?.instrument || '',
side: openSide === 'BUY' ? 'long' : 'short',
quantity: Math.max(opens.reduce((s, o) => s + o.qty, 0), now?.quantity ?? 0, before?.quantity ?? 0),
openPrice: openPrice ?? now?.openPrice ?? null,
closePrice: closed ? vwap(closes) : null,
pnl: closed && pnlCodes.length > 0 ? Math.round(pnlCodes.reduce((s, c) => s + settled.get(c)!, 0) * 100) / 100 : null,
fee: fees.length > 0 ? Math.round(fees.reduce((s, c) => s + commission.get(c)!, 0) * 100) / 100 : null,
openedAt: opens.length > 0 ? opens[0].at : before!.openedAt,
closedAt: last ? last.at : null,
openCode: opens.length > 0 ? opens[0].orderCode : null,
closeCode: last?.orderCode ?? null,
closeType: last?.type ?? null,
});
}
return out;
}