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,239 @@
|
|
1
|
+
# src/instruments/ql_fields.py
|
2
|
+
from __future__ import annotations
|
3
|
+
|
4
|
+
import inspect
|
5
|
+
from typing import Optional, Dict, Any
|
6
|
+
from typing_extensions import Annotated
|
7
|
+
from pydantic import BeforeValidator, PlainSerializer, WithJsonSchema
|
8
|
+
import QuantLib as ql
|
9
|
+
|
10
|
+
# Reuse your existing codec helpers
|
11
|
+
from mainsequence.instruments.instruments.json_codec import (
|
12
|
+
# Period
|
13
|
+
period_to_json, period_from_json,
|
14
|
+
# DayCounter
|
15
|
+
daycount_to_json, daycount_from_json,
|
16
|
+
# Calendar
|
17
|
+
calendar_to_json, calendar_from_json,
|
18
|
+
# BDC
|
19
|
+
bdc_to_json, bdc_from_json,
|
20
|
+
# IborIndex (used only for legacy conversion)
|
21
|
+
ibor_to_json, ibor_from_json,
|
22
|
+
# Schedule
|
23
|
+
schedule_to_json, schedule_from_json,
|
24
|
+
)
|
25
|
+
|
26
|
+
# ============================================================================
|
27
|
+
# Automatic Calendar Factory
|
28
|
+
# ============================================================================
|
29
|
+
|
30
|
+
def _build_fully_automatic_calendar_factory() -> Dict[str, callable]:
|
31
|
+
"""
|
32
|
+
Build a mapping: <calendar display name> -> zero-arg callable that returns ql.Calendar
|
33
|
+
We try both no-arg constructors and Market-enum constructors.
|
34
|
+
|
35
|
+
Examples of keys (display names):
|
36
|
+
- "TARGET"
|
37
|
+
- "Mexican stock exchange" (ql.Mexico())
|
38
|
+
- "New York stock exchange" (ql.UnitedStates(ql.UnitedStates.NYSE))
|
39
|
+
- "United States settlement" (ql.UnitedStates(ql.UnitedStates.Settlement))
|
40
|
+
- "London stock exchange" (ql.UnitedKingdom(ql.UnitedKingdom.LSE))
|
41
|
+
"""
|
42
|
+
factory: Dict[str, callable] = {}
|
43
|
+
|
44
|
+
def _try_register(ctor):
|
45
|
+
try:
|
46
|
+
inst = ctor()
|
47
|
+
name = inst.name()
|
48
|
+
# Keep first seen mapping; if duplicates exist, first one wins.
|
49
|
+
factory.setdefault(name, ctor)
|
50
|
+
return True
|
51
|
+
except Exception:
|
52
|
+
return False
|
53
|
+
|
54
|
+
# Iterate over all QuantLib classes; find Calendar subclasses (excluding base Calendar)
|
55
|
+
for _, cls in inspect.getmembers(ql, predicate=inspect.isclass):
|
56
|
+
try:
|
57
|
+
if not issubclass(cls, ql.Calendar) or cls is ql.Calendar:
|
58
|
+
continue
|
59
|
+
except TypeError:
|
60
|
+
# Some SWIG artifacts aren't proper classes for issubclass; skip them
|
61
|
+
continue
|
62
|
+
|
63
|
+
# Case A: try no-arg constructor (e.g., TARGET, Mexico, Turkey, etc.)
|
64
|
+
_try_register(lambda c=cls: c())
|
65
|
+
|
66
|
+
# Case B: try int-valued attributes on the class (likely Market enums)
|
67
|
+
for attr_name, attr_val in inspect.getmembers(cls):
|
68
|
+
if attr_name.startswith("_"):
|
69
|
+
continue
|
70
|
+
if isinstance(attr_val, int):
|
71
|
+
_try_register(lambda c=cls, e=attr_val: c(e))
|
72
|
+
|
73
|
+
# Case C: nested 'Market' enum classes (common in recent QuantLib builds)
|
74
|
+
for attr_name, attr_val in inspect.getmembers(cls):
|
75
|
+
if not inspect.isclass(attr_val):
|
76
|
+
continue
|
77
|
+
if attr_name.lower().startswith("market"):
|
78
|
+
for mname, mval in inspect.getmembers(attr_val):
|
79
|
+
if mname.startswith("_"):
|
80
|
+
continue
|
81
|
+
if isinstance(mval, int):
|
82
|
+
_try_register(lambda c=cls, e=mval: c(e))
|
83
|
+
|
84
|
+
return factory
|
85
|
+
|
86
|
+
|
87
|
+
# Build once; also a case-insensitive mirror for defensive lookups.
|
88
|
+
_CAL_FACTORY: Dict[str, callable] = _build_fully_automatic_calendar_factory()
|
89
|
+
_CAL_FACTORY_CI: Dict[str, callable] = {k.casefold(): v for k, v in _CAL_FACTORY.items()}
|
90
|
+
|
91
|
+
|
92
|
+
def calendar_from_display_name(name: str) -> ql.Calendar:
|
93
|
+
"""
|
94
|
+
Rebuild a QuantLib calendar from its display name (Calendar::name()).
|
95
|
+
Example: "Mexican stock exchange" -> ql.Mexico()
|
96
|
+
"""
|
97
|
+
ctor = _CAL_FACTORY.get(name) or _CAL_FACTORY_CI.get(name.casefold())
|
98
|
+
if ctor is None:
|
99
|
+
raise ValueError(f"Unsupported calendar display name {name!r}. "
|
100
|
+
"Available: " + ", ".join(sorted(_CAL_FACTORY.keys())[:20]) + ("..." if len(_CAL_FACTORY) > 20 else ""))
|
101
|
+
return ctor()
|
102
|
+
|
103
|
+
|
104
|
+
# ============================================================================
|
105
|
+
# Strict serializers that force the real calendar name via virtual .name()
|
106
|
+
# ============================================================================
|
107
|
+
|
108
|
+
def _calendar_to_json_actual(cal: ql.Calendar) -> Dict[str, Any]:
|
109
|
+
"""
|
110
|
+
Serialize as {'name': cal.name()} using the virtual method.
|
111
|
+
Works even if 'cal' is a base Calendar proxy returned by SWIG.
|
112
|
+
"""
|
113
|
+
return {"name": cal.name()}
|
114
|
+
|
115
|
+
def _schedule_to_json_actual(s: Optional[ql.Schedule]) -> Optional[Dict[str, Any]]:
|
116
|
+
"""
|
117
|
+
Serialize a schedule; ensure its calendar is emitted with the true display name.
|
118
|
+
"""
|
119
|
+
if s is None:
|
120
|
+
return None
|
121
|
+
data = schedule_to_json(s) # keep your canonical fields (dates, BDCs, EOM, rule, etc.)
|
122
|
+
try:
|
123
|
+
data["calendar"] = {"name": s.calendar().name()}
|
124
|
+
except Exception:
|
125
|
+
pass
|
126
|
+
return data
|
127
|
+
|
128
|
+
|
129
|
+
# ============================================================================
|
130
|
+
# Lenient deserializers that accept {'name': '<display name>'}
|
131
|
+
# ============================================================================
|
132
|
+
|
133
|
+
def _calendar_from_json_auto(v):
|
134
|
+
"""
|
135
|
+
Accept:
|
136
|
+
- ql.Calendar (pass-through)
|
137
|
+
- {'name': '<display name>'} -> rebuilt via factory
|
138
|
+
- str '<display name>' -> rebuilt via factory
|
139
|
+
- else -> delegate to existing calendar_from_json
|
140
|
+
"""
|
141
|
+
if isinstance(v, ql.Calendar):
|
142
|
+
return v
|
143
|
+
if isinstance(v, dict):
|
144
|
+
nm = v.get("name")
|
145
|
+
if isinstance(nm, str) and nm and nm != "Calendar":
|
146
|
+
return calendar_from_display_name(nm)
|
147
|
+
if isinstance(v, str) and v and v != "Calendar":
|
148
|
+
return calendar_from_display_name(v)
|
149
|
+
# Fallback to your existing helper (may accept other legacy formats)
|
150
|
+
return calendar_from_json(v)
|
151
|
+
|
152
|
+
def _schedule_from_json_auto(v):
|
153
|
+
"""
|
154
|
+
If schedule JSON contains {'calendar': {'name': '<display name>'}},
|
155
|
+
rebuild a concrete ql.Calendar first, then delegate to schedule_from_json.
|
156
|
+
"""
|
157
|
+
if v is None or isinstance(v, ql.Schedule):
|
158
|
+
return v
|
159
|
+
if isinstance(v, dict) and "calendar" in v:
|
160
|
+
cal_spec = v["calendar"]
|
161
|
+
# Rebuild calendar if we have a display name
|
162
|
+
try:
|
163
|
+
v = dict(v)
|
164
|
+
v["calendar"] = _calendar_from_json_auto(cal_spec)
|
165
|
+
except Exception:
|
166
|
+
# Leave as-is; schedule_from_json may still handle it
|
167
|
+
pass
|
168
|
+
return schedule_from_json(v)
|
169
|
+
|
170
|
+
|
171
|
+
# ============================================================================
|
172
|
+
# Pydantic Annotated field types
|
173
|
+
# ============================================================================
|
174
|
+
|
175
|
+
# ---------- Period -----------------------------------------------------------
|
176
|
+
QuantLibPeriod = Annotated[
|
177
|
+
ql.Period,
|
178
|
+
BeforeValidator(period_from_json),
|
179
|
+
PlainSerializer(period_to_json, return_type=str),
|
180
|
+
]
|
181
|
+
|
182
|
+
# ---------- DayCounter -------------------------------------------------------
|
183
|
+
QuantLibDayCounter = Annotated[
|
184
|
+
ql.DayCounter,
|
185
|
+
BeforeValidator(daycount_from_json),
|
186
|
+
PlainSerializer(daycount_to_json, return_type=str),
|
187
|
+
]
|
188
|
+
|
189
|
+
# ---------- Calendar ---------------------------------------------------------
|
190
|
+
QuantLibCalendar = Annotated[
|
191
|
+
ql.Calendar,
|
192
|
+
BeforeValidator(_calendar_from_json_auto), # <— use factory-based loader
|
193
|
+
PlainSerializer(_calendar_to_json_actual, return_type=Dict[str, Any]), # <— always emit true name
|
194
|
+
]
|
195
|
+
|
196
|
+
# ---------- Business Day Convention (BDC) -----------------------------------
|
197
|
+
def _bdc_from_any(v):
|
198
|
+
return bdc_from_json(v)
|
199
|
+
|
200
|
+
def _bdc_to_str(v: int) -> str:
|
201
|
+
return str(bdc_to_json(int(v)))
|
202
|
+
|
203
|
+
QuantLibBDC = Annotated[
|
204
|
+
int,
|
205
|
+
BeforeValidator(_bdc_from_any),
|
206
|
+
PlainSerializer(_bdc_to_str, return_type=str),
|
207
|
+
]
|
208
|
+
|
209
|
+
# ---------- Schedule ---------------------------------------------------------
|
210
|
+
QuantLibSchedule = Annotated[
|
211
|
+
Optional[ql.Schedule],
|
212
|
+
BeforeValidator(_schedule_from_json_auto), # <— rebuild calendar from display name first
|
213
|
+
PlainSerializer(_schedule_to_json_actual, return_type=Optional[Dict[str, Any]]), # <— emit true name
|
214
|
+
WithJsonSchema(
|
215
|
+
{
|
216
|
+
"type": ["object", "null"],
|
217
|
+
"properties": {
|
218
|
+
"dates": {"type": "array", "items": {"type": "string", "pattern": r"^\d{4}-\d{2}-\d{2}$"}},
|
219
|
+
"calendar": {"type": "object"}, # {"name": "<display name from cal.name()>"}
|
220
|
+
"business_day_convention": {"type": ["string", "integer"]},
|
221
|
+
"termination_business_day_convention": {"type": ["string", "integer"]},
|
222
|
+
"end_of_month": {"type": "boolean"},
|
223
|
+
"tenor": {"type": "string"},
|
224
|
+
"rule": {"type": ["string", "integer"]},
|
225
|
+
},
|
226
|
+
"required": ["dates"],
|
227
|
+
"additionalProperties": True,
|
228
|
+
},
|
229
|
+
mode="serialization",
|
230
|
+
),
|
231
|
+
]
|
232
|
+
|
233
|
+
__all__ = [
|
234
|
+
"QuantLibPeriod",
|
235
|
+
"QuantLibDayCounter",
|
236
|
+
"QuantLibCalendar",
|
237
|
+
"QuantLibBDC",
|
238
|
+
"QuantLibSchedule",
|
239
|
+
]
|
@@ -0,0 +1,107 @@
|
|
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.pricing_models.fx_option_pricer import create_fx_garman_kohlhagen_model, get_fx_market_data
|
8
|
+
from mainsequence.instruments.utils import to_ql_date
|
9
|
+
from .base_instrument import InstrumentModel
|
10
|
+
|
11
|
+
|
12
|
+
class VanillaFXOption(InstrumentModel):
|
13
|
+
"""Vanilla FX option priced with Garman-Kohlhagen model."""
|
14
|
+
|
15
|
+
currency_pair: str = Field(
|
16
|
+
..., description="Currency pair in format 'EURUSD', 'GBPUSD', etc. (6 characters)."
|
17
|
+
)
|
18
|
+
strike: float = Field(
|
19
|
+
..., description="Option strike price (domestic currency per unit of foreign currency)."
|
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
|
+
notional: float = Field(
|
28
|
+
..., description="Notional amount in foreign currency units."
|
29
|
+
)
|
30
|
+
|
31
|
+
|
32
|
+
# Allow QuantLib types & keep runtime attrs out of the schema
|
33
|
+
model_config = {"arbitrary_types_allowed": True}
|
34
|
+
|
35
|
+
# Runtime-only QuantLib objects
|
36
|
+
_option: Optional[ql.VanillaOption] = PrivateAttr(default=None)
|
37
|
+
_engine: Optional[ql.PricingEngine] = PrivateAttr(default=None)
|
38
|
+
|
39
|
+
def _setup_pricing_components(self) -> None:
|
40
|
+
"""Set up the QuantLib pricing components for the FX option."""
|
41
|
+
# 1) Validate currency pair format
|
42
|
+
if len(self.currency_pair) != 6:
|
43
|
+
raise ValueError("Currency pair must be 6 characters (e.g., 'EURUSD')")
|
44
|
+
|
45
|
+
# 2) Get FX market data
|
46
|
+
market_data = get_fx_market_data(self.currency_pair, self.valuation_date)
|
47
|
+
spot_fx = market_data["spot_fx_rate"]
|
48
|
+
vol = market_data["volatility"]
|
49
|
+
domestic_rate = market_data["domestic_rate"]
|
50
|
+
foreign_rate = market_data["foreign_rate"]
|
51
|
+
|
52
|
+
# 3) Convert dates to QuantLib format
|
53
|
+
ql_calc = to_ql_date(self.valuation_date)
|
54
|
+
ql_mty = to_ql_date(self.maturity)
|
55
|
+
ql.Settings.instance().evaluationDate = ql_calc
|
56
|
+
|
57
|
+
# 4) Create Garman-Kohlhagen process
|
58
|
+
process = create_fx_garman_kohlhagen_model(
|
59
|
+
ql_calc, spot_fx, vol, domestic_rate, foreign_rate
|
60
|
+
)
|
61
|
+
|
62
|
+
# 5) Create instrument and engine
|
63
|
+
payoff = ql.PlainVanillaPayoff(
|
64
|
+
ql.Option.Call if self.option_type == "call" else ql.Option.Put,
|
65
|
+
self.strike
|
66
|
+
)
|
67
|
+
exercise = ql.EuropeanExercise(ql_mty)
|
68
|
+
self._option = ql.VanillaOption(payoff, exercise)
|
69
|
+
self._engine = ql.AnalyticEuropeanEngine(process)
|
70
|
+
self._option.setPricingEngine(self._engine)
|
71
|
+
|
72
|
+
def price(self) -> float:
|
73
|
+
"""Calculate the option price (NPV)."""
|
74
|
+
if not self._option:
|
75
|
+
self._setup_pricing_components()
|
76
|
+
# Return price multiplied by notional
|
77
|
+
return float(self._option.NPV() * self.notional)
|
78
|
+
|
79
|
+
def get_greeks(self) -> dict:
|
80
|
+
"""Calculate the option Greeks."""
|
81
|
+
if not self._option:
|
82
|
+
self._setup_pricing_components()
|
83
|
+
self._option.NPV() # Ensure calculations are performed
|
84
|
+
|
85
|
+
return {
|
86
|
+
"delta": self._option.delta() * self.notional,
|
87
|
+
"gamma": self._option.gamma() * self.notional,
|
88
|
+
"vega": self._option.vega() * self.notional / 100.0, # Convert to 1% vol change
|
89
|
+
"theta": self._option.theta() * self.notional / 365.0, # Convert to per day
|
90
|
+
"rho_domestic": self._option.rho() * self.notional / 100.0, # Convert to 1% rate change
|
91
|
+
}
|
92
|
+
|
93
|
+
def get_market_info(self) -> dict:
|
94
|
+
"""Get the market data used for pricing."""
|
95
|
+
market_data = get_fx_market_data(self.currency_pair, self.valuation_date)
|
96
|
+
foreign_ccy = self.currency_pair[:3]
|
97
|
+
domestic_ccy = self.currency_pair[3:]
|
98
|
+
|
99
|
+
return {
|
100
|
+
"currency_pair": self.currency_pair,
|
101
|
+
"foreign_currency": foreign_ccy,
|
102
|
+
"domestic_currency": domestic_ccy,
|
103
|
+
"spot_fx_rate": market_data["spot_fx_rate"],
|
104
|
+
"volatility": market_data["volatility"],
|
105
|
+
"domestic_rate": market_data["domestic_rate"],
|
106
|
+
"foreign_rate": market_data["foreign_rate"]
|
107
|
+
}
|
File without changes
|
@@ -0,0 +1,49 @@
|
|
1
|
+
import QuantLib as ql
|
2
|
+
|
3
|
+
|
4
|
+
def create_bsm_model(
|
5
|
+
calculation_date: ql.Date,
|
6
|
+
spot_price: float,
|
7
|
+
volatility: float,
|
8
|
+
risk_free_rate: float,
|
9
|
+
dividend_yield: float
|
10
|
+
) -> ql.BlackScholesMertonProcess:
|
11
|
+
"""
|
12
|
+
Sets up the Black-Scholes-Merton process in QuantLib.
|
13
|
+
|
14
|
+
Args:
|
15
|
+
calculation_date: The date for which the pricing is being performed.
|
16
|
+
spot_price: The current price of the underlying asset.
|
17
|
+
volatility: The annualized volatility of the underlying asset.
|
18
|
+
risk_free_rate: The annualized risk-free interest rate.
|
19
|
+
dividend_yield: The annualized dividend yield of the underlying asset.
|
20
|
+
|
21
|
+
Returns:
|
22
|
+
A QuantLib BlackScholesMertonProcess object configured with the market data.
|
23
|
+
"""
|
24
|
+
# Set the evaluation date in QuantLib
|
25
|
+
ql.Settings.instance().evaluationDate = calculation_date
|
26
|
+
|
27
|
+
# Define underlying asset price and curves
|
28
|
+
underlying_handle = ql.QuoteHandle(ql.SimpleQuote(spot_price))
|
29
|
+
|
30
|
+
day_count = ql.Actual365Fixed()
|
31
|
+
|
32
|
+
flat_ts = ql.YieldTermStructureHandle(
|
33
|
+
ql.FlatForward(calculation_date, risk_free_rate, day_count)
|
34
|
+
)
|
35
|
+
|
36
|
+
dividend_ts = ql.YieldTermStructureHandle(
|
37
|
+
ql.FlatForward(calculation_date, dividend_yield, day_count)
|
38
|
+
)
|
39
|
+
|
40
|
+
flat_vol_ts = ql.BlackVolTermStructureHandle(
|
41
|
+
ql.BlackConstantVol(calculation_date, ql.TARGET(), volatility, day_count)
|
42
|
+
)
|
43
|
+
|
44
|
+
# Create the Black-Scholes-Merton process
|
45
|
+
bsm_process = ql.BlackScholesMertonProcess(
|
46
|
+
underlying_handle, dividend_ts, flat_ts, flat_vol_ts
|
47
|
+
)
|
48
|
+
|
49
|
+
return bsm_process
|
@@ -0,0 +1,182 @@
|
|
1
|
+
# mainsequence/instruments/pricing_models/bond_pricer.py
|
2
|
+
import QuantLib as ql
|
3
|
+
from typing import List, Dict, Any, Optional
|
4
|
+
from mainsequence.instruments.data_interface import data_interface
|
5
|
+
from mainsequence.instruments.utils import to_ql_date
|
6
|
+
import datetime
|
7
|
+
import matplotlib.pyplot as plt
|
8
|
+
|
9
|
+
|
10
|
+
def _map_daycount(dc: str) -> ql.DayCounter:
|
11
|
+
s = (dc or '').upper()
|
12
|
+
if s.startswith('30/360'):
|
13
|
+
return ql.Thirty360(ql.Thirty360.USA)
|
14
|
+
if s in ('ACT/365', 'ACT/365F', 'ACTUAL/365', 'ACTUAL/365F'):
|
15
|
+
return ql.Actual365Fixed()
|
16
|
+
if s in ('ACT/ACT', 'ACTUAL/ACTUAL'):
|
17
|
+
return ql.ActualActual()
|
18
|
+
return ql.Thirty360(ql.Thirty360.USA)
|
19
|
+
|
20
|
+
|
21
|
+
|
22
|
+
|
23
|
+
|
24
|
+
|
25
|
+
def create_fixed_rate_bond(
|
26
|
+
calculation_date: ql.Date,
|
27
|
+
face: float,
|
28
|
+
issue_date: ql.Date,
|
29
|
+
maturity_date: ql.Date,
|
30
|
+
coupon_rate: float,
|
31
|
+
coupon_frequency: ql.Period,
|
32
|
+
day_count: ql.DayCounter,
|
33
|
+
calendar: ql.Calendar = ql.TARGET(),
|
34
|
+
business_day_convention: int = ql.Following, # enums are ints in the Python wrapper
|
35
|
+
settlement_days: int = 2,
|
36
|
+
discount_curve: Optional[ql.YieldTermStructureHandle] = None,
|
37
|
+
schedule: Optional[ql.Schedule] = None,
|
38
|
+
|
39
|
+
) -> ql.FixedRateBond:
|
40
|
+
"""Construct and engine-attach."""
|
41
|
+
ql.Settings.instance().evaluationDate = calculation_date
|
42
|
+
|
43
|
+
|
44
|
+
|
45
|
+
|
46
|
+
# --------- Schedule ----------
|
47
|
+
if schedule is None:
|
48
|
+
schedule = ql.Schedule(
|
49
|
+
issue_date, maturity_date, coupon_frequency, calendar,
|
50
|
+
business_day_convention, business_day_convention,
|
51
|
+
ql.DateGeneration.Forward, False
|
52
|
+
)
|
53
|
+
else:
|
54
|
+
asof = ql.Settings.instance().evaluationDate
|
55
|
+
n = len(schedule.dates())
|
56
|
+
# True floater periods exist only if schedule has >=2 dates AND at least one period end > as-of date.
|
57
|
+
has_periods_left = (n >= 2) and any(schedule.dates()[i + 1] > asof for i in range(n - 1))
|
58
|
+
if not has_periods_left:
|
59
|
+
# Redemption-only: price as a zero-coupon bond (par redemption by default).
|
60
|
+
maturity = schedule.dates()[n-1] if n > 0 else maturity_date
|
61
|
+
zcb = ql.ZeroCouponBond(
|
62
|
+
settlement_days,
|
63
|
+
calendar, # use the same calendar as above
|
64
|
+
face, # notional
|
65
|
+
maturity, # maturity date
|
66
|
+
business_day_convention, # payment convention (for settlement)
|
67
|
+
100.0, # redemption (% of face)
|
68
|
+
issue_date # issue date
|
69
|
+
)
|
70
|
+
zcb.setPricingEngine(ql.DiscountingBondEngine(discount_curve))
|
71
|
+
return zcb
|
72
|
+
|
73
|
+
|
74
|
+
bond = ql.FixedRateBond(settlement_days, face, schedule, [coupon_rate], day_count)
|
75
|
+
bond.setPricingEngine(ql.DiscountingBondEngine(discount_curve))
|
76
|
+
return bond
|
77
|
+
|
78
|
+
|
79
|
+
def create_floating_rate_bond_with_curve(
|
80
|
+
*,
|
81
|
+
calculation_date: ql.Date,
|
82
|
+
face: float,
|
83
|
+
issue_date: ql.Date,
|
84
|
+
maturity_date: ql.Date,
|
85
|
+
floating_rate_index: ql.IborIndex,
|
86
|
+
spread: float = 0.0,
|
87
|
+
coupon_frequency: ql.Period | None = None,
|
88
|
+
day_count: ql.DayCounter | None = None,
|
89
|
+
calendar: ql.Calendar | None = None,
|
90
|
+
business_day_convention: int = ql.Following,
|
91
|
+
settlement_days: int = 2,
|
92
|
+
curve: ql.YieldTermStructureHandle,
|
93
|
+
seed_past_fixings_from_curve: bool = True,
|
94
|
+
discount_curve: Optional[ql.YieldTermStructureHandle] = None,
|
95
|
+
schedule: Optional[ql.Schedule] = None,
|
96
|
+
) -> ql.FloatingRateBond:
|
97
|
+
"""
|
98
|
+
Build/prices a floating-rate bond like your swap-with-curve:
|
99
|
+
- clone index to 'curve'
|
100
|
+
- spot-start safeguard
|
101
|
+
- seed past/today fixings from the same curve
|
102
|
+
- discount with the same curve
|
103
|
+
"""
|
104
|
+
|
105
|
+
# --- evaluation settings (match swap) ---
|
106
|
+
ql.Settings.instance().evaluationDate = calculation_date
|
107
|
+
ql.Settings.instance().includeReferenceDateEvents = False
|
108
|
+
ql.Settings.instance().enforceTodaysHistoricFixings = False
|
109
|
+
|
110
|
+
if curve is None:
|
111
|
+
raise ValueError("create_floating_rate_bond_with_curve: 'curve' is None")
|
112
|
+
# Probe the handle by attempting a discount on calculation_date.
|
113
|
+
# If the handle is unlinked/invalid this will raise; we convert it to a clear message.
|
114
|
+
try:
|
115
|
+
_ = curve.discount(calculation_date)
|
116
|
+
except Exception as e:
|
117
|
+
raise ValueError(
|
118
|
+
"create_floating_rate_bond_with_curve: provided curve handle "
|
119
|
+
"is not linked or cannot discount on calculation_date"
|
120
|
+
) from e
|
121
|
+
|
122
|
+
# --- index & calendars ---
|
123
|
+
pricing_index = floating_rate_index.clone(curve) # forecast on the provided curve
|
124
|
+
cal = calendar or pricing_index.fixingCalendar()
|
125
|
+
freq = coupon_frequency or pricing_index.tenor()
|
126
|
+
dc = day_count or pricing_index.dayCounter()
|
127
|
+
|
128
|
+
eff_start = issue_date
|
129
|
+
eff_end = maturity_date
|
130
|
+
|
131
|
+
# --------- Schedule ----------
|
132
|
+
if schedule is None:
|
133
|
+
schedule = ql.Schedule(
|
134
|
+
eff_start, eff_end, freq, cal,
|
135
|
+
business_day_convention, business_day_convention,
|
136
|
+
ql.DateGeneration.Forward, False
|
137
|
+
)
|
138
|
+
else:
|
139
|
+
asof = ql.Settings.instance().evaluationDate
|
140
|
+
n = len(schedule.dates())
|
141
|
+
# True floater periods exist only if schedule has >=2 dates AND at least one period end > as-of date.
|
142
|
+
has_periods_left = (n >= 2) and any(schedule.dates()[i + 1] > asof for i in range(n - 1))
|
143
|
+
if not has_periods_left:
|
144
|
+
# Redemption-only: price as a zero-coupon bond (par redemption by default).
|
145
|
+
maturity = schedule.dates()[n-1] if n > 0 else eff_end
|
146
|
+
zcb = ql.ZeroCouponBond(
|
147
|
+
settlement_days,
|
148
|
+
cal, # use the same calendar as above
|
149
|
+
face, # notional
|
150
|
+
maturity, # maturity date
|
151
|
+
business_day_convention, # payment convention (for settlement)
|
152
|
+
100.0, # redemption (% of face)
|
153
|
+
issue_date # issue date
|
154
|
+
)
|
155
|
+
zcb.setPricingEngine(ql.DiscountingBondEngine(curve))
|
156
|
+
return zcb
|
157
|
+
|
158
|
+
|
159
|
+
# --------- Instrument ----------
|
160
|
+
try:
|
161
|
+
bond = ql.FloatingRateBond(
|
162
|
+
settlement_days,
|
163
|
+
face,
|
164
|
+
schedule,
|
165
|
+
pricing_index,
|
166
|
+
dc,
|
167
|
+
business_day_convention,
|
168
|
+
pricing_index.fixingDays(),
|
169
|
+
[1.0], # gearings
|
170
|
+
[spread], # spreads
|
171
|
+
[], [], # caps, floors
|
172
|
+
False, # inArrears
|
173
|
+
100.0, # redemption
|
174
|
+
issue_date
|
175
|
+
)
|
176
|
+
except Exception as e:
|
177
|
+
raise e
|
178
|
+
|
179
|
+
|
180
|
+
|
181
|
+
return bond
|
182
|
+
|
@@ -0,0 +1,90 @@
|
|
1
|
+
import QuantLib as ql
|
2
|
+
from mainsequence.instruments.data_interface import data_interface, DateInfo
|
3
|
+
|
4
|
+
|
5
|
+
def create_fx_garman_kohlhagen_model(
|
6
|
+
calculation_date: ql.Date,
|
7
|
+
spot_fx_rate: float,
|
8
|
+
volatility: float,
|
9
|
+
domestic_rate: float,
|
10
|
+
foreign_rate: float
|
11
|
+
) -> ql.BlackScholesMertonProcess:
|
12
|
+
"""
|
13
|
+
Sets up the Garman-Kohlhagen process for FX options in QuantLib.
|
14
|
+
|
15
|
+
The Garman-Kohlhagen model is essentially Black-Scholes where:
|
16
|
+
- The underlying is the FX spot rate
|
17
|
+
- The "dividend yield" is replaced by the foreign risk-free rate
|
18
|
+
- The risk-free rate is the domestic risk-free rate
|
19
|
+
|
20
|
+
Args:
|
21
|
+
calculation_date: The date for which the pricing is being performed.
|
22
|
+
spot_fx_rate: The current FX spot rate (domestic per foreign currency).
|
23
|
+
volatility: The annualized volatility of the FX rate.
|
24
|
+
domestic_rate: The annualized domestic risk-free interest rate.
|
25
|
+
foreign_rate: The annualized foreign risk-free interest rate.
|
26
|
+
|
27
|
+
Returns:
|
28
|
+
A QuantLib BlackScholesMertonProcess object configured for FX options.
|
29
|
+
"""
|
30
|
+
# Set the evaluation date in QuantLib
|
31
|
+
ql.Settings.instance().evaluationDate = calculation_date
|
32
|
+
|
33
|
+
# Define FX spot rate handle
|
34
|
+
spot_handle = ql.QuoteHandle(ql.SimpleQuote(spot_fx_rate))
|
35
|
+
|
36
|
+
day_count = ql.Actual365Fixed()
|
37
|
+
|
38
|
+
# Domestic risk-free rate curve
|
39
|
+
domestic_ts = ql.YieldTermStructureHandle(
|
40
|
+
ql.FlatForward(calculation_date, domestic_rate, day_count)
|
41
|
+
)
|
42
|
+
|
43
|
+
# Foreign risk-free rate curve (treated as "dividend yield" in BS framework)
|
44
|
+
foreign_ts = ql.YieldTermStructureHandle(
|
45
|
+
ql.FlatForward(calculation_date, foreign_rate, day_count)
|
46
|
+
)
|
47
|
+
|
48
|
+
# FX volatility surface
|
49
|
+
vol_ts = ql.BlackVolTermStructureHandle(
|
50
|
+
ql.BlackConstantVol(calculation_date, ql.TARGET(), volatility, day_count)
|
51
|
+
)
|
52
|
+
|
53
|
+
# Create the Garman-Kohlhagen process (using BlackScholesMertonProcess)
|
54
|
+
gk_process = ql.BlackScholesMertonProcess(
|
55
|
+
spot_handle, foreign_ts, domestic_ts, vol_ts
|
56
|
+
)
|
57
|
+
|
58
|
+
return gk_process
|
59
|
+
|
60
|
+
|
61
|
+
def get_fx_market_data(currency_pair: str, calculation_date) -> dict:
|
62
|
+
"""
|
63
|
+
Fetches FX market data for a given currency pair.
|
64
|
+
|
65
|
+
Args:
|
66
|
+
currency_pair: Currency pair in format "EURUSD", "GBPUSD", etc.
|
67
|
+
calculation_date: The calculation date for market data
|
68
|
+
|
69
|
+
Returns:
|
70
|
+
Dictionary containing spot rate, volatility, domestic rate, and foreign rate
|
71
|
+
"""
|
72
|
+
# Extract domestic and foreign currencies from pair
|
73
|
+
if len(currency_pair) != 6:
|
74
|
+
raise ValueError("Currency pair must be 6 characters (e.g., 'EURUSD')")
|
75
|
+
|
76
|
+
foreign_ccy = currency_pair[:3] # First 3 characters
|
77
|
+
domestic_ccy = currency_pair[3:] # Last 3 characters
|
78
|
+
|
79
|
+
# Fetch market data using the data interface
|
80
|
+
asset_range_map = {currency_pair: DateInfo(start_date=calculation_date)}
|
81
|
+
market_data = data_interface.get_historical_data("fx_options", asset_range_map)
|
82
|
+
|
83
|
+
return {
|
84
|
+
"spot_fx_rate": market_data["spot_fx_rate"],
|
85
|
+
"volatility": market_data["volatility"],
|
86
|
+
"domestic_rate": market_data["domestic_rate"],
|
87
|
+
"foreign_rate": market_data["foreign_rate"],
|
88
|
+
"foreign_currency": foreign_ccy,
|
89
|
+
"domestic_currency": domestic_ccy
|
90
|
+
}
|