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
+111 -3
View File
@@ -42,7 +42,7 @@ import { currentTradeDateCT, rankForTarget, dllReserve } from './trading-logic';
import { sizingHoldHours } from './person-size';
import {
saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions,
saveEndedCycleStart,
saveEndedCycleStart, saveTrades, markTrade, getOrCreateSetting, TRADES_SINCE_SETTING,
} from './db';
import { getCachedFirm } from './firm-catalog';
import { firmInstruments } from './instruments';
@@ -53,9 +53,9 @@ import { vestSessionFor, dropVestSession, listVestSessions } from './vest-sessio
import {
vestAccountId, vestAccountName, vestDayOf, lastVestResetMs, nextVestResetMs, parseCapitalAccount, parsePosition, parseOpenOrder,
parseTxn, bookTxns, awaitingLegs, unrealizedPnl, hourlyVolatility, marketOpen, num, toMs, formatDecimal, resetSafeStop,
VEST_STATUS_ACTIVE, VEST_TXN,
parseHistoryPosition, VEST_STATUS_ACTIVE, VEST_TXN,
} from './vest-helpers';
import type { VestCapitalAccount, VestPosition, VestOpenOrder, VestTxn } from './vest-helpers';
import type { VestCapitalAccount, VestPosition, VestOpenOrder, VestTxn, VestTrade } from './vest-helpers';
const BASE = 'https://api-gateway.hz.vestmarkets.com';
/** The web app's origin: the gateway serves it, so requests say they are its. */
@@ -87,6 +87,9 @@ const CANDLE_HOURS = 7 * 24;
/** Transactions per page, and the most pages one account's history reads. */
const TXN_PAGE = 100;
const TXN_MAX_PAGES = 200;
/** Position history (GET /v3/positions) pages, for the trades table. */
const TRADES_PAGE = 50;
const TRADES_MAX_PAGES = 20;
/** Balance the books can't explain by this much holds the account. */
const BOOKS_TOLERANCE = 200;
const SUSPECT_SETTLE_MS = 90_000;
@@ -208,6 +211,13 @@ export class VestClient implements PlatformClient {
private historyLoadedIds = new Set<number>();
private booksHeld = new Set<number>();
private fetchingIds = new Set<number>();
/** Accounts whose position history to read again soon: a position
* closed, or a flat balance moved (a round trip between two syncs). */
private tradesDue = new Set<number>();
private tradesFetchInFlight = false;
/** Open positions already in the trades table, by id → size stored. */
private recordedOpen = new Map<string, number>();
private flatAmounts = new Map<number, number>();
private gateNext: Record<Bucket, number> = { read: 0, trade: 0, history: 0 };
private suspectAccounts = new Map<number, number>();
@@ -232,6 +242,7 @@ export class VestClient implements PlatformClient {
this.cfg = cfg;
this.wallet = cfg.wallet.trim().toLowerCase();
this.onSync = onSync;
getOrCreateSetting(TRADES_SINCE_SETTING, () => String(Date.now()));
this.syncInterval = setInterval(() => void this.requestSync(), SYNC_INTERVAL_MS);
this.watchdogInterval = setInterval(() => this.watchdog(), WATCHDOG_INTERVAL_MS);
void this.requestSync();
@@ -534,6 +545,14 @@ export class VestClient implements PlatformClient {
const amount = free.get(a.id);
if (amount === undefined) { complete = false; continue; }
const balance = amount + mine.reduce((s, p) => s + p.collateral, 0);
this.recordOpenTrades(acc, mine);
if (mine.length === 0) {
const was = this.flatAmounts.get(id);
if (was !== undefined && Math.abs(was - amount) >= 0.01) this.tradesDue.add(id);
this.flatAmounts.set(id, amount);
} else {
this.flatAmounts.delete(id);
}
const upnl = mine.reduce((s, p) => s + unrealizedPnl(p, this.marks.get(p.symbol)?.mark ?? NaN), 0);
this.accountCashBalances[id] = {
amount: round2(balance),
@@ -553,6 +572,7 @@ export class VestClient implements PlatformClient {
console.log(`${this.tag} sync — ${list.length} account(s), ${Object.keys(this.positions).length} open position(s)`);
this.maybeFetchDaysTraded();
this.maybeRefreshTrades();
if (this.historyLoaded && (this.lastSurveyTradeDate !== currentTradeDateCT() || Date.now() - this.lastSurveyAt >= SURVEY_INTERVAL_MS)) {
this.lastSurveyTradeDate = currentTradeDateCT();
this.lastSurveyAt = Date.now();
@@ -591,6 +611,8 @@ export class VestClient implements PlatformClient {
if (seen) {
console.log(`${this.tag} ${acc.name}: position closed after ${((Date.now() - seen.at) / 3_600_000).toFixed(2)}h`);
this.seenOpenAt.delete(acc.id);
this.recordedOpen.delete(seen.positionId);
this.tradesDue.add(acc.id);
}
delete this.positions[acc.id];
return;
@@ -771,6 +793,7 @@ export class VestClient implements PlatformClient {
try {
if (decision.action === 'close') {
await this.submit(uuid, '/v3/positions/close', { positionId: pos.positionId, symbol: pos.symbol, orderType: 'market' });
this.markOwn('closed', acc.id, pos);
console.warn(`${this.tag} ${acc.name}: closed ${pos.symbol} before the 8 PM ET reset — no room for it past the reset`);
} else {
const triggerPrice = formatDecimal(decision.price, spec.priceDecimals);
@@ -882,6 +905,11 @@ export class VestClient implements PlatformClient {
failed++;
this.fallBackToCache(account, errorMessage(err));
}
try {
await this.fetchTrades(account);
} catch (err) {
console.warn(`${this.tag} ${account.name}: position history read failed: ${errorMessage(err)}`);
}
}
} finally {
for (const id of ids) this.fetchingIds.delete(id);
@@ -907,6 +935,84 @@ export class VestClient implements PlatformClient {
return [...all.values()].sort((a, b) => a.time - b.time);
}
/** The account's position history — GET /v3/positions, the app's History
* tab — into the trades table: every position, whoever opened it, with
* how it closed. */
private async fetchTrades(account: PlatformAccount): Promise<void> {
const uuid = this.uuidById.get(account.id);
if (!uuid) return;
const trades: VestTrade[] = [];
for (let page = 0; page < TRADES_MAX_PAGES; page++) {
const { data } = await this.request<{ positions?: unknown[] | null; total?: unknown }>('history', 'GET', '/v3/positions', {
account: uuid, params: { account_id: uuid, limit: TRADES_PAGE, offset: page * TRADES_PAGE },
});
const rows = data?.positions ?? [];
for (const r of rows) {
const t = parseHistoryPosition(r);
if (t) trades.push(t);
}
const total = num(data?.total);
if (rows.length < TRADES_PAGE || (total !== undefined && (page + 1) * TRADES_PAGE >= total)) break;
}
if (trades.length > 0) saveTrades(trades.map((t) => ({ ...t, accountId: account.id, accountName: account.name })));
}
/** Reads the history of the accounts tradesDue names, one pass at a time.
* A failed read waits for the hourly history pass. */
private maybeRefreshTrades(): void {
if (this.tradesFetchInFlight || this.tradesDue.size === 0 || this.intentionalDisconnect) return;
const ids = [...this.tradesDue];
this.tradesDue.clear();
this.tradesFetchInFlight = true;
void (async () => {
for (const id of ids) {
const account = this.accountList.find((a) => a.id === id);
if (!account || this.intentionalDisconnect) continue;
try {
await this.fetchTrades(account);
} catch (err) {
console.warn(`${this.tag} ${account.name}: position history read failed: ${errorMessage(err)}`);
}
}
})().finally(() => {
this.tradesFetchInFlight = false;
});
}
/** Open positions into the trades table as they appear (or change size),
* so a trade shows before it closes. */
private recordOpenTrades(acc: PlatformAccount, list: VestPosition[]): void {
const fresh = list.filter((p) => this.recordedOpen.get(p.positionId) !== p.quantity);
if (fresh.length === 0) return;
try {
saveTrades(fresh.map((p) => ({
positionId: p.positionId, accountId: acc.id, accountName: acc.name, symbol: p.symbol, side: p.side, quantity: p.quantity,
leverage: p.leverage > 0 ? p.leverage : null, openPrice: Number.isFinite(p.openPrice) ? p.openPrice : null, closePrice: null,
pnl: null, fee: null, funding: null, openedAt: Number.isFinite(p.openedAt) ? p.openedAt : Date.now(), closedAt: null, closeReason: null,
})));
for (const p of fresh) this.recordedOpen.set(p.positionId, p.quantity);
} catch (err) {
console.warn(`${this.tag} ${acc.name}: couldn't record the open position: ${errorMessage(err)}`);
}
}
/** This client's own order opened or closed the position: say so in the
* trades table (Vest shows an API close as "self-requested", same as a
* click). */
private markOwn(by: 'opened' | 'closed', accountId: number, pos: VestPosition): void {
if (!pos.positionId) return;
try {
markTrade(by, {
positionId: pos.positionId, accountId, accountName: this.accountList.find((a) => a.id === accountId)?.name ?? String(accountId),
symbol: pos.symbol, side: pos.side, quantity: pos.quantity, openPrice: Number.isFinite(pos.openPrice) ? pos.openPrice : null,
openedAt: Number.isFinite(pos.openedAt) ? pos.openedAt : Date.now(),
});
} catch (err) {
console.warn(`${this.tag} couldn't mark ${pos.positionId} as the bot's: ${errorMessage(err)}`);
}
if (by === 'closed') this.tradesDue.add(accountId);
}
private bookHistory(account: PlatformAccount, txns: VestTxn[]): void {
const a = this.capital.get(account.id);
const { daily, funds } = bookTxns(txns);
@@ -1306,9 +1412,11 @@ export class VestClient implements PlatformClient {
this.positions[accountId] = {
contractId: symbol, netPos: side === 'long' ? pos.quantity : -pos.quantity, netPrice: pos.openPrice, timestamp: new Date(),
};
this.markOwn('opened', accountId, pos);
} else if (unitsOrdered >= existing.quantity - step / 2) {
this.positionsDetail.set(accountId, []);
delete this.positions[accountId];
this.markOwn('closed', accountId, existing);
}
const result = {
orderId: orderNumber, vestOrderId: placed.orderId, price: fill.price, qty: contracts, units: unitsOrdered, accountId, contractId: symbol,