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
@@ -684,12 +684,116 @@ export function markTrade(
|
||||
`).run(t.positionId, t.accountId, t.accountName, t.symbol, t.side, t.quantity, t.openPrice, t.openedAt, Date.now());
|
||||
}
|
||||
|
||||
/** Accounts with a trade the table still shows open. */
|
||||
/** Vest accounts with a trade the table still shows open (Vest's position
|
||||
* ids are bare numbers; the fill-built trades below carry a prefix). */
|
||||
export function openTradeAccounts(): { accountId: number; accountName: string }[] {
|
||||
return db.prepare('SELECT account_id AS accountId, MAX(account_name) AS accountName FROM trades WHERE closed_at IS NULL GROUP BY account_id')
|
||||
return db.prepare(`SELECT account_id AS accountId, MAX(account_name) AS accountName FROM trades
|
||||
WHERE closed_at IS NULL AND position_id NOT LIKE '%:%' GROUP BY account_id`)
|
||||
.all() as { accountId: number; accountName: string }[];
|
||||
}
|
||||
|
||||
// ── Own orders, and trades built from fills ──────────────────────────────────
|
||||
//
|
||||
// TopstepX and Tradovate report fills, not positions, and neither says who
|
||||
// placed an order. So every order this instance places is noted here as it
|
||||
// goes in, and a trade built from fills (lib/trade-recorder.ts) is the bot's
|
||||
// when its opening fill's order is one of these. Its kind tells how the bot
|
||||
// closed one: its stop, its take-profit limit, or a market order. Those
|
||||
// trades carry a platform prefix on their id ("tsx:…", "tdv:…") and their
|
||||
// marks are worked out afresh on each pass: 'bot', 'outside', or 'unknown'
|
||||
// for a fill from before this table began.
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS own_orders (
|
||||
account_id INTEGER NOT NULL,
|
||||
order_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
placed_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (account_id, order_id)
|
||||
);
|
||||
`);
|
||||
|
||||
/** settings key: when this instance began noting its own futures orders. */
|
||||
const OWN_ORDERS_SINCE_SETTING = 'own_orders_since';
|
||||
const ownOrdersSinceMs = Number(getOrCreateSetting(OWN_ORDERS_SINCE_SETTING, () => String(Date.now())));
|
||||
|
||||
export function ownOrdersSince(): number {
|
||||
return ownOrdersSinceMs;
|
||||
}
|
||||
|
||||
export type OwnOrderKind = 'market' | 'limit' | 'stop';
|
||||
|
||||
export function recordOwnOrder(accountId: number, orderId: string | number, kind: OwnOrderKind): void {
|
||||
db.prepare('INSERT OR IGNORE INTO own_orders (account_id, order_id, kind, placed_at) VALUES (?, ?, ?, ?)')
|
||||
.run(accountId, String(orderId), kind, Date.now());
|
||||
}
|
||||
|
||||
export function loadOwnOrders(accountId: number): Map<string, OwnOrderKind> {
|
||||
const rows = db.prepare('SELECT order_id AS orderId, kind FROM own_orders WHERE account_id = ?').all(accountId) as { orderId: string; kind: OwnOrderKind }[];
|
||||
return new Map(rows.map((r) => [r.orderId, r.kind]));
|
||||
}
|
||||
|
||||
export type FillTradeRow = Omit<TradeRow, 'leverage' | 'funding'>;
|
||||
|
||||
/** One account's stored trades off one platform — for one contract, when
|
||||
* `symbol` is given. */
|
||||
export function loadFillTrades(accountId: number, prefix: string, symbol?: string): FillTradeRow[] {
|
||||
return db.prepare(`
|
||||
SELECT position_id AS positionId, account_id AS accountId, account_name AS accountName, symbol, side, quantity,
|
||||
open_price AS openPrice, close_price AS closePrice, pnl, fee, opened_at AS openedAt, closed_at AS closedAt,
|
||||
close_reason AS closeReason, opened_by AS openedBy, closed_by AS closedBy
|
||||
FROM trades WHERE account_id = ? AND position_id LIKE ? ${symbol === undefined ? '' : 'AND symbol = ?'}
|
||||
`).all(accountId, `${prefix}:%`, ...(symbol === undefined ? [] : [symbol])) as FillTradeRow[];
|
||||
}
|
||||
|
||||
/** Trades rebuilt from fills: every field as worked out this pass, except a
|
||||
* null openedBy (a carried trade's) keeps the stored one. */
|
||||
export function saveFillTrades(rows: FillTradeRow[]): void {
|
||||
const up = db.prepare(`
|
||||
INSERT INTO trades (position_id, account_id, account_name, symbol, side, quantity, open_price, close_price,
|
||||
pnl, fee, opened_at, closed_at, close_reason, opened_by, closed_by, updated_at)
|
||||
VALUES (@positionId, @accountId, @accountName, @symbol, @side, @quantity, @openPrice, @closePrice,
|
||||
@pnl, @fee, @openedAt, @closedAt, @closeReason, @openedBy, @closedBy, @updatedAt)
|
||||
ON CONFLICT(position_id) DO UPDATE SET
|
||||
account_name = excluded.account_name,
|
||||
side = excluded.side,
|
||||
quantity = excluded.quantity,
|
||||
open_price = COALESCE(excluded.open_price, trades.open_price),
|
||||
close_price = excluded.close_price,
|
||||
pnl = excluded.pnl,
|
||||
fee = COALESCE(excluded.fee, trades.fee),
|
||||
opened_at = excluded.opened_at,
|
||||
closed_at = excluded.closed_at,
|
||||
close_reason = excluded.close_reason,
|
||||
opened_by = COALESCE(excluded.opened_by, trades.opened_by),
|
||||
closed_by = excluded.closed_by,
|
||||
updated_at = excluded.updated_at
|
||||
`);
|
||||
const now = Date.now();
|
||||
db.transaction(() => {
|
||||
for (const r of rows) up.run({ ...r, updatedAt: now });
|
||||
})();
|
||||
}
|
||||
|
||||
/** Trades a pass found never existed (built off a history that moved
|
||||
* under it). */
|
||||
export function deleteTrades(positionIds: string[]): void {
|
||||
const del = db.prepare('DELETE FROM trades WHERE position_id = ?');
|
||||
db.transaction(() => {
|
||||
for (const id of positionIds) del.run(id);
|
||||
})();
|
||||
}
|
||||
|
||||
/** Open trades whose close the history no longer reaches: closed, how and
|
||||
* when unknown. */
|
||||
export function closeTradesUnseen(positionIds: string[]): void {
|
||||
const up = db.prepare(`UPDATE trades SET close_reason = 'unknown', updated_at = ? WHERE position_id = ? AND closed_at IS NULL AND close_reason IS NULL`);
|
||||
const now = Date.now();
|
||||
db.transaction(() => {
|
||||
for (const id of positionIds) up.run(now, id);
|
||||
})();
|
||||
}
|
||||
|
||||
/** Newest first (by close, else open); every account when accountIds is
|
||||
* undefined. */
|
||||
export function loadTrades(o: { accountIds?: number[]; limit: number }): TradeRow[] {
|
||||
|
||||
+131
-2
@@ -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[]> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Trades out of fills, for the futures platforms (TopstepX, Tradovate). A
|
||||
* trade runs from flat back to flat: adds and partial closes stay inside it,
|
||||
* and a fill that crosses zero closes one trade and opens the next. Vest and
|
||||
* DXtrade report whole positions themselves.
|
||||
*
|
||||
* No imports, so scripts/check-fill-trades.mjs can test it directly.
|
||||
*/
|
||||
|
||||
export interface Fill {
|
||||
id: string;
|
||||
orderId: string;
|
||||
at: number;
|
||||
side: 'buy' | 'sell';
|
||||
qty: number;
|
||||
price: number;
|
||||
/** What the fill cost (positive); null when unknown. */
|
||||
fee: number | null;
|
||||
/** The P&L the platform booked on this fill, before fees (TopstepX's
|
||||
* closing half-turns); null to work it out from the prices. */
|
||||
pnl: number | null;
|
||||
}
|
||||
|
||||
/** A trade already stored as open, from before the fills at hand begin —
|
||||
* continued when they start part-way through a position. */
|
||||
export interface CarriedTrade {
|
||||
id: string;
|
||||
side: 'long' | 'short';
|
||||
quantity: number;
|
||||
openPrice: number | null;
|
||||
fee: number | null;
|
||||
openedAt: number;
|
||||
}
|
||||
|
||||
export interface FillTrade {
|
||||
/** The first fill's id, or the carried trade's. */
|
||||
id: string;
|
||||
side: 'long' | 'short';
|
||||
/** The most held at once. */
|
||||
quantity: number;
|
||||
/** Average of the fills that opened or added to it. */
|
||||
openPrice: number | null;
|
||||
/** Average of the fills that reduced it; null while open. */
|
||||
closePrice: number | null;
|
||||
/** Before fees; null while open, or when it can't be told. */
|
||||
pnl: number | null;
|
||||
fee: number | null;
|
||||
openedAt: number;
|
||||
closedAt: number | null;
|
||||
/** The order whose fill opened it; null for a carried trade. */
|
||||
openOrderId: string | null;
|
||||
/** The order whose fill took it flat; null while open. */
|
||||
closeOrderId: string | null;
|
||||
carried: boolean;
|
||||
}
|
||||
|
||||
const EPS = 1e-9;
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
const signed = (f: Fill) => (f.side === 'buy' ? f.qty : -f.qty);
|
||||
|
||||
/** Oldest first; ties by id, numerically when both are numbers. */
|
||||
export function byFillTime(a: Fill, b: Fill): number {
|
||||
if (a.at !== b.at) return a.at - b.at;
|
||||
const an = Number(a.id);
|
||||
const bn = Number(b.id);
|
||||
if (Number.isFinite(an) && Number.isFinite(bn)) return an - bn;
|
||||
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
||||
}
|
||||
|
||||
/** The position before the first of `fills`, given the one after the last. */
|
||||
export function startingNet(fills: Fill[], currentNet: number): number {
|
||||
const before = currentNet - fills.reduce((s, f) => s + signed(f), 0);
|
||||
return Math.abs(before) < EPS ? 0 : before;
|
||||
}
|
||||
|
||||
interface Building {
|
||||
id: string;
|
||||
dir: 1 | -1;
|
||||
net: number;
|
||||
maxAbs: number;
|
||||
openQty: number;
|
||||
openNotional: number | null;
|
||||
closeQty: number;
|
||||
closeNotional: number;
|
||||
platformPnl: number;
|
||||
pnlFromPlatform: boolean;
|
||||
fee: number | null;
|
||||
openedAt: number;
|
||||
openOrderId: string | null;
|
||||
carried: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The trades `fills` (one account, one contract) make. `startNet` is the
|
||||
* position before the first fill (startingNet): when it isn't flat, the
|
||||
* fills start inside a position — `carry` continues the stored trade when it
|
||||
* is on the same side, and without one those fills are passed over until the
|
||||
* account is flat. `pointValue` (dollars per point per contract) works out
|
||||
* the P&L of a fill the platform gave none for.
|
||||
*/
|
||||
export function fillsToTrades(fills: Fill[], o: { startNet: number; pointValue: number | null; carry?: CarriedTrade | null }): FillTrade[] {
|
||||
const out: FillTrade[] = [];
|
||||
let net = Math.abs(o.startNet) < EPS ? 0 : o.startNet;
|
||||
let cur: Building | null = null;
|
||||
if (net !== 0 && o.carry && (o.carry.side === 'long') === net > 0) {
|
||||
const qty = Math.abs(net);
|
||||
cur = {
|
||||
id: o.carry.id,
|
||||
dir: net > 0 ? 1 : -1,
|
||||
net,
|
||||
maxAbs: Math.max(qty, o.carry.quantity),
|
||||
openQty: qty,
|
||||
openNotional: o.carry.openPrice === null ? null : o.carry.openPrice * qty,
|
||||
closeQty: 0,
|
||||
closeNotional: 0,
|
||||
platformPnl: 0,
|
||||
pnlFromPlatform: true,
|
||||
fee: o.carry.fee,
|
||||
openedAt: o.carry.openedAt,
|
||||
openOrderId: null,
|
||||
carried: true,
|
||||
};
|
||||
}
|
||||
|
||||
const open = (f: Fill, qty: number, feeShare: number | null): Building => ({
|
||||
id: f.id,
|
||||
dir: f.side === 'buy' ? 1 : -1,
|
||||
net: f.side === 'buy' ? qty : -qty,
|
||||
maxAbs: qty,
|
||||
openQty: qty,
|
||||
openNotional: f.price * qty,
|
||||
closeQty: 0,
|
||||
closeNotional: 0,
|
||||
platformPnl: 0,
|
||||
pnlFromPlatform: true,
|
||||
fee: feeShare,
|
||||
openedAt: f.at,
|
||||
openOrderId: f.orderId,
|
||||
carried: false,
|
||||
});
|
||||
const addFee = (b: Building, fee: number | null) => {
|
||||
b.fee = b.fee === null || fee === null ? null : b.fee + fee;
|
||||
};
|
||||
|
||||
for (const f of [...fills].sort(byFillTime)) {
|
||||
if (!(f.qty > 0)) continue;
|
||||
const s = signed(f);
|
||||
if (cur === null && net !== 0) {
|
||||
// Inside a position whose start isn't on record: wait for flat.
|
||||
if (Math.sign(s) === Math.sign(net)) {
|
||||
net += s;
|
||||
continue;
|
||||
}
|
||||
const closing = Math.min(Math.abs(s), Math.abs(net));
|
||||
net += Math.sign(s) * closing;
|
||||
if (Math.abs(net) < EPS) net = 0;
|
||||
const rest = f.qty - closing;
|
||||
if (net === 0 && rest > EPS) {
|
||||
cur = open(f, rest, f.fee === null ? null : f.fee * (rest / f.qty));
|
||||
net = cur.net;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (cur === null) {
|
||||
cur = open(f, f.qty, f.fee);
|
||||
net = cur.net;
|
||||
continue;
|
||||
}
|
||||
if (Math.sign(s) === cur.dir) {
|
||||
cur.net += s;
|
||||
net = cur.net;
|
||||
cur.maxAbs = Math.max(cur.maxAbs, Math.abs(cur.net));
|
||||
cur.openQty += f.qty;
|
||||
cur.openNotional = cur.openNotional === null ? null : cur.openNotional + f.price * f.qty;
|
||||
addFee(cur, f.fee);
|
||||
continue;
|
||||
}
|
||||
const closing = Math.min(f.qty, Math.abs(cur.net));
|
||||
const rest = f.qty - closing;
|
||||
addFee(cur, f.fee === null ? null : f.fee * (closing / f.qty));
|
||||
cur.closeQty += closing;
|
||||
cur.closeNotional += f.price * closing;
|
||||
if (f.pnl === null) cur.pnlFromPlatform = false;
|
||||
else cur.platformPnl += f.pnl;
|
||||
cur.net += Math.sign(s) * closing;
|
||||
if (Math.abs(cur.net) < EPS) cur.net = 0;
|
||||
net = cur.net;
|
||||
if (cur.net !== 0) continue;
|
||||
|
||||
out.push(finish(cur, f, o.pointValue));
|
||||
cur = null;
|
||||
if (rest > EPS) {
|
||||
cur = open(f, rest, f.fee === null ? null : f.fee * (rest / f.qty));
|
||||
net = cur.net;
|
||||
}
|
||||
}
|
||||
if (cur !== null) {
|
||||
out.push({
|
||||
id: cur.id,
|
||||
side: cur.dir === 1 ? 'long' : 'short',
|
||||
quantity: cur.maxAbs,
|
||||
openPrice: cur.openNotional === null ? null : cur.openNotional / cur.openQty,
|
||||
closePrice: null,
|
||||
pnl: null,
|
||||
fee: cur.fee === null ? null : round2(cur.fee),
|
||||
openedAt: cur.openedAt,
|
||||
closedAt: null,
|
||||
openOrderId: cur.openOrderId,
|
||||
closeOrderId: null,
|
||||
carried: cur.carried,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function finish(b: Building, last: Fill, pointValue: number | null): FillTrade {
|
||||
// Flat to flat, so as much was bought as sold: the P&L is the difference
|
||||
// in what each side came to.
|
||||
const computed = b.openNotional === null || pointValue === null
|
||||
? null
|
||||
: b.dir * (b.closeNotional - b.openNotional) * pointValue;
|
||||
const pnl = b.pnlFromPlatform ? b.platformPnl : computed;
|
||||
return {
|
||||
id: b.id,
|
||||
side: b.dir === 1 ? 'long' : 'short',
|
||||
quantity: b.maxAbs,
|
||||
openPrice: b.openNotional === null ? null : b.openNotional / b.openQty,
|
||||
closePrice: b.closeQty > 0 ? b.closeNotional / b.closeQty : null,
|
||||
pnl: pnl === null ? null : round2(pnl),
|
||||
fee: b.fee === null ? null : round2(b.fee),
|
||||
openedAt: b.openedAt,
|
||||
closedAt: last.at,
|
||||
openOrderId: b.openOrderId,
|
||||
closeOrderId: last.orderId,
|
||||
carried: b.carried,
|
||||
};
|
||||
}
|
||||
|
||||
/** "MNQZ6" → "MNQ"; a name with no month code comes back as it is. */
|
||||
export function contractRoot(name: string): string {
|
||||
const m = /^([A-Z0-9]+?)[FGHJKMNQUVXZ]\d{1,2}$/.exec(name.trim().toUpperCase());
|
||||
return m ? m[1] : name.trim().toUpperCase();
|
||||
}
|
||||
@@ -61,3 +61,14 @@ export function topstepCommission(root: string | null, size: number): number {
|
||||
const perSide = (root !== null ? TOPSTEP_COMMISSION_PER_SIDE[root] : undefined) ?? TOPSTEP_COMMISSION_FALLBACK;
|
||||
return Math.round(perSide * Math.abs(size || 0) * 100) / 100;
|
||||
}
|
||||
|
||||
/** How a contract reads on the dashboard: "CON.F.US.ENQ.Z26" → "NQZ6" (the
|
||||
* root where it's known, else the product code); anything else as it is. */
|
||||
export function topstepxSymbol(contractId: unknown, learned?: ReadonlyMap<string, string>): string {
|
||||
const parts = String(contractId ?? '').split('.');
|
||||
const code = topstepxProductCode(contractId);
|
||||
if (!code) return String(contractId ?? '');
|
||||
const expiry = /^([FGHJKMNQUVXZ])(\d{1,2})$/.exec(parts[4] ?? '');
|
||||
const root = topstepxRoot(contractId, learned) ?? code;
|
||||
return expiry ? `${root}${expiry[1]}${expiry[2].slice(-1)}` : root;
|
||||
}
|
||||
|
||||
+60
-2
@@ -27,9 +27,11 @@ import * as signalR from '@microsoft/signalr';
|
||||
import { currentTradeDateCT, FUND_EVENT_MIN, fundReconcileStep, type FundSighting } from './trading-logic';
|
||||
import { getCachedContract } from './contract-resolver';
|
||||
import { signalrWebSocket } from './outbound-proxy';
|
||||
import { saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions, saveEndedCycleStart, dropFundTransaction } from './db';
|
||||
import { saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions, saveEndedCycleStart, dropFundTransaction, recordOwnOrder } from './db';
|
||||
import { getAccountConfig } from './account-type-resolver';
|
||||
import { topstepxProductCode, topstepxRoot, topstepCommission, TOPSTEP_COMMISSION_FALLBACK } from './topstep-fees';
|
||||
import { topstepxProductCode, topstepxRoot, topstepxSymbol, topstepCommission, TOPSTEP_COMMISSION_FALLBACK } from './topstep-fees';
|
||||
import { recordFillTrades } from './trade-recorder';
|
||||
import type { Fill } from './fill-trades';
|
||||
import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth } from './platform-client';
|
||||
import { OrderNotPlacedError, isOrderNotPlaced, errorMessage } from './platform-client';
|
||||
|
||||
@@ -844,6 +846,44 @@ export class TopstepXClient implements PlatformClient {
|
||||
return (t.fees ?? 0) + topstepCommission(root, t.size);
|
||||
}
|
||||
|
||||
/** The trades the last two days' fills make, in the trades table the
|
||||
* dashboard lists (lib/trade-recorder.ts). Never fails the sync. */
|
||||
private recordTrades(acc: PlatformAccount, trades: TsxTrade[], open: { contractId: string; type: number; size: number }[]): void {
|
||||
try {
|
||||
const learned = this.learnedProductRoots();
|
||||
const byContract = new Map<string, Fill[]>();
|
||||
for (const p of open) byContract.set(p.contractId, []);
|
||||
for (const t of trades) {
|
||||
if (t.voided || !(t.size > 0)) continue;
|
||||
const at = Date.parse(t.creationTimestamp);
|
||||
if (!Number.isFinite(at)) continue;
|
||||
const fills = byContract.get(t.contractId) ?? [];
|
||||
fills.push({
|
||||
id: String(t.id),
|
||||
orderId: String(t.orderId),
|
||||
at,
|
||||
side: t.side === SIDE_BUY ? 'buy' : 'sell',
|
||||
qty: t.size,
|
||||
price: t.price,
|
||||
fee: this.tradeFees(t, learned),
|
||||
pnl: t.profitAndLoss,
|
||||
});
|
||||
byContract.set(t.contractId, fills);
|
||||
}
|
||||
recordFillTrades('tsx', acc, [...byContract].map(([contractId, fills]) => {
|
||||
const p = open.find((x) => x.contractId === contractId);
|
||||
return {
|
||||
symbol: topstepxSymbol(contractId, learned),
|
||||
pointValue: null,
|
||||
fills,
|
||||
currentNet: !p ? 0 : p.type === 1 ? p.size : -p.size,
|
||||
};
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error(`[topstepx] trade record failed for ${acc.name}: ${errorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Product code → root, off the contracts resolved this run
|
||||
* ("CON.F.US.GCE.Z26" resolved for GC → GCE is GC). */
|
||||
private learnedProductRoots(): Map<string, string> {
|
||||
@@ -900,12 +940,15 @@ export class TopstepXClient implements PlatformClient {
|
||||
for (const acc of this.accountList) {
|
||||
const balance = (accRes.accounts ?? []).find((a) => a.id === acc.id)?.balance ?? 0;
|
||||
let accountFresh = true;
|
||||
/** This pass's open positions, for the trades record. */
|
||||
let openNow: { contractId: string; type: number; size: number }[] | null = null;
|
||||
|
||||
try {
|
||||
const posRes = await this.post<{ positions: { accountId: number; contractId: string; type: number; size: number; averagePrice: number; creationTimestamp: string }[] }>(
|
||||
'/api/Position/searchOpen', { accountId: acc.id });
|
||||
if (superseded()) return;
|
||||
const open = (posRes.positions ?? []).filter((p) => p.size > 0);
|
||||
openNow = open;
|
||||
if (open.length > 0) {
|
||||
this.applyPosition(open[0]);
|
||||
} else {
|
||||
@@ -941,6 +984,7 @@ export class TopstepXClient implements PlatformClient {
|
||||
map.set(t.id, t);
|
||||
}
|
||||
this.todayTrades[acc.id] = map;
|
||||
if (openNow !== null) this.recordTrades(acc, tradeRes.trades ?? [], openNow);
|
||||
} catch (err) {
|
||||
accountFresh = false;
|
||||
console.error(`[topstepx] trade sync failed for ${acc.name}: ${errorMessage(err)}`);
|
||||
@@ -1602,9 +1646,11 @@ export class TopstepXClient implements PlatformClient {
|
||||
trailPrice: null,
|
||||
customTag: null,
|
||||
});
|
||||
this.noteOwnOrder(accountId, res.orderId, type);
|
||||
return res.orderId;
|
||||
} catch (err) {
|
||||
if (err instanceof GatewayRefusedError) {
|
||||
if (err.orderId !== undefined) this.noteOwnOrder(accountId, err.orderId, type);
|
||||
const ambiguous = err.orderId !== undefined
|
||||
|| (err.errorCode !== undefined && AMBIGUOUS_PLACE_ERRORS.has(err.errorCode));
|
||||
if (!ambiguous) throw new OrderNotPlacedError(`TopstepX refused the order: ${err.message}`);
|
||||
@@ -1614,6 +1660,7 @@ export class TopstepXClient implements PlatformClient {
|
||||
const status = response?.status;
|
||||
// Same rule as a refusal: an answer that names an order means it exists.
|
||||
const namesOrder = typeof response?.data?.orderId === 'number' && response.data.orderId > 0;
|
||||
if (namesOrder) this.noteOwnOrder(accountId, response?.data?.orderId, type);
|
||||
if (typeof status === 'number' && REFUSED_BEFORE_HANDLER.has(status) && !namesOrder) {
|
||||
throw new OrderNotPlacedError(`TopstepX rejected the order request: ${errorMessage(err)}`);
|
||||
}
|
||||
@@ -1621,6 +1668,17 @@ export class TopstepXClient implements PlatformClient {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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, type: number): void {
|
||||
if (typeof orderId !== 'number' || !(orderId > 0)) return;
|
||||
try {
|
||||
recordOwnOrder(accountId, orderId, type === TYPE_STOP ? 'stop' : type === TYPE_LIMIT ? 'limit' : 'market');
|
||||
} catch (err) {
|
||||
console.error(`[topstepx] could not note order ${orderId} as the bot's: ${errorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** See PlatformClient: an account whose last order's outcome is unknown
|
||||
* counts as holding a working order until a sync's Order/searchOpen shows
|
||||
* none. */
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { byFillTime, fillsToTrades, startingNet, type Fill, type CarriedTrade } from './fill-trades';
|
||||
import {
|
||||
loadOwnOrders, ownOrdersSince, loadFillTrades, saveFillTrades, deleteTrades, closeTradesUnseen,
|
||||
type FillTradeRow,
|
||||
} from './db';
|
||||
|
||||
/**
|
||||
* Records the trades a futures platform's fills make (lib/fill-trades.ts) in
|
||||
* the trades table the dashboard lists, with who opened and closed each: the
|
||||
* bot when the fill's order is one it placed (own_orders), and how — its
|
||||
* stop, its take-profit, or a market close.
|
||||
*
|
||||
* Each pass hands over a window of fills (TopstepX: two days; Tradovate: the
|
||||
* session's) with the account's position now, and the window's trades are
|
||||
* rebuilt whole. A history and a position read a moment apart can disagree —
|
||||
* a fill landing between the two calls — and that pass would rebuild the
|
||||
* wrong trades, so a contract is written only when the position before its
|
||||
* window comes out the same two passes running.
|
||||
*/
|
||||
|
||||
export interface ContractFills {
|
||||
/** As shown: "NQZ6". */
|
||||
symbol: string;
|
||||
/** Dollars per point per contract, for P&L the platform didn't give. */
|
||||
pointValue: number | null;
|
||||
fills: Fill[];
|
||||
/** Signed contracts held now. */
|
||||
currentNet: number;
|
||||
}
|
||||
|
||||
/** The position before each window, last pass: prefix:account:symbol → net. */
|
||||
const lastStart = new Map<string, number>();
|
||||
|
||||
export function recordFillTrades(prefix: string, account: { id: number; name: string }, contracts: ContractFills[]): void {
|
||||
let own: Map<string, string> | null = null;
|
||||
for (const c of contracts) {
|
||||
const fills = [...c.fills].sort(byFillTime);
|
||||
const startNet = startingNet(fills, c.currentNet);
|
||||
const key = `${prefix}:${account.id}:${c.symbol}`;
|
||||
const steady = lastStart.get(key) === startNet;
|
||||
lastStart.set(key, startNet);
|
||||
if (!steady) continue;
|
||||
|
||||
const stored = loadFillTrades(account.id, prefix, c.symbol);
|
||||
const isOpen = (t: FillTradeRow) => t.closedAt === null && t.closeReason === null;
|
||||
const windowStart = fills.length > 0 ? fills[0].at : Infinity;
|
||||
|
||||
if (fills.length === 0) {
|
||||
// Nothing in the window: an open trade stays open while the
|
||||
// position does, and is closed out of sight once it's gone.
|
||||
if (startNet === 0) closeTradesUnseen(stored.filter(isOpen).map((t) => t.positionId));
|
||||
continue;
|
||||
}
|
||||
|
||||
const carryRow = stored
|
||||
.filter((t) => isOpen(t) && t.openedAt < windowStart)
|
||||
.sort((a, b) => b.openedAt - a.openedAt)[0];
|
||||
const carry: CarriedTrade | null = carryRow
|
||||
? { id: carryRow.positionId, side: carryRow.side, quantity: carryRow.quantity, openPrice: carryRow.openPrice, fee: carryRow.fee, openedAt: carryRow.openedAt }
|
||||
: null;
|
||||
const trades = fillsToTrades(fills, { startNet, pointValue: c.pointValue, carry });
|
||||
|
||||
own ??= loadOwnOrders(account.id);
|
||||
const since = ownOrdersSince();
|
||||
const rows: FillTradeRow[] = trades.map((t) => {
|
||||
const closeKind = t.closeOrderId === null ? undefined : own!.get(t.closeOrderId);
|
||||
const closed = t.closedAt !== null;
|
||||
return {
|
||||
positionId: t.carried ? t.id : `${prefix}:${t.id}`,
|
||||
accountId: account.id,
|
||||
accountName: account.name,
|
||||
symbol: c.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: !closed ? null : closeKind === 'stop' ? 'stop_loss' : closeKind === 'limit' ? 'take_profit' : null,
|
||||
openedBy: t.carried ? null
|
||||
: t.openOrderId !== null && own!.has(t.openOrderId) ? 'bot'
|
||||
: t.openedAt >= since ? 'outside' : 'unknown',
|
||||
// A trade opened before own orders were noted had its exits
|
||||
// placed then too: whose close it was is unknown, not outside.
|
||||
closedBy: !closed || closeKind === 'stop' || closeKind === 'limit' ? null
|
||||
: closeKind === 'market' ? 'bot'
|
||||
: t.openedAt >= since ? 'outside' : 'unknown',
|
||||
};
|
||||
});
|
||||
saveFillTrades(rows);
|
||||
|
||||
const ids = new Set(rows.map((r) => r.positionId));
|
||||
// Built last time off a window that has since moved under it.
|
||||
deleteTrades(stored.filter((t) => t.openedAt >= windowStart && !ids.has(t.positionId)).map((t) => t.positionId));
|
||||
// Open from before the window, and the window doesn't carry it on:
|
||||
// it closed somewhere the history no longer reaches.
|
||||
closeTradesUnseen(stored.filter((t) => isOpen(t) && t.openedAt < windowStart && !ids.has(t.positionId)).map((t) => t.positionId));
|
||||
}
|
||||
}
|
||||
+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