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
|
@@ -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
|