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.
- mainsequence/__init__.py +0 -0
- mainsequence/__main__.py +9 -0
- mainsequence/cli/__init__.py +1 -0
- mainsequence/cli/api.py +157 -0
- mainsequence/cli/cli.py +442 -0
- mainsequence/cli/config.py +78 -0
- mainsequence/cli/ssh_utils.py +126 -0
- mainsequence/client/__init__.py +17 -0
- mainsequence/client/base.py +431 -0
- mainsequence/client/data_sources_interfaces/__init__.py +0 -0
- mainsequence/client/data_sources_interfaces/duckdb.py +1468 -0
- mainsequence/client/data_sources_interfaces/timescale.py +479 -0
- mainsequence/client/models_helpers.py +113 -0
- mainsequence/client/models_report_studio.py +412 -0
- mainsequence/client/models_tdag.py +2276 -0
- mainsequence/client/models_vam.py +1983 -0
- mainsequence/client/utils.py +387 -0
- mainsequence/dashboards/__init__.py +0 -0
- mainsequence/dashboards/streamlit/__init__.py +0 -0
- mainsequence/dashboards/streamlit/assets/config.toml +12 -0
- mainsequence/dashboards/streamlit/assets/favicon.png +0 -0
- mainsequence/dashboards/streamlit/assets/logo.png +0 -0
- mainsequence/dashboards/streamlit/core/__init__.py +0 -0
- mainsequence/dashboards/streamlit/core/theme.py +212 -0
- mainsequence/dashboards/streamlit/pages/__init__.py +0 -0
- mainsequence/dashboards/streamlit/scaffold.py +220 -0
- mainsequence/instrumentation/__init__.py +7 -0
- mainsequence/instrumentation/utils.py +101 -0
- mainsequence/instruments/__init__.py +1 -0
- mainsequence/instruments/data_interface/__init__.py +10 -0
- mainsequence/instruments/data_interface/data_interface.py +361 -0
- mainsequence/instruments/instruments/__init__.py +3 -0
- mainsequence/instruments/instruments/base_instrument.py +85 -0
- mainsequence/instruments/instruments/bond.py +447 -0
- mainsequence/instruments/instruments/european_option.py +74 -0
- mainsequence/instruments/instruments/interest_rate_swap.py +217 -0
- mainsequence/instruments/instruments/json_codec.py +585 -0
- mainsequence/instruments/instruments/knockout_fx_option.py +146 -0
- mainsequence/instruments/instruments/position.py +475 -0
- mainsequence/instruments/instruments/ql_fields.py +239 -0
- mainsequence/instruments/instruments/vanilla_fx_option.py +107 -0
- mainsequence/instruments/pricing_models/__init__.py +0 -0
- mainsequence/instruments/pricing_models/black_scholes.py +49 -0
- mainsequence/instruments/pricing_models/bond_pricer.py +182 -0
- mainsequence/instruments/pricing_models/fx_option_pricer.py +90 -0
- mainsequence/instruments/pricing_models/indices.py +350 -0
- mainsequence/instruments/pricing_models/knockout_fx_pricer.py +209 -0
- mainsequence/instruments/pricing_models/swap_pricer.py +502 -0
- mainsequence/instruments/settings.py +175 -0
- mainsequence/instruments/utils.py +29 -0
- mainsequence/logconf.py +284 -0
- mainsequence/reportbuilder/__init__.py +0 -0
- mainsequence/reportbuilder/__main__.py +0 -0
- mainsequence/reportbuilder/examples/ms_template_report.py +706 -0
- mainsequence/reportbuilder/model.py +713 -0
- mainsequence/reportbuilder/slide_templates.py +532 -0
- mainsequence/tdag/__init__.py +8 -0
- mainsequence/tdag/__main__.py +0 -0
- mainsequence/tdag/config.py +129 -0
- mainsequence/tdag/data_nodes/__init__.py +12 -0
- mainsequence/tdag/data_nodes/build_operations.py +751 -0
- mainsequence/tdag/data_nodes/data_nodes.py +1292 -0
- mainsequence/tdag/data_nodes/persist_managers.py +812 -0
- mainsequence/tdag/data_nodes/run_operations.py +543 -0
- mainsequence/tdag/data_nodes/utils.py +24 -0
- mainsequence/tdag/future_registry.py +25 -0
- mainsequence/tdag/utils.py +40 -0
- mainsequence/virtualfundbuilder/__init__.py +45 -0
- mainsequence/virtualfundbuilder/__main__.py +235 -0
- mainsequence/virtualfundbuilder/agent_interface.py +77 -0
- mainsequence/virtualfundbuilder/config_handling.py +86 -0
- mainsequence/virtualfundbuilder/contrib/__init__.py +0 -0
- mainsequence/virtualfundbuilder/contrib/apps/__init__.py +8 -0
- mainsequence/virtualfundbuilder/contrib/apps/etf_replicator_app.py +164 -0
- mainsequence/virtualfundbuilder/contrib/apps/generate_report.py +292 -0
- mainsequence/virtualfundbuilder/contrib/apps/load_external_portfolio.py +107 -0
- mainsequence/virtualfundbuilder/contrib/apps/news_app.py +437 -0
- mainsequence/virtualfundbuilder/contrib/apps/portfolio_report_app.py +91 -0
- mainsequence/virtualfundbuilder/contrib/apps/portfolio_table.py +95 -0
- mainsequence/virtualfundbuilder/contrib/apps/run_named_portfolio.py +45 -0
- mainsequence/virtualfundbuilder/contrib/apps/run_portfolio.py +40 -0
- mainsequence/virtualfundbuilder/contrib/apps/templates/base.html +147 -0
- mainsequence/virtualfundbuilder/contrib/apps/templates/report.html +77 -0
- mainsequence/virtualfundbuilder/contrib/data_nodes/__init__.py +5 -0
- mainsequence/virtualfundbuilder/contrib/data_nodes/external_weights.py +61 -0
- mainsequence/virtualfundbuilder/contrib/data_nodes/intraday_trend.py +149 -0
- mainsequence/virtualfundbuilder/contrib/data_nodes/market_cap.py +310 -0
- mainsequence/virtualfundbuilder/contrib/data_nodes/mock_signal.py +78 -0
- mainsequence/virtualfundbuilder/contrib/data_nodes/portfolio_replicator.py +269 -0
- mainsequence/virtualfundbuilder/contrib/prices/__init__.py +1 -0
- mainsequence/virtualfundbuilder/contrib/prices/data_nodes.py +810 -0
- mainsequence/virtualfundbuilder/contrib/prices/utils.py +11 -0
- mainsequence/virtualfundbuilder/contrib/rebalance_strategies/__init__.py +1 -0
- mainsequence/virtualfundbuilder/contrib/rebalance_strategies/rebalance_strategies.py +313 -0
- mainsequence/virtualfundbuilder/data_nodes.py +637 -0
- mainsequence/virtualfundbuilder/enums.py +23 -0
- mainsequence/virtualfundbuilder/models.py +282 -0
- mainsequence/virtualfundbuilder/notebook_handling.py +42 -0
- mainsequence/virtualfundbuilder/portfolio_interface.py +272 -0
- mainsequence/virtualfundbuilder/resource_factory/__init__.py +0 -0
- mainsequence/virtualfundbuilder/resource_factory/app_factory.py +170 -0
- mainsequence/virtualfundbuilder/resource_factory/base_factory.py +238 -0
- mainsequence/virtualfundbuilder/resource_factory/rebalance_factory.py +101 -0
- mainsequence/virtualfundbuilder/resource_factory/signal_factory.py +183 -0
- mainsequence/virtualfundbuilder/utils.py +381 -0
- mainsequence-2.0.0.dist-info/METADATA +105 -0
- mainsequence-2.0.0.dist-info/RECORD +110 -0
- mainsequence-2.0.0.dist-info/WHEEL +5 -0
- mainsequence-2.0.0.dist-info/licenses/LICENSE +40 -0
- 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()
|