mxm-refdata 0.3.0__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.
- mxm/refdata/__init__.py +1 -0
- mxm/refdata/api/__init__.py +1 -0
- mxm/refdata/api/ref_data_api.py +607 -0
- mxm/refdata/cli.py +218 -0
- mxm/refdata/data/first_day_of_interest_rule.json +88 -0
- mxm/refdata/data/futures_products.csv +6 -0
- mxm/refdata/data/last_trading_rule.json +34 -0
- mxm/refdata/database/__init__.py +1 -0
- mxm/refdata/database/sql_session_manager.py +135 -0
- mxm/refdata/mappings/__init__.py +31 -0
- mxm/refdata/mappings/futures_contract_vs_orm.py +51 -0
- mxm/refdata/mappings/futures_product_vs_orm.py +56 -0
- mxm/refdata/mappings/period_cycles_vs_orm.py +102 -0
- mxm/refdata/mappings/period_vs_orm.py +40 -0
- mxm/refdata/models/__init__.py +31 -0
- mxm/refdata/models/contracts/__init__.py +0 -0
- mxm/refdata/models/contracts/futures_contract.py +22 -0
- mxm/refdata/models/currencies.py +24 -0
- mxm/refdata/models/months.py +72 -0
- mxm/refdata/models/orm/__init__.py +16 -0
- mxm/refdata/models/orm/base.py +3 -0
- mxm/refdata/models/orm/futures_contracts.py +53 -0
- mxm/refdata/models/orm/futures_products.py +61 -0
- mxm/refdata/models/orm/period_cycles.py +84 -0
- mxm/refdata/models/orm/periods.py +30 -0
- mxm/refdata/models/period_cycles.py +73 -0
- mxm/refdata/models/periods.py +64 -0
- mxm/refdata/models/products/__init__.py +1 -0
- mxm/refdata/models/products/futures_product.py +38 -0
- mxm/refdata/models/products/settlement.py +10 -0
- mxm/refdata/models/reference_events.py +9 -0
- mxm/refdata/models/units.py +28 -0
- mxm/refdata/models/weekdays.py +66 -0
- mxm/refdata/parsing/__init__.py +1 -0
- mxm/refdata/parsing/futures_products_from_csv.py +76 -0
- mxm/refdata/py.typed +0 -0
- mxm/refdata/scripts/__init__.py +1 -0
- mxm/refdata/scripts/db_utils.py +61 -0
- mxm/refdata/scripts/manage_static_ref_data.py +73 -0
- mxm/refdata/services/__init__.py +0 -0
- mxm/refdata/services/bootstrap.py +84 -0
- mxm/refdata/services/futures_contract_factory.py +127 -0
- mxm/refdata/services/futures_product_factory.py +132 -0
- mxm/refdata/services/period_factory.py +315 -0
- mxm/refdata/services/ref_data_service.py +322 -0
- mxm/refdata/services/smokecheck.py +326 -0
- mxm/refdata/trading_calendars/__init__.py +0 -0
- mxm/refdata/trading_calendars/first_day_of_interest.py +74 -0
- mxm/refdata/trading_calendars/last_trading_day.py +117 -0
- mxm/refdata/trading_calendars/nth_business_day.py +52 -0
- mxm/refdata/trading_calendars/nth_calendar_day_of_period.py +43 -0
- mxm/refdata/trading_calendars/nth_weekday_of_period.py +50 -0
- mxm/refdata/trading_calendars/trading_calendar.py +186 -0
- mxm/refdata/utils/__init__.py +1 -0
- mxm/refdata/utils/cache_manager.py +33 -0
- mxm/refdata/utils/config.py +32 -0
- mxm/refdata/utils/period_types_codec.py +16 -0
- mxm/refdata/utils/regex_patterns.py +18 -0
- mxm/refdata/utils/resources.py +29 -0
- mxm_refdata-0.3.0.dist-info/METADATA +228 -0
- mxm_refdata-0.3.0.dist-info/RECORD +64 -0
- mxm_refdata-0.3.0.dist-info/WHEEL +4 -0
- mxm_refdata-0.3.0.dist-info/entry_points.txt +3 -0
- mxm_refdata-0.3.0.dist-info/licenses/LICENSE +21 -0
mxm/refdata/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Code for the core reference data service package."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Single access api for objects in the refData package."""
|
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from datetime import date
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from mxm.refdata.database.sql_session_manager import SQLSessionManager
|
|
6
|
+
from mxm.refdata.mappings import (
|
|
7
|
+
futures_contract_from_orm,
|
|
8
|
+
futures_product_from_orm,
|
|
9
|
+
period_cycle_from_orm,
|
|
10
|
+
period_cycle_membership_from_orm,
|
|
11
|
+
period_from_orm,
|
|
12
|
+
)
|
|
13
|
+
from mxm.refdata.models.contracts.futures_contract import FuturesContract
|
|
14
|
+
from mxm.refdata.models.orm.futures_contracts import FuturesContractORM
|
|
15
|
+
from mxm.refdata.models.orm.futures_products import FuturesProductORM
|
|
16
|
+
from mxm.refdata.models.orm.period_cycles import (
|
|
17
|
+
PeriodCycleMembershipORM,
|
|
18
|
+
PeriodCycleORM,
|
|
19
|
+
)
|
|
20
|
+
from mxm.refdata.models.orm.periods import PeriodORM
|
|
21
|
+
from mxm.refdata.models.period_cycles import PeriodCycle, PeriodCycleMembership
|
|
22
|
+
from mxm.refdata.models.periods import Period, PeriodType
|
|
23
|
+
from mxm.refdata.models.products.futures_product import FuturesProduct
|
|
24
|
+
from mxm.refdata.services.bootstrap import ensure_refdata_ready
|
|
25
|
+
from mxm.refdata.utils.cache_manager import CacheManager
|
|
26
|
+
from mxm.refdata.utils.config import load_config
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RefDataLookupError(KeyError):
|
|
30
|
+
"""
|
|
31
|
+
Raised when a required reference data object cannot be found.
|
|
32
|
+
|
|
33
|
+
This error signals a violation of an invariant: the caller assumed that
|
|
34
|
+
the requested object exists in the curated reference dataset, but it
|
|
35
|
+
was not present.
|
|
36
|
+
|
|
37
|
+
Typical causes:
|
|
38
|
+
- contract_id not part of the prepared reference data universe
|
|
39
|
+
- reference data not built for the requested product/time range
|
|
40
|
+
- upstream data preparation incomplete or inconsistent
|
|
41
|
+
|
|
42
|
+
This is not a transient error and should not be silently ignored.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RefDataAPI:
|
|
47
|
+
"""
|
|
48
|
+
API interface to access reference data stored in the database.
|
|
49
|
+
Provides optimized querying and caching.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, session_manager: SQLSessionManager | None = None):
|
|
53
|
+
"""
|
|
54
|
+
Initialize the RefDataAPI with a session manager.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
session_manager (SQLSessionManager): Manages database connections.
|
|
58
|
+
"""
|
|
59
|
+
self.cfg = load_config()
|
|
60
|
+
self.session_manager = session_manager or SQLSessionManager(
|
|
61
|
+
db_url=self.cfg.SQL_DB_URL
|
|
62
|
+
)
|
|
63
|
+
self.cache: CacheManager[Any] = CacheManager(
|
|
64
|
+
maxsize=10000
|
|
65
|
+
) # Add caching for faster access
|
|
66
|
+
self.logger = logging.getLogger(__name__)
|
|
67
|
+
|
|
68
|
+
def maybe_get_contract_by_id(self, contract_id: str) -> FuturesContract | None:
|
|
69
|
+
"""
|
|
70
|
+
Retrieve a FuturesContract by its contract_id, if available.
|
|
71
|
+
|
|
72
|
+
This method performs a best-effort lookup against the curated reference
|
|
73
|
+
dataset. It is intended for boundary layers where partial coverage is
|
|
74
|
+
expected and must be handled explicitly.
|
|
75
|
+
|
|
76
|
+
The result may be None if the contract_id is not present in the current
|
|
77
|
+
reference dataset.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
contract_id:
|
|
81
|
+
Canonical contract identifier.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
FuturesContract if found, otherwise None.
|
|
85
|
+
|
|
86
|
+
Usage:
|
|
87
|
+
Use this method when exploring or validating coverage, e.g.:
|
|
88
|
+
- checking whether a product universe has been prepared
|
|
89
|
+
- probing availability across time ranges
|
|
90
|
+
"""
|
|
91
|
+
ensure_refdata_ready(self.session_manager, self.cfg)
|
|
92
|
+
|
|
93
|
+
cache_key = f"contract:{contract_id}"
|
|
94
|
+
if cached := self.cache.get(cache_key):
|
|
95
|
+
return cached
|
|
96
|
+
|
|
97
|
+
with self.session_manager.db_session_scope() as session:
|
|
98
|
+
contract = (
|
|
99
|
+
session.query(FuturesContractORM)
|
|
100
|
+
.filter_by(contract_id=contract_id)
|
|
101
|
+
.first()
|
|
102
|
+
)
|
|
103
|
+
if contract is not None:
|
|
104
|
+
result = futures_contract_from_orm(contract)
|
|
105
|
+
self.cache.set(cache_key, result)
|
|
106
|
+
return result
|
|
107
|
+
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
def get_contract_by_id(self, contract_id: str) -> FuturesContract:
|
|
111
|
+
"""
|
|
112
|
+
Retrieve a FuturesContract by its contract_id, enforcing existence.
|
|
113
|
+
|
|
114
|
+
This method assumes that the requested contract_id is valid and present
|
|
115
|
+
in the curated reference dataset. If the contract cannot be found, a
|
|
116
|
+
RefDataLookupError is raised.
|
|
117
|
+
|
|
118
|
+
This is the preferred method for use within typed execution code where
|
|
119
|
+
contract identities are already validated or constructed by the system.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
contract_id:
|
|
123
|
+
Canonical contract identifier.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
FuturesContract (guaranteed to be present).
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
RefDataLookupError:
|
|
130
|
+
If the contract_id is not found in the reference dataset.
|
|
131
|
+
|
|
132
|
+
Usage:
|
|
133
|
+
Use this method in invariant-bearing code paths, e.g.:
|
|
134
|
+
- execution, pricing, and PnL logic
|
|
135
|
+
- synthetic asset construction
|
|
136
|
+
- any context where missing contracts indicate a system error
|
|
137
|
+
"""
|
|
138
|
+
contract = self.maybe_get_contract_by_id(contract_id)
|
|
139
|
+
|
|
140
|
+
if contract is None:
|
|
141
|
+
raise RefDataLookupError(
|
|
142
|
+
f"FuturesContract not found for contract_id='{contract_id}'. "
|
|
143
|
+
"This indicates missing or incomplete reference data."
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
return contract
|
|
147
|
+
|
|
148
|
+
def get_contracts_by_id(self, contract_ids: list[str]) -> list[FuturesContract]:
|
|
149
|
+
"""
|
|
150
|
+
Retrieve multiple FuturesContracts by their contract_id values.
|
|
151
|
+
|
|
152
|
+
Semantics:
|
|
153
|
+
- Returns only contracts that are found (missing IDs are ignored).
|
|
154
|
+
- Preserves the input order of `contract_ids`.
|
|
155
|
+
- Uses caching to avoid redundant DB queries.
|
|
156
|
+
"""
|
|
157
|
+
ensure_refdata_ready(self.session_manager, self.cfg)
|
|
158
|
+
|
|
159
|
+
if not contract_ids:
|
|
160
|
+
return []
|
|
161
|
+
|
|
162
|
+
# Preserve input order while deduplicating for the query
|
|
163
|
+
ordered_ids = list(contract_ids)
|
|
164
|
+
unique_ids = list(dict.fromkeys(contract_ids))
|
|
165
|
+
|
|
166
|
+
cache_key = f"contracts:ids:{','.join(unique_ids)}"
|
|
167
|
+
if cached := self.cache.get(cache_key):
|
|
168
|
+
# Preserve caller order
|
|
169
|
+
by_id = {c.contract_id: c for c in cached}
|
|
170
|
+
return [by_id[cid] for cid in ordered_ids if cid in by_id]
|
|
171
|
+
|
|
172
|
+
with self.session_manager.db_session_scope() as session:
|
|
173
|
+
contracts = (
|
|
174
|
+
session.query(FuturesContractORM)
|
|
175
|
+
.filter(FuturesContractORM.contract_id.in_(unique_ids))
|
|
176
|
+
.all()
|
|
177
|
+
)
|
|
178
|
+
result = [futures_contract_from_orm(contract) for contract in contracts]
|
|
179
|
+
|
|
180
|
+
# Cache the unordered result set (content-stable)
|
|
181
|
+
self.cache.set(cache_key, result)
|
|
182
|
+
|
|
183
|
+
# Return in the caller-specified order
|
|
184
|
+
by_id = {c.contract_id: c for c in result}
|
|
185
|
+
return [by_id[cid] for cid in ordered_ids if cid in by_id]
|
|
186
|
+
|
|
187
|
+
def get_active_contracts(
|
|
188
|
+
self,
|
|
189
|
+
as_of_date: date,
|
|
190
|
+
*,
|
|
191
|
+
product_id: str | None = None,
|
|
192
|
+
product_ids: list[str] | None = None,
|
|
193
|
+
) -> list[FuturesContract]:
|
|
194
|
+
"""
|
|
195
|
+
Retrieve contracts that are "active" (in MXM sense) on a given date, defined as:
|
|
196
|
+
|
|
197
|
+
FuturesContract.first_day_of_interest <= as_of_date <= FuturesContract.last_trading_day
|
|
198
|
+
|
|
199
|
+
Optionally restrict scope by a single product_id or a list of product_ids.
|
|
200
|
+
"""
|
|
201
|
+
ensure_refdata_ready(self.session_manager, self.cfg)
|
|
202
|
+
|
|
203
|
+
if product_id is not None and product_ids is not None:
|
|
204
|
+
raise ValueError("Provide only one of product_id or product_ids, not both.")
|
|
205
|
+
|
|
206
|
+
pid_part = (
|
|
207
|
+
f"pid:{product_id}"
|
|
208
|
+
if product_id is not None
|
|
209
|
+
else (
|
|
210
|
+
f"pids:{','.join(sorted(product_ids))}"
|
|
211
|
+
if product_ids is not None
|
|
212
|
+
else "pid:ALL"
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
cache_key = f"active_contracts:{as_of_date.isoformat()}:{pid_part}"
|
|
216
|
+
if cached := self.cache.get(cache_key):
|
|
217
|
+
return cached
|
|
218
|
+
|
|
219
|
+
with self.session_manager.db_session_scope() as session:
|
|
220
|
+
q = session.query(FuturesContractORM).filter(
|
|
221
|
+
FuturesContractORM.first_day_of_interest <= as_of_date,
|
|
222
|
+
FuturesContractORM.last_trading_day >= as_of_date,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
if product_id is not None:
|
|
226
|
+
q = q.filter(FuturesContractORM.product_id == product_id)
|
|
227
|
+
elif product_ids is not None:
|
|
228
|
+
if len(product_ids) == 0:
|
|
229
|
+
return []
|
|
230
|
+
q = q.filter(FuturesContractORM.product_id.in_(product_ids))
|
|
231
|
+
|
|
232
|
+
contracts = q.order_by(
|
|
233
|
+
FuturesContractORM.product_id.asc(),
|
|
234
|
+
FuturesContractORM.last_trading_day.asc(),
|
|
235
|
+
FuturesContractORM.contract_id.asc(),
|
|
236
|
+
).all()
|
|
237
|
+
|
|
238
|
+
result = [futures_contract_from_orm(contract) for contract in contracts]
|
|
239
|
+
|
|
240
|
+
self.cache.set(cache_key, result)
|
|
241
|
+
return result
|
|
242
|
+
|
|
243
|
+
def get_all_products(self) -> list[FuturesProduct]:
|
|
244
|
+
"""Retrieve all futures products, with caching."""
|
|
245
|
+
|
|
246
|
+
ensure_refdata_ready(self.session_manager, self.cfg)
|
|
247
|
+
cache_key = "all_products"
|
|
248
|
+
if cached := self.cache.get(cache_key):
|
|
249
|
+
return cached
|
|
250
|
+
|
|
251
|
+
with self.session_manager.db_session_scope() as session:
|
|
252
|
+
products = session.query(FuturesProductORM).all()
|
|
253
|
+
result = [futures_product_from_orm(product) for product in products]
|
|
254
|
+
|
|
255
|
+
self.cache.set(cache_key, result)
|
|
256
|
+
return result
|
|
257
|
+
|
|
258
|
+
def get_product_by_id(self, product_id: str) -> FuturesProduct:
|
|
259
|
+
"""Retrieve a specific futures product by its ID, with caching."""
|
|
260
|
+
ensure_refdata_ready(self.session_manager, self.cfg)
|
|
261
|
+
|
|
262
|
+
cache_key = f"product:{product_id}"
|
|
263
|
+
if cached := self.cache.get(cache_key):
|
|
264
|
+
return cached
|
|
265
|
+
|
|
266
|
+
with self.session_manager.db_session_scope() as session:
|
|
267
|
+
product = (
|
|
268
|
+
session.query(FuturesProductORM)
|
|
269
|
+
.filter_by(product_id=product_id)
|
|
270
|
+
.first()
|
|
271
|
+
)
|
|
272
|
+
if product:
|
|
273
|
+
product = futures_product_from_orm(product)
|
|
274
|
+
|
|
275
|
+
if product:
|
|
276
|
+
self.cache.set(cache_key, product)
|
|
277
|
+
return product
|
|
278
|
+
|
|
279
|
+
def get_contracts_for_product(
|
|
280
|
+
self,
|
|
281
|
+
product_id: str,
|
|
282
|
+
*,
|
|
283
|
+
period_type: PeriodType | str | None = None,
|
|
284
|
+
) -> list[FuturesContract]:
|
|
285
|
+
"""
|
|
286
|
+
Retrieve all contracts for a given product, optionally filtered by period_type.
|
|
287
|
+
|
|
288
|
+
Semantics
|
|
289
|
+
---------
|
|
290
|
+
- Always returns a deterministically ordered list:
|
|
291
|
+
by Period (as defined in Period.__lt__), then contract_id
|
|
292
|
+
- If period_type is provided, only contracts whose Period.period_type matches
|
|
293
|
+
are returned.
|
|
294
|
+
- Results are cached (cache key includes period_type).
|
|
295
|
+
"""
|
|
296
|
+
ensure_refdata_ready(self.session_manager, self.cfg)
|
|
297
|
+
|
|
298
|
+
pt: PeriodType | None
|
|
299
|
+
if period_type is None:
|
|
300
|
+
pt = None
|
|
301
|
+
elif isinstance(period_type, PeriodType):
|
|
302
|
+
pt = period_type
|
|
303
|
+
else:
|
|
304
|
+
try:
|
|
305
|
+
pt = PeriodType(period_type)
|
|
306
|
+
except Exception as e:
|
|
307
|
+
raise ValueError(f"Unknown period_type {period_type!r}") from e
|
|
308
|
+
|
|
309
|
+
cache_key = (
|
|
310
|
+
f"contracts_for_product:{product_id}:"
|
|
311
|
+
f"period_type={pt.value if pt is not None else '*'}"
|
|
312
|
+
)
|
|
313
|
+
if cached := self.cache.get(cache_key):
|
|
314
|
+
return cached
|
|
315
|
+
|
|
316
|
+
with self.session_manager.db_session_scope() as session:
|
|
317
|
+
contracts_orm = (
|
|
318
|
+
session.query(FuturesContractORM).filter_by(product_id=product_id).all()
|
|
319
|
+
)
|
|
320
|
+
contracts: list[FuturesContract] = [
|
|
321
|
+
futures_contract_from_orm(c) for c in contracts_orm
|
|
322
|
+
]
|
|
323
|
+
|
|
324
|
+
if not contracts:
|
|
325
|
+
self.cache.set(cache_key, [])
|
|
326
|
+
return []
|
|
327
|
+
|
|
328
|
+
# Bulk period lookup via API helper (cached internally)
|
|
329
|
+
period_ids = [c.period_id for c in contracts]
|
|
330
|
+
periods = self.get_periods_by_id(period_ids)
|
|
331
|
+
|
|
332
|
+
# Build mapping for filtering / ordering.
|
|
333
|
+
# Note: get_periods_by_id preserves input order and ignores missing IDs;
|
|
334
|
+
# we must still map by id for joins.
|
|
335
|
+
period_by_id: dict[str, Period] = {p.period_id: p for p in periods}
|
|
336
|
+
|
|
337
|
+
# Drop contracts whose period_id is missing from refdata (should be impossible,
|
|
338
|
+
# but we keep it explicit and non-silent in behaviour: missing periods => excluded).
|
|
339
|
+
contracts = [c for c in contracts if c.period_id in period_by_id]
|
|
340
|
+
|
|
341
|
+
# Optional filter by Period.period_type (authoritative)
|
|
342
|
+
if pt is not None:
|
|
343
|
+
contracts = [
|
|
344
|
+
c for c in contracts if period_by_id[c.period_id].period_type == pt
|
|
345
|
+
]
|
|
346
|
+
|
|
347
|
+
# Deterministic ordering: by Period (as defined in Period.__lt__), then contract_id
|
|
348
|
+
contracts.sort(key=lambda c: (period_by_id[c.period_id], c.contract_id))
|
|
349
|
+
|
|
350
|
+
self.cache.set(cache_key, contracts)
|
|
351
|
+
return contracts
|
|
352
|
+
|
|
353
|
+
def get_contracts_for_date(self, target_date: date) -> list[FuturesContract]:
|
|
354
|
+
"""Retrieve contracts that are in their delivery period on a given date."""
|
|
355
|
+
ensure_refdata_ready(self.session_manager, self.cfg)
|
|
356
|
+
|
|
357
|
+
cache_key = f"contracts_for_date:{target_date.isoformat()}"
|
|
358
|
+
if cached := self.cache.get(cache_key):
|
|
359
|
+
return cached
|
|
360
|
+
|
|
361
|
+
with self.session_manager.db_session_scope() as session:
|
|
362
|
+
contracts = (
|
|
363
|
+
session.query(FuturesContractORM)
|
|
364
|
+
.join(PeriodORM, FuturesContractORM.period_id == PeriodORM.period_id)
|
|
365
|
+
.filter(
|
|
366
|
+
PeriodORM.first_date <= target_date,
|
|
367
|
+
PeriodORM.last_date >= target_date,
|
|
368
|
+
)
|
|
369
|
+
.all()
|
|
370
|
+
)
|
|
371
|
+
result = [futures_contract_from_orm(contract) for contract in contracts]
|
|
372
|
+
|
|
373
|
+
self.cache.set(cache_key, result)
|
|
374
|
+
return result
|
|
375
|
+
|
|
376
|
+
def get_periods(self) -> list[Period]:
|
|
377
|
+
"""Retrieve all available periods."""
|
|
378
|
+
ensure_refdata_ready(self.session_manager, self.cfg)
|
|
379
|
+
cache_key = "all_periods"
|
|
380
|
+
if cached := self.cache.get(cache_key):
|
|
381
|
+
return cached
|
|
382
|
+
|
|
383
|
+
with self.session_manager.db_session_scope() as session:
|
|
384
|
+
periods = session.query(PeriodORM).all()
|
|
385
|
+
result = [period_from_orm(period) for period in periods]
|
|
386
|
+
|
|
387
|
+
self.cache.set(cache_key, result)
|
|
388
|
+
return result
|
|
389
|
+
|
|
390
|
+
def get_period_by_id(self, period_id: str) -> Period | None:
|
|
391
|
+
"""
|
|
392
|
+
Retrieve a single Period by its period_id, with caching.
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
Period if found, otherwise None.
|
|
396
|
+
"""
|
|
397
|
+
ensure_refdata_ready(self.session_manager, self.cfg)
|
|
398
|
+
|
|
399
|
+
cache_key = f"period:{period_id}"
|
|
400
|
+
if cached := self.cache.get(cache_key):
|
|
401
|
+
return cached
|
|
402
|
+
|
|
403
|
+
with self.session_manager.db_session_scope() as session:
|
|
404
|
+
period = session.query(PeriodORM).filter_by(period_id=period_id).first()
|
|
405
|
+
if period:
|
|
406
|
+
result = period_from_orm(period)
|
|
407
|
+
self.cache.set(cache_key, result)
|
|
408
|
+
return result
|
|
409
|
+
|
|
410
|
+
return None
|
|
411
|
+
|
|
412
|
+
def get_periods_by_id(self, period_ids: list[str]) -> list[Period]:
|
|
413
|
+
"""
|
|
414
|
+
Retrieve multiple Periods by their period_id values.
|
|
415
|
+
|
|
416
|
+
Semantics:
|
|
417
|
+
- Returns only periods that are found (missing IDs are ignored).
|
|
418
|
+
- Preserves the input order of `period_ids`.
|
|
419
|
+
- Uses caching to avoid redundant DB queries.
|
|
420
|
+
"""
|
|
421
|
+
ensure_refdata_ready(self.session_manager, self.cfg)
|
|
422
|
+
|
|
423
|
+
if not period_ids:
|
|
424
|
+
return []
|
|
425
|
+
|
|
426
|
+
ordered_ids = list(period_ids)
|
|
427
|
+
unique_ids = list(dict.fromkeys(period_ids))
|
|
428
|
+
|
|
429
|
+
cache_key = f"periods:ids:{','.join(unique_ids)}"
|
|
430
|
+
if cached := self.cache.get(cache_key):
|
|
431
|
+
by_id = {p.period_id: p for p in cached}
|
|
432
|
+
return [by_id[pid] for pid in ordered_ids if pid in by_id]
|
|
433
|
+
|
|
434
|
+
with self.session_manager.db_session_scope() as session:
|
|
435
|
+
periods = (
|
|
436
|
+
session.query(PeriodORM)
|
|
437
|
+
.filter(PeriodORM.period_id.in_(unique_ids))
|
|
438
|
+
.all()
|
|
439
|
+
)
|
|
440
|
+
result = [period_from_orm(p) for p in periods]
|
|
441
|
+
|
|
442
|
+
self.cache.set(cache_key, result)
|
|
443
|
+
|
|
444
|
+
by_id = {p.period_id: p for p in result}
|
|
445
|
+
return [by_id[pid] for pid in ordered_ids if pid in by_id]
|
|
446
|
+
|
|
447
|
+
def get_cycles(self) -> list[PeriodCycle]:
|
|
448
|
+
"""
|
|
449
|
+
Retrieve all available PeriodCycle definitions.
|
|
450
|
+
|
|
451
|
+
Cached as a whole-list artifact; cycles are few and essentially static for a DB build.
|
|
452
|
+
"""
|
|
453
|
+
ensure_refdata_ready(self.session_manager, self.cfg)
|
|
454
|
+
|
|
455
|
+
cache_key = "all_period_cycles"
|
|
456
|
+
if cached := self.cache.get(cache_key):
|
|
457
|
+
return cached
|
|
458
|
+
|
|
459
|
+
with self.session_manager.db_session_scope() as session:
|
|
460
|
+
cycles = (
|
|
461
|
+
session.query(PeriodCycleORM)
|
|
462
|
+
.order_by(PeriodCycleORM.cycle_id.asc())
|
|
463
|
+
.all()
|
|
464
|
+
)
|
|
465
|
+
result = [period_cycle_from_orm(c) for c in cycles]
|
|
466
|
+
|
|
467
|
+
self.cache.set(cache_key, result)
|
|
468
|
+
return result
|
|
469
|
+
|
|
470
|
+
def get_cycle_by_id(self, cycle_id: str) -> PeriodCycle | None:
|
|
471
|
+
"""
|
|
472
|
+
Retrieve a single PeriodCycle by cycle_id, with caching.
|
|
473
|
+
|
|
474
|
+
Returns:
|
|
475
|
+
PeriodCycle if found, otherwise None.
|
|
476
|
+
"""
|
|
477
|
+
ensure_refdata_ready(self.session_manager, self.cfg)
|
|
478
|
+
|
|
479
|
+
cache_key = f"period_cycle:{cycle_id}"
|
|
480
|
+
if cached := self.cache.get(cache_key):
|
|
481
|
+
return cached
|
|
482
|
+
|
|
483
|
+
with self.session_manager.db_session_scope() as session:
|
|
484
|
+
c = session.query(PeriodCycleORM).filter_by(cycle_id=cycle_id).first()
|
|
485
|
+
if c:
|
|
486
|
+
result = period_cycle_from_orm(c)
|
|
487
|
+
self.cache.set(cache_key, result)
|
|
488
|
+
return result
|
|
489
|
+
|
|
490
|
+
return None
|
|
491
|
+
|
|
492
|
+
def get_cycle_memberships(self, cycle_id: str) -> list[PeriodCycleMembership]:
|
|
493
|
+
"""
|
|
494
|
+
Retrieve all memberships for a given cycle_id.
|
|
495
|
+
|
|
496
|
+
Semantics:
|
|
497
|
+
- Deterministically ordered by (cycle_instance, cycle_element, period_id).
|
|
498
|
+
- Cached by cycle_id.
|
|
499
|
+
|
|
500
|
+
This is primarily an inspection/audit surface. Selection logic should usually use
|
|
501
|
+
`get_cycle_elements(...)` for targeted lookup.
|
|
502
|
+
"""
|
|
503
|
+
ensure_refdata_ready(self.session_manager, self.cfg)
|
|
504
|
+
|
|
505
|
+
cache_key = f"period_cycle_memberships:{cycle_id}"
|
|
506
|
+
if cached := self.cache.get(cache_key):
|
|
507
|
+
return cached
|
|
508
|
+
|
|
509
|
+
with self.session_manager.db_session_scope() as session:
|
|
510
|
+
rows = (
|
|
511
|
+
session.query(PeriodCycleMembershipORM)
|
|
512
|
+
.filter(PeriodCycleMembershipORM.cycle_id == cycle_id)
|
|
513
|
+
.order_by(
|
|
514
|
+
PeriodCycleMembershipORM.cycle_instance.asc(),
|
|
515
|
+
PeriodCycleMembershipORM.cycle_element.asc(),
|
|
516
|
+
PeriodCycleMembershipORM.period_id.asc(),
|
|
517
|
+
)
|
|
518
|
+
.all()
|
|
519
|
+
)
|
|
520
|
+
result = [period_cycle_membership_from_orm(r) for r in rows]
|
|
521
|
+
|
|
522
|
+
self.cache.set(cache_key, result)
|
|
523
|
+
return result
|
|
524
|
+
|
|
525
|
+
def get_cycle_elements(
|
|
526
|
+
self,
|
|
527
|
+
period_ids: list[str],
|
|
528
|
+
*,
|
|
529
|
+
cycle_id: str,
|
|
530
|
+
) -> dict[str, int]:
|
|
531
|
+
"""
|
|
532
|
+
Batch lookup: map period_id -> cycle_element for the given cycle.
|
|
533
|
+
|
|
534
|
+
This is the key surface needed by MXM V1 contract selection:
|
|
535
|
+
contract.period_id -> cycle element (e.g. month number, quarter number)
|
|
536
|
+
|
|
537
|
+
Semantics:
|
|
538
|
+
- Returns only found period_ids (missing IDs are omitted).
|
|
539
|
+
- Input order is not preserved (dict output); caller can re-order if needed.
|
|
540
|
+
- Cached by (cycle_id, unique(sorted(period_ids))).
|
|
541
|
+
- Deterministic query ordering, but the returned mapping is inherently unordered.
|
|
542
|
+
|
|
543
|
+
Returns:
|
|
544
|
+
Dict[str, int] mapping period_id -> cycle_element
|
|
545
|
+
"""
|
|
546
|
+
ensure_refdata_ready(self.session_manager, self.cfg)
|
|
547
|
+
|
|
548
|
+
if not period_ids:
|
|
549
|
+
return {}
|
|
550
|
+
|
|
551
|
+
unique_ids = sorted(set(period_ids))
|
|
552
|
+
cache_key = f"period_cycle_elements:{cycle_id}:pids:{','.join(unique_ids)}"
|
|
553
|
+
if cached := self.cache.get(cache_key):
|
|
554
|
+
return cached
|
|
555
|
+
|
|
556
|
+
with self.session_manager.db_session_scope() as session:
|
|
557
|
+
rows = (
|
|
558
|
+
session.query(
|
|
559
|
+
PeriodCycleMembershipORM.period_id,
|
|
560
|
+
PeriodCycleMembershipORM.cycle_element,
|
|
561
|
+
)
|
|
562
|
+
.filter(
|
|
563
|
+
PeriodCycleMembershipORM.cycle_id == cycle_id,
|
|
564
|
+
PeriodCycleMembershipORM.period_id.in_(unique_ids),
|
|
565
|
+
)
|
|
566
|
+
.order_by(PeriodCycleMembershipORM.period_id.asc())
|
|
567
|
+
.all()
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
result: dict[str, int] = {pid: int(elem) for (pid, elem) in rows}
|
|
571
|
+
self.cache.set(cache_key, result)
|
|
572
|
+
return result
|
|
573
|
+
|
|
574
|
+
def get_cycle_element(
|
|
575
|
+
self,
|
|
576
|
+
period_id: str,
|
|
577
|
+
*,
|
|
578
|
+
cycle_id: str,
|
|
579
|
+
) -> int | None:
|
|
580
|
+
"""
|
|
581
|
+
Convenience wrapper: lookup a single period_id -> cycle_element for a cycle.
|
|
582
|
+
|
|
583
|
+
Uses caching; implemented via a direct ORM query (not via get_cycle_elements)
|
|
584
|
+
to avoid building large cache keys for singleton usage.
|
|
585
|
+
"""
|
|
586
|
+
ensure_refdata_ready(self.session_manager, self.cfg)
|
|
587
|
+
|
|
588
|
+
cache_key = f"period_cycle_element:{cycle_id}:{period_id}"
|
|
589
|
+
if cached := self.cache.get(cache_key):
|
|
590
|
+
return cached
|
|
591
|
+
|
|
592
|
+
with self.session_manager.db_session_scope() as session:
|
|
593
|
+
row = (
|
|
594
|
+
session.query(PeriodCycleMembershipORM.cycle_element)
|
|
595
|
+
.filter(
|
|
596
|
+
PeriodCycleMembershipORM.cycle_id == cycle_id,
|
|
597
|
+
PeriodCycleMembershipORM.period_id == period_id,
|
|
598
|
+
)
|
|
599
|
+
.first()
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
if row is None:
|
|
603
|
+
return None
|
|
604
|
+
|
|
605
|
+
elem = int(row[0])
|
|
606
|
+
self.cache.set(cache_key, elem)
|
|
607
|
+
return elem
|