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:
co-authored by
Claude Opus 5.5
parent
29eaf64a6b
commit
95cc3bd23f
@@ -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(`
|
||||
|
||||
+111
-3
@@ -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,
|
||||
|
||||
@@ -283,6 +283,57 @@ export function parsePosition(raw: unknown): VestPosition | null {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One position from GET /v3/positions — Vest's position history, the app's
|
||||
* History tab: closed positions with how they ended (an open one has no
|
||||
* close yet). pnl is Vest's realized P&L before fees and funding.
|
||||
*/
|
||||
export interface VestTrade {
|
||||
positionId: string;
|
||||
symbol: string;
|
||||
side: 'long' | 'short';
|
||||
/** 0 when Vest doesn't say (a store keeps the size it already knows). */
|
||||
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;
|
||||
/** user_requested, stop_loss, take_profit, liquidation, auto_deleverage,
|
||||
* temporary_leverage_limit, corporate_action. */
|
||||
closeReason: string | null;
|
||||
}
|
||||
|
||||
export function parseHistoryPosition(raw: unknown): VestTrade | null {
|
||||
const p = raw as Record<string, unknown>;
|
||||
const id = p?.positionId ?? p?.position_id;
|
||||
if (!p || typeof id !== 'string' || typeof p.symbol !== 'string') return null;
|
||||
const openedAt = toMs(p.openDate ?? p.open_date ?? p.createdAt ?? p.created_at);
|
||||
if (!Number.isFinite(openedAt)) return null;
|
||||
const closedAt = toMs(p.closeDate ?? p.close_date);
|
||||
const openPrice = num(p.openPrice ?? p.open_price);
|
||||
const closePrice = num(p.closePrice ?? p.close_price);
|
||||
const reason = p.closeReason ?? p.close_reason;
|
||||
return {
|
||||
positionId: id,
|
||||
symbol: p.symbol,
|
||||
side: String(p.side).toLowerCase() === 'short' ? 'short' : 'long',
|
||||
quantity: Math.abs(num(p.quantity) ?? 0),
|
||||
leverage: num(p.leverage) ?? null,
|
||||
openPrice: openPrice !== undefined && openPrice > 0 ? openPrice : null,
|
||||
closePrice: closePrice !== undefined && closePrice > 0 ? closePrice : null,
|
||||
pnl: num(p.pnl) ?? null,
|
||||
fee: num(p.fee) ?? null,
|
||||
funding: num(p.funding) ?? null,
|
||||
openedAt,
|
||||
closedAt: Number.isFinite(closedAt) && closedAt > 0 ? closedAt : null,
|
||||
closeReason: typeof reason === 'string' && reason !== '' ? reason : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Exit legs still waiting to trigger. */
|
||||
export function awaitingLegs(p: VestPosition): VestLeg[] {
|
||||
return [...p.stopLosses, ...p.takeProfits].filter((l) => l.status === 'awaiting');
|
||||
|
||||
Reference in New Issue
Block a user