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,322 @@
1
+ """Service to create, update, and manage reference data in the database."""
2
+
3
+ import logging
4
+ from datetime import date
5
+
6
+ from sqlalchemy.orm import DeclarativeBase
7
+
8
+ from mxm.refdata.database.sql_session_manager import SQLSessionManager
9
+ from mxm.refdata.mappings import (
10
+ futures_contract_to_orm,
11
+ futures_product_from_orm,
12
+ futures_product_to_orm,
13
+ period_from_orm,
14
+ period_to_orm,
15
+ )
16
+ from mxm.refdata.models import FuturesContract, Period
17
+ from mxm.refdata.models.orm.futures_contracts import FuturesContractORM
18
+ from mxm.refdata.models.orm.futures_products import FuturesProductORM
19
+ from mxm.refdata.models.orm.period_cycles import (
20
+ PeriodCycleMembershipORM,
21
+ PeriodCycleORM,
22
+ )
23
+ from mxm.refdata.models.orm.periods import PeriodORM
24
+ from mxm.refdata.models.period_cycles import CycleInstanceKind
25
+ from mxm.refdata.models.periods import PeriodType
26
+ from mxm.refdata.services.futures_contract_factory import FuturesContractFactory
27
+ from mxm.refdata.services.futures_product_factory import FuturesProductFactory
28
+ from mxm.refdata.services.period_factory import PeriodFactory
29
+ from mxm.refdata.utils.config import load_config
30
+ from mxm.refdata.utils.resources import futures_products_csv_path
31
+
32
+ logging.basicConfig(level=logging.INFO)
33
+ logger = logging.getLogger(__name__)
34
+
35
+ CYCLE_ID_CALENDAR_MONTHS = "CALENDAR_MONTHS"
36
+ CYCLE_ID_CALENDAR_QUARTERS = "CALENDAR_QUARTERS"
37
+
38
+
39
+ class RefDataService:
40
+ """
41
+ Manages reference data processes including initializing, updating, and resetting futures products.
42
+ """
43
+
44
+ def __init__(self, session_manager: SQLSessionManager):
45
+ """
46
+ Initialize the RefDataService with a session manager.
47
+
48
+ Args:
49
+ session_manager (SQLSessionManager): The session manager handling DB connections.
50
+ """
51
+ self.session_manager = session_manager
52
+ self.product_factory = FuturesProductFactory()
53
+ self.contract_factory = FuturesContractFactory()
54
+ self.period_factory = PeriodFactory()
55
+
56
+ def reset_database(self):
57
+ """
58
+ Reset the database by dropping and reinitializing all tables.
59
+ WARNING: This will delete all existing data.
60
+ """
61
+ logger.warning("Resetting the database. This will delete ALL existing data.")
62
+ self.session_manager.drop_db()
63
+ self.session_manager.init_db()
64
+ logger.info("Database successfully reset.")
65
+
66
+ def _is_database_empty(self) -> bool:
67
+ """
68
+ Check if the database contains any data in any relevant tables.
69
+
70
+ Returns:
71
+ bool: True if all relevant tables are empty, False otherwise.
72
+ """
73
+ with self.session_manager.db_session_scope() as session:
74
+ tables_to_check = [FuturesProductORM, FuturesContractORM, PeriodORM]
75
+
76
+ for table in tables_to_check:
77
+ if session.query(table).count() > 0:
78
+ return False # At least one table is not empty
79
+
80
+ return True # All tables are empty
81
+
82
+ def is_table_empty(self, orm_model: type[DeclarativeBase]) -> bool:
83
+ """
84
+ Check if a given ORM table is empty.
85
+
86
+ Args:
87
+ orm_model (Base): The SQLAlchemy ORM model representing the table.
88
+
89
+ Returns:
90
+ bool: True if the table is empty, False otherwise.
91
+ """
92
+ with self.session_manager.db_session_scope() as session:
93
+ return session.query(orm_model).count() == 0
94
+
95
+ def setup_instruments(
96
+ self,
97
+ csv_file_path: str | None = None,
98
+ start_date: date | None = None,
99
+ end_date: date | None = None,
100
+ ) -> None:
101
+ """
102
+ Perform a full instrument setup: initialise periods, products, and contracts.
103
+
104
+ This method is a convenience function that ensures all required data is set up correctly.
105
+
106
+ NOTE: initialisation expects boundary-aligned horizons.
107
+ We filter to periods fully contained in [start_date, end_date] deliberately.
108
+ Do not call this with arbitrary dates; use a separate overlap query for ad hoc ranges.
109
+ Args:
110
+ csv_file_path (Optional[str]): Path to the CSV file for futures products.
111
+ start_date (Optional[date]): Start date for contract generation (default: today).
112
+ end_date (Optional[date]): End date for contract generation (default: +1 year).
113
+ """
114
+ logging.info("Starting full instrument setup...")
115
+
116
+ # Ensure we have a reasonable date range if not provided
117
+ start_date = start_date or date(2000, 1, 1)
118
+ end_date = end_date or date(2045, 12, 31)
119
+
120
+ # Step 1: Initialise periods (YEAR, QUARTER, MONTH)
121
+ self.initialise_periods(start_date=start_date, end_date=end_date)
122
+ self.initialise_period_cycles()
123
+ # Step 2: Initialise futures products from CSV
124
+ self.initialise_futures_products(csv_file_path=csv_file_path)
125
+
126
+ # Step 3: Generate and store contracts for each product
127
+ self.initialise_futures_contracts(start_date=start_date, end_date=end_date)
128
+
129
+ logging.info("Full instrument setup completed successfully.")
130
+
131
+ def initialise_futures_products(self, csv_file_path: str | None = None) -> None:
132
+ """
133
+ Initialize the database with futures products from a CSV file.
134
+
135
+ Args:
136
+ csv_file_path (Optional[str]): Path to the CSV file containing futures product definitions.
137
+ If None, uses the default path from config.
138
+
139
+ Raises:
140
+ ValueError: If the database is not empty.
141
+ """
142
+ if not self.is_table_empty(FuturesProductORM):
143
+ raise ValueError(
144
+ "Database already contains products. Run `reset_database()` first."
145
+ )
146
+
147
+ if csv_file_path is None:
148
+ cfg = load_config()
149
+ with futures_products_csv_path(cfg) as p:
150
+ products = FuturesProductFactory.initialise_from_csv(str(p))
151
+ else:
152
+ assert csv_file_path is not None
153
+ products = self.product_factory.initialise_from_csv(csv_file_path)
154
+
155
+ with self.session_manager.db_session_scope() as session:
156
+ for product in products:
157
+ product_orm = futures_product_to_orm(product)
158
+ session.add(product_orm)
159
+
160
+ def initialise_periods(
161
+ self,
162
+ start_date: date,
163
+ end_date: date,
164
+ period_types: list[PeriodType] | None = None,
165
+ ) -> None:
166
+ """
167
+ Initialise periods for a given date range and set of period types.
168
+
169
+ Ensures that all required periods exist **before** any contracts reference them.
170
+
171
+ Args:
172
+ start_date (date): The start date for period creation.
173
+ end_date (date): The end date for period creation.
174
+ period_types (Optional[list[PeriodType]]): List of period types to create.
175
+ Defaults to YEAR, QUARTER, and MONTH.
176
+ """
177
+ if not self.is_table_empty(PeriodORM):
178
+ raise ValueError(
179
+ "Database already contains periods. Run `reset_database()` first."
180
+ )
181
+
182
+ if period_types is None:
183
+ period_types = [PeriodType.YEAR, PeriodType.QUARTER, PeriodType.MONTH]
184
+
185
+ with self.session_manager.db_session_scope() as session:
186
+ existing_period_ids = {p.period_id for p in session.query(PeriodORM).all()}
187
+
188
+ new_periods: list[Period] = []
189
+ for period_type in period_types:
190
+ generated_periods = self.period_factory.get_periods_in_range(
191
+ start_date, end_date, period_type
192
+ )
193
+
194
+ for period in generated_periods:
195
+ if period.period_id not in existing_period_ids:
196
+ new_periods.append(period)
197
+
198
+ if new_periods:
199
+ with self.session_manager.db_session_scope() as session:
200
+ for period in new_periods:
201
+ session.add(
202
+ period_to_orm(period)
203
+ ) # Convert & store only new periods
204
+
205
+ def initialise_futures_contracts(self, start_date: date, end_date: date) -> None:
206
+ """
207
+ Initialize futures contracts using existing products and periods in the database.
208
+
209
+ Args:
210
+ start_date (date): The start date for contract generation.
211
+ end_date (date): The end date for contract generation.
212
+ """
213
+ with self.session_manager.db_session_scope() as session:
214
+ # Retrieve all existing products and convert them before session closes
215
+ products = [
216
+ futures_product_from_orm(productORM)
217
+ for productORM in session.query(FuturesProductORM).all()
218
+ ]
219
+ if not products:
220
+ raise ValueError(
221
+ "No products found in the database. Run initialise_futures_products() first."
222
+ )
223
+
224
+ # Retrieve all periods within the date range
225
+ periods = {
226
+ p.period_id: period_from_orm(p)
227
+ for p in session.query(PeriodORM)
228
+ .filter(
229
+ PeriodORM.first_date >= start_date, PeriodORM.last_date <= end_date
230
+ )
231
+ .all()
232
+ }
233
+ if not periods:
234
+ raise ValueError(
235
+ "No periods found in the database. Run initialise_periods() first."
236
+ )
237
+
238
+ # Generate contracts outside of session
239
+ contracts: list[FuturesContract] = []
240
+ for product in products:
241
+ product_contracts = self.contract_factory.create_contracts_for_product(
242
+ product, periods
243
+ )
244
+ contracts.extend(product_contracts)
245
+
246
+ # Store contracts in the database
247
+ with self.session_manager.db_session_scope() as session:
248
+ for contract in contracts:
249
+ session.add(futures_contract_to_orm(contract=contract))
250
+
251
+ def initialise_period_cycles(self) -> None:
252
+ """
253
+ Initialise canonical PeriodCycles and PeriodCycleMemberships.
254
+
255
+ Requires PeriodORM to already exist.
256
+ """
257
+ if not self.is_table_empty(PeriodCycleORM) or not self.is_table_empty(
258
+ PeriodCycleMembershipORM
259
+ ):
260
+ raise ValueError(
261
+ "Database already contains period cycles. Run `reset_database()` first."
262
+ )
263
+
264
+ with self.session_manager.db_session_scope() as session:
265
+ # --- cycle definitions ---
266
+ session.add_all(
267
+ [
268
+ PeriodCycleORM(
269
+ cycle_id=CYCLE_ID_CALENDAR_MONTHS,
270
+ name="Calendar Months",
271
+ period_type=PeriodType.MONTH.name,
272
+ instance_kind=CycleInstanceKind.YEAR.value,
273
+ cycle_size=12,
274
+ ),
275
+ PeriodCycleORM(
276
+ cycle_id=CYCLE_ID_CALENDAR_QUARTERS,
277
+ name="Calendar Quarters",
278
+ period_type=PeriodType.QUARTER.name,
279
+ instance_kind=CycleInstanceKind.YEAR.value,
280
+ cycle_size=4,
281
+ ),
282
+ ]
283
+ )
284
+
285
+ # --- memberships derived from PeriodORM ---
286
+ periods = (
287
+ session.query(PeriodORM)
288
+ .filter(
289
+ PeriodORM.period_type.in_(
290
+ [PeriodType.MONTH.name, PeriodType.QUARTER.name]
291
+ )
292
+ )
293
+ .all()
294
+ )
295
+
296
+ memberships: list[PeriodCycleMembershipORM] = []
297
+
298
+ for p in periods:
299
+ year = p.first_date.year
300
+ month = p.first_date.month
301
+
302
+ if p.period_type == PeriodType.MONTH:
303
+ memberships.append(
304
+ PeriodCycleMembershipORM(
305
+ cycle_id=CYCLE_ID_CALENDAR_MONTHS,
306
+ period_id=p.period_id,
307
+ cycle_instance=year,
308
+ cycle_element=month, # 1..12
309
+ )
310
+ )
311
+ elif p.period_type == PeriodType.QUARTER:
312
+ q = ((month - 1) // 3) + 1 # 1..4
313
+ memberships.append(
314
+ PeriodCycleMembershipORM(
315
+ cycle_id=CYCLE_ID_CALENDAR_QUARTERS,
316
+ period_id=p.period_id,
317
+ cycle_instance=year,
318
+ cycle_element=q,
319
+ )
320
+ )
321
+
322
+ session.add_all(memberships)
@@ -0,0 +1,326 @@
1
+ """Operational smoke checks for the materialised mxm-refdata database."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Iterable
6
+ from dataclasses import dataclass
7
+ from typing import Literal
8
+
9
+ from sqlalchemy import func
10
+ from sqlalchemy.orm import Session
11
+
12
+ from mxm.refdata.database.sql_session_manager import SQLSessionManager
13
+ from mxm.refdata.mappings import (
14
+ futures_contract_from_orm,
15
+ futures_product_from_orm,
16
+ period_from_orm,
17
+ )
18
+ from mxm.refdata.models.orm.futures_contracts import FuturesContractORM
19
+ from mxm.refdata.models.orm.futures_products import FuturesProductORM
20
+ from mxm.refdata.models.orm.period_cycles import (
21
+ PeriodCycleMembershipORM,
22
+ PeriodCycleORM,
23
+ )
24
+ from mxm.refdata.models.orm.periods import PeriodORM
25
+ from mxm.refdata.models.periods import PeriodType
26
+ from mxm.refdata.utils.period_types_codec import decode_period_types
27
+
28
+ CYCLE_ID_CALENDAR_MONTHS = "CALENDAR_MONTHS"
29
+ CYCLE_ID_CALENDAR_QUARTERS = "CALENDAR_QUARTERS"
30
+
31
+ type SmokeCheckStatus = Literal["pass", "fail"]
32
+
33
+
34
+ class SmokeCheckFailed(Exception):
35
+ """Raised when an individual smoke check fails."""
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class RefDataCounts:
40
+ """Row counts for the materialised refdata database."""
41
+
42
+ products: int
43
+ periods: int
44
+ contracts: int
45
+ cycles: int
46
+ memberships: int
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class SmokeCheckResult:
51
+ """Result of one smoke check."""
52
+
53
+ name: str
54
+ status: SmokeCheckStatus
55
+ message: str = ""
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class SmokeCheckReport:
60
+ """Aggregate smoke-check report."""
61
+
62
+ counts: RefDataCounts
63
+ results: list[SmokeCheckResult]
64
+
65
+ @property
66
+ def passed(self) -> bool:
67
+ """Return True if all smoke checks passed."""
68
+ return all(result.status == "pass" for result in self.results)
69
+
70
+
71
+ type SmokeCheck = Callable[[Session], None]
72
+
73
+
74
+ def _fail(message: str) -> None:
75
+ raise SmokeCheckFailed(message)
76
+
77
+
78
+ def _assert(condition: bool, message: str) -> None:
79
+ if not condition:
80
+ _fail(message)
81
+
82
+
83
+ def _pick_first[T](iterable: Iterable[T]) -> T | None:
84
+ for item in iterable:
85
+ return item
86
+ return None
87
+
88
+
89
+ def count_refdata_rows(session: Session) -> RefDataCounts:
90
+ """Count key materialised refdata tables."""
91
+ return RefDataCounts(
92
+ products=session.query(FuturesProductORM).count(),
93
+ periods=session.query(PeriodORM).count(),
94
+ contracts=session.query(FuturesContractORM).count(),
95
+ cycles=session.query(PeriodCycleORM).count(),
96
+ memberships=session.query(PeriodCycleMembershipORM).count(),
97
+ )
98
+
99
+
100
+ def smoke_check_non_empty_core_tables(session: Session) -> None:
101
+ """Verify that products, periods, and contracts are populated."""
102
+ counts = count_refdata_rows(session)
103
+
104
+ _assert(counts.products > 0, "No products inserted.")
105
+ _assert(counts.periods > 0, "No periods inserted.")
106
+ _assert(counts.contracts > 0, "No contracts inserted.")
107
+
108
+
109
+ def smoke_check_roundtrip_period_types(session: Session) -> None:
110
+ """Verify period_types storage and ORM/domain round-trip semantics."""
111
+ product_orm = _pick_first(session.query(FuturesProductORM).limit(1).all())
112
+ if product_orm is None:
113
+ raise SmokeCheckFailed("No products found.")
114
+
115
+ decoded = decode_period_types(product_orm.period_types)
116
+ product = futures_product_from_orm(product_orm)
117
+
118
+ _assert(
119
+ product.period_types == decoded,
120
+ "Domain period_types does not match decoded ORM value.",
121
+ )
122
+
123
+
124
+ def smoke_check_contract_date_types(session: Session) -> None:
125
+ """Verify contract date fields map correctly into the domain model."""
126
+ contract_orm = _pick_first(session.query(FuturesContractORM).limit(1).all())
127
+
128
+ if contract_orm is None:
129
+ raise SmokeCheckFailed("No contracts found.")
130
+
131
+ contract = futures_contract_from_orm(contract_orm)
132
+
133
+ _assert(
134
+ contract.first_day_of_interest == contract_orm.first_day_of_interest,
135
+ "Domain first_day_of_interest does not match ORM value.",
136
+ )
137
+ _assert(
138
+ contract.last_trading_day == contract_orm.last_trading_day,
139
+ "Domain last_trading_day does not match ORM value.",
140
+ )
141
+
142
+
143
+ def smoke_check_contracts_periods_coherence(session: Session) -> None:
144
+ """Verify contracts reference periods and period-type filtering is feasible."""
145
+ product_id = _pick_first(
146
+ product_id
147
+ for (product_id,) in session.query(FuturesContractORM.product_id)
148
+ .distinct()
149
+ .limit(1)
150
+ .all()
151
+ )
152
+ _assert(product_id is not None, "No product_id found in contracts table.")
153
+
154
+ contracts_orm = (
155
+ session.query(FuturesContractORM)
156
+ .filter(FuturesContractORM.product_id == product_id)
157
+ .order_by(FuturesContractORM.contract_id.asc())
158
+ .all()
159
+ )
160
+ _assert(
161
+ len(contracts_orm) > 0,
162
+ f"No contracts found for product_id={product_id!r}.",
163
+ )
164
+
165
+ contracts = [futures_contract_from_orm(contract) for contract in contracts_orm]
166
+ periods = {p.period_id: period_from_orm(p) for p in session.query(PeriodORM).all()}
167
+
168
+ period_types = [
169
+ periods[contract.period_id].period_type
170
+ for contract in contracts
171
+ if contract.period_id in periods
172
+ ]
173
+
174
+ _assert(
175
+ len(period_types) > 0,
176
+ "Could not resolve period types for contracts via periods table.",
177
+ )
178
+
179
+ chosen = _pick_first(sorted(set(period_types), key=lambda item: item.value))
180
+ _assert(chosen is not None, "No period types found for product contracts.")
181
+
182
+ subset = [
183
+ contract
184
+ for contract in contracts
185
+ if periods[contract.period_id].period_type == chosen
186
+ ]
187
+ _assert(
188
+ 0 < len(subset) <= len(contracts),
189
+ "Filtering by period_type should yield a non-empty subset.",
190
+ )
191
+
192
+
193
+ def smoke_check_period_cycles_present(session: Session) -> None:
194
+ """Verify canonical calendar cycles exist and have memberships."""
195
+ cycle_ids = {
196
+ cycle_id for (cycle_id,) in session.query(PeriodCycleORM.cycle_id).all()
197
+ }
198
+
199
+ _assert(CYCLE_ID_CALENDAR_MONTHS in cycle_ids, "Missing cycle CALENDAR_MONTHS.")
200
+ _assert(CYCLE_ID_CALENDAR_QUARTERS in cycle_ids, "Missing cycle CALENDAR_QUARTERS.")
201
+
202
+ month_memberships = (
203
+ session.query(PeriodCycleMembershipORM)
204
+ .filter(PeriodCycleMembershipORM.cycle_id == CYCLE_ID_CALENDAR_MONTHS)
205
+ .count()
206
+ )
207
+ quarter_memberships = (
208
+ session.query(PeriodCycleMembershipORM)
209
+ .filter(PeriodCycleMembershipORM.cycle_id == CYCLE_ID_CALENDAR_QUARTERS)
210
+ .count()
211
+ )
212
+
213
+ _assert(month_memberships > 0, "No memberships for CALENDAR_MONTHS.")
214
+ _assert(quarter_memberships > 0, "No memberships for CALENDAR_QUARTERS.")
215
+
216
+
217
+ def smoke_check_period_cycle_membership_uniqueness(session: Session) -> None:
218
+ """Verify canonical cycle membership keys are unique."""
219
+ duplicates = (
220
+ session.query(
221
+ PeriodCycleMembershipORM.cycle_id,
222
+ PeriodCycleMembershipORM.cycle_instance,
223
+ PeriodCycleMembershipORM.cycle_element,
224
+ func.count().label("n"),
225
+ )
226
+ .group_by(
227
+ PeriodCycleMembershipORM.cycle_id,
228
+ PeriodCycleMembershipORM.cycle_instance,
229
+ PeriodCycleMembershipORM.cycle_element,
230
+ )
231
+ .having(func.count() > 1)
232
+ .limit(1)
233
+ .all()
234
+ )
235
+
236
+ _assert(not duplicates, f"Duplicate cycle membership keys found: {duplicates!r}")
237
+
238
+
239
+ def smoke_check_calendar_month_mapping(session: Session) -> None:
240
+ """Spot-check that December periods map to calendar month element 12."""
241
+ expected_month = 12
242
+ membership = (
243
+ session.query(PeriodCycleMembershipORM)
244
+ .filter(
245
+ PeriodCycleMembershipORM.cycle_id == CYCLE_ID_CALENDAR_MONTHS,
246
+ PeriodCycleMembershipORM.cycle_element == expected_month,
247
+ )
248
+ .limit(1)
249
+ .one_or_none()
250
+ )
251
+ if membership is None:
252
+ raise SmokeCheckFailed(
253
+ f"No month membership found for element={expected_month}."
254
+ )
255
+
256
+ period = (
257
+ session.query(PeriodORM)
258
+ .filter(PeriodORM.period_id == membership.period_id)
259
+ .one_or_none()
260
+ )
261
+ if period is None:
262
+ raise SmokeCheckFailed(
263
+ f"Membership references missing Period: {membership.period_id!r}"
264
+ )
265
+
266
+ _assert(
267
+ period.period_type == PeriodType.MONTH,
268
+ f"Expected mapped period_type MONTH, got {period.period_type!r}.",
269
+ )
270
+ _assert(
271
+ period.first_date.month == expected_month,
272
+ f"Expected Period.first_date.month={expected_month}, "
273
+ f"got {period.first_date.month}.",
274
+ )
275
+
276
+
277
+ SMOKE_CHECKS: tuple[tuple[str, SmokeCheck], ...] = (
278
+ ("core tables populated", smoke_check_non_empty_core_tables),
279
+ ("period_types storage + round-trip", smoke_check_roundtrip_period_types),
280
+ ("contract date field types", smoke_check_contract_date_types),
281
+ (
282
+ "contracts/periods coherence + filterability",
283
+ smoke_check_contracts_periods_coherence,
284
+ ),
285
+ (
286
+ "period cycles present + non-empty memberships",
287
+ smoke_check_period_cycles_present,
288
+ ),
289
+ (
290
+ "period cycle membership uniqueness",
291
+ smoke_check_period_cycle_membership_uniqueness,
292
+ ),
293
+ ("calendar month mapping spot-check", smoke_check_calendar_month_mapping),
294
+ )
295
+
296
+
297
+ def run_smokechecks(
298
+ session_manager: SQLSessionManager | None = None,
299
+ ) -> SmokeCheckReport:
300
+ """Run operational smoke checks against the materialised refdata database."""
301
+ sm = session_manager or SQLSessionManager()
302
+
303
+ with sm.db_session_scope() as session:
304
+ counts = count_refdata_rows(session)
305
+ results: list[SmokeCheckResult] = []
306
+
307
+ for name, check in SMOKE_CHECKS:
308
+ try:
309
+ check(session)
310
+ except SmokeCheckFailed as err:
311
+ results.append(
312
+ SmokeCheckResult(
313
+ name=name,
314
+ status="fail",
315
+ message=str(err),
316
+ )
317
+ )
318
+ else:
319
+ results.append(
320
+ SmokeCheckResult(
321
+ name=name,
322
+ status="pass",
323
+ )
324
+ )
325
+
326
+ return SmokeCheckReport(counts=counts, results=results)
File without changes