765 lines
35 KiB
TypeScript
765 lines
35 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|