On Vest, a claim lowers the daily-loss floor by the amount claimed. Winning $1,500 and claiming it leaves $3,000 of room the same day, the full drawdown. The bot now uses that. - Payout claims: a scheduler step (runClaims, under the lane mutex, before the flat gate) claims every payoutable account that reached its stage's target, via a platform that takes payouts by API. The claim is all profit above the starting balance. It then clears the account's legs and entry outcome for the day so it can trade its next stage. Failures retry after 5 minutes. The auto_claim setting 'off' disables it. - VestClient.claimProfit: POST /v3/capital/withdraw to the login's main account (account_type 1 on /v3/accounts). The payout is booked at once: today's fund row and the cycle cut, with today's P&L counted from the post-claim balance. The history read later rebooks it from Vest's WITHDRAW row, and the books check counts a claim until that row shows. - Same-day stages: an account type can pick its stage by the payouts since the firm's last daily reset (stagesSameDay, sameDayClaims). A stage can carry its own daily loss limit. - That limit replaces the type's everywhere room, stops and last stands are worked out: the batch, the manual leg, copy trade, the dashboard and the report. - Room from the platform's own floor: dailyLossFloor (Vest) makes the room min(stage limit, balance − Vest's floor). A same-day claim that lowers the floor therefore frees room, and the room is never more than Vest allows. VestClient.dayStartBalance is gone. - Before the 8 PM ET reset: from 7:55 PM ET, each open Vest position on an account with a daily limit is fitted to the room the reset will leave (resetSafeStop). Its stop comes in via PUT/POST stop-loss, or the position closes when there is no room. - Checks for same-day stages, the stage limit, floorRoom, the post-claim plan, the next reset across both time changes, and resetSafeStop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
460 lines
20 KiB
TypeScript
460 lines
20 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;
|
||
}
|
||
|
||
/**
|
||
* What to do with an open position before the daily reset so the reset can't
|
||
* breach it. At 20:00 ET Vest captures the realized balance (collateral in,
|
||
* unrealized P&L out) and sets the day's floor to balance × (1 − rate); the
|
||
* max-drawdown floor stays. A position whose loss allowance is wider than
|
||
* that next-day room would breach the moment the reset lands, so its stop
|
||
* comes in to where equity stays above the coming floor plus `reserve`, with
|
||
* the exit fee paid:
|
||
* 'none' — its stop is already at least that tight;
|
||
* 'stop' — move (or place) the stop at `price`, on the tick grid, rounded
|
||
* toward the position's safe side;
|
||
* 'close' — no room at all, or the mark is already past that price.
|
||
*/
|
||
export function resetSafeStop(o: {
|
||
side: 'long' | 'short';
|
||
quantity: number;
|
||
openPrice: number;
|
||
mark: number;
|
||
/** Realized balance, collateral included — what the reset captures. */
|
||
balance: number;
|
||
dailyLossPct: number;
|
||
maxDrawdownFloor: number | undefined;
|
||
reserve: number;
|
||
/** Taker fee as a fraction of notional. */
|
||
exitFeeRate: number;
|
||
currentStop: number | null;
|
||
tick: number;
|
||
}): { action: 'none' } | { action: 'stop'; price: number } | { action: 'close' } {
|
||
const floor = Math.max(o.balance * (1 - o.dailyLossPct), o.maxDrawdownFloor ?? -Infinity);
|
||
const exitFee = o.exitFeeRate * o.quantity * (Number.isFinite(o.mark) && o.mark > 0 ? o.mark : o.openPrice);
|
||
const allowed = o.balance - floor - o.reserve - exitFee;
|
||
if (!(allowed > 0) || !(o.quantity > 0)) return { action: 'close' };
|
||
const long = o.side === 'long';
|
||
const raw = long ? o.openPrice - allowed / o.quantity : o.openPrice + allowed / o.quantity;
|
||
const price = o.tick > 0
|
||
? (long ? Math.ceil(raw / o.tick - 1e-9) * o.tick : Math.floor(raw / o.tick + 1e-9) * o.tick)
|
||
: raw;
|
||
const rounded = Number(price.toFixed(10));
|
||
if (o.currentStop !== null && (long ? o.currentStop >= rounded : o.currentStop <= rounded)) return { action: 'none' };
|
||
if (Number.isFinite(o.mark) && (long ? o.mark <= rounded : o.mark >= rounded)) return { action: 'close' };
|
||
return { action: 'stop', price: rounded };
|
||
}
|
||
|
||
// ── accounts ────────────────────────────────────────────────────────────────
|
||
|
||
export interface VestCapitalAccount {
|
||
id: string;
|
||
planId: string;
|
||
planName: string;
|
||
/** 'eval' | 'instant_funded' | … as the gateway names the product. */
|
||
productType: string;
|
||
/** 1-based stage; stages[stage-1] is the current one. */
|
||
stage: number;
|
||
stepsTotal: number;
|
||
/** The current stage: 'eval' or 'funded'. */
|
||
kind: string;
|
||
status: number;
|
||
attemptIndex: number;
|
||
initialCapital: number;
|
||
/** The current stage's profit target in dollars (0 = none). */
|
||
profitTarget: number;
|
||
targetEquity: number | undefined;
|
||
maxDrawdownLimit: number | undefined;
|
||
maxDailyLossPct: number;
|
||
dailyResetBalance: number | undefined;
|
||
dailyLossFloor: number | undefined;
|
||
dailyResetAt: number;
|
||
activatedAt: number;
|
||
createdAt: number;
|
||
}
|
||
|
||
/** One entry of GET /v3/capital/accounts/active; null when it isn't one. */
|
||
export function parseCapitalAccount(raw: unknown): VestCapitalAccount | null {
|
||
const a = raw as Record<string, unknown>;
|
||
if (!a || typeof a.id !== 'string') return null;
|
||
const stages = Array.isArray(a.stages) ? (a.stages as Record<string, unknown>[]) : [];
|
||
const stage = num(a.stage) ?? 1;
|
||
const current = stages.find((s) => num(s.ordinal) === stage) ?? stages[stage - 1] ?? {};
|
||
return {
|
||
id: a.id,
|
||
planId: typeof a.plan_id === 'string' ? a.plan_id : '',
|
||
planName: typeof a.plan_name === 'string' ? a.plan_name : '',
|
||
productType: typeof a.plan_product_type === 'string' ? a.plan_product_type : '',
|
||
stage,
|
||
stepsTotal: num(a.steps_total) ?? 0,
|
||
kind: typeof current.kind === 'string' ? current.kind : '',
|
||
status: num(a.status) ?? -1,
|
||
attemptIndex: num(a.attempt_index) ?? 0,
|
||
initialCapital: num(a.initial_capital) ?? 0,
|
||
profitTarget: num(current.profit_target) ?? 0,
|
||
targetEquity: num(a.target_equity),
|
||
maxDrawdownLimit: num(a.max_drawdown_limit),
|
||
maxDailyLossPct: num(a.max_daily_loss_pct) ?? 0,
|
||
dailyResetBalance: num(a.daily_reset_balance),
|
||
dailyLossFloor: num(a.daily_loss_floor),
|
||
dailyResetAt: toMs(a.daily_reset_at),
|
||
activatedAt: toMs(a.activated_at),
|
||
createdAt: toMs(a.created_at),
|
||
};
|
||
}
|
||
|
||
/** The capital account status every listed live account showed (2026-10-26).
|
||
* Anything else is treated as unable to trade until seen and named. */
|
||
export const VEST_STATUS_ACTIVE = 2;
|
||
|
||
const pct = (fraction: number) => Math.round(fraction * 1000) / 10;
|
||
|
||
/**
|
||
* What the dashboard calls the account and what account-type prefixes match
|
||
* against. Vest itself only says "Account 34", so the name carries the rules
|
||
* that tell its types apart:
|
||
* V50K-E-D3-T10-34 evaluation, $50k, 3% daily loss, 10% target, attempt 34
|
||
* V50K-F-D3-34 the funded stage of an evaluation account
|
||
* V25K-I-7 instant funded (no daily loss limit, no target)
|
||
* D0 = no daily loss limit. The target is the current stage's (a 2-Step's
|
||
* second stage reads T5).
|
||
*/
|
||
export function vestAccountName(a: VestCapitalAccount): string {
|
||
const size = a.initialCapital >= 1000 ? `${+(a.initialCapital / 1000).toFixed(1)}K` : `${Math.round(a.initialCapital)}`;
|
||
const dll = `D${pct(a.maxDailyLossPct)}`;
|
||
if (/instant/i.test(a.productType)) return `V${size}-I-${a.attemptIndex}`;
|
||
if (a.kind === 'funded') return `V${size}-F-${dll}-${a.attemptIndex}`;
|
||
const target = a.initialCapital > 0 && a.profitTarget > 0 ? `-T${pct(a.profitTarget / a.initialCapital)}` : '';
|
||
return `V${size}-E-${dll}${target}-${a.attemptIndex}`;
|
||
}
|
||
|
||
// ── positions and orders ────────────────────────────────────────────────────
|
||
|
||
export interface VestLeg {
|
||
id: string;
|
||
triggerPrice: number;
|
||
executionType: string;
|
||
status: string;
|
||
}
|
||
|
||
export interface VestPosition {
|
||
positionId: string;
|
||
accountId: string;
|
||
symbol: string;
|
||
side: 'long' | 'short';
|
||
quantity: number;
|
||
openPrice: number;
|
||
/** Margin the position holds out of the free balance. */
|
||
collateral: number;
|
||
leverage: number;
|
||
openedAt: number;
|
||
stopLosses: VestLeg[];
|
||
takeProfits: VestLeg[];
|
||
}
|
||
|
||
function parseLegs(raw: unknown): VestLeg[] {
|
||
if (!Array.isArray(raw)) return [];
|
||
return raw.flatMap((l) => {
|
||
const leg = l as Record<string, unknown>;
|
||
const trigger = num(leg?.triggerPrice);
|
||
if (typeof leg?.id !== 'string' || trigger === undefined) return [];
|
||
return [{ id: leg.id, triggerPrice: trigger, executionType: String(leg.executionType ?? ''), status: String(leg.status ?? '') }];
|
||
});
|
||
}
|
||
|
||
/** One entry of GET /v3/positions/opened; null when it isn't an open one. */
|
||
export function parsePosition(raw: unknown): VestPosition | null {
|
||
const p = raw as Record<string, unknown>;
|
||
const accountId = p?.accountId ?? p?.account_id;
|
||
if (!p || typeof p.positionId !== 'string' || typeof accountId !== 'string' || typeof p.symbol !== 'string') return null;
|
||
const quantity = Math.abs(num(p.quantity) ?? 0);
|
||
if (!(quantity > 0)) return null;
|
||
return {
|
||
positionId: p.positionId,
|
||
accountId,
|
||
symbol: p.symbol,
|
||
side: String(p.side).toLowerCase() === 'short' ? 'short' : 'long',
|
||
quantity,
|
||
openPrice: num(p.openPrice) ?? NaN,
|
||
collateral: num(p.collateral) ?? 0,
|
||
leverage: num(p.leverage) ?? 0,
|
||
openedAt: toMs(p.openDate ?? p.createdAt),
|
||
stopLosses: parseLegs(p.stopLosses),
|
||
takeProfits: parseLegs(p.takeProfits),
|
||
};
|
||
}
|
||
|
||
/** Exit legs still waiting to trigger. */
|
||
export function awaitingLegs(p: VestPosition): VestLeg[] {
|
||
return [...p.stopLosses, ...p.takeProfits].filter((l) => l.status === 'awaiting');
|
||
}
|
||
|
||
export interface VestOpenOrder {
|
||
orderId: string;
|
||
accountId: string;
|
||
positionId: string | undefined;
|
||
symbol: string;
|
||
createdAt: number;
|
||
}
|
||
|
||
/** One entry of GET /v3/positions/opened-orders (snake or camel case). */
|
||
export function parseOpenOrder(raw: unknown): VestOpenOrder | null {
|
||
const o = raw as Record<string, unknown>;
|
||
const orderId = o?.order_id ?? o?.orderId ?? o?.id;
|
||
const accountId = o?.account_id ?? o?.accountId;
|
||
if (typeof orderId !== 'string' || typeof accountId !== 'string') return null;
|
||
const positionId = o.position_id ?? o.positionId;
|
||
return {
|
||
orderId,
|
||
accountId,
|
||
positionId: typeof positionId === 'string' ? positionId : undefined,
|
||
symbol: String(o.symbol ?? ''),
|
||
createdAt: toMs(o.created_at ?? o.createdAt),
|
||
};
|
||
}
|
||
|
||
/** Unrealized P&L of a position at `mark`. */
|
||
export function unrealizedPnl(p: Pick<VestPosition, 'side' | 'quantity' | 'openPrice'>, mark: number): number {
|
||
if (!Number.isFinite(mark) || !Number.isFinite(p.openPrice)) return 0;
|
||
return (p.side === 'long' ? 1 : -1) * p.quantity * (mark - p.openPrice);
|
||
}
|
||
|
||
// ── transactions ────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* The gateway's transaction types (its app's own enum, 2026-10-26). Trading
|
||
* P&L is fees, realized P&L, funding and the adjustments that come with them;
|
||
* funds are money moved in or out; the rest only moves margin between the
|
||
* free balance and positions, and leaves the account's total alone.
|
||
*/
|
||
export const VEST_TXN = {
|
||
DEPOSIT: 1, WITHDRAW: 2, OPEN_POSITION: 3, CLOSE_POSITION: 4, CORRECTION: 5, FILL_MARGIN: 6, MARGIN_RELEASE: 7,
|
||
FEE: 8, REALIZED_PNL: 9, FUNDING: 10, LEVERAGE_RESERVE: 11, LEVERAGE_RELEASE: 12, ADD_COLLATERAL: 13,
|
||
REMOVE_COLLATERAL: 14, ORDER_MARGIN_RESERVE: 15, ORDER_MARGIN_RELEASE: 16, LIQUIDATION_DEFICIT_COVERAGE: 17,
|
||
CORPORATE_ACTION_CASH_ADJUSTMENT: 18, FUNDED_PROFIT_CREDIT: 19, AFFILIATE_PAYOUT_CREDIT: 20, COMPENSATION: 22,
|
||
BALANCE_MIGRATION: 23,
|
||
} as const;
|
||
|
||
const TRADING_TXNS = new Set<number>([
|
||
VEST_TXN.CORRECTION, VEST_TXN.FEE, VEST_TXN.REALIZED_PNL, VEST_TXN.FUNDING,
|
||
VEST_TXN.LIQUIDATION_DEFICIT_COVERAGE, VEST_TXN.CORPORATE_ACTION_CASH_ADJUSTMENT,
|
||
]);
|
||
const FUND_TXNS = new Set<number>([
|
||
VEST_TXN.DEPOSIT, VEST_TXN.WITHDRAW, VEST_TXN.FUNDED_PROFIT_CREDIT, VEST_TXN.AFFILIATE_PAYOUT_CREDIT,
|
||
VEST_TXN.COMPENSATION, VEST_TXN.BALANCE_MIGRATION,
|
||
]);
|
||
|
||
export type VestTxnKind = 'trade' | 'fund' | 'margin';
|
||
|
||
export function txnKind(type: number): VestTxnKind {
|
||
if (TRADING_TXNS.has(type)) return 'trade';
|
||
if (FUND_TXNS.has(type)) return 'fund';
|
||
return 'margin';
|
||
}
|
||
|
||
export interface VestTxn {
|
||
/** action_id, else type|time|amount — what a page overlap dedupes on. */
|
||
key: string;
|
||
type: number;
|
||
kind: VestTxnKind;
|
||
/** Signed: what it did to the account's balance. */
|
||
amount: number;
|
||
time: number;
|
||
}
|
||
|
||
/** One entry of GET /v3/transactions; null when unreadable. A withdrawal is
|
||
* always negative and a deposit positive, whatever sign the gateway used. */
|
||
export function parseTxn(raw: unknown): VestTxn | null {
|
||
const t = raw as Record<string, unknown>;
|
||
const type = num(t?.transaction_type ?? t?.transactionType);
|
||
const amount = num(t?.amount);
|
||
const time = toMs(t?.created_at ?? t?.createdAt);
|
||
if (type === undefined || amount === undefined || !Number.isFinite(time)) return null;
|
||
const signed = type === VEST_TXN.WITHDRAW ? -Math.abs(amount) : type === VEST_TXN.DEPOSIT ? Math.abs(amount) : amount;
|
||
const action = t.action_id ?? t.actionId;
|
||
return {
|
||
key: typeof action === 'string' || typeof action === 'number' ? `${action}|${type}` : `${type}|${time}|${amount}`,
|
||
type,
|
||
kind: txnKind(type),
|
||
amount: signed,
|
||
time,
|
||
};
|
||
}
|
||
|
||
/** Daily trading P&L and fund rows by Vest day, from the account's
|
||
* transactions. */
|
||
export function bookTxns(txns: VestTxn[]): { daily: Map<string, { pnl: number; fees: number }>; funds: Map<string, number> } {
|
||
const daily = new Map<string, { pnl: number; fees: number }>();
|
||
const funds = new Map<string, number>();
|
||
for (const t of txns) {
|
||
const day = vestDayOf(t.time);
|
||
if (t.kind === 'fund') {
|
||
funds.set(day, (funds.get(day) ?? 0) + t.amount);
|
||
} else if (t.kind === 'trade') {
|
||
const e = daily.get(day) ?? { pnl: 0, fees: 0 };
|
||
e.pnl += t.amount;
|
||
if (t.type === VEST_TXN.FEE) e.fees += Math.abs(t.amount);
|
||
daily.set(day, e);
|
||
}
|
||
}
|
||
return { daily, funds };
|
||
}
|
||
|
||
// ── market data ─────────────────────────────────────────────────────────────
|
||
|
||
/** Fewest hourly returns a volatility is measured from. */
|
||
export const MIN_HOURLY_RETURNS = 24;
|
||
|
||
/**
|
||
* σ of log returns per √hour from hourly klines ([openTime, o, h, l, close, …],
|
||
* any order). Only returns between consecutive hours count: an index perp
|
||
* closes for weekends and the daily maintenance hour, and a return across
|
||
* such a gap is a gap, not an hour's move. null when too few are left.
|
||
*/
|
||
export function hourlyVolatility(klines: unknown[]): number | null {
|
||
const rows = klines.flatMap((k) => {
|
||
if (!Array.isArray(k)) return [];
|
||
const t = num(k[0]);
|
||
const close = num(k[4]);
|
||
return t !== undefined && close !== undefined && close > 0 ? [{ t: toMs(t), close }] : [];
|
||
}).sort((a, b) => a.t - b.t);
|
||
const returns: number[] = [];
|
||
for (let i = 1; i < rows.length; i++) {
|
||
if (Math.abs(rows[i].t - rows[i - 1].t - 3_600_000) > 60_000) continue;
|
||
returns.push(Math.log(rows[i].close / rows[i - 1].close));
|
||
}
|
||
if (returns.length < MIN_HOURLY_RETURNS) return null;
|
||
const mean = returns.reduce((s, r) => s + r, 0) / returns.length;
|
||
const variance = returns.reduce((s, r) => s + (r - mean) ** 2, 0) / (returns.length - 1);
|
||
return Math.sqrt(variance);
|
||
}
|
||
|
||
/**
|
||
* Whether the market-hours periods (GET /v4/market-hours/{assetId}) have the
|
||
* symbol open for trading at `now`, and for how long yet. A symbol with no
|
||
* periods at all (crypto: always open) reads as open with no end. A closure
|
||
* (weekly, the daily maintenance hour) counts as closed even where the
|
||
* gateway still says OPEN: no leg should start into one.
|
||
*/
|
||
export function marketOpen(periods: unknown, now: number): { open: boolean; closesAt: number | null } {
|
||
if (!Array.isArray(periods) || periods.length === 0) return { open: true, closesAt: null };
|
||
const list = periods.flatMap((p) => {
|
||
const r = p as {
|
||
fromMs?: unknown; toMs?: unknown;
|
||
state?: { tradingStatus?: unknown; closure?: unknown; price?: { allow?: unknown } };
|
||
};
|
||
const from = num(r?.fromMs);
|
||
const to = num(r?.toMs);
|
||
if (from === undefined || to === undefined) return [];
|
||
const closure = r.state?.closure;
|
||
const open = r.state?.tradingStatus === 'OPEN' && r.state?.price?.allow !== false
|
||
&& (closure === undefined || closure === 'unspecified');
|
||
return [{ from, to, open }];
|
||
}).sort((a, b) => a.from - b.from);
|
||
const i = list.findIndex((p) => p.from <= now && now < p.to);
|
||
if (i < 0 || !list[i].open) return { open: false, closesAt: null };
|
||
// Consecutive open periods (overnight → pre-market → regular …) are one stretch.
|
||
let end = list[i].to;
|
||
for (let j = i + 1; j < list.length && list[j].open && list[j].from <= end + 1; j++) end = list[j].to;
|
||
return { open: true, closesAt: end };
|
||
}
|