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 <noreply@anthropic.com>
408 lines
18 KiB
TypeScript
408 lines
18 KiB
TypeScript
/**
|
||
* 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<string, unknown>;
|
||
if (!a || typeof a.id !== 'string') return null;
|
||
const stages = Array.isArray(a.stages) ? (a.stages as Record<string, unknown>[]) : [];
|
||
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<string, unknown>;
|
||
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<string, unknown>;
|
||
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<string, unknown>;
|
||
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<VestPosition, 'side' | 'quantity' | 'openPrice'>, 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<number>([
|
||
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<number>([
|
||
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<string, unknown>;
|
||
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<string, { pnl: number; fees: number }>; funds: Map<string, number> } {
|
||
const daily = new Map<string, { pnl: number; fees: number }>();
|
||
const funds = new Map<string, number>();
|
||
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 };
|
||
}
|