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,146 @@
|
|
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 get_fx_market_data
|
8
|
+
from mainsequence.instruments.pricing_models.knockout_fx_pricer import create_knockout_fx_option
|
9
|
+
from mainsequence.instruments.utils import to_ql_date
|
10
|
+
|
11
|
+
|
12
|
+
from .base_instrument import InstrumentModel
|
13
|
+
|
14
|
+
class KnockOutFXOption(InstrumentModel):
|
15
|
+
"""
|
16
|
+
Knock-out FX option - a path-dependent option that becomes worthless
|
17
|
+
if the underlying FX rate hits the barrier level during the option's life.
|
18
|
+
"""
|
19
|
+
|
20
|
+
currency_pair: str = Field(
|
21
|
+
..., description="Currency pair in format 'EURUSD', 'GBPUSD', etc. (6 characters)."
|
22
|
+
)
|
23
|
+
strike: float = Field(
|
24
|
+
..., description="Option strike price (domestic currency per unit of foreign currency)."
|
25
|
+
)
|
26
|
+
barrier: float = Field(
|
27
|
+
..., description="Barrier level - option is knocked out if FX rate hits this level."
|
28
|
+
)
|
29
|
+
maturity: datetime.date = Field(
|
30
|
+
..., description="Option expiration date."
|
31
|
+
)
|
32
|
+
option_type: Literal["call", "put"] = Field(
|
33
|
+
..., description="Option type: 'call' or 'put'."
|
34
|
+
)
|
35
|
+
barrier_type: Literal["up_and_out", "down_and_out"] = Field(
|
36
|
+
..., description="Barrier type: 'up_and_out' (knocked out if rate goes above barrier) or 'down_and_out' (knocked out if rate goes below barrier)."
|
37
|
+
)
|
38
|
+
notional: float = Field(
|
39
|
+
..., description="Notional amount in foreign currency units."
|
40
|
+
)
|
41
|
+
rebate: float = Field(
|
42
|
+
default=0.0, description="Rebate paid if option is knocked out (default: 0.0)."
|
43
|
+
)
|
44
|
+
|
45
|
+
|
46
|
+
# Allow QuantLib types & keep runtime attrs out of the schema
|
47
|
+
model_config = {"arbitrary_types_allowed": True}
|
48
|
+
|
49
|
+
# Runtime-only QuantLib objects
|
50
|
+
_option: Optional[ql.BarrierOption] = PrivateAttr(default=None)
|
51
|
+
_engine: Optional[ql.PricingEngine] = PrivateAttr(default=None)
|
52
|
+
|
53
|
+
def _setup_pricing_components(self) -> None:
|
54
|
+
"""Set up the QuantLib pricing components for the knock-out FX option."""
|
55
|
+
# 1) Validate currency pair format
|
56
|
+
if len(self.currency_pair) != 6:
|
57
|
+
raise ValueError("Currency pair must be 6 characters (e.g., 'EURUSD')")
|
58
|
+
|
59
|
+
# 2) Validate barrier logic
|
60
|
+
market_data = get_fx_market_data(self.currency_pair, self.valuation_date)
|
61
|
+
spot_fx = market_data["spot_fx_rate"]
|
62
|
+
|
63
|
+
if self.barrier_type == "up_and_out" and self.barrier <= spot_fx:
|
64
|
+
raise ValueError("For up-and-out barrier, barrier level must be above current spot rate")
|
65
|
+
elif self.barrier_type == "down_and_out" and self.barrier >= spot_fx:
|
66
|
+
raise ValueError("For down-and-out barrier, barrier level must be below current spot rate")
|
67
|
+
|
68
|
+
# 3) Convert dates to QuantLib format
|
69
|
+
ql_calc = to_ql_date(self.valuation_date)
|
70
|
+
ql_mty = to_ql_date(self.maturity)
|
71
|
+
ql.Settings.instance().evaluationDate = ql_calc
|
72
|
+
|
73
|
+
# 4) Create the barrier option using the specialized pricer
|
74
|
+
self._option, self._engine = create_knockout_fx_option(
|
75
|
+
currency_pair=self.currency_pair,
|
76
|
+
calculation_date=ql_calc,
|
77
|
+
maturity_date=ql_mty,
|
78
|
+
strike=self.strike,
|
79
|
+
barrier=self.barrier,
|
80
|
+
option_type=self.option_type,
|
81
|
+
barrier_type=self.barrier_type,
|
82
|
+
rebate=self.rebate
|
83
|
+
)
|
84
|
+
|
85
|
+
def price(self) -> float:
|
86
|
+
"""Calculate the knock-out option price (NPV)."""
|
87
|
+
if not self._option:
|
88
|
+
self._setup_pricing_components()
|
89
|
+
# Return price multiplied by notional
|
90
|
+
return float(self._option.NPV() * self.notional)
|
91
|
+
|
92
|
+
def get_greeks(self) -> dict:
|
93
|
+
"""Calculate the option Greeks."""
|
94
|
+
if not self._option:
|
95
|
+
self._setup_pricing_components()
|
96
|
+
|
97
|
+
# Ensure calculations are performed
|
98
|
+
npv = self._option.NPV()
|
99
|
+
|
100
|
+
return {
|
101
|
+
"delta": self._option.delta() * self.notional,
|
102
|
+
"gamma": self._option.gamma() * self.notional,
|
103
|
+
"vega": self._option.vega() * self.notional / 100.0, # Convert to 1% vol change
|
104
|
+
"theta": self._option.theta() * self.notional / 365.0, # Convert to per day
|
105
|
+
"rho_domestic": self._option.rho() * self.notional / 100.0, # Convert to 1% rate change
|
106
|
+
}
|
107
|
+
|
108
|
+
def get_market_info(self) -> dict:
|
109
|
+
"""Get the market data used for pricing."""
|
110
|
+
market_data = get_fx_market_data(self.currency_pair, self.valuation_date)
|
111
|
+
foreign_ccy = self.currency_pair[:3]
|
112
|
+
domestic_ccy = self.currency_pair[3:]
|
113
|
+
|
114
|
+
return {
|
115
|
+
"currency_pair": self.currency_pair,
|
116
|
+
"foreign_currency": foreign_ccy,
|
117
|
+
"domestic_currency": domestic_ccy,
|
118
|
+
"spot_fx_rate": market_data["spot_fx_rate"],
|
119
|
+
"volatility": market_data["volatility"],
|
120
|
+
"domestic_rate": market_data["domestic_rate"],
|
121
|
+
"foreign_rate": market_data["foreign_rate"],
|
122
|
+
"barrier": self.barrier,
|
123
|
+
"barrier_type": self.barrier_type,
|
124
|
+
"rebate": self.rebate
|
125
|
+
}
|
126
|
+
|
127
|
+
def get_barrier_info(self) -> dict:
|
128
|
+
"""Get information about the barrier and current market position."""
|
129
|
+
market_data = get_fx_market_data(self.currency_pair, self.valuation_date)
|
130
|
+
spot_fx = market_data["spot_fx_rate"]
|
131
|
+
|
132
|
+
if self.barrier_type == "up_and_out":
|
133
|
+
distance_to_barrier = (self.barrier - spot_fx) / spot_fx
|
134
|
+
barrier_status = "Active" if spot_fx < self.barrier else "Knocked Out"
|
135
|
+
else: # down_and_out
|
136
|
+
distance_to_barrier = (spot_fx - self.barrier) / spot_fx
|
137
|
+
barrier_status = "Active" if spot_fx > self.barrier else "Knocked Out"
|
138
|
+
|
139
|
+
return {
|
140
|
+
"barrier_level": self.barrier,
|
141
|
+
"barrier_type": self.barrier_type,
|
142
|
+
"current_spot": spot_fx,
|
143
|
+
"distance_to_barrier_pct": distance_to_barrier * 100,
|
144
|
+
"barrier_status": barrier_status,
|
145
|
+
"rebate": self.rebate
|
146
|
+
}
|
@@ -0,0 +1,475 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import datetime
|
4
|
+
from collections import defaultdict
|
5
|
+
from dataclasses import dataclass
|
6
|
+
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
7
|
+
|
8
|
+
from pydantic import BaseModel, Field, field_validator
|
9
|
+
|
10
|
+
from .base_instrument import InstrumentModel as Instrument # runtime_checkable Protocol: requires .price() -> float
|
11
|
+
|
12
|
+
from typing import Type, Mapping
|
13
|
+
|
14
|
+
from .european_option import EuropeanOption
|
15
|
+
from .vanilla_fx_option import VanillaFXOption
|
16
|
+
from .knockout_fx_option import KnockOutFXOption
|
17
|
+
from .bond import FixedRateBond
|
18
|
+
from .bond import FloatingRateBond
|
19
|
+
from .interest_rate_swap import InterestRateSwap
|
20
|
+
import pandas as pd
|
21
|
+
import numpy as np
|
22
|
+
Instrument._DEFAULT_REGISTRY.update({
|
23
|
+
"EuropeanOption": globals().get("EuropeanOption"),
|
24
|
+
"VanillaFXOption": globals().get("VanillaFXOption"),
|
25
|
+
"KnockOutFXOption": globals().get("KnockOutFXOption"),
|
26
|
+
"FixedRateBond": globals().get("FixedRateBond"),
|
27
|
+
"FloatingRateBond": globals().get("FloatingRateBond"),
|
28
|
+
"InterestRateSwap": globals().get("InterestRateSwap"),
|
29
|
+
})
|
30
|
+
# Optionally: prune any Nones if some classes aren't imported yet
|
31
|
+
Instrument._DEFAULT_REGISTRY = {k: v for k, v in Instrument._DEFAULT_REGISTRY.items() if v is not None}
|
32
|
+
|
33
|
+
@dataclass(frozen=True)
|
34
|
+
class PositionLine:
|
35
|
+
"""
|
36
|
+
A single position: an instrument and the number of units held.
|
37
|
+
Units may be negative for short positions.
|
38
|
+
"""
|
39
|
+
instrument: Instrument
|
40
|
+
units: float
|
41
|
+
extra_market_info:dict = None
|
42
|
+
|
43
|
+
def unit_price(self) -> float:
|
44
|
+
return float(self.instrument.price())
|
45
|
+
|
46
|
+
def market_value(self) -> float:
|
47
|
+
return self.units * self.unit_price()
|
48
|
+
|
49
|
+
|
50
|
+
class Position(BaseModel):
|
51
|
+
"""
|
52
|
+
A collection of instrument positions with convenient aggregations.
|
53
|
+
|
54
|
+
- Each line is an (instrument, units) pair.
|
55
|
+
- `price()` returns the sum of units * instrument.price().
|
56
|
+
- `get_cashflows(aggregate=...)` merges cashflows from instruments that expose `get_cashflows()`.
|
57
|
+
* Expects each instrument's `get_cashflows()` to return a dict[str, list[dict]], like the swap.
|
58
|
+
* Amounts are scaled by `units`. Unknown structures are passed through best-effort.
|
59
|
+
- `get_greeks()` sums greeks from instruments that expose `get_greeks()`.
|
60
|
+
"""
|
61
|
+
|
62
|
+
lines: List[PositionLine] = Field(default_factory=list)
|
63
|
+
position_date:Optional[datetime.datetime]=None
|
64
|
+
model_config = {"arbitrary_types_allowed": True}
|
65
|
+
|
66
|
+
|
67
|
+
|
68
|
+
|
69
|
+
@classmethod
|
70
|
+
def from_json_dict(
|
71
|
+
cls,
|
72
|
+
data: Dict[str, Any],
|
73
|
+
registry: Optional[Mapping[str, Type]] = None
|
74
|
+
) -> "Position":
|
75
|
+
# default registry with your known instruments
|
76
|
+
|
77
|
+
lines: List[PositionLine] = []
|
78
|
+
for item in data.get("lines", []):
|
79
|
+
inst = Instrument.rebuild(item, registry=registry)
|
80
|
+
units = item["units"]
|
81
|
+
extra_market_info = item.get("extra_market_info")
|
82
|
+
lines.append(PositionLine(instrument=inst, units=units, extra_market_info=extra_market_info))
|
83
|
+
return cls(lines=lines)
|
84
|
+
|
85
|
+
# ---------------- JSON ENCODING ----------------
|
86
|
+
|
87
|
+
def _instrument_payload(self, inst: Any) -> Dict[str, Any]:
|
88
|
+
"""
|
89
|
+
Robustly obtain a JSON-serializable dict from an instrument.
|
90
|
+
Tries, in order: to_json_dict(), to_json() (parse), model_dump(mode="json").
|
91
|
+
"""
|
92
|
+
# 1) Preferred: your JSONMixin path
|
93
|
+
to_jd = getattr(inst, "to_json_dict", None)
|
94
|
+
if callable(to_jd):
|
95
|
+
payload = to_jd()
|
96
|
+
if isinstance(payload, dict):
|
97
|
+
return payload
|
98
|
+
|
99
|
+
# 2) Accept a JSON string and parse it
|
100
|
+
to_js = getattr(inst, "to_json", None)
|
101
|
+
if callable(to_js):
|
102
|
+
s = to_js()
|
103
|
+
if isinstance(s, (bytes, bytearray)):
|
104
|
+
s = s.decode("utf-8")
|
105
|
+
if isinstance(s, str):
|
106
|
+
try:
|
107
|
+
obj = json.loads(s)
|
108
|
+
if isinstance(obj, dict):
|
109
|
+
return obj
|
110
|
+
except Exception:
|
111
|
+
pass # fall through
|
112
|
+
|
113
|
+
# 3) Pydantic models without JSONMixin
|
114
|
+
md = getattr(inst, "model_dump", None)
|
115
|
+
if callable(md):
|
116
|
+
return md(mode="json")
|
117
|
+
|
118
|
+
raise TypeError(
|
119
|
+
f"Instrument {type(inst).__name__} is not JSON-serializable. "
|
120
|
+
f"Provide to_json_dict()/to_json() or a Pydantic model."
|
121
|
+
)
|
122
|
+
|
123
|
+
def to_json_dict(self) -> Dict[str, Any]:
|
124
|
+
"""
|
125
|
+
Serialize the position as:
|
126
|
+
{
|
127
|
+
"lines": [
|
128
|
+
{ "instrument_type": "...", "instrument": { ... }, "units": <float> },
|
129
|
+
...
|
130
|
+
]
|
131
|
+
}
|
132
|
+
"""
|
133
|
+
out_lines: list[dict] = []
|
134
|
+
for line in self.lines:
|
135
|
+
inst = line.instrument
|
136
|
+
out_lines.append({
|
137
|
+
"instrument_type": type(inst).__name__,
|
138
|
+
"instrument": self._instrument_payload(inst),
|
139
|
+
"units": float(line.units),
|
140
|
+
"extra_market_info":line.extra_market_info
|
141
|
+
})
|
142
|
+
return {"lines": out_lines}
|
143
|
+
|
144
|
+
# ---- validation ---------------------------------------------------------
|
145
|
+
@field_validator("lines")
|
146
|
+
@classmethod
|
147
|
+
def _validate_lines(cls, v: List[PositionLine]) -> List[PositionLine]:
|
148
|
+
for i, line in enumerate(v):
|
149
|
+
inst = line.instrument
|
150
|
+
# Accept anything implementing the Instrument Protocol (price() -> float)
|
151
|
+
if not hasattr(inst, "price") or not callable(getattr(inst, "price")):
|
152
|
+
raise TypeError(
|
153
|
+
f"lines[{i}].instrument must implement price() -> float; got {type(inst).__name__}"
|
154
|
+
)
|
155
|
+
return v
|
156
|
+
|
157
|
+
# ---- mutation helpers ---------------------------------------------------
|
158
|
+
def add(self, instrument: Instrument, units: float = 1.0) -> None:
|
159
|
+
"""Append a new position line."""
|
160
|
+
self.lines.append(PositionLine(instrument=instrument, units=units))
|
161
|
+
|
162
|
+
def extend(self, items: Iterable[Tuple[Instrument, float]]) -> None:
|
163
|
+
"""Append many (instrument, units) items."""
|
164
|
+
for inst, qty in items:
|
165
|
+
self.add(inst, qty)
|
166
|
+
|
167
|
+
# ---- pricing ------------------------------------------------------------
|
168
|
+
def price(self) -> float:
|
169
|
+
"""Total market value: Σ units * instrument.price()."""
|
170
|
+
return float(sum(line.market_value() for line in self.lines))
|
171
|
+
|
172
|
+
def price_breakdown(self) -> List[Dict[str, Any]]:
|
173
|
+
"""
|
174
|
+
Line-by-line price decomposition.
|
175
|
+
Returns: [{instrument, units, unit_price, market_value}, ...]
|
176
|
+
"""
|
177
|
+
out: List[Dict[str, Any]] = []
|
178
|
+
for line in self.lines:
|
179
|
+
out.append(
|
180
|
+
{
|
181
|
+
"instrument": type(line.instrument).__name__,
|
182
|
+
"units": line.units,
|
183
|
+
"unit_price": line.unit_price(),
|
184
|
+
"market_value": line.market_value(),
|
185
|
+
}
|
186
|
+
)
|
187
|
+
return out
|
188
|
+
|
189
|
+
# ---- cashflows ----------------------------------------------------------
|
190
|
+
def get_cashflows(self, aggregate: bool = False) -> Dict[str, List[Dict[str, Any]]]:
|
191
|
+
"""
|
192
|
+
Merge cashflows from all instruments that implement `get_cashflows()`.
|
193
|
+
|
194
|
+
Returns a dict keyed by leg/label (e.g., "fixed", "floating") with lists of cashflow dicts.
|
195
|
+
Each cashflow's 'amount' is scaled by position units. Original fields are preserved;
|
196
|
+
metadata 'instrument' and 'units' are added for traceability.
|
197
|
+
|
198
|
+
If aggregate=True, amounts are summed by payment date within each leg.
|
199
|
+
"""
|
200
|
+
combined: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
|
201
|
+
|
202
|
+
for idx, line in enumerate(self.lines):
|
203
|
+
inst = line.instrument
|
204
|
+
if not hasattr(inst, "get_cashflows"):
|
205
|
+
continue # silently skip instruments without cashflows
|
206
|
+
flows = inst.get_cashflows() # type: ignore[attr-defined]
|
207
|
+
if not isinstance(flows, dict):
|
208
|
+
continue
|
209
|
+
|
210
|
+
for leg, items in flows.items():
|
211
|
+
if not isinstance(items, (list, tuple)):
|
212
|
+
continue
|
213
|
+
for cf in items:
|
214
|
+
if not isinstance(cf, dict):
|
215
|
+
continue
|
216
|
+
scaled = dict(cf) # shallow copy
|
217
|
+
# scale common amount field if present
|
218
|
+
if "amount" in scaled and isinstance(scaled["amount"], (int, float)):
|
219
|
+
scaled["amount"] = float(scaled["amount"]) * line.units
|
220
|
+
# annotate
|
221
|
+
scaled.setdefault("instrument", type(inst).__name__)
|
222
|
+
scaled.setdefault("units", line.units)
|
223
|
+
scaled.setdefault("position_index", idx)
|
224
|
+
combined[leg].append(scaled)
|
225
|
+
|
226
|
+
if not aggregate:
|
227
|
+
return dict(combined)
|
228
|
+
|
229
|
+
# Aggregate amounts by payment date (fallback to 'date' or 'fixing_date' if needed)
|
230
|
+
aggregated: Dict[str, List[Dict[str, Any]]] = {}
|
231
|
+
for leg, items in combined.items():
|
232
|
+
buckets: Dict[datetime.date, float] = defaultdict(float)
|
233
|
+
exemplars: Dict[datetime.date, Dict[str, Any]] = {}
|
234
|
+
|
235
|
+
for cf in items:
|
236
|
+
# identify a date field
|
237
|
+
dt = (
|
238
|
+
cf.get("payment_date")
|
239
|
+
or cf.get("date")
|
240
|
+
or cf.get("fixing_date")
|
241
|
+
)
|
242
|
+
if isinstance(dt, datetime.date):
|
243
|
+
amount = float(cf.get("amount", 0.0))
|
244
|
+
buckets[dt] += amount
|
245
|
+
# keep exemplar fields for output ordering/context
|
246
|
+
if dt not in exemplars:
|
247
|
+
exemplars[dt] = {k: v for k, v in cf.items() if k not in {"amount", "units", "position_index"}}
|
248
|
+
# if no usable date, just pass through (unaggregated)
|
249
|
+
else:
|
250
|
+
buckets_key = None # sentinel
|
251
|
+
# Collect undated flows under today's key to avoid loss
|
252
|
+
buckets[datetime.date.today()] += float(cf.get("amount", 0.0))
|
253
|
+
|
254
|
+
# build sorted list
|
255
|
+
leg_rows: List[Dict[str, Any]] = []
|
256
|
+
for dt, amt in sorted(buckets.items(), key=lambda kv: kv[0]):
|
257
|
+
row = {"payment_date": dt, "amount": amt}
|
258
|
+
# attach exemplar metadata if any
|
259
|
+
ex = exemplars.get(dt)
|
260
|
+
if ex:
|
261
|
+
row.update({k: v for k, v in ex.items() if k in ("leg", "rate", "spread")})
|
262
|
+
leg_rows.append(row)
|
263
|
+
aggregated[leg] = leg_rows
|
264
|
+
|
265
|
+
return aggregated
|
266
|
+
|
267
|
+
# ---- greeks (optional) --------------------------------------------------
|
268
|
+
def get_greeks(self) -> Dict[str, float]:
|
269
|
+
"""
|
270
|
+
Aggregate greeks from instruments that implement `get_greeks()`.
|
271
|
+
|
272
|
+
For each instrument i with dictionary Gi and units ui, returns Σ ui * Gi[key].
|
273
|
+
Keys not common across all instruments are included on a best-effort basis.
|
274
|
+
"""
|
275
|
+
totals: Dict[str, float] = defaultdict(float)
|
276
|
+
for line in self.lines:
|
277
|
+
inst = line.instrument
|
278
|
+
getg = getattr(inst, "get_greeks", None)
|
279
|
+
if callable(getg):
|
280
|
+
g = getg()
|
281
|
+
if isinstance(g, dict):
|
282
|
+
for k, v in g.items():
|
283
|
+
if isinstance(v, (int, float)):
|
284
|
+
totals[k] += line.units * float(v)
|
285
|
+
return dict(totals)
|
286
|
+
|
287
|
+
# ---- convenience constructors -------------------------------------------
|
288
|
+
@classmethod
|
289
|
+
def from_single(cls, instrument: Instrument, units: float = 1.0) -> "Position":
|
290
|
+
return cls(lines=[PositionLine(instrument=instrument, units=units)])
|
291
|
+
|
292
|
+
# Mao interface
|
293
|
+
|
294
|
+
def units_by_id(self) -> Dict[str, float]:
|
295
|
+
"""Map instrument id -> units."""
|
296
|
+
return {line.instrument.content_hash(): float(line.units) for line in self.lines}
|
297
|
+
|
298
|
+
def npvs_by_id(self, *, apply_units: bool = True) -> Dict[str, float]:
|
299
|
+
"""
|
300
|
+
Return PVs per instrument id. If apply_units=True, PVs are already scaled by line.units.
|
301
|
+
"""
|
302
|
+
out: Dict[str, float] = {}
|
303
|
+
for line in self.lines:
|
304
|
+
ins = line.instrument
|
305
|
+
ins_id = ins.content_hash()
|
306
|
+
pv = float(ins.price())
|
307
|
+
if apply_units:
|
308
|
+
pv *= float(line.units)
|
309
|
+
out[ins_id] = pv
|
310
|
+
return out
|
311
|
+
|
312
|
+
def cashflows_by_id(self,
|
313
|
+
cutoff: Optional[datetime.date] = None,
|
314
|
+
*,
|
315
|
+
apply_units: bool = True) -> pd.DataFrame:
|
316
|
+
"""
|
317
|
+
Aggregate cashflows across all lines.
|
318
|
+
|
319
|
+
Returns a DataFrame with columns: ['ins_id', 'payment_date', 'amount'].
|
320
|
+
If apply_units=True, amounts are multiplied by line.units.
|
321
|
+
"""
|
322
|
+
rows = []
|
323
|
+
for line in self.lines:
|
324
|
+
ins = line.instrument
|
325
|
+
ins_id = ins.content_hash()
|
326
|
+
|
327
|
+
s = ins.get_net_cashflows() # Expect Series indexed by payment_date
|
328
|
+
if s is None:
|
329
|
+
continue
|
330
|
+
if not isinstance(s, pd.Series):
|
331
|
+
# Be conservative: try converting if possible; otherwise skip
|
332
|
+
try:
|
333
|
+
s = pd.Series(s)
|
334
|
+
except Exception:
|
335
|
+
continue
|
336
|
+
|
337
|
+
df = s.to_frame("amount").reset_index()
|
338
|
+
# Normalize index/column name for payment date
|
339
|
+
if "payment_date" not in df.columns:
|
340
|
+
# typical reset_index name is 'index' or the original index name
|
341
|
+
idx_col = "payment_date" if s.index.name == "payment_date" else "index"
|
342
|
+
df = df.rename(columns={idx_col: "payment_date"})
|
343
|
+
|
344
|
+
if cutoff is not None:
|
345
|
+
df = df[df["payment_date"] <= cutoff]
|
346
|
+
|
347
|
+
if apply_units:
|
348
|
+
df["amount"] = df["amount"].astype(float) * float(line.units)
|
349
|
+
else:
|
350
|
+
df["amount"] = df["amount"].astype(float)
|
351
|
+
|
352
|
+
df["ins_id"] = ins_id
|
353
|
+
rows.append(df[["ins_id", "payment_date", "amount"]])
|
354
|
+
|
355
|
+
if not rows:
|
356
|
+
return pd.DataFrame(columns=["ins_id", "payment_date", "amount"])
|
357
|
+
|
358
|
+
return pd.concat(rows, ignore_index=True)
|
359
|
+
|
360
|
+
def agg_net_cashflows(self) -> pd.DataFrame:
|
361
|
+
"""
|
362
|
+
Aggregate 'net' cashflows from all instruments.
|
363
|
+
Preferred: instrument.get_net_cashflows() -> pd.Series indexed by payment_date.
|
364
|
+
Fallback: instrument.get_cashflows() -> dict[leg] -> list[dict] with 'amount' and a date field.
|
365
|
+
Returns DataFrame with ['payment_date','amount'] summed across instruments & units.
|
366
|
+
"""
|
367
|
+
rows = []
|
368
|
+
for line in self.lines:
|
369
|
+
inst = line.instrument
|
370
|
+
units = float(line.units)
|
371
|
+
|
372
|
+
# Preferred API (already used in your other app)
|
373
|
+
s = getattr(inst, "get_net_cashflows", None)
|
374
|
+
if callable(s):
|
375
|
+
ser = s()
|
376
|
+
if isinstance(ser, pd.Series):
|
377
|
+
df = ser.to_frame("amount").reset_index() # index is payment_date
|
378
|
+
# Normalize column name
|
379
|
+
if "index" in df.columns and "payment_date" not in df.columns:
|
380
|
+
df = df.rename(columns={"index": "payment_date"})
|
381
|
+
df["amount"] = df["amount"].astype(float) * units
|
382
|
+
rows.append(df[["payment_date", "amount"]])
|
383
|
+
continue # next line
|
384
|
+
|
385
|
+
# Fallback: flatten get_cashflows()
|
386
|
+
g = getattr(inst, "get_cashflows", None)
|
387
|
+
if callable(g):
|
388
|
+
flows = g()
|
389
|
+
flat = []
|
390
|
+
for leg, items in (flows or {}).items():
|
391
|
+
for cf in (items or []):
|
392
|
+
pay = cf.get("payment_date") or cf.get("date") or cf.get("pay_date") or cf.get("fixing_date")
|
393
|
+
amt = cf.get("amount")
|
394
|
+
if pay is None or amt is None:
|
395
|
+
continue
|
396
|
+
flat.append({"payment_date": pd.to_datetime(pay).date(), "amount": float(amt) * units})
|
397
|
+
if flat:
|
398
|
+
rows.append(pd.DataFrame(flat))
|
399
|
+
|
400
|
+
if not rows:
|
401
|
+
return pd.DataFrame(columns=["payment_date", "amount"])
|
402
|
+
|
403
|
+
df_all = pd.concat(rows, ignore_index=True)
|
404
|
+
df_all["payment_date"] = pd.to_datetime(df_all["payment_date"]).dt.date
|
405
|
+
df_all = df_all.groupby("payment_date", as_index=False)["amount"].sum()
|
406
|
+
return df_all
|
407
|
+
|
408
|
+
def position_total_npv(self) -> float:
|
409
|
+
"""Σ units * instrument.price()."""
|
410
|
+
tot = 0.0
|
411
|
+
for line in self.lines:
|
412
|
+
tot += float(line.units) * float(line.instrument.price())
|
413
|
+
return float(tot)
|
414
|
+
|
415
|
+
def position_carry_to_cutoff(self, valuation_date: datetime.date, cutoff: datetime.date) -> float:
|
416
|
+
"""
|
417
|
+
Carry = Σ net cashflow amounts with valuation_date < payment_date ≤ cutoff.
|
418
|
+
Positive = inflow to the bank; negative = outflow.
|
419
|
+
"""
|
420
|
+
cf = self.agg_net_cashflows()
|
421
|
+
if cf.empty:
|
422
|
+
return 0.0
|
423
|
+
mask = (cf["payment_date"] > valuation_date) & (cf["payment_date"] <= cutoff)
|
424
|
+
return float(cf.loc[mask, "amount"].sum())
|
425
|
+
|
426
|
+
def npv_table(npv_base: Dict[str, float],
|
427
|
+
npv_bumped: Optional[Dict[str, float]] = None,
|
428
|
+
units: Optional[Dict[str, float]] = None,
|
429
|
+
*,
|
430
|
+
include_total: bool = True) -> pd.DataFrame:
|
431
|
+
"""
|
432
|
+
Build a raw (unformatted) NPV table for programmatic use.
|
433
|
+
|
434
|
+
Columns: instrument, units, base, bumped, delta (bumped/delta are NaN if npv_bumped is None)
|
435
|
+
"""
|
436
|
+
ids = sorted(npv_base.keys())
|
437
|
+
rows = []
|
438
|
+
for ins_id in ids:
|
439
|
+
base = float(npv_base.get(ins_id, np.nan))
|
440
|
+
bumped = float(npv_bumped.get(ins_id, np.nan)) if npv_bumped is not None else np.nan
|
441
|
+
delta = bumped - base if npv_bumped is not None and np.isfinite(base) and np.isfinite(bumped) else np.nan
|
442
|
+
u = float(units.get(ins_id, np.nan)) if units else np.nan
|
443
|
+
rows.append({"instrument": ins_id, "units": u, "base": base, "bumped": bumped, "delta": delta})
|
444
|
+
|
445
|
+
df = pd.DataFrame(rows)
|
446
|
+
|
447
|
+
if include_total and not df.empty:
|
448
|
+
tot = {
|
449
|
+
"instrument": "TOTAL",
|
450
|
+
"units": np.nan,
|
451
|
+
"base": df["base"].sum(skipna=True),
|
452
|
+
"bumped": df["bumped"].sum(skipna=True) if npv_bumped is not None else np.nan,
|
453
|
+
"delta": df["delta"].sum(skipna=True) if npv_bumped is not None else np.nan,
|
454
|
+
}
|
455
|
+
df = pd.concat([df, pd.DataFrame([tot])], ignore_index=True)
|
456
|
+
|
457
|
+
return df
|
458
|
+
|
459
|
+
def portfolio_stats(position, bumped_position, valuation_date: datetime.date, cutoff: datetime.date):
|
460
|
+
"""
|
461
|
+
Returns a dict with base/bumped NPV and Carry to cutoff, plus deltas.
|
462
|
+
"""
|
463
|
+
npv_base = position.position_total_npv()
|
464
|
+
npv_bump = bumped_position.position_total_npv()
|
465
|
+
carry_base = position.position_carry_to_cutoff( valuation_date, cutoff)
|
466
|
+
carry_bump = bumped_position.position_carry_to_cutoff( valuation_date, cutoff)
|
467
|
+
|
468
|
+
return {
|
469
|
+
"npv_base": npv_base,
|
470
|
+
"npv_bumped": npv_bump,
|
471
|
+
"npv_delta": npv_bump - npv_base,
|
472
|
+
"carry_base": carry_base,
|
473
|
+
"carry_bumped": carry_bump,
|
474
|
+
"carry_delta": carry_bump - carry_base,
|
475
|
+
}
|