Vest: record every trade, the bot's and outside ones, and list them

Vest's position history (GET /v3/positions) gives each position's size,
open and close price, P&L, fees, funding and why it closed. The client
reads it hourly and a minute after a position closes (or a flat balance
moves) into a new trades table, and stores open positions as they
appear. Vest can't tell an API order from a click, so the bot marks its
own opens and closes as it sends them; an unmarked trade since marking
began was someone else's.

The dashboard's Vest firm row gets a Trades button listing every trade
on its accounts with who opened it and how it closed; the account page
shows its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-10-26 19:23:24 -05:00
co-authored by Claude Opus 5.5
parent 29eaf64a6b
commit 95cc3bd23f
10 changed files with 603 additions and 4 deletions
+118
View File
@@ -577,6 +577,124 @@ export function wipeAccountData(accountId: number): void {
txn();
}
// ── Trades ───────────────────────────────────────────────────────────────────
//
// Every position a platform's history shows — the bot's and anyone else's
// (Vest: GET /v3/positions). The platform can't tell an API order from a
// click, so opened_by / closed_by record what this instance did itself:
// 'bot' when its own order opened or closed the position, NULL otherwise.
// A wipe leaves these alone: they're refetched, but the marks aren't.
db.exec(`
CREATE TABLE IF NOT EXISTS trades (
position_id TEXT PRIMARY KEY,
account_id INTEGER NOT NULL,
account_name TEXT NOT NULL,
symbol TEXT NOT NULL,
side TEXT NOT NULL,
quantity REAL NOT NULL,
leverage REAL,
open_price REAL,
close_price REAL,
pnl REAL,
fee REAL,
funding REAL,
opened_at INTEGER NOT NULL,
closed_at INTEGER,
close_reason TEXT,
opened_by TEXT,
closed_by TEXT,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS trades_account_opened ON trades (account_id, opened_at);
`);
/** settings key: when this instance began marking its own trades (epoch
* ms). A position opened before it and never marked is of unknown origin;
* one opened after it was opened outside the bot. */
export const TRADES_SINCE_SETTING = 'trades_tracked_since';
export interface TradeRow {
positionId: string;
accountId: number;
accountName: string;
symbol: string;
side: 'long' | 'short';
quantity: number;
leverage: number | null;
openPrice: number | null;
closePrice: number | null;
pnl: number | null;
fee: number | null;
funding: number | null;
openedAt: number;
closedAt: number | null;
closeReason: string | null;
openedBy: string | null;
closedBy: string | null;
}
/** The platform's view of positions: inserts new ones, refreshes known ones.
* A size it no longer reports (0) keeps the one already stored, a field it
* leaves out keeps its value, and the bot's marks are never touched. */
export function saveTrades(rows: Omit<TradeRow, 'openedBy' | 'closedBy'>[]): void {
const up = db.prepare(`
INSERT INTO trades (position_id, account_id, account_name, symbol, side, quantity, leverage, open_price, close_price,
pnl, fee, funding, opened_at, closed_at, close_reason, updated_at)
VALUES (@positionId, @accountId, @accountName, @symbol, @side, @quantity, @leverage, @openPrice, @closePrice,
@pnl, @fee, @funding, @openedAt, @closedAt, @closeReason, @updatedAt)
ON CONFLICT(position_id) DO UPDATE SET
account_name = excluded.account_name,
symbol = excluded.symbol,
side = excluded.side,
quantity = CASE WHEN excluded.quantity > 0 THEN excluded.quantity ELSE trades.quantity END,
leverage = COALESCE(excluded.leverage, trades.leverage),
open_price = COALESCE(excluded.open_price, trades.open_price),
close_price = COALESCE(excluded.close_price, trades.close_price),
pnl = COALESCE(excluded.pnl, trades.pnl),
fee = COALESCE(excluded.fee, trades.fee),
funding = COALESCE(excluded.funding, trades.funding),
opened_at = excluded.opened_at,
closed_at = COALESCE(excluded.closed_at, trades.closed_at),
close_reason = COALESCE(excluded.close_reason, trades.close_reason),
updated_at = excluded.updated_at
`);
const now = Date.now();
const txn = db.transaction(() => {
for (const r of rows) up.run({ ...r, updatedAt: now });
});
txn();
}
/** This instance's own order opened (or closed) the position — stored at
* once, before the platform's history shows it. */
export function markTrade(
by: 'opened' | 'closed',
t: { positionId: string; accountId: number; accountName: string; symbol: string; side: 'long' | 'short'; quantity: number; openPrice: number | null; openedAt: number },
): void {
const col = by === 'opened' ? 'opened_by' : 'closed_by';
db.prepare(`
INSERT INTO trades (position_id, account_id, account_name, symbol, side, quantity, open_price, opened_at, ${col}, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'bot', ?)
ON CONFLICT(position_id) DO UPDATE SET ${col} = 'bot', updated_at = excluded.updated_at
`).run(t.positionId, t.accountId, t.accountName, t.symbol, t.side, t.quantity, t.openPrice, t.openedAt, Date.now());
}
/** Newest first (by close, else open); every account when accountIds is
* undefined. */
export function loadTrades(o: { accountIds?: number[]; limit: number }): TradeRow[] {
if (o.accountIds && o.accountIds.length === 0) return [];
const where = o.accountIds ? `WHERE account_id IN (${o.accountIds.map(() => '?').join(', ')})` : '';
return db.prepare(`
SELECT position_id AS positionId, account_id AS accountId, account_name AS accountName, symbol, side, quantity, leverage,
open_price AS openPrice, close_price AS closePrice, pnl, fee, funding, opened_at AS openedAt, closed_at AS closedAt,
close_reason AS closeReason, opened_by AS openedBy, closed_by AS closedBy
FROM trades ${where}
ORDER BY COALESCE(closed_at, opened_at) DESC
LIMIT ?
`).all(...(o.accountIds ?? []), o.limit) as TradeRow[];
}
// ── AutoBuyer captures ───────────────────────────────────────────────────────
db.exec(`