Files
autofirmer-expanded/lib/vest-helpers.ts
T
Brandon LiandClaude Opus 5.5 a0e7c5b7c2 Vest: relay the login's token, never one account's
The Vest app renews each trading account's own token through the same
POST /v3/auth/refresh as the login's (with that account's refresh token
in the body). The extension relayed those too, and the dashboard kept
whichever token lived longest — so while someone traded in an account on
Vest, Me synced with a token that sees that account alone and dropped
the other 24 from the list.

Extension 0.17.2 skips account renewals. The dashboard also refuses a
token that sees one account and no primary (or names that account in
its claims), keeps the last account list, and falls back to the login's
token it replaced. An account gone from Vest's list no longer leaves a
stale position holding the lane.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 19:32:40 -05:00

545 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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' — no room at all, or the mark is already past that price.
*/
export function resetSafeStop(o: {
side: 'long' | 'short';
quantity: number;
openPrice: number;
mark: number;
/** Realized balance, collateral included — what the reset captures. */
balance: 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.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 (!(allowed > 0) || !(o.quantity > 0)) 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<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),
};
}
/**
* 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<string, unknown>;
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 closedAt = toMs(p.closeDate ?? p.close_date);
const openPrice = num(p.openPrice ?? p.open_price);
const closePrice = 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: num(p.pnl) ?? null,
fee: num(p.fee) ?? null,
funding: num(p.funding) ?? null,
openedAt,
closedAt: Number.isFinite(closedAt) && closedAt > 0 ? closedAt : null,
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<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 };
}