spb/market-atlas
Public
TypeScript 96.7%
SQL 1.6%
CSS 0.8%
JavaScript 0.5%
1import type { Exchange, ExchangeStatus, HolidaySpec, MarketState, TradingSessionSpec } from "@market-atlas/market-model";2import { hmToMs, zonedParts, zonedTimeToUtc } from "@market-atlas/market-model";34/**5 * Market hours engine. Time zones, DST, weekends, holidays and early closes are evaluated6 * with Intl — nothing is hard-coded to 09:30–16:00.7 */8export class MarketCalendar {9 private exchanges = new Map<string, Exchange>();10 private holidays = new Map<string, Map<string, HolidaySpec>>(); // exchangeId -> date -> spec1112 load(exchanges: Exchange[], holidays: HolidaySpec[]) {13 this.exchanges.clear();14 this.holidays.clear();15 for (const e of exchanges) this.exchanges.set(e.id, e);16 for (const h of holidays) this.addHoliday(h);17 }1819 addHoliday(h: HolidaySpec) {20 let m = this.holidays.get(h.exchangeId);21 if (!m) {22 m = new Map();23 this.holidays.set(h.exchangeId, m);24 }25 m.set(h.date, h);26 }2728 exchange(id: string): Exchange | undefined {29 return this.exchanges.get(id);30 }3132 list(): Exchange[] {33 return [...this.exchanges.values()];34 }3536 status(exchangeId: string, now = Date.now()): ExchangeStatus {37 const ex = this.exchanges.get(exchangeId);38 if (!ex) return { exchangeId, state: "UNKNOWN", localTime: "", nextTransition: null, isHoliday: false, holidayName: null };39 const p = zonedParts(now, ex.timezone);40 const sessions = ex.sessions;41 if (sessions.continuous) {42 return { exchangeId, state: "OPEN", localTime: p.time, nextTransition: null, isHoliday: false, holidayName: null };43 }44 const holiday = this.holidays.get(exchangeId)?.get(p.date) ?? null;45 const weekdays = sessions.weekdays ?? [1, 2, 3, 4, 5];46 const tradingDay = weekdays.includes(p.weekday) && holiday?.kind !== "CLOSED";47 const state = tradingDay ? this.stateWithin(sessions, p.time, holiday) : "CLOSED";48 return {49 exchangeId,50 state,51 localTime: p.time,52 nextTransition: this.nextTransition(ex, now),53 isHoliday: !!holiday,54 holidayName: holiday?.name ?? null,55 };56 }5758 isOpen(exchangeId: string, now = Date.now()): boolean {59 return this.state(exchangeId, now) === "OPEN";60 }6162 private stateCache = new Map<string, { at: number; state: MarketState }>();6364 /** Cheap state lookup (no next-transition scan), memoized for 5 s per exchange. Hot path for list endpoints. */65 state(exchangeId: string, now = Date.now()): MarketState {66 const ex = this.exchanges.get(exchangeId);67 if (!ex) return "UNKNOWN";68 if (ex.sessions.continuous) return "OPEN";69 const c = this.stateCache.get(exchangeId);70 if (c && now - c.at < 5000) return c.state;71 const state = this.rawState(ex, now);72 this.stateCache.set(exchangeId, { at: now, state });73 return state;74 }7576 /** True when any regular session of the exchange is trading, pre or post included. */77 isTradingDay(exchangeId: string, now = Date.now()): boolean {78 const s = this.status(exchangeId, now).state;79 return s === "OPEN" || s === "PRE" || s === "POST" || s === "AUCTION";80 }8182 private stateWithin(sessions: TradingSessionSpec, time: string, holiday: HolidaySpec | null): MarketState {83 const t = hmToMs(time);84 const regular = sessions.regular.map((s) => ({ open: hmToMs(s.open), close: hmToMs(holiday?.kind === "EARLY_CLOSE" && holiday.closeTime ? holiday.closeTime : s.close) }));85 for (const s of regular) if (t >= s.open && t < s.close) return "OPEN";86 if (sessions.pre && t >= hmToMs(sessions.pre.open) && t < hmToMs(sessions.pre.close)) return "PRE";87 if (sessions.post && holiday?.kind !== "EARLY_CLOSE" && t >= hmToMs(sessions.post.open) && t < hmToMs(sessions.post.close)) return "POST";88 return "CLOSED";89 }9091 /** Scan forward (minute granularity, up to 10 days) for the next state change. */92 private nextTransition(ex: Exchange, now: number): { state: MarketState; at: number } | null {93 const current = this.rawState(ex, now);94 const step = 60_000;95 const limit = now + 10 * 86_400_000;96 // Coarse scan by 15 minutes then refine by minute.97 for (let t = now + step; t <= limit; t += 15 * step) {98 if (this.rawState(ex, t) !== current) {99 for (let u = t - 15 * step; u <= t; u += step) {100 const s = this.rawState(ex, u);101 if (s !== current) return { state: s, at: u };102 }103 }104 }105 return null;106 }107108 private rawState(ex: Exchange, at: number): MarketState {109 const p = zonedParts(at, ex.timezone);110 const holiday = this.holidays.get(ex.id)?.get(p.date) ?? null;111 const weekdays = ex.sessions.weekdays ?? [1, 2, 3, 4, 5];112 if (!weekdays.includes(p.weekday) || holiday?.kind === "CLOSED") return "CLOSED";113 return this.stateWithin(ex.sessions, p.time, holiday);114 }115116 /** Session start (UTC ms) of the trading day containing `now`, used for session high/low resets. */117 sessionStart(exchangeId: string, now = Date.now()): number | null {118 const ex = this.exchanges.get(exchangeId);119 if (!ex) return null;120 if (ex.sessions.continuous) {121 const p = zonedParts(now, "UTC");122 return zonedTimeToUtc(p.date, "UTC");123 }124 const p = zonedParts(now, ex.timezone);125 const first = ex.sessions.pre?.open ?? ex.sessions.regular[0]?.open;126 if (!first) return null;127 return zonedTimeToUtc(`${p.date}T${first}:00`, ex.timezone);128 }129130 holidaysFor(exchangeId: string): HolidaySpec[] {131 return [...(this.holidays.get(exchangeId)?.values() ?? [])].sort((a, b) => a.date.localeCompare(b.date));132 }133}134135export const calendar = new MarketCalendar();136