SPB Git forge

spb/market-atlas

Public
12commits 1branches 0releases
1.1 MBsize
maindefault branch
10 days agolast push
TypeScript 96.7% SQL 1.6% CSS 0.8% JavaScript 0.5%
5.5 KB · 136 lines typescript
Raw Blame History
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