From 96fdd9ca236866e7589b0258b3a174de737a752d Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Mon, 26 Oct 2026 14:19:31 -0500 Subject: [PATCH] Vest platform: a client for Vest Markets, traded on a session the extension relays Vest (next.vestmarkets.com) is a perps prop firm: index, metal and energy perps on CME hours, plus stocks, FX and crypto. It joins as a fourth platform, 'vest', on the DXtrade engine paths. Those are now keyed by isNotionalPlatform() instead of === 'dxtrade': the firm's own symbols, percent fees, notional sizing from volatility, and a scheduler lane of its own. - Login is relayed, not stored. Vest signs people in with Google through Privy and renews the session with a device key the browser won't export. Extension 0.17.0's vest-relay.js reads the token the Vest app renews for itself (~15 min). It renews nothing, so the tab is never logged out. The background script posts that token to POST /api/vest/session, which holds it in memory only. With no Vest tab open on a machine that trades Vest, the extension opens one. - The firm's credential is the Vest login's wallet address. The dashboard's form offers the relayed wallet with one click. - VestClient (lib/vest-class.ts), mapped from the web app's traffic and code since Vest publishes no API: - Reads the capital accounts, balances (free amount plus position collateral), positions and open orders with the user token. - Mints per-account tokens for orders and transactions. - Names accounts for their rules (V50K-E-D3-T10-34, V50K-F-D3-34, V25K-I-7) so account-type prefixes can match. - Takes Vest's own max-drawdown and daily-loss floors. dayStartBalance = floor + the type's limit, so the engine's room is never more than Vest's. - Uses each symbol's taker fee and margin rate from exchangeInfo, and hourly volatility over in-session hours only. - Places take-profit and stop as position legs, which Vest cancels as an OCO pair. - Daily P&L and fund rows come from /v3/transactions by Vest's 20:00 ET day. A books check holds an account, naming the transaction types, when the balance and the transactions disagree. - scripts/check-vest.mjs (in npm run check): Vest's day across DST, account names, parsing, volatility gaps, market hours, the session store. Not yet run against Vest with a live session: the order and exit bodies come from the app's code, and the transaction classification from its enum. Co-Authored-By: Claude Opus 5.5 --- README.md | 44 + app/api/firms/[id]/credentials/route.ts | 8 +- app/api/vest/session/route.ts | 57 + app/page.tsx | 84 +- extension/README.md | 25 + extension/background.js | 106 ++ extension/manifest.json | 11 +- extension/popup.js | 1 + extension/vest-relay.js | 68 ++ lib/auto-trade.ts | 18 +- lib/clients.ts | 10 +- lib/firm-catalog.ts | 16 +- lib/instrument-specs.ts | 8 +- lib/instruments.ts | 3 +- lib/master-protocol.ts | 10 +- lib/platform-client.ts | 11 + lib/trade-mix.ts | 3 +- lib/vest-class.ts | 1342 +++++++++++++++++++++++ lib/vest-helpers.ts | 407 +++++++ lib/vest-session.ts | 87 ++ package.json | 2 +- scripts/check-vest.mjs | 227 ++++ 22 files changed, 2520 insertions(+), 28 deletions(-) create mode 100644 app/api/vest/session/route.ts create mode 100644 extension/vest-relay.js create mode 100644 lib/vest-class.ts create mode 100644 lib/vest-helpers.ts create mode 100644 lib/vest-session.ts create mode 100644 scripts/check-vest.mjs diff --git a/README.md b/README.md index 8c5b5cf..5abd619 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,50 @@ Use `brokerWebSocket` or `signalrWebSocket` from `lib/outbound-proxy.ts` for bro sockets, and `directWebSocket` for control sockets. All share the same user agent; the broker proxy still applies only to broker traffic. +## Vest firms (Vest Markets) + +Vest (next.vestmarkets.com) is a perps prop firm: index, metal and energy perps +on CME hours, plus stocks, FX and crypto. Set it up on the master's **Firms** +page with platform **Vest**. It shares the DXtrade engine: its own symbols +(new firms start on `NDX-USD-PERP`, `SPX-USD-PERP`, `GC-PERP`, standing in for +NQ, ES and GC), notional sizing from volatility, percent fees, hard daily-loss +stops, and a scheduler lane of its own. Leave **Trades every day** off for the +index and metal perps: they close for weekends and 5–6 PM ET. + +**Login is relayed, not stored.** Vest signs people in with Google (through +Privy) and renews the session with a device key the browser keeps, so this +instance cannot log in by itself. Instead: + +1. In Chrome on this machine (with the AutoFirmer extension, 0.17.0+), open + next.vestmarkets.com and sign in. The person signs in once; Vest keeps the + session for 30 days. +2. The extension relays the tab's short-lived access token to this dashboard + (`POST /api/vest/session`, held in memory only) every time the Vest app + renews it. If no Vest tab is open, the extension opens one in the background. +3. On the dashboard, the Vest firm's credential is the **wallet address** of + that login. The credential form offers the relayed wallet with one click. + +With no live session for the wallet, the client sits unauthenticated and says +so; it never tries to log in. + +What the client reads from Vest (lib/vest-class.ts, mapped from the web app's +own traffic since Vest publishes no API): + +- Accounts: every eval and funded account on the login, named for their rules, + e.g. `V50K-E-D3-T10-34` (50K eval, 3% daily loss, 10% target, attempt 34), + `V50K-F-D3-34` (funded) and `V25K-I-7` (instant). Account-type prefixes on the + master match those names. +- Floors: Vest states each account's max-drawdown floor (the auto-liq threshold + here) and daily-loss floor. The engine's daily room is never more than Vest + leaves. +- Fees and leverage: each symbol's taker fee (0.25 bp on the index and metal + perps) and margin rate, from Vest's exchange info. Set the firm's + **Commission %** to the same (`0.0025`) for the pre-trade checks. +- Exits: the take-profit and stop are legs of the position on Vest's side, + and Vest cancels the other when one triggers. + +`npm run check` includes the Vest helpers (scripts/check-vest.mjs). + --- ## The master dashboard diff --git a/app/api/firms/[id]/credentials/route.ts b/app/api/firms/[id]/credentials/route.ts index b69dac2..e217111 100644 --- a/app/api/firms/[id]/credentials/route.ts +++ b/app/api/firms/[id]/credentials/route.ts @@ -16,7 +16,9 @@ type Params = { params: Promise<{ id: string }> }; * and login domain are set on the master, per firm). TopstepX firms need the * API key; username is optional for now (the loginKey call still wants it, * so the client sits idle with a clear error until one is added) and - * password is unused. */ + * password is unused. Vest firms need the login's wallet address in the API + * key slot: Vest's session is relayed by the extension from a signed-in Vest + * tab (lib/vest-session.ts), and the address says whose to trade. */ export async function PUT(req: NextRequest, { params }: Params) { const { id } = await params; @@ -33,7 +35,9 @@ export async function PUT(req: NextRequest, { params }: Params) { ? 'apiKey is required for a TopstepX firm' : firm.platform === 'dxtrade' ? 'username and password are required for a DXtrade firm' - : 'username and password are required'; + : firm.platform === 'vest' + ? 'a Vest firm needs the wallet address of the Vest login (0x followed by 40 hex characters)' + : 'username and password are required'; return NextResponse.json({ error }, { status: 400 }); } diff --git a/app/api/vest/session/route.ts b/app/api/vest/session/route.ts new file mode 100644 index 0000000..e0f89ba --- /dev/null +++ b/app/api/vest/session/route.ts @@ -0,0 +1,57 @@ +import { NextRequest } from 'next/server'; +import { putVestSession, listVestSessions } from '@/lib/vest-session'; +import { getTradableFirms } from '@/lib/firm-catalog'; +import { getClients } from '@/lib/clients'; +import { corsJson, corsPreflight } from '../../autobuyer/cors'; + +/** The wallets this machine's Vest firms trade (their saved credential), lower-case. */ +function wantedWallets(): string[] { + const wallets = getTradableFirms() + .filter((f) => f.platform === 'vest') + .map((f) => f.apiKey.trim().toLowerCase()); + return [...new Set(wallets)]; +} + +/** + * The extension's Vest relay (lib/vest-session.ts): POST the access token the + * signed-in Vest tab just renewed — { accessToken, expiresAt, primaryAddr, + * userId } — and it is held in memory for this machine's Vest client. GET + * lists what has been relayed (never the tokens) and which wallets this + * machine wants, so the extension knows whether to keep a Vest tab open and + * the dashboard can offer the address as the firm's credential. + * + * Same LAN-only, any-origin CORS as the AutoBuyer routes: the extension's + * service worker calls it. + */ +export async function POST(req: NextRequest) { + let body: Record; + try { + body = await req.json() as Record; + } catch { + return corsJson({ error: 'JSON body required' }, { status: 400 }); + } + const refused = putVestSession({ + accessToken: body.accessToken, + expiresAt: body.expiresAt, + primaryAddr: body.primaryAddr, + userId: body.userId, + }); + if (refused) return corsJson({ ok: false, reason: refused }, { status: 400 }); + + const wanted = wantedWallets(); + const addr = String(body.primaryAddr).toLowerCase(); + // A client waiting on this wallet's session syncs now, not at its next poll. + for (const client of getClients().values()) { + const vest = client as { onVestSession?: (wallet: string) => void }; + vest.onVestSession?.(addr); + } + return corsJson({ ok: true, used: wanted.includes(addr), wanted }); +} + +export async function GET() { + return corsJson({ sessions: listVestSessions(), wanted: wantedWallets() }); +} + +export async function OPTIONS() { + return corsPreflight(); +} diff --git a/app/page.tsx b/app/page.tsx index 2fd82be..775fe5b 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -195,14 +195,89 @@ function credentialNoun(platform: FirmConfig['platform']): string { case 'topstepx': return 'TopstepX API key'; case 'dxtrade': return 'DXtrade login'; case 'tradovate': return 'Tradovate login'; + case 'vest': return 'Vest wallet address'; } } /** Inline credential entry, shown to the right of a firm that has none. * Tradovate and DXtrade want username + password; TopstepX wants the API key * (username optional for now, though login needs it eventually — password - * unused). */ + * unused); Vest the login's wallet address, whose session the extension + * relays from a signed-in Vest tab. */ function CredentialForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => void }) { + if (firm.platform === 'vest') return ; + return ; +} + +/** A Vest firm's credential: the wallet its Vest login belongs to. The + * extension has relayed the session of any Vest tab signed in on this + * machine, so those wallets are offered with one click. */ +function VestWalletForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => void }) { + const [wallet, setWallet] = useState(''); + const [seen, setSeen] = useState([]); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + fetch('/api/vest/session', { cache: 'no-store' }) + .then((r) => r.json()) + .then((d: { sessions?: { primaryAddr: string }[] }) => setSeen([...new Set((d.sessions ?? []).map((x) => x.primaryAddr))])) + .catch(() => setSeen([])); + }, []); + + async function save(address: string) { + setSaving(true); + setError(''); + try { + const res = await fetch(`/api/firms/${firm.id}/credentials`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ apiKey: address.trim() }), + }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? 'Failed'); + setWallet(''); + onSaved(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed'); + } finally { + setSaving(false); + } + } + + const valid = /^0x[0-9a-fA-F]{40}$/.test(wallet.trim()); + return ( +
e.stopPropagation()}> + {seen.map((address) => ( + + ))} + 0 ? 'or another wallet 0x…' : 'Vest wallet 0x… (sign in to Vest in Chrome first)'} + value={wallet} + onChange={(e) => setWallet(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter' && valid) void save(wallet); }} + /> + + {error && {error}} +
+ ); +} + +function LoginForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => void }) { const isTopstepX = firm.platform === 'topstepx'; const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); @@ -503,7 +578,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
{firm.firm} - {firm.platform !== 'dxtrade' && tradeSymbol !== 'Auto' && !firm.instruments?.includes(tradeSymbol) && ( + {firm.platform !== 'dxtrade' && firm.platform !== 'vest' && tradeSymbol !== 'Auto' && !firm.instruments?.includes(tradeSymbol) && ( {tradeSymbol} not listed @@ -518,6 +593,11 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo DXtrade )} + {firm.platform === 'vest' && ( + + Vest + + )} {firm.platform === 'topstepx' && hasCredentials && !firm.username && ( Date.now() + 30000 ? s : null }; +} + +/** The wallets the dashboard's Vest firms trade; [] from an older dashboard. */ +async function fetchVestWanted(cfg) { + try { + const res = await fetch(`${cfg.apiBase}/api/vest/session`, { cache: 'no-store' }); + if (!res.ok) return []; + const body = await res.json(); + return Array.isArray(body.wanted) ? body.wanted : []; + } catch { + return []; + } +} + +async function relayVestSession(cfg) { + const now = Date.now(); + if (now - vestLastCheck < VEST_EVERY_MS) return; + vestLastCheck = now; + + const tabs = (await chrome.tabs.query({ url: VEST_PATTERN })).filter((t) => /^https:/.test(t.url || '')); + if (tabs.length === 0) { + // This machine trades Vest: open the app in the background. It signs + // itself back in from its 30-day session, and the hook catches that. + const wanted = await fetchVestWanted(cfg); + if (wanted.length === 0) return; + if (now - vestOpenedAt > VEST_REOPEN_MS) { + vestOpenedAt = now; + await chrome.tabs.create({ url: VEST_URL, active: false }); + await setState({ vest: 'Opened a Vest tab to relay its session' }); + } else { + await setState({ vest: 'No Vest tab open' }); + } + return; + } + + let found = null; + for (const tab of tabs) { + // A discarded tab stops renewing the session; keep Chrome from it. + if (tab.autoDiscardable !== false) chrome.tabs.update(tab.id, { autoDiscardable: false }).catch(() => {}); + const [injection] = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, world: 'MAIN', func: pageVestSession, + }).catch(() => [null]); + const out = injection?.result; + if (out && !out.hooked) { + // A tab open from before this build: hook it now, and its app's + // next renewal is caught. + await chrome.scripting.executeScript({ target: { tabId: tab.id }, world: 'MAIN', files: ['vest-relay.js'] }).catch(() => {}); + } + if (out?.session) { found = out.session; break; } + } + if (!found) { + await setState({ vest: 'Vest tab open — waiting for its session (sign in there if it shows Log In)' }); + return; + } + + const fingerprint = `${found.expiresAt}:${found.accessToken.length}:${found.accessToken.slice(-16)}`; + if (fingerprint === vestLastRelayed) return; + const res = await fetch(`${cfg.apiBase}/api/vest/session`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + accessToken: found.accessToken, expiresAt: found.expiresAt, primaryAddr: found.primaryAddr, userId: found.userId, + }), + }); + const body = await res.json().catch(() => ({})); + if (res.status === 404) throw new Error('this dashboard is too old for the Vest relay'); + if (!res.ok) throw new Error(`dashboard refused the session: ${body.reason || body.error || `HTTP ${res.status}`}`); + vestLastRelayed = fingerprint; + const short = `${found.primaryAddr.slice(0, 6)}…${found.primaryAddr.slice(-4)}`; + const mins = Math.max(0, Math.round((found.expiresAt - Date.now()) / 60000)); + await setState({ vest: `Relayed ${short}${body.used ? '' : ' (no Vest firm here uses this wallet)'} · good ${mins}m` }); } // ── 2FA: the person's authenticator code for the firm site you are on ─────── diff --git a/extension/manifest.json b/extension/manifest.json index 22e30ee..6a7f136 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,18 +1,19 @@ { "manifest_version": 3, "name": "AutoFirmer Capture", - "version": "0.16.0", + "version": "0.17.0", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxdbOMRR+58a9dHlbIKtlALtS3E4eltBGqfTnUc0lzJuWj5ZA4+5YFSdUnxv5N1ogxwpJUGkUV3MnKJuZnTekCmsNwjCy41a20Xtc7jQLHrIBZkesB6cB+d0l9Vo8M6EC3ZMD8Iv6MdAalU3GZh1Hn9vm24NjJ+xUT1qNIVHEcNUIb9evYobR/XHv+JAp6bOFnaLM+3wpyYKe4efMhsz0F86u9thRNhQnraTqUWfnKNpsHPK2zivaYHrziEEWX7Y3EsMHkf2FRhkUe7IDoxeJM49DBToTo9sAJUkcgLeGCGZLo7H3mcxyn/zERYR+rD3k5xdeBtjoZHXFx2+FanLb9wIDAQAB", "update_url": "https://ext.juicerroom.com/updates.xml", - "description": "Scrapes the HTML of the firm tab you are viewing and posts it to the AutoFirmer dashboard, and shows the 2FA code for the firm site you are on.", + "description": "Scrapes the HTML of the firm tab you are viewing and posts it to the AutoFirmer dashboard, shows the 2FA code for the firm site you are on, and relays a signed-in Vest tab's session to the dashboard.", "permissions": ["scripting", "tabs", "storage", "alarms"], "host_permissions": [ "https://*.tradeify.co/*", "https://*.lucidtrading.com/*", "https://*.topstep.com/*", "https://*.apextraderfunding.com/*", + "https://*.vestmarkets.com/*", "http://localhost/*", "http://127.0.0.1/*" ], @@ -29,6 +30,12 @@ "run_at": "document_start", "all_frames": true, "world": "MAIN" + }, + { + "matches": ["https://next.vestmarkets.com/*"], + "js": ["vest-relay.js"], + "run_at": "document_start", + "world": "MAIN" } ], "action": { diff --git a/extension/popup.js b/extension/popup.js index 4637a2c..8416b03 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -29,6 +29,7 @@ async function render() { document.getElementById('status').innerHTML = `${label}` + detail + + (state.vest ? `
Vest: ${esc(state.vest)}
` : '') + (state.error ? `
${esc(state.error)}
` : ''); } diff --git a/extension/vest-relay.js b/extension/vest-relay.js new file mode 100644 index 0000000..ee2ee41 --- /dev/null +++ b/extension/vest-relay.js @@ -0,0 +1,68 @@ +/* AutoFirmer — Vest session hook. + * + * Runs in the page's MAIN world on next.vestmarkets.com, at document_start + * (manifest content script) or injected later into a tab that was already open + * (background.js relayVestSession). Installs once per page. + * + * Vest's app renews its own ~15-minute access token by itself: at load, then + * whenever one nears expiry (POST /v3/auth/refresh, or /v3/auth/register at + * sign-in). Those answers carry the token, its expiry and the login's wallet. + * This hook reads them off the app's own XHRs and keeps the latest on + * `window.__afVestSession`, where background.js collects it and relays it to + * the local dashboard, whose Vest client trades with it. + * + * It sends nothing anywhere, changes no request and never renews anything + * itself: the session stays the app's, refreshed by the app with its own + * device key, so the tab is never logged out by the bot. + */ + +(() => { + if (window.__afVestHook) return; + window.__afVestHook = true; + + const AUTH = /^https:\/\/api-gateway\.hz\.vestmarkets\.com\/v3\/auth\/(refresh|register)(\?|$)/; + + function take(url, status, text) { + if (status < 200 || status >= 300 || !AUTH.test(url)) return; + let body; + try { body = JSON.parse(text); } catch { return; } + if (!body || typeof body.accessToken !== 'string' || !body.accessToken) return; + const expiresAt = Number(body.accessExpiresAtMs); + if (!Number.isFinite(expiresAt)) return; + window.__afVestSession = { + accessToken: body.accessToken, + expiresAt, + primaryAddr: typeof body.primaryAddr === 'string' ? body.primaryAddr : '', + userId: typeof body.userId === 'string' ? body.userId : '', + at: Date.now(), + }; + } + + const open = XMLHttpRequest.prototype.open; + const send = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.open = function (method, url) { + try { this.__afVestUrl = String(url); } catch { /* ignore */ } + return open.apply(this, arguments); + }; + XMLHttpRequest.prototype.send = function () { + if (this.__afVestUrl && AUTH.test(this.__afVestUrl)) { + this.addEventListener('loadend', () => { + try { + if (this.responseType === '' || this.responseType === 'text') take(this.__afVestUrl, this.status, this.responseText); + else if (this.responseType === 'json') take(this.__afVestUrl, this.status, JSON.stringify(this.response)); + } catch { /* never break the app */ } + }); + } + return send.apply(this, arguments); + }; + + const fetch0 = window.fetch; + window.fetch = async function (input) { + const res = await fetch0.apply(this, arguments); + try { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input && input.url; + if (url && AUTH.test(url)) res.clone().text().then((t) => take(url, res.status, t)).catch(() => {}); + } catch { /* never break the app */ } + return res; + }; +})(); diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index 29533df..c3358bf 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -23,6 +23,7 @@ import { } from './trading-logic'; import { getSetting, setSetting, loadAccountMeta, saveAccountMeta } from './db'; import type { LaneTick, TickReason } from './master-protocol'; +import { isNotionalPlatform } from './master-protocol'; import { POINT_VALUES, housekeepingRoot, HOUSEKEEPING_ROOT, MAX_HOLD_HOURS, futuresContracts } from './instrument-specs'; import { firmInstruments, drawFuturesInstrument, spreadBlock, type LegShape } from './instruments'; import { recordEntry } from './trade-mix'; @@ -310,10 +311,11 @@ interface FirmClient { firm: TradableFirm; client: PlatformClient } * multi-hour crypto position never holds up the futures open and a stuck * futures position never freezes a 24/7 firm's weekend. Every CME-futures * firm (Tradovate, TopstepX) shares the one 'futures' lane — exactly how all - * firms traded before lanes existed; each DXTrade firm is a lane of its own. + * firms traded before lanes existed; each DXTrade or Vest firm is a lane of + * its own. */ export function laneOf(firm: { id: string; platform: string }): string { - return firm.platform === 'dxtrade' ? firm.id : FUTURES_LANE; + return isNotionalPlatform(firm.platform) ? firm.id : FUTURES_LANE; } const FUTURES_LANE = 'futures'; @@ -455,9 +457,9 @@ function releaseTradeMutex(lease: number, lane: string): void { /** What a firm's tiny legs (drain, extra-day) trade: a futures firm the * micro of what it lists (MNQ when NQ is listed, as always — see - * housekeepingRoot); a DXtrade firm its current pick. */ + * housekeepingRoot); a DXtrade or Vest firm its current pick. */ function housekeepingFor(firm: { platform: string; instruments?: string[]; bannedSymbols?: string[]; symbolMap?: Record }): string { - if (firm.platform === 'dxtrade') return '*'; + if (isNotionalPlatform(firm.platform)) return '*'; return housekeepingRoot(firmInstruments(firm)) ?? HOUSEKEEPING_ROOT; } @@ -1830,11 +1832,11 @@ export async function fireManualLeg(accountId: number, action: 'Buy' | 'Sell' | cash: ev.cash, dailyPnL: ev.dailyPnL, plan: ev.plan, drain: ev.drain, prefix: cfg.prefix, dllRoom: room, }; - // The instrument: a DXtrade firm ranks its list; a futures firm joins the - // held root, else trades an explicit scheduler symbol it lists, else - // draws from its list. + // The instrument: a DXtrade or Vest firm ranks its list; a futures + // firm joins the held root, else trades an explicit scheduler symbol + // it lists, else draws from its list. let symbol = '*'; - if (firm.platform !== 'dxtrade') { + if (!isNotionalPlatform(firm.platform)) { const listed = firmInstruments(firm); const state = getState(); const found = held.length > 0 ? await heldRoot(client, held[0].contractId, [...listed, housekeepingFor(firm)]) : null; diff --git a/lib/clients.ts b/lib/clients.ts index e08cc52..45058a1 100644 --- a/lib/clients.ts +++ b/lib/clients.ts @@ -1,6 +1,7 @@ import { TradovateClient } from './tradovate-class'; import { TopstepXClient } from './topstepx-class'; import { DXTradeClient } from './dxtrade-class'; +import { VestClient } from './vest-class'; import type { FirmPlatform } from './master-protocol'; import { errorMessage, isClientReady, type PlatformClient } from './platform-client'; import { getTradableFirms, startCatalogSync } from './firm-catalog'; @@ -102,6 +103,11 @@ export function initClient( case 'tradovate': client = new TradovateClient(creds.username, creds.password, onSyncComplete); break; + case 'vest': + // The saved "API key" is the Vest login's wallet: whose session, + // relayed by the extension, this client trades. + client = new VestClient({ firmId: id, firmName, wallet: creds.apiKey }, onSyncComplete); + break; default: { const unknown: never = platform; throw new Error(`no client for platform ${String(unknown)}`); @@ -376,10 +382,12 @@ const REBUILD_COOLDOWN_MS = 20 * 60_000; * and fixing the credentials (or the firm's settings) rebuilds them through * reconcileClients anyway. TopstepX with an API key but no username never * tries to log in; DXTrade with no server URL can't, and after refused - * credentials it stops trying so as not to lock the account. */ + * credentials it stops trying so as not to lock the account; Vest waits on + * the extension to relay a signed-in tab's session. */ function needsAttention(platform: FirmPlatform, client: PlatformClient): boolean { if (platform === 'topstepx') return /username missing/i.test(client.lastFetchErrors?.['login'] ?? ''); if (platform === 'dxtrade') return (client as DXTradeClient).needsAttention === true; + if (platform === 'vest') return (client as VestClient).needsAttention === true; return false; } diff --git a/lib/firm-catalog.ts b/lib/firm-catalog.ts index ac142b3..e71ac6b 100644 --- a/lib/firm-catalog.ts +++ b/lib/firm-catalog.ts @@ -17,7 +17,8 @@ * the two are not linked yet.) */ -import { isFirmPlatform } from './master-protocol'; +import { isFirmPlatform, isNotionalPlatform } from './master-protocol'; +import { isVestWalletAddress } from './vest-helpers'; import { sanitizeInstruments, sanitizeInstrumentLimits, DEFAULT_MAX_ENTRY_SPREAD } from './instrument-specs'; import { normalizeAccountType } from './kept-rules'; import type { @@ -170,7 +171,8 @@ export function getCatalogMeta(): { } /** Whether stored credentials are what the firm's platform logs in with — a - * TopstepX API key, or a username + password (Tradovate, DXTrade). */ + * TopstepX API key, a username + password (Tradovate, DXTrade), or for Vest + * the login's wallet address (its session is relayed by the extension). */ export function hasPlatformCredentials( platform: FirmPlatform, creds: { username: string; password: string; apiKey: string } | null | undefined, @@ -178,6 +180,7 @@ export function hasPlatformCredentials( if (!creds) return false; switch (platform) { case 'topstepx': return !!creds.apiKey; + case 'vest': return isVestWalletAddress(creds.apiKey); case 'tradovate': case 'dxtrade': return !!creds.username && !!creds.password; } @@ -296,9 +299,10 @@ function validatePayload(raw: unknown): FirmCatalogPayload { warnUnknownPlatform(firm.id, firm.name, platform); return; } - // Percent commission and every-day trading are DXtrade settings; - // futures firms keep measured per-contract fees and the CME calendar. - const dx = platform === 'dxtrade'; + // Percent commission and every-day trading are settings of the + // notional platforms (DXtrade, Vest); futures firms keep measured + // per-contract fees and the CME calendar. + const dx = isNotionalPlatform(platform); const tradesEveryDay = dx && firm.tradesEveryDay === true; if (!Array.isArray(firm.accountTypes)) throw new Error(`firm ${firm.name} is missing accountTypes`); @@ -317,7 +321,7 @@ function validatePayload(raw: unknown): FirmCatalogPayload { // The master's list when it sends one; absent from an older // master, whose bannedSymbols / symbolMap then apply. ...(Array.isArray(firm.instruments) ? { instruments: sanitizeInstruments(platform, firm.instruments) } : {}), - ...(platform !== 'dxtrade' ? { instrumentLimits: sanitizeInstrumentLimits(firm.instrumentLimits) } : {}), + ...(!dx ? { instrumentLimits: sanitizeInstrumentLimits(firm.instrumentLimits) } : {}), bannedSymbols: Array.isArray(firm.bannedSymbols) ? (firm.bannedSymbols as string[]) : [], accountTypes, ...(keptAccountRules ? { keptAccountRules } : {}), diff --git a/lib/instrument-specs.ts b/lib/instrument-specs.ts index 62e578c..230d5cf 100644 --- a/lib/instrument-specs.ts +++ b/lib/instrument-specs.ts @@ -259,7 +259,9 @@ const DXTRADE_SYMBOL = /^[A-Za-z0-9./_:-]{1,32}$/; * specs for, or valid DXtrade symbols. Deduplicated, in order. */ export function sanitizeInstruments(platform: string, raw: unknown): string[] { if (!Array.isArray(raw)) return []; - const futures = platform !== 'dxtrade'; + // Notional platforms (DXtrade, Vest) list their own symbols. Spelled out + // here: this file has no imports (see master-protocol isNotionalPlatform). + const futures = platform !== 'dxtrade' && platform !== 'vest'; const out: string[] = []; for (const value of raw) { if (typeof value !== 'string') continue; @@ -281,10 +283,10 @@ export function resolveAllowlist( localEnabled: readonly string[], ): { roots: string[]; source: 'master' | 'legacy' } { if (Array.isArray(firm.instruments)) return { roots: sanitizeInstruments(firm.platform, firm.instruments), source: 'master' }; - if (firm.platform === 'dxtrade') { + if (firm.platform === 'dxtrade' || firm.platform === 'vest') { const map = firm.symbolMap ?? {}; const keys = Object.keys(map).sort((a, b) => (a === '*' ? -1 : b === '*' ? 1 : a.localeCompare(b))); - return { roots: sanitizeInstruments('dxtrade', keys.flatMap((k) => (map[k] ?? []).map((c) => c.symbol))), source: 'legacy' }; + return { roots: sanitizeInstruments(firm.platform, keys.flatMap((k) => (map[k] ?? []).map((c) => c.symbol))), source: 'legacy' }; } const banned = new Set(firm.bannedSymbols ?? []); return { roots: sanitizeInstruments(firm.platform, localEnabled.filter((r) => !banned.has(r))), source: 'legacy' }; diff --git a/lib/instruments.ts b/lib/instruments.ts index 311ef86..4a52cdf 100644 --- a/lib/instruments.ts +++ b/lib/instruments.ts @@ -27,6 +27,7 @@ import { getCachedContract } from './contract-resolver'; import { currentTradeDateCT } from './trading-logic'; import { errorMessage } from './platform-client'; import type { InstrumentStats } from './master-protocol'; +import { isNotionalPlatform } from './master-protocol'; import { CME_SPECS, CME_ROOTS, dailyMoveDollars, futuresContracts, expectedHoldHours, sessionProgress, drawChances, pickByChance, resolveAllowlist, spreadTooWide, MAX_HOLD_HOURS, type DrawCandidate, @@ -56,7 +57,7 @@ export function instrumentsSource(firm: FirmLike): 'master' | 'legacy' { } export function isFuturesFirm(firm: { platform: string }): boolean { - return firm.platform !== 'dxtrade'; + return !isNotionalPlatform(firm.platform); } /** Every root a futures firm on this machine lists, in CME order — what the diff --git a/lib/master-protocol.ts b/lib/master-protocol.ts index 1a3df1f..88b8b35 100644 --- a/lib/master-protocol.ts +++ b/lib/master-protocol.ts @@ -409,13 +409,21 @@ export interface KeptAccountRules extends CatalogAccountType { /** Where a firm's accounts trade. A build only knows the platforms listed * here; the catalog sync skips a firm on any other rather than guess. */ -export const FIRM_PLATFORMS = ['tradovate', 'topstepx', 'dxtrade'] as const; +export const FIRM_PLATFORMS = ['tradovate', 'topstepx', 'dxtrade', 'vest'] as const; export type FirmPlatform = (typeof FIRM_PLATFORMS)[number]; export function isFirmPlatform(value: unknown): value is FirmPlatform { return typeof value === 'string' && (FIRM_PLATFORMS as readonly string[]).includes(value); } +/** Platforms whose firms trade their own symbols by notional (DXtrade's + * CFDs, Vest's perps) rather than CME contracts: the firm's instrument list + * is platform symbols, fees are a percent of notional, and each firm is a + * scheduler lane of its own. */ +export function isNotionalPlatform(platform: string | undefined): boolean { + return platform === 'dxtrade' || platform === 'vest'; +} + /** One symbol a DXTrade firm may trade for a CME root. Position size comes * from volatility and the firm's target hold, and the commission is the * firm's (an older master's notionalPerContract or per-symbol commissionPct diff --git a/lib/platform-client.ts b/lib/platform-client.ts index fdd9197..1f3b857 100644 --- a/lib/platform-client.ts +++ b/lib/platform-client.ts @@ -281,6 +281,17 @@ export function brokerFacts( if (platform === 'topstepx') { return { inactiveReason: 'cannot_trade', ...(account.active ? {} : { brokerStatus: 'canTrade:false' }) }; } + if (platform === 'vest') { + // Vest's capital account status is a number; 2 is the live one + // (vest-helpers VEST_STATUS_ACTIVE). A breached account leaves the + // active list, so no status here reads as terminal. + const status = meta?.status; + return { + inactiveReason: status ? `status:${status}` : 'inactive', + ...(status && status !== '2' ? { brokerStatus: `status:${status}` } : {}), + ...(meta && validTime(meta.registrationTime) ? { registeredAt: meta.registrationTime } : {}), + }; + } const status = meta?.status || undefined; return { inactiveReason: status === 'CLOSE_ONLY' ? 'close_only' : status ? `status:${status}` : 'inactive', diff --git a/lib/trade-mix.ts b/lib/trade-mix.ts index 96191fc..beb598f 100644 --- a/lib/trade-mix.ts +++ b/lib/trade-mix.ts @@ -16,6 +16,7 @@ import { currentTradeDateCT } from './trading-logic'; import { rootOfContract } from './contract-resolver'; import { errorMessage } from './platform-client'; import type { InstrumentCount, TradeMix } from './master-protocol'; +import { isNotionalPlatform } from './master-protocol'; const TODAY_KEY = 'trade_mix_today'; const NAMES_KEY = 'trade_mix_contracts'; @@ -78,7 +79,7 @@ export function tradeMixFor( const own = typeof client.contractRoots === 'function' ? (client.contractRoots() as Map) : null; for (const [id, root] of own ?? []) names.set(id, root); for (const [id, instrument] of Object.entries(readJson>(NAMES_KEY) ?? {})) names.set(id, instrument); - const instrumentOf = (contractId: unknown): string => (firm.platform === 'dxtrade' + const instrumentOf = (contractId: unknown): string => (isNotionalPlatform(firm.platform) ? String(contractId).split('|')[0] : names.get(String(contractId)) ?? rootOfContract(contractId as number | string) ?? `#${String(contractId)}`); diff --git a/lib/vest-class.ts b/lib/vest-class.ts new file mode 100644 index 0000000..fe2208a --- /dev/null +++ b/lib/vest-class.ts @@ -0,0 +1,1342 @@ +/** + * Vest client — Vest Markets' perps prop program (next.vestmarkets.com): US + * index, metal and energy perps on CME hours, stocks, FX and crypto. + * + * REST only: https://api-gateway.hz.vestmarkets.com. Mapped from the Vest web + * app's own traffic and code (2026-10-26); there is no published API. + * + * Login is not this file's: Vest signs people in through Google/Privy in the + * browser and binds the session to a key the browser won't export. The + * extension relays the signed-in Vest tab's ~15-minute access token to this + * machine (lib/vest-session.ts), keyed by the login's wallet — the firm's + * saved credential here. With no live token for that wallet the client sits + * unauthenticated and says so; it never tries to log in. + * + * - The user token reads everything account-wide: the capital accounts (each + * eval or funded account with its floors), balances, open positions and + * open orders. Orders, transactions and exits need a per-account token, + * minted from the user token (POST /v3/auth/account-token) and cached until + * it nears expiry. + * - Account ids are uuids; ids are a stable hash of them (vestAccountId) so + * every numeric account table keeps working. Names carry the account's rules + * (vestAccountName) for the account-type prefixes to match. + * - A balance is the free `amount` plus the collateral held by open positions + * — what Vest's daily reset and the DLL docs call the balance. Equity adds + * unrealized P&L at the mark. + * - Vest states each account's floors: max_drawdown_limit is the auto-liq + * threshold; the daily-loss floor, held against the account type's own + * limit, gives dayStartBalance — so the engine's room never exceeds Vest's. + * - No CME products: the firm's instrument list (master Firms page) names the + * Vest symbols it trades, and findFrontMonthContract picks among them as + * DXtrade does. One engine "contract" is one size step of the symbol, and the + * step rides in the contract name. + * - Exits are legs of the position (take-profit, stop-loss) that Vest itself + * cancels when the other triggers or the position closes: a native OCO, with + * nothing for this client to babysit. + * - Fees are each symbol's taker fee from exchangeInfo, a percent of notional. + */ + +import axios from './http-client'; +import { currentTradeDateCT, rankForTarget } from './trading-logic'; +import { sizingHoldHours } from './person-size'; +import { + saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions, + saveEndedCycleStart, +} from './db'; +import { getAccountConfig } from './account-type-resolver'; +import { getCachedFirm } from './firm-catalog'; +import { firmInstruments } from './instruments'; +import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth, PickContext } from './platform-client'; +import { OrderNotPlacedError, isOrderNotPlaced, errorMessage } from './platform-client'; +import { encodeContractName, decodeContractName, roundToStep, scoreCandidate } from './dxtrade-helpers'; +import { vestSessionFor, dropVestSession } from './vest-session'; +import { + vestAccountId, vestAccountName, vestDayOf, lastVestResetMs, parseCapitalAccount, parsePosition, parseOpenOrder, + parseTxn, bookTxns, awaitingLegs, unrealizedPnl, hourlyVolatility, marketOpen, num, toMs, formatDecimal, + VEST_STATUS_ACTIVE, +} from './vest-helpers'; +import type { VestCapitalAccount, VestPosition, VestOpenOrder, VestTxn } from './vest-helpers'; + +const BASE = 'https://api-gateway.hz.vestmarkets.com'; +/** The web app's origin: the gateway serves it, so requests say they are its. */ +const APP_ORIGIN = 'https://next.vestmarkets.com'; + +// ── timings ────────────────────────────────────────────────────────────────── + +const SYNC_INTERVAL_MS = 60_000; +const SYNC_WEDGED_MS = 3 * 60_000; +const WATCHDOG_INTERVAL_MS = 15_000; +/** Request spacing: the gateway's limits aren't published, so stay well under + * what its own app does. */ +const SPACING_MS = { read: 150, trade: 100, history: 400 } as const; +type Bucket = keyof typeof SPACING_MS; +const TIMEOUT_MS = { read: 20_000, trade: 15_000, history: 30_000 } as const; +/** A cached account token is renewed this long before it expires. */ +const ACCOUNT_TOKEN_SKEW_MS = 90_000; +/** The relayed user token must have this long left to start a request. */ +const USER_TOKEN_MIN_MS = 20_000; +const FILL_POLL_MS = 1_000; +const FILL_TIMEOUT_MS = 30_000; +const PICK_TTL_MS = 45_000; +const SURVEY_INTERVAL_MS = 30 * 60_000; +const EXCHANGE_INFO_TTL_MS = 60 * 60_000; +const MARKET_HOURS_TTL_MS = 15 * 60_000; +const CANDLE_RETRY_MS = 15 * 60_000; +/** Volatility lookback: a week of hourly candles, as on DXtrade. */ +const CANDLE_HOURS = 7 * 24; +/** Transactions per page, and the most pages one account's history reads. */ +const TXN_PAGE = 100; +const TXN_MAX_PAGES = 200; +/** Balance the books can't explain by this much holds the account. */ +const BOOKS_TOLERANCE = 200; +const SUSPECT_SETTLE_MS = 90_000; +/** A position this old with no awaiting exit leg is flagged as unprotected. */ +const NAKED_AFTER_MS = 2 * 60_000; +/** Vest's own spread isn't on its REST surface (only the depth socket): a + * candidate is costed as crossing this many ticks. */ +const ASSUMED_SPREAD_TICKS = 1; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const round2 = (n: number) => Math.round(n * 100) / 100; + +/** The gateway answered with an error status. */ +class VestApiError extends Error { + constructor(message: string, readonly status: number, readonly code: string | undefined) { + super(message); + this.name = 'VestApiError'; + } +} + +interface SymbolSpec { + symbol: string; + assetId: number; + sizeDecimals: number; + priceDecimals: number; + tick: number; + takerFee: number; + /** Initial margin per dollar of notional on a capital (prop) account. */ + marginRate: number; + tradingStatus: string; + capitalTradingStatus: string; +} + +export interface VestConfig { + firmId: string; + firmName: string; + /** The Vest login's primary wallet — whose relayed session this client + * trades (the firm's saved credential on this machine). */ + wallet: string; +} + +export class VestClient implements PlatformClient { + private readonly cfg: VestConfig; + private readonly wallet: string; + + public accountList: PlatformAccount[] = []; + public accountCashBalances: { [accountId: number]: { amount: number; realizedPnL: number; equity?: number; availableFunds?: number } } = {}; + public positions: { + [accountId: number]: { contractId: string; netPos: number; netPrice: number; timestamp: Date }; + } = {}; + + public daysTraded: { [accountId: number]: number } = {}; + public dailyPnL: { [accountId: number]: { date: string; pnl: number }[] } = {}; + public fullDailyPnL: { [accountId: number]: { date: string; pnl: number }[] } = {}; + public lastFundDates: { [accountId: number]: string | null } = {}; + public fundTransactions: { [accountId: number]: { date: string; amount: number }[] } = {}; + public priorProfit: { [accountId: number]: number } = {}; + public autoLiqThresholds: { [accountId: number]: number } = {}; + + public fetchDaysComplete = false; + public syncComplete = false; + public lastSyncAt = 0; + public lastSyncStartedAt = 0; + public lastFetchErrors: Record = {}; + public lastFetchRaw: Record = {}; + public recentEntityEvents: { entityType: string; eventType: string; entity: unknown; ts: number }[] = []; + /** Tradovate-only concept. */ + public products: { id: number; name: string }[] = []; + /** Every listed symbol that could trade at the last full look — what the + * dashboard ranks each account's next leg across. */ + public lastCandidates: PlatformContract[] = []; + + private readonly onSync: () => Promise; + + private uuidById = new Map(); + private capital = new Map(); + private positionsDetail = new Map(); + private openOrders = new Map(); + private seenOpenAt = new Map(); + private accountTokens = new Map(); + private accountTokenFlights = new Map>(); + + private syncInterval: ReturnType | null = null; + private watchdogInterval: ReturnType | null = null; + private intentionalDisconnect = false; + + private lastResponseAt = 0; + private lastAuthAt = 0; + private lastAuthError: string | null = null; + private consecutiveAuthFailures = 0; + + private syncInFlight = false; + private syncInFlightSince = 0; + private syncGeneration = 0; + private syncRunning: Promise = Promise.resolve(); + + private daysFetchInFlight = false; + private daysFetchInFlightSince = 0; + private lastDaysFetchStartedAt = 0; + private lastDaysFetchSucceededAt = 0; + private nextDaysFetchRetryAt = 0; + private historyLoaded = false; + private historyLoadedIds = new Set(); + private booksHeld = new Set(); + private fetchingIds = new Set(); + + private gateNext: Record = { read: 0, trade: 0, history: 0 }; + private suspectAccounts = new Map(); + /** Opens in flight per symbol, for the hedge guard. */ + private openingSides = new Map(); + private fillWaiters: { orderId: number; callback: (fill: Record) => void }[] = []; + private pendingFills = 0; + private nextOrderNumber = 1; + + private specs = new Map(); + private specsAt = 0; + private marks = new Map(); + private hoursCache = new Map(); + private candleCache = new Map(); + private pickCache = new Map(); + private lastSurveyTradeDate = ''; + private lastSurveyAt = 0; + private unlistedLoggedAt = 0; + private unknownStatusLogged = new Set(); + + constructor(cfg: VestConfig, onSync: () => Promise) { + this.cfg = cfg; + this.wallet = cfg.wallet.trim().toLowerCase(); + this.onSync = onSync; + this.syncInterval = setInterval(() => void this.requestSync(), SYNC_INTERVAL_MS); + this.watchdogInterval = setInterval(() => this.watchdog(), WATCHDOG_INTERVAL_MS); + void this.requestSync(); + } + + private get tag(): string { + return `[vest] ${this.cfg.firmName}:`; + } + + // ── session ────────────────────────────────────────────────────────────── + + /** The relayed user token, or null with the reason noted. */ + private userToken(): string | null { + const session = vestSessionFor(this.wallet, Date.now(), USER_TOKEN_MIN_MS); + if (session) return session.accessToken; + const msg = `no Vest session for ${this.wallet} — keep a Vest tab signed in to that wallet open in Chrome with the AutoFirmer extension`; + if (this.lastFetchErrors['login'] !== msg) console.warn(`${this.tag} ${msg}`); + this.lastAuthError = this.lastFetchErrors['login'] = msg; + return null; + } + + /** The extension relayed a session (POST /api/vest/session): a client + * that was waiting on its wallet syncs now. */ + public onVestSession(wallet: string): void { + if (wallet !== this.wallet || this.intentionalDisconnect) return; + if (this.lastSyncAt === 0 || Date.now() - this.lastSyncAt > SYNC_INTERVAL_MS) void this.requestSync(); + } + + /** A token for one trading account, minted from the user token and cached. */ + private async accountToken(uuid: string): Promise { + const cached = this.accountTokens.get(uuid); + if (cached && cached.expiresAt - Date.now() > ACCOUNT_TOKEN_SKEW_MS) return cached.token; + const flight = this.accountTokenFlights.get(uuid); + if (flight) return flight; + const p = (async () => { + const { data } = await this.request<{ accessToken?: unknown; accessExpiresAtMs?: unknown }>('read', 'POST', '/v3/auth/account-token', { + body: { accountId: uuid }, + }); + const token = typeof data?.accessToken === 'string' ? data.accessToken : ''; + const expiresAt = num(data?.accessExpiresAtMs) ?? Date.now() + 10 * 60_000; + if (!token) throw new Error('/v3/auth/account-token answered without a token'); + this.accountTokens.set(uuid, { token, expiresAt }); + return token; + })().finally(() => this.accountTokenFlights.delete(uuid)); + this.accountTokenFlights.set(uuid, p); + return p; + } + + // ── transport ──────────────────────────────────────────────────────────── + + private async gate(bucket: Bucket): Promise { + const now = Date.now(); + const at = Math.max(now, this.gateNext[bucket]); + this.gateNext[bucket] = at + SPACING_MS[bucket]; + if (at > now) await sleep(at - now); + } + + /** + * One REST call: with the user token, an account's token (`account`), or + * none (`auth: false`, public data). Non-2xx answers become VestApiError + * with the gateway's own code; transport failures a plain Error carrying + * only the message — never the axios error, whose config holds the token. + * A refused account token is re-minted once; a refused user token is + * dropped, so nothing keeps knocking with it until a fresh one arrives. + */ + private async request( + bucket: Bucket, + method: 'GET' | 'POST' | 'DELETE', + path: string, + opts: { params?: Record; body?: unknown; account?: string; auth?: boolean; retried?: boolean } = {}, + ): Promise<{ data: T }> { + let token: string | null = null; + if (opts.auth !== false) { + token = opts.account ? await this.accountToken(opts.account) : this.userToken(); + if (!token) throw new VestApiError(`${path}: no Vest session`, 401, 'NO_SESSION'); + } + await this.gate(bucket); + let res; + try { + res = await axios.request({ + method, + url: `${BASE}${path}`, + params: opts.params, + data: opts.body, + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(opts.body !== undefined ? { 'Content-Type': 'application/json' } : {}), + Accept: 'application/json', + Origin: APP_ORIGIN, + Referer: `${APP_ORIGIN}/`, + }, + timeout: TIMEOUT_MS[bucket], + signal: AbortSignal.timeout(TIMEOUT_MS[bucket] + 5_000), + validateStatus: () => true, + }); + } catch (err) { + if (axios.isCancel(err)) throw new Error(`${path} timed out after ${TIMEOUT_MS[bucket] / 1000}s`); + throw new Error(`${path}: ${errorMessage(err)}`); + } + this.lastResponseAt = Date.now(); + if (res.status >= 200 && res.status < 300) return { data: res.data as T }; + + const body = (res.data && typeof res.data === 'object' ? res.data : {}) as { code?: unknown; msg?: unknown; message?: unknown; detail?: { code?: unknown; msg?: unknown } }; + const code = body.detail?.code ?? body.code; + const msg = body.detail?.msg ?? body.msg ?? body.message; + const error = new VestApiError( + `${path}: ${typeof msg === 'string' ? msg : `HTTP ${res.status}`} (HTTP ${res.status}${code !== undefined ? `, code ${String(code)}` : ''})`, + res.status, code !== undefined ? String(code) : undefined, + ); + if (res.status === 401 && token) { + if (opts.account) { + this.accountTokens.delete(opts.account); + if (!opts.retried) return this.request(bucket, method, path, { ...opts, retried: true }); + } else { + // The user token itself was refused: forget it, and wait for the + // tab's next renewal rather than retrying it. + dropVestSession(this.wallet, token); + this.accountTokens.clear(); + this.consecutiveAuthFailures++; + this.lastAuthError = this.lastFetchErrors['login'] = `Vest refused the relayed session (${error.message}) — waiting for the Vest tab to renew it`; + } + } + if (!opts.retried && res.status === 429) { + await sleep(2_000); + return this.request(bucket, method, path, { ...opts, retried: true }); + } + throw error; + } + + /** No sync when there is no session to sync with, and a sync that hung + * is started over. */ + private watchdog(): void { + if (this.intentionalDisconnect) return; + const now = Date.now(); + if (this.syncInFlight && now - this.syncInFlightSince >= SYNC_WEDGED_MS) void this.requestSync(); + } + + // ── public market data ─────────────────────────────────────────────────── + + private async symbolSpecs(): Promise> { + if (this.specs.size > 0 && Date.now() - this.specsAt < EXCHANGE_INFO_TTL_MS) return this.specs; + const { data } = await this.request<{ symbols?: Record[] }>('read', 'GET', '/v3/exchangeInfo', { auth: false }); + const next = new Map(); + for (const s of data?.symbols ?? []) { + if (typeof s.symbol !== 'string') continue; + const capitalIm = num(s.capitalInitMarginRatio) ?? num(s.fundedInitMarginRatio) ?? num(s.initMarginRatio); + next.set(s.symbol, { + symbol: s.symbol, + assetId: num(s.assetId) ?? -1, + sizeDecimals: num(s.sizeDecimals) ?? 0, + priceDecimals: num(s.priceDecimals) ?? 2, + tick: num(s.minTickSize) ?? num(s.defaultTickSize) ?? 0, + takerFee: num(s.takerFee) ?? 0, + marginRate: capitalIm && capitalIm > 0 ? capitalIm : 0, + tradingStatus: String(s.tradingStatus ?? ''), + capitalTradingStatus: String(s.capitalTradingStatus ?? s.tradingStatus ?? ''), + }); + } + if (next.size > 0) { + this.specs = next; + this.specsAt = Date.now(); + } + return this.specs; + } + + /** Mark prices for the symbols given (every listed one when none given). */ + private async refreshMarks(symbols?: string[]): Promise { + const params = symbols && symbols.length > 0 ? { symbols: symbols.join(',') } : undefined; + const { data } = await this.request<{ tickers?: Record[] }>('read', 'GET', '/v3/ticker/latest', { auth: false, params }); + const now = Date.now(); + for (const t of data?.tickers ?? []) { + const mark = num(t.markPrice); + if (typeof t.symbol === 'string' && mark !== undefined && mark > 0) this.marks.set(t.symbol, { mark, at: now }); + } + } + + private async marketHours(assetId: number): Promise { + const cached = this.hoursCache.get(assetId); + if (cached && Date.now() - cached.at < MARKET_HOURS_TTL_MS) return cached.periods; + const { data } = await this.request<{ periods?: unknown }[]>('read', 'GET', `/v4/market-hours/${assetId}`, { auth: false }); + const periods = Array.isArray(data) ? data.flatMap((d) => (Array.isArray(d?.periods) ? d.periods : [])) : []; + this.hoursCache.set(assetId, { periods, at: Date.now() }); + return periods; + } + + /** Measured once per trade date, so a day's sizing and hold estimate stay + * put; an empty measurement retries after a while. */ + private async volatility(symbols: string[]): Promise> { + const now = Date.now(); + const today = currentTradeDateCT(); + for (const s of symbols) { + const c = this.candleCache.get(s); + if (c && c.tradeDate === today && (c.vol !== null || now - c.at < CANDLE_RETRY_MS)) continue; + const rows: unknown[] = []; + let endTime: number | undefined; + // Newest first, a page at a time, until the lookback is covered. + for (let page = 0; page < 4 && rows.length < CANDLE_HOURS + 1; page++) { + const { data } = await this.request<{ data?: unknown[] }>('read', 'GET', endTime ? '/v2/klines/history' : '/v2/klines', { + auth: false, params: { symbol: s, interval: '1h', ...(endTime ? { endTime } : {}) }, + }); + const list = Array.isArray(data?.data) ? data.data : []; + if (list.length === 0) break; + rows.push(...list); + const oldest = Math.min(...list.map((k) => (Array.isArray(k) ? num(k[0]) ?? Infinity : Infinity))); + if (!Number.isFinite(oldest)) break; + endTime = oldest - 1; + } + const recent = rows.filter((k) => Array.isArray(k) && (num(k[0]) ?? 0) >= now - (CANDLE_HOURS + 1) * 3_600_000); + const vol = hourlyVolatility(recent); + this.candleCache.set(s, { vol, candles: recent.length, at: now, tradeDate: today }); + if (vol !== null) console.log(`${this.tag} ${s} volatility for ${today}: ${(vol * Math.sqrt(24) * 100).toFixed(2)}%/day (${recent.length} hourly candles)`); + } + return new Map(symbols.map((s) => [s, this.candleCache.get(s) ?? { vol: null, candles: 0 }])); + } + + // ── sync ───────────────────────────────────────────────────────────────── + + /** + * One REST sync. Single-flight, with the same wedge recovery as the other + * clients. lastSyncAt — what the scheduler trades on — is certified only + * when the accounts, balances, positions and open orders all came back and + * the first history pass has landed. + */ + private async requestSync(): Promise { + if (this.intentionalDisconnect) return; + if (!this.userToken()) return; + const startedAt = Date.now(); + if (this.syncInFlight) { + const runningMs = startedAt - this.syncInFlightSince; + if (runningMs < SYNC_WEDGED_MS) return this.syncRunning; + console.warn(`${this.tag} sync running for ${Math.round(runningMs / 1000)}s — looks wedged, starting a fresh one`); + } + const gen = ++this.syncGeneration; + const superseded = () => gen !== this.syncGeneration || this.intentionalDisconnect; + this.syncInFlight = true; + this.syncInFlightSince = startedAt; + let finished!: () => void; + this.syncRunning = new Promise((resolve) => { finished = resolve; }); + try { + const [cap, bal, pos, ord] = await Promise.all([ + this.request<{ accounts?: unknown[] }>('read', 'GET', '/v3/capital/accounts/active'), + this.request<{ accounts?: Record[] }>('read', 'GET', '/v3/accounts', { params: { active: true, limit: 500 } }), + this.request<{ positions?: unknown[] }>('read', 'GET', '/v3/positions/opened'), + this.request<{ orders?: unknown[] | null }>('read', 'GET', '/v3/positions/opened-orders'), + ]); + if (superseded()) return; + this.lastAuthAt = this.lastAuthAt || Date.now(); + this.consecutiveAuthFailures = 0; + this.lastAuthError = null; + delete this.lastFetchErrors['login']; + + const accounts = (cap.data?.accounts ?? []).map(parseCapitalAccount).filter((a): a is VestCapitalAccount => a !== null); + const free = new Map(); + for (const b of bal.data?.accounts ?? []) { + const amount = num(b.amount); + if (typeof b.account_id === 'string' && amount !== undefined) free.set(b.account_id, amount); + } + const positions = (pos.data?.positions ?? []).map(parsePosition).filter((p): p is VestPosition => p !== null); + const orders = (ord.data?.orders ?? []).map(parseOpenOrder).filter((o): o is VestOpenOrder => o !== null); + const held = [...new Set(positions.map((p) => p.symbol))]; + if (held.length > 0) { + try { + await this.refreshMarks(held); + } catch (err) { + console.warn(`${this.tag} mark prices failed: ${errorMessage(err)}`); + } + } + if (superseded()) return; + + const list: PlatformAccount[] = []; + let complete = true; + const now = Date.now(); + for (const a of accounts) { + const id = vestAccountId(a.id); + const clash = this.uuidById.get(id); + if (clash && clash !== a.id) { + console.error(`${this.tag} account id collision between ${clash} and ${a.id} — leaving ${a.id} out`); + continue; + } + this.uuidById.set(id, a.id); + this.capital.set(id, a); + if (a.status !== VEST_STATUS_ACTIVE && !this.unknownStatusLogged.has(`${a.id}:${a.status}`)) { + this.unknownStatusLogged.add(`${a.id}:${a.status}`); + console.warn(`${this.tag} ${vestAccountName(a)} has status ${a.status} — not trading it until that status is known`); + } + const acc: PlatformAccount = { id, name: vestAccountName(a), active: a.status === VEST_STATUS_ACTIVE }; + list.push(acc); + + const mine = positions.filter((p) => p.accountId === a.id); + this.positionsDetail.set(id, mine); + this.openOrders.set(id, orders.filter((o) => o.accountId === a.id)); + this.applyPosition(acc, mine); + + const amount = free.get(a.id); + if (amount === undefined) { complete = false; continue; } + const balance = amount + mine.reduce((s, p) => s + p.collateral, 0); + const upnl = mine.reduce((s, p) => s + unrealizedPnl(p, this.marks.get(p.symbol)?.mark ?? NaN), 0); + const resetBalance = this.resetBalanceOf(a, balance, now); + this.accountCashBalances[id] = { + amount: round2(balance), + realizedPnL: resetBalance !== undefined ? round2(balance - resetBalance) : 0, + equity: round2(balance + upnl), + availableFunds: round2(amount), + }; + this.autoLiqThresholds[id] = a.maxDrawdownLimit ?? 0; + this.lastFetchRaw[`floors:${acc.name}`] = `dd ${a.maxDrawdownLimit ?? '?'}, daily ${this.dailyFloorOf(a, balance, now) ?? 'none'}` + + `${a.targetEquity ? `, target ${a.targetEquity}` : ''}`; + } + this.accountList = list; + + this.settleSuspects(); + this.checkNakedPositions(); + if (superseded()) return; + + console.log(`${this.tag} sync — ${list.length} account(s), ${Object.keys(this.positions).length} open position(s)`); + this.maybeFetchDaysTraded(); + if (this.historyLoaded && (this.lastSurveyTradeDate !== currentTradeDateCT() || Date.now() - this.lastSurveyAt >= SURVEY_INTERVAL_MS)) { + this.lastSurveyTradeDate = currentTradeDateCT(); + this.lastSurveyAt = Date.now(); + void this.surveyCandidates().catch((err) => + console.warn(`${this.tag} candidate survey failed: ${errorMessage(err)}`)); + } + + this.syncComplete = true; + if (!complete) { + console.warn(`${this.tag} sync incomplete (a balance was missing) — not counted as a good sync`); + } else if (this.historyLoaded) { + this.lastSyncAt = Date.now(); + this.lastSyncStartedAt = startedAt; + } + try { + await this.onSync(); + } catch (err) { + console.error(`${this.tag} post-sync callback failed: ${errorMessage(err)}`); + } + } catch (err) { + console.error(`${this.tag} sync failed: ${errorMessage(err)}`); + } finally { + if (gen === this.syncGeneration) { + this.syncInFlight = false; + this.syncInFlightSince = 0; + } + finished(); + } + } + + private applyPosition(acc: PlatformAccount, list: VestPosition[]): void { + if (list.length > 1) console.warn(`${this.tag} ${acc.name} holds ${list.length} positions — tracking the first`); + const pos = list[0]; + if (!pos) { + const seen = this.seenOpenAt.get(acc.id); + if (seen) { + console.log(`${this.tag} ${acc.name}: position closed after ${((Date.now() - seen.at) / 3_600_000).toFixed(2)}h`); + this.seenOpenAt.delete(acc.id); + } + delete this.positions[acc.id]; + return; + } + const seen = this.seenOpenAt.get(acc.id); + if (!seen || seen.positionId !== pos.positionId) { + this.seenOpenAt.set(acc.id, { positionId: pos.positionId, at: Number.isFinite(pos.openedAt) ? pos.openedAt : Date.now(), naggedAt: 0 }); + } + this.positions[acc.id] = { + contractId: pos.symbol, + netPos: pos.side === 'long' ? pos.quantity : -pos.quantity, + netPrice: pos.openPrice, + timestamp: new Date(Number.isFinite(pos.openedAt) ? pos.openedAt : Date.now()), + }; + } + + /** + * The balance Vest measures today's daily loss from. Vest captures it on + * an account's first update after 20:00 ET, so an account that has not + * moved since the boundary still shows yesterday's — its next update + * (our own order, say) will reset it to the balance it has now. + */ + private resetBalanceOf(a: VestCapitalAccount, balance: number, now: number): number | undefined { + if (Number.isFinite(a.dailyResetAt) && a.dailyResetAt >= lastVestResetMs(now)) return a.dailyResetBalance; + return balance; + } + + /** The equity floor of Vest's daily loss limit as it stands (or will, on + * the account's next update); undefined when the account has none. */ + private dailyFloorOf(a: VestCapitalAccount, balance: number, now: number): number | undefined { + if (!(a.maxDailyLossPct > 0)) return undefined; + if (Number.isFinite(a.dailyResetAt) && a.dailyResetAt >= lastVestResetMs(now)) return a.dailyLossFloor; + return round2(balance * (1 - a.maxDailyLossPct)); + } + + /** + * See PlatformClient: what the engine measures the day's loss from. Vest + * states the floor itself (reset balance × (1 − rate), moved by transfers), + * so this is that floor plus the account type's own daily limit — the + * engine's room, limit + min(0, balance − dayStart), is then + * min(limit, balance − floor): never more than Vest leaves. null until + * the account has been read. + */ + public dayStartBalance(accountId: number): number | null { + const a = this.capital.get(accountId); + const cash = this.accountCashBalances[accountId]; + if (!a || !cash) return null; + const floor = this.dailyFloorOf(a, cash.amount, Date.now()); + const limit = getAccountConfig(accountId).accountType?.dailyLossLimit ?? 0; + if (floor === undefined) return this.resetBalanceOf(a, cash.amount, Date.now()) ?? null; + return round2(floor + limit); + } + + private settleSuspects(): void { + const now = Date.now(); + for (const [id, since] of this.suspectAccounts) { + if (now - since >= SUSPECT_SETTLE_MS && (this.openOrders.get(id) ?? []).length === 0) this.suspectAccounts.delete(id); + } + } + + /** An order of unknown outcome, a pending order, or exit legs waiting on + * a position. */ + public hasWorkingOrders(accountId: number): boolean { + return this.suspectAccounts.has(accountId) + || (this.openOrders.get(accountId) ?? []).length > 0 + || (this.positionsDetail.get(accountId) ?? []).some((p) => awaitingLegs(p).length > 0); + } + + /** A position with no exit leg waiting — a restart mid-entry, or a leg + * whose exits failed. Not closed automatically (it may be a manual + * trade), but loud, and on the account's fetch-error note. */ + private checkNakedPositions(): void { + const now = Date.now(); + for (const acc of this.accountList) { + const seen = this.seenOpenAt.get(acc.id); + const pos = this.positionsDetail.get(acc.id)?.[0]; + if (!seen || !pos || awaitingLegs(pos).length > 0 || now - seen.at < NAKED_AFTER_MS) continue; + if (now - seen.naggedAt < 15 * 60_000) continue; + seen.naggedAt = now; + console.error(`${this.tag} ${acc.name}: position ${pos.positionId} has NO take-profit or stop-loss — close it or add exits on Vest`); + } + } + + // ── history ────────────────────────────────────────────────────────────── + + private maybeFetchDaysTraded(): void { + if (this.daysFetchInFlight || this.intentionalDisconnect) return; + const now = Date.now(); + const initialFetchDue = this.lastDaysFetchStartedAt === 0; + const retryPending = this.nextDaysFetchRetryAt > 0; + const retryDue = retryPending && now >= this.nextDaysFetchRetryAt; + const hourlyRefreshDue = !retryPending && this.lastDaysFetchSucceededAt > 0 && now - this.lastDaysFetchSucceededAt >= 60 * 60_000; + const waiting = !initialFetchDue && !retryPending + ? this.accountList.filter((a) => !this.historyLoadedIds.has(a.id) && !this.fetchingIds.has(a.id)).map((a) => a.id) + : []; + const targeted = !retryDue && !hourlyRefreshDue && !initialFetchDue && waiting.length > 0; + if (!initialFetchDue && !retryDue && !hourlyRefreshDue && !targeted) return; + + this.daysFetchInFlight = true; + this.daysFetchInFlightSince = now; + this.lastDaysFetchStartedAt = now; + if (retryDue) this.nextDaysFetchRetryAt = 0; + + this.fetchDaysTraded(targeted ? waiting : undefined) + .then(({ failedAccounts }) => { + if (!this.historyLoaded && !this.intentionalDisconnect) { + this.historyLoaded = true; + void this.requestSync(); + } + if (failedAccounts > 0) { + this.nextDaysFetchRetryAt = Date.now() + 5 * 60_000; + console.warn(`${this.tag} ${failedAccounts} account(s) failed history fetch — retrying in 5 minutes`); + return; + } + if (!targeted) this.lastDaysFetchSucceededAt = Date.now(); + }) + .catch((err) => { + this.nextDaysFetchRetryAt = Date.now() + 5 * 60_000; + console.error(`${this.tag} history fetch failed — retrying in 5 minutes: ${errorMessage(err)}`); + }) + .finally(() => { + this.daysFetchInFlight = false; + this.daysFetchInFlightSince = 0; + }); + } + + public async fetchDaysTraded(onlyAccountIds?: number[]): Promise<{ failedAccounts: number }> { + const ids = this.accountList.filter((a) => !onlyAccountIds || onlyAccountIds.includes(a.id)).map((a) => a.id); + for (const id of ids) this.fetchingIds.add(id); + this.fetchDaysComplete = false; + let failed = 0; + try { + for (const id of ids) { + if (this.intentionalDisconnect) break; + const account = this.accountList.find((a) => a.id === id); + if (!account) continue; + try { + this.bookHistory(account, await this.fetchTxns(this.uuidById.get(id)!)); + } catch (err) { + failed++; + this.fallBackToCache(account, errorMessage(err)); + } + } + } finally { + for (const id of ids) this.fetchingIds.delete(id); + this.fetchDaysComplete = true; + } + return { failedAccounts: failed }; + } + + /** Every transaction on the account, newest page first, deduplicated. */ + private async fetchTxns(uuid: string): Promise { + const all = new Map(); + for (let page = 0; page < TXN_MAX_PAGES; page++) { + const { data } = await this.request<{ transactions?: unknown[] | null }>('history', 'GET', '/v3/transactions', { + account: uuid, params: { limit: TXN_PAGE, offset: page * TXN_PAGE }, + }); + const rows = data?.transactions ?? []; + for (const r of rows) { + const t = parseTxn(r); + if (t) all.set(t.key, t); + } + if (rows.length < TXN_PAGE) break; + } + return [...all.values()].sort((a, b) => a.time - b.time); + } + + private bookHistory(account: PlatformAccount, txns: VestTxn[]): void { + const a = this.capital.get(account.id); + const { daily, funds } = bookTxns(txns); + const types = [...new Set(txns.map((t) => t.type))].sort((x, y) => x - y); + this.lastFetchRaw[account.name] = `${txns.length} transaction(s), types ${types.join(',') || 'none'}`; + + // An account Vest funded without a deposit row opens with its capital + // on the day it was activated — the resolver infers its size from it, + // and its first cycle starts there. + const hasDeposit = [...funds.values()].some((v) => v > 0); + if (!hasDeposit && a && a.initialCapital > 0) { + const opened = vestDayOf(Number.isFinite(a.activatedAt) ? a.activatedAt : a.createdAt); + funds.set(opened, (funds.get(opened) ?? 0) + a.initialCapital); + } + + const fresh = [...daily.entries()] + .map(([date, e]) => ({ date, pnl: round2(e.pnl), fees: Math.round(e.fees * 10000) / 10000 })) + .sort((x, y) => x.date.localeCompare(y.date)); + const merged = new Map(); + for (const e of loadDailyPnL(account.id)) merged.set(e.date, e.pnl); + for (const e of fresh) merged.set(e.date, e.pnl); + this.fullDailyPnL[account.id] = [...merged.entries()] + .map(([date, pnl]) => ({ date, pnl })) + .sort((x, y) => x.date.localeCompare(y.date)); + saveDailyPnL(account.id, account.name, fresh); + + const storedFundDate = loadAccountMeta(account.id, 'last_fund_date'); + const fundRows = [...funds.entries()] + .map(([date, amount]) => ({ date, amount: round2(amount) })) + .filter((f) => f.amount !== 0) + .sort((x, y) => x.date.localeCompare(y.date)); + if (fundRows.length > 0) saveFundTransactions(account.id, account.name, fundRows); + this.fundTransactions[account.id] = loadFundTransactions(account.id); + const lastFundDate = fundRows.length > 0 ? fundRows[fundRows.length - 1].date : storedFundDate; + if (lastFundDate && storedFundDate && lastFundDate > storedFundDate) { + const prevAmt = this.fundTransactions[account.id]?.find((f) => f.date === storedFundDate)?.amount ?? null; + saveEndedCycleStart(account.id, { fundDate: storedFundDate, includesDay: !(prevAmt !== null && prevAmt < 0) }); + } + if (lastFundDate) saveAccountMeta(account.id, 'last_fund_date', lastFundDate); + this.lastFundDates[account.id] = lastFundDate ?? null; + this.applyCycleSlice(account.id); + + // Books check: the balance must be what the transactions add up to + // (the opening capital included). A payout or credit booked under a + // type this client doesn't count would otherwise trade a finished + // cycle as if nothing happened. + const cash = this.accountCashBalances[account.id]; + const expected = txns.reduce((s, t) => s + (t.kind === 'margin' ? 0 : t.amount), 0) + (hasDeposit ? 0 : a?.initialCapital ?? 0); + if (account.active && cash && !this.positions[account.id] && Math.abs(cash.amount - expected) >= BOOKS_TOLERANCE) { + this.booksHeld.add(account.id); + this.lastFetchErrors[account.name] = `balance ${round2(cash.amount)} vs transactions ${round2(expected)} (types ${types.join(',')}) — held until they agree`; + console.warn(`${this.tag} ${account.name}: ${this.lastFetchErrors[account.name]}`); + } else { + this.booksHeld.delete(account.id); + delete this.lastFetchErrors[account.name]; + } + this.historyLoadedIds.add(account.id); + } + + private fallBackToCache(account: PlatformAccount, msg: string): void { + const cached = loadDailyPnL(account.id); + this.fundTransactions[account.id] ??= loadFundTransactions(account.id); + this.lastFundDates[account.id] ??= loadAccountMeta(account.id, 'last_fund_date'); + if (cached.length > 0) { + this.fullDailyPnL[account.id] = cached; + this.applyCycleSlice(account.id); + this.lastFetchErrors[account.name] = `${msg} (using ${cached.length} cached entries from database)`; + } else { + this.daysTraded[account.id] ??= 0; + this.lastFetchErrors[account.name] = msg; + } + } + + /** The active cycle out of the full history: a withdrawal's day belongs to + * the OLD cycle, a deposit's day to the new one (as on DXtrade). */ + private applyCycleSlice(accountId: number): void { + const merged = this.fullDailyPnL[accountId] ?? []; + const fundDate = this.lastFundDates[accountId]; + const txns = this.fundTransactions[accountId] ?? []; + const lastAmt = fundDate ? (txns.find((f) => f.date === fundDate)?.amount ?? null) : null; + const isWithdrawal = lastAmt !== null && lastAmt < 0; + const active = fundDate ? merged.filter((d) => (isWithdrawal ? d.date > fundDate : d.date >= fundDate)) : merged; + const prior = fundDate ? merged.filter((d) => (isWithdrawal ? d.date <= fundDate : d.date < fundDate)) : []; + this.priorProfit[accountId] = round2(prior.reduce((s, d) => s + d.pnl, 0)); + this.dailyPnL[accountId] = active; + this.daysTraded[accountId] = active.filter((d) => d.pnl !== 0).length; + } + + public historyReady(accountId: number): boolean { + return this.historyLoadedIds.has(accountId) + && !this.booksHeld.has(accountId) + && this.dailyPnL[accountId] !== undefined + && this.fundTransactions[accountId] !== undefined; + } + + /** The account's Vest status and activation time, for the report. */ + public brokerAccountMeta(accountId: number): { status: string; registrationTime: number } | undefined { + const a = this.capital.get(accountId); + return a ? { status: String(a.status), registrationTime: Number.isFinite(a.activatedAt) ? a.activatedAt : a.createdAt } : undefined; + } + + public forgetHistory(accountId: number, accountName: string): void { + this.historyLoadedIds.delete(accountId); + this.booksHeld.delete(accountId); + delete this.dailyPnL[accountId]; + delete this.fullDailyPnL[accountId]; + delete this.daysTraded[accountId]; + delete this.priorProfit[accountId]; + delete this.lastFundDates[accountId]; + delete this.fundTransactions[accountId]; + delete this.lastFetchErrors[accountName]; + delete this.lastFetchRaw[accountName]; + } + + // ── instrument selector ────────────────────────────────────────────────── + + /** + * The contract this firm trades right now, from its instrument list — the + * root is ignored, as on DXtrade. With a pick context (the batch's leg), + * each candidate is sized as the leg would be and the cheapest that + * reaches the target hold wins (else the quickest); without one, the + * cheapest per unit of volatility. Sticky while an account holds one of + * the candidates; a batch's pick is reused briefly so its lookups agree. + */ + public async findFrontMonthContract(_root: string, ctx?: PickContext): Promise { + if (!this.userToken()) return null; + const root = '*'; + const firm = getCachedFirm(this.cfg.firmId); + const candidates = firm ? firmInstruments(firm) : []; + const now = Date.now(); + if (candidates.length === 0) { + if (now - this.unlistedLoggedAt > 10 * 60_000) { + this.unlistedLoggedAt = now; + console.log(`${this.tag} no instruments listed — skipping; add Vest symbols (e.g. NDX-USD-PERP) on the master Firms page`); + } + return null; + } + const held = new Set([...this.positionsDetail.values()].flat().map((p) => p.symbol)); + const sticky = candidates.find((s) => held.has(s)); + if (!sticky && !ctx) { + const cached = this.pickCache.get(root); + if (cached && now - cached.at < PICK_TTL_MS) return cached.contract; + } + const pick = await this.pickCandidate(root, sticky ? [sticky] : candidates, sticky !== undefined, ctx); + if (pick && !sticky) this.pickCache.set(root, { contract: pick, at: now }); + return pick; + } + + /** Every listed symbol, looked at afresh whatever the accounts hold: the + * day's volatility, and the candidates the dashboard ranks. */ + private async surveyCandidates(): Promise { + const firm = getCachedFirm(this.cfg.firmId); + const list = firm ? firmInstruments(firm) : []; + if (list.length > 0) await this.pickCandidate('survey', list, false); + } + + private async pickCandidate(root: string, symbols: string[], sticky: boolean, ctx?: PickContext): Promise { + const firm = getCachedFirm(this.cfg.firmId); + const targetHold = sizingHoldHours(firm); + const specs = await this.symbolSpecs(); + const known = symbols.filter((s) => specs.has(s)); + if (known.length > 0) await this.refreshMarks(known); + const vols = await this.volatility(known); + const now = Date.now(); + + const board: { symbol: string; score: number | null; note: string; contract?: PlatformContract }[] = []; + for (const symbol of symbols) { + const spec = specs.get(symbol); + if (!spec) { board.push({ symbol, score: null, note: 'unknown symbol' }); continue; } + const mark = this.marks.get(symbol); + const vol = vols.get(symbol); + let hours: { open: boolean; closesAt: number | null }; + try { + hours = marketOpen(await this.marketHours(spec.assetId), now); + } catch (err) { + board.push({ symbol, score: null, note: `market hours unknown (${errorMessage(err)})` }); + continue; + } + const step = 10 ** -spec.sizeDecimals; + const stepNotional = mark ? step * mark.mark : NaN; + const spread = mark && spec.tick > 0 ? (ASSUMED_SPREAD_TICKS * spec.tick) / mark.mark : NaN; + const rejection = spec.capitalTradingStatus !== 'TRADING' ? `capital trading ${spec.capitalTradingStatus || 'unknown'}` + : !hours.open ? 'market closed' + : !mark || now - mark.at > 5 * 60_000 ? 'no mark price' + : !(spec.tick > 0) ? 'no tick size' + : (vol?.candles ?? 0) < 25 ? `only ${vol?.candles ?? 0} hourly candles` + : null; + if (rejection) { board.push({ symbol, score: null, note: rejection }); continue; } + const pct = spec.takerFee > 0 ? spec.takerFee * 100 : firm?.commissionPct ?? 0; + const score = scoreCandidate({ + spreadFraction: spread, + commissionPct: pct, + financingPctPerDay: firm?.financingPctPerDay ?? 0, + targetHoldHours: targetHold, + volatilityPerHour: vol?.vol ?? null, + }); + if (score === null) { board.push({ symbol, score: null, note: 'no volatility' }); continue; } + board.push({ + symbol, + score, + note: `fee ${(pct * 100).toFixed(2)}bp${vol?.vol ? `, σ ${(vol.vol * Math.sqrt(24) * 100).toFixed(2)}%/day` : ''}` + + (spec.marginRate ? `, ${+(1 / spec.marginRate).toFixed(1)}:1` : '') + + (hours.closesAt ? `, open ${((hours.closesAt - now) / 3_600_000).toFixed(1)}h more` : ''), + contract: { + id: symbol, + name: encodeContractName(symbol, step), + tickSize: spec.tick, + pointValue: step, + commissionPct: pct, + stepNotional, + volatilityPerHour: vol?.vol ?? undefined, + spreadFraction: spread, + minContracts: 1, + marginRate: spec.marginRate || undefined, + }, + }); + } + + let ranked = board.filter((b) => b.contract).sort((a, b) => a.score! - b.score!); + if (ctx) { + const sized = ranked.flatMap((b) => { + const ev = ctx.evaluate(b.contract!); + if (!ev) { b.note += ', can\'t size'; return []; } + b.note += `, $${Math.round(ev.notional / 1000)}k → ${ev.holdHours.toFixed(1)}h, ~$${Math.round(ev.cost)}`; + return [{ b, holdHours: ev.holdHours, cost: ev.cost }]; + }); + ranked = rankForTarget(sized, ctx.targetHoldHours).map((i) => sized[i].b); + } + const winner = ranked[0]; + const summary = board.map((b) => `${b.symbol}${b.score !== null ? ` ${b.score.toFixed(4)}` : ''} (${b.note})`).join('; '); + this.lastFetchRaw[`pick:${root}`] = `${new Date().toISOString()} → ${winner?.symbol ?? 'none'} — ${summary}`; + console.log(`${this.tag} ${root}${sticky ? ' (held)' : ''} → ${winner?.symbol ?? 'nothing tradable'} — ${summary}`); + if (!sticky) this.lastCandidates = board.flatMap((b) => (b.contract ? [b.contract] : [])); + return winner?.contract ?? null; + } + + public async fetchInstrumentFees(): Promise<{ [symbol: string]: number }> { + // A percent of notional per symbol, carried on each picked contract. + return {}; + } + + // ── orders ─────────────────────────────────────────────────────────────── + + private async freshPositions(uuid: string): Promise { + const { data } = await this.request<{ positions?: unknown[] }>('trade', 'GET', '/v3/positions/opened'); + return (data?.positions ?? []).map(parsePosition).filter((p): p is VestPosition => p !== null && p.accountId === uuid); + } + + /** What an order needs before it is sent. Failing here reached nothing. */ + private orderContext(accountId: number, contractName: string): { uuid: string; symbol: string; step: number; spec: SymbolSpec } { + if (!this.userToken()) throw new OrderNotPlacedError('no Vest session — order not sent'); + const uuid = this.uuidById.get(accountId); + if (!uuid) throw new OrderNotPlacedError(`Vest account ${accountId} unknown — order not sent`); + const decoded = decodeContractName(contractName); + if (!decoded) throw new OrderNotPlacedError(`Vest contract ${contractName} unreadable — order not sent`); + const spec = this.specs.get(decoded.symbol); + if (!spec) throw new OrderNotPlacedError(`Vest symbol ${decoded.symbol} has no spec loaded — order not sent`); + return { uuid, symbol: decoded.symbol, step: decoded.step, spec }; + } + + /** A price on the symbol's tick grid as the gateway wants it, rounded + * toward the position's safe side: an exit selling (closing a long) up, + * one buying (closing a short) down — the take-profit never nets less, + * the stop never sits further out. */ + private exitPrice(spec: SymbolSpec, price: number, exitSide: 'long' | 'short'): string { + const rounded = spec.tick > 0 ? roundToStep(price, spec.tick, exitSide === 'short' ? 'up' : 'down') : price; + return formatDecimal(rounded, spec.priceDecimals); + } + + /** The leverage an entry asks for: the most the symbol allows a capital + * account (cross margin, so it only sets the collateral held, not the + * liquidation point — the prop floors sit far above that). */ + private leverageFor(spec: SymbolSpec): string { + return formatDecimal(spec.marginRate > 0 ? Math.floor(1 / spec.marginRate) : 1, 2); + } + + /** + * Place an order and wait for its fill: an open when the account is flat, + * a close (or reduce) of the position when it holds the opposite side. + * Adding to a position is refused, and so is an open against another + * account's opposite position in the same symbol (no hedging). Resolves + * with the fill `price`. + */ + public async sendOrder( + accountId: number, + contractSymbol: string, + quantity: number, + action: 'Buy' | 'Sell', + orderType: 'Market' | 'Limit', + price?: number, + ): Promise> { + try { + return await this.sendOrderAndWait(accountId, contractSymbol, quantity, action, orderType, price); + } catch (err) { + if (!isOrderNotPlaced(err)) this.suspectAccounts.set(accountId, Date.now()); + throw err; + } + } + + private async sendOrderAndWait( + accountId: number, + contractSymbol: string, + contracts: number, + action: 'Buy' | 'Sell', + orderType: 'Market' | 'Limit', + price?: number, + ): Promise> { + const { uuid, symbol, step, spec } = this.orderContext(accountId, contractSymbol); + const side: 'long' | 'short' = action === 'Buy' ? 'long' : 'short'; + let positions: VestPosition[]; + try { + positions = await this.freshPositions(uuid); + } catch (err) { + throw new OrderNotPlacedError(`Vest position check failed — order not sent: ${errorMessage(err)}`); + } + const existing = positions.find((p) => p.symbol === symbol); + if (existing && existing.side === side) throw new OrderNotPlacedError(`adding to an open ${symbol} position is not supported`); + + const units = roundToStep(contracts * step, step); + const qty = (n: number) => formatDecimal(n, spec.sizeDecimals); + const tif = orderType === 'Market' ? 'IOC' : 'GTC'; + const priced = orderType === 'Limit' && price !== undefined; + let path: string; + let body: Record; + let unitsOrdered: number; + if (existing) { + unitsOrdered = Math.min(existing.quantity, units); + const full = unitsOrdered >= existing.quantity - step / 2; + path = full ? '/v3/positions/close' : '/v3/positions/reduce'; + body = full + ? { + positionId: existing.positionId, symbol, orderType: orderType === 'Market' ? 'market' : 'limit', + ...(priced ? { limitPrice: this.exitPrice(spec, price!, side) } : {}), + } + : { + positionId: existing.positionId, symbol, orderType: orderType === 'Market' ? 'market' : 'limit', + leverage: this.leverageFor(spec), quantity: qty(unitsOrdered), timeInForce: tif, reduceOnly: true, + ...(priced ? { price: this.exitPrice(spec, price!, side) } : {}), + }; + } else { + const meta = this.capital.get(accountId); + if (!meta || meta.status !== VEST_STATUS_ACTIVE) { + throw new OrderNotPlacedError(`account status is ${meta?.status ?? 'unknown'} — order not sent`); + } + const opposite = side === 'long' ? 'short' : 'long'; + const hedged = [...this.positionsDetail.values()].flat().some((p) => p.symbol === symbol && p.side === opposite) + || this.openingSides.get(symbol)?.side === opposite; + if (hedged) throw new OrderNotPlacedError(`another account holds the opposite side of ${symbol} — no hedging`); + unitsOrdered = units; + path = '/v3/positions/open'; + body = { + orderType: orderType === 'Market' ? 'market' : 'limit', + leverage: this.leverageFor(spec), + side, + symbol, + quantity: qty(units), + timeInForce: tif, + takeProfits: [], + stopLosses: [], + ...(priced ? { price: formatDecimal(roundToStep(price!, spec.tick || 1e-9), spec.priceDecimals) } : {}), + }; + } + if (!(unitsOrdered > 0)) throw new OrderNotPlacedError(`${contracts} contract(s) of ${symbol} round to nothing — order not sent`); + + const opening = !existing; + if (opening) { + const cur = this.openingSides.get(symbol); + this.openingSides.set(symbol, { side, count: (cur?.side === side ? cur.count : 0) + 1 }); + } + this.pendingFills++; + const orderNumber = this.nextOrderNumber++; + const sentAt = Date.now(); + try { + const placed = await this.submit(uuid, path, body); + console.log(`${this.tag} order ${placed.orderId ?? '?'}: ${side === 'long' ? 'BUY' : 'SELL'} ${unitsOrdered} ${symbol} ${orderType}${existing ? ' CLOSE' : ' OPEN'}`); + const fill = await this.waitForFill(uuid, placed, symbol, side, existing ?? null, unitsOrdered, sentAt); + if (opening) { + const pos = fill.position ?? { + positionId: placed.positionId ?? '', accountId: uuid, symbol, side, quantity: unitsOrdered, + openPrice: fill.price, collateral: 0, leverage: 0, openedAt: Date.now(), stopLosses: [], takeProfits: [], + }; + this.positionsDetail.set(accountId, [pos]); + this.positions[accountId] = { + contractId: symbol, netPos: side === 'long' ? pos.quantity : -pos.quantity, netPrice: pos.openPrice, timestamp: new Date(), + }; + } else if (unitsOrdered >= existing.quantity - step / 2) { + this.positionsDetail.set(accountId, []); + delete this.positions[accountId]; + } + const result = { + orderId: orderNumber, vestOrderId: placed.orderId, price: fill.price, qty: contracts, units: unitsOrdered, accountId, contractId: symbol, + }; + for (const w of this.fillWaiters.filter((x) => x.orderId === orderNumber)) w.callback(result); + this.fillWaiters = this.fillWaiters.filter((x) => x.orderId !== orderNumber); + return result; + } finally { + this.pendingFills--; + if (opening) { + const cur = this.openingSides.get(symbol); + if (cur && cur.count <= 1) this.openingSides.delete(symbol); + else if (cur) this.openingSides.set(symbol, { ...cur, count: cur.count - 1 }); + } + } + } + + /** + * POST an order. Only an answer that proves nothing was placed becomes + * OrderNotPlacedError (a 4xx with the gateway's refusal); a transport + * failure or a 5xx stays a plain Error — it may have gone in. + */ + private async submit(uuid: string, path: string, body: Record): Promise<{ orderId?: string; positionId?: string }> { + try { + const { data } = await this.request<{ orderId?: unknown; positionId?: unknown }>('trade', 'POST', path, { account: uuid, body }); + return { + orderId: typeof data?.orderId === 'string' ? data.orderId : undefined, + positionId: typeof data?.positionId === 'string' ? data.positionId : undefined, + }; + } catch (err) { + if (err instanceof VestApiError && err.status >= 400 && err.status < 500 && err.status !== 408) { + throw new OrderNotPlacedError(`Vest refused the order: ${err.message}`); + } + throw new Error(`Vest order outcome unknown (it may still have been placed): ${errorMessage(err)}`); + } + } + + /** + * Poll until the order fills: an open shows as the position it created + * (by positionId, else the account's only one in the symbol), a close as + * the position gone or shrunk. Still unfilled after FILL_TIMEOUT_MS is + * reported as unknown — a market order is IOC, so it can't fill later, + * but a limit may. + */ + private async waitForFill( + uuid: string, + placed: { orderId?: string; positionId?: string }, + symbol: string, + side: 'long' | 'short', + closing: VestPosition | null, + units: number, + sentAt: number, + ): Promise<{ price: number; position?: VestPosition }> { + const deadline = Date.now() + FILL_TIMEOUT_MS; + while (Date.now() < deadline) { + await sleep(FILL_POLL_MS); + try { + const positions = await this.freshPositions(uuid); + if (closing) { + const still = positions.find((p) => p.positionId === closing.positionId); + if (!still || still.quantity < closing.quantity - units / 2) { + return { price: await this.exitFillPrice(uuid, symbol, side, sentAt) }; + } + } else { + const pos = positions.find((p) => (placed.positionId ? p.positionId === placed.positionId : p.symbol === symbol && p.side === side)); + if (pos) return { price: pos.openPrice, position: pos }; + } + } catch { /* transient — the next poll tries again */ } + } + throw new Error(`order ${placed.orderId ?? '?'} on ${symbol} not seen filled within ${FILL_TIMEOUT_MS / 1000}s — outcome unknown`); + } + + /** What a close filled at: the volume-weighted price of the account's + * executions in the symbol, on the closing side, since the order went + * out — else the mark, as an estimate. */ + private async exitFillPrice(uuid: string, symbol: string, side: 'long' | 'short', sentAt: number): Promise { + try { + const { data } = await this.request<{ items?: Record[] }>('trade', 'GET', '/v3/executions', { + account: uuid, params: { account_id: uuid, symbol, limit: 20 }, + }); + let qty = 0; + let value = 0; + for (const e of data?.items ?? []) { + const q = num(e.quantity); + const price = num(e.price); + if (q === undefined || price === undefined || toMs(e.executedAt) < sentAt - 5_000) continue; + if ((e.isBuy === true) !== (side === 'long')) continue; + qty += q; + value += q * price; + } + if (qty > 0) return value / qty; + } catch (err) { + console.warn(`${this.tag} executions read failed — taking the mark as the exit price: ${errorMessage(err)}`); + } + await this.refreshMarks([symbol]).catch(() => { /* NaN below then */ }); + return this.marks.get(symbol)?.mark ?? NaN; + } + + /** One exit leg on the open position: POST /v3/positions/take-profit or + * /stop-loss. A take-profit rests as a limit at its price; a stop goes + * out at market when it triggers. */ + private async addLeg(uuid: string, spec: SymbolSpec, pos: VestPosition, kind: 'take-profit' | 'stop-loss', price: number): Promise<{ orderId?: string }> { + const exitSide = pos.side === 'long' ? 'short' : 'long'; + const trigger = this.exitPrice(spec, price, exitSide); + const body = kind === 'take-profit' + ? { positionId: pos.positionId, executionType: 'limit', triggerPrice: trigger, limitPrice: trigger } + : { positionId: pos.positionId, executionType: 'market', triggerPrice: trigger }; + return this.submit(uuid, `/v3/positions/${kind}`, body); + } + + private async heldPosition(uuid: string, symbol: string, exitSide: 'long' | 'short'): Promise { + const pos = (await this.freshPositions(uuid).catch((err) => { + throw new OrderNotPlacedError(`Vest position check failed — exit not sent: ${errorMessage(err)}`); + })).find((p) => p.symbol === symbol && p.side !== exitSide); + if (!pos) throw new OrderNotPlacedError(`no ${symbol} position to exit — nothing sent`); + return pos; + } + + /** A take-profit on the whole open position. Exit only: with no opposite + * position there is nothing to close, so nothing is sent. */ + public async placeOrderNoWait( + accountId: number, + contractSymbol: string, + _quantity: number, + action: 'Buy' | 'Sell', + orderType: 'Market' | 'Limit', + price?: number, + ): Promise<{ orderId?: number }> { + const { uuid, symbol, spec } = this.orderContext(accountId, contractSymbol); + const exitSide: 'long' | 'short' = action === 'Buy' ? 'long' : 'short'; + const pos = await this.heldPosition(uuid, symbol, exitSide); + if (orderType !== 'Limit' || price === undefined) { + await this.submit(uuid, '/v3/positions/close', { positionId: pos.positionId, symbol, orderType: 'market' }); + return {}; + } + await this.addLeg(uuid, spec, pos, 'take-profit', price); + return {}; + } + + /** + * Take-profit and stop on the open position. Vest holds them as legs of + * the position and cancels the other when one triggers (a native OCO) or + * the position closes. The stop goes first, so the position is never + * without it. + * + * What the caller may do next depends on what is left at the broker: + * OrderNotPlacedError = nothing of the pair is working; a plain Error = + * something may be (a stop, at least) — place nothing more. + */ + public async placeOcoExit( + accountId: number, + contractSymbol: string, + _quantity: number, + exitAction: 'Buy' | 'Sell', + limitPrice: number, + stopPrice: number, + ): Promise<{ orderId?: number }> { + const { uuid, symbol, spec } = this.orderContext(accountId, contractSymbol); + const exitSide: 'long' | 'short' = exitAction === 'Buy' ? 'long' : 'short'; + const pos = await this.heldPosition(uuid, symbol, exitSide); + await this.addLeg(uuid, spec, pos, 'stop-loss', stopPrice); // throws OrderNotPlacedError when nothing went in + try { + await this.addLeg(uuid, spec, pos, 'take-profit', limitPrice); + } catch (err) { + console.error(`${this.tag} ${symbol}: stop is working but the take-profit failed (${errorMessage(err)}) — place it by hand`); + throw new Error(`stop working, take-profit not placed: ${errorMessage(err)}`); + } + console.log(`${this.tag} exits on ${pos.positionId} — take-profit @ ${this.exitPrice(spec, limitPrice, exitSide)}, stop @ ${this.exitPrice(spec, stopPrice, exitSide)}`); + return {}; + } + + public onFill(orderId: number, callback: (fill: Record) => void): void { + this.fillWaiters.push({ orderId, callback }); + } + + // ── lifecycle ──────────────────────────────────────────────────────────── + + public health(): ClientHealth { + const session = vestSessionFor(this.wallet, Date.now(), 0); + return { + platform: 'vest', + authenticated: session !== null, + transport: 'rest', + syncComplete: this.syncComplete, + lastSyncAt: this.lastSyncAt, + lastLoginAt: session?.receivedAt ?? this.lastAuthAt, + lastLoginError: this.lastAuthError, + consecutiveLoginFailures: this.consecutiveAuthFailures, + nextReconnectAt: 0, + tokenExpiresAt: session?.expiresAt ?? 0, + lastInboundAt: this.lastResponseAt, + pendingRequests: this.pendingFills, + syncInFlightSince: this.syncInFlight ? this.syncInFlightSince : 0, + daysFetchInFlightSince: this.daysFetchInFlight ? this.daysFetchInFlightSince : 0, + }; + } + + /** No session relayed for this wallet: a rebuild can't fix that — only a + * signed-in Vest tab with the extension can — so the supervisor leaves + * the client alone. */ + public get needsAttention(): boolean { + return vestSessionFor(this.wallet, Date.now(), 0) === null; + } + + /** Final: nothing this client scheduled fires afterwards. There is no + * session of its own to log out — the browser's stays the browser's. */ + public disconnect(): void { + this.intentionalDisconnect = true; + for (const t of [this.syncInterval, this.watchdogInterval]) if (t) clearInterval(t); + this.syncInterval = this.watchdogInterval = null; + this.daysFetchInFlight = false; + this.daysFetchInFlightSince = 0; + this.accountTokens.clear(); + } +} diff --git a/lib/vest-helpers.ts b/lib/vest-helpers.ts new file mode 100644 index 0000000..53af44c --- /dev/null +++ b/lib/vest-helpers.ts @@ -0,0 +1,407 @@ +/** + * Pure helpers for the Vest client (lib/vest-class.ts) — parsing the gateway's + * answers, naming accounts, Vest's trading day, market hours, volatility. + * + * Only Node built-ins are imported, so scripts/check-vest.mjs loads this file + * directly (node --experimental-strip-types). + */ + +import { createHash } from 'crypto'; + +/** A Vest login's primary wallet: 0x + 40 hex. The instance's saved Vest + * "credential" is this address — it says whose relayed session to trade. */ +export function isVestWalletAddress(value: unknown): value is string { + return typeof value === 'string' && /^0x[0-9a-fA-F]{40}$/.test(value.trim()); +} + +/** Stable numeric id for a Vest account uuid, in the same range as DXtrade's + * (dxAccountId) but keyed apart, so every numeric account table keeps + * working. */ +export function vestAccountId(uuid: string): number { + const digest = createHash('sha256').update(`vest|${uuid.toLowerCase()}`).digest(); + return 2 ** 50 + digest.readUIntBE(0, 6) * 16 + (digest[6] >> 4); +} + +/** A decimal string or number from the gateway → a number (undefined when + * missing or not a number). */ +export function num(raw: unknown): number | undefined { + if (typeof raw === 'number') return Number.isFinite(raw) ? raw : undefined; + if (typeof raw !== 'string' || raw.trim() === '') return undefined; + const n = Number(raw); + return Number.isFinite(n) ? n : undefined; +} + +/** Epoch ms from the gateway's epoch ms, epoch seconds or ISO strings; NaN + * when unreadable. */ +export function toMs(raw: unknown): number { + if (typeof raw === 'number' && Number.isFinite(raw)) return raw < 1e11 ? raw * 1000 : raw; + if (typeof raw === 'string' && raw.trim() !== '') { + const n = Number(raw); + if (Number.isFinite(n)) return n < 1e11 ? n * 1000 : n; + const t = Date.parse(raw); + return Number.isFinite(t) ? t : NaN; + } + return NaN; +} + +/** `value` on a decimal grid, as the gateway wants it: a plain string with at + * most `decimals` places, trailing zeros trimmed (never exponent notation). */ +export function formatDecimal(value: number, decimals: number): string { + const d = Math.max(0, Math.min(18, Math.floor(decimals))); + const fixed = value.toFixed(d); + return d > 0 ? fixed.replace(/\.?0+$/, '') || '0' : fixed; +} + +// ── Vest's trading day ────────────────────────────────────────────────────── + +const ET_PARTS = new Intl.DateTimeFormat('en-US', { + timeZone: 'America/New_York', hourCycle: 'h23', + year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', +}); + +/** Vest's daily reset hour, America/New_York (its docs: 20:00 ET). */ +export const VEST_RESET_HOUR_ET = 20; + +/** + * The trading day a moment belongs to, YYYY-MM-DD. Vest's day runs 20:00 ET + * to 20:00 ET and is named for the date it ends on, like the CME session + * that opens the evening before — so 21:00 ET on the 26th is the 27th's day. + */ +export function vestDayOf(ms: number): string { + const parts = Object.fromEntries(ET_PARTS.formatToParts(new Date(ms)).map((p) => [p.type, p.value])); + const date = `${parts.year}-${parts.month}-${parts.day}`; + if (Number(parts.hour) < VEST_RESET_HOUR_ET) return date; + const next = new Date(`${date}T12:00:00Z`); + next.setUTCDate(next.getUTCDate() + 1); + return next.toISOString().slice(0, 10); +} + +/** The last 20:00 ET at or before `now`, epoch ms. 20:00 ET is 00:00 UTC + * in summer and 01:00 UTC in winter; whichever of those hours, on this or + * an earlier UTC day, reads as 20:00 in New York is the boundary. */ +export function lastVestResetMs(now: number): number { + const day = Date.UTC(new Date(now).getUTCFullYear(), new Date(now).getUTCMonth(), new Date(now).getUTCDate()); + for (let back = 0; back <= 2; back++) { + for (const hour of [1, 0]) { + const t = day - back * 86_400_000 + hour * 3_600_000; + if (t > now) continue; + const parts = Object.fromEntries(ET_PARTS.formatToParts(new Date(t)).map((p) => [p.type, p.value])); + if (Number(parts.hour) === VEST_RESET_HOUR_ET) return t; + } + } + return NaN; +} + +// ── accounts ──────────────────────────────────────────────────────────────── + +export interface VestCapitalAccount { + id: string; + planId: string; + planName: string; + /** 'eval' | 'instant_funded' | … as the gateway names the product. */ + productType: string; + /** 1-based stage; stages[stage-1] is the current one. */ + stage: number; + stepsTotal: number; + /** The current stage: 'eval' or 'funded'. */ + kind: string; + status: number; + attemptIndex: number; + initialCapital: number; + /** The current stage's profit target in dollars (0 = none). */ + profitTarget: number; + targetEquity: number | undefined; + maxDrawdownLimit: number | undefined; + maxDailyLossPct: number; + dailyResetBalance: number | undefined; + dailyLossFloor: number | undefined; + dailyResetAt: number; + activatedAt: number; + createdAt: number; +} + +/** One entry of GET /v3/capital/accounts/active; null when it isn't one. */ +export function parseCapitalAccount(raw: unknown): VestCapitalAccount | null { + const a = raw as Record; + if (!a || typeof a.id !== 'string') return null; + const stages = Array.isArray(a.stages) ? (a.stages as Record[]) : []; + const stage = num(a.stage) ?? 1; + const current = stages.find((s) => num(s.ordinal) === stage) ?? stages[stage - 1] ?? {}; + return { + id: a.id, + planId: typeof a.plan_id === 'string' ? a.plan_id : '', + planName: typeof a.plan_name === 'string' ? a.plan_name : '', + productType: typeof a.plan_product_type === 'string' ? a.plan_product_type : '', + stage, + stepsTotal: num(a.steps_total) ?? 0, + kind: typeof current.kind === 'string' ? current.kind : '', + status: num(a.status) ?? -1, + attemptIndex: num(a.attempt_index) ?? 0, + initialCapital: num(a.initial_capital) ?? 0, + profitTarget: num(current.profit_target) ?? 0, + targetEquity: num(a.target_equity), + maxDrawdownLimit: num(a.max_drawdown_limit), + maxDailyLossPct: num(a.max_daily_loss_pct) ?? 0, + dailyResetBalance: num(a.daily_reset_balance), + dailyLossFloor: num(a.daily_loss_floor), + dailyResetAt: toMs(a.daily_reset_at), + activatedAt: toMs(a.activated_at), + createdAt: toMs(a.created_at), + }; +} + +/** The capital account status every listed live account showed (2026-10-26). + * Anything else is treated as unable to trade until seen and named. */ +export const VEST_STATUS_ACTIVE = 2; + +const pct = (fraction: number) => Math.round(fraction * 1000) / 10; + +/** + * What the dashboard calls the account and what account-type prefixes match + * against. Vest itself only says "Account 34", so the name carries the rules + * that tell its types apart: + * V50K-E-D3-T10-34 evaluation, $50k, 3% daily loss, 10% target, attempt 34 + * V50K-F-D3-34 the funded stage of an evaluation account + * V25K-I-7 instant funded (no daily loss limit, no target) + * D0 = no daily loss limit. The target is the current stage's (a 2-Step's + * second stage reads T5). + */ +export function vestAccountName(a: VestCapitalAccount): string { + const size = a.initialCapital >= 1000 ? `${+(a.initialCapital / 1000).toFixed(1)}K` : `${Math.round(a.initialCapital)}`; + const dll = `D${pct(a.maxDailyLossPct)}`; + if (/instant/i.test(a.productType)) return `V${size}-I-${a.attemptIndex}`; + if (a.kind === 'funded') return `V${size}-F-${dll}-${a.attemptIndex}`; + const target = a.initialCapital > 0 && a.profitTarget > 0 ? `-T${pct(a.profitTarget / a.initialCapital)}` : ''; + return `V${size}-E-${dll}${target}-${a.attemptIndex}`; +} + +// ── positions and orders ──────────────────────────────────────────────────── + +export interface VestLeg { + id: string; + triggerPrice: number; + executionType: string; + status: string; +} + +export interface VestPosition { + positionId: string; + accountId: string; + symbol: string; + side: 'long' | 'short'; + quantity: number; + openPrice: number; + /** Margin the position holds out of the free balance. */ + collateral: number; + leverage: number; + openedAt: number; + stopLosses: VestLeg[]; + takeProfits: VestLeg[]; +} + +function parseLegs(raw: unknown): VestLeg[] { + if (!Array.isArray(raw)) return []; + return raw.flatMap((l) => { + const leg = l as Record; + const trigger = num(leg?.triggerPrice); + if (typeof leg?.id !== 'string' || trigger === undefined) return []; + return [{ id: leg.id, triggerPrice: trigger, executionType: String(leg.executionType ?? ''), status: String(leg.status ?? '') }]; + }); +} + +/** One entry of GET /v3/positions/opened; null when it isn't an open one. */ +export function parsePosition(raw: unknown): VestPosition | null { + const p = raw as Record; + const accountId = p?.accountId ?? p?.account_id; + if (!p || typeof p.positionId !== 'string' || typeof accountId !== 'string' || typeof p.symbol !== 'string') return null; + const quantity = Math.abs(num(p.quantity) ?? 0); + if (!(quantity > 0)) return null; + return { + positionId: p.positionId, + accountId, + symbol: p.symbol, + side: String(p.side).toLowerCase() === 'short' ? 'short' : 'long', + quantity, + openPrice: num(p.openPrice) ?? NaN, + collateral: num(p.collateral) ?? 0, + leverage: num(p.leverage) ?? 0, + openedAt: toMs(p.openDate ?? p.createdAt), + stopLosses: parseLegs(p.stopLosses), + takeProfits: parseLegs(p.takeProfits), + }; +} + +/** Exit legs still waiting to trigger. */ +export function awaitingLegs(p: VestPosition): VestLeg[] { + return [...p.stopLosses, ...p.takeProfits].filter((l) => l.status === 'awaiting'); +} + +export interface VestOpenOrder { + orderId: string; + accountId: string; + positionId: string | undefined; + symbol: string; + createdAt: number; +} + +/** One entry of GET /v3/positions/opened-orders (snake or camel case). */ +export function parseOpenOrder(raw: unknown): VestOpenOrder | null { + const o = raw as Record; + const orderId = o?.order_id ?? o?.orderId ?? o?.id; + const accountId = o?.account_id ?? o?.accountId; + if (typeof orderId !== 'string' || typeof accountId !== 'string') return null; + const positionId = o.position_id ?? o.positionId; + return { + orderId, + accountId, + positionId: typeof positionId === 'string' ? positionId : undefined, + symbol: String(o.symbol ?? ''), + createdAt: toMs(o.created_at ?? o.createdAt), + }; +} + +/** Unrealized P&L of a position at `mark`. */ +export function unrealizedPnl(p: Pick, mark: number): number { + if (!Number.isFinite(mark) || !Number.isFinite(p.openPrice)) return 0; + return (p.side === 'long' ? 1 : -1) * p.quantity * (mark - p.openPrice); +} + +// ── transactions ──────────────────────────────────────────────────────────── + +/** + * The gateway's transaction types (its app's own enum, 2026-10-26). Trading + * P&L is fees, realized P&L, funding and the adjustments that come with them; + * funds are money moved in or out; the rest only moves margin between the + * free balance and positions, and leaves the account's total alone. + */ +export const VEST_TXN = { + DEPOSIT: 1, WITHDRAW: 2, OPEN_POSITION: 3, CLOSE_POSITION: 4, CORRECTION: 5, FILL_MARGIN: 6, MARGIN_RELEASE: 7, + FEE: 8, REALIZED_PNL: 9, FUNDING: 10, LEVERAGE_RESERVE: 11, LEVERAGE_RELEASE: 12, ADD_COLLATERAL: 13, + REMOVE_COLLATERAL: 14, ORDER_MARGIN_RESERVE: 15, ORDER_MARGIN_RELEASE: 16, LIQUIDATION_DEFICIT_COVERAGE: 17, + CORPORATE_ACTION_CASH_ADJUSTMENT: 18, FUNDED_PROFIT_CREDIT: 19, AFFILIATE_PAYOUT_CREDIT: 20, COMPENSATION: 22, + BALANCE_MIGRATION: 23, +} as const; + +const TRADING_TXNS = new Set([ + VEST_TXN.CORRECTION, VEST_TXN.FEE, VEST_TXN.REALIZED_PNL, VEST_TXN.FUNDING, + VEST_TXN.LIQUIDATION_DEFICIT_COVERAGE, VEST_TXN.CORPORATE_ACTION_CASH_ADJUSTMENT, +]); +const FUND_TXNS = new Set([ + VEST_TXN.DEPOSIT, VEST_TXN.WITHDRAW, VEST_TXN.FUNDED_PROFIT_CREDIT, VEST_TXN.AFFILIATE_PAYOUT_CREDIT, + VEST_TXN.COMPENSATION, VEST_TXN.BALANCE_MIGRATION, +]); + +export type VestTxnKind = 'trade' | 'fund' | 'margin'; + +export function txnKind(type: number): VestTxnKind { + if (TRADING_TXNS.has(type)) return 'trade'; + if (FUND_TXNS.has(type)) return 'fund'; + return 'margin'; +} + +export interface VestTxn { + /** action_id, else type|time|amount — what a page overlap dedupes on. */ + key: string; + type: number; + kind: VestTxnKind; + /** Signed: what it did to the account's balance. */ + amount: number; + time: number; +} + +/** One entry of GET /v3/transactions; null when unreadable. A withdrawal is + * always negative and a deposit positive, whatever sign the gateway used. */ +export function parseTxn(raw: unknown): VestTxn | null { + const t = raw as Record; + const type = num(t?.transaction_type ?? t?.transactionType); + const amount = num(t?.amount); + const time = toMs(t?.created_at ?? t?.createdAt); + if (type === undefined || amount === undefined || !Number.isFinite(time)) return null; + const signed = type === VEST_TXN.WITHDRAW ? -Math.abs(amount) : type === VEST_TXN.DEPOSIT ? Math.abs(amount) : amount; + const action = t.action_id ?? t.actionId; + return { + key: typeof action === 'string' || typeof action === 'number' ? `${action}|${type}` : `${type}|${time}|${amount}`, + type, + kind: txnKind(type), + amount: signed, + time, + }; +} + +/** Daily trading P&L and fund rows by Vest day, from the account's + * transactions. */ +export function bookTxns(txns: VestTxn[]): { daily: Map; funds: Map } { + const daily = new Map(); + const funds = new Map(); + for (const t of txns) { + const day = vestDayOf(t.time); + if (t.kind === 'fund') { + funds.set(day, (funds.get(day) ?? 0) + t.amount); + } else if (t.kind === 'trade') { + const e = daily.get(day) ?? { pnl: 0, fees: 0 }; + e.pnl += t.amount; + if (t.type === VEST_TXN.FEE) e.fees += Math.abs(t.amount); + daily.set(day, e); + } + } + return { daily, funds }; +} + +// ── market data ───────────────────────────────────────────────────────────── + +/** Fewest hourly returns a volatility is measured from. */ +export const MIN_HOURLY_RETURNS = 24; + +/** + * σ of log returns per √hour from hourly klines ([openTime, o, h, l, close, …], + * any order). Only returns between consecutive hours count: an index perp + * closes for weekends and the daily maintenance hour, and a return across + * such a gap is a gap, not an hour's move. null when too few are left. + */ +export function hourlyVolatility(klines: unknown[]): number | null { + const rows = klines.flatMap((k) => { + if (!Array.isArray(k)) return []; + const t = num(k[0]); + const close = num(k[4]); + return t !== undefined && close !== undefined && close > 0 ? [{ t: toMs(t), close }] : []; + }).sort((a, b) => a.t - b.t); + const returns: number[] = []; + for (let i = 1; i < rows.length; i++) { + if (Math.abs(rows[i].t - rows[i - 1].t - 3_600_000) > 60_000) continue; + returns.push(Math.log(rows[i].close / rows[i - 1].close)); + } + if (returns.length < MIN_HOURLY_RETURNS) return null; + const mean = returns.reduce((s, r) => s + r, 0) / returns.length; + const variance = returns.reduce((s, r) => s + (r - mean) ** 2, 0) / (returns.length - 1); + return Math.sqrt(variance); +} + +/** + * Whether the market-hours periods (GET /v4/market-hours/{assetId}) have the + * symbol open for trading at `now`, and for how long yet. A symbol with no + * periods at all (crypto: always open) reads as open with no end. A closure + * (weekly, the daily maintenance hour) counts as closed even where the + * gateway still says OPEN: no leg should start into one. + */ +export function marketOpen(periods: unknown, now: number): { open: boolean; closesAt: number | null } { + if (!Array.isArray(periods) || periods.length === 0) return { open: true, closesAt: null }; + const list = periods.flatMap((p) => { + const r = p as { + fromMs?: unknown; toMs?: unknown; + state?: { tradingStatus?: unknown; closure?: unknown; price?: { allow?: unknown } }; + }; + const from = num(r?.fromMs); + const to = num(r?.toMs); + if (from === undefined || to === undefined) return []; + const closure = r.state?.closure; + const open = r.state?.tradingStatus === 'OPEN' && r.state?.price?.allow !== false + && (closure === undefined || closure === 'unspecified'); + return [{ from, to, open }]; + }).sort((a, b) => a.from - b.from); + const i = list.findIndex((p) => p.from <= now && now < p.to); + if (i < 0 || !list[i].open) return { open: false, closesAt: null }; + // Consecutive open periods (overnight → pre-market → regular …) are one stretch. + let end = list[i].to; + for (let j = i + 1; j < list.length && list[j].open && list[j].from <= end + 1; j++) end = list[j].to; + return { open: true, closesAt: end }; +} diff --git a/lib/vest-session.ts b/lib/vest-session.ts new file mode 100644 index 0000000..3e5fdac --- /dev/null +++ b/lib/vest-session.ts @@ -0,0 +1,87 @@ +/** + * Vest sessions relayed by the extension. + * + * Vest logs a person in through Google/Privy in the browser, and refreshes + * the session with a device key the browser will not export, so this + * machine cannot log in by itself. Instead the AutoFirmer extension watches + * the signed-in Vest tab: whenever the Vest app renews its own ~15-minute + * access token, the extension posts it here (POST /api/vest/session). The + * Vest client (lib/vest-class.ts) trades with the latest one for its wallet + * and mints per-account tokens from it. + * + * Kept in memory only — never in the DB, the logs, a report or a backup. A + * restart forgets them; the Vest tab's next renewal (or the extension's + * next tick) relays a fresh one within minutes. + */ + +export interface VestSession { + accessToken: string; + /** Epoch ms the access token stops working. */ + expiresAt: number; + /** The wallet the Vest login belongs to, lower-case. */ + primaryAddr: string; + userId: string; + receivedAt: number; +} + +/** What the dashboard and the extension may see of a session: never the token. */ +export interface VestSessionInfo { + primaryAddr: string; + userId: string; + expiresAt: number; + receivedAt: number; +} + +const g = globalThis as typeof globalThis & { __vestSessions?: Map }; + +function store(): Map { + return (g.__vestSessions ??= new Map()); +} + +/** Shortest life a relayed token must have left to be taken. */ +const MIN_REMAINING_MS = 30_000; +/** Longest a relayed token may claim to live — a Vest access token is + * ~15 minutes; anything far beyond that is not one. */ +const MAX_REMAINING_MS = 6 * 60 * 60_000; + +/** Take a relayed session. Returns why it was refused, or null. A token for + * a wallet only ever replaces one that expires sooner. */ +export function putVestSession(input: { accessToken: unknown; expiresAt: unknown; primaryAddr: unknown; userId: unknown }, now = Date.now()): string | null { + const token = typeof input.accessToken === 'string' ? input.accessToken.trim() : ''; + const expiresAt = Number(input.expiresAt); + const addr = typeof input.primaryAddr === 'string' ? input.primaryAddr.trim().toLowerCase() : ''; + if (!/^[\w-]+\.[\w-]+\.[\w-]+$/.test(token) || token.length > 8192) return 'accessToken is not a JWT'; + if (!/^0x[0-9a-f]{40}$/.test(addr)) return 'primaryAddr is not a wallet address'; + if (!Number.isFinite(expiresAt) || expiresAt - now < MIN_REMAINING_MS) return 'token already expired'; + if (expiresAt - now > MAX_REMAINING_MS) return 'expiresAt is implausibly far away'; + const prev = store().get(addr); + if (prev && prev.expiresAt > expiresAt && prev.accessToken !== token) return null; // keep the fresher one + store().set(addr, { + accessToken: token, + expiresAt, + primaryAddr: addr, + userId: typeof input.userId === 'string' ? input.userId : '', + receivedAt: now, + }); + return null; +} + +/** The live session for a wallet, or null when none has been relayed or it + * is about to expire. */ +export function vestSessionFor(primaryAddr: string, now = Date.now(), minRemainingMs = MIN_REMAINING_MS): VestSession | null { + const s = store().get(primaryAddr.trim().toLowerCase()); + return s && s.expiresAt - now >= minRemainingMs ? s : null; +} + +/** Drop a wallet's session — the gateway refused it. */ +export function dropVestSession(primaryAddr: string, token: string): void { + const key = primaryAddr.trim().toLowerCase(); + if (store().get(key)?.accessToken === token) store().delete(key); +} + +/** Every relayed session, newest first, without tokens. */ +export function listVestSessions(): VestSessionInfo[] { + return [...store().values()] + .sort((a, b) => b.receivedAt - a.receivedAt) + .map(({ primaryAddr, userId, expiresAt, receivedAt }) => ({ primaryAddr, userId, expiresAt, receivedAt })); +} diff --git a/package.json b/package.json index dedf19a..f2d70dd 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "build": "next build", "start": "next start", "lint": "eslint", - "check": "node --experimental-strip-types --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/check-trading-logic.mjs scripts/check-instruments.mjs scripts/check-account-status.mjs scripts/check-kept-rules.mjs scripts/check-request-user-agent.mjs" + "check": "node --experimental-strip-types --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/check-trading-logic.mjs scripts/check-instruments.mjs scripts/check-account-status.mjs scripts/check-kept-rules.mjs scripts/check-request-user-agent.mjs scripts/check-vest.mjs" }, "dependencies": { "@microsoft/signalr": "^10.0.11", diff --git a/scripts/check-vest.mjs b/scripts/check-vest.mjs new file mode 100644 index 0000000..f89f54b --- /dev/null +++ b/scripts/check-vest.mjs @@ -0,0 +1,227 @@ +// Checks for the Vest client's pure helpers (lib/vest-helpers.ts) and the +// relayed-session store (lib/vest-session.ts). +// +// npm run check +// +// Both files import only Node built-ins, so Node strips their types and loads +// them directly; .mjs keeps this file out of Next's type check. The samples are +// shaped like the gateway's real answers (captured 2026-10-26). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + isVestWalletAddress, vestAccountId, num, toMs, formatDecimal, vestDayOf, lastVestResetMs, parseCapitalAccount, + vestAccountName, parsePosition, awaitingLegs, parseOpenOrder, unrealizedPnl, parseTxn, bookTxns, txnKind, VEST_TXN, + hourlyVolatility, marketOpen, MIN_HOURLY_RETURNS, +} from '../lib/vest-helpers.ts'; +import { putVestSession, vestSessionFor, dropVestSession, listVestSessions } from '../lib/vest-session.ts'; + +const iso = (s) => Date.parse(s); + +test('wallet addresses and account ids', () => { + assert.equal(isVestWalletAddress('0x7ce10771819f80130e204ddb5ca1522e48a9b451'), true); + assert.equal(isVestWalletAddress('0x7CE10771819F80130E204DDB5CA1522E48A9B451'), true); + assert.equal(isVestWalletAddress('0x7ce1'), false); + assert.equal(isVestWalletAddress(''), false); + const id = vestAccountId('843f60e2-e013-5a0e-8b6c-face84bc5509'); + assert.equal(id, vestAccountId('843F60E2-E013-5A0E-8B6C-FACE84BC5509')); + assert.notEqual(id, vestAccountId('102566b2-515a-51c5-9ad7-4a7eae0a6a2a')); + assert.ok(id >= 2 ** 50 && Number.isSafeInteger(id)); +}); + +test('numbers, times and decimals as the gateway writes them', () => { + assert.equal(num('49968.511115'), 49968.511115); + assert.equal(num(''), undefined); + assert.equal(num(null), undefined); + assert.equal(toMs(1793035089), 1793035089000); + assert.equal(toMs(1793035089277), 1793035089277); + assert.equal(toMs('2026-10-26T18:41:02.886Z'), iso('2026-10-26T18:41:02.886Z')); + assert.ok(Number.isNaN(toMs(undefined))); + assert.equal(formatDecimal(29554.5, 2), '29554.5'); + assert.equal(formatDecimal(50, 2), '50'); + assert.equal(formatDecimal(3 * 0.0001, 4), '0.0003'); + assert.equal(formatDecimal(42.65449999, 4), '42.6545'); + assert.equal(formatDecimal(1e-7, 4), '0'); +}); + +test("Vest's day turns at 20:00 New York, summer and winter", () => { + // EDT (UTC−4): 20:00 ET is 00:00 UTC. + assert.equal(vestDayOf(iso('2026-10-26T23:59:00Z')), '2026-10-26'); + assert.equal(vestDayOf(iso('2026-10-27T00:00:00Z')), '2026-10-27'); + assert.equal(vestDayOf(iso('2026-10-26T22:05:00Z')), '2026-10-26'); // the bots' 6:05 PM ET open + // EST (UTC−5): 20:00 ET is 01:00 UTC. + assert.equal(vestDayOf(iso('2026-12-01T00:30:00Z')), '2026-11-30'); + assert.equal(vestDayOf(iso('2026-12-01T01:00:00Z')), '2026-12-01'); + // Month and year ends. + assert.equal(vestDayOf(iso('2026-12-31T23:00:00Z')), '2026-12-31'); + assert.equal(vestDayOf(iso('2027-01-01T01:30:00Z')), '2027-01-01'); + + assert.equal(lastVestResetMs(iso('2026-10-26T18:00:00Z')), iso('2026-10-26T00:00:00Z')); + assert.equal(lastVestResetMs(iso('2026-10-27T00:00:00Z')), iso('2026-10-27T00:00:00Z')); + assert.equal(lastVestResetMs(iso('2026-12-01T00:30:00Z')), iso('2026-11-30T01:00:00Z')); + assert.equal(lastVestResetMs(iso('2026-12-01T01:00:01Z')), iso('2026-12-01T01:00:00Z')); + // Across the November switch back (Nov 1 2026, 2 AM ET). + assert.equal(lastVestResetMs(iso('2026-11-01T12:00:00Z')), iso('2026-11-01T00:00:00Z')); + assert.equal(lastVestResetMs(iso('2026-11-02T12:00:00Z')), iso('2026-11-02T01:00:00Z')); +}); + +const evalAccount = { + payment_id: 'tailoredpay:vest-markets:production:12727557177', + id: 'e7f69717-bd18-5cb7-9466-673cb283d0b2', + plan_id: '86bb03f9-7422-513c-8d5c-c546cb8d221d', + account_type: 2, stage: 1, status: 2, + initial_capital: '50000.00000000', max_drawdown_pct: '0.060000', max_leverage: '5.0000', max_profit_split_pct: '0.8000', + target_equity: '55000.00000000', max_drawdown_limit: '47000.00000000', max_daily_loss_pct: '0.030000', + daily_reset_balance: '50000.00000000000000', daily_reset_equity: '50000.00000000000000', + daily_reset_base_limit: '1500.000000000000000', daily_loss_floor: '48500.00000000000000', daily_reset_at: 1793035043842, + origin: 'eval_purchase', plan_name: 'Vest Capital Evaluation Fee | 10% Goal | 3% Daily Loss | 80/20 | Emerald', + plan_product_type: 'eval', step: 1, steps_total: 1, root_account_id: 'e7f69717-bd18-5cb7-9466-673cb283d0b2', + stages: [ + { ordinal: 1, kind: 'eval', initial_capital: '50000.00000000', profit_target: '5000.00000000', max_drawdown_pct: '0.060000' }, + { ordinal: 2, kind: 'funded', initial_capital: '50000.00000000', profit_target: '', max_drawdown_pct: '0.060000' }, + ], + attempt_index: 34, activated_at: 1793035043834, created_at: 1793035043756, updated_at: 1793035043843, +}; + +test('capital accounts parse, and are named for their rules', () => { + const a = parseCapitalAccount(evalAccount); + assert.equal(a.id, evalAccount.id); + assert.equal(a.kind, 'eval'); + assert.equal(a.status, 2); + assert.equal(a.initialCapital, 50000); + assert.equal(a.profitTarget, 5000); + assert.equal(a.maxDrawdownLimit, 47000); + assert.equal(a.dailyLossFloor, 48500); + assert.equal(a.maxDailyLossPct, 0.03); + assert.equal(a.dailyResetAt, 1793035043842); + assert.equal(vestAccountName(a), 'V50K-E-D3-T10-34'); + + assert.equal(vestAccountName(parseCapitalAccount({ ...evalAccount, stage: 2 })), 'V50K-F-D3-34'); + assert.equal(vestAccountName(parseCapitalAccount({ ...evalAccount, max_daily_loss_pct: '0' })), 'V50K-E-D0-T10-34'); + assert.equal(vestAccountName(parseCapitalAccount({ + ...evalAccount, initial_capital: '5000.00000000', attempt_index: 12, stage: 2, steps_total: 2, + stages: [ + { ordinal: 1, kind: 'eval', profit_target: '500.00000000' }, + { ordinal: 2, kind: 'eval', profit_target: '250.00000000' }, + { ordinal: 3, kind: 'funded', profit_target: '' }, + ], + })), 'V5K-E-D3-T5-12'); + assert.equal(vestAccountName(parseCapitalAccount({ + ...evalAccount, plan_product_type: 'instant_funded', initial_capital: '25000.00000000', max_daily_loss_pct: '0', attempt_index: 7, + stages: [{ ordinal: 1, kind: 'funded', profit_target: '' }], + })), 'V25K-I-7'); + assert.equal(vestAccountName(parseCapitalAccount({ + ...evalAccount, plan_product_type: 'instant_funded', initial_capital: '500.00000000', attempt_index: 1, + stages: [{ ordinal: 1, kind: 'funded', profit_target: '' }], + })), 'V500-I-1'); + assert.equal(parseCapitalAccount({ nope: true }), null); +}); + +const openPosition = { + positionId: 'afe96d7e-f5ac-4b8a-aae8-f5b63b74e508', accountId: '843f60e2-e013-5a0e-8b6c-face84bc5509', accountType: 2, + symbol: 'NDX-USD-PERP', side: 'short', quantity: '42.6545', openPrice: '29529.250000000', closePrice: '290.250000000', + openDate: 1793035089277, pnl: '0', fee: '31.488885', funding: '-5.423271000000000000', collateral: '24944.027426', leverage: '50', + createdAt: 1793035089244, updatedAt: 1793037621104, + stopLosses: [{ id: 'e22311d2-fbd9-4ab8-b65f-c2921f58fefc', triggerPrice: '29554.500000000', sizeMode: 'full_position', executionType: 'market', status: 'awaiting' }], + takeProfits: [], +}; + +test('positions, their legs and unrealized P&L', () => { + const p = parsePosition(openPosition); + assert.equal(p.side, 'short'); + assert.equal(p.quantity, 42.6545); + assert.equal(p.collateral, 24944.027426); + assert.equal(p.openedAt, 1793035089277); + assert.equal(awaitingLegs(p).length, 1); + assert.equal(awaitingLegs({ ...p, stopLosses: [{ ...p.stopLosses[0], status: 'triggered' }] }).length, 0); + // Short 42.6545 from 29529.25, marked at 29477.50: +51.75 points each. + assert.ok(Math.abs(unrealizedPnl(p, 29477.5) - 42.6545 * 51.75) < 1e-6); + assert.equal(unrealizedPnl(p, NaN), 0); + assert.equal(parsePosition({ ...openPosition, quantity: '0' }), null); + assert.equal(parseOpenOrder({ order_id: 'o1', account_id: 'a1', position_id: 'p1', symbol: 'GC-PERP', created_at: 1 }).positionId, 'p1'); + assert.equal(parseOpenOrder({ symbol: 'x' }), null); +}); + +test('transactions book into daily P&L and fund rows by Vest day', () => { + assert.equal(txnKind(VEST_TXN.FEE), 'trade'); + assert.equal(txnKind(VEST_TXN.REALIZED_PNL), 'trade'); + assert.equal(txnKind(VEST_TXN.FUNDING), 'trade'); + assert.equal(txnKind(VEST_TXN.WITHDRAW), 'fund'); + assert.equal(txnKind(VEST_TXN.FILL_MARGIN), 'margin'); + assert.equal(txnKind(VEST_TXN.ORDER_MARGIN_RELEASE), 'margin'); + + const w = parseTxn({ account_type: 3, action_id: 'a9', amount: '1200', created_at: iso('2026-10-26T15:00:00Z'), transaction_type: 2 }); + assert.equal(w.amount, -1200); + assert.equal(w.kind, 'fund'); + assert.equal(parseTxn({ amount: 'x', transaction_type: 9, created_at: 1 }), null); + + const rows = [ + { action_id: 'd', amount: '50000', created_at: iso('2026-10-20T14:00:00Z'), transaction_type: 1 }, + { action_id: 'o', amount: '-24944.02', created_at: iso('2026-10-26T17:18:00Z'), transaction_type: 6 }, + { action_id: 'f1', amount: '-31.49', created_at: iso('2026-10-26T17:18:00Z'), transaction_type: 8 }, + // After 20:00 ET on the 26th: the 27th's day. + { action_id: 'r1', amount: '812.5', created_at: iso('2026-10-27T00:30:00Z'), transaction_type: 9 }, + { action_id: 'f2', amount: '-31.2', created_at: iso('2026-10-27T00:30:00Z'), transaction_type: 8 }, + { action_id: 'u1', amount: '-5.42', created_at: iso('2026-10-27T01:00:00Z'), transaction_type: 10 }, + ].map(parseTxn); + const { daily, funds } = bookTxns(rows); + assert.deepEqual([...funds.entries()], [['2026-10-20', 50000]]); + assert.ok(Math.abs(daily.get('2026-10-26').pnl - -31.49) < 1e-9); + assert.ok(Math.abs(daily.get('2026-10-27').pnl - (812.5 - 31.2 - 5.42)) < 1e-9); + assert.ok(Math.abs(daily.get('2026-10-27').fees - 31.2) < 1e-9); +}); + +test('hourly volatility leaves out the gaps', () => { + const start = iso('2026-10-19T00:00:00Z'); + const kl = []; + let price = 29000; + for (let h = 0; h < 60; h++) { + price *= h % 2 === 0 ? 1.001 : 1 / 1.001; + kl.push([start + h * 3_600_000, '0', '0', '0', String(price), '1']); + } + const base = hourlyVolatility(kl); + assert.ok(base > 0.0009 && base < 0.0011, `σ ${base}`); + // A weekend gap with a 3% jump: not an hour's move, so σ doesn't change. + const gapped = [...kl, [start + 120 * 3_600_000, '0', '0', '0', String(price * 1.03), '1']]; + assert.ok(Math.abs(hourlyVolatility(gapped) - base) < 1e-12); + // Any order in, the same σ out. + assert.equal(hourlyVolatility([...kl].reverse()), base); + assert.equal(hourlyVolatility(kl.slice(0, MIN_HOURLY_RETURNS)), null); +}); + +test('market hours: open stretches, closures and crypto', () => { + const periods = [ + { fromMs: 0, toMs: 100, state: { session: 'unspecified', closure: 'weekly', tradingStatus: 'CLOSE', price: { allow: false } } }, + { fromMs: 100, toMs: 200, state: { session: 'overnight', closure: 'unspecified', tradingStatus: 'OPEN', price: { allow: true } } }, + { fromMs: 200, toMs: 300, state: { session: 'regular', closure: 'unspecified', tradingStatus: 'OPEN', price: { allow: true } } }, + { fromMs: 300, toMs: 400, state: { session: 'unspecified', closure: 'maintenance', tradingStatus: 'OPEN', price: { allow: true } } }, + { fromMs: 400, toMs: 500, state: { session: 'overnight', closure: 'unspecified', tradingStatus: 'OPEN', price: { allow: true } } }, + ]; + assert.deepEqual(marketOpen(periods, 50), { open: false, closesAt: null }); + assert.deepEqual(marketOpen(periods, 150), { open: true, closesAt: 300 }); + assert.deepEqual(marketOpen(periods, 350), { open: false, closesAt: null }); + assert.deepEqual(marketOpen(periods, 450), { open: true, closesAt: 500 }); + assert.deepEqual(marketOpen(periods, 600), { open: false, closesAt: null }); + assert.deepEqual(marketOpen([], 600), { open: true, closesAt: null }); +}); + +test('relayed sessions: taken, kept fresher, dropped, never listed with tokens', () => { + const now = iso('2026-10-26T18:40:00Z'); + const jwt = (n) => `eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIke${n}In0.sig${n}`; + const wallet = '0x7CE10771819F80130E204DDB5CA1522E48A9B451'; + assert.equal(putVestSession({ accessToken: 'nope', expiresAt: now + 900_000, primaryAddr: wallet, userId: 'u' }, now), 'accessToken is not a JWT'); + assert.equal(putVestSession({ accessToken: jwt(1), expiresAt: now + 10_000, primaryAddr: wallet, userId: 'u' }, now), 'token already expired'); + assert.equal(putVestSession({ accessToken: jwt(1), expiresAt: now + 900_000, primaryAddr: '0x12', userId: 'u' }, now), 'primaryAddr is not a wallet address'); + assert.equal(putVestSession({ accessToken: jwt(1), expiresAt: now + 900_000, primaryAddr: wallet, userId: 'u' }, now), null); + // An older token relayed late doesn't replace the fresher one. + assert.equal(putVestSession({ accessToken: jwt(0), expiresAt: now + 300_000, primaryAddr: wallet, userId: 'u' }, now), null); + assert.equal(vestSessionFor(wallet.toLowerCase(), now).accessToken, jwt(1)); + assert.equal(vestSessionFor(wallet, now + 890_000), null); // under 30s left + const listed = listVestSessions(); + assert.equal(listed.length, 1); + assert.equal(listed[0].primaryAddr, wallet.toLowerCase()); + assert.equal('accessToken' in listed[0], false); + dropVestSession(wallet, jwt(0)); // not the held one: kept + assert.ok(vestSessionFor(wallet, now)); + dropVestSession(wallet, jwt(1)); + assert.equal(vestSessionFor(wallet, now), null); +});