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
+141
-1
@@ -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 };
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user