Files
autofirmer-expanded/lib/vest-helpers.ts
T
Brandon Li 3a16af38f6 Merge branch 'vest-fix-timing' into vest-fixes
# Conflicts:
#	lib/reporter.ts
#	lib/vest-class.ts
#	scripts/check-vest.mjs
2026-10-27 14:51:33 -05:00

765 lines
35 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 and errors, naming accounts, Vest's trading day, profit-claim
* bookkeeping, 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;
}
// ── gateway errors ──────────────────────────────────────────────────────────
/** The gateway answered with an error status. */
export class VestApiError extends Error {
readonly status: number;
readonly code: string | undefined;
constructor(message: string, status: number, code: string | undefined) {
super(message);
this.name = 'VestApiError';
this.status = status;
this.code = code;
}
}
/** The request never went out: the account token it needed couldn't be had. */
export class VestNotSentError extends Error {
constructor(message: string) {
super(message);
this.name = 'VestNotSentError';
}
}
/** Whether a failed request proves nothing it asked for happened: it never
* went out, or the gateway refused it (a 4xx, but not a timeout). A
* transport failure or a 5xx may have gone in. */
export function nothingWentIn(err: unknown): boolean {
return err instanceof VestNotSentError
|| (err instanceof VestApiError && err.status >= 400 && err.status < 500 && err.status !== 408);
}
/** The account token a request needs, from `mint`. Any failure to mint one —
* a timeout, a 5xx, an answer without a token — is the request not sent,
* never an outcome that could have gone in. */
export async function tokenForRequest(path: string, mint: () => Promise<string>): Promise<string> {
try {
return await mint();
} catch (err) {
throw new VestNotSentError(`${path} not sent — no account token: ${err instanceof Error ? err.message : String(err)}`);
}
}
// ── 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;
}
/** Vest's stopSlippageFraction: 0.02% of the position, about 6 NDX points.
* Measured 2026-10-27 on 9 bot stop-outs (Me, $74k–$132k): fills 0.9 points
* past the trigger on average, 2.25 at worst — the 0.1% default held back
* about 30. The flat 10% of the limit still applies under it. */
export const VEST_STOP_SLIPPAGE_FRACTION = 0.0002;
/**
* 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<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 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');
}
/**
* The position an open of unknown outcome (a timeout, a reset, a 5xx after
* the send) made, once a read shows it: on its account, in its symbol and
* side, opened between `from` and `until` — the send and the error, widened
* for the gateway's clock. A market open is IOC, so a later one isn't it.
*/
export function unknownOpenFill(
sent: { accountId: string; symbol: string; side: 'long' | 'short'; from: number; until: number },
positions: VestPosition[],
): VestPosition | null {
return positions.find((p) => p.accountId === sent.accountId && p.symbol === sent.symbol && p.side === sent.side
&& p.openedAt >= sent.from && p.openedAt <= sent.until) ?? null;
}
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 };
}
// ── profit claims ───────────────────────────────────────────────────────────
/** A claim shows in Vest's transactions as a WITHDRAW of the same amount
* within this long of it. */
export const CLAIM_MATCH_MS = 30 * 60_000;
/** A claim of unknown outcome (the request failed in transit) that no
* WITHDRAW matches in a history read this long after it never happened. */
export const UNKNOWN_CLAIM_EXPIRES_MS = 60 * 60_000;
/** A claim this client sent: the amount, when (at = once it was answered;
* sentAt = before it went out, absent on claims saved before it was kept),
* and the balance it left. unknown = the request failed in transit, so it
* may or may not have gone in — it holds further claims, but nothing is
* booked on it. */
export interface OwnClaim { at: number; sentAt?: number; amount: number; base: number; unknown?: true }
/** A WITHDRAW transaction: when, and its (negative) amount. */
export interface VestWithdrawal { at: number; amount: number }
/** The WITHDRAW that shows the claim — its amount, within CLAIM_MATCH_MS,
* the nearest in time — or undefined while Vest's transactions don't. */
export function claimWithdrawal(withdrawals: VestWithdrawal[], c: OwnClaim): VestWithdrawal | undefined {
let best: VestWithdrawal | undefined;
for (const w of withdrawals) {
if (Math.abs(Math.abs(w.amount) - c.amount) >= 0.01 || Math.abs(w.at - c.at) >= CLAIM_MATCH_MS) continue;
if (!best || Math.abs(w.at - c.at) < Math.abs(best.at - c.at)) best = w;
}
return best;
}
/** The claims Vest's transactions, as read at `readAt`, don't show: those
* still pending, and those that lapse — of unknown outcome and still not
* shown an hour on, so never made. One Vest accepted never lapses. */
export function settleClaims(claims: OwnClaim[], withdrawals: VestWithdrawal[], readAt: number): { pending: OwnClaim[]; lapsed: OwnClaim[] } {
const pending: OwnClaim[] = [];
const lapsed: OwnClaim[] = [];
for (const c of claims) {
if (claimWithdrawal(withdrawals, c)) continue;
if (c.unknown && readAt - c.at > UNKNOWN_CLAIM_EXPIRES_MS) lapsed.push(c);
else pending.push(c);
}
return { pending, lapsed };
}
/**
* The account's fund rows, one per Vest day: Vest's own (bookTxns' funds),
* plus each claim this client made that the transactions don't show yet, on
* the day it was booked. And the stored days to take back: a day a claim
* was booked on that Vest's transactions leave empty, its WITHDRAW being
* dated another day (the request spanned 20:00 ET, or the clocks differ) —
* kept, the payout would count twice.
*/
export function claimFundRows(o: {
funds: Map<string, number>; claims: OwnClaim[]; withdrawals: VestWithdrawal[]; stored: { date: string }[];
}): { rows: { date: string; amount: number }[]; dropped: string[] } {
const funds = new Map(o.funds);
const made = o.claims.filter((c) => !c.unknown);
for (const c of made) {
if (claimWithdrawal(o.withdrawals, c)) continue;
const day = vestDayOf(c.at);
funds.set(day, (funds.get(day) ?? 0) - c.amount);
}
const rows = [...funds.entries()]
.map(([date, amount]) => ({ date, amount: Math.round(amount * 100) / 100 }))
.filter((f) => f.amount !== 0)
.sort((x, y) => x.date.localeCompare(y.date));
const claimDays = new Set(made.map((c) => vestDayOf(c.at)));
const dropped = o.stored.map((f) => f.date).filter((d) => claimDays.has(d) && !rows.some((r) => r.date === d));
return { rows, dropped };
}
/**
* What a history read makes of this client's claims (VestClient.bookHistory):
* Vest's WITHDRAWs; the claims that lapse and those still outstanding
* (settleClaims); the fund rows and the stored days to take back
* (claimFundRows); and the balance the transactions add up to — `opening`
* (the capital of an account Vest funded without a deposit row) included,
* the claims they don't show yet taken out — that the books check holds the
* account to. A lapsed claim is out of it: counted, one that never went in
* would hold the account for good.
*/
export function readClaims(o: {
txns: VestTxn[]; funds: Map<string, number>; claims: OwnClaim[]; stored: { date: string }[]; readAt: number; opening: number;
}): {
withdrawals: VestWithdrawal[]; lapsed: OwnClaim[]; outstanding: OwnClaim[];
rows: { date: string; amount: number }[]; dropped: string[]; expected: number;
} {
const withdrawals = o.txns.filter((t) => t.type === VEST_TXN.WITHDRAW).map((t) => ({ at: t.time, amount: t.amount }));
const { pending, lapsed } = settleClaims(o.claims, withdrawals, o.readAt);
const { rows, dropped } = claimFundRows({ funds: o.funds, claims: o.claims.filter((c) => !lapsed.includes(c)), withdrawals, stored: o.stored });
const unbooked = pending.reduce((s, c) => s + c.amount, 0);
const expected = o.txns.reduce((s, t) => s + (t.kind === 'margin' ? 0 : t.amount), 0) + o.opening - unbooked;
return { withdrawals, lapsed, outstanding: pending, rows, dropped, expected };
}
/** Each payout taken from the account, by its Vest day, one entry per
* payout: every WITHDRAW Vest's transactions show (claims made by hand
* among them), and each claim of this client's they don't show yet, on the
* day it was booked — a fund row can't say how many, it holds a whole Vest
* day's. A day whose fund rows net negative with neither still counts once,
* as it did before. Sorted. */
export function payoutDaysOf(withdrawals: VestWithdrawal[], claims: OwnClaim[], fundRows: { date: string; amount: number }[]): string[] {
const days = [
...withdrawals.map((w) => vestDayOf(w.at)),
...claims.filter((c) => !c.unknown && !claimWithdrawal(withdrawals, c)).map((c) => vestDayOf(c.at)),
];
for (const f of fundRows) if (f.amount < 0 && !days.includes(f.date)) days.push(f.date);
return days.sort();
}
/** Payouts since `since` (the last 20:00 ET): this client's claims — each at
* its WITHDRAW's time once Vest shows it, Vest's day being the one it
* counts on, and until then at when it was sent — or the WITHDRAWs Vest
* shows, whichever counts more (a claim made by hand shows only there).
* Sent, not answered: Vest dates it no earlier (clocks in sync), so a
* claim answered just after 20:00 ET can't put the fresh day on a payout
* stage before Vest says it's that day's; one Vest dates after the turn
* reads as the day before until then, the cautious way round. */
export function payoutsSince(withdrawals: VestWithdrawal[], claims: OwnClaim[], since: number): number {
const mine = claims.filter((c) => !c.unknown && (claimWithdrawal(withdrawals, c)?.at ?? c.sentAt ?? c.at) >= since).length;
const seen = withdrawals.filter((w) => w.at >= since).length;
return Math.max(mine, seen);
}
// ── 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 = hoursPeriods(periods);
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 };
}
/** The market-hours periods, read and in order; open = trading, with no
* closure (see marketOpen). */
function hoursPeriods(periods: unknown[]): { from: number; to: number; open: boolean }[] {
return 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);
}
/**
* Whether the closure that starts at `closesAt` (marketOpen's) keeps the
* market shut across one of Vest's 8 PM ET resets — a weekend, a holiday —
* or for as far as the periods go. A position held into it can't be exited
* or fitted to that reset until the market reopens, and meets the reopen's
* gap with its stop; the daily maintenance hour reopens before the reset.
* A close the periods don't show (they just end there) isn't one.
*/
export function longClosure(periods: unknown, closesAt: number): boolean {
if (!Array.isArray(periods)) return false;
const list = hoursPeriods(periods);
if (!list.some((p) => p.to > closesAt)) return false;
const reopensAt = list.find((p) => p.open && p.from >= closesAt)?.from;
return reopensAt === undefined || nextVestResetMs(closesAt) < reopensAt;
}