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
+131 -2
View File
@@ -36,7 +36,7 @@ import { currentTradeDateCT, FIRM_RESET_UTC_HOUR, rankForTarget } from './tradin
import { sizingHoldHours } from './person-size';
import {
saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions,
saveEndedCycleStart,
saveEndedCycleStart, loadFillTrades, saveFillTrades, closeTradesUnseen, type FillTradeRow,
} from './db';
import { getAccountConfig } from './account-type-resolver';
import { getCachedFirm } from './firm-catalog';
@@ -48,13 +48,19 @@ import {
dxAccountId, accountDisplayName, accountCodeOf, encodeContractName, decodeContractName, roundToStep,
lastUtcReset, ctTradeDayStart, firmDayOf, timeOf, parseDurationMs, ciNum, isFundType, signedFundValue, newOrderCode,
inTradingHours, realizedVolPerHour, scoreCandidate, candidateRejection, parseTransfers, marginRateOf,
parseHistoryOrder, dxTrades,
} from './dxtrade-helpers';
import type { DxTxn } from './dxtrade-helpers';
import type { DxTxn, DxHistoryOrder, DxKnownPosition } from './dxtrade-helpers';
import { hyperliquidCoins, hyperliquidCoinFor, hyperliquidQuote, hyperliquidHourlyCloses } from './hyperliquid-feed';
// ── timings ──────────────────────────────────────────────────────────────────
const SYNC_INTERVAL_MS = 60_000;
/** The trades record reads an active account's orders this often — sooner
* once its positions or the day's cash show something happened. */
const TRADES_RECORD_MS = 5 * 60_000;
/** How far back the trades record reads orders and cash. */
const TRADES_WINDOW_MS = 2 * 86_400_000;
const SYNC_WEDGED_MS = 3 * 60_000;
const WATCHDOG_INTERVAL_MS = 60_000;
/** Logged in longer than this with no good sync for as long → re-login. */
@@ -257,6 +263,12 @@ export class DXTradeClient implements PlatformClient {
private recentTxns = new Map<number, Map<string, DxTxn>>();
/** Accounts whose balance and day's transactions came back this pass. */
private dayFresh = new Set<number>();
/** The trades record: when each account was last read, and what its
* positions and day's cash looked like then. */
private tradesRecordedAt = new Map<number, { at: number; sig: string }>();
private tradesRecordInFlight = false;
/** The order history refused a time-from: read the latest orders instead. */
private historyTakesNoTimeFrom = false;
/** When each open position was first seen, for hold-time logging. */
private seenOpenAt = new Map<number, { positionCode: string; at: number; naggedAt: number }>();
@@ -685,6 +697,7 @@ export class DXTradeClient implements PlatformClient {
await this.reapStrayOrders();
this.checkNakedPositions();
if (superseded()) return;
if (fresh) void this.recordTrades();
console.log(`${this.tag} sync — ${list.length} account(s), ${Object.keys(this.positions).length} open position(s)`);
this.maybeFetchDaysTraded();
@@ -1330,6 +1343,122 @@ export class DXTradeClient implements PlatformClient {
return {};
}
// ── trades record ────────────────────────────────────────────────────────
/**
* Every position on the accounts, the bot's and anyone else's, in the
* trades table the dashboard lists — off the last two days' filled orders
* and their SETTLEMENT and COMMISSION. The bot's orders carry its af-
* codes, so who opened and closed each needs nothing kept locally. Only
* an account with something going on is read (an open position, the
* day's cash, a trade still open in the table), each at most every few
* minutes. Never fails the sync.
*/
private async recordTrades(): Promise<void> {
if (this.tradesRecordInFlight || !this.token || this.intentionalDisconnect) return;
const now = Date.now();
const due: { acc: PlatformAccount; code: string; sig: string; stored: FillTradeRow[] }[] = [];
for (const acc of this.accountList) {
const code = this.codeById.get(acc.id);
if (!code || !this.dayFresh.has(acc.id)) continue;
const positions = (this.positionsDetail.get(acc.id) ?? []).map((p) => p.positionCode).sort().join(',');
const trading = [...(this.recentTxns.get(acc.id)?.values() ?? [])].filter((t) => !isFundType(t.type)).length;
const sig = positions || trading > 0 ? `${positions}|${trading}` : '';
const stored = loadFillTrades(acc.id, 'dx');
const last = this.tradesRecordedAt.get(acc.id);
const storedOpen = stored.some((t) => t.closedAt === null && t.closeReason === null);
if (sig === '' && !storedOpen && (!last || last.sig === '')) continue;
if (last && last.sig === sig && now - last.at < TRADES_RECORD_MS) continue;
due.push({ acc, code, sig, stored });
}
if (due.length === 0) return;
this.tradesRecordInFlight = true;
try {
const from = new Date(now - TRADES_WINDOW_MS).toISOString();
const txns = (await this.perAccounts<unknown>('/accounts/transfers', due.map((d) => d.code), (d) => [d],
{ 'time-from': from, limit: 1000 }, 'history')).flatMap((body) => parseTransfers(body));
for (const { acc, code, sig, stored } of due) {
if (this.intentionalDisconnect) return;
const orders = await this.historyOrders(code, from);
if (orders === null) continue;
this.saveTrades(acc, orders, txns.filter((t) => t.account === code), stored);
this.tradesRecordedAt.set(acc.id, { at: Date.now(), sig });
}
} catch (err) {
console.warn(`${this.tag} trades record failed: ${errorMessage(err)}`);
} finally {
this.tradesRecordInFlight = false;
}
}
/** An account's filled orders since `from`; null when they couldn't be read. */
private async historyOrders(code: string, from: string): Promise<DxHistoryOrder[] | null> {
const path = `/accounts/${encodeURIComponent(code)}/orders/history`;
try {
let data: { orders?: unknown[] } | undefined;
try {
({ data } = await this.request<{ orders?: unknown[] }>('history', 'GET', path, {
params: this.historyTakesNoTimeFrom ? { limit: 500 } : { 'time-from': from, limit: 500 },
}));
} catch (err) {
if (!(err instanceof DxApiError) || err.status !== 400 || this.historyTakesNoTimeFrom) throw err;
this.historyTakesNoTimeFrom = true;
({ data } = await this.request<{ orders?: unknown[] }>('history', 'GET', path, { params: { limit: 500 } }));
}
const since = Date.parse(from);
return (data?.orders ?? []).map(parseHistoryOrder).filter((o): o is DxHistoryOrder => o !== null && o.at >= since);
} catch (err) {
console.warn(`${this.tag} order history for ${accountDisplayName(code)} failed: ${errorMessage(err)}`);
return null;
}
}
private saveTrades(acc: PlatformAccount, orders: DxHistoryOrder[], txns: DxTxn[], stored: FillTradeRow[]): void {
const prefix = `dx:${acc.id}:`;
const open = new Map<string, DxKnownPosition>();
for (const p of this.positionsDetail.get(acc.id) ?? []) {
open.set(p.positionCode, {
symbol: p.symbol, side: p.side === 'BUY' ? 'long' : 'short', quantity: p.quantity,
openPrice: Number.isFinite(p.openPrice) ? p.openPrice : null, openedAt: p.openTime,
});
}
const known = new Map<string, DxKnownPosition>(stored.map((t) => [t.positionId.slice(prefix.length), {
symbol: t.symbol, side: t.side, quantity: t.quantity, openPrice: t.openPrice, openedAt: t.openedAt,
}]));
const ours = (code: string | null) => code !== null && code.startsWith('af-');
const rows: FillTradeRow[] = dxTrades(orders, txns, open, known)
.filter((t) => Number.isFinite(t.openedAt))
.map((t) => {
const closed = t.closedAt !== null;
const exit = closed && ours(t.closeCode) ? t.closeType : null;
return {
positionId: `${prefix}${t.positionCode}`,
accountId: acc.id,
accountName: acc.name,
symbol: t.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: exit === 'STOP' ? 'stop_loss' : exit === 'LIMIT' ? 'take_profit' : null,
openedBy: t.openCode === null ? null : ours(t.openCode) ? 'bot' : 'outside',
closedBy: !closed || exit === 'STOP' || exit === 'LIMIT' ? null : ours(t.closeCode) ? 'bot' : 'outside',
};
});
saveFillTrades(rows);
// Open in the table, gone from the account, and nothing in the
// history closed it: closed out of sight.
const seen = new Set(rows.map((r) => r.positionId));
closeTradesUnseen(stored
.filter((t) => t.closedAt === null && t.closeReason === null && !seen.has(t.positionId) && !open.has(t.positionId.slice(prefix.length)))
.map((t) => t.positionId));
}
// ── orders ───────────────────────────────────────────────────────────────
private async freshPositions(account: string): Promise<DxPosition[]> {