tickerforge 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ from tickerforge.spec_loader import load_spec
2
+ from tickerforge.ticker_generator import TickerForge, generate_ticker_for_contract
3
+ from tickerforge.ticker_parser import ParsedTicker, TickerParser, parse_ticker
4
+
5
+ __all__ = [
6
+ "TickerForge",
7
+ "TickerParser",
8
+ "ParsedTicker",
9
+ "generate_ticker_for_contract",
10
+ "parse_ticker",
11
+ "load_spec",
12
+ ]
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ from functools import lru_cache
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from tickerforge.schedule import ExchangeSchedule
8
+
9
+ try:
10
+ import exchange_calendars as xcals
11
+ except ImportError: # pragma: no cover
12
+ xcals = None # type: ignore[assignment,unused-ignore]
13
+
14
+ EXCHANGE_CALENDAR_ALIASES: dict[str, str] = {
15
+ "B3": "BVMF",
16
+ "CME": "CMES",
17
+ "EUREX": "XEUR",
18
+ "ICE": "IEPA",
19
+ }
20
+
21
+ _SCHEDULES: dict[str, ExchangeSchedule] = {}
22
+
23
+
24
+ def register_schedules(schedules: dict[str, ExchangeSchedule]) -> None:
25
+ _SCHEDULES.update(schedules)
26
+ get_calendar.cache_clear()
27
+
28
+
29
+ def _resolve_calendar_name(exchange_code: str) -> str:
30
+ if xcals is None:
31
+ raise RuntimeError(
32
+ "exchange_calendars is not installed and no spec schedule is available "
33
+ f"for exchange '{exchange_code}'"
34
+ )
35
+ code = exchange_code.upper()
36
+ candidates = [EXCHANGE_CALENDAR_ALIASES.get(code), code]
37
+
38
+ for candidate in candidates:
39
+ if not candidate:
40
+ continue
41
+ try:
42
+ xcals.get_calendar(candidate)
43
+ return candidate
44
+ except Exception:
45
+ continue
46
+
47
+ for name in xcals.get_calendar_names():
48
+ if name.upper() == code:
49
+ return name
50
+
51
+ raise ValueError(f"No calendar found for exchange '{exchange_code}'")
52
+
53
+
54
+ @lru_cache(maxsize=16)
55
+ def get_calendar(exchange_code: str):
56
+ code = exchange_code.upper()
57
+ if code in _SCHEDULES:
58
+ from tickerforge.schedule import SpecCalendar
59
+
60
+ return SpecCalendar(_SCHEDULES[code])
61
+
62
+ calendar_name = _resolve_calendar_name(exchange_code)
63
+ return xcals.get_calendar(calendar_name)
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from tickerforge.models import ContractCycle
4
+ from tickerforge.month_codes import code_to_month
5
+
6
+ BUILTIN_CYCLES: dict[str, list[str]] = {
7
+ "monthly": ["F", "G", "H", "J", "K", "M", "N", "Q", "U", "V", "X", "Z"],
8
+ "bimonthly_even": ["G", "J", "M", "Q", "V", "Z"],
9
+ "quarterly": ["H", "M", "U", "Z"],
10
+ }
11
+
12
+
13
+ def resolve_contract_months(
14
+ contract_cycle: ContractCycle | str, year: int
15
+ ) -> list[int]:
16
+ del year # The cycle defines valid months; year is kept for a stable API.
17
+
18
+ if isinstance(contract_cycle, ContractCycle):
19
+ month_codes = contract_cycle.months
20
+ elif isinstance(contract_cycle, str):
21
+ try:
22
+ month_codes = BUILTIN_CYCLES[contract_cycle]
23
+ except KeyError as exc:
24
+ raise ValueError(f"Unknown contract cycle: {contract_cycle}") from exc
25
+ else:
26
+ raise TypeError("contract_cycle must be ContractCycle or str")
27
+
28
+ return sorted(code_to_month(code) for code in month_codes)
@@ -0,0 +1,159 @@
1
+ from __future__ import annotations
2
+
3
+ from calendar import monthrange
4
+ from datetime import date
5
+
6
+ from tickerforge.models import ContractSpec, ExpirationRule
7
+
8
+ WEEKDAY_NAME_TO_NUMBER = {
9
+ "monday": 0,
10
+ "tuesday": 1,
11
+ "wednesday": 2,
12
+ "thursday": 3,
13
+ "friday": 4,
14
+ "saturday": 5,
15
+ "sunday": 6,
16
+ }
17
+
18
+
19
+ def _month_sessions(calendar, year: int, month: int) -> list[date]:
20
+ last_day = monthrange(year, month)[1]
21
+ month_start = date(year, month, 1)
22
+ month_end = date(year, month, last_day)
23
+ cal_first = calendar.first_session.date()
24
+ cal_last = calendar.last_session.date()
25
+ if month_end < cal_first or month_start > cal_last:
26
+ return []
27
+ clip_start = max(month_start, cal_first)
28
+ clip_end = min(month_end, cal_last)
29
+ if clip_start > clip_end:
30
+ return []
31
+ sessions = calendar.sessions_in_range(
32
+ clip_start.isoformat(),
33
+ clip_end.isoformat(),
34
+ )
35
+ return [session.date() for session in sessions]
36
+
37
+
38
+ def _resolve_first_business_day(calendar, year: int, month: int) -> date:
39
+ sessions = _month_sessions(calendar, year, month)
40
+ return sessions[0]
41
+
42
+
43
+ def _resolve_last_business_day(calendar, year: int, month: int) -> date:
44
+ sessions = _month_sessions(calendar, year, month)
45
+ return sessions[-1]
46
+
47
+
48
+ def _resolve_nth_business_day(calendar, year: int, month: int, n: int) -> date:
49
+ sessions = _month_sessions(calendar, year, month)
50
+ if n < 1 or n > len(sessions):
51
+ raise ValueError(f"Invalid nth business day '{n}' for {year}-{month:02d}")
52
+ return sessions[n - 1]
53
+
54
+
55
+ def _resolve_fixed_day(calendar, year: int, month: int, day: int) -> date:
56
+ last_day = monthrange(year, month)[1]
57
+ target = date(year, month, min(day, last_day))
58
+ sessions = _month_sessions(calendar, year, month)
59
+ for session_day in sessions:
60
+ if session_day >= target:
61
+ return session_day
62
+ return sessions[-1]
63
+
64
+
65
+ def _resolve_nearest_weekday_to_day(
66
+ calendar,
67
+ year: int,
68
+ month: int,
69
+ weekday_name: str,
70
+ day: int,
71
+ ) -> date:
72
+ weekday_number = WEEKDAY_NAME_TO_NUMBER[weekday_name.lower()]
73
+ sessions = _month_sessions(calendar, year, month)
74
+ weekday_sessions = [
75
+ session_day
76
+ for session_day in sessions
77
+ if session_day.weekday() == weekday_number
78
+ ]
79
+ if not weekday_sessions:
80
+ raise ValueError(
81
+ f"No sessions on weekday '{weekday_name}' for {year}-{month:02d}"
82
+ )
83
+
84
+ last_day = monthrange(year, month)[1]
85
+ target = date(year, month, min(day, last_day))
86
+ return min(weekday_sessions, key=lambda value: (abs((value - target).days), value))
87
+
88
+
89
+ def _resolve_nth_weekday_of_month(
90
+ calendar,
91
+ year: int,
92
+ month: int,
93
+ weekday_name: str,
94
+ n: int,
95
+ ) -> date:
96
+ weekday_number = WEEKDAY_NAME_TO_NUMBER[weekday_name.lower()]
97
+ sessions = _month_sessions(calendar, year, month)
98
+ weekday_sessions = [
99
+ session_day
100
+ for session_day in sessions
101
+ if session_day.weekday() == weekday_number
102
+ ]
103
+ if n < 1 or n > len(weekday_sessions):
104
+ raise ValueError(
105
+ f"Invalid nth weekday '{n}' for weekday '{weekday_name}' "
106
+ f"in {year}-{month:02d}"
107
+ )
108
+ return weekday_sessions[n - 1]
109
+
110
+
111
+ def resolve_expiration(
112
+ contract: ContractSpec,
113
+ year: int,
114
+ month: int,
115
+ expiration_rule: ExpirationRule,
116
+ calendar,
117
+ ) -> date:
118
+ # Expiration can be rule-based; kept for future product-specific logic.
119
+ del contract
120
+
121
+ rule_type = expiration_rule.type
122
+ if rule_type == "first_business_day":
123
+ return _resolve_first_business_day(calendar, year, month)
124
+ if rule_type == "last_business_day":
125
+ return _resolve_last_business_day(calendar, year, month)
126
+ if rule_type == "nth_business_day":
127
+ if expiration_rule.n is None:
128
+ raise ValueError("nth_business_day rule requires 'n'")
129
+ return _resolve_nth_business_day(calendar, year, month, expiration_rule.n)
130
+ if rule_type == "fixed_day":
131
+ if expiration_rule.day is None:
132
+ raise ValueError("fixed_day rule requires 'day'")
133
+ return _resolve_fixed_day(calendar, year, month, expiration_rule.day)
134
+ if rule_type == "nearest_weekday_to_day":
135
+ if expiration_rule.weekday is None or expiration_rule.day is None:
136
+ raise ValueError("nearest_weekday_to_day rule requires 'weekday' and 'day'")
137
+ return _resolve_nearest_weekday_to_day(
138
+ calendar,
139
+ year,
140
+ month,
141
+ expiration_rule.weekday,
142
+ expiration_rule.day,
143
+ )
144
+ if rule_type == "nth_weekday_of_month":
145
+ if expiration_rule.weekday is None or expiration_rule.n is None:
146
+ raise ValueError("nth_weekday_of_month rule requires 'weekday' and 'n'")
147
+ return _resolve_nth_weekday_of_month(
148
+ calendar,
149
+ year,
150
+ month,
151
+ expiration_rule.weekday,
152
+ expiration_rule.n,
153
+ )
154
+ if rule_type == "schedule":
155
+ raise NotImplementedError(
156
+ "schedule expiration rules need external schedule data"
157
+ )
158
+
159
+ raise ValueError(f"Unsupported expiration rule type: {rule_type}")
tickerforge/models.py ADDED
@@ -0,0 +1,181 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
6
+
7
+ if TYPE_CHECKING:
8
+ from datetime import date, datetime
9
+
10
+ from tickerforge.spec_loader import SpecRepository
11
+
12
+
13
+ class SessionSegment(BaseModel):
14
+ """One clock-time trading window; YAML uses the key as ``name`` (not repeated in the value)."""
15
+
16
+ model_config = ConfigDict(extra="forbid")
17
+
18
+ name: str
19
+ start: str
20
+ end: str
21
+
22
+
23
+ def _sessions_mapping_to_list(sess: dict[str, Any]) -> list[dict[str, Any]]:
24
+ out: list[dict[str, Any]] = []
25
+ for k, v in sess.items():
26
+ if not isinstance(v, dict):
27
+ raise ValueError(f"session '{k}' must be an object with start and end")
28
+ start, end = v.get("start"), v.get("end")
29
+ if start is None or end is None:
30
+ raise ValueError(f"session '{k}' requires start and end")
31
+ out.append({"name": str(k), "start": str(start), "end": str(end)})
32
+ return out
33
+
34
+
35
+ class Asset(BaseModel):
36
+ model_config = ConfigDict(extra="allow")
37
+
38
+ symbol: str
39
+ type: str | None = None
40
+ category: str | None = None
41
+ description: str | None = None
42
+ sessions: list[SessionSegment] = Field(default_factory=list)
43
+
44
+ @model_validator(mode="before")
45
+ @classmethod
46
+ def _sessions_map_to_segments(cls, data: Any) -> Any:
47
+ if not isinstance(data, dict):
48
+ return data
49
+ sess = data.get("sessions")
50
+ if isinstance(sess, dict):
51
+ data = {**data, "sessions": _sessions_mapping_to_list(sess)}
52
+ return data
53
+
54
+ @model_validator(mode="after")
55
+ def _validate_sessions(self) -> Asset:
56
+ if not self.sessions:
57
+ raise ValueError("Asset sessions must include at least one segment")
58
+ if self.sessions[0].name.lower() != "regular":
59
+ raise ValueError("First session segment must be named 'regular'")
60
+ return self
61
+
62
+ def is_unique_session(self) -> bool:
63
+ """True if there is exactly one trading band (no implicit pauses between segments)."""
64
+ return len(self.sessions) == 1
65
+
66
+ def default_session(self) -> SessionSegment | None:
67
+ """The sole session when ``is_unique_session``; otherwise ``None`` (multiple bands)."""
68
+ return self.sessions[0] if len(self.sessions) == 1 else None
69
+
70
+
71
+ class Exchange(BaseModel):
72
+ model_config = ConfigDict(extra="allow")
73
+
74
+ code: str
75
+ mic: str | None = None
76
+ full_name: str | None = None
77
+ country: str | None = None
78
+ timezone: str | None = None
79
+ assets: dict[str, Asset] = Field(default_factory=dict)
80
+
81
+
82
+ class ContractCycle(BaseModel):
83
+ model_config = ConfigDict(extra="allow")
84
+
85
+ name: str
86
+ description: str | None = None
87
+ months: list[str] = Field(default_factory=list)
88
+
89
+
90
+ class ExpirationRule(BaseModel):
91
+ model_config = ConfigDict(extra="allow")
92
+
93
+ name: str
94
+ type: str
95
+ description: str | None = None
96
+ weekday: str | None = None
97
+ day: int | None = None
98
+ n: int | None = None
99
+ tags: list[str] = Field(default_factory=list)
100
+
101
+
102
+ class ContractSpec(BaseModel):
103
+ model_config = ConfigDict(extra="allow")
104
+
105
+ symbol: str
106
+ exchange: str
107
+ description: str | None = None
108
+ ticker_format: str = "{symbol}{month_code}{yy}"
109
+ contract_cycle: str
110
+ expiration_rule: str
111
+ contract_multiplier: float | None = None
112
+ tick_size: float | None = None
113
+ currency: str | None = None
114
+ aliases: list[str] = Field(default_factory=list)
115
+ # Filled at load time from `exchanges/<mic>.yaml` for this symbol (not in contract YAML).
116
+ sessions: list[SessionSegment] = Field(default_factory=list)
117
+ exchange_timezone: str | None = None
118
+
119
+ @model_validator(mode="before")
120
+ @classmethod
121
+ def _sessions_map_to_segments(cls, data: Any) -> Any:
122
+ if not isinstance(data, dict):
123
+ return data
124
+ sess = data.get("sessions")
125
+ if isinstance(sess, dict):
126
+ data = {**data, "sessions": _sessions_mapping_to_list(sess)}
127
+ return data
128
+
129
+ @model_validator(mode="after")
130
+ def _validate_sessions(self) -> ContractSpec:
131
+ if self.sessions and self.sessions[0].name.lower() != "regular":
132
+ raise ValueError("First session segment must be named 'regular'")
133
+ return self
134
+
135
+ def regular_session(self) -> SessionSegment | None:
136
+ """The regular band (first segment; clock times in ``exchange_timezone``)."""
137
+ return self.sessions[0] if self.sessions else None
138
+
139
+ def is_unique_session(self) -> bool:
140
+ """True if there is exactly one trading band (no implicit pauses between segments)."""
141
+ return len(self.sessions) == 1
142
+
143
+ def default_session(self) -> SessionSegment | None:
144
+ """The sole session when there is only one band; ``None`` if zero or multiple segments."""
145
+ return self.sessions[0] if len(self.sessions) == 1 else None
146
+
147
+ def regular_session_start_end(self) -> tuple[str, str] | None:
148
+ """Start and end clock times for the regular session, e.g. ``('09:00', '18:30')``."""
149
+ reg = self.regular_session()
150
+ if not reg:
151
+ return None
152
+ return (reg.start, reg.end)
153
+
154
+ def trading_symbol_today(
155
+ self,
156
+ spec: SpecRepository | None = None,
157
+ *,
158
+ offset: int = 0,
159
+ ) -> str:
160
+ """Front-month ticker using bundled spec unless ``spec`` is passed."""
161
+ from datetime import date
162
+
163
+ from tickerforge.spec_loader import load_spec
164
+ from tickerforge.ticker_generator import generate_ticker_for_contract
165
+
166
+ repo = spec if spec is not None else load_spec()
167
+ return generate_ticker_for_contract(self, date.today(), repo, offset=offset)
168
+
169
+ def trading_symbol_for(
170
+ self,
171
+ as_of: str | date | datetime,
172
+ spec: SpecRepository | None = None,
173
+ *,
174
+ offset: int = 0,
175
+ ) -> str:
176
+ """Front-month ticker for ``as_of``; bundled spec unless ``spec`` is passed."""
177
+ from tickerforge.spec_loader import load_spec
178
+ from tickerforge.ticker_generator import generate_ticker_for_contract
179
+
180
+ repo = spec if spec is not None else load_spec()
181
+ return generate_ticker_for_contract(self, as_of, repo, offset=offset)
@@ -0,0 +1,31 @@
1
+ MONTH_TO_CODE: dict[int, str] = {
2
+ 1: "F",
3
+ 2: "G",
4
+ 3: "H",
5
+ 4: "J",
6
+ 5: "K",
7
+ 6: "M",
8
+ 7: "N",
9
+ 8: "Q",
10
+ 9: "U",
11
+ 10: "V",
12
+ 11: "X",
13
+ 12: "Z",
14
+ }
15
+
16
+ CODE_TO_MONTH: dict[str, int] = {code: month for month, code in MONTH_TO_CODE.items()}
17
+
18
+
19
+ def month_to_code(month: int) -> str:
20
+ try:
21
+ return MONTH_TO_CODE[month]
22
+ except KeyError as exc:
23
+ raise ValueError(f"Invalid month: {month}") from exc
24
+
25
+
26
+ def code_to_month(code: str) -> int:
27
+ normalized = code.upper()
28
+ try:
29
+ return CODE_TO_MONTH[normalized]
30
+ except KeyError as exc:
31
+ raise ValueError(f"Invalid month code: {code}") from exc
tickerforge/py.typed ADDED
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,205 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import date, timedelta
4
+ from pathlib import Path
5
+
6
+ import yaml
7
+ from dateutil.easter import easter
8
+
9
+ WEEKDAY_MAP = {
10
+ "monday": 0,
11
+ "tuesday": 1,
12
+ "wednesday": 2,
13
+ "thursday": 3,
14
+ "friday": 4,
15
+ }
16
+
17
+
18
+ def _nth_weekday_of_month(year: int, month: int, weekday: int, nth: int) -> date:
19
+ first = date(year, month, 1)
20
+ diff = (weekday - first.weekday()) % 7
21
+ first_occ = first + timedelta(days=diff)
22
+ return first_occ + timedelta(weeks=nth - 1)
23
+
24
+
25
+ def _last_weekday_of_month(year: int, month: int, weekday: int) -> date:
26
+ if month == 12:
27
+ last_day = date(year + 1, 1, 1) - timedelta(days=1)
28
+ else:
29
+ last_day = date(year, month + 1, 1) - timedelta(days=1)
30
+ diff = (last_day.weekday() - weekday) % 7
31
+ return last_day - timedelta(days=diff)
32
+
33
+
34
+ def _rule_applies(rule: dict, year: int) -> bool:
35
+ if "from_year" in rule and year < rule["from_year"]:
36
+ return False
37
+ if "to_year" in rule and year > rule["to_year"]:
38
+ return False
39
+ return True
40
+
41
+
42
+ class ExchangeSchedule:
43
+ def __init__(self, data: dict) -> None:
44
+ self.exchange: str = data["exchange"]
45
+ self.timezone: str = data["timezone"]
46
+ self._holidays = data.get("holidays", {})
47
+ self._early_closes = data.get("early_closes", {})
48
+ self._holiday_cache: dict[int, set[date]] = {}
49
+ self._early_close_cache: dict[int, dict[date, str]] = {}
50
+
51
+ def holidays_for_year(self, year: int) -> set[date]:
52
+ if year in self._holiday_cache:
53
+ return self._holiday_cache[year]
54
+
55
+ holidays: set[date] = set()
56
+ easter_sunday = easter(year)
57
+
58
+ for rule in self._holidays.get("fixed", []):
59
+ if not _rule_applies(rule, year):
60
+ continue
61
+ holidays.add(date(year, rule["month"], rule["day"]))
62
+
63
+ for rule in self._holidays.get("easter_offset", []):
64
+ if not _rule_applies(rule, year):
65
+ continue
66
+ holidays.add(easter_sunday + timedelta(days=rule["offset"]))
67
+
68
+ for rule in self._holidays.get("nth_weekday", []):
69
+ if not _rule_applies(rule, year):
70
+ continue
71
+ wd = WEEKDAY_MAP[rule["weekday"]]
72
+ holidays.add(_nth_weekday_of_month(year, rule["month"], wd, rule["nth"]))
73
+
74
+ for rule in self._holidays.get("last_weekday", []):
75
+ if not _rule_applies(rule, year):
76
+ continue
77
+ wd = WEEKDAY_MAP[rule["weekday"]]
78
+ holidays.add(_last_weekday_of_month(year, rule["month"], wd))
79
+
80
+ for rule in self._holidays.get("overrides", []):
81
+ d = date.fromisoformat(rule["date"])
82
+ if d.year != year:
83
+ continue
84
+ if rule["action"] == "add":
85
+ holidays.add(d)
86
+ elif rule["action"] == "remove":
87
+ holidays.discard(d)
88
+
89
+ holidays = {d for d in holidays if d.weekday() < 5}
90
+ self._holiday_cache[year] = holidays
91
+ return holidays
92
+
93
+ def early_closes_for_year(self, year: int) -> dict[date, str]:
94
+ if year in self._early_close_cache:
95
+ return self._early_close_cache[year]
96
+
97
+ result: dict[date, str] = {}
98
+ easter_sunday = easter(year)
99
+
100
+ for rule in self._early_closes.get("fixed", []):
101
+ if not _rule_applies(rule, year):
102
+ continue
103
+ d = date(year, rule["month"], rule["day"])
104
+ if d.weekday() < 5:
105
+ result[d] = rule["open"]
106
+
107
+ for rule in self._early_closes.get("easter_offset", []):
108
+ if not _rule_applies(rule, year):
109
+ continue
110
+ d = easter_sunday + timedelta(days=rule["offset"])
111
+ if d.weekday() < 5:
112
+ result[d] = rule["open"]
113
+
114
+ self._early_close_cache[year] = result
115
+ return result
116
+
117
+ def is_early_close(self, d: date) -> bool:
118
+ return d in self.early_closes_for_year(d.year)
119
+
120
+ def early_close_time(self, d: date) -> str | None:
121
+ return self.early_closes_for_year(d.year).get(d)
122
+
123
+ def is_session(self, d: date) -> bool:
124
+ if d.weekday() >= 5:
125
+ return False
126
+ return d not in self.holidays_for_year(d.year)
127
+
128
+ def sessions_in_range(self, start: date, end: date) -> list[date]:
129
+ result: list[date] = []
130
+ current = start
131
+ one_day = timedelta(days=1)
132
+ while current <= end:
133
+ if self.is_session(current):
134
+ result.append(current)
135
+ current += one_day
136
+ return result
137
+
138
+
139
+ class SpecCalendar:
140
+ """Wraps ExchangeSchedule to expose the interface expected by expiration_rules.py."""
141
+
142
+ def __init__(self, schedule: ExchangeSchedule) -> None:
143
+ self._schedule = schedule
144
+ first = date(1990, 1, 1)
145
+ while not schedule.is_session(first):
146
+ first += timedelta(days=1)
147
+ self._first_session = first
148
+
149
+ last = date(2035, 12, 31)
150
+ while not schedule.is_session(last):
151
+ last -= timedelta(days=1)
152
+ self._last_session = last
153
+
154
+ @property
155
+ def first_session(self) -> _DateWrapper:
156
+ return _DateWrapper(self._first_session)
157
+
158
+ @property
159
+ def last_session(self) -> _DateWrapper:
160
+ return _DateWrapper(self._last_session)
161
+
162
+ def sessions_in_range(
163
+ self, start: str | date, end: str | date
164
+ ) -> list[_DateWrapper]:
165
+ s = date.fromisoformat(str(start)) if isinstance(start, str) else start
166
+ e = date.fromisoformat(str(end)) if isinstance(end, str) else end
167
+ return [_DateWrapper(d) for d in self._schedule.sessions_in_range(s, e)]
168
+
169
+ def is_early_close(self, d: date) -> bool:
170
+ return self._schedule.is_early_close(d)
171
+
172
+ def early_close_time(self, d: date) -> str | None:
173
+ return self._schedule.early_close_time(d)
174
+
175
+
176
+ class _DateWrapper:
177
+ """Mimics the pandas.Timestamp interface that exchange_calendars returns."""
178
+
179
+ def __init__(self, d: date) -> None:
180
+ self._date = d
181
+
182
+ def date(self) -> date:
183
+ return self._date
184
+
185
+ def __repr__(self) -> str:
186
+ return f"_DateWrapper({self._date})"
187
+
188
+
189
+ def load_schedule(path: Path) -> ExchangeSchedule:
190
+ with path.open("r", encoding="utf-8") as handle:
191
+ data = yaml.safe_load(handle)
192
+ if not isinstance(data, dict):
193
+ raise ValueError(f"Expected YAML mapping in {path}")
194
+ return ExchangeSchedule(data)
195
+
196
+
197
+ def load_schedules(spec_root: Path) -> dict[str, ExchangeSchedule]:
198
+ schedules_dir = spec_root / "schedules"
199
+ result: dict[str, ExchangeSchedule] = {}
200
+ if not schedules_dir.is_dir():
201
+ return result
202
+ for yaml_path in sorted(schedules_dir.glob("*.yaml")):
203
+ schedule = load_schedule(yaml_path)
204
+ result[schedule.exchange.upper()] = schedule
205
+ return result