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.
- tickerforge/__init__.py +12 -0
- tickerforge/calendars.py +63 -0
- tickerforge/contract_cycle.py +28 -0
- tickerforge/expiration_rules.py +159 -0
- tickerforge/models.py +181 -0
- tickerforge/month_codes.py +31 -0
- tickerforge/py.typed +1 -0
- tickerforge/schedule.py +205 -0
- tickerforge/spec_loader.py +175 -0
- tickerforge/ticker_generator.py +95 -0
- tickerforge/ticker_parser.py +255 -0
- tickerforge-0.1.3.dist-info/METADATA +181 -0
- tickerforge-0.1.3.dist-info/RECORD +15 -0
- tickerforge-0.1.3.dist-info/WHEEL +4 -0
- tickerforge-0.1.3.dist-info/licenses/LICENSE +21 -0
tickerforge/__init__.py
ADDED
|
@@ -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
|
+
]
|
tickerforge/calendars.py
ADDED
|
@@ -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
|
+
|
tickerforge/schedule.py
ADDED
|
@@ -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
|