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:
co-authored by
Claude Opus 5.5
parent
6f6660fdcc
commit
1e844178de
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user