diff --git a/lib/db.ts b/lib/db.ts index 63976f7..95f605d 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -682,6 +682,12 @@ 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. */ +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') + .all() as { accountId: number; accountName: string }[]; +} + /** Newest first (by close, else open); every account when accountIds is * undefined. */ export function loadTrades(o: { accountIds?: number[]; limit: number }): TradeRow[] { diff --git a/lib/vest-class.ts b/lib/vest-class.ts index a05c9c4..2e58ff2 100644 --- a/lib/vest-class.ts +++ b/lib/vest-class.ts @@ -42,7 +42,7 @@ import { currentTradeDateCT, rankForTarget, dllReserve } from './trading-logic'; import { sizingHoldHours } from './person-size'; import { saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions, - saveEndedCycleStart, saveTrades, markTrade, getOrCreateSetting, TRADES_SINCE_SETTING, + saveEndedCycleStart, saveTrades, markTrade, getOrCreateSetting, TRADES_SINCE_SETTING, openTradeAccounts, } from './db'; import { getCachedFirm } from './firm-catalog'; import { firmInstruments } from './instruments'; @@ -90,6 +90,10 @@ const TXN_MAX_PAGES = 200; /** Position history (GET /v3/positions) pages, for the trades table. */ const TRADES_PAGE = 50; const TRADES_MAX_PAGES = 20; +/** Closed-account history (GET /v3/capital/accounts/history) pages: the + * app's own size, and how far back to look for one. */ +const CLOSED_PAGE = 8; +const CLOSED_MAX_PAGES = 30; /** Balance the books can't explain by this much holds the account. */ const BOOKS_TOLERANCE = 200; const SUSPECT_SETTLE_MS = 90_000; @@ -218,6 +222,10 @@ export class VestClient implements PlatformClient { /** Open positions already in the trades table, by id → size stored. */ private recordedOpen = new Map(); private flatAmounts = new Map(); + /** Accounts off Vest's active list whose leftover open trades were + * read once more this run (closeOutDropped). */ + private closeOutTried = new Set(); + private closeOutInFlight = false; /** When a relayed token last turned out to be one account's own. */ private scopedRefusedAt = 0; @@ -579,7 +587,9 @@ export class VestClient implements PlatformClient { this.lastFetchRaw[`floors:${acc.name}`] = `dd ${a.maxDrawdownLimit ?? '?'}, daily ${this.dailyFloorOf(a, balance, now) ?? 'none'}` + `${a.targetEquity ? `, target ${a.targetEquity}` : ''}`; } + const shrank = this.accountList.some((a) => !list.some((b) => b.id === a.id)); this.accountList = list; + if (shrank) void this.closeOutDropped(); // An account gone from Vest's list (breached, closed) takes its // position with it — a stale one here would hold the lane. for (const key of Object.keys(this.positions)) { @@ -940,9 +950,59 @@ export class VestClient implements PlatformClient { for (const id of ids) this.fetchingIds.delete(id); this.fetchDaysComplete = true; } + if (!onlyAccountIds) await this.closeOutDropped(); return { failedAccounts: failed }; } + /** + * Trades the table still shows open on accounts Vest no longer lists + * (breached, closed): read those accounts' position history once more, + * so they close out with how they ended. Their ids come from memory, or + * — after a restart — from Vest's closed-account history. Once per + * account per run; a failure is logged and left. + */ + private async closeOutDropped(): Promise { + if (this.closeOutInFlight || this.intentionalDisconnect || !this.userToken()) return; + const listed = new Set(this.accountList.map((a) => a.id)); + const stale = openTradeAccounts().filter((a) => !listed.has(a.accountId) && !this.closeOutTried.has(a.accountId)); + if (stale.length === 0) return; + this.closeOutInFlight = true; + try { + let cursor: string | null = null; + for (let page = 0; page < CLOSED_MAX_PAGES && stale.some((a) => !this.uuidById.has(a.accountId)); page++) { + const { data } = await this.request<{ accounts?: unknown[] | null; next?: unknown }>('history', 'GET', '/v3/capital/accounts/history', { + params: { limit: CLOSED_PAGE, ...(cursor ? { cursor } : {}) }, + }); + for (const raw of data?.accounts ?? []) { + const a = parseCapitalAccount(raw); + if (a && !this.uuidById.has(vestAccountId(a.id))) this.uuidById.set(vestAccountId(a.id), a.id); + } + cursor = typeof data?.next === 'string' && data.next ? data.next : null; + if (!cursor) break; + } + for (const a of stale) { + this.closeOutTried.add(a.accountId); + if (!this.uuidById.has(a.accountId)) { + console.warn(`${this.tag} ${a.accountName}: trades left open, but Vest lists no such account, active or closed — left as they are`); + continue; + } + try { + const acc = { id: a.accountId, name: a.accountName, active: false }; + // A closed account may get no token of its own: the + // login's reads it as the app's history tab does. + await this.fetchTrades(acc).catch(() => this.fetchTrades(acc, true)); + console.log(`${this.tag} ${a.accountName}: off Vest's active list — read its position history one last time`); + } catch (err) { + console.warn(`${this.tag} ${a.accountName}: off Vest's active list, and its position history read failed: ${errorMessage(err)}`); + } + } + } catch (err) { + console.warn(`${this.tag} closed-account history read failed: ${errorMessage(err)}`); + } finally { + this.closeOutInFlight = false; + } + } + /** Every transaction on the account, newest page first, deduplicated. */ private async fetchTxns(uuid: string): Promise { const all = new Map(); @@ -963,13 +1023,13 @@ export class VestClient implements PlatformClient { /** 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 { + private async fetchTrades(account: PlatformAccount, viaLogin = false): Promise { 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 }, + ...(viaLogin ? {} : { account: uuid }), params: { account_id: uuid, limit: TRADES_PAGE, offset: page * TRADES_PAGE }, }); const rows = data?.positions ?? []; for (const r of rows) {