src/assets/marketHours.ts
v0.3.0 · 7.8 KB
// Client-side trading-session model for tokenized US equities / indices on HIP-3 dex `xyz`.
// HL does not expose a "market closed" flag: this is a calendar model, not an API answer.
/** Symbols on gated dexes that trade around the clock (oil follows CME, almost 24/7). */
export const XYZ_ALWAYS_ON_COINS: readonly string[] = Object.freeze(['xyz:CL']);
/** A holiday calendar: dates are `YYYY-MM-DD` in America/New_York. */
export interface TradingCalendar {
/** Years the tables are complete for. Outside them holidays are NOT applied (weekends and session still are). */
readonly years: readonly number[];
/** Full closures. */
readonly fullHolidays: readonly string[];
/** Early closes at 13:00 ET. */
readonly halfDays: readonly string[];
}
/**
* NYSE calendar for 2026–2027. Update yearly: several dates float (MLK, Good Friday, Thanksgiving…) and observed
* holidays shift when the date falls on a weekend (2026-07-03, 2027-06-18, 2027-07-05, 2027-12-24).
*/
export const NYSE_CALENDAR: TradingCalendar = Object.freeze({
years: Object.freeze([2026, 2027]),
fullHolidays: Object.freeze([
'2026-01-01', '2026-01-19', '2026-02-16', '2026-04-03', '2026-05-25',
'2026-06-19', '2026-07-03', '2026-09-07', '2026-11-26', '2026-12-25',
'2027-01-01', '2027-01-18', '2027-02-15', '2027-03-26', '2027-05-31',
'2027-06-18', '2027-07-05', '2027-09-06', '2027-11-25', '2027-12-24',
]),
halfDays: Object.freeze(['2026-11-27', '2026-12-24', '2027-11-26']),
});
/** Session bounds in minutes after ET midnight: extended session 04:00–20:00, half-day close 13:00. */
export const SESSION_OPEN_MIN = 4 * 60;
export const SESSION_CLOSE_MIN = 20 * 60;
export const HALF_DAY_CLOSE_MIN = 13 * 60;
export interface MarketHoursOptions {
/** Dexes whose markets follow the calendar (case-insensitive). Default `['xyz']`; other HIP-3 dexes' hours are unknown. */
readonly gatedDexes?: readonly string[];
/** Coins on gated dexes that are never closed. Default {@link XYZ_ALWAYS_ON_COINS}. */
readonly alwaysOn?: readonly string[];
readonly calendar?: TradingCalendar;
/** Called once per (callback, year) when the calendar has no table for that year. */
readonly onUnknownYear?: (year: number) => void;
}
export type MarketSessionReason =
| 'not_gated'
| 'always_on'
| 'open'
| 'weekend'
| 'holiday'
| 'before_open'
| 'after_close'
| 'invalid_date';
export interface MarketSession {
readonly open: boolean;
readonly reason: MarketSessionReason;
/** ET calendar date `YYYY-MM-DD` (null for non-gated coins or invalid dates). */
readonly etDate: string | null;
/** Minutes after ET midnight, 0..1439. */
readonly etMinutes: number | null;
readonly halfDay: boolean;
/** False when the year is outside the calendar tables (holidays were not applied). */
readonly calendarKnown: boolean;
}
export interface EasternTimeParts {
readonly year: number;
readonly month: number;
readonly day: number;
/** `YYYY-MM-DD`. */
readonly ymd: string;
/** 0 = Sunday … 6 = Saturday. */
readonly weekday: number;
/** 0..23. */
readonly hour: number;
readonly minute: number;
}
const WEEKDAYS: Readonly<Record<string, number>> = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
let etFormatter: Intl.DateTimeFormat | null = null;
/**
* Wall-clock parts in America/New_York; EST/EDT transitions are handled by Intl, no dependencies needed.
*
* Why `hourCycle: 'h23'` and not `hour12: false`: the latter formats midnight as `24` in Node's ICU, which breaks
* "minutes after midnight" math. `hour12` must not be set at all — it overrides `hourCycle`.
*/
export function easternTimeParts(at: Date | number): EasternTimeParts | null {
const date = typeof at === 'number' ? new Date(at) : at;
if (!(date instanceof Date) || Number.isNaN(date.getTime())) return null;
etFormatter ??= new Intl.DateTimeFormat('en-US', {
timeZone: 'America/New_York',
hourCycle: 'h23',
year: 'numeric',
month: '2-digit',
day: '2-digit',
weekday: 'short',
hour: '2-digit',
minute: '2-digit',
});
const p: Record<string, string> = {};
for (const part of etFormatter.formatToParts(date)) p[part.type] = part.value;
const year = Number(p.year);
const month = Number(p.month);
const day = Number(p.day);
const weekday = p.weekday !== undefined ? WEEKDAYS[p.weekday] : undefined;
const hour = Number(p.hour) % 24; // belt and braces against ICU builds that still emit 24
const minute = Number(p.minute);
if (![year, month, day, hour, minute].every(Number.isInteger) || weekday === undefined) return null;
const ymd = `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
return { year, month, day, ymd, weekday, hour, minute };
}
const warnedYears = new WeakMap<(year: number) => void, Set<number>>();
function coinKey(coin: string): { dex: string; key: string } | null {
const i = coin.indexOf(':');
if (i <= 0) return null;
const dex = coin.slice(0, i).toLowerCase();
return { dex, key: `${dex}:${coin.slice(i + 1)}` };
}
/**
* Session state of a coin at a moment.
*
* Model: tokenized US equities and indices on `xyz` use the deployer's oracle, which freezes outside the US session;
* the perp stays tradable but fills happen at a stale price in a thin book. Treat the market as open during the
* extended session 04:00–20:00 ET (13:00 on half-days), Monday–Friday, except NYSE holidays. Always-on symbols
* (`xyz:CL`) and coins outside gated dexes (main-dex crypto) are always open.
*
* Intended use: gate only OPENING new positions. Increases/reductions/closes of existing positions must pass anyway.
* Invalid dates on gated coins return `open: false` (fail-closed for opens).
*/
export function getMarketSession(coin: string, at: Date | number = Date.now(), opts: MarketHoursOptions = {}): MarketSession {
const notGated: MarketSession = { open: true, reason: 'not_gated', etDate: null, etMinutes: null, halfDay: false, calendarKnown: true };
const ck = coinKey(coin);
const gated = (opts.gatedDexes ?? ['xyz']).map((d) => d.toLowerCase());
if (!ck || !gated.includes(ck.dex)) return notGated;
const alwaysOn = (opts.alwaysOn ?? XYZ_ALWAYS_ON_COINS).map((c) => coinKey(c)?.key ?? c);
if (alwaysOn.includes(ck.key)) return { ...notGated, reason: 'always_on' };
const et = easternTimeParts(at);
if (!et) return { open: false, reason: 'invalid_date', etDate: null, etMinutes: null, halfDay: false, calendarKnown: false };
const cal = opts.calendar ?? NYSE_CALENDAR;
const calendarKnown = cal.years.includes(et.year);
if (!calendarKnown && opts.onUnknownYear) {
let set = warnedYears.get(opts.onUnknownYear);
if (!set) warnedYears.set(opts.onUnknownYear, (set = new Set()));
if (!set.has(et.year)) {
set.add(et.year);
try {
opts.onUnknownYear(et.year);
} catch {
/* diagnostics only */
}
}
}
const etMinutes = et.hour * 60 + et.minute;
const halfDay = calendarKnown && cal.halfDays.includes(et.ymd);
const base = { etDate: et.ymd, etMinutes, halfDay, calendarKnown };
if (et.weekday === 0 || et.weekday === 6) return { ...base, open: false, reason: 'weekend' };
if (calendarKnown && cal.fullHolidays.includes(et.ymd)) return { ...base, open: false, reason: 'holiday' };
if (etMinutes < SESSION_OPEN_MIN) return { ...base, open: false, reason: 'before_open' };
const close = halfDay ? HALF_DAY_CLOSE_MIN : SESSION_CLOSE_MIN;
if (etMinutes >= close) return { ...base, open: false, reason: 'after_close' };
return { ...base, open: true, reason: 'open' };
}
/** `getMarketSession(coin, at, opts).open`. See {@link getMarketSession} for the model and intended use. */
export function isMarketOpen(coin: string, at: Date | number = Date.now(), opts: MarketHoursOptions = {}): boolean {
return getMarketSession(coin, at, opts).open;
}