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
+106 -2
View File
@@ -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[] {