/** * Vest client — Vest Markets' perps prop program (next.vestmarkets.com): US * index, metal and energy perps on CME hours, stocks, FX and crypto. * * REST only: https://api-gateway.hz.vestmarkets.com. Mapped from the Vest web * app's own traffic and code (2026-10-26); there is no published API. * * Login is not this file's: Vest signs people in through Google/Privy in the * browser and binds the session to a key the browser won't export. The * extension relays the signed-in Vest tab's ~15-minute access token to this * machine (lib/vest-session.ts), keyed by the login's wallet — the firm's * saved credential here. With no live token for that wallet the client sits * unauthenticated and says so; it never tries to log in. * * - The user token reads everything account-wide: the capital accounts (each * eval or funded account with its floors), balances, open positions and * open orders. Orders, transactions and exits need a per-account token, * minted from the user token (POST /v3/auth/account-token) and cached until * it nears expiry. * - Account ids are uuids; ids are a stable hash of them (vestAccountId) so * every numeric account table keeps working. Names carry the account's rules * (vestAccountName) for the account-type prefixes to match. * - A balance is the free `amount` plus the collateral held by open positions * — what Vest's daily reset and the DLL docs call the balance. Equity adds * unrealized P&L at the mark. * - Vest states each account's floors: max_drawdown_limit is the auto-liq * threshold; the daily-loss floor, held against the account type's own * limit, gives dayStartBalance — so the engine's room never exceeds Vest's. * - No CME products: the firm's instrument list (master Firms page) names the * Vest symbols it trades, and findFrontMonthContract picks among them as * DXtrade does. One engine "contract" is one size step of the symbol, and the * step rides in the contract name. * - Exits are legs of the position (take-profit, stop-loss) that Vest itself * cancels when the other triggers or the position closes: a native OCO, with * nothing for this client to babysit. * - Fees are each symbol's taker fee from exchangeInfo, a percent of notional. */ import axios from './http-client'; import { currentTradeDateCT, rankForTarget } from './trading-logic'; import { sizingHoldHours } from './person-size'; import { saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions, saveEndedCycleStart, } from './db'; import { getAccountConfig } from './account-type-resolver'; import { getCachedFirm } from './firm-catalog'; import { firmInstruments } from './instruments'; import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth, PickContext } from './platform-client'; import { OrderNotPlacedError, isOrderNotPlaced, errorMessage } from './platform-client'; import { encodeContractName, decodeContractName, roundToStep, scoreCandidate } from './dxtrade-helpers'; import { vestSessionFor, dropVestSession } from './vest-session'; import { vestAccountId, vestAccountName, vestDayOf, lastVestResetMs, parseCapitalAccount, parsePosition, parseOpenOrder, parseTxn, bookTxns, awaitingLegs, unrealizedPnl, hourlyVolatility, marketOpen, num, toMs, formatDecimal, VEST_STATUS_ACTIVE, } from './vest-helpers'; import type { VestCapitalAccount, VestPosition, VestOpenOrder, VestTxn } 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. */ const APP_ORIGIN = 'https://next.vestmarkets.com'; // ── timings ────────────────────────────────────────────────────────────────── const SYNC_INTERVAL_MS = 60_000; const SYNC_WEDGED_MS = 3 * 60_000; const WATCHDOG_INTERVAL_MS = 15_000; /** Request spacing: the gateway's limits aren't published, so stay well under * what its own app does. */ const SPACING_MS = { read: 150, trade: 100, history: 400 } as const; type Bucket = keyof typeof SPACING_MS; const TIMEOUT_MS = { read: 20_000, trade: 15_000, history: 30_000 } as const; /** A cached account token is renewed this long before it expires. */ const ACCOUNT_TOKEN_SKEW_MS = 90_000; /** The relayed user token must have this long left to start a request. */ const USER_TOKEN_MIN_MS = 20_000; const FILL_POLL_MS = 1_000; const FILL_TIMEOUT_MS = 30_000; const PICK_TTL_MS = 45_000; const SURVEY_INTERVAL_MS = 30 * 60_000; const EXCHANGE_INFO_TTL_MS = 60 * 60_000; const MARKET_HOURS_TTL_MS = 15 * 60_000; const CANDLE_RETRY_MS = 15 * 60_000; /** Volatility lookback: a week of hourly candles, as on DXtrade. */ 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; /** Balance the books can't explain by this much holds the account. */ const BOOKS_TOLERANCE = 200; const SUSPECT_SETTLE_MS = 90_000; /** A position this old with no awaiting exit leg is flagged as unprotected. */ const NAKED_AFTER_MS = 2 * 60_000; /** Vest's own spread isn't on its REST surface (only the depth socket): a * candidate is costed as crossing this many ticks. */ const ASSUMED_SPREAD_TICKS = 1; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const round2 = (n: number) => Math.round(n * 100) / 100; /** The gateway answered with an error status. */ class VestApiError extends Error { constructor(message: string, readonly status: number, readonly code: string | undefined) { super(message); this.name = 'VestApiError'; } } interface SymbolSpec { symbol: string; assetId: number; sizeDecimals: number; priceDecimals: number; tick: number; takerFee: number; /** Initial margin per dollar of notional on a capital (prop) account. */ marginRate: number; tradingStatus: string; capitalTradingStatus: string; } export interface VestConfig { firmId: string; firmName: string; /** The Vest login's primary wallet — whose relayed session this client * trades (the firm's saved credential on this machine). */ wallet: string; } export class VestClient implements PlatformClient { private readonly cfg: VestConfig; private readonly wallet: string; public accountList: PlatformAccount[] = []; public accountCashBalances: { [accountId: number]: { amount: number; realizedPnL: number; equity?: number; availableFunds?: number } } = {}; public positions: { [accountId: number]: { contractId: string; netPos: number; netPrice: number; timestamp: Date }; } = {}; public daysTraded: { [accountId: number]: number } = {}; public dailyPnL: { [accountId: number]: { date: string; pnl: number }[] } = {}; public fullDailyPnL: { [accountId: number]: { date: string; pnl: number }[] } = {}; public lastFundDates: { [accountId: number]: string | null } = {}; public fundTransactions: { [accountId: number]: { date: string; amount: number }[] } = {}; public priorProfit: { [accountId: number]: number } = {}; public autoLiqThresholds: { [accountId: number]: number } = {}; public fetchDaysComplete = false; public syncComplete = false; public lastSyncAt = 0; public lastSyncStartedAt = 0; public lastFetchErrors: Record = {}; public lastFetchRaw: Record = {}; public recentEntityEvents: { entityType: string; eventType: string; entity: unknown; ts: number }[] = []; /** Tradovate-only concept. */ public products: { id: number; name: string }[] = []; /** Every listed symbol that could trade at the last full look — what the * dashboard ranks each account's next leg across. */ public lastCandidates: PlatformContract[] = []; private readonly onSync: () => Promise; private uuidById = new Map(); private capital = new Map(); private positionsDetail = new Map(); private openOrders = new Map(); private seenOpenAt = new Map(); private accountTokens = new Map(); private accountTokenFlights = new Map>(); private syncInterval: ReturnType | null = null; private watchdogInterval: ReturnType | null = null; private intentionalDisconnect = false; private lastResponseAt = 0; private lastAuthAt = 0; private lastAuthError: string | null = null; private consecutiveAuthFailures = 0; private syncInFlight = false; private syncInFlightSince = 0; private syncGeneration = 0; private syncRunning: Promise = Promise.resolve(); private daysFetchInFlight = false; private daysFetchInFlightSince = 0; private lastDaysFetchStartedAt = 0; private lastDaysFetchSucceededAt = 0; private nextDaysFetchRetryAt = 0; private historyLoaded = false; private historyLoadedIds = new Set(); private booksHeld = new Set(); private fetchingIds = new Set(); private gateNext: Record = { read: 0, trade: 0, history: 0 }; private suspectAccounts = new Map(); /** Opens in flight per symbol, for the hedge guard. */ private openingSides = new Map(); private fillWaiters: { orderId: number; callback: (fill: Record) => void }[] = []; private pendingFills = 0; private nextOrderNumber = 1; private specs = new Map(); private specsAt = 0; private marks = new Map(); private hoursCache = new Map(); private candleCache = new Map(); private pickCache = new Map(); private lastSurveyTradeDate = ''; private lastSurveyAt = 0; private unlistedLoggedAt = 0; private unknownStatusLogged = new Set(); constructor(cfg: VestConfig, onSync: () => Promise) { this.cfg = cfg; this.wallet = cfg.wallet.trim().toLowerCase(); this.onSync = onSync; this.syncInterval = setInterval(() => void this.requestSync(), SYNC_INTERVAL_MS); this.watchdogInterval = setInterval(() => this.watchdog(), WATCHDOG_INTERVAL_MS); void this.requestSync(); } private get tag(): string { return `[vest] ${this.cfg.firmName}:`; } // ── session ────────────────────────────────────────────────────────────── /** The relayed user token, or null with the reason noted. */ private userToken(): string | null { const session = vestSessionFor(this.wallet, Date.now(), USER_TOKEN_MIN_MS); if (session) return session.accessToken; const msg = `no Vest session for ${this.wallet} — keep a Vest tab signed in to that wallet open in Chrome with the AutoFirmer extension`; if (this.lastFetchErrors['login'] !== msg) console.warn(`${this.tag} ${msg}`); this.lastAuthError = this.lastFetchErrors['login'] = msg; return null; } /** The extension relayed a session (POST /api/vest/session): a client * that was waiting on its wallet syncs now. */ public onVestSession(wallet: string): void { if (wallet !== this.wallet || this.intentionalDisconnect) return; if (this.lastSyncAt === 0 || Date.now() - this.lastSyncAt > SYNC_INTERVAL_MS) void this.requestSync(); } /** A token for one trading account, minted from the user token and cached. */ private async accountToken(uuid: string): Promise { const cached = this.accountTokens.get(uuid); if (cached && cached.expiresAt - Date.now() > ACCOUNT_TOKEN_SKEW_MS) return cached.token; const flight = this.accountTokenFlights.get(uuid); if (flight) return flight; const p = (async () => { const { data } = await this.request<{ accessToken?: unknown; accessExpiresAtMs?: unknown }>('read', 'POST', '/v3/auth/account-token', { body: { accountId: uuid }, }); const token = typeof data?.accessToken === 'string' ? data.accessToken : ''; const expiresAt = num(data?.accessExpiresAtMs) ?? Date.now() + 10 * 60_000; if (!token) throw new Error('/v3/auth/account-token answered without a token'); this.accountTokens.set(uuid, { token, expiresAt }); return token; })().finally(() => this.accountTokenFlights.delete(uuid)); this.accountTokenFlights.set(uuid, p); return p; } // ── transport ──────────────────────────────────────────────────────────── private async gate(bucket: Bucket): Promise { const now = Date.now(); const at = Math.max(now, this.gateNext[bucket]); this.gateNext[bucket] = at + SPACING_MS[bucket]; if (at > now) await sleep(at - now); } /** * One REST call: with the user token, an account's token (`account`), or * none (`auth: false`, public data). Non-2xx answers become VestApiError * with the gateway's own code; transport failures a plain Error carrying * only the message — never the axios error, whose config holds the token. * A refused account token is re-minted once; a refused user token is * dropped, so nothing keeps knocking with it until a fresh one arrives. */ private async request( bucket: Bucket, method: 'GET' | 'POST' | 'DELETE', path: string, opts: { params?: Record; body?: unknown; account?: string; auth?: boolean; retried?: boolean } = {}, ): Promise<{ data: T }> { let token: string | null = null; if (opts.auth !== false) { token = opts.account ? await this.accountToken(opts.account) : this.userToken(); if (!token) throw new VestApiError(`${path}: no Vest session`, 401, 'NO_SESSION'); } await this.gate(bucket); let res; try { res = await axios.request({ method, url: `${BASE}${path}`, params: opts.params, data: opts.body, headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(opts.body !== undefined ? { 'Content-Type': 'application/json' } : {}), Accept: 'application/json', Origin: APP_ORIGIN, Referer: `${APP_ORIGIN}/`, }, timeout: TIMEOUT_MS[bucket], signal: AbortSignal.timeout(TIMEOUT_MS[bucket] + 5_000), validateStatus: () => true, }); } catch (err) { if (axios.isCancel(err)) throw new Error(`${path} timed out after ${TIMEOUT_MS[bucket] / 1000}s`); throw new Error(`${path}: ${errorMessage(err)}`); } this.lastResponseAt = Date.now(); if (res.status >= 200 && res.status < 300) return { data: res.data as T }; const body = (res.data && typeof res.data === 'object' ? res.data : {}) as { code?: unknown; msg?: unknown; message?: unknown; detail?: { code?: unknown; msg?: unknown } }; const code = body.detail?.code ?? body.code; const msg = body.detail?.msg ?? body.msg ?? body.message; const error = new VestApiError( `${path}: ${typeof msg === 'string' ? msg : `HTTP ${res.status}`} (HTTP ${res.status}${code !== undefined ? `, code ${String(code)}` : ''})`, res.status, code !== undefined ? String(code) : undefined, ); if (res.status === 401 && token) { if (opts.account) { this.accountTokens.delete(opts.account); if (!opts.retried) return this.request(bucket, method, path, { ...opts, retried: true }); } else { // The user token itself was refused: forget it, and wait for the // tab's next renewal rather than retrying it. dropVestSession(this.wallet, token); this.accountTokens.clear(); this.consecutiveAuthFailures++; this.lastAuthError = this.lastFetchErrors['login'] = `Vest refused the relayed session (${error.message}) — waiting for the Vest tab to renew it`; } } if (!opts.retried && res.status === 429) { await sleep(2_000); return this.request(bucket, method, path, { ...opts, retried: true }); } throw error; } /** No sync when there is no session to sync with, and a sync that hung * is started over. */ private watchdog(): void { if (this.intentionalDisconnect) return; const now = Date.now(); if (this.syncInFlight && now - this.syncInFlightSince >= SYNC_WEDGED_MS) void this.requestSync(); } // ── public market data ─────────────────────────────────────────────────── private async symbolSpecs(): Promise> { if (this.specs.size > 0 && Date.now() - this.specsAt < EXCHANGE_INFO_TTL_MS) return this.specs; const { data } = await this.request<{ symbols?: Record[] }>('read', 'GET', '/v3/exchangeInfo', { auth: false }); const next = new Map(); for (const s of data?.symbols ?? []) { if (typeof s.symbol !== 'string') continue; const capitalIm = num(s.capitalInitMarginRatio) ?? num(s.fundedInitMarginRatio) ?? num(s.initMarginRatio); next.set(s.symbol, { symbol: s.symbol, assetId: num(s.assetId) ?? -1, sizeDecimals: num(s.sizeDecimals) ?? 0, priceDecimals: num(s.priceDecimals) ?? 2, tick: num(s.minTickSize) ?? num(s.defaultTickSize) ?? 0, takerFee: num(s.takerFee) ?? 0, marginRate: capitalIm && capitalIm > 0 ? capitalIm : 0, tradingStatus: String(s.tradingStatus ?? ''), capitalTradingStatus: String(s.capitalTradingStatus ?? s.tradingStatus ?? ''), }); } if (next.size > 0) { this.specs = next; this.specsAt = Date.now(); } return this.specs; } /** Mark prices for the symbols given (every listed one when none given). */ private async refreshMarks(symbols?: string[]): Promise { const params = symbols && symbols.length > 0 ? { symbols: symbols.join(',') } : undefined; const { data } = await this.request<{ tickers?: Record[] }>('read', 'GET', '/v3/ticker/latest', { auth: false, params }); const now = Date.now(); for (const t of data?.tickers ?? []) { const mark = num(t.markPrice); if (typeof t.symbol === 'string' && mark !== undefined && mark > 0) this.marks.set(t.symbol, { mark, at: now }); } } private async marketHours(assetId: number): Promise { const cached = this.hoursCache.get(assetId); if (cached && Date.now() - cached.at < MARKET_HOURS_TTL_MS) return cached.periods; const { data } = await this.request<{ periods?: unknown }[]>('read', 'GET', `/v4/market-hours/${assetId}`, { auth: false }); const periods = Array.isArray(data) ? data.flatMap((d) => (Array.isArray(d?.periods) ? d.periods : [])) : []; this.hoursCache.set(assetId, { periods, at: Date.now() }); return periods; } /** Measured once per trade date, so a day's sizing and hold estimate stay * put; an empty measurement retries after a while. */ private async volatility(symbols: string[]): Promise> { const now = Date.now(); const today = currentTradeDateCT(); for (const s of symbols) { const c = this.candleCache.get(s); if (c && c.tradeDate === today && (c.vol !== null || now - c.at < CANDLE_RETRY_MS)) continue; const rows: unknown[] = []; let endTime: number | undefined; // Newest first, a page at a time, until the lookback is covered. for (let page = 0; page < 4 && rows.length < CANDLE_HOURS + 1; page++) { const { data } = await this.request<{ data?: unknown[] }>('read', 'GET', endTime ? '/v2/klines/history' : '/v2/klines', { auth: false, params: { symbol: s, interval: '1h', ...(endTime ? { endTime } : {}) }, }); const list = Array.isArray(data?.data) ? data.data : []; if (list.length === 0) break; rows.push(...list); const oldest = Math.min(...list.map((k) => (Array.isArray(k) ? num(k[0]) ?? Infinity : Infinity))); if (!Number.isFinite(oldest)) break; endTime = oldest - 1; } const recent = rows.filter((k) => Array.isArray(k) && (num(k[0]) ?? 0) >= now - (CANDLE_HOURS + 1) * 3_600_000); const vol = hourlyVolatility(recent); this.candleCache.set(s, { vol, candles: recent.length, at: now, tradeDate: today }); if (vol !== null) console.log(`${this.tag} ${s} volatility for ${today}: ${(vol * Math.sqrt(24) * 100).toFixed(2)}%/day (${recent.length} hourly candles)`); } return new Map(symbols.map((s) => [s, this.candleCache.get(s) ?? { vol: null, candles: 0 }])); } // ── sync ───────────────────────────────────────────────────────────────── /** * One REST sync. Single-flight, with the same wedge recovery as the other * clients. lastSyncAt — what the scheduler trades on — is certified only * when the accounts, balances, positions and open orders all came back and * the first history pass has landed. */ private async requestSync(): Promise { if (this.intentionalDisconnect) return; if (!this.userToken()) return; const startedAt = Date.now(); if (this.syncInFlight) { const runningMs = startedAt - this.syncInFlightSince; if (runningMs < SYNC_WEDGED_MS) return this.syncRunning; console.warn(`${this.tag} sync running for ${Math.round(runningMs / 1000)}s — looks wedged, starting a fresh one`); } const gen = ++this.syncGeneration; const superseded = () => gen !== this.syncGeneration || this.intentionalDisconnect; this.syncInFlight = true; this.syncInFlightSince = startedAt; let finished!: () => void; this.syncRunning = new Promise((resolve) => { finished = resolve; }); try { const [cap, bal, pos, ord] = await Promise.all([ this.request<{ accounts?: unknown[] }>('read', 'GET', '/v3/capital/accounts/active'), this.request<{ accounts?: Record[] }>('read', 'GET', '/v3/accounts', { params: { active: true, limit: 500 } }), this.request<{ positions?: unknown[] }>('read', 'GET', '/v3/positions/opened'), this.request<{ orders?: unknown[] | null }>('read', 'GET', '/v3/positions/opened-orders'), ]); if (superseded()) return; this.lastAuthAt = this.lastAuthAt || Date.now(); this.consecutiveAuthFailures = 0; this.lastAuthError = null; delete this.lastFetchErrors['login']; const accounts = (cap.data?.accounts ?? []).map(parseCapitalAccount).filter((a): a is VestCapitalAccount => a !== null); const free = new Map(); for (const b of bal.data?.accounts ?? []) { const amount = num(b.amount); if (typeof b.account_id === 'string' && amount !== undefined) free.set(b.account_id, amount); } const positions = (pos.data?.positions ?? []).map(parsePosition).filter((p): p is VestPosition => p !== null); const orders = (ord.data?.orders ?? []).map(parseOpenOrder).filter((o): o is VestOpenOrder => o !== null); const held = [...new Set(positions.map((p) => p.symbol))]; if (held.length > 0) { try { await this.refreshMarks(held); } catch (err) { console.warn(`${this.tag} mark prices failed: ${errorMessage(err)}`); } } if (superseded()) return; const list: PlatformAccount[] = []; let complete = true; const now = Date.now(); for (const a of accounts) { const id = vestAccountId(a.id); const clash = this.uuidById.get(id); if (clash && clash !== a.id) { console.error(`${this.tag} account id collision between ${clash} and ${a.id} — leaving ${a.id} out`); continue; } this.uuidById.set(id, a.id); this.capital.set(id, a); if (a.status !== VEST_STATUS_ACTIVE && !this.unknownStatusLogged.has(`${a.id}:${a.status}`)) { this.unknownStatusLogged.add(`${a.id}:${a.status}`); console.warn(`${this.tag} ${vestAccountName(a)} has status ${a.status} — not trading it until that status is known`); } const acc: PlatformAccount = { id, name: vestAccountName(a), active: a.status === VEST_STATUS_ACTIVE }; list.push(acc); const mine = positions.filter((p) => p.accountId === a.id); this.positionsDetail.set(id, mine); this.openOrders.set(id, orders.filter((o) => o.accountId === a.id)); this.applyPosition(acc, mine); const amount = free.get(a.id); if (amount === undefined) { complete = false; continue; } const balance = amount + mine.reduce((s, p) => s + p.collateral, 0); const upnl = mine.reduce((s, p) => s + unrealizedPnl(p, this.marks.get(p.symbol)?.mark ?? NaN), 0); const resetBalance = this.resetBalanceOf(a, balance, now); this.accountCashBalances[id] = { amount: round2(balance), realizedPnL: resetBalance !== undefined ? round2(balance - resetBalance) : 0, equity: round2(balance + upnl), availableFunds: round2(amount), }; this.autoLiqThresholds[id] = a.maxDrawdownLimit ?? 0; this.lastFetchRaw[`floors:${acc.name}`] = `dd ${a.maxDrawdownLimit ?? '?'}, daily ${this.dailyFloorOf(a, balance, now) ?? 'none'}` + `${a.targetEquity ? `, target ${a.targetEquity}` : ''}`; } this.accountList = list; this.settleSuspects(); this.checkNakedPositions(); if (superseded()) return; console.log(`${this.tag} sync — ${list.length} account(s), ${Object.keys(this.positions).length} open position(s)`); this.maybeFetchDaysTraded(); if (this.historyLoaded && (this.lastSurveyTradeDate !== currentTradeDateCT() || Date.now() - this.lastSurveyAt >= SURVEY_INTERVAL_MS)) { this.lastSurveyTradeDate = currentTradeDateCT(); this.lastSurveyAt = Date.now(); void this.surveyCandidates().catch((err) => console.warn(`${this.tag} candidate survey failed: ${errorMessage(err)}`)); } this.syncComplete = true; if (!complete) { console.warn(`${this.tag} sync incomplete (a balance was missing) — not counted as a good sync`); } else if (this.historyLoaded) { this.lastSyncAt = Date.now(); this.lastSyncStartedAt = startedAt; } try { await this.onSync(); } catch (err) { console.error(`${this.tag} post-sync callback failed: ${errorMessage(err)}`); } } catch (err) { console.error(`${this.tag} sync failed: ${errorMessage(err)}`); } finally { if (gen === this.syncGeneration) { this.syncInFlight = false; this.syncInFlightSince = 0; } finished(); } } private applyPosition(acc: PlatformAccount, list: VestPosition[]): void { if (list.length > 1) console.warn(`${this.tag} ${acc.name} holds ${list.length} positions — tracking the first`); const pos = list[0]; if (!pos) { const seen = this.seenOpenAt.get(acc.id); 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); } delete this.positions[acc.id]; return; } const seen = this.seenOpenAt.get(acc.id); if (!seen || seen.positionId !== pos.positionId) { this.seenOpenAt.set(acc.id, { positionId: pos.positionId, at: Number.isFinite(pos.openedAt) ? pos.openedAt : Date.now(), naggedAt: 0 }); } this.positions[acc.id] = { contractId: pos.symbol, netPos: pos.side === 'long' ? pos.quantity : -pos.quantity, netPrice: pos.openPrice, timestamp: new Date(Number.isFinite(pos.openedAt) ? pos.openedAt : Date.now()), }; } /** * The balance Vest measures today's daily loss from. Vest captures it on * an account's first update after 20:00 ET, so an account that has not * moved since the boundary still shows yesterday's — its next update * (our own order, say) will reset it to the balance it has now. */ private resetBalanceOf(a: VestCapitalAccount, balance: number, now: number): number | undefined { if (Number.isFinite(a.dailyResetAt) && a.dailyResetAt >= lastVestResetMs(now)) return a.dailyResetBalance; return balance; } /** The equity floor of Vest's daily loss limit as it stands (or will, on * the account's next update); undefined when the account has none. */ private dailyFloorOf(a: VestCapitalAccount, balance: number, now: number): number | undefined { if (!(a.maxDailyLossPct > 0)) return undefined; if (Number.isFinite(a.dailyResetAt) && a.dailyResetAt >= lastVestResetMs(now)) return a.dailyLossFloor; return round2(balance * (1 - a.maxDailyLossPct)); } /** * See PlatformClient: what the engine measures the day's loss from. Vest * states the floor itself (reset balance × (1 − rate), moved by transfers), * so this is that floor plus the account type's own daily limit — the * engine's room, limit + min(0, balance − dayStart), is then * min(limit, balance − floor): never more than Vest leaves. null until * the account has been read. */ public dayStartBalance(accountId: number): number | null { const a = this.capital.get(accountId); const cash = this.accountCashBalances[accountId]; if (!a || !cash) return null; const floor = this.dailyFloorOf(a, cash.amount, Date.now()); const limit = getAccountConfig(accountId).accountType?.dailyLossLimit ?? 0; if (floor === undefined) return this.resetBalanceOf(a, cash.amount, Date.now()) ?? null; return round2(floor + limit); } private settleSuspects(): void { const now = Date.now(); for (const [id, since] of this.suspectAccounts) { if (now - since >= SUSPECT_SETTLE_MS && (this.openOrders.get(id) ?? []).length === 0) this.suspectAccounts.delete(id); } } /** An order of unknown outcome, a pending order, or exit legs waiting on * a position. */ public hasWorkingOrders(accountId: number): boolean { return this.suspectAccounts.has(accountId) || (this.openOrders.get(accountId) ?? []).length > 0 || (this.positionsDetail.get(accountId) ?? []).some((p) => awaitingLegs(p).length > 0); } /** A position with no exit leg waiting — a restart mid-entry, or a leg * whose exits failed. Not closed automatically (it may be a manual * trade), but loud, and on the account's fetch-error note. */ private checkNakedPositions(): void { const now = Date.now(); for (const acc of this.accountList) { const seen = this.seenOpenAt.get(acc.id); const pos = this.positionsDetail.get(acc.id)?.[0]; if (!seen || !pos || awaitingLegs(pos).length > 0 || now - seen.at < NAKED_AFTER_MS) continue; if (now - seen.naggedAt < 15 * 60_000) continue; seen.naggedAt = now; console.error(`${this.tag} ${acc.name}: position ${pos.positionId} has NO take-profit or stop-loss — close it or add exits on Vest`); } } // ── history ────────────────────────────────────────────────────────────── private maybeFetchDaysTraded(): void { if (this.daysFetchInFlight || this.intentionalDisconnect) return; const now = Date.now(); const initialFetchDue = this.lastDaysFetchStartedAt === 0; const retryPending = this.nextDaysFetchRetryAt > 0; const retryDue = retryPending && now >= this.nextDaysFetchRetryAt; const hourlyRefreshDue = !retryPending && this.lastDaysFetchSucceededAt > 0 && now - this.lastDaysFetchSucceededAt >= 60 * 60_000; const waiting = !initialFetchDue && !retryPending ? this.accountList.filter((a) => !this.historyLoadedIds.has(a.id) && !this.fetchingIds.has(a.id)).map((a) => a.id) : []; const targeted = !retryDue && !hourlyRefreshDue && !initialFetchDue && waiting.length > 0; if (!initialFetchDue && !retryDue && !hourlyRefreshDue && !targeted) return; this.daysFetchInFlight = true; this.daysFetchInFlightSince = now; this.lastDaysFetchStartedAt = now; if (retryDue) this.nextDaysFetchRetryAt = 0; this.fetchDaysTraded(targeted ? waiting : undefined) .then(({ failedAccounts }) => { if (!this.historyLoaded && !this.intentionalDisconnect) { this.historyLoaded = true; void this.requestSync(); } if (failedAccounts > 0) { this.nextDaysFetchRetryAt = Date.now() + 5 * 60_000; console.warn(`${this.tag} ${failedAccounts} account(s) failed history fetch — retrying in 5 minutes`); return; } if (!targeted) this.lastDaysFetchSucceededAt = Date.now(); }) .catch((err) => { this.nextDaysFetchRetryAt = Date.now() + 5 * 60_000; console.error(`${this.tag} history fetch failed — retrying in 5 minutes: ${errorMessage(err)}`); }) .finally(() => { this.daysFetchInFlight = false; this.daysFetchInFlightSince = 0; }); } public async fetchDaysTraded(onlyAccountIds?: number[]): Promise<{ failedAccounts: number }> { const ids = this.accountList.filter((a) => !onlyAccountIds || onlyAccountIds.includes(a.id)).map((a) => a.id); for (const id of ids) this.fetchingIds.add(id); this.fetchDaysComplete = false; let failed = 0; try { for (const id of ids) { if (this.intentionalDisconnect) break; const account = this.accountList.find((a) => a.id === id); if (!account) continue; try { this.bookHistory(account, await this.fetchTxns(this.uuidById.get(id)!)); } catch (err) { failed++; this.fallBackToCache(account, errorMessage(err)); } } } finally { for (const id of ids) this.fetchingIds.delete(id); this.fetchDaysComplete = true; } return { failedAccounts: failed }; } /** Every transaction on the account, newest page first, deduplicated. */ private async fetchTxns(uuid: string): Promise { const all = new Map(); for (let page = 0; page < TXN_MAX_PAGES; page++) { const { data } = await this.request<{ transactions?: unknown[] | null }>('history', 'GET', '/v3/transactions', { account: uuid, params: { limit: TXN_PAGE, offset: page * TXN_PAGE }, }); const rows = data?.transactions ?? []; for (const r of rows) { const t = parseTxn(r); if (t) all.set(t.key, t); } if (rows.length < TXN_PAGE) break; } return [...all.values()].sort((a, b) => a.time - b.time); } private bookHistory(account: PlatformAccount, txns: VestTxn[]): void { const a = this.capital.get(account.id); const { daily, funds } = bookTxns(txns); const types = [...new Set(txns.map((t) => t.type))].sort((x, y) => x - y); this.lastFetchRaw[account.name] = `${txns.length} transaction(s), types ${types.join(',') || 'none'}`; // An account Vest funded without a deposit row opens with its capital // on the day it was activated — the resolver infers its size from it, // and its first cycle starts there. const hasDeposit = [...funds.values()].some((v) => v > 0); if (!hasDeposit && a && a.initialCapital > 0) { const opened = vestDayOf(Number.isFinite(a.activatedAt) ? a.activatedAt : a.createdAt); funds.set(opened, (funds.get(opened) ?? 0) + a.initialCapital); } const fresh = [...daily.entries()] .map(([date, e]) => ({ date, pnl: round2(e.pnl), fees: Math.round(e.fees * 10000) / 10000 })) .sort((x, y) => x.date.localeCompare(y.date)); const merged = new Map(); for (const e of loadDailyPnL(account.id)) merged.set(e.date, e.pnl); for (const e of fresh) merged.set(e.date, e.pnl); this.fullDailyPnL[account.id] = [...merged.entries()] .map(([date, pnl]) => ({ date, pnl })) .sort((x, y) => x.date.localeCompare(y.date)); saveDailyPnL(account.id, account.name, fresh); const storedFundDate = loadAccountMeta(account.id, 'last_fund_date'); const fundRows = [...funds.entries()] .map(([date, amount]) => ({ date, amount: round2(amount) })) .filter((f) => f.amount !== 0) .sort((x, y) => x.date.localeCompare(y.date)); if (fundRows.length > 0) saveFundTransactions(account.id, account.name, fundRows); this.fundTransactions[account.id] = loadFundTransactions(account.id); const lastFundDate = fundRows.length > 0 ? fundRows[fundRows.length - 1].date : storedFundDate; if (lastFundDate && storedFundDate && lastFundDate > storedFundDate) { const prevAmt = this.fundTransactions[account.id]?.find((f) => f.date === storedFundDate)?.amount ?? null; saveEndedCycleStart(account.id, { fundDate: storedFundDate, includesDay: !(prevAmt !== null && prevAmt < 0) }); } if (lastFundDate) saveAccountMeta(account.id, 'last_fund_date', lastFundDate); this.lastFundDates[account.id] = lastFundDate ?? null; this.applyCycleSlice(account.id); // Books check: the balance must be what the transactions add up to // (the opening capital included). A payout or credit booked under a // type this client doesn't count would otherwise trade a finished // cycle as if nothing happened. const cash = this.accountCashBalances[account.id]; const expected = txns.reduce((s, t) => s + (t.kind === 'margin' ? 0 : t.amount), 0) + (hasDeposit ? 0 : a?.initialCapital ?? 0); if (account.active && cash && !this.positions[account.id] && Math.abs(cash.amount - expected) >= BOOKS_TOLERANCE) { this.booksHeld.add(account.id); this.lastFetchErrors[account.name] = `balance ${round2(cash.amount)} vs transactions ${round2(expected)} (types ${types.join(',')}) — held until they agree`; console.warn(`${this.tag} ${account.name}: ${this.lastFetchErrors[account.name]}`); } else { this.booksHeld.delete(account.id); delete this.lastFetchErrors[account.name]; } this.historyLoadedIds.add(account.id); } private fallBackToCache(account: PlatformAccount, msg: string): void { const cached = loadDailyPnL(account.id); this.fundTransactions[account.id] ??= loadFundTransactions(account.id); this.lastFundDates[account.id] ??= loadAccountMeta(account.id, 'last_fund_date'); if (cached.length > 0) { this.fullDailyPnL[account.id] = cached; this.applyCycleSlice(account.id); this.lastFetchErrors[account.name] = `${msg} (using ${cached.length} cached entries from database)`; } else { this.daysTraded[account.id] ??= 0; this.lastFetchErrors[account.name] = msg; } } /** The active cycle out of the full history: a withdrawal's day belongs to * the OLD cycle, a deposit's day to the new one (as on DXtrade). */ private applyCycleSlice(accountId: number): void { const merged = this.fullDailyPnL[accountId] ?? []; const fundDate = this.lastFundDates[accountId]; const txns = this.fundTransactions[accountId] ?? []; const lastAmt = fundDate ? (txns.find((f) => f.date === fundDate)?.amount ?? null) : null; const isWithdrawal = lastAmt !== null && lastAmt < 0; const active = fundDate ? merged.filter((d) => (isWithdrawal ? d.date > fundDate : d.date >= fundDate)) : merged; const prior = fundDate ? merged.filter((d) => (isWithdrawal ? d.date <= fundDate : d.date < fundDate)) : []; this.priorProfit[accountId] = round2(prior.reduce((s, d) => s + d.pnl, 0)); this.dailyPnL[accountId] = active; this.daysTraded[accountId] = active.filter((d) => d.pnl !== 0).length; } public historyReady(accountId: number): boolean { return this.historyLoadedIds.has(accountId) && !this.booksHeld.has(accountId) && this.dailyPnL[accountId] !== undefined && this.fundTransactions[accountId] !== undefined; } /** The account's Vest status and activation time, for the report. */ public brokerAccountMeta(accountId: number): { status: string; registrationTime: number } | undefined { const a = this.capital.get(accountId); return a ? { status: String(a.status), registrationTime: Number.isFinite(a.activatedAt) ? a.activatedAt : a.createdAt } : undefined; } public forgetHistory(accountId: number, accountName: string): void { this.historyLoadedIds.delete(accountId); this.booksHeld.delete(accountId); delete this.dailyPnL[accountId]; delete this.fullDailyPnL[accountId]; delete this.daysTraded[accountId]; delete this.priorProfit[accountId]; delete this.lastFundDates[accountId]; delete this.fundTransactions[accountId]; delete this.lastFetchErrors[accountName]; delete this.lastFetchRaw[accountName]; } // ── instrument selector ────────────────────────────────────────────────── /** * The contract this firm trades right now, from its instrument list — the * root is ignored, as on DXtrade. With a pick context (the batch's leg), * each candidate is sized as the leg would be and the cheapest that * reaches the target hold wins (else the quickest); without one, the * cheapest per unit of volatility. Sticky while an account holds one of * the candidates; a batch's pick is reused briefly so its lookups agree. */ public async findFrontMonthContract(_root: string, ctx?: PickContext): Promise { if (!this.userToken()) return null; const root = '*'; const firm = getCachedFirm(this.cfg.firmId); const candidates = firm ? firmInstruments(firm) : []; const now = Date.now(); if (candidates.length === 0) { if (now - this.unlistedLoggedAt > 10 * 60_000) { this.unlistedLoggedAt = now; console.log(`${this.tag} no instruments listed — skipping; add Vest symbols (e.g. NDX-USD-PERP) on the master Firms page`); } return null; } const held = new Set([...this.positionsDetail.values()].flat().map((p) => p.symbol)); const sticky = candidates.find((s) => held.has(s)); if (!sticky && !ctx) { const cached = this.pickCache.get(root); if (cached && now - cached.at < PICK_TTL_MS) return cached.contract; } const pick = await this.pickCandidate(root, sticky ? [sticky] : candidates, sticky !== undefined, ctx); if (pick && !sticky) this.pickCache.set(root, { contract: pick, at: now }); return pick; } /** Every listed symbol, looked at afresh whatever the accounts hold: the * day's volatility, and the candidates the dashboard ranks. */ private async surveyCandidates(): Promise { const firm = getCachedFirm(this.cfg.firmId); const list = firm ? firmInstruments(firm) : []; if (list.length > 0) await this.pickCandidate('survey', list, false); } private async pickCandidate(root: string, symbols: string[], sticky: boolean, ctx?: PickContext): Promise { const firm = getCachedFirm(this.cfg.firmId); const targetHold = sizingHoldHours(firm); const specs = await this.symbolSpecs(); const known = symbols.filter((s) => specs.has(s)); if (known.length > 0) await this.refreshMarks(known); const vols = await this.volatility(known); const now = Date.now(); const board: { symbol: string; score: number | null; note: string; contract?: PlatformContract }[] = []; for (const symbol of symbols) { const spec = specs.get(symbol); if (!spec) { board.push({ symbol, score: null, note: 'unknown symbol' }); continue; } const mark = this.marks.get(symbol); const vol = vols.get(symbol); let hours: { open: boolean; closesAt: number | null }; try { hours = marketOpen(await this.marketHours(spec.assetId), now); } catch (err) { board.push({ symbol, score: null, note: `market hours unknown (${errorMessage(err)})` }); continue; } const step = 10 ** -spec.sizeDecimals; const stepNotional = mark ? step * mark.mark : NaN; const spread = mark && spec.tick > 0 ? (ASSUMED_SPREAD_TICKS * spec.tick) / mark.mark : NaN; const rejection = spec.capitalTradingStatus !== 'TRADING' ? `capital trading ${spec.capitalTradingStatus || 'unknown'}` : !hours.open ? 'market closed' : !mark || now - mark.at > 5 * 60_000 ? 'no mark price' : !(spec.tick > 0) ? 'no tick size' : (vol?.candles ?? 0) < 25 ? `only ${vol?.candles ?? 0} hourly candles` : null; if (rejection) { board.push({ symbol, score: null, note: rejection }); continue; } const pct = spec.takerFee > 0 ? spec.takerFee * 100 : firm?.commissionPct ?? 0; const score = scoreCandidate({ spreadFraction: spread, commissionPct: pct, financingPctPerDay: firm?.financingPctPerDay ?? 0, targetHoldHours: targetHold, volatilityPerHour: vol?.vol ?? null, }); if (score === null) { board.push({ symbol, score: null, note: 'no volatility' }); continue; } board.push({ symbol, score, note: `fee ${(pct * 100).toFixed(2)}bp${vol?.vol ? `, σ ${(vol.vol * Math.sqrt(24) * 100).toFixed(2)}%/day` : ''}` + (spec.marginRate ? `, ${+(1 / spec.marginRate).toFixed(1)}:1` : '') + (hours.closesAt ? `, open ${((hours.closesAt - now) / 3_600_000).toFixed(1)}h more` : ''), contract: { id: symbol, name: encodeContractName(symbol, step), tickSize: spec.tick, pointValue: step, commissionPct: pct, stepNotional, volatilityPerHour: vol?.vol ?? undefined, spreadFraction: spread, minContracts: 1, marginRate: spec.marginRate || undefined, }, }); } let ranked = board.filter((b) => b.contract).sort((a, b) => a.score! - b.score!); if (ctx) { const sized = ranked.flatMap((b) => { const ev = ctx.evaluate(b.contract!); if (!ev) { b.note += ', can\'t size'; return []; } b.note += `, $${Math.round(ev.notional / 1000)}k → ${ev.holdHours.toFixed(1)}h, ~$${Math.round(ev.cost)}`; return [{ b, holdHours: ev.holdHours, cost: ev.cost }]; }); ranked = rankForTarget(sized, ctx.targetHoldHours).map((i) => sized[i].b); } const winner = ranked[0]; const summary = board.map((b) => `${b.symbol}${b.score !== null ? ` ${b.score.toFixed(4)}` : ''} (${b.note})`).join('; '); this.lastFetchRaw[`pick:${root}`] = `${new Date().toISOString()} → ${winner?.symbol ?? 'none'} — ${summary}`; console.log(`${this.tag} ${root}${sticky ? ' (held)' : ''} → ${winner?.symbol ?? 'nothing tradable'} — ${summary}`); if (!sticky) this.lastCandidates = board.flatMap((b) => (b.contract ? [b.contract] : [])); return winner?.contract ?? null; } public async fetchInstrumentFees(): Promise<{ [symbol: string]: number }> { // A percent of notional per symbol, carried on each picked contract. return {}; } // ── orders ─────────────────────────────────────────────────────────────── private async freshPositions(uuid: string): Promise { const { data } = await this.request<{ positions?: unknown[] }>('trade', 'GET', '/v3/positions/opened'); return (data?.positions ?? []).map(parsePosition).filter((p): p is VestPosition => p !== null && p.accountId === uuid); } /** What an order needs before it is sent. Failing here reached nothing. */ private orderContext(accountId: number, contractName: string): { uuid: string; symbol: string; step: number; spec: SymbolSpec } { if (!this.userToken()) throw new OrderNotPlacedError('no Vest session — order not sent'); const uuid = this.uuidById.get(accountId); if (!uuid) throw new OrderNotPlacedError(`Vest account ${accountId} unknown — order not sent`); const decoded = decodeContractName(contractName); if (!decoded) throw new OrderNotPlacedError(`Vest contract ${contractName} unreadable — order not sent`); const spec = this.specs.get(decoded.symbol); if (!spec) throw new OrderNotPlacedError(`Vest symbol ${decoded.symbol} has no spec loaded — order not sent`); return { uuid, symbol: decoded.symbol, step: decoded.step, spec }; } /** A price on the symbol's tick grid as the gateway wants it, rounded * toward the position's safe side: an exit selling (closing a long) up, * one buying (closing a short) down — the take-profit never nets less, * the stop never sits further out. */ private exitPrice(spec: SymbolSpec, price: number, exitSide: 'long' | 'short'): string { const rounded = spec.tick > 0 ? roundToStep(price, spec.tick, exitSide === 'short' ? 'up' : 'down') : price; return formatDecimal(rounded, spec.priceDecimals); } /** The leverage an entry asks for: the most the symbol allows a capital * account (cross margin, so it only sets the collateral held, not the * liquidation point — the prop floors sit far above that). */ private leverageFor(spec: SymbolSpec): string { return formatDecimal(spec.marginRate > 0 ? Math.floor(1 / spec.marginRate) : 1, 2); } /** * Place an order and wait for its fill: an open when the account is flat, * a close (or reduce) of the position when it holds the opposite side. * Adding to a position is refused, and so is an open against another * account's opposite position in the same symbol (no hedging). Resolves * with the fill `price`. */ public async sendOrder( accountId: number, contractSymbol: string, quantity: number, action: 'Buy' | 'Sell', orderType: 'Market' | 'Limit', price?: number, ): Promise> { try { return await this.sendOrderAndWait(accountId, contractSymbol, quantity, action, orderType, price); } catch (err) { if (!isOrderNotPlaced(err)) this.suspectAccounts.set(accountId, Date.now()); throw err; } } private async sendOrderAndWait( accountId: number, contractSymbol: string, contracts: number, action: 'Buy' | 'Sell', orderType: 'Market' | 'Limit', price?: number, ): Promise> { const { uuid, symbol, step, spec } = this.orderContext(accountId, contractSymbol); const side: 'long' | 'short' = action === 'Buy' ? 'long' : 'short'; let positions: VestPosition[]; try { positions = await this.freshPositions(uuid); } catch (err) { throw new OrderNotPlacedError(`Vest position check failed — order not sent: ${errorMessage(err)}`); } const existing = positions.find((p) => p.symbol === symbol); if (existing && existing.side === side) throw new OrderNotPlacedError(`adding to an open ${symbol} position is not supported`); const units = roundToStep(contracts * step, step); const qty = (n: number) => formatDecimal(n, spec.sizeDecimals); const tif = orderType === 'Market' ? 'IOC' : 'GTC'; const priced = orderType === 'Limit' && price !== undefined; let path: string; let body: Record; let unitsOrdered: number; if (existing) { unitsOrdered = Math.min(existing.quantity, units); const full = unitsOrdered >= existing.quantity - step / 2; path = full ? '/v3/positions/close' : '/v3/positions/reduce'; body = full ? { positionId: existing.positionId, symbol, orderType: orderType === 'Market' ? 'market' : 'limit', ...(priced ? { limitPrice: this.exitPrice(spec, price!, side) } : {}), } : { positionId: existing.positionId, symbol, orderType: orderType === 'Market' ? 'market' : 'limit', leverage: this.leverageFor(spec), quantity: qty(unitsOrdered), timeInForce: tif, reduceOnly: true, ...(priced ? { price: this.exitPrice(spec, price!, side) } : {}), }; } else { const meta = this.capital.get(accountId); if (!meta || meta.status !== VEST_STATUS_ACTIVE) { throw new OrderNotPlacedError(`account status is ${meta?.status ?? 'unknown'} — order not sent`); } const opposite = side === 'long' ? 'short' : 'long'; const hedged = [...this.positionsDetail.values()].flat().some((p) => p.symbol === symbol && p.side === opposite) || this.openingSides.get(symbol)?.side === opposite; if (hedged) throw new OrderNotPlacedError(`another account holds the opposite side of ${symbol} — no hedging`); unitsOrdered = units; path = '/v3/positions/open'; body = { orderType: orderType === 'Market' ? 'market' : 'limit', leverage: this.leverageFor(spec), side, symbol, quantity: qty(units), timeInForce: tif, takeProfits: [], stopLosses: [], ...(priced ? { price: formatDecimal(roundToStep(price!, spec.tick || 1e-9), spec.priceDecimals) } : {}), }; } if (!(unitsOrdered > 0)) throw new OrderNotPlacedError(`${contracts} contract(s) of ${symbol} round to nothing — order not sent`); const opening = !existing; if (opening) { const cur = this.openingSides.get(symbol); this.openingSides.set(symbol, { side, count: (cur?.side === side ? cur.count : 0) + 1 }); } this.pendingFills++; const orderNumber = this.nextOrderNumber++; const sentAt = Date.now(); try { const placed = await this.submit(uuid, path, body); console.log(`${this.tag} order ${placed.orderId ?? '?'}: ${side === 'long' ? 'BUY' : 'SELL'} ${unitsOrdered} ${symbol} ${orderType}${existing ? ' CLOSE' : ' OPEN'}`); const fill = await this.waitForFill(uuid, placed, symbol, side, existing ?? null, unitsOrdered, sentAt); if (opening) { const pos = fill.position ?? { positionId: placed.positionId ?? '', accountId: uuid, symbol, side, quantity: unitsOrdered, openPrice: fill.price, collateral: 0, leverage: 0, openedAt: Date.now(), stopLosses: [], takeProfits: [], }; this.positionsDetail.set(accountId, [pos]); this.positions[accountId] = { contractId: symbol, netPos: side === 'long' ? pos.quantity : -pos.quantity, netPrice: pos.openPrice, timestamp: new Date(), }; } else if (unitsOrdered >= existing.quantity - step / 2) { this.positionsDetail.set(accountId, []); delete this.positions[accountId]; } const result = { orderId: orderNumber, vestOrderId: placed.orderId, price: fill.price, qty: contracts, units: unitsOrdered, accountId, contractId: symbol, }; for (const w of this.fillWaiters.filter((x) => x.orderId === orderNumber)) w.callback(result); this.fillWaiters = this.fillWaiters.filter((x) => x.orderId !== orderNumber); return result; } finally { this.pendingFills--; if (opening) { const cur = this.openingSides.get(symbol); if (cur && cur.count <= 1) this.openingSides.delete(symbol); else if (cur) this.openingSides.set(symbol, { ...cur, count: cur.count - 1 }); } } } /** * POST an order. Only an answer that proves nothing was placed becomes * OrderNotPlacedError (a 4xx with the gateway's refusal); a transport * failure or a 5xx stays a plain Error — it may have gone in. */ private async submit(uuid: string, path: string, body: Record): Promise<{ orderId?: string; positionId?: string }> { try { const { data } = await this.request<{ orderId?: unknown; positionId?: unknown }>('trade', 'POST', path, { account: uuid, body }); return { orderId: typeof data?.orderId === 'string' ? data.orderId : undefined, positionId: typeof data?.positionId === 'string' ? data.positionId : undefined, }; } catch (err) { if (err instanceof VestApiError && err.status >= 400 && err.status < 500 && err.status !== 408) { throw new OrderNotPlacedError(`Vest refused the order: ${err.message}`); } throw new Error(`Vest order outcome unknown (it may still have been placed): ${errorMessage(err)}`); } } /** * Poll until the order fills: an open shows as the position it created * (by positionId, else the account's only one in the symbol), a close as * the position gone or shrunk. Still unfilled after FILL_TIMEOUT_MS is * reported as unknown — a market order is IOC, so it can't fill later, * but a limit may. */ private async waitForFill( uuid: string, placed: { orderId?: string; positionId?: string }, symbol: string, side: 'long' | 'short', closing: VestPosition | null, units: number, sentAt: number, ): Promise<{ price: number; position?: VestPosition }> { const deadline = Date.now() + FILL_TIMEOUT_MS; while (Date.now() < deadline) { await sleep(FILL_POLL_MS); try { const positions = await this.freshPositions(uuid); if (closing) { const still = positions.find((p) => p.positionId === closing.positionId); if (!still || still.quantity < closing.quantity - units / 2) { return { price: await this.exitFillPrice(uuid, symbol, side, sentAt) }; } } else { const pos = positions.find((p) => (placed.positionId ? p.positionId === placed.positionId : p.symbol === symbol && p.side === side)); if (pos) return { price: pos.openPrice, position: pos }; } } catch { /* transient — the next poll tries again */ } } throw new Error(`order ${placed.orderId ?? '?'} on ${symbol} not seen filled within ${FILL_TIMEOUT_MS / 1000}s — outcome unknown`); } /** What a close filled at: the volume-weighted price of the account's * executions in the symbol, on the closing side, since the order went * out — else the mark, as an estimate. */ private async exitFillPrice(uuid: string, symbol: string, side: 'long' | 'short', sentAt: number): Promise { try { const { data } = await this.request<{ items?: Record[] }>('trade', 'GET', '/v3/executions', { account: uuid, params: { account_id: uuid, symbol, limit: 20 }, }); let qty = 0; let value = 0; for (const e of data?.items ?? []) { const q = num(e.quantity); const price = num(e.price); if (q === undefined || price === undefined || toMs(e.executedAt) < sentAt - 5_000) continue; if ((e.isBuy === true) !== (side === 'long')) continue; qty += q; value += q * price; } if (qty > 0) return value / qty; } catch (err) { console.warn(`${this.tag} executions read failed — taking the mark as the exit price: ${errorMessage(err)}`); } await this.refreshMarks([symbol]).catch(() => { /* NaN below then */ }); return this.marks.get(symbol)?.mark ?? NaN; } /** One exit leg on the open position: POST /v3/positions/take-profit or * /stop-loss. A take-profit rests as a limit at its price; a stop goes * out at market when it triggers. */ private async addLeg(uuid: string, spec: SymbolSpec, pos: VestPosition, kind: 'take-profit' | 'stop-loss', price: number): Promise<{ orderId?: string }> { const exitSide = pos.side === 'long' ? 'short' : 'long'; const trigger = this.exitPrice(spec, price, exitSide); const body = kind === 'take-profit' ? { positionId: pos.positionId, executionType: 'limit', triggerPrice: trigger, limitPrice: trigger } : { positionId: pos.positionId, executionType: 'market', triggerPrice: trigger }; return this.submit(uuid, `/v3/positions/${kind}`, body); } private async heldPosition(uuid: string, symbol: string, exitSide: 'long' | 'short'): Promise { const pos = (await this.freshPositions(uuid).catch((err) => { throw new OrderNotPlacedError(`Vest position check failed — exit not sent: ${errorMessage(err)}`); })).find((p) => p.symbol === symbol && p.side !== exitSide); if (!pos) throw new OrderNotPlacedError(`no ${symbol} position to exit — nothing sent`); return pos; } /** A take-profit on the whole open position. Exit only: with no opposite * position there is nothing to close, so nothing is sent. */ public async placeOrderNoWait( accountId: number, contractSymbol: string, _quantity: number, action: 'Buy' | 'Sell', orderType: 'Market' | 'Limit', price?: number, ): Promise<{ orderId?: number }> { const { uuid, symbol, spec } = this.orderContext(accountId, contractSymbol); const exitSide: 'long' | 'short' = action === 'Buy' ? 'long' : 'short'; const pos = await this.heldPosition(uuid, symbol, exitSide); if (orderType !== 'Limit' || price === undefined) { await this.submit(uuid, '/v3/positions/close', { positionId: pos.positionId, symbol, orderType: 'market' }); return {}; } await this.addLeg(uuid, spec, pos, 'take-profit', price); return {}; } /** * Take-profit and stop on the open position. Vest holds them as legs of * the position and cancels the other when one triggers (a native OCO) or * the position closes. The stop goes first, so the position is never * without it. * * What the caller may do next depends on what is left at the broker: * OrderNotPlacedError = nothing of the pair is working; a plain Error = * something may be (a stop, at least) — place nothing more. */ public async placeOcoExit( accountId: number, contractSymbol: string, _quantity: number, exitAction: 'Buy' | 'Sell', limitPrice: number, stopPrice: number, ): Promise<{ orderId?: number }> { const { uuid, symbol, spec } = this.orderContext(accountId, contractSymbol); const exitSide: 'long' | 'short' = exitAction === 'Buy' ? 'long' : 'short'; const pos = await this.heldPosition(uuid, symbol, exitSide); await this.addLeg(uuid, spec, pos, 'stop-loss', stopPrice); // throws OrderNotPlacedError when nothing went in try { await this.addLeg(uuid, spec, pos, 'take-profit', limitPrice); } catch (err) { console.error(`${this.tag} ${symbol}: stop is working but the take-profit failed (${errorMessage(err)}) — place it by hand`); throw new Error(`stop working, take-profit not placed: ${errorMessage(err)}`); } console.log(`${this.tag} exits on ${pos.positionId} — take-profit @ ${this.exitPrice(spec, limitPrice, exitSide)}, stop @ ${this.exitPrice(spec, stopPrice, exitSide)}`); return {}; } public onFill(orderId: number, callback: (fill: Record) => void): void { this.fillWaiters.push({ orderId, callback }); } // ── lifecycle ──────────────────────────────────────────────────────────── public health(): ClientHealth { const session = vestSessionFor(this.wallet, Date.now(), 0); return { platform: 'vest', authenticated: session !== null, transport: 'rest', syncComplete: this.syncComplete, lastSyncAt: this.lastSyncAt, lastLoginAt: session?.receivedAt ?? this.lastAuthAt, lastLoginError: this.lastAuthError, consecutiveLoginFailures: this.consecutiveAuthFailures, nextReconnectAt: 0, tokenExpiresAt: session?.expiresAt ?? 0, lastInboundAt: this.lastResponseAt, pendingRequests: this.pendingFills, syncInFlightSince: this.syncInFlight ? this.syncInFlightSince : 0, daysFetchInFlightSince: this.daysFetchInFlight ? this.daysFetchInFlightSince : 0, }; } /** No session relayed for this wallet: a rebuild can't fix that — only a * signed-in Vest tab with the extension can — so the supervisor leaves * the client alone. */ public get needsAttention(): boolean { return vestSessionFor(this.wallet, Date.now(), 0) === null; } /** Final: nothing this client scheduled fires afterwards. There is no * session of its own to log out — the browser's stays the browser's. */ public disconnect(): void { this.intentionalDisconnect = true; for (const t of [this.syncInterval, this.watchdogInterval]) if (t) clearInterval(t); this.syncInterval = this.watchdogInterval = null; this.daysFetchInFlight = false; this.daysFetchInFlightSince = 0; this.accountTokens.clear(); } }