main
py 104 lines 4.45 KB
Raw
1 from __future__ import annotations
2
3 from dataclasses import dataclass
4 from datetime import date, datetime, time, timezone, timedelta
5 from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
6
7
8 @dataclass(frozen=True)
9 class MarketTradingSchedule:
10 market_code: str
11 mic: str | None
12 country_code: str | None
13 timezone: str
14 trading_day: int
15 regular_open_time: time
16 regular_close_time: time
17 enabled: bool = True
18
19
20 @dataclass(frozen=True)
21 class MarketCalendarException:
22 market_code: str
23 trading_date: date
24 exception_type: str
25 open_time: time | None = None
26 close_time: time | None = None
27 reason: str | None = None
28
29
30 def market_session_status(market: str | None, schedules: list[MarketTradingSchedule],
31 exceptions: list[MarketCalendarException], now_utc: datetime) -> str:
32 """Data-driven, DST-safe regular-session state. Unknown configuration fails closed."""
33 key = (market or "").upper()
34 applicable = [s for s in schedules if s.enabled and (s.market_code.upper() == key or (s.mic or "").upper() == key)]
35 if not applicable:
36 return "UNKNOWN"
37 try:
38 local = now_utc.astimezone(ZoneInfo(applicable[0].timezone))
39 except (ValueError, ZoneInfoNotFoundError):
40 return "UNKNOWN"
41 exception = next((e for e in exceptions if e.market_code.upper() == applicable[0].market_code.upper() and e.trading_date == local.date()), None)
42 if exception and exception.exception_type == "CLOSED":
43 return "HOLIDAY"
44 schedule = next((s for s in applicable if s.trading_day == local.weekday()), None)
45 if not schedule and not (exception and exception.exception_type == "SPECIAL_SESSION"):
46 return "CLOSED"
47 open_time = schedule.regular_open_time if schedule else None
48 close_time = schedule.regular_close_time if schedule else None
49 if exception:
50 if exception.exception_type in {"LATE_OPEN", "SPECIAL_SESSION"}:
51 open_time = exception.open_time
52 if exception.exception_type in {"EARLY_CLOSE", "SPECIAL_SESSION"}:
53 close_time = exception.close_time
54 if open_time is None or close_time is None:
55 return "UNKNOWN"
56 return "OPEN" if open_time <= local.timetz().replace(tzinfo=None) < close_time else "CLOSED"
57
58
59 def class_due(last_at: datetime | None, ttl_seconds: int, now_utc: datetime) -> bool:
60 return last_at is None or (now_utc - last_at).total_seconds() >= ttl_seconds
61
62
63 def price_sync_eligible(market_status: str, last_price_at: datetime | None, price_ttl_seconds: int,
64 now_utc: datetime) -> bool:
65 return market_status == "OPEN" and class_due(last_price_at, price_ttl_seconds, now_utc)
66
67
68 def _session_bounds(market, schedules, exceptions, day):
69 matches = [s for s in schedules if s.enabled and market.upper() in {s.market_code.upper(), (s.mic or "").upper()}]
70 if not matches:
71 return None
72 first = matches[0]
73 exception = next((e for e in exceptions if e.market_code.upper() == first.market_code.upper() and e.trading_date == day), None)
74 if exception and exception.exception_type == "CLOSED":
75 return None
76 schedule = next((s for s in matches if s.trading_day == day.weekday()), None)
77 if schedule is None and not (exception and exception.exception_type == "SPECIAL_SESSION"):
78 return None
79 opening = exception.open_time if exception and exception.open_time else schedule.regular_open_time if schedule else None
80 closing = exception.close_time if exception and exception.close_time else schedule.regular_close_time if schedule else None
81 if opening is None or closing is None:
82 return None
83 try:
84 zone = ZoneInfo(first.timezone)
85 except (ValueError, ZoneInfoNotFoundError):
86 return None
87 return (datetime.combine(day, opening, zone).astimezone(timezone.utc), datetime.combine(day, closing, zone).astimezone(timezone.utc))
88
89
90 def latest_completed_session(market, schedules, exceptions, at):
91 # Bounded lookback and unknown-calendar fail closed; holidays come from DB.
92 for offset in range(15):
93 bounds = _session_bounds(market, schedules, exceptions, at.date() - timedelta(days=offset))
94 if bounds and bounds[1] <= at:
95 return bounds[1]
96 return None
97
98
99 def next_session_open(market, schedules, exceptions, after):
100 for offset in range(15):
101 bounds = _session_bounds(market, schedules, exceptions, after.date() + timedelta(days=offset))
102 if bounds and bounds[0] > after:
103 return bounds[0]
104 return None