Market close: a Vest entry now needs its symbol's market to stay open for RESET_CLEAR_HOLDS holds of the leg (clearsClose), the clearance the 8 PM ET reset already gets: at the target hold, and at the leg's sized hold when a leverage cap stretches it. That covers the weekly close and the daily maintenance hour as Vest's market-hours periods list them. Before a closure that runs past an 8 PM ET reset (the weekend, a holiday: longClosure), the bot's own legs still open in the last 5 minutes are closed at market, loudly; a position the bot didn't open is named in the log and left alone. Vest's day: Vest accounts are planned, and their legs and entries counted, on Vest's day (20:00 ET) instead of the Central date (tradeDateFor, PlatformClient.tradeDate). Between 5 PM CT and the reset the Central date counted the open Vest day as a prior day and as today's realized P&L both ($3,000 to go, $800 made: $2,050 left, not $1,900) and started the leg count over. Other platforms keep the Central date. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
585 lines
26 KiB
TypeScript
585 lines
26 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;
|
||
}
|
||
|
||
/** 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');
|
||
}
|
||
|
||
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 = 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;
|
||
}
|