/** * 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 { randomUUID } from 'crypto'; import axios from './http-client'; import { currentTradeDateCT, rankForTarget, dllReserve, resetClearStop, legCloseNote } from './trading-logic'; import { sizingHoldHours } from './person-size'; import { saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions, saveEndedCycleStart, dropFundTransaction, saveTrades, markTrade, getOrCreateSetting, TRADES_SINCE_SETTING, openTradeAccounts, botOpenedPositions, } from './db'; import { getCachedFirm } from './firm-catalog'; import { firmInstruments } from './instruments'; import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth, PickContext, OrderIntent } from './platform-client'; import { OrderNotPlacedError, isOrderNotPlaced, errorMessage, orderEffect } from './platform-client'; import { encodeContractName, decodeContractName, roundToStep, scoreCandidate } from './dxtrade-helpers'; import { vestSessionFor, dropVestSession, listVestSessions, refuseVestSession } from './vest-session'; import { vestAccountId, vestAccountName, vestDayOf, lastVestResetMs, nextVestResetMs, parseCapitalAccount, parsePosition, parseOpenOrder, parseTxn, bookTxns, awaitingLegs, unknownOpenFill, unrealizedPnl, hourlyVolatility, marketOpen, longClosure, num, toMs, formatDecimal, resetSafeStop, parseHistoryPosition, jwtClaimStrings, accountScopedToken, VEST_STATUS_ACTIVE, VEST_STOP_SLIPPAGE_FRACTION, VestApiError, VestNotSentError, nothingWentIn, tokenForRequest, settleClaims, readClaims, payoutDaysOf, payoutsSince, } from './vest-helpers'; import type { VestCapitalAccount, VestPosition, VestOpenOrder, VestTxn, VestTrade, OwnClaim } 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; /** 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; /** 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; /** How long before the daily reset open positions are fitted to the room * the reset will leave (guardReset) — and before a market closure that runs * past one, the bot's own legs are closed (guardClose). */ const RESET_GUARD_MS = 5 * 60_000; /** No new position opens this close to the reset: the guard's window, and a * couple of minutes before it for an entry in flight to place its exits. */ const RESET_ENTRY_BLOCK_MS = RESET_GUARD_MS + 2 * 60_000; /** How long a stop or close the guard just sent counts before a read of * open positions shows it. */ const GUARD_ECHO_MS = 30_000; /** An open of unknown outcome is the position opened this close to its send * and its error — the gateway's clock and lag (unknownOpenFill). */ const UNKNOWN_OPEN_SLACK_MS = 2 * 60_000; /** account_meta key of the profit claims this client made, per account. */ const CLAIMS_META = 'vest_claims'; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const round2 = (n: number) => Math.round(n * 100) / 100; 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 readonly stopSlippageFraction = VEST_STOP_SLIPPAGE_FRACTION; 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(); /** The login's main (primary) account — where a claimed profit goes. */ private primaryAccountId: string | null = null; /** Profit claims this client made, per account: when, how much, and the * balance right after — today's P&L counts from there (realizedSince). * Loaded from account_meta on first use, so a restart keeps them. */ private claims = new Map(); /** The sync generation current when each account last claimed: a sync * of that generation or older read the balance before the claim. */ private claimSeq = new Map(); /** When each account's transactions were last read. */ private historyReadAt = new Map(); private unmatchedClaimLogged = new Set(); /** Each account's WITHDRAW transactions at the last history read, so a * claim made by hand on Vest counts toward today's stage too. */ private withdrawals = new Map(); /** The reset guardReset is fitting positions for (epoch ms), and the * stops it placed and closes it sent, by position, with when — a read * just after may not show them yet. */ private resetGuard = { reset: 0, stops: new Map(), closes: new Map(), noSessionLogged: false, unfittable: new Set(), }; private resetGuardInFlight = false; /** guardClose's closes just sent (position → when), the hand trades it * has named (position → the close they were named for), and when it * last said it couldn't read a symbol's hours or had no session. */ private closeGuard = { closes: new Map(), named: new Map(), hoursWarnedAt: new Map(), noSessionFor: 0, }; private closeGuardInFlight = false; /** Opens whose outcome came back unknown, by account: the position one * made is the bot's once a read shows it (adoptUnknownOpen) — no exits * went on it, so it's the leg guardClose most needs to see. */ private unknownOpens = 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(); /** 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(); private tradesFetchInFlight = false; /** 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; 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; 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(); } 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 other = listVestSessions().find((s) => s.primaryAddr !== this.wallet && s.expiresAt > Date.now()); const msg = other ? `no Vest session for ${this.wallet} — the Vest tab here is signed in as ${other.primaryAddr}; sign in as ${this.wallet} there, or remove this firm's credential on the dashboard to switch to the tab's login` : Date.now() - this.scopedRefusedAt < 30 * 60_000 ? `no Vest session for ${this.wallet} — the extension relayed one account's own token, not the login's; reload the Vest tab (or update the extension) to relay the login's` : `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. * An account token that can't be minted is VestNotSentError: the call * itself never went out. 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' | 'PUT' | '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) { const account = opts.account; token = account ? await tokenForRequest(path, () => this.accountToken(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(); void this.guardReset().catch((err) => console.error(`${this.tag} reset guard failed: ${errorMessage(err)}`)); void this.guardClose().catch((err) => console.error(`${this.tag} market-close guard failed: ${errorMessage(err)}`)); } // ── 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; const token = this.userToken(); if (!token) 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); // One account's own token (the Vest app renews those through the // same endpoint the extension watches) sees that account alone: // syncing on it would drop every other account from the list. const hasPrimary = (bal.data?.accounts ?? []).some((b) => num(b.account_type) === 1); if (accountScopedToken({ capitalIds: accounts.map((a) => a.id), hasPrimary, claims: jwtClaimStrings(token) })) { refuseVestSession(this.wallet, token); this.scopedRefusedAt = Date.now(); console.warn(`${this.tag} the relayed token is one Vest account's own (it sees ${accounts.length} account, no primary), not the login's — refused; keeping the last account list until the login's token comes`); return; } 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); // The login's own wallet account: where Claim Profit sends a payout. if (typeof b.account_id === 'string' && num(b.account_type) === 1) this.primaryAccountId = b.account_id; } 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); this.adoptUnknownOpen(id, mine, startedAt); 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); // A claim made while this sync was out: what it read predates // the payout, and writing it back would show the profit again — // a second claim of it. The claim's own booking stands until a // sync that started after it. if ((this.claimSeq.get(id) ?? -1) >= gen && this.accountCashBalances[id]) continue; this.accountCashBalances[id] = { amount: round2(balance), realizedPnL: this.realizedSince(id, a, balance, now), 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}` : ''}`; } 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)) { const id = Number(key); if (!list.some((a) => a.id === id)) { delete this.positions[id]; this.positionsDetail.delete(id); this.seenOpenAt.delete(id); } } 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(); this.maybeRefreshTrades(); 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); this.recordedOpen.delete(seen.positionId); this.tradesDue.add(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: Vest states the day's floor itself — reset balance * × (1 − rate), lowered by a payout the same day — so the engine's room * is what's left of the stage's limit, widened by dayGainForRoom, never * more than balance − floor (floorRoom). null until the account has been read; undefined for an account * Vest gives no daily limit. */ public dailyLossFloor(accountId: number): number | null | undefined { const a = this.capital.get(accountId); const cash = this.accountCashBalances[accountId]; if (!a || !cash) return null; if (!(a.maxDailyLossPct > 0)) return undefined; return this.dailyFloorOf(a, cash.amount, Date.now()) ?? null; } /** See PlatformClient: Vest's own day (vestDayOf, 20:00 ET to 20:00 ET), * which its daily rows, realized P&L (realizedSince) and floors follow. */ public tradeDate(now: number): string { return vestDayOf(now); } /** * See PlatformClient: the gain since the day's base that is still in the * account — since this client's own claim today, else since the reset, * with no add-back for a claim made by hand (that profit is gone) — up to * the stop that keeps the leg all but sure to close before the 8 PM ET * reset at the firm's target hold (resetClearStop); none before Vest has * captured today's reset balance. */ public dayGainForRoom(accountId: number, o: { leg: number; limit: number }): number { const now = Date.now(); const hold = sizingHoldHours(getCachedFirm(this.cfg.firmId), this.tradeDate(now)); const allowed = resetClearStop({ leg: o.leg, resetInMs: nextVestResetMs(now) - now, holdHours: hold }) - o.limit; if (!(allowed > 0)) return 0; const a = this.capital.get(accountId); const cash = this.accountCashBalances[accountId]; if (!a || !cash) return 0; const mine = this.madeClaims(accountId).filter((c) => c.at >= lastVestResetMs(now)); const base = mine.length > 0 ? mine[mine.length - 1].base : Number.isFinite(a.dailyResetAt) && a.dailyResetAt >= lastVestResetMs(now) ? a.dailyResetBalance : undefined; return base === undefined || !Number.isFinite(base) ? 0 : Math.max(0, Math.min(round2(cash.amount - base), round2(allowed))); } private claimsOf(accountId: number): OwnClaim[] { let list = this.claims.get(accountId); if (!list) { try { const raw = JSON.parse(loadAccountMeta(accountId, CLAIMS_META) ?? '[]'); list = Array.isArray(raw) ? raw.filter((c) => Number.isFinite(c?.at) && Number.isFinite(c?.amount) && Number.isFinite(c?.base)) .map((c): OwnClaim => ({ at: c.at, amount: c.amount, base: c.base, ...(Number.isFinite(c.sentAt) ? { sentAt: c.sentAt } : {}), ...(c.unknown === true ? { unknown: true as const } : {}), })) : []; } catch { list = []; } this.claims.set(accountId, list); } return list; } /** The claims this client knows went in (the platform said so). */ private madeClaims(accountId: number): OwnClaim[] { return this.claimsOf(accountId).filter((c) => !c.unknown); } private saveClaims(accountId: number, list: OwnClaim[]): void { const now = Date.now(); const kept = list.filter((c) => now - c.at < 7 * 86_400_000); this.claims.set(accountId, kept); saveAccountMeta(accountId, CLAIMS_META, JSON.stringify(kept)); } /** The claims Vest's transactions, as last read (at `readAt`), don't * show yet. One of unknown outcome still not shown an hour on never went * in: it's dropped here (settleClaims). */ private outstandingClaims(accountId: number, readAt: number): OwnClaim[] { const { pending, lapsed } = settleClaims(this.claimsOf(accountId), this.withdrawals.get(accountId) ?? [], readAt); this.forgetLapsed(accountId, lapsed); return pending; } /** Drop claims of unknown outcome that never went in (settleClaims). */ private forgetLapsed(accountId: number, lapsed: OwnClaim[]): void { if (lapsed.length === 0) return; const name = this.accountList.find((a) => a.id === accountId)?.name ?? accountId; this.saveClaims(accountId, this.claimsOf(accountId).filter((c) => !lapsed.includes(c))); for (const c of lapsed) console.warn(`${this.tag} ${name}: the claim of $${c.amount} with an unknown outcome never showed on Vest — taken as not made`); } /** * Today's realized P&L as the day plan wants it: since the latest claim * this client made since the last reset — the plan's new cycle starts * there — else since the reset. A claim made by hand on Vest, whose * after-balance this client never saw, is added back to the reset balance * instead (Vest's reset balance doesn't take the payout out). */ private realizedSince(accountId: number, a: VestCapitalAccount, balance: number, now: number): number { const reset = lastVestResetMs(now); const mine = this.madeClaims(accountId).filter((c) => c.at >= reset); if (mine.length > 0) return round2(balance - mine[mine.length - 1].base); const resetBalance = this.resetBalanceOf(a, balance, now); if (resetBalance === undefined) return 0; const byHand = (this.withdrawals.get(accountId) ?? []).filter((w) => w.at >= reset).reduce((s, w) => s + Math.abs(w.amount), 0); return round2(balance - resetBalance + byHand); } /** See PlatformClient: the payouts since the last 20:00 ET — this client's * own claims, on Vest's day once its transactions show them, or the * WITHDRAW transactions Vest shows, whichever counts more (a claim made * by hand shows only there). */ public sameDayClaims(accountId: number): number { return payoutsSince(this.withdrawals.get(accountId) ?? [], this.claimsOf(accountId), lastVestResetMs(Date.now())); } /** See PlatformClient: one per payout, on its Vest day — Vest's WITHDRAW * transactions, and this client's claims they don't show yet * (payoutDaysOf) — where a fund row holds a whole Vest day's. Undefined * until the account's history is read. */ public payoutDays(accountId: number): string[] | undefined { const withdrawals = this.withdrawals.get(accountId); const fundRows = this.fundTransactions[accountId]; if (!withdrawals || !fundRows || !this.historyLoadedIds.has(accountId)) return undefined; return payoutDaysOf(withdrawals, this.claimsOf(accountId), fundRows); } /** * See PlatformClient. Vest's Claim Profit: POST /v3/capital/withdraw from * the funded account to the login's main account, which pays the profit * split within 24 hours. The account must be flat with no orders, and the * claim at least $1 of the profit above its starting balance. * * The payout is booked here at once — today's fund row, the cycle cut, * the P&L base — so the account can trade its next stage without waiting * for the hourly history read, which then rebooks it from Vest's own * transactions (the same day's row, the same amount — or the day Vest * dates it, when a claim at 20:00 ET lands on the other side). */ public async claimProfit(accountId: number, amount: number): Promise<{ claimed: number; received: number | null }> { const uuid = this.uuidById.get(accountId); const account = this.accountList.find((a) => a.id === accountId); const cash = this.accountCashBalances[accountId]; const meta = this.capital.get(accountId); if (!uuid || !account || !cash || !meta) throw new Error('account not read yet'); if (!this.primaryAccountId) throw new Error('the main account is unknown — no sync has listed it'); if (this.positions[accountId] || (this.openOrders.get(accountId) ?? []).length > 0) throw new Error('positions or orders open'); if (this.claimPending(accountId)) throw new Error("the last claim hasn't shown in Vest's transactions yet"); // Vest's own word over the account type assigned here: only a funded // (or instant) account pays out, and only above its starting capital. if (meta.kind !== 'funded' && !/instant/i.test(meta.productType)) { throw new Error(`Vest shows ${account.name} as ${meta.kind || 'not funded'} — only a funded account's profit can be claimed`); } const claimed = Math.floor(amount * 100) / 100; if (!(claimed >= 1)) throw new Error(`$${claimed} is below Vest's $1 minimum claim`); if (meta.initialCapital > 0 && claimed > cash.amount - meta.initialCapital + 0.01) { throw new Error(`$${claimed} is more than the $${round2(cash.amount - meta.initialCapital)} above Vest's starting capital of $${meta.initialCapital}`); } // Taken before it goes out, which Vest dates it no earlier than: a // claim answered after 20:00 ET isn't the fresh day's payout until // Vest says so (payoutsSince). const sentAt = Date.now(); let data: { trader_amount?: unknown } | undefined; try { ({ data } = await this.request<{ trader_amount?: unknown }>('trade', 'POST', '/v3/capital/withdraw', { account: uuid, body: { account_id: uuid, amount: formatDecimal(claimed, 6), target_account_id: this.primaryAccountId, idempotency_key: randomUUID(), }, })); } catch (err) { // Only a refusal, or no account token to send it with, proves // nothing went in. A timeout, a reset or a 5xx may have: hold // every further claim on the account until Vest's transactions // say either way (claimPending). if (!nothingWentIn(err)) { this.saveClaims(accountId, [...this.claimsOf(accountId), { at: Date.now(), sentAt, amount: claimed, base: round2(cash.amount - claimed), unknown: true }]); this.claimSeq.set(accountId, this.syncGeneration); console.error(`${this.tag} ${account.name}: claim of $${claimed} — outcome unknown (${errorMessage(err)}); no further claim on it until Vest's transactions settle it`); void this.fetchDaysTraded([accountId]).catch(() => { /* the hourly read retries */ }); } throw err; } const received = num(data?.trader_amount) ?? null; const now = Date.now(); this.saveClaims(accountId, [...this.claimsOf(accountId), { at: now, sentAt, amount: claimed, base: round2(cash.amount - claimed) }]); this.claimSeq.set(accountId, this.syncGeneration); // Book it now: today's fund row (with whatever else that day holds), // the cycle cut, and the balance until the next sync reads Vest's. const day = vestDayOf(now); const sameDay = (this.fundTransactions[accountId] ?? []).find((f) => f.date === day)?.amount ?? 0; this.applyFundRows(account, [{ date: day, amount: round2(sameDay - claimed) }]); this.accountCashBalances[accountId] = { ...cash, amount: round2(cash.amount - claimed), realizedPnL: 0 }; console.log(`${this.tag} ${account.name}: claimed $${claimed}${received !== null ? ` — $${received} to the main account` : ''}`); void this.fetchDaysTraded([accountId]).catch((err) => console.warn(`${this.tag} history after the claim failed: ${errorMessage(err)}`)); // A fresh sync — after any one already out, whose reads predate this. void this.syncRunning.then(() => this.requestSync()); return { claimed, received }; } /** * See PlatformClient: a claim of this client's that Vest's transactions * don't show yet — until they do, the balance on hand can't be trusted * not to show the claimed profit again. One whose outcome was unknown, * and that a history read an hour on still doesn't show, never went in: * it's dropped. One Vest accepted holds for as long as it doesn't show * (noted once in the log after an hour). */ public claimPending(accountId: number): boolean { const now = Date.now(); const pending = this.outstandingClaims(accountId, this.historyReadAt.get(accountId) ?? 0); const name = this.accountList.find((a) => a.id === accountId)?.name ?? accountId; for (const c of pending) { const key = `${accountId}:${c.at}`; if (!c.unknown && now - c.at > 60 * 60_000 && !this.unmatchedClaimLogged.has(key)) { this.unmatchedClaimLogged.add(key); console.error(`${this.tag} ${name}: the claim of $${c.amount} an hour ago still doesn't show in Vest's transactions — no further claim on this account until it does`); } } return pending.length > 0; } /** * Before each 20:00 ET reset, fit every open position on an account with * a daily limit into the room the reset will leave (resetSafeStop): a * leg carrying more room than that — a same-day stage on the room a * payout freed, a last stand, a split day's leg widened by its gain, a * hand trade — would otherwise breach the moment the floor moves up to * 97% of the realized balance. Its stop comes in — on the profit side of * the entry when the account's other positions took the room — or the * position closes when the mark is already past that price. An account's * positions share the room: those with a stop first, least at risk * first, then those with none, oldest first. * * Every tick of the last RESET_GUARD_MS, on positions read afresh, so one * opened by hand after the first pass is fitted too; no new position * opens in that window (RESET_ENTRY_BLOCK_MS). */ private async guardReset(): Promise { if (!this.syncComplete || this.intentionalDisconnect || this.resetGuardInFlight) return; const now = Date.now(); const next = nextVestResetMs(now); if (!Number.isFinite(next) || next - now > RESET_GUARD_MS) return; if (this.resetGuard.reset !== next) { this.resetGuard = { reset: next, stops: new Map(), closes: new Map(), noSessionLogged: false, unfittable: new Set() }; } const guard = this.resetGuard; if (!vestSessionFor(this.wallet, now, USER_TOKEN_MIN_MS)) { const open = [...this.positionsDetail.values()].flat().length; if (open > 0 && !guard.noSessionLogged) { guard.noSessionLogged = true; console.error(`${this.tag} ${open} open position(s) can't be fitted to the 8 PM ET reset — no Vest session (open the Vest tab)`); } return; } this.resetGuardInFlight = true; try { const specs = await this.symbolSpecs(); const { data } = await this.request<{ positions?: unknown[] }>('trade', 'GET', '/v3/positions/opened'); const open = (data?.positions ?? []).map(parsePosition).filter((p): p is VestPosition => p !== null); const now2 = Date.now(); for (const acc of this.accountList) { const a = this.capital.get(acc.id); const cash = this.accountCashBalances[acc.id]; const uuid = this.uuidById.get(acc.id); if (!a || !cash || !uuid || !(a.maxDailyLossPct > 0)) continue; // The account's positions share the room: each is fitted to // what the ones before it leave — their loss at their stops, // and a reserve for the slippage of all of them together. // Those with a stop go first, the least at risk first, so one // that already fits keeps it; those with none get the rest. // The stop Vest shows wins (moved by hand after the guard, it // is fitted again); one the guard just placed counts only // while a read may not show it yet. A close just sent is left // for the same while. const stopOf = (p: VestPosition): number | null => { const placed = guard.stops.get(p.positionId); return p.stopLosses.find((l) => l.status === 'awaiting')?.triggerPrice ?? (placed && now2 - placed.at < GUARD_ECHO_MS ? placed.price : null); }; // What the position loses at its stop — negative when the stop // locks in a profit, which the others may then use. const lossAt = (p: VestPosition, stop: number) => (p.side === 'long' ? p.openPrice - stop : stop - p.openPrice) * p.quantity; const held = open .filter((p) => p.accountId === uuid && now2 - (guard.closes.get(p.positionId) ?? 0) >= GUARD_ECHO_MS) .map((p) => ({ pos: p, currentStop: stopOf(p) })) .sort((x, y) => (x.currentStop === null ? 1 : 0) - (y.currentStop === null ? 1 : 0) || (x.currentStop !== null && y.currentStop !== null ? lossAt(x.pos, x.currentStop) - lossAt(y.pos, y.currentStop) : 0) || x.pos.openedAt - y.pos.openedAt || x.pos.positionId.localeCompare(y.pos.positionId)); let committed = 0; let notionalSoFar = 0; for (const { pos, currentStop } of held) { const spec = specs.get(pos.symbol); if (!spec) { // Can't be fitted without its tick, but it still holds // its loss against the room the others share. notionalSoFar += pos.quantity * pos.openPrice; if (currentStop !== null) committed += lossAt(pos, currentStop); else if (!guard.unfittable.has(pos.positionId)) { guard.unfittable.add(pos.positionId); console.error(`${this.tag} ${acc.name}: ${pos.symbol} has no stop and no symbol spec — it can't be fitted to the 8 PM ET reset; close it or add a stop on Vest`); } continue; } await this.refreshMarks([pos.symbol]).catch(() => { /* the last mark stands */ }); const mark = this.marks.get(pos.symbol)?.mark ?? NaN; const stopLeg = pos.stopLosses.find((l) => l.status === 'awaiting') ?? null; const notional = pos.quantity * (Number.isFinite(mark) ? mark : pos.openPrice); notionalSoFar += notional; const exitFee = spec.takerFee * notional; const decision = resetSafeStop({ side: pos.side, quantity: pos.quantity, openPrice: pos.openPrice, mark, balance: cash.amount - committed, dailyLossPct: a.maxDailyLossPct, maxDrawdownFloor: a.maxDrawdownLimit, floorBalance: cash.amount, reserve: dllReserve(cash.amount * a.maxDailyLossPct, notionalSoFar, this.stopSlippageFraction), exitFeeRate: spec.takerFee, currentStop, tick: spec.tick, }); const stopAt = decision.action === 'stop' ? decision.price : decision.action === 'none' ? currentStop : null; if (stopAt !== null) committed += lossAt(pos, stopAt) + exitFee; if (decision.action === 'none') continue; try { if (decision.action === 'close') { await this.submit(uuid, '/v3/positions/close', { positionId: pos.positionId, symbol: pos.symbol, orderType: 'market' }); guard.closes.set(pos.positionId, Date.now()); 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); if (stopLeg) { await this.request('trade', 'PUT', '/v3/positions/stop-loss', { account: uuid, body: { positionId: pos.positionId, executionType: 'market', triggerPrice, stopLossId: stopLeg.id }, }); } else { await this.submit(uuid, '/v3/positions/stop-loss', { positionId: pos.positionId, executionType: 'market', triggerPrice }); } guard.stops.set(pos.positionId, { price: decision.price, at: Date.now() }); console.warn(`${this.tag} ${acc.name}: stop on ${pos.symbol} moved to ${triggerPrice} for the 8 PM ET reset (was ${currentStop ?? 'none'})`); } } catch (err) { console.error(`${this.tag} ${acc.name}: couldn't fit ${pos.symbol} to the reset: ${errorMessage(err)}`); // A stop on the profit side that Vest won't take: the // position can't carry its old one past the reset. if (decision.action === 'stop' && lossAt(pos, decision.price) < 0) { try { await this.submit(uuid, '/v3/positions/close', { positionId: pos.positionId, symbol: pos.symbol, orderType: 'market' }); guard.closes.set(pos.positionId, Date.now()); this.markOwn('closed', acc.id, pos); console.warn(`${this.tag} ${acc.name}: closed ${pos.symbol} before the 8 PM ET reset — its profit-side stop was refused`); } catch (closeErr) { console.error(`${this.tag} ${acc.name}: couldn't close ${pos.symbol} either: ${errorMessage(closeErr)}`); } } } } } } finally { this.resetGuardInFlight = false; } } /** * Before a long market closure — one that keeps a symbol's market shut * across an 8 PM ET reset: the weekend, a holiday (longClosure) — close * the bot's own legs in it still open in the last RESET_GUARD_MS, while * the market takes orders. Inside the closure nothing exits, the reset * guard's stops and closes are refused, and the reopen's gap jumps a * stop. No leg starts under RESET_CLEAR_HOLDS holds of a close * (pickCandidate), so this only meets one that outlived them. * * Only positions the bot's own order opened (markOwn, or an open of * unknown outcome that turned out filled: adoptUnknownOpen): one without * that mark — a hand trade, or one from before marking began — is left * as it is and named once in the log. The daily maintenance hour reopens * before the reset, and is ridden. */ private async guardClose(): Promise { if (!this.syncComplete || this.intentionalDisconnect || this.closeGuardInFlight) return; const held = [...new Set([...this.positionsDetail.values()].flat().map((p) => p.symbol))]; if (held.length === 0) return; this.closeGuardInFlight = true; try { const guard = this.closeGuard; const now = Date.now(); for (const [id, at] of guard.closes) if (now - at >= GUARD_ECHO_MS) guard.closes.delete(id); for (const [id, closesAt] of guard.named) if (closesAt < now) guard.named.delete(id); // This runs all day while anything is open: hours it can't read // are said once in a while, not every tick. const unreadable = (what: string, err: unknown) => { if (now - (guard.hoursWarnedAt.get(what) ?? 0) < MARKET_HOURS_TTL_MS) return; guard.hoursWarnedAt.set(what, now); console.warn(`${this.tag} ${what}: market hours unreadable — can't tell whether its market is about to close (${errorMessage(err)})`); }; let specs: Map; try { specs = await this.symbolSpecs(); } catch (err) { unreadable(held.join(', '), err); return; } // Each held symbol whose long closure is this near, and when. const closing = new Map(); for (const symbol of held) { const spec = specs.get(symbol); if (!spec) continue; let periods: unknown; try { periods = await this.marketHours(spec.assetId); } catch (err) { unreadable(symbol, err); continue; } const hours = marketOpen(periods, now); if (hours.open && hours.closesAt !== null && hours.closesAt - now <= RESET_GUARD_MS && longClosure(periods, hours.closesAt)) { closing.set(symbol, hours.closesAt); } } if (closing.size === 0) return; if (!vestSessionFor(this.wallet, now, USER_TOKEN_MIN_MS)) { const first = Math.min(...closing.values()); if (guard.noSessionFor !== first) { guard.noSessionFor = first; console.error(`${this.tag} the market closes in ${Math.ceil((first - now) / 60_000)} min until past an 8 PM ET reset, and the bot's open legs can't be closed — no Vest session (open the Vest tab)`); } return; } const readAt = Date.now(); const { data } = await this.request<{ positions?: unknown[] }>('trade', 'GET', '/v3/positions/opened'); const all = (data?.positions ?? []).map(parsePosition).filter((p): p is VestPosition => p !== null); for (const id of [...this.unknownOpens.keys()]) this.adoptUnknownOpen(id, all, readAt); const open = all.filter((p) => closing.has(p.symbol)); const mine = botOpenedPositions(open.map((p) => p.positionId)); for (const acc of this.accountList) { const uuid = this.uuidById.get(acc.id); if (!uuid) continue; for (const pos of open.filter((p) => p.accountId === uuid)) { const closesAt = closing.get(pos.symbol)!; const inMin = Math.max(0, Math.ceil((closesAt - Date.now()) / 60_000)); if (!mine.has(pos.positionId)) { if (guard.named.get(pos.positionId) !== closesAt) { guard.named.set(pos.positionId, closesAt); console.error(`${this.tag} ${acc.name}: ${pos.side} ${pos.symbol} isn't marked as the bot's (a hand trade, or a bot leg from before marking or a restart mid-entry) and stays open — its market closes in ${inMin} min until past an 8 PM ET reset; close it on Vest if it shouldn't carry the closure and the reopen's gap`); } continue; } // A close just sent counts until a read of open positions shows it. if (guard.closes.has(pos.positionId)) continue; try { await this.submit(uuid, '/v3/positions/close', { positionId: pos.positionId, symbol: pos.symbol, orderType: 'market' }); guard.closes.set(pos.positionId, Date.now()); this.markOwn('closed', acc.id, pos); console.error(`${this.tag} ${acc.name}: CLOSED the bot's ${pos.side} ${pos.quantity} ${pos.symbol} at market — its market closes in ${inMin} min until past an 8 PM ET reset, and nothing could exit it there`); } catch (err) { console.error(`${this.tag} ${acc.name}: couldn't close the bot's ${pos.symbol} before its market closes in ${inMin} min: ${errorMessage(err)}`); } } } } finally { this.closeGuardInFlight = false; } } /** An open of unknown outcome that a read (started at `readAt`) shows * filled after all: the position is the bot's (markOwn). One the read * doesn't show by then never filled — a market open is IOC. */ private adoptUnknownOpen(accountId: number, positions: VestPosition[], readAt: number): void { const sent = this.unknownOpens.get(accountId); if (!sent) return; const pos = unknownOpenFill(sent, positions); if (pos) { this.unknownOpens.delete(accountId); this.markOwn('opened', accountId, pos); const name = this.accountList.find((a) => a.id === accountId)?.name ?? String(accountId); console.warn(`${this.tag} ${name}: the open of unknown outcome filled after all — ${pos.side} ${pos.quantity} ${pos.symbol} (${pos.positionId}) marked as the bot's`); } else if (readAt > sent.until) { this.unknownOpens.delete(accountId); } } 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)); } 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); 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(); 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); } /** 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, 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', { ...(viaLogin ? {} : { 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); 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 readAt = Date.now(); const read = readClaims({ txns, funds, claims: this.claimsOf(account.id), stored: loadFundTransactions(account.id), readAt, opening: hasDeposit ? 0 : a?.initialCapital ?? 0, }); this.withdrawals.set(account.id, read.withdrawals); this.historyReadAt.set(account.id, readAt); // Settled here, not only in claimPending: its callers wait on // historyReady, which a claim that never went in would hold below // for good. this.forgetLapsed(account.id, read.lapsed); // A claim Vest dates on the other side of 20:00 ET than it was booked // here leaves that day's row to this client alone: taken back, so the // payout counts once, on Vest's day. if (read.dropped.length > 0) this.dropClaimDays(account, read.dropped); this.applyFundRows(account, read.rows); // 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]; // A claim this client just made may not show in Vest's transactions // yet: readClaims counts it until its WITHDRAW row does (same amount, // near time). const expected = read.expected; 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); } /** Store fund rows (one per Vest day), note where a cycle ended when the * latest fund day moves forward, and recut the active cycle. */ private applyFundRows(account: PlatformAccount, fundRows: { date: string; amount: number }[]): void { const storedFundDate = loadAccountMeta(account.id, 'last_fund_date'); if (fundRows.length > 0) saveFundTransactions(account.id, account.name, fundRows); this.fundTransactions[account.id] = loadFundTransactions(account.id); const latest = fundRows.length > 0 ? fundRows.map((f) => f.date).reduce((x, y) => (y > x ? y : x)) : null; const lastFundDate = latest && (!storedFundDate || latest > storedFundDate) ? latest : storedFundDate ?? latest; 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); } /** Take back fund rows only a claim of this client's put there (see * claimFundRows), and the latest fund date with them when it was one — * back to the latest row left. The ended-cycle start that date saved * stays: the next fund day saves the same one. */ private dropClaimDays(account: PlatformAccount, days: string[]): void { const stored = loadAccountMeta(account.id, 'last_fund_date'); const left = loadFundTransactions(account.id).map((f) => f.date).filter((d) => !days.includes(d)); const last = stored !== null && !days.includes(stored) ? stored : left.reduce((x, y) => (x === null || y > x ? y : x), null); for (const day of days) dropFundTransaction(account.id, day, { last_fund_date: last }); console.warn(`${this.tag} ${account.name}: the claim booked on ${days.join(', ')} shows on another Vest day — that row taken back`); } 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, this.tradeDate(Date.now())); 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(); // A leg a long closure (a weekend, a holiday) catches open can't be // exited until the market reopens, and meets the reopen's gap with // its stop: none starts under RESET_CLEAR_HOLDS holds of one — the // daily reset's clearance. The daily maintenance hour reopens before // the reset and is ridden, as futures legs ride CME's daily break. // Such a symbol stays on the board (the dashboard's candidates) but // isn't picked (legCloseNote); fireLeg holds each account's own leg // to it too. const hold = ctx?.targetHoldHours ?? targetHold; const board: { symbol: string; score: number | null; note: string; contract?: PlatformContract; closing?: boolean }[] = []; // No new position this close to the reset: nothing picked, and the // last survey's candidates stay up for the dashboard. const toReset = nextVestResetMs(now) - now; if (root !== 'survey' && toReset <= RESET_ENTRY_BLOCK_MS) { console.log(`${this.tag} ${root} → nothing tradable — Vest's 8 PM ET reset in ${Math.ceil(toReset / 60_000)} min`); return null; } 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 }; let gateAt: number | null; try { const periods = await this.marketHours(spec.assetId); hours = marketOpen(periods, now); gateAt = hours.closesAt !== null && longClosure(periods, hours.closesAt) ? hours.closesAt : null; } 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; } const closing = legCloseNote(gateAt, hold, now); 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` : '') + (closing ? `, ${closing}` : hours.closesAt ? `, open ${((hours.closesAt - now) / 3_600_000).toFixed(1)}h more` : ''), closing: closing !== null, 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, closesAt: gateAt ?? undefined, }, }); } let ranked = board.filter((b) => b.contract && !b.closing).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)}`; // Held short of the target hold (a leverage cap), the leg // runs longer: it has to clear the close as well. const closing = legCloseNote(b.contract!.closesAt, ev.holdHours, now); if (closing) { b.note += `, ${closing}`; return []; } 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 — * only the one `intent` names, when given (orderEffect). 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, intent?: OrderIntent, ): Promise> { try { return await this.sendOrderAndWait(accountId, contractSymbol, quantity, action, orderType, price, intent); } 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, intent?: OrderIntent, ): 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 held = positions.find((p) => p.symbol === symbol); const recorded = this.positionsDetail.get(accountId)?.find((p) => p.symbol === symbol)?.side ?? null; // orderEffect picks open or close; `existing` is the position a close closes. const existing = orderEffect({ symbol, held: held?.side ?? null, recorded, side, intent }) === 'close' ? held : undefined; 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 toReset = nextVestResetMs(Date.now()) - Date.now(); if (toReset <= RESET_ENTRY_BLOCK_MS) { throw new OrderNotPlacedError(`Vest's 8 PM ET reset is ${Math.ceil(toReset / 60_000)} min away — no new position until it passes`); } 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(), }; 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, }; 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; } catch (err) { // An open that may have gone in: no exits will go on it, so the // position it made, once a read shows it, is marked the bot's. if (opening && !isOrderNotPlaced(err)) { this.unknownOpens.set(accountId, { accountId: uuid, symbol, side, from: sentAt - UNKNOWN_OPEN_SLACK_MS, until: Date.now() + UNKNOWN_OPEN_SLACK_MS, }); } throw err; } 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 a failure that proves nothing was placed becomes * OrderNotPlacedError (a 4xx with the gateway's refusal, or no account * token to send it with); 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 VestNotSentError) throw new OrderNotPlacedError(`Vest order not sent: ${err.message}`); if (nothingWentIn(err)) throw new OrderNotPlacedError(`Vest refused the order: ${errorMessage(err)}`); 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(); } }