import type { Exchange, ExchangeStatus, HolidaySpec, MarketState, TradingSessionSpec } from "@market-atlas/market-model"; import { hmToMs, zonedParts, zonedTimeToUtc } from "@market-atlas/market-model"; /** * Market hours engine. Time zones, DST, weekends, holidays and early closes are evaluated * with Intl — nothing is hard-coded to 09:30–16:00. */ export class MarketCalendar { private exchanges = new Map(); private holidays = new Map>(); // exchangeId -> date -> spec load(exchanges: Exchange[], holidays: HolidaySpec[]) { this.exchanges.clear(); this.holidays.clear(); for (const e of exchanges) this.exchanges.set(e.id, e); for (const h of holidays) this.addHoliday(h); } addHoliday(h: HolidaySpec) { let m = this.holidays.get(h.exchangeId); if (!m) { m = new Map(); this.holidays.set(h.exchangeId, m); } m.set(h.date, h); } exchange(id: string): Exchange | undefined { return this.exchanges.get(id); } list(): Exchange[] { return [...this.exchanges.values()]; } status(exchangeId: string, now = Date.now()): ExchangeStatus { const ex = this.exchanges.get(exchangeId); if (!ex) return { exchangeId, state: "UNKNOWN", localTime: "", nextTransition: null, isHoliday: false, holidayName: null }; const p = zonedParts(now, ex.timezone); const sessions = ex.sessions; if (sessions.continuous) { return { exchangeId, state: "OPEN", localTime: p.time, nextTransition: null, isHoliday: false, holidayName: null }; } const holiday = this.holidays.get(exchangeId)?.get(p.date) ?? null; const weekdays = sessions.weekdays ?? [1, 2, 3, 4, 5]; const tradingDay = weekdays.includes(p.weekday) && holiday?.kind !== "CLOSED"; const state = tradingDay ? this.stateWithin(sessions, p.time, holiday) : "CLOSED"; return { exchangeId, state, localTime: p.time, nextTransition: this.nextTransition(ex, now), isHoliday: !!holiday, holidayName: holiday?.name ?? null, }; } isOpen(exchangeId: string, now = Date.now()): boolean { return this.state(exchangeId, now) === "OPEN"; } private stateCache = new Map(); /** Cheap state lookup (no next-transition scan), memoized for 5 s per exchange. Hot path for list endpoints. */ state(exchangeId: string, now = Date.now()): MarketState { const ex = this.exchanges.get(exchangeId); if (!ex) return "UNKNOWN"; if (ex.sessions.continuous) return "OPEN"; const c = this.stateCache.get(exchangeId); if (c && now - c.at < 5000) return c.state; const state = this.rawState(ex, now); this.stateCache.set(exchangeId, { at: now, state }); return state; } /** True when any regular session of the exchange is trading, pre or post included. */ isTradingDay(exchangeId: string, now = Date.now()): boolean { const s = this.status(exchangeId, now).state; return s === "OPEN" || s === "PRE" || s === "POST" || s === "AUCTION"; } private stateWithin(sessions: TradingSessionSpec, time: string, holiday: HolidaySpec | null): MarketState { const t = hmToMs(time); const regular = sessions.regular.map((s) => ({ open: hmToMs(s.open), close: hmToMs(holiday?.kind === "EARLY_CLOSE" && holiday.closeTime ? holiday.closeTime : s.close) })); for (const s of regular) if (t >= s.open && t < s.close) return "OPEN"; if (sessions.pre && t >= hmToMs(sessions.pre.open) && t < hmToMs(sessions.pre.close)) return "PRE"; if (sessions.post && holiday?.kind !== "EARLY_CLOSE" && t >= hmToMs(sessions.post.open) && t < hmToMs(sessions.post.close)) return "POST"; return "CLOSED"; } /** Scan forward (minute granularity, up to 10 days) for the next state change. */ private nextTransition(ex: Exchange, now: number): { state: MarketState; at: number } | null { const current = this.rawState(ex, now); const step = 60_000; const limit = now + 10 * 86_400_000; // Coarse scan by 15 minutes then refine by minute. for (let t = now + step; t <= limit; t += 15 * step) { if (this.rawState(ex, t) !== current) { for (let u = t - 15 * step; u <= t; u += step) { const s = this.rawState(ex, u); if (s !== current) return { state: s, at: u }; } } } return null; } private rawState(ex: Exchange, at: number): MarketState { const p = zonedParts(at, ex.timezone); const holiday = this.holidays.get(ex.id)?.get(p.date) ?? null; const weekdays = ex.sessions.weekdays ?? [1, 2, 3, 4, 5]; if (!weekdays.includes(p.weekday) || holiday?.kind === "CLOSED") return "CLOSED"; return this.stateWithin(ex.sessions, p.time, holiday); } /** Session start (UTC ms) of the trading day containing `now`, used for session high/low resets. */ sessionStart(exchangeId: string, now = Date.now()): number | null { const ex = this.exchanges.get(exchangeId); if (!ex) return null; if (ex.sessions.continuous) { const p = zonedParts(now, "UTC"); return zonedTimeToUtc(p.date, "UTC"); } const p = zonedParts(now, ex.timezone); const first = ex.sessions.pre?.open ?? ex.sessions.regular[0]?.open; if (!first) return null; return zonedTimeToUtc(`${p.date}T${first}:00`, ex.timezone); } holidaysFor(exchangeId: string): HolidaySpec[] { return [...(this.holidays.get(exchangeId)?.values() ?? [])].sort((a, b) => a.date.localeCompare(b.date)); } } export const calendar = new MarketCalendar();