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.
Files changed (64) hide show
  1. mxm/refdata/__init__.py +1 -0
  2. mxm/refdata/api/__init__.py +1 -0
  3. mxm/refdata/api/ref_data_api.py +607 -0
  4. mxm/refdata/cli.py +218 -0
  5. mxm/refdata/data/first_day_of_interest_rule.json +88 -0
  6. mxm/refdata/data/futures_products.csv +6 -0
  7. mxm/refdata/data/last_trading_rule.json +34 -0
  8. mxm/refdata/database/__init__.py +1 -0
  9. mxm/refdata/database/sql_session_manager.py +135 -0
  10. mxm/refdata/mappings/__init__.py +31 -0
  11. mxm/refdata/mappings/futures_contract_vs_orm.py +51 -0
  12. mxm/refdata/mappings/futures_product_vs_orm.py +56 -0
  13. mxm/refdata/mappings/period_cycles_vs_orm.py +102 -0
  14. mxm/refdata/mappings/period_vs_orm.py +40 -0
  15. mxm/refdata/models/__init__.py +31 -0
  16. mxm/refdata/models/contracts/__init__.py +0 -0
  17. mxm/refdata/models/contracts/futures_contract.py +22 -0
  18. mxm/refdata/models/currencies.py +24 -0
  19. mxm/refdata/models/months.py +72 -0
  20. mxm/refdata/models/orm/__init__.py +16 -0
  21. mxm/refdata/models/orm/base.py +3 -0
  22. mxm/refdata/models/orm/futures_contracts.py +53 -0
  23. mxm/refdata/models/orm/futures_products.py +61 -0
  24. mxm/refdata/models/orm/period_cycles.py +84 -0
  25. mxm/refdata/models/orm/periods.py +30 -0
  26. mxm/refdata/models/period_cycles.py +73 -0
  27. mxm/refdata/models/periods.py +64 -0
  28. mxm/refdata/models/products/__init__.py +1 -0
  29. mxm/refdata/models/products/futures_product.py +38 -0
  30. mxm/refdata/models/products/settlement.py +10 -0
  31. mxm/refdata/models/reference_events.py +9 -0
  32. mxm/refdata/models/units.py +28 -0
  33. mxm/refdata/models/weekdays.py +66 -0
  34. mxm/refdata/parsing/__init__.py +1 -0
  35. mxm/refdata/parsing/futures_products_from_csv.py +76 -0
  36. mxm/refdata/py.typed +0 -0
  37. mxm/refdata/scripts/__init__.py +1 -0
  38. mxm/refdata/scripts/db_utils.py +61 -0
  39. mxm/refdata/scripts/manage_static_ref_data.py +73 -0
  40. mxm/refdata/services/__init__.py +0 -0
  41. mxm/refdata/services/bootstrap.py +84 -0
  42. mxm/refdata/services/futures_contract_factory.py +127 -0
  43. mxm/refdata/services/futures_product_factory.py +132 -0
  44. mxm/refdata/services/period_factory.py +315 -0
  45. mxm/refdata/services/ref_data_service.py +322 -0
  46. mxm/refdata/services/smokecheck.py +326 -0
  47. mxm/refdata/trading_calendars/__init__.py +0 -0
  48. mxm/refdata/trading_calendars/first_day_of_interest.py +74 -0
  49. mxm/refdata/trading_calendars/last_trading_day.py +117 -0
  50. mxm/refdata/trading_calendars/nth_business_day.py +52 -0
  51. mxm/refdata/trading_calendars/nth_calendar_day_of_period.py +43 -0
  52. mxm/refdata/trading_calendars/nth_weekday_of_period.py +50 -0
  53. mxm/refdata/trading_calendars/trading_calendar.py +186 -0
  54. mxm/refdata/utils/__init__.py +1 -0
  55. mxm/refdata/utils/cache_manager.py +33 -0
  56. mxm/refdata/utils/config.py +32 -0
  57. mxm/refdata/utils/period_types_codec.py +16 -0
  58. mxm/refdata/utils/regex_patterns.py +18 -0
  59. mxm/refdata/utils/resources.py +29 -0
  60. mxm_refdata-0.3.0.dist-info/METADATA +228 -0
  61. mxm_refdata-0.3.0.dist-info/RECORD +64 -0
  62. mxm_refdata-0.3.0.dist-info/WHEEL +4 -0
  63. mxm_refdata-0.3.0.dist-info/entry_points.txt +3 -0
  64. mxm_refdata-0.3.0.dist-info/licenses/LICENSE +21 -0
@@ -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