mainsequence 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. mainsequence/__init__.py +0 -0
  2. mainsequence/__main__.py +9 -0
  3. mainsequence/cli/__init__.py +1 -0
  4. mainsequence/cli/api.py +157 -0
  5. mainsequence/cli/cli.py +442 -0
  6. mainsequence/cli/config.py +78 -0
  7. mainsequence/cli/ssh_utils.py +126 -0
  8. mainsequence/client/__init__.py +17 -0
  9. mainsequence/client/base.py +431 -0
  10. mainsequence/client/data_sources_interfaces/__init__.py +0 -0
  11. mainsequence/client/data_sources_interfaces/duckdb.py +1468 -0
  12. mainsequence/client/data_sources_interfaces/timescale.py +479 -0
  13. mainsequence/client/models_helpers.py +113 -0
  14. mainsequence/client/models_report_studio.py +412 -0
  15. mainsequence/client/models_tdag.py +2276 -0
  16. mainsequence/client/models_vam.py +1983 -0
  17. mainsequence/client/utils.py +387 -0
  18. mainsequence/dashboards/__init__.py +0 -0
  19. mainsequence/dashboards/streamlit/__init__.py +0 -0
  20. mainsequence/dashboards/streamlit/assets/config.toml +12 -0
  21. mainsequence/dashboards/streamlit/assets/favicon.png +0 -0
  22. mainsequence/dashboards/streamlit/assets/logo.png +0 -0
  23. mainsequence/dashboards/streamlit/core/__init__.py +0 -0
  24. mainsequence/dashboards/streamlit/core/theme.py +212 -0
  25. mainsequence/dashboards/streamlit/pages/__init__.py +0 -0
  26. mainsequence/dashboards/streamlit/scaffold.py +220 -0
  27. mainsequence/instrumentation/__init__.py +7 -0
  28. mainsequence/instrumentation/utils.py +101 -0
  29. mainsequence/instruments/__init__.py +1 -0
  30. mainsequence/instruments/data_interface/__init__.py +10 -0
  31. mainsequence/instruments/data_interface/data_interface.py +361 -0
  32. mainsequence/instruments/instruments/__init__.py +3 -0
  33. mainsequence/instruments/instruments/base_instrument.py +85 -0
  34. mainsequence/instruments/instruments/bond.py +447 -0
  35. mainsequence/instruments/instruments/european_option.py +74 -0
  36. mainsequence/instruments/instruments/interest_rate_swap.py +217 -0
  37. mainsequence/instruments/instruments/json_codec.py +585 -0
  38. mainsequence/instruments/instruments/knockout_fx_option.py +146 -0
  39. mainsequence/instruments/instruments/position.py +475 -0
  40. mainsequence/instruments/instruments/ql_fields.py +239 -0
  41. mainsequence/instruments/instruments/vanilla_fx_option.py +107 -0
  42. mainsequence/instruments/pricing_models/__init__.py +0 -0
  43. mainsequence/instruments/pricing_models/black_scholes.py +49 -0
  44. mainsequence/instruments/pricing_models/bond_pricer.py +182 -0
  45. mainsequence/instruments/pricing_models/fx_option_pricer.py +90 -0
  46. mainsequence/instruments/pricing_models/indices.py +350 -0
  47. mainsequence/instruments/pricing_models/knockout_fx_pricer.py +209 -0
  48. mainsequence/instruments/pricing_models/swap_pricer.py +502 -0
  49. mainsequence/instruments/settings.py +175 -0
  50. mainsequence/instruments/utils.py +29 -0
  51. mainsequence/logconf.py +284 -0
  52. mainsequence/reportbuilder/__init__.py +0 -0
  53. mainsequence/reportbuilder/__main__.py +0 -0
  54. mainsequence/reportbuilder/examples/ms_template_report.py +706 -0
  55. mainsequence/reportbuilder/model.py +713 -0
  56. mainsequence/reportbuilder/slide_templates.py +532 -0
  57. mainsequence/tdag/__init__.py +8 -0
  58. mainsequence/tdag/__main__.py +0 -0
  59. mainsequence/tdag/config.py +129 -0
  60. mainsequence/tdag/data_nodes/__init__.py +12 -0
  61. mainsequence/tdag/data_nodes/build_operations.py +751 -0
  62. mainsequence/tdag/data_nodes/data_nodes.py +1292 -0
  63. mainsequence/tdag/data_nodes/persist_managers.py +812 -0
  64. mainsequence/tdag/data_nodes/run_operations.py +543 -0
  65. mainsequence/tdag/data_nodes/utils.py +24 -0
  66. mainsequence/tdag/future_registry.py +25 -0
  67. mainsequence/tdag/utils.py +40 -0
  68. mainsequence/virtualfundbuilder/__init__.py +45 -0
  69. mainsequence/virtualfundbuilder/__main__.py +235 -0
  70. mainsequence/virtualfundbuilder/agent_interface.py +77 -0
  71. mainsequence/virtualfundbuilder/config_handling.py +86 -0
  72. mainsequence/virtualfundbuilder/contrib/__init__.py +0 -0
  73. mainsequence/virtualfundbuilder/contrib/apps/__init__.py +8 -0
  74. mainsequence/virtualfundbuilder/contrib/apps/etf_replicator_app.py +164 -0
  75. mainsequence/virtualfundbuilder/contrib/apps/generate_report.py +292 -0
  76. mainsequence/virtualfundbuilder/contrib/apps/load_external_portfolio.py +107 -0
  77. mainsequence/virtualfundbuilder/contrib/apps/news_app.py +437 -0
  78. mainsequence/virtualfundbuilder/contrib/apps/portfolio_report_app.py +91 -0
  79. mainsequence/virtualfundbuilder/contrib/apps/portfolio_table.py +95 -0
  80. mainsequence/virtualfundbuilder/contrib/apps/run_named_portfolio.py +45 -0
  81. mainsequence/virtualfundbuilder/contrib/apps/run_portfolio.py +40 -0
  82. mainsequence/virtualfundbuilder/contrib/apps/templates/base.html +147 -0
  83. mainsequence/virtualfundbuilder/contrib/apps/templates/report.html +77 -0
  84. mainsequence/virtualfundbuilder/contrib/data_nodes/__init__.py +5 -0
  85. mainsequence/virtualfundbuilder/contrib/data_nodes/external_weights.py +61 -0
  86. mainsequence/virtualfundbuilder/contrib/data_nodes/intraday_trend.py +149 -0
  87. mainsequence/virtualfundbuilder/contrib/data_nodes/market_cap.py +310 -0
  88. mainsequence/virtualfundbuilder/contrib/data_nodes/mock_signal.py +78 -0
  89. mainsequence/virtualfundbuilder/contrib/data_nodes/portfolio_replicator.py +269 -0
  90. mainsequence/virtualfundbuilder/contrib/prices/__init__.py +1 -0
  91. mainsequence/virtualfundbuilder/contrib/prices/data_nodes.py +810 -0
  92. mainsequence/virtualfundbuilder/contrib/prices/utils.py +11 -0
  93. mainsequence/virtualfundbuilder/contrib/rebalance_strategies/__init__.py +1 -0
  94. mainsequence/virtualfundbuilder/contrib/rebalance_strategies/rebalance_strategies.py +313 -0
  95. mainsequence/virtualfundbuilder/data_nodes.py +637 -0
  96. mainsequence/virtualfundbuilder/enums.py +23 -0
  97. mainsequence/virtualfundbuilder/models.py +282 -0
  98. mainsequence/virtualfundbuilder/notebook_handling.py +42 -0
  99. mainsequence/virtualfundbuilder/portfolio_interface.py +272 -0
  100. mainsequence/virtualfundbuilder/resource_factory/__init__.py +0 -0
  101. mainsequence/virtualfundbuilder/resource_factory/app_factory.py +170 -0
  102. mainsequence/virtualfundbuilder/resource_factory/base_factory.py +238 -0
  103. mainsequence/virtualfundbuilder/resource_factory/rebalance_factory.py +101 -0
  104. mainsequence/virtualfundbuilder/resource_factory/signal_factory.py +183 -0
  105. mainsequence/virtualfundbuilder/utils.py +381 -0
  106. mainsequence-2.0.0.dist-info/METADATA +105 -0
  107. mainsequence-2.0.0.dist-info/RECORD +110 -0
  108. mainsequence-2.0.0.dist-info/WHEEL +5 -0
  109. mainsequence-2.0.0.dist-info/licenses/LICENSE +40 -0
  110. mainsequence-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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
+ }