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,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
|
+
}
|