/** * 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; } /** The next 20:00 ET after `now`, epoch ms (a day can be 23 or 25 hours). */ export function nextVestResetMs(now: number): number { for (let h = 1; h <= 26; h++) { const t = lastVestResetMs(now + h * 3_600_000); if (t > now) return t; } return NaN; } /** * What to do with an open position before the daily reset so the reset can't * breach it. At 20:00 ET Vest captures the realized balance (collateral in, * unrealized P&L out) and sets the day's floor to balance × (1 − rate); the * max-drawdown floor stays. A position whose loss allowance is wider than * that next-day room would breach the moment the reset lands, so its stop * comes in to where equity stays above the coming floor plus `reserve`, with * the exit fee paid: * 'none' — its stop is already at least that tight; * 'stop' — move (or place) the stop at `price`, on the tick grid, rounded * toward the position's safe side; * 'close' — the mark is already past that price (with no room left by * the account's other positions, it sits on the profit side), * or no room and no mark to tell. */ export function resetSafeStop(o: { side: 'long' | 'short'; quantity: number; openPrice: number; mark: number; /** Realized balance, collateral included — what the reset captures — * less what the account's other positions may lose at their stops. */ balance: number; /** The realized balance the floor is set from; default `balance`. */ floorBalance?: number; dailyLossPct: number; maxDrawdownFloor: number | undefined; reserve: number; /** Taker fee as a fraction of notional. */ exitFeeRate: number; currentStop: number | null; tick: number; }): { action: 'none' } | { action: 'stop'; price: number } | { action: 'close' } { const floor = Math.max((o.floorBalance ?? o.balance) * (1 - o.dailyLossPct), o.maxDrawdownFloor ?? -Infinity); const exitFee = o.exitFeeRate * o.quantity * (Number.isFinite(o.mark) && o.mark > 0 ? o.mark : o.openPrice); const allowed = o.balance - floor - o.reserve - exitFee; if (!(o.quantity > 0)) return { action: 'close' }; // No room to lose (the account's other positions took it): the stop must // lock in at least the shortfall — on the profit side of the entry, // which a position already that far up can still carry. if (!(allowed > 0) && !Number.isFinite(o.mark)) return { action: 'close' }; const long = o.side === 'long'; const raw = long ? o.openPrice - allowed / o.quantity : o.openPrice + allowed / o.quantity; const price = o.tick > 0 ? (long ? Math.ceil(raw / o.tick - 1e-9) * o.tick : Math.floor(raw / o.tick + 1e-9) * o.tick) : raw; const rounded = Number(price.toFixed(10)); if (o.currentStop !== null && (long ? o.currentStop >= rounded : o.currentStop <= rounded)) return { action: 'none' }; if (Number.isFinite(o.mark) && (long ? o.mark <= rounded : o.mark >= rounded)) return { action: 'close' }; return { action: 'stop', price: rounded }; } // ── 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), }; } /** * One position from GET /v3/positions — Vest's position history, the app's * History tab: closed positions with how they ended (an open one has no * close yet). pnl is Vest's realized P&L before fees and funding. */ export interface VestTrade { positionId: string; symbol: string; side: 'long' | 'short'; /** 0 when Vest doesn't say (a store keeps the size it already knows). */ quantity: number; leverage: number | null; openPrice: number | null; closePrice: number | null; pnl: number | null; fee: number | null; funding: number | null; openedAt: number; closedAt: number | null; /** user_requested, stop_loss, take_profit, liquidation, auto_deleverage, * temporary_leverage_limit, corporate_action. */ closeReason: string | null; } export function parseHistoryPosition(raw: unknown): VestTrade | null { const p = raw as Record; const id = p?.positionId ?? p?.position_id; if (!p || typeof id !== 'string' || typeof p.symbol !== 'string') return null; const openedAt = toMs(p.openDate ?? p.open_date ?? p.createdAt ?? p.created_at); if (!Number.isFinite(openedAt)) return null; const closedMs = toMs(p.closeDate ?? p.close_date); const closedAt = Number.isFinite(closedMs) && closedMs > 0 ? closedMs : null; const openPrice = num(p.openPrice ?? p.open_price); // An open position comes with a placeholder close price and a $0 P&L // (seen: 290.25 on an NDX short at 29,538.96): only a close has them. const closePrice = closedAt === null ? undefined : num(p.closePrice ?? p.close_price); const reason = p.closeReason ?? p.close_reason; return { positionId: id, symbol: p.symbol, side: String(p.side).toLowerCase() === 'short' ? 'short' : 'long', quantity: Math.abs(num(p.quantity) ?? 0), leverage: num(p.leverage) ?? null, openPrice: openPrice !== undefined && openPrice > 0 ? openPrice : null, closePrice: closePrice !== undefined && closePrice > 0 ? closePrice : null, pnl: closedAt === null ? null : num(p.pnl) ?? null, fee: num(p.fee) ?? null, funding: num(p.funding) ?? null, openedAt, closedAt, closeReason: typeof reason === 'string' && reason !== '' ? reason : null, }; } /** Every string value in a JWT's payload, lower-case ([] when it isn't * one). Used only to compare against account ids — never logged. */ export function jwtClaimStrings(token: string): string[] { const part = token.split('.')[1]; if (!part) return []; let payload: unknown; try { payload = JSON.parse(Buffer.from(part.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')); } catch { return []; } const out: string[] = []; const walk = (v: unknown, depth: number): void => { if (typeof v === 'string') out.push(v.toLowerCase()); else if (v && typeof v === 'object' && depth < 4) for (const x of Object.values(v)) walk(x, depth + 1); }; walk(payload, 0); return out; } /** * Whether a token the extension relayed is one Vest account's own token, not * the login's. The Vest app renews both through POST /v3/auth/refresh — the * login's every ~15 minutes, an account's while someone trades in it — and * an account's token sees only that account: /v3/capital/accounts/active * lists it alone, and /v3/accounts has no primary (wallet) account. The * login sees every account and its primary one. */ export function accountScopedToken(o: { capitalIds: string[]; hasPrimary: boolean; claims: string[] }): boolean { if (o.capitalIds.length > 1) return false; if (!o.hasPrimary) return true; return o.capitalIds.some((id) => o.claims.includes(id.toLowerCase())); } /** 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 }; }