mainsequence 2.0.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 (110) hide show
  1. mainsequence/__init__.py +0 -0
  2. mainsequence/__main__.py +9 -0
  3. mainsequence/cli/__init__.py +1 -0
  4. mainsequence/cli/api.py +157 -0
  5. mainsequence/cli/cli.py +442 -0
  6. mainsequence/cli/config.py +78 -0
  7. mainsequence/cli/ssh_utils.py +126 -0
  8. mainsequence/client/__init__.py +17 -0
  9. mainsequence/client/base.py +431 -0
  10. mainsequence/client/data_sources_interfaces/__init__.py +0 -0
  11. mainsequence/client/data_sources_interfaces/duckdb.py +1468 -0
  12. mainsequence/client/data_sources_interfaces/timescale.py +479 -0
  13. mainsequence/client/models_helpers.py +113 -0
  14. mainsequence/client/models_report_studio.py +412 -0
  15. mainsequence/client/models_tdag.py +2276 -0
  16. mainsequence/client/models_vam.py +1983 -0
  17. mainsequence/client/utils.py +387 -0
  18. mainsequence/dashboards/__init__.py +0 -0
  19. mainsequence/dashboards/streamlit/__init__.py +0 -0
  20. mainsequence/dashboards/streamlit/assets/config.toml +12 -0
  21. mainsequence/dashboards/streamlit/assets/favicon.png +0 -0
  22. mainsequence/dashboards/streamlit/assets/logo.png +0 -0
  23. mainsequence/dashboards/streamlit/core/__init__.py +0 -0
  24. mainsequence/dashboards/streamlit/core/theme.py +212 -0
  25. mainsequence/dashboards/streamlit/pages/__init__.py +0 -0
  26. mainsequence/dashboards/streamlit/scaffold.py +220 -0
  27. mainsequence/instrumentation/__init__.py +7 -0
  28. mainsequence/instrumentation/utils.py +101 -0
  29. mainsequence/instruments/__init__.py +1 -0
  30. mainsequence/instruments/data_interface/__init__.py +10 -0
  31. mainsequence/instruments/data_interface/data_interface.py +361 -0
  32. mainsequence/instruments/instruments/__init__.py +3 -0
  33. mainsequence/instruments/instruments/base_instrument.py +85 -0
  34. mainsequence/instruments/instruments/bond.py +447 -0
  35. mainsequence/instruments/instruments/european_option.py +74 -0
  36. mainsequence/instruments/instruments/interest_rate_swap.py +217 -0
  37. mainsequence/instruments/instruments/json_codec.py +585 -0
  38. mainsequence/instruments/instruments/knockout_fx_option.py +146 -0
  39. mainsequence/instruments/instruments/position.py +475 -0
  40. mainsequence/instruments/instruments/ql_fields.py +239 -0
  41. mainsequence/instruments/instruments/vanilla_fx_option.py +107 -0
  42. mainsequence/instruments/pricing_models/__init__.py +0 -0
  43. mainsequence/instruments/pricing_models/black_scholes.py +49 -0
  44. mainsequence/instruments/pricing_models/bond_pricer.py +182 -0
  45. mainsequence/instruments/pricing_models/fx_option_pricer.py +90 -0
  46. mainsequence/instruments/pricing_models/indices.py +350 -0
  47. mainsequence/instruments/pricing_models/knockout_fx_pricer.py +209 -0
  48. mainsequence/instruments/pricing_models/swap_pricer.py +502 -0
  49. mainsequence/instruments/settings.py +175 -0
  50. mainsequence/instruments/utils.py +29 -0
  51. mainsequence/logconf.py +284 -0
  52. mainsequence/reportbuilder/__init__.py +0 -0
  53. mainsequence/reportbuilder/__main__.py +0 -0
  54. mainsequence/reportbuilder/examples/ms_template_report.py +706 -0
  55. mainsequence/reportbuilder/model.py +713 -0
  56. mainsequence/reportbuilder/slide_templates.py +532 -0
  57. mainsequence/tdag/__init__.py +8 -0
  58. mainsequence/tdag/__main__.py +0 -0
  59. mainsequence/tdag/config.py +129 -0
  60. mainsequence/tdag/data_nodes/__init__.py +12 -0
  61. mainsequence/tdag/data_nodes/build_operations.py +751 -0
  62. mainsequence/tdag/data_nodes/data_nodes.py +1292 -0
  63. mainsequence/tdag/data_nodes/persist_managers.py +812 -0
  64. mainsequence/tdag/data_nodes/run_operations.py +543 -0
  65. mainsequence/tdag/data_nodes/utils.py +24 -0
  66. mainsequence/tdag/future_registry.py +25 -0
  67. mainsequence/tdag/utils.py +40 -0
  68. mainsequence/virtualfundbuilder/__init__.py +45 -0
  69. mainsequence/virtualfundbuilder/__main__.py +235 -0
  70. mainsequence/virtualfundbuilder/agent_interface.py +77 -0
  71. mainsequence/virtualfundbuilder/config_handling.py +86 -0
  72. mainsequence/virtualfundbuilder/contrib/__init__.py +0 -0
  73. mainsequence/virtualfundbuilder/contrib/apps/__init__.py +8 -0
  74. mainsequence/virtualfundbuilder/contrib/apps/etf_replicator_app.py +164 -0
  75. mainsequence/virtualfundbuilder/contrib/apps/generate_report.py +292 -0
  76. mainsequence/virtualfundbuilder/contrib/apps/load_external_portfolio.py +107 -0
  77. mainsequence/virtualfundbuilder/contrib/apps/news_app.py +437 -0
  78. mainsequence/virtualfundbuilder/contrib/apps/portfolio_report_app.py +91 -0
  79. mainsequence/virtualfundbuilder/contrib/apps/portfolio_table.py +95 -0
  80. mainsequence/virtualfundbuilder/contrib/apps/run_named_portfolio.py +45 -0
  81. mainsequence/virtualfundbuilder/contrib/apps/run_portfolio.py +40 -0
  82. mainsequence/virtualfundbuilder/contrib/apps/templates/base.html +147 -0
  83. mainsequence/virtualfundbuilder/contrib/apps/templates/report.html +77 -0
  84. mainsequence/virtualfundbuilder/contrib/data_nodes/__init__.py +5 -0
  85. mainsequence/virtualfundbuilder/contrib/data_nodes/external_weights.py +61 -0
  86. mainsequence/virtualfundbuilder/contrib/data_nodes/intraday_trend.py +149 -0
  87. mainsequence/virtualfundbuilder/contrib/data_nodes/market_cap.py +310 -0
  88. mainsequence/virtualfundbuilder/contrib/data_nodes/mock_signal.py +78 -0
  89. mainsequence/virtualfundbuilder/contrib/data_nodes/portfolio_replicator.py +269 -0
  90. mainsequence/virtualfundbuilder/contrib/prices/__init__.py +1 -0
  91. mainsequence/virtualfundbuilder/contrib/prices/data_nodes.py +810 -0
  92. mainsequence/virtualfundbuilder/contrib/prices/utils.py +11 -0
  93. mainsequence/virtualfundbuilder/contrib/rebalance_strategies/__init__.py +1 -0
  94. mainsequence/virtualfundbuilder/contrib/rebalance_strategies/rebalance_strategies.py +313 -0
  95. mainsequence/virtualfundbuilder/data_nodes.py +637 -0
  96. mainsequence/virtualfundbuilder/enums.py +23 -0
  97. mainsequence/virtualfundbuilder/models.py +282 -0
  98. mainsequence/virtualfundbuilder/notebook_handling.py +42 -0
  99. mainsequence/virtualfundbuilder/portfolio_interface.py +272 -0
  100. mainsequence/virtualfundbuilder/resource_factory/__init__.py +0 -0
  101. mainsequence/virtualfundbuilder/resource_factory/app_factory.py +170 -0
  102. mainsequence/virtualfundbuilder/resource_factory/base_factory.py +238 -0
  103. mainsequence/virtualfundbuilder/resource_factory/rebalance_factory.py +101 -0
  104. mainsequence/virtualfundbuilder/resource_factory/signal_factory.py +183 -0
  105. mainsequence/virtualfundbuilder/utils.py +381 -0
  106. mainsequence-2.0.0.dist-info/METADATA +105 -0
  107. mainsequence-2.0.0.dist-info/RECORD +110 -0
  108. mainsequence-2.0.0.dist-info/WHEEL +5 -0
  109. mainsequence-2.0.0.dist-info/licenses/LICENSE +40 -0
  110. mainsequence-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,585 @@
1
+ # src/instruments/json_codec.py
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from typing import Any, Dict, Optional, Union
6
+ import QuantLib as ql
7
+ import inspect # <-- ADD
8
+
9
+ from mainsequence.instruments.pricing_models.indices import get_index as _index_by_name
10
+ import hashlib
11
+ # ----------------------------- ql.Period -------------------------------------
12
+
13
+ _UNITS_TO_SHORT = {
14
+ ql.Days: "D",
15
+ ql.Weeks: "W",
16
+ ql.Months: "M",
17
+ ql.Years: "Y",
18
+ }
19
+
20
+ def period_to_json(p: Optional[Union[str, ql.Period]]) -> Optional[str]:
21
+ """
22
+ Encode a QuantLib Period as a compact string like '28D', '3M', '6M', '2Y'.
23
+ Accepts strings and passes them through (idempotent).
24
+ """
25
+ if p is None:
26
+ return None
27
+ if isinstance(p, ql.Period):
28
+ return f"{p.length()}{_UNITS_TO_SHORT[p.units()]}"
29
+ return str(p)
30
+
31
+ def period_from_json(v: Optional[Union[str, ql.Period]]) -> Optional[ql.Period]:
32
+ """Decode strings like '28D', '3M' into ql.Period; pass ql.Period through."""
33
+ if v is None or isinstance(v, ql.Period):
34
+ return v
35
+ return ql.Period(str(v))
36
+
37
+
38
+ # ----------------------------- ql.DayCounter ---------------------------------
39
+
40
+ # Prefer explicit enumerations you actually use in this codebase.
41
+ _DAYCOUNT_FACTORIES = {
42
+ "Actual360": lambda: ql.Actual360(),
43
+ "Actual365Fixed": lambda: ql.Actual365Fixed(),
44
+ # Default to USA when we can't introspect Thirty360 convention via SWIG.
45
+ "Thirty360": lambda: ql.Thirty360(ql.Thirty360.USA),
46
+ "Thirty360_USA": lambda: ql.Thirty360(ql.Thirty360.USA),
47
+ "Thirty360_BondBasis": lambda: ql.Thirty360(ql.Thirty360.BondBasis),
48
+ "Thirty360_European": lambda: ql.Thirty360(ql.Thirty360.European),
49
+ "Thirty360_ISMA": lambda: ql.Thirty360(ql.Thirty360.ISMA),
50
+ "Thirty360_ISDA": lambda: ql.Thirty360(ql.Thirty360.ISDA),
51
+ }
52
+
53
+ def daycount_to_json(dc: ql.DayCounter) -> str:
54
+ """Encode common DayCounters to a stable string token."""
55
+ if isinstance(dc, ql.Actual360):
56
+ return "Actual360"
57
+ if isinstance(dc, ql.Actual365Fixed):
58
+ return "Actual365Fixed"
59
+ if isinstance(dc, ql.Thirty360):
60
+ # SWIG doesn't expose convention reliably; default to USA in JSON.
61
+ return "Thirty360_USA"
62
+ # Fallback to class name (caller should ensure a known value)
63
+ return dc.__class__.__name__
64
+
65
+ def daycount_from_json(v: Union[str, ql.DayCounter]) -> ql.DayCounter:
66
+ """Decode from a string token back to a DayCounter instance."""
67
+ if isinstance(v, ql.DayCounter):
68
+ return v
69
+ key = str(v)
70
+ factory = _DAYCOUNT_FACTORIES.get(key)
71
+ if not factory and key == "Thirty360":
72
+ factory = _DAYCOUNT_FACTORIES["Thirty360"]
73
+ if not factory:
74
+ raise ValueError(f"Unsupported day_count '{key}'")
75
+ return factory()
76
+
77
+
78
+ # ----------------------------- ql.Calendar -----------------------------------
79
+ # We standardize on: {"name": "<QuantLib class name>", "market": <int optional>}
80
+ # Example: {"name": "Mexico"}, {"name": "UnitedStates", "market": 1}
81
+
82
+ def _build_calendar_display_factory() -> Dict[str, callable]:
83
+ """
84
+ Build a mapping: display name (Calendar::name()) -> zero-arg callable that
85
+ constructs a concrete ql.Calendar. Handles classes with Market enums too.
86
+ """
87
+ factory: Dict[str, callable] = {}
88
+
89
+ def _try_register(ctor):
90
+ try:
91
+ inst = ctor()
92
+ disp = inst.name()
93
+ factory.setdefault(disp, ctor)
94
+ return True
95
+ except Exception:
96
+ return False
97
+
98
+ for _, cls in inspect.getmembers(ql, predicate=inspect.isclass):
99
+ try:
100
+ if not issubclass(cls, ql.Calendar) or cls is ql.Calendar:
101
+ continue
102
+ except TypeError:
103
+ continue
104
+
105
+ # Case A: no-arg constructor (TARGET, Mexico, Turkey, ...)
106
+ _try_register(lambda c=cls: c())
107
+
108
+ # Case B: int-valued attributes (legacy Market enums on the class)
109
+ for attr_name, attr_val in inspect.getmembers(cls):
110
+ if attr_name.startswith("_"):
111
+ continue
112
+ if isinstance(attr_val, int):
113
+ _try_register(lambda c=cls, e=attr_val: c(e))
114
+
115
+ # Case C: nested Market enum classes (common style)
116
+ for attr_name, attr_val in inspect.getmembers(cls):
117
+ if not inspect.isclass(attr_val):
118
+ continue
119
+ if "market" in attr_name.lower():
120
+ for mname, mval in inspect.getmembers(attr_val):
121
+ if mname.startswith("_"):
122
+ continue
123
+ if isinstance(mval, int):
124
+ _try_register(lambda c=cls, e=mval: c(e))
125
+
126
+ return factory
127
+
128
+ # Build once + case-insensitive mirror
129
+ _CAL_DISP_FACTORY: Dict[str, callable] = _build_calendar_display_factory()
130
+ _CAL_DISP_FACTORY_CI: Dict[str, callable] = {k.casefold(): v for k, v in _CAL_DISP_FACTORY.items()}
131
+
132
+ def _calendar_from_display_name(display: str) -> ql.Calendar:
133
+ ctor = _CAL_DISP_FACTORY.get(display) or _CAL_DISP_FACTORY_CI.get(display.casefold())
134
+ if ctor is None:
135
+ raise ValueError(
136
+ f"Unsupported calendar display name {display!r}. "
137
+ f"Available: " + ", ".join(sorted(_CAL_DISP_FACTORY.keys())[:20]) +
138
+ ("..." if len(_CAL_DISP_FACTORY) > 20 else "")
139
+ )
140
+ return ctor()
141
+
142
+ def _try_get_market(c: ql.Calendar) -> Optional[int]:
143
+ try:
144
+ return int(getattr(c, "market")())
145
+ except Exception:
146
+ return None
147
+
148
+ def _normalize_calendar_to_class_and_market(cal: ql.Calendar) -> Dict[str, Any]:
149
+ """
150
+ Return the canonical JSON dict {"name": <class name>, "market": <int?>}
151
+ even if 'cal' is a base Calendar proxy. We achieve this by re-instantiating
152
+ a derived calendar from Calendar::name() (display name) when needed.
153
+ """
154
+ cls_name = cal.__class__.__name__
155
+ # If SWIG gives a base proxy ('Calendar'), derive a concrete instance via display name
156
+ if cls_name == "Calendar":
157
+ try:
158
+ derived = _calendar_from_display_name(cal.name())
159
+ name = derived.__class__.__name__
160
+ market = _try_get_market(derived)
161
+ except Exception:
162
+ # Fall back to best-effort: use display name as a string name (non-canonical)
163
+ # but caller's calendar_from_json can still accept it as a display name.
164
+ return {"name": cal.name()}
165
+ else:
166
+ name = cls_name
167
+ market = _try_get_market(cal)
168
+
169
+ out: Dict[str, Any] = {"name": name}
170
+ if market is not None:
171
+ out["market"] = market
172
+ return out
173
+
174
+ def calendar_to_json(cal: ql.Calendar) -> Dict[str, Any]:
175
+ """
176
+ Encode a Calendar as canonical {"name": "<QuantLib class name>", "market": <int?>}.
177
+ Robust to base Calendar proxies returned by SWIG.
178
+ """
179
+ return _normalize_calendar_to_class_and_market(cal)
180
+
181
+ def _calendar_from_class_and_market(name: str, market: Optional[int]) -> ql.Calendar:
182
+ """
183
+ Construct a calendar from a QuantLib class name + optional market.
184
+ Tries nested Market enums, then plain int, then no-arg.
185
+ """
186
+ cls = getattr(ql, name, None)
187
+ if cls is None or not inspect.isclass(cls):
188
+ raise ValueError(f"Unsupported calendar class {name!r}")
189
+ # Try with market if provided
190
+ if market is not None:
191
+ # Most calendars expose a nested Market enum:
192
+ try:
193
+ enum_cls = getattr(cls, "Market")
194
+ return cls(enum_cls(int(market)))
195
+ except Exception:
196
+ pass
197
+ # Some accept a raw int:
198
+ try:
199
+ return cls(int(market))
200
+ except Exception:
201
+ pass
202
+ # Fallback: no-arg constructor
203
+ return cls()
204
+
205
+ def calendar_from_json(v: Union[Dict[str, Any], str, ql.Calendar]) -> ql.Calendar:
206
+ """
207
+ Decode dict or string into a Calendar instance. Accepts:
208
+ - {"name": "<class name>", "market": <int?>} (canonical)
209
+ - "<class name>" (canonical string form)
210
+ - {"name": "<display name>"} / "<display name>" (legacy interop)
211
+ """
212
+ if isinstance(v, ql.Calendar):
213
+ return v
214
+
215
+ # String input
216
+ if isinstance(v, str):
217
+ name = v
218
+ # First try class name
219
+ try:
220
+ return _calendar_from_class_and_market(name, None)
221
+ except Exception:
222
+ # Then try display name
223
+ return _calendar_from_display_name(name)
224
+
225
+ # Dict input
226
+ if isinstance(v, dict):
227
+ name = v.get("name")
228
+ if not name:
229
+ raise ValueError("Calendar dict must contain 'name'.")
230
+ market = v.get("market", None)
231
+
232
+ # Prefer class name path; if that fails, treat 'name' as display name.
233
+ try:
234
+ return _calendar_from_class_and_market(name, market)
235
+ except Exception:
236
+ return _calendar_from_display_name(name)
237
+
238
+ raise TypeError(f"Cannot decode calendar from {type(v).__name__}")
239
+
240
+ # ----------------------------- Business Day Convention helpers --------------
241
+
242
+ _BDC_TO_STR = {
243
+ ql.Following: "Following",
244
+ ql.ModifiedFollowing: "ModifiedFollowing",
245
+ ql.Preceding: "Preceding",
246
+ ql.ModifiedPreceding: "ModifiedPreceding",
247
+ ql.Unadjusted: "Unadjusted",
248
+ ql.HalfMonthModifiedFollowing: "HalfMonthModifiedFollowing",
249
+ ql.Nearest: "Nearest",
250
+ }
251
+
252
+ _STR_TO_BDC = {v: k for k, v in _BDC_TO_STR.items()}
253
+
254
+ def bdc_to_json(bdc: int) -> Union[int, str]:
255
+ """Encode BusinessDayConvention as a stable string (falls back to int if unknown)."""
256
+ return _BDC_TO_STR.get(int(bdc), int(bdc))
257
+
258
+ def bdc_from_json(v: Union[int, str]) -> int:
259
+ """Decode a BusinessDayConvention from string or int."""
260
+ if isinstance(v, int):
261
+ return int(v)
262
+ if isinstance(v, str):
263
+ if v in _STR_TO_BDC:
264
+ return int(_STR_TO_BDC[v])
265
+ # accept enum name variants like 'Following' / 'following'
266
+ key = v[0].upper() + v[1:]
267
+ if key in _STR_TO_BDC:
268
+ return int(_STR_TO_BDC[key])
269
+ try:
270
+ return int(v)
271
+ except Exception:
272
+ pass
273
+ raise ValueError(f"Unsupported business day convention '{v}'")
274
+ # ----------------------------- Date utils -----------------------------------
275
+
276
+ def _ql_date_to_iso(d: ql.Date) -> str:
277
+ return f"{d.year():04d}-{int(d.month()):02d}-{d.dayOfMonth():02d}"
278
+
279
+ def _iso_to_ql_date(s: str) -> ql.Date:
280
+ y, m, d = (int(x) for x in s.split("-"))
281
+ return ql.Date(d, m, y)
282
+ # ----------------------------- Schedule codec --------------------------------
283
+
284
+ # Optional rule mapping (used only if you ever store rule-based schedules)
285
+ _RULE_TO_STR = {
286
+ ql.DateGeneration.Backward: "Backward",
287
+ ql.DateGeneration.Forward: "Forward",
288
+ ql.DateGeneration.Zero: "Zero",
289
+ ql.DateGeneration.Twentieth: "Twentieth",
290
+ ql.DateGeneration.TwentiethIMM: "TwentiethIMM",
291
+ ql.DateGeneration.ThirdWednesday: "ThirdWednesday",
292
+ ql.DateGeneration.OldCDS: "OldCDS",
293
+ ql.DateGeneration.CDS: "CDS",
294
+ ql.DateGeneration.CDS2015: "CDS2015",
295
+ }
296
+ _STR_TO_RULE = {v: k for k, v in _RULE_TO_STR.items() if v != 9999}
297
+
298
+ def schedule_to_json(s: Optional[ql.Schedule]) -> Optional[Dict[str, Any]]:
299
+ """
300
+ Encode a QuantLib Schedule. We always include the explicit 'dates' array so
301
+ round-tripping never depends on rule/tenor reconstruction.
302
+ """
303
+ if s is None:
304
+ return None
305
+
306
+ # Extract dates as ISO strings
307
+ try:
308
+ dates_iso = [_ql_date_to_iso(d) for d in list(s.dates())]
309
+ except Exception:
310
+ # Some SWIG builds require iterating via size()/date(i)
311
+ dates_iso = [_ql_date_to_iso(s.date(i)) for i in range(s.size())]
312
+
313
+ payload: Dict[str, Any] = {
314
+ "dates": dates_iso,
315
+ }
316
+
317
+ # Include helpful metadata when available (not required for decoding)
318
+ try:
319
+ payload["calendar"] = calendar_to_json(s.calendar())
320
+ except Exception:
321
+ pass
322
+ try:
323
+ payload["business_day_convention"] = bdc_to_json(int(s.businessDayConvention()))
324
+ except Exception:
325
+ pass
326
+ try:
327
+ payload["termination_business_day_convention"] = bdc_to_json(int(s.terminationDateConvention()))
328
+ except Exception:
329
+ pass
330
+ try:
331
+ payload["end_of_month"] = bool(s.endOfMonth())
332
+ except Exception:
333
+ pass
334
+ try:
335
+ payload["tenor"] = period_to_json(s.tenor())
336
+ except Exception:
337
+ pass
338
+ try:
339
+ rule = s.rule()
340
+ payload["rule"] = _RULE_TO_STR.get(int(rule), int(rule))
341
+ except Exception:
342
+ pass
343
+
344
+ return payload
345
+
346
+
347
+ def schedule_from_json(v: Union[None, ql.Schedule, Dict[str, Any], List[str], List[ql.Date]]) -> Optional[ql.Schedule]:
348
+ """
349
+ Decode a schedule. Supported forms:
350
+ - None
351
+ - ql.Schedule (returned as-is)
352
+ - {"dates":[...], "calendar":{...}, "business_day_convention":"Following", ...}
353
+ - ["2025-01-15", "2025-02-12", ...] (ISO date list)
354
+ - [ql.Date(...), ...] (rare, but supported)
355
+ For explicit-date payloads we build: ql.Schedule(DateVector, calendar, bdc).
356
+ """
357
+ if v is None or isinstance(v, ql.Schedule):
358
+ return v
359
+
360
+ # List forms (explicit dates)
361
+ if isinstance(v, list):
362
+ if not v:
363
+ return ql.Schedule() # empty schedule
364
+ # Accept ISO strings or ql.Date
365
+ date_vec = ql.DateVector()
366
+ if isinstance(v[0], ql.Date):
367
+ for d in v:
368
+ date_vec.push_back(d)
369
+ else:
370
+ for s in v:
371
+ date_vec.push_back(_iso_to_ql_date(str(s)))
372
+ # Defaults are conservative; your model field carries calendar/bdc anyway
373
+ cal = ql.NullCalendar()
374
+ bdc = ql.Following
375
+ return ql.Schedule(date_vec, cal, bdc)
376
+
377
+ # Dict form
378
+ if isinstance(v, dict):
379
+ dates = v.get("dates")
380
+ if dates:
381
+ date_vec = ql.DateVector()
382
+ # Accept both ISO strings and ql.Date in the list
383
+ for x in dates:
384
+ if isinstance(x, ql.Date):
385
+ date_vec.push_back(x)
386
+ else:
387
+ date_vec.push_back(_iso_to_ql_date(str(x)))
388
+ cal_json = v.get("calendar", {"name": "NullCalendar"})
389
+ cal = calendar_from_json(cal_json)
390
+ bdc_json = v.get("business_day_convention", "Following")
391
+ bdc = bdc_from_json(bdc_json)
392
+ return ql.Schedule(date_vec, cal, bdc)
393
+
394
+ # Optional: support rule-based reconstruction if no explicit dates provided
395
+ start = v.get("start")
396
+ end = v.get("end")
397
+ tenor = v.get("tenor")
398
+ if start and end and tenor:
399
+ cal = calendar_from_json(v.get("calendar", {"name": "TARGET"}))
400
+ bdc = bdc_from_json(v.get("business_day_convention", "Following"))
401
+ term_bdc = bdc_from_json(v.get("termination_business_day_convention", bdc))
402
+ rule_val = v.get("rule", "Forward")
403
+ if isinstance(rule_val, str):
404
+ rule = _STR_TO_RULE.get(rule_val, ql.DateGeneration.Forward)
405
+ else:
406
+ rule = int(rule_val)
407
+ eom = bool(v.get("end_of_month", False))
408
+ first_date = v.get("first_date")
409
+ next_to_last = v.get("next_to_last_date")
410
+
411
+ sd = _iso_to_ql_date(str(start))
412
+ ed = _iso_to_ql_date(str(end))
413
+ ten = ql.Period(str(tenor))
414
+ fd = _iso_to_ql_date(str(first_date)) if first_date else ql.Date()
415
+ ntl = _iso_to_ql_date(str(next_to_last)) if next_to_last else ql.Date()
416
+
417
+ return ql.Schedule(sd, ed, ten, cal, bdc, term_bdc, rule, eom, fd, ntl)
418
+
419
+ raise TypeError(f"Cannot decode Schedule from {type(v).__name__}")
420
+
421
+ # ----------------------------- ql.IborIndex ----------------------------------
422
+
423
+ def ibor_to_json(idx: Optional[ql.IborIndex]) -> Optional[Dict[str, Any]]:
424
+ """
425
+ Encode an IborIndex without trying to serialize the curve handle:
426
+ {"family": "USDLibor", "tenor": "3M"} or {"family":"Euribor","tenor":"6M"}.
427
+ """
428
+ if idx is None:
429
+ return None
430
+ name_upper = idx.name().upper()
431
+ if "TIIE" in name_upper or "MXNTIIE" in name_upper:
432
+ return {"family": "TIIE-28D", "tenor": "28D"}
433
+
434
+ family = getattr(idx, "familyName", lambda: None)() or idx.name()
435
+ try:
436
+ ten = period_to_json(idx.tenor())
437
+ except Exception:
438
+ ten = None
439
+ out = {"family": str(family)}
440
+ if ten:
441
+ out["tenor"] = ten
442
+ return out
443
+
444
+ def _construct_ibor(family: str, tenor: str) -> ql.IborIndex:
445
+ p = ql.Period(tenor)
446
+ # Common families—extend as needed
447
+ if hasattr(ql, "USDLibor") and family == "USDLibor":
448
+ return ql.USDLibor(p, ql.YieldTermStructureHandle())
449
+ if hasattr(ql, "Euribor") and family == "Euribor":
450
+ return ql.Euribor(p, ql.YieldTermStructureHandle())
451
+ # Generic fallback if QuantLib exposes the family by name
452
+ ctor = getattr(ql, family, None)
453
+ if ctor:
454
+ try:
455
+ return ctor(p, ql.YieldTermStructureHandle())
456
+ except TypeError:
457
+ return ctor(p)
458
+ # TIIE is not a built-in IborIndex; TIIE swaps build their own index later.
459
+ raise ValueError(f"Unsupported Ibor index family '{family}'")
460
+
461
+ def ibor_from_json(v: Union[None, str, Dict[str, Any], ql.IborIndex]) -> Optional[ql.IborIndex]:
462
+ """
463
+ Decode from JSON into a ql.IborIndex, delegating to the central factory when possible.
464
+ Falls back to legacy parsing for 'USDLibor3M' / 'Euribor6M' styles.
465
+ NOTE: TIIE for swaps remains handled in TIIESwap; this function does not change that flow.
466
+ """
467
+ if v is None or isinstance(v, ql.IborIndex):
468
+ return v
469
+
470
+ # 1) String form: try the factory first (supports: 'EURIBOR_6M', 'USD_LIBOR_3M', 'SOFOR'→'SOFR', etc.)
471
+ if isinstance(v, str):
472
+ if _index_by_name is not None:
473
+ try:
474
+ idx = _index_by_name(v)
475
+ # For instruments here we expect an IborIndex; ignore overnight-only results.
476
+ if isinstance(idx, ql.IborIndex):
477
+ return idx
478
+ except Exception:
479
+ pass # fall back to legacy parser
480
+ # Legacy fallback: 'USDLibor3M' / 'Euribor6M' / 'USDLibor' (defaults 3M)
481
+ name = v
482
+ tenor = "3M"
483
+ for t in ("1M", "3M", "6M", "12M", "1Y", "28D"):
484
+ if name.endswith(t):
485
+ tenor = t
486
+ family = name[:-len(t)]
487
+ break
488
+ else:
489
+ family = name
490
+ return _construct_ibor(family, tenor)
491
+
492
+ # 2) Dict form: try the factory if we have family/tenor; else fallback
493
+ if isinstance(v, dict):
494
+ family = v.get("family") or v.get("name")
495
+ tenor = v.get("tenor", "3M")
496
+ if not family:
497
+ return None
498
+ if _index_by_name is not None:
499
+ try:
500
+ # Accept either {'family':'Euribor','tenor':'6M'} or {'name':'USD_LIBOR','tenor':'3M'}
501
+ candidate = f"{family}_{tenor}" if tenor else family
502
+ idx = _index_by_name(candidate)
503
+ if isinstance(idx, ql.IborIndex):
504
+ return idx
505
+ except Exception as e:
506
+ raise e
507
+ return _construct_ibor(family, tenor)
508
+
509
+ raise TypeError(f"Cannot decode IborIndex from {type(v).__name__}")
510
+
511
+ def _fix_schedule_calendar_from_top_level(data: dict) -> dict:
512
+ try:
513
+ sched = data.get("schedule")
514
+ top_cal = data.get("calendar")
515
+ if isinstance(sched, dict) and isinstance(sched.get("calendar"), dict):
516
+ if sched["calendar"].get("name") == "Calendar" and isinstance(top_cal, dict) and top_cal.get("name"):
517
+ sched["calendar"] = {"name": top_cal["name"]}
518
+ except Exception:
519
+ pass
520
+ return data
521
+ # ----------------------------- Generic mixin ---------------------------------
522
+
523
+ class JSONMixin:
524
+ """
525
+ Mixin to give Pydantic models convenient JSON round-trip helpers.
526
+ Uses Pydantic's JSON mode (so field_serializers are honored).
527
+ """
528
+ def to_json_dict(self) -> Dict[str, Any]:
529
+ return self.model_dump(mode="json")
530
+
531
+ def to_json(self, **json_kwargs: Any) -> str:
532
+ return json.dumps(self.to_json_dict(), default=str, **json_kwargs)
533
+
534
+ @classmethod
535
+ def from_json_dict(cls, data: Dict[str, Any]):
536
+ data = _fix_schedule_calendar_from_top_level(data)
537
+ return cls.model_validate(data)
538
+
539
+ @classmethod
540
+ def from_json(cls, payload: Union[str, bytes, Dict[str, Any]]): # <-- broadened
541
+ if isinstance(payload, dict):
542
+ return cls.from_json_dict(payload)
543
+ if isinstance(payload, (bytes, bytearray)):
544
+ payload = payload.decode("utf-8")
545
+ return cls.from_json_dict(json.loads(payload))
546
+
547
+
548
+ def to_canonical_json(self) -> str:
549
+ """
550
+ Canonical JSON used for hashing:
551
+ - keys sorted
552
+ - no extra whitespace
553
+ - UTF-8 friendly (no ASCII escaping)
554
+ """
555
+ data = self.to_json_dict()
556
+ return json.dumps(data, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
557
+
558
+ def content_hash(self, algorithm: str = "sha256") -> str:
559
+ """
560
+ Hash of the canonical JSON representation.
561
+ `algorithm` must be a hashlib-supported name (e.g., 'sha256', 'sha1', 'md5', 'blake2b').
562
+ """
563
+ s = self.to_canonical_json().encode("utf-8")
564
+ h = hashlib.new(algorithm)
565
+ h.update(s)
566
+ return h.hexdigest()
567
+
568
+ @classmethod
569
+ def hash_payload(cls, payload: Union[str, bytes, Dict[str, Any]], algorithm: str = "sha256") -> str:
570
+ """
571
+ Hash an arbitrary JSON payload (str/bytes/dict) using the same canonicalization.
572
+ Useful if you have serialized JSON already and want the same digest.
573
+ """
574
+ if isinstance(payload, (bytes, bytearray)):
575
+ payload = payload.decode("utf-8")
576
+ if isinstance(payload, str):
577
+ obj = json.loads(payload)
578
+ elif isinstance(payload, dict):
579
+ obj = payload
580
+ else:
581
+ raise TypeError(f"Unsupported payload type: {type(payload).__name__}")
582
+ s = json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
583
+ h = hashlib.new(algorithm)
584
+ h.update(s)
585
+ return h.hexdigest()