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,447 @@
1
+ # mainsequence/instruments/instruments/bond.py
2
+ import datetime
3
+ from typing import Optional, Dict, Any, List
4
+
5
+ import QuantLib as ql
6
+ from pydantic import Field, PrivateAttr
7
+
8
+ from .base_instrument import InstrumentModel
9
+ from .ql_fields import (
10
+ QuantLibPeriod as QPeriod,
11
+ QuantLibDayCounter as QDayCounter,
12
+ QuantLibCalendar as QCalendar,
13
+ QuantLibBDC as QBDC,
14
+ QuantLibSchedule as QSchedule,
15
+ )
16
+ from mainsequence.instruments.utils import to_ql_date, to_py_date
17
+ from mainsequence.instruments.pricing_models.bond_pricer import (
18
+ create_floating_rate_bond_with_curve,
19
+ )
20
+ from mainsequence.instruments.pricing_models.indices import get_index
21
+
22
+
23
+ class Bond(InstrumentModel):
24
+ """
25
+ Shared pricing lifecycle for vanilla bonds.
26
+
27
+ Subclasses must implement:
28
+ - _get_default_discount_curve(): Optional[ql.YieldTermStructureHandle]
29
+ - _create_bond(discount_curve: ql.YieldTermStructureHandle) -> ql.Bond
30
+ (return a ql.FixedRateBond or ql.FloatingRateBond, etc. *without* assuming any global state)
31
+ """
32
+
33
+ face_value: float = Field(...)
34
+ issue_date: datetime.date = Field(...)
35
+ maturity_date: datetime.date = Field(...)
36
+ coupon_frequency: QPeriod = Field(...)
37
+ day_count: QDayCounter = Field(...)
38
+ calendar: QCalendar = Field(default_factory=ql.TARGET)
39
+ business_day_convention: QBDC = Field(default=ql.Following)
40
+ settlement_days: int = Field(default=2)
41
+ schedule: Optional[QSchedule] = Field(None)
42
+
43
+ model_config = {"arbitrary_types_allowed": True}
44
+
45
+ _bond: Optional[ql.Bond] = PrivateAttr(default=None)
46
+ _with_yield: Optional[float] = PrivateAttr(default=None)
47
+
48
+ # ---- valuation lifecycle ----
49
+ def _on_valuation_date_set(self) -> None:
50
+ self._bond = None
51
+ self._with_yield = None
52
+
53
+ # ---- hooks for subclasses ----
54
+ def _get_default_discount_curve(self) -> Optional[ql.YieldTermStructureHandle]:
55
+ """
56
+ Subclasses return a curve if they have one (e.g., floating uses its index curve),
57
+ or None if they require with_yield or an explicitly supplied handle.
58
+ """
59
+ return None
60
+
61
+ def _create_bond(self, discount_curve: Optional[ql.YieldTermStructureHandle]) -> ql.Bond:
62
+
63
+ """Subclasses must create and return a QuantLib bond (Fixed or Floating).
64
+ discount_curve may be None: subclasses must not assume it is present for cashflow-only usage."""
65
+ raise NotImplementedError
66
+
67
+ def _ensure_instrument(self) -> None:
68
+ if self.valuation_date is None:
69
+ raise ValueError("Set valuation_date before building instrument: set_valuation_date(dt).")
70
+
71
+ ql_calc_date = to_ql_date(self.valuation_date)
72
+ ql.Settings.instance().evaluationDate = ql_calc_date
73
+ ql.Settings.instance().includeReferenceDateEvents = False
74
+ ql.Settings.instance().enforceTodaysHistoricFixings = False
75
+
76
+ # Build only if not already built
77
+ if self._bond is None:
78
+ self._bond = self._create_bond(None) # << NO discount curve required here
79
+
80
+ # ---- internal helpers ----
81
+ def _resolve_discount_curve(
82
+ self, with_yield: Optional[float]
83
+ ) -> ql.YieldTermStructureHandle:
84
+ """
85
+ Priority:
86
+ 1) If with_yield provided -> build a flat curve off that yield.
87
+ 2) Otherwise, use subclass-provided default curve.
88
+ """
89
+ ql_calc_date = to_ql_date(self.valuation_date)
90
+
91
+ if with_yield is not None:
92
+ # Compounded Annual for YTM-style flat curves; day_count from instrument
93
+ flat = ql.FlatForward(
94
+ ql_calc_date, with_yield, self.day_count, ql.Compounded, ql.Annual
95
+ )
96
+ return ql.YieldTermStructureHandle(flat)
97
+
98
+ default = self._get_default_discount_curve()
99
+ if default is None:
100
+ raise ValueError(
101
+ "No discount curve available. Either pass with_yield=... to price(), "
102
+ "or the instrument must supply a default discount curve."
103
+ )
104
+ return default
105
+
106
+ def _setup_pricer(self, with_yield: Optional[float] = None) -> None:
107
+ if self.valuation_date is None:
108
+ raise ValueError("Set valuation_date before pricing: set_valuation_date(dt).")
109
+
110
+ ql_calc_date = to_ql_date(self.valuation_date)
111
+ ql.Settings.instance().evaluationDate = ql_calc_date
112
+ ql.Settings.instance().includeReferenceDateEvents = False
113
+ ql.Settings.instance().enforceTodaysHistoricFixings = False
114
+
115
+ # Build or rebuild only when needed
116
+ if self._bond is None or self._with_yield != with_yield:
117
+ discount_curve = self._resolve_discount_curve(with_yield)
118
+ bond = self._create_bond(discount_curve)
119
+ # Ensure engine is attached (safe even if subclass already set one)
120
+ bond.setPricingEngine(ql.DiscountingBondEngine(discount_curve))
121
+ self._bond = bond
122
+ self._with_yield = with_yield
123
+
124
+ # ---- public API shared by all vanilla bonds ----
125
+ def price(self, with_yield: Optional[float] = None) -> float:
126
+ self._setup_pricer(with_yield=with_yield)
127
+ return float(self._bond.NPV())
128
+
129
+ def analytics(self, with_yield: Optional[float] = None) -> dict:
130
+ self._setup_pricer(with_yield=with_yield)
131
+ _ = self._bond.NPV()
132
+ return {
133
+ "clean_price": self._bond.cleanPrice(),
134
+ "dirty_price": self._bond.dirtyPrice(),
135
+ "accrued_amount": self._bond.accruedAmount(),
136
+ }
137
+
138
+ def get_cashflows(self) -> Dict[str, List[Dict[str, Any]]]:
139
+ """
140
+ Generic cashflow extractor.
141
+ For fixed bonds, you'll see "fixed" + "redemption".
142
+ For floaters, you'll see "floating" + "redemption".
143
+ """
144
+ self._setup_pricer()
145
+ ql.Settings.instance().evaluationDate = to_ql_date(self.valuation_date)
146
+
147
+ out: Dict[str, List[Dict[str, Any]]] = {"fixed": [], "floating": [], "redemption": []}
148
+
149
+ for cf in self._bond.cashflows():
150
+ if cf.hasOccurred():
151
+ continue
152
+
153
+ f_cpn = ql.as_floating_rate_coupon(cf)
154
+ if f_cpn is not None:
155
+ out["floating"].append({
156
+ "payment_date": to_py_date(f_cpn.date()),
157
+ "fixing_date": to_py_date(f_cpn.fixingDate()),
158
+ "rate": float(f_cpn.rate()),
159
+ "spread": float(f_cpn.spread()),
160
+ "amount": float(f_cpn.amount()),
161
+ })
162
+ continue
163
+
164
+ x_cpn = ql.as_fixed_rate_coupon(cf)
165
+ if x_cpn is not None:
166
+ out["fixed"].append({
167
+ "payment_date": to_py_date(x_cpn.date()),
168
+ "rate": float(x_cpn.rate()),
169
+ "amount": float(x_cpn.amount()),
170
+ })
171
+ continue
172
+
173
+ # Redemption/principal
174
+ out["redemption"].append({
175
+ "payment_date": to_py_date(cf.date()),
176
+ "amount": float(cf.amount()),
177
+ })
178
+
179
+ # Trim empty legs to stay tidy
180
+ return {k: v for k, v in out.items() if len(v) > 0}
181
+
182
+ def get_cashflows_df(self):
183
+ """Convenience dataframe with coupon + redemption aligned."""
184
+ self._ensure_instrument() # << build-only; no curve/yield needed
185
+
186
+ import pandas as pd
187
+ cfs = self.get_cashflows()
188
+ legs = [k for k in ("fixed", "floating") if k in cfs]
189
+ if not legs and "redemption" not in cfs:
190
+ return pd.DataFrame()
191
+
192
+ # build coupon df
193
+ df_cpn = None
194
+ for leg in legs:
195
+ df_leg = pd.DataFrame(cfs[leg]) if len(cfs[leg]) else pd.DataFrame(columns=["payment_date", "amount"])
196
+ if not df_leg.empty:
197
+ df_leg = df_leg[["payment_date", "amount"]].set_index("payment_date")
198
+ if df_cpn is None:
199
+ df_cpn = df_leg
200
+ else:
201
+ # if both fixed and floating exist (exotics), sum them
202
+ df_cpn = df_cpn.add(df_leg, fill_value=0.0)
203
+
204
+ df_red = pd.DataFrame(cfs.get("redemption", []))
205
+ if not df_red.empty:
206
+ df_red = df_red.set_index("payment_date")[["amount"]]
207
+
208
+ if df_cpn is None and df_red is None:
209
+ return pd.DataFrame()
210
+
211
+ if df_cpn is None:
212
+ df_out = df_red.rename(columns={"amount": "net_cashflow"})
213
+ elif df_red is None or df_red.empty:
214
+ df_out = df_cpn.rename(columns={"amount": "net_cashflow"})
215
+ else:
216
+ idx = df_cpn.index.union(df_red.index)
217
+ df_cpn = df_cpn.reindex(idx).fillna(0.0)
218
+ df_red = df_red.reindex(idx).fillna(0.0)
219
+ df_out = (df_cpn["amount"] + df_red["amount"]).to_frame("net_cashflow")
220
+
221
+ return df_out
222
+
223
+ def get_net_cashflows(self):
224
+ """Shorthand Series of combined coupon + redemption."""
225
+ df = self.get_cashflows_df()
226
+ return df["net_cashflow"] if "net_cashflow" in df.columns else df.squeeze()
227
+
228
+ def get_yield(self, override_clean_price: Optional[float] = None) -> float:
229
+ """
230
+ Yield-to-maturity based on current clean price (or override), compounded annually.
231
+ """
232
+ self._setup_pricer()
233
+ ql.Settings.instance().evaluationDate = to_ql_date(self.valuation_date)
234
+
235
+ clean_price = override_clean_price if override_clean_price is not None else self._bond.cleanPrice()
236
+ freq: ql.Frequency = self.coupon_frequency.frequency()
237
+ settlement: ql.Date = self._bond.settlementDate()
238
+
239
+ ytm = self._bond.bondYield(
240
+ clean_price,
241
+ self.day_count,
242
+ ql.Compounded,
243
+ freq,
244
+ settlement
245
+ )
246
+ return float(ytm)
247
+
248
+ def get_ql_bond(
249
+ self,
250
+ *,
251
+ build_if_needed: bool = True,
252
+ with_yield: Optional[float] = None
253
+ ) -> ql.Bond:
254
+ """
255
+ Safely access the underlying QuantLib bond.
256
+ If you don't pass a yield and there is no default curve, we build without an engine.
257
+ """
258
+ if self.valuation_date is None:
259
+ raise ValueError("Set valuation_date before accessing the QuantLib bond (set_valuation_date(dt)).")
260
+
261
+ if build_if_needed:
262
+ # If caller gave a yield OR we have a default curve, do full pricing setup.
263
+ if with_yield is not None or self._get_default_discount_curve() is not None:
264
+ self._setup_pricer(with_yield=with_yield if with_yield is not None else self._with_yield)
265
+ else:
266
+ # No curve, no yield -> build instrument only (good for fixed cashflows)
267
+ self._ensure_instrument()
268
+
269
+ if self._bond is None:
270
+ raise RuntimeError(
271
+ "Underlying QuantLib bond is not available. "
272
+ "Call price()/analytics() first or use get_ql_bond(build_if_needed=True, "
273
+ "with_yield=...) to build it."
274
+ )
275
+ return self._bond
276
+
277
+
278
+ class FixedRateBond(Bond):
279
+ """Plain-vanilla fixed-rate bond following the shared Bond lifecycle."""
280
+
281
+ coupon_rate: float = Field(...)
282
+ # Optional market curve if you want to discount off a curve instead of a flat yield
283
+ discount_curve: Optional[ql.YieldTermStructureHandle] = Field(default=None)
284
+
285
+ model_config = {"arbitrary_types_allowed": True}
286
+
287
+ def _get_default_discount_curve(self) -> Optional[ql.YieldTermStructureHandle]:
288
+ return self.discount_curve
289
+
290
+ def _build_schedule(self) -> ql.Schedule:
291
+ if self.schedule is not None:
292
+ return self.schedule
293
+ return ql.Schedule(
294
+ to_ql_date(self.issue_date),
295
+ to_ql_date(self.maturity_date),
296
+ self.coupon_frequency,
297
+ self.calendar,
298
+ self.business_day_convention,
299
+ self.business_day_convention,
300
+ ql.DateGeneration.Forward,
301
+ False
302
+ )
303
+
304
+ def _create_bond(self, discount_curve: Optional[ql.YieldTermStructureHandle]) -> ql.Bond:
305
+ ql.Settings.instance().evaluationDate = to_ql_date(self.valuation_date)
306
+ sched = self._build_schedule()
307
+
308
+ dates = list(sched.dates())
309
+ asof = ql.Settings.instance().evaluationDate
310
+ has_periods_left = len(dates) >= 2 and any(dates[i + 1] > asof for i in range(len(dates) - 1))
311
+ if not has_periods_left:
312
+ maturity = dates[-1] if dates else to_ql_date(self.maturity_date)
313
+ return ql.ZeroCouponBond(
314
+ self.settlement_days,
315
+ self.calendar,
316
+ self.face_value,
317
+ maturity,
318
+ self.business_day_convention,
319
+ 100.0,
320
+ to_ql_date(self.issue_date),
321
+ )
322
+
323
+ return ql.FixedRateBond(
324
+ self.settlement_days,
325
+ self.face_value,
326
+ sched,
327
+ [self.coupon_rate],
328
+ self.day_count
329
+ )
330
+
331
+ class FloatingRateBond(Bond):
332
+ """Floating-rate bond with specified floating rate index (backward compatible)."""
333
+
334
+ face_value: float = Field(...)
335
+ floating_rate_index_name: str = Field(...)
336
+ spread: float = Field(default=0.0)
337
+ # All other fields (issue_date, maturity_date, coupon_frequency, day_count, calendar, etc.)
338
+ # are inherited from Bond
339
+
340
+ model_config = {"arbitrary_types_allowed": True}
341
+
342
+ _bond: Optional[ql.FloatingRateBond] = PrivateAttr(default=None)
343
+ _index: Optional[ql.IborIndex] = PrivateAttr(default=None)
344
+ _with_yield: Optional[float] = PrivateAttr(default=None)
345
+
346
+ # ---------- lifecycle ----------
347
+ def _ensure_index(self) -> None:
348
+ if self._index is not None:
349
+ return
350
+ if self.valuation_date is None:
351
+ raise ValueError("Set valuation_date before pricing: set_valuation_date(dt).")
352
+ self._index = get_index(
353
+ self.floating_rate_index_name,
354
+ target_date=self.valuation_date,
355
+ hydrate_fixings=True,
356
+ )
357
+
358
+ def _on_valuation_date_set(self) -> None:
359
+ super()._on_valuation_date_set()
360
+ self._index = None
361
+
362
+ def reset_curve(self, curve: ql.YieldTermStructureHandle) -> None:
363
+ """Optional: re-link a custom curve to this index and rebuild."""
364
+ if self.valuation_date is None:
365
+ raise ValueError("Set valuation_date before reset_curve().")
366
+
367
+ self._index = get_index(
368
+ self.floating_rate_index_name,
369
+ target_date=self.valuation_date,
370
+ forwarding_curve=curve,
371
+ hydrate_fixings=True,
372
+ )
373
+
374
+ private = ql.RelinkableYieldTermStructureHandle()
375
+ link = curve.currentLink() if hasattr(curve, "currentLink") else curve
376
+ private.linkTo(link)
377
+ self._index = self._index.clone(private)
378
+
379
+ # Force rebuild on next price()
380
+ self._bond = None
381
+ self._with_yield = None
382
+
383
+ # ---- Bond hooks ----
384
+ def _get_default_discount_curve(self) -> Optional[ql.YieldTermStructureHandle]:
385
+ self._ensure_index()
386
+ # Forecasting and (by default) discounting off the index curve for compatibility
387
+ return self._index.forwardingTermStructure()
388
+
389
+ def _create_bond(self, discount_curve: Optional[ql.YieldTermStructureHandle]) -> ql.Bond:
390
+ self._ensure_index()
391
+ ql_calc_date = to_ql_date(self.valuation_date)
392
+ forecasting = self._index.forwardingTermStructure()
393
+
394
+ return create_floating_rate_bond_with_curve(
395
+ calculation_date=ql_calc_date,
396
+ face=self.face_value,
397
+ issue_date=to_ql_date(self.issue_date),
398
+ maturity_date=to_ql_date(self.maturity_date),
399
+ floating_rate_index=self._index,
400
+ spread=self.spread,
401
+ coupon_frequency=self.coupon_frequency,
402
+ day_count=self.day_count,
403
+ calendar=self.calendar,
404
+ business_day_convention=self.business_day_convention,
405
+ settlement_days=self.settlement_days,
406
+ curve=forecasting,
407
+ discount_curve=discount_curve, # may be None (OK)
408
+ seed_past_fixings_from_curve=True,
409
+ schedule=self.schedule
410
+ )
411
+
412
+ # ---------- public API (kept for backward compatibility) ----------
413
+ def get_index_curve(self):
414
+ self._ensure_index()
415
+ return self._index.forwardingTermStructure()
416
+
417
+ # price(with_yield) and analytics(with_yield) are inherited from Bond and remain compatible
418
+
419
+ def get_cashflows(self) -> Dict[str, List[Dict[str, Any]]]:
420
+ """
421
+ Keep the original floater-specific structure (floating + redemption).
422
+ """
423
+ self._setup_pricer()
424
+ ql.Settings.instance().evaluationDate = to_ql_date(self.valuation_date)
425
+
426
+ out: Dict[str, List[Dict[str, Any]]] = {"floating": [], "redemption": []}
427
+
428
+ for cf in self._bond.cashflows():
429
+ if cf.hasOccurred():
430
+ continue
431
+
432
+ cpn = ql.as_floating_rate_coupon(cf)
433
+ if cpn is not None:
434
+ out["floating"].append({
435
+ "payment_date": to_py_date(cpn.date()),
436
+ "fixing_date": to_py_date(cpn.fixingDate()),
437
+ "rate": float(cpn.rate()),
438
+ "spread": float(cpn.spread()),
439
+ "amount": float(cpn.amount()),
440
+ })
441
+ else:
442
+ out["redemption"].append({
443
+ "payment_date": to_py_date(cf.date()),
444
+ "amount": float(cf.amount()),
445
+ })
446
+
447
+ return out
@@ -0,0 +1,74 @@
1
+ import datetime
2
+ from typing import Optional, Literal
3
+
4
+ import QuantLib as ql
5
+ from pydantic import BaseModel, Field, PrivateAttr
6
+
7
+ from mainsequence.instruments.data_interface import data_interface, DateInfo
8
+ from mainsequence.instruments.pricing_models.black_scholes import create_bsm_model
9
+ from mainsequence.instruments.utils import to_ql_date
10
+ from .base_instrument import InstrumentModel
11
+
12
+ class EuropeanOption(InstrumentModel):
13
+ """European option priced with Black–Scholes–Merton."""
14
+
15
+ underlying: str = Field(
16
+ ..., description="Ticker/identifier of the underlying asset (e.g., 'SPY')."
17
+ )
18
+ strike: float = Field(
19
+ ..., description="Option strike price (in underlying currency units)."
20
+ )
21
+ maturity: datetime.date = Field(
22
+ ..., description="Option expiration date."
23
+ )
24
+ option_type: Literal["call", "put"] = Field(
25
+ ..., description="Option type: 'call' or 'put'."
26
+ )
27
+
28
+
29
+ # Allow QuantLib types & keep runtime attrs out of the schema
30
+ model_config = {"arbitrary_types_allowed": True}
31
+
32
+ # Runtime-only QuantLib objects
33
+ _option: Optional[ql.VanillaOption] = PrivateAttr(default=None)
34
+ _engine: Optional[ql.PricingEngine] = PrivateAttr(default=None)
35
+
36
+ def _setup_pricing_components(self) -> None:
37
+ # 1) market data
38
+ asset_range_map = {self.underlying: DateInfo(start_date=self.valuation_date)}
39
+ md = data_interface.get_historical_data("equities_daily", asset_range_map)
40
+ spot, vol, r, q = md["spot_price"], md["volatility"], md["risk_free_rate"], md["dividend_yield"]
41
+
42
+ # 2) dates
43
+ ql_calc = to_ql_date(self.valuation_date)
44
+ ql_mty = to_ql_date(self.maturity)
45
+ ql.Settings.instance().evaluationDate = ql_calc
46
+
47
+ # 3) model
48
+ process = create_bsm_model(ql_calc, spot, vol, r, q)
49
+
50
+ # 4) instrument + engine
51
+ payoff = ql.PlainVanillaPayoff(
52
+ ql.Option.Call if self.option_type == "call" else ql.Option.Put, self.strike
53
+ )
54
+ exercise = ql.EuropeanExercise(ql_mty)
55
+ self._option = ql.VanillaOption(payoff, exercise)
56
+ self._engine = ql.AnalyticEuropeanEngine(process)
57
+ self._option.setPricingEngine(self._engine)
58
+
59
+ def price(self) -> float:
60
+ if not self._option:
61
+ self._setup_pricing_components()
62
+ return float(self._option.NPV())
63
+
64
+ def get_greeks(self) -> dict:
65
+ if not self._option:
66
+ self._setup_pricing_components()
67
+ self._option.NPV()
68
+ return {
69
+ "delta": self._option.delta(),
70
+ "gamma": self._option.gamma(),
71
+ "vega": self._option.vega() / 100.0,
72
+ "theta": self._option.theta() / 365.0,
73
+ "rho": self._option.rho() / 100.0,
74
+ }