Skip to content
markpaper

src/assets/marketHours.ts

v0.3.0 · 7.8 KB

Download file
// 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;
}
All files