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
+141 -1
View File
@@ -6,7 +6,9 @@ import { computeSec, randomUUIDV4 } from './tradovate-helpers';
import { currentTradeDateCT } from './trading-logic';
import { POINT_VALUES } from './instrument-specs';
import { getCachedContract, resolveContracts } from './contract-resolver';
import { saveDailyPnL, loadDailyPnL, saveAccountMeta, loadAccountMeta, saveFundTransactions, loadFundTransactions, saveEndedCycleStart } from './db';
import { saveDailyPnL, loadDailyPnL, saveAccountMeta, loadAccountMeta, saveFundTransactions, loadFundTransactions, saveEndedCycleStart, recordOwnOrder, type OwnOrderKind } from './db';
import { recordFillTrades, type ContractFills } from './trade-recorder';
import { contractRoot, type Fill } from './fill-trades';
import { brokerWebSocket } from './outbound-proxy';
import { FILLS_FALLBACK_NOTE, OrderNotPlacedError, SYNC_STALE_MS, errorMessage, type ClientHealth } from './platform-client';
@@ -113,6 +115,8 @@ const FINISHED_ORDER_STATUSES = new Set(['Filled', 'Canceled', 'Rejected', 'Expi
* a payout is hundreds. */
const FUND_DRIFT_TOLERANCE = 25;
const CONTRACT_FIND_TIMEOUT_MS = 15_000;
/** How often a product's fees are asked for again, for the trades record. */
const FEES_REFRESH_MS = 6 * 60 * 60 * 1000;
/** On-demand front-month resolve (several Yahoo lookups, then Tradovate). */
const CONTRACT_RESOLVE_DEADLINE_MS = 30_000;
const READY_STATE_NAMES = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'];
@@ -371,6 +375,13 @@ export class TradovateClient {
* outcome clears it. */
private cashHistoryRefusedSince = new Map<number, number>();
/** Interval handles tracked so they can be cleared on reconnect */
/** Contract id → name ("NQZ6"), for the trades record. */
private contractNames = new Map<number, string>();
private contractLookupInFlight = false;
/** Product → fees per contract per side, for the trades record. */
private feePerSide = new Map<string, number>();
private feesAskedAt = new Map<string, number>();
private feesInFlight = false;
private syncInterval: ReturnType<typeof setInterval> | null = null;
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
private tokenRenewalInterval: ReturnType<typeof setInterval> | null = null;
@@ -1049,6 +1060,8 @@ export class TradovateClient {
{} as { [accountId: number]: { contractId: number; netPos: number; netPrice: number; timestamp: Date } }
);
this.recordTrades(response);
this.lastSyncAt = Date.now();
this.lastSyncStartedAt = requestedAt || this.lastSyncAt;
// A session counts as working once it has synced (abandonSocket).
@@ -2028,6 +2041,128 @@ export class TradovateClient {
}
}
/** Notes an order as the bot's, so the trade its fill opens or closes is
* credited to the bot (lib/trade-recorder.ts). */
private noteOwnOrder(accountId: number, orderId: unknown, kind: OwnOrderKind): void {
if (typeof orderId !== 'number' || !(orderId > 0)) return;
try {
recordOwnOrder(accountId, orderId, kind);
} catch (err) {
console.error(`${this.tag} could not note order ${orderId} as the bot's: ${errorMessage(err)}`);
}
}
/**
* The trades the sync snapshot's fills make (the session's), in the
* trades table the dashboard lists (lib/trade-recorder.ts). A fill names
* its order, not its account, so the snapshot's orders say whose it is.
* Never fails the sync.
*/
private recordTrades(response: { contracts?: unknown[]; orders?: unknown[]; positions?: unknown[]; fills?: unknown[] }): void {
try {
for (const c of (response.contracts ?? []) as { id?: unknown; name?: unknown }[]) {
if (typeof c?.id === 'number' && typeof c.name === 'string') this.contractNames.set(c.id, c.name);
}
const accountOfOrder = new Map<number, number>();
for (const o of (response.orders ?? []) as { id?: unknown; accountId?: unknown }[]) {
if (typeof o?.id === 'number' && typeof o.accountId === 'number') accountOfOrder.set(o.id, o.accountId);
}
const books = new Map<number, Map<number, { fills: Fill[]; net: number; netAt: number }>>();
const book = (accountId: number, contractId: number) => {
const contracts = books.get(accountId) ?? new Map<number, { fills: Fill[]; net: number; netAt: number }>();
books.set(accountId, contracts);
const b = contracts.get(contractId) ?? { fills: [], net: 0, netAt: -Infinity };
contracts.set(contractId, b);
return b;
};
for (const p of (response.positions ?? []) as { accountId?: unknown; contractId?: unknown; netPos?: unknown; timestamp?: unknown }[]) {
if (typeof p?.accountId !== 'number' || typeof p.contractId !== 'number') continue;
const b = book(p.accountId, p.contractId);
const at = Date.parse(String(p.timestamp ?? '')) || 0;
if (at >= b.netAt) {
b.net = typeof p.netPos === 'number' ? p.netPos : 0;
b.netAt = at;
}
}
for (const raw of (response.fills ?? []) as Record<string, unknown>[]) {
const f = raw ?? {};
if (f.active === false || typeof f.id !== 'number' || typeof f.orderId !== 'number' || typeof f.contractId !== 'number') continue;
const qty = Number(f.qty);
const price = Number(f.price);
const at = Date.parse(String(f.timestamp ?? ''));
const accountId = accountOfOrder.get(f.orderId);
if (accountId === undefined || !(qty > 0) || !Number.isFinite(price) || !Number.isFinite(at)) continue;
book(accountId, f.contractId).fills.push({
id: String(f.id), orderId: String(f.orderId), at, side: f.action === 'Buy' ? 'buy' : 'sell', qty, price, fee: null, pnl: null,
});
}
const unnamed = new Set<number>();
const roots = new Set<string>();
for (const acc of this.accountList) {
const contracts = books.get(acc.id);
if (!contracts) continue;
const list: ContractFills[] = [];
for (const [contractId, b] of contracts) {
if (b.fills.length === 0 && b.net === 0) continue;
const name = this.contractNames.get(contractId);
if (!name) {
unnamed.add(contractId);
continue;
}
const root = contractRoot(name);
roots.add(root);
const perSide = this.feePerSide.get(root);
list.push({
symbol: name,
pointValue: POINT_VALUES[root] ?? null,
currentNet: b.net,
fills: b.fills.map((f) => ({ ...f, fee: perSide === undefined ? null : Math.round(perSide * f.qty * 100) / 100 })),
});
}
if (list.length > 0) recordFillTrades('tdv', acc, list);
}
if (unnamed.size > 0) void this.lookUpContractNames([...unnamed]);
this.refreshFees(roots);
} catch (err) {
console.error(`${this.tag} trade record failed: ${errorMessage(err)}`);
}
}
/** Names for contracts the snapshot didn't describe — the next sync's
* record uses them. */
private async lookUpContractNames(ids: number[]): Promise<void> {
if (this.contractLookupInFlight) return;
this.contractLookupInFlight = true;
try {
await this.sendRequest('contract/items', `ids=${ids.join(',')}`, CONTRACT_FIND_TIMEOUT_MS, (data) => {
for (const c of (Array.isArray(data) ? data : []) as { id?: unknown; name?: unknown }[]) {
if (typeof c?.id === 'number' && typeof c.name === 'string') this.contractNames.set(c.id, c.name);
}
});
} catch (err) {
console.warn(`${this.tag} contract/items failed: ${errorMessage(err)}`);
} finally {
this.contractLookupInFlight = false;
}
}
/** Each product's fees per contract per side (getproductfeeparams), for
* the trades record: asked for a product once, then every few hours. */
private refreshFees(roots: Set<string>): void {
const now = Date.now();
const due = [...roots].filter((r) => now - (this.feesAskedAt.get(r) ?? 0) >= FEES_REFRESH_MS);
if (due.length === 0 || this.feesInFlight || this.products.length === 0) return;
this.feesInFlight = true;
for (const r of due) this.feesAskedAt.set(r, now);
this.fetchInstrumentFees(due)
.then((fees) => {
for (const [root, fee] of Object.entries(fees)) this.feePerSide.set(root, fee);
})
.catch((err) => console.warn(`${this.tag} fee lookup failed: ${errorMessage(err)}`))
.finally(() => { this.feesInFlight = false; });
}
/** A string or empty ack is the broker refusing the order — nothing was placed. */
private static acceptedAck(response: unknown, endpoint: string): { orderId?: number; id?: number; commandStatus?: string } {
if (typeof response === 'string') throw new OrderNotPlacedError(response || `${endpoint} refused`);
@@ -2148,6 +2283,7 @@ export class TradovateClient {
const ack = TradovateClient.acceptedAck(response, 'order/placeorder');
// Tradovate returns the command object: { id, commandStatus, orderId, ... }
const orderId = ack.orderId ?? ack.id;
this.noteOwnOrder(accountId, orderId, orderType === 'Limit' ? 'limit' : 'market');
console.log(`${this.tag} sendOrder orderId=${orderId} status=${ack.commandStatus}`);
// Wait for the fill event — it carries the real execution price
@@ -2180,6 +2316,7 @@ export class TradovateClient {
ACK_TIMEOUT_MS,
(response) => {
const ack = TradovateClient.acceptedAck(response, 'order/placeorder');
this.noteOwnOrder(accountId, ack.orderId ?? ack.id, orderType === 'Limit' ? 'limit' : 'market');
return { orderId: ack.orderId ?? ack.id };
},
);
@@ -2219,6 +2356,9 @@ export class TradovateClient {
ACK_TIMEOUT_MS,
(response) => {
const ack = TradovateClient.acceptedAck(response, 'order/placeoco');
// PlaceOCOResult: orderId is the take-profit limit, ocoId its stop.
this.noteOwnOrder(accountId, ack.orderId ?? ack.id, 'limit');
this.noteOwnOrder(accountId, (response as { ocoId?: unknown }).ocoId, 'stop');
return { orderId: ack.orderId ?? ack.id };
},
);