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,132 @@
1
+ """
2
+ Factory for FuturesProduct instances.
3
+
4
+ Semantics
5
+ ---------
6
+ - This factory provides *interning*: at most one FuturesProduct instance per
7
+ product_id within this process.
8
+ - It may be initialised from CSV for convenience.
9
+ - It also supports construction from a typed dict payload (useful for tests),
10
+ but the preferred path is to parse into FuturesProduct and then intern.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import threading
16
+ from typing import Any, TypedDict, cast
17
+
18
+ from mxm.refdata.models.currencies import Currency
19
+ from mxm.refdata.models.periods import PeriodType
20
+ from mxm.refdata.models.products.futures_product import FuturesProduct, SettlementMethod
21
+ from mxm.refdata.models.units import ProductUnit
22
+ from mxm.refdata.parsing.futures_products_from_csv import parse_futures_products_csv
23
+
24
+
25
+ class FuturesProductSpec(TypedDict, total=False):
26
+ """
27
+ Typed input payload for creating FuturesProduct instances.
28
+
29
+ Intended primarily for tests/fixtures and controlled programmatic creation.
30
+
31
+ Notes:
32
+ - This is *not* persisted format.
33
+ - period_types must already be canonical: tuple[PeriodType, ...].
34
+ """
35
+
36
+ product_id: str
37
+ venue: str
38
+ description: str
39
+ currency: Currency
40
+ unit: ProductUnit
41
+ contract_size: float
42
+ valid_period_rule: str
43
+ listing_rule: str
44
+ period_types: tuple[PeriodType, ...]
45
+ settlement: SettlementMethod
46
+ last_trading_rule: str
47
+ expiry_rule: str
48
+ trading_calendar: str
49
+ trading_hours: str | None
50
+ tick_size: float | None
51
+ tick_value: float | None
52
+ initial_margin: float | None
53
+ maintenance_margin: float | None
54
+
55
+
56
+ class FuturesProductFactory:
57
+ """Factory / interning cache for FuturesProduct instances."""
58
+
59
+ _instance: FuturesProductFactory | None = None
60
+ _lock = threading.Lock()
61
+ _cache: dict[str, FuturesProduct]
62
+
63
+ def __new__(cls) -> FuturesProductFactory:
64
+ with cls._lock:
65
+ if cls._instance is None:
66
+ cls._instance = super().__new__(cls)
67
+ cls._instance._cache = {} # instance-owned cache
68
+ return cls._instance
69
+
70
+ # -------------------------
71
+ # Core cache operations
72
+ # -------------------------
73
+
74
+ def intern(self, product: FuturesProduct) -> FuturesProduct:
75
+ """
76
+ Return the canonical FuturesProduct instance for product.product_id.
77
+ """
78
+ cached = self._cache.get(product.product_id)
79
+ if cached is None:
80
+ self._cache[product.product_id] = product
81
+ return product
82
+ return cached
83
+
84
+ def get(self, product_id: str) -> FuturesProduct | None:
85
+ """Return product if present, else None."""
86
+ return self._cache.get(product_id)
87
+
88
+ def require(self, product_id: str) -> FuturesProduct:
89
+ """Return product if present, else raise."""
90
+ p = self._cache.get(product_id)
91
+ if p is None:
92
+ raise KeyError(f"Unknown product_id: {product_id!r}")
93
+ return p
94
+
95
+ def all(self) -> list[FuturesProduct]:
96
+ """Return all cached products (order not guaranteed)."""
97
+ return list(self._cache.values())
98
+
99
+ def clear(self) -> None:
100
+ """Clear the cache (useful in tests)."""
101
+ self._cache.clear()
102
+
103
+ # -------------------------
104
+ # Construction helpers
105
+ # -------------------------
106
+
107
+ def create_from_spec(self, spec: FuturesProductSpec) -> FuturesProduct:
108
+ """
109
+ Create (and intern) a FuturesProduct from a typed spec payload.
110
+
111
+ This is useful for tests, fixtures, and programmatic creation.
112
+
113
+ Requirements:
114
+ - spec must include product_id and all required FuturesProduct fields.
115
+ - period_types must be a tuple[PeriodType, ...] (canonical).
116
+ """
117
+ if "product_id" not in spec or not spec["product_id"]:
118
+ raise ValueError("FuturesProductSpec requires non-empty 'product_id'")
119
+
120
+ # TypedDict is total=False, so we need to trust the caller for required keys.
121
+ # This is intended for controlled/test usage.
122
+ product = FuturesProduct(**cast(dict[str, Any], spec)) # safe boundary
123
+ return self.intern(product)
124
+
125
+ @classmethod
126
+ def initialise_from_csv(cls, csv_file_path: str) -> list[FuturesProduct]:
127
+ """
128
+ Load products from CSV and intern them into the factory cache.
129
+ """
130
+ products = parse_futures_products_csv(csv_file_path)
131
+ factory = cls()
132
+ return [factory.intern(p) for p in products]
@@ -0,0 +1,315 @@
1
+ """Module to manage the creation of Period objects with flyweight pattern."""
2
+
3
+ import threading
4
+ from collections.abc import Callable, Mapping
5
+ from datetime import date, timedelta
6
+ from enum import Enum
7
+ from types import MappingProxyType
8
+ from typing import ClassVar
9
+
10
+ from mxm.refdata.models.months import Month
11
+ from mxm.refdata.models.periods import Period, PeriodType
12
+ from mxm.refdata.utils.regex_patterns import PERIOD_TYPE_PARSING_MAP
13
+
14
+
15
+ class HandleCurrentPeriod(Enum):
16
+ """Options for handling the current period when generating a list of periods."""
17
+
18
+ INCLUDE = "include"
19
+ EXCLUDE = "exclude"
20
+
21
+
22
+ class HandlePartialEndPeriod(Enum):
23
+ """Options for handling a partial end period when generating a list of periods."""
24
+
25
+ INCLUDE = "include"
26
+ EXCLUDE = "exclude"
27
+
28
+
29
+ class PeriodFactory:
30
+ """Factory class to manage the creation of Period objects with flyweight pattern."""
31
+
32
+ _instance = None # Singleton instance
33
+ _period_cache: ClassVar[dict[str, Period]] = {}
34
+ _lock = threading.Lock() # Lock for thread safety
35
+
36
+ date_to_period_id_map: Mapping[PeriodType, Callable[[date], str]] = (
37
+ MappingProxyType(
38
+ {
39
+ PeriodType.YEAR: lambda d: f"{d.year}",
40
+ PeriodType.MONTH: lambda d: d.strftime("%b-%Y"),
41
+ PeriodType.QUARTER: lambda d: f"{d.year}-Q{(d.month - 1) // 3 + 1}",
42
+ PeriodType.WEEK: lambda d: f"{d.year}-W{d.isocalendar()[1]}",
43
+ },
44
+ )
45
+ )
46
+
47
+ def __new__(cls) -> "PeriodFactory":
48
+ """Ensures that only one instance of PeriodFactory is created (thread-safe singleton)."""
49
+ with cls._lock:
50
+ if cls._instance is None:
51
+ cls._instance = super().__new__(cls)
52
+ return cls._instance
53
+
54
+ @classmethod
55
+ def get_period_by_id(cls, period_id: str) -> Period:
56
+ """Get a Period object by canonical period_id."""
57
+ if period_id not in cls._period_cache:
58
+ period_type = cls._period_type_from_period_id(period_id)
59
+ first_day, last_day = cls.calculate_period_dates(period_id, period_type)
60
+ cls._period_cache[period_id] = Period(
61
+ period_id=period_id,
62
+ period_type=period_type,
63
+ first_date=first_day,
64
+ last_date=last_day,
65
+ )
66
+
67
+ return cls._period_cache[period_id]
68
+
69
+ @classmethod
70
+ def get_period_by_date(
71
+ cls,
72
+ date_obj: date,
73
+ period_type: PeriodType,
74
+ ) -> Period:
75
+ """Get a Period object containing a date for the given period type."""
76
+ period = cls._create_from_date(date_obj, period_type)
77
+
78
+ if period.period_id not in cls._period_cache:
79
+ cls._period_cache[period.period_id] = period
80
+
81
+ return cls._period_cache[period.period_id]
82
+
83
+ @classmethod
84
+ def get_period(
85
+ cls,
86
+ period_id: str | None = None,
87
+ date_obj: date | None = None,
88
+ period_type: PeriodType | None = None,
89
+ ) -> Period:
90
+ """Get a Period object by id or by date and period type.
91
+
92
+ Prefer get_period_by_id or get_period_by_date in new code.
93
+ """
94
+ if period_id is not None:
95
+ return cls.get_period_by_id(period_id)
96
+
97
+ if date_obj is not None and period_type is not None:
98
+ return cls.get_period_by_date(date_obj, period_type)
99
+
100
+ raise ValueError("Must provide either period_id or date_obj with period_type")
101
+
102
+ @classmethod
103
+ def _period_type_from_period_id(cls, period_id: str) -> PeriodType:
104
+ """Parse a period_id string and return the corresponding PeriodType."""
105
+ for period_type, pattern in PERIOD_TYPE_PARSING_MAP.items():
106
+ if pattern.match(period_id):
107
+ return period_type
108
+ raise ValueError(f"Unrecognized period_id format: {period_id}")
109
+
110
+ @classmethod
111
+ def _create_from_date(cls, date_obj: date, period_type: PeriodType) -> Period:
112
+ """Create a Period from a specific date and period type."""
113
+ if period_type not in cls.date_to_period_id_map:
114
+ message = f"Unsupported period_type: {period_type}"
115
+ raise ValueError(message)
116
+
117
+ period_id = cls.date_to_period_id_map[period_type](date_obj)
118
+ first_day, last_day = cls.calculate_period_dates(period_id, period_type)
119
+ return Period(
120
+ period_id=period_id,
121
+ period_type=period_type,
122
+ first_date=first_day,
123
+ last_date=last_day,
124
+ )
125
+
126
+ @classmethod
127
+ def calculate_period_dates(
128
+ cls, period_id: str, period_type: PeriodType
129
+ ) -> tuple[date, date]:
130
+ """Calculate the first and last date for the given period based on its type."""
131
+ if period_type == PeriodType.YEAR:
132
+ return cls._calculate_year_dates(period_id)
133
+ elif period_type == PeriodType.MONTH:
134
+ return cls._calculate_month_dates(period_id)
135
+ elif period_type == PeriodType.QUARTER:
136
+ return cls._calculate_quarter_dates(period_id)
137
+ elif period_type == PeriodType.WEEK:
138
+ return cls._calculate_week_dates(period_id)
139
+ else:
140
+ raise ValueError(f"Unsupported period type: {period_type}")
141
+
142
+ @staticmethod
143
+ def _calculate_year_dates(period_id: str) -> tuple[date, date]:
144
+ """Calculate the first and last date for a year."""
145
+ year = int(period_id)
146
+ return date(year, 1, 1), date(year, 12, 31)
147
+
148
+ @staticmethod
149
+ def _calculate_month_dates(period_id: str) -> tuple[date, date]:
150
+ """Calculate the first and last date for a month."""
151
+ month_str, year = period_id.split("-")
152
+ month = Month.from_str(month_str)
153
+ first_date = date(int(year), month.as_int, 1)
154
+ last_date = PeriodFactory._last_day_of_month(first_date)
155
+ return first_date, last_date
156
+
157
+ @staticmethod
158
+ def _calculate_quarter_dates(period_id: str) -> tuple[date, date]:
159
+ """Calculate the first and last date for a quarter."""
160
+ year, quarter = period_id.split("-Q")
161
+ start_month = (int(quarter) - 1) * 3 + 1
162
+ first_date = date(int(year), start_month, 1)
163
+ last_date = PeriodFactory._last_day_of_month(
164
+ date(int(year), start_month + 2, 1) # Third month of the quarter
165
+ )
166
+ return first_date, last_date
167
+
168
+ @staticmethod
169
+ def _calculate_week_dates(period_id: str) -> tuple[date, date]:
170
+ """Calculate the first and last date for a week."""
171
+ year, week = period_id.split("-W")
172
+ first_date = date.fromisocalendar(int(year), int(week), 1)
173
+ last_date = first_date + timedelta(days=6)
174
+ return first_date, last_date
175
+
176
+ @staticmethod
177
+ def _last_day_of_month(first_date: date) -> date:
178
+ """Calculate the last day of the month."""
179
+ next_month = first_date.replace(day=28) + timedelta(
180
+ days=4
181
+ ) # Move to next month
182
+ return next_month - timedelta(days=next_month.day)
183
+
184
+ @classmethod
185
+ def get_next_n_periods(
186
+ cls,
187
+ start_date: date,
188
+ period_type: PeriodType,
189
+ n: int,
190
+ handle_current_period: HandleCurrentPeriod = HandleCurrentPeriod.INCLUDE,
191
+ ) -> list[Period]:
192
+ """Generate the next n Period objects from a given start_date.
193
+
194
+ Args:
195
+ start_date (date): The date to start from.
196
+ period_type (PeriodType): The type of period (e.g., month, quarter).
197
+ n (int): Number of periods to generate.
198
+ handle_current_period (HandleCurrentPeriod): Whether to include the period containing start_date.
199
+
200
+ Returns:
201
+ list[Period]: A list of Period objects representing the next n periods.
202
+ """
203
+ periods: list[Period] = []
204
+
205
+ # Handle the current period
206
+ if handle_current_period == HandleCurrentPeriod.INCLUDE:
207
+ period = cls.get_period(date_obj=start_date, period_type=period_type)
208
+ periods.append(period)
209
+
210
+ current_date = cls.shift_date_by_n_periods(start_date, period_type, 1)
211
+
212
+ # Generate the remaining n - 1 periods
213
+ while len(periods) < n:
214
+ period = cls.get_period(date_obj=current_date, period_type=period_type)
215
+ periods.append(period)
216
+ current_date = cls.shift_date_by_n_periods(current_date, period_type, 1)
217
+
218
+ return periods
219
+
220
+ @classmethod
221
+ def shift_date_by_n_periods(
222
+ cls, start_date: date, period_type: "PeriodType", steps: int
223
+ ) -> date:
224
+ """Shift a date by a given number of periods in the specified PeriodType."""
225
+ if period_type == PeriodType.YEAR:
226
+ return cls._shift_years(start_date, steps)
227
+ elif period_type == PeriodType.MONTH:
228
+ return cls._shift_months(start_date, steps)
229
+ elif period_type == PeriodType.QUARTER:
230
+ return cls._shift_quarters(start_date, steps)
231
+ elif period_type == PeriodType.WEEK:
232
+ return cls._shift_weeks(start_date, steps)
233
+ else:
234
+ raise ValueError(f"Unsupported period type: {period_type}")
235
+
236
+ @staticmethod
237
+ def _shift_years(start_date: date, steps: int) -> date:
238
+ """Shift a date by a given number of years."""
239
+ return date(start_date.year + steps, start_date.month, start_date.day)
240
+
241
+ @staticmethod
242
+ def _shift_months(start_date: date, steps: int) -> date:
243
+ """Shift a date by a given number of months, preserving the day when possible."""
244
+ new_month = (start_date.month - 1 + steps) % 12 + 1
245
+ new_year = start_date.year + ((start_date.month - 1 + steps) // 12)
246
+ return date(new_year, new_month, 1)
247
+
248
+ @staticmethod
249
+ def _shift_quarters(start_date: date, steps: int) -> date:
250
+ """Shift a date by a given number of quarters."""
251
+ start_month = (start_date.month - 1) // 3 * 3 + 1
252
+ new_quarter_start_month = (start_month - 1 + steps * 3) % 12 + 1
253
+ new_year = start_date.year + ((start_month - 1 + steps * 3) // 12)
254
+ return date(new_year, new_quarter_start_month, 1)
255
+
256
+ @staticmethod
257
+ def _shift_weeks(start_date: date, steps: int) -> date:
258
+ """Shift a date by a given number of weeks."""
259
+ return start_date + timedelta(weeks=steps)
260
+
261
+ @classmethod
262
+ def shift_period_by_n(cls, period: Period, n: int) -> Period:
263
+ """
264
+ Shifts a given period by `n` instances of its period type.
265
+
266
+ Supports:
267
+ - **Months** (e.g., "Jun-2025" → "Jul-2025" for `n=1`).
268
+ - **Quarters** (e.g., "2025-Q2" → "2025-Q3" for `n=1`).
269
+ - **Years** (e.g., "2025" → "2026" for `n=1`).
270
+ - **Weeks** (e.g., "2025-W10" → "2025-W11" for `n=1`).
271
+
272
+ Args:
273
+ period (Period): The period instance to shift.
274
+ n (int): The number of periods to shift (can be negative for backward shifts).
275
+
276
+ Returns:
277
+ Period: The new shifted period instance.
278
+ """
279
+ # Shift the first date by `n` periods using the existing method
280
+ new_start_date = cls.shift_date_by_n_periods(
281
+ period.first_date, period.period_type, n
282
+ )
283
+
284
+ # Retrieve the new period instance based on the shifted date
285
+ return cls.get_period(date_obj=new_start_date, period_type=period.period_type)
286
+
287
+ @classmethod
288
+ def get_periods_in_range(
289
+ cls,
290
+ start_date: date,
291
+ end_date: date,
292
+ period_type: PeriodType,
293
+ handle_partial_end_period: HandlePartialEndPeriod = HandlePartialEndPeriod.EXCLUDE,
294
+ ) -> list[Period]:
295
+ """Generate a list of Period objects within a specified date range."""
296
+ if start_date > end_date:
297
+ message = "start_date must be earlier than end_date"
298
+ raise ValueError(message)
299
+
300
+ periods: list[Period] = []
301
+ current_date = start_date
302
+
303
+ while current_date <= end_date:
304
+ period = cls.get_period(date_obj=current_date, period_type=period_type)
305
+ if (
306
+ period.last_date > end_date
307
+ and handle_partial_end_period == HandlePartialEndPeriod.EXCLUDE
308
+ ):
309
+ break
310
+ periods.append(period)
311
+ current_date = cls.shift_date_by_n_periods(
312
+ period.first_date, period_type, 1
313
+ )
314
+
315
+ return periods