py-beacon-kit 0.1.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 (165) hide show
  1. beacon/CHANGELOG.md +39 -0
  2. beacon/__init__.py +25 -0
  3. beacon/_optional.py +58 -0
  4. beacon/analysis/__init__.py +72 -0
  5. beacon/analysis/attribution.py +433 -0
  6. beacon/analysis/concentration.py +254 -0
  7. beacon/analysis/etf/analytics.py +134 -0
  8. beacon/analysis/liquidity.py +74 -0
  9. beacon/analysis/relative.py +215 -0
  10. beacon/analysis/risk.py +155 -0
  11. beacon/asset/__init__.py +19 -0
  12. beacon/asset/base.py +27 -0
  13. beacon/asset/bond.py +28 -0
  14. beacon/asset/commodity.py +25 -0
  15. beacon/asset/equity.py +84 -0
  16. beacon/asset/view.py +120 -0
  17. beacon/backtest/__init__.py +26 -0
  18. beacon/backtest/asset_view.py +270 -0
  19. beacon/backtest/engine.py +758 -0
  20. beacon/backtest/main.py +373 -0
  21. beacon/backtest/pricing.py +452 -0
  22. beacon/backtest/result.py +492 -0
  23. beacon/backtest/rules.py +214 -0
  24. beacon/catalogue.py +287 -0
  25. beacon/changelog.py +154 -0
  26. beacon/data/__init__.py +39 -0
  27. beacon/data/adjustment.py +167 -0
  28. beacon/data/base.py +454 -0
  29. beacon/data/corporate_actions.py +407 -0
  30. beacon/data/features.py +391 -0
  31. beacon/data/fetcher.py +1303 -0
  32. beacon/data/free_float.py +125 -0
  33. beacon/data/identifiers.py +399 -0
  34. beacon/data/ingest.py +334 -0
  35. beacon/data/loader.py +36 -0
  36. beacon/data/session.py +142 -0
  37. beacon/data/store.py +378 -0
  38. beacon/derivatives/__init__.py +34 -0
  39. beacon/derivatives/base.py +146 -0
  40. beacon/derivatives/curves.py +246 -0
  41. beacon/derivatives/forwards.py +6 -0
  42. beacon/derivatives/futures.py +228 -0
  43. beacon/derivatives/pricing.py +199 -0
  44. beacon/derivatives/swaps.py +263 -0
  45. beacon/derivatives/term_structure.py +221 -0
  46. beacon/environment/__init__.py +3 -0
  47. beacon/environment/config.py +105 -0
  48. beacon/exceptions.py +237 -0
  49. beacon/expressions/__init__.py +43 -0
  50. beacon/expressions/catalogue.py +92 -0
  51. beacon/expressions/core.py +454 -0
  52. beacon/expressions/namespaces.py +283 -0
  53. beacon/expressions/resolve.py +344 -0
  54. beacon/expressions/stubs.py +186 -0
  55. beacon/expressions/validation.py +227 -0
  56. beacon/fund/__init__.py +13 -0
  57. beacon/fund/base.py +234 -0
  58. beacon/fund/etf.py +146 -0
  59. beacon/index/__init__.py +39 -0
  60. beacon/index/asset_view.py +97 -0
  61. beacon/index/cache.py +621 -0
  62. beacon/index/calculation/__init__.py +23 -0
  63. beacon/index/calculation/calculator.py +990 -0
  64. beacon/index/calculation/corporate_actions.py +266 -0
  65. beacon/index/calculation/deletions.py +150 -0
  66. beacon/index/calculation/market_values.py +612 -0
  67. beacon/index/calculation/selection.py +238 -0
  68. beacon/index/calculation/total_return.py +258 -0
  69. beacon/index/capping.py +200 -0
  70. beacon/index/chaining.py +421 -0
  71. beacon/index/constructor.py +218 -0
  72. beacon/index/context.py +34 -0
  73. beacon/index/derived.py +363 -0
  74. beacon/index/expression_rules.py +159 -0
  75. beacon/index/feature_rules.py +158 -0
  76. beacon/index/methodology.py +842 -0
  77. beacon/index/requirements.py +179 -0
  78. beacon/index/result.py +276 -0
  79. beacon/index/schedule.py +766 -0
  80. beacon/optimise/__init__.py +84 -0
  81. beacon/optimise/config.py +167 -0
  82. beacon/optimise/constraints.py +612 -0
  83. beacon/optimise/frontier.py +490 -0
  84. beacon/optimise/result.py +170 -0
  85. beacon/optimise/solver.py +691 -0
  86. beacon/plot/__init__.py +61 -0
  87. beacon/plot/accessors.py +527 -0
  88. beacon/plot/base.py +75 -0
  89. beacon/plot/comparison.py +158 -0
  90. beacon/plot/style.py +243 -0
  91. beacon/portfolio/__init__.py +16 -0
  92. beacon/portfolio/asset_view.py +151 -0
  93. beacon/portfolio/base.py +571 -0
  94. beacon/portfolio/history.py +169 -0
  95. beacon/portfolio/reporting.py +139 -0
  96. beacon/py.typed +0 -0
  97. beacon/report/__init__.py +50 -0
  98. beacon/report/blocks.py +376 -0
  99. beacon/report/pdf.py +500 -0
  100. beacon/risk/__init__.py +81 -0
  101. beacon/risk/contribution.py +234 -0
  102. beacon/risk/covariance.py +381 -0
  103. beacon/risk/factors.py +441 -0
  104. beacon/risk/model.py +290 -0
  105. beacon/server/__init__.py +42 -0
  106. beacon/server/__main__.py +198 -0
  107. beacon/server/app.py +263 -0
  108. beacon/server/backtests.py +301 -0
  109. beacon/server/benchmarks.py +138 -0
  110. beacon/server/config.py +195 -0
  111. beacon/server/constraints.py +304 -0
  112. beacon/server/definitions.py +567 -0
  113. beacon/server/derivatives.py +480 -0
  114. beacon/server/documents.py +334 -0
  115. beacon/server/errors.py +334 -0
  116. beacon/server/jobs.py +537 -0
  117. beacon/server/methods.py +84 -0
  118. beacon/server/optimisation.py +373 -0
  119. beacon/server/preview.py +347 -0
  120. beacon/server/reference.py +602 -0
  121. beacon/server/reports.py +235 -0
  122. beacon/server/risk.py +150 -0
  123. beacon/server/routers/__init__.py +35 -0
  124. beacon/server/routers/beacon.py +317 -0
  125. beacon/server/routers/coverage.py +410 -0
  126. beacon/server/routers/data.py +726 -0
  127. beacon/server/routers/derivatives.py +105 -0
  128. beacon/server/routers/indices.py +633 -0
  129. beacon/server/routers/jobs.py +182 -0
  130. beacon/server/routers/optimise.py +242 -0
  131. beacon/server/routers/reports.py +247 -0
  132. beacon/server/routers/risk.py +150 -0
  133. beacon/server/routers/universes.py +540 -0
  134. beacon/server/routers/watchlists.py +92 -0
  135. beacon/server/runs.py +67 -0
  136. beacon/server/schemas.py +3571 -0
  137. beacon/server/security.py +50 -0
  138. beacon/server/serialisation.py +68 -0
  139. beacon/server/store.py +302 -0
  140. beacon/server/types.py +67 -0
  141. beacon/server/views.py +331 -0
  142. beacon/server/weights.py +382 -0
  143. beacon/sources.py +124 -0
  144. beacon/synthetic/__init__.py +48 -0
  145. beacon/synthetic/__main__.py +297 -0
  146. beacon/synthetic/dataset.py +236 -0
  147. beacon/synthetic/features.py +352 -0
  148. beacon/synthetic/fx.py +138 -0
  149. beacon/synthetic/listings.py +261 -0
  150. beacon/synthetic/prices.py +488 -0
  151. beacon/synthetic/profiles.py +358 -0
  152. beacon/synthetic/regimes.py +269 -0
  153. beacon/synthetic/regions.py +217 -0
  154. beacon/synthetic/returns.py +460 -0
  155. beacon/synthetic/universe.py +344 -0
  156. beacon/testing/__init__.py +70 -0
  157. beacon/testing/dataset.py +310 -0
  158. beacon/testing/weights.py +87 -0
  159. beacon/tokens/__init__.py +210 -0
  160. beacon/tokens/colors.json +298 -0
  161. beacon/universe.py +181 -0
  162. py_beacon_kit-0.1.0.dist-info/METADATA +503 -0
  163. py_beacon_kit-0.1.0.dist-info/RECORD +165 -0
  164. py_beacon_kit-0.1.0.dist-info/WHEEL +4 -0
  165. py_beacon_kit-0.1.0.dist-info/licenses/LICENSE.txt +21 -0
beacon/CHANGELOG.md ADDED
@@ -0,0 +1,39 @@
1
+ # Changelog
2
+
3
+ What changed in each release of py-beacon. The newest release is first.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
6
+ and the version numbers follow [Semantic Versioning](https://semver.org/).
7
+ Before 1.0, any release may change the API.
8
+
9
+ ## [Unreleased]
10
+
11
+ ## [0.1.0] - 2026-09-24
12
+
13
+ The first release.
14
+
15
+ ### Added
16
+
17
+ - Build an index from rules and a weighting scheme: equal weight, market cap or free-float market cap, with optional weight caps.
18
+ - Calculate the index level on a real exchange calendar, as price, total return or net total return.
19
+ - Adjust the index for dividends, special dividends and delistings.
20
+ - Write selection rules as expressions, such as `data.market.market_cap > 1e9`, including rules on company fundamentals and other features.
21
+ - Backtest a portfolio that trades to the index weights, with transaction costs, drift thresholds and a benchmark.
22
+ - Read results with tracking error, attribution, risk, concentration and drift, and draw them as charts.
23
+ - Optimise an index under constraints (position limits, group limits, turnover, number of names), with the efficient frontier and factor exposures.
24
+ - Estimate risk models with shrinkage, and split risk and active risk by constituent.
25
+ - Price index futures, ETF futures and total return swaps, with carry, roll and a sensitivity grid.
26
+ - Hold names in several currencies. Prices are converted with FX rates.
27
+ - Load data from files, generate a realistic synthetic dataset, or download prices with yfinance. Data is kept in a local store that reports its coverage and age.
28
+ - Three settings, shown on `/health`: whether a missing FX rate carries forward, when a stale price drops a name, and how long a free float carries forward (90 days by default).
29
+ - A local API server for the Beacon desktop app, covering data, indices, universes, backtests, the optimiser, risk, derivatives and PDF reports. Long tasks run as jobs with live progress.
30
+ - This changelog, served at `GET /changelog` so the app can show what changed.
31
+ - Optional extras keep the core install small: `data`, `excel`, `pdf`, `optimise`, `plot` and `server`.
32
+
33
+ ### Notes
34
+
35
+ - An index or backtest never uses a price, rate or free float dated after the day it is working on.
36
+ - Requires Python 3.11 or later.
37
+
38
+ [Unreleased]: https://github.com/karanbh01/py-beacon/compare/v0.1.0...HEAD
39
+ [0.1.0]: https://github.com/karanbh01/py-beacon/releases/tag/v0.1.0
beacon/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ # src/beacon/__init__.py
2
+ """
3
+ Beacon — an end-to-end toolkit for index, ETF, and Delta-1 derivatives
4
+ development.
5
+ """
6
+ __version__ = "0.1.0"
7
+
8
+ from . import derivatives
9
+ from .derivatives import (
10
+ DerivativeBase,
11
+ ETFFuture,
12
+ IndexFuture,
13
+ TotalReturnSwap,
14
+ )
15
+ from .sources import use
16
+
17
+ __all__ = [
18
+ "DerivativeBase",
19
+ "ETFFuture",
20
+ "IndexFuture",
21
+ "TotalReturnSwap",
22
+ "__version__",
23
+ "derivatives",
24
+ "use",
25
+ ]
beacon/_optional.py ADDED
@@ -0,0 +1,58 @@
1
+ # src/beacon/_optional.py
2
+ """
3
+ Import guards for Beacon's optional dependencies.
4
+
5
+ The core pipeline — index, backtest, portfolio, fund, derivatives — runs on
6
+ pandas, numpy, pydantic and `exchange_calendars` alone. The last of those is
7
+ core rather than an extra since BN-180: every index schedules against a real
8
+ trading calendar, and a required input cannot sit behind an optional install.
9
+ Everything beyond that (Excel reporting, plotting, optimisation, market-data
10
+ downloads, the API server) lives behind an extra.
11
+ Modules needing one import it through :func:`require`, so a missing package
12
+ reports the extra to install instead of surfacing a bare ImportError.
13
+ """
14
+ import importlib
15
+ from types import ModuleType
16
+
17
+ from .exceptions import MissingDependencyError
18
+
19
+ # Optional module -> the pyproject.toml extra that provides it. Every module
20
+ # passed to require() must appear here; a missing entry is a packaging bug.
21
+ EXTRA_FOR_MODULE = {
22
+ "fastapi": "server",
23
+ "matplotlib": "plot",
24
+ "openpyxl": "excel",
25
+ "orjson": "server",
26
+ "platformdirs": "server",
27
+ "plotly": "plot-interactive",
28
+ "reportlab": "pdf",
29
+ "scipy": "optimise",
30
+ "uvicorn": "server",
31
+ "websockets": "server",
32
+ "yfinance": "data",
33
+ }
34
+
35
+
36
+ def require(module_name: str,
37
+ feature: str) -> ModuleType:
38
+ """Import an optional dependency, or raise an actionable error.
39
+
40
+ Args:
41
+ module_name: Module to import, e.g. ``"openpyxl"``.
42
+ feature: Human-readable name of the Beacon feature that needs it,
43
+ used to open the error message, e.g. ``"Excel reporting"``.
44
+
45
+ Returns:
46
+ The imported module.
47
+
48
+ Raises:
49
+ MissingDependencyError: If the module is not installed. The message
50
+ names the extra to install.
51
+ KeyError: If module_name is not a registered optional dependency.
52
+ """
53
+ extra = EXTRA_FOR_MODULE[module_name]
54
+
55
+ try:
56
+ return importlib.import_module(module_name)
57
+ except ImportError as exc:
58
+ raise MissingDependencyError(module_name, feature, extra) from exc
@@ -0,0 +1,72 @@
1
+ # src/beacon/analysis/__init__.py
2
+ """
3
+ The __init__.py for the 'analysis' module.
4
+
5
+ This module provides tools for analyzing the performance and
6
+ risk characteristics of indices, ETFs, and portfolios.
7
+ """
8
+ from .attribution import (
9
+ Attribution,
10
+ AttributionResult,
11
+ Contribution,
12
+ attribute,
13
+ cap_drag,
14
+ carino_factor,
15
+ cost_drag,
16
+ drifted_weights,
17
+ link_contributions,
18
+ simple_performance_attribution,
19
+ )
20
+ from .concentration import (
21
+ ConcentrationMetrics,
22
+ DriftMetrics,
23
+ concentration,
24
+ drift_from_target,
25
+ drift_history,
26
+ effective_number_of_assets,
27
+ herfindahl_index,
28
+ top_n_weight,
29
+ )
30
+ from .etf.analytics import (
31
+ ETFAnalytics,
32
+ calculate_premium_discount,
33
+ calculate_tracking_difference,
34
+ calculate_tracking_error,
35
+ )
36
+ from .liquidity import average_daily_volume
37
+ from .risk import (
38
+ RiskMetricsCalculator,
39
+ calculate_max_drawdown,
40
+ calculate_sharpe_ratio,
41
+ calculate_volatility,
42
+ )
43
+
44
+ __all__ = [
45
+ "Attribution",
46
+ "AttributionResult",
47
+ "ConcentrationMetrics",
48
+ "Contribution",
49
+ "DriftMetrics",
50
+ "ETFAnalytics",
51
+ "RiskMetricsCalculator",
52
+ "attribute",
53
+ "average_daily_volume",
54
+ "calculate_max_drawdown",
55
+ "calculate_premium_discount",
56
+ "calculate_sharpe_ratio",
57
+ "calculate_tracking_difference",
58
+ "calculate_tracking_error",
59
+ "calculate_volatility",
60
+ "cap_drag",
61
+ "carino_factor",
62
+ "concentration",
63
+ "cost_drag",
64
+ "drift_from_target",
65
+ "drift_history",
66
+ "drifted_weights",
67
+ "effective_number_of_assets",
68
+ "herfindahl_index",
69
+ "link_contributions",
70
+ "simple_performance_attribution",
71
+ "top_n_weight",
72
+ ]
@@ -0,0 +1,433 @@
1
+ # src/beacon/analysis/attribution.py
2
+ """
3
+ Performance attribution: which constituents produced an index's return.
4
+
5
+ ## The identity, and why it needs care
6
+
7
+ Within a single period the decomposition is exact:
8
+
9
+ R_t = Σ_i w_{i,t-1} × r_{i,t}
10
+
11
+ where ``w`` are the index's own drifting weights. Since BN-103 made the
12
+ weighting scheme drive the level, this holds to better than 1e-12 for every
13
+ scheme, on rebalance days as well as ordinary ones.
14
+
15
+ Over multiple periods it does **not** carry over. Returns compound while
16
+ contributions add, so the arithmetic sum of daily contributions falls short of
17
+ the compounded total — by 1.02 percentage points over a 130-day window on a
18
+ modest fixture, which is far too large to write off as a residual.
19
+
20
+ The fix is Carino linking. Scale each period's contributions by ``k_t / K``
21
+ where ``k_t = ln(1+R_t)/R_t`` and ``K = ln(1+R)/R`` for the total return ``R``.
22
+ Then
23
+
24
+ Σ_i Σ_t (k_t/K) c_{i,t} = (1/K) Σ_t ln(1+R_t) = ln(1+R)/K = R
25
+
26
+ exactly. The residual is reported regardless, and should sit at machine
27
+ epsilon; a residual that is not tiny means an assumption has broken.
28
+
29
+ ## What the drags are, and what they are not
30
+
31
+ Cap drag and cost drag are **comparisons**, not terms in the identity above.
32
+ Each is the difference between two returns — the index as built versus a
33
+ counterfactual — so adding them to a decomposition of a single return would be
34
+ mixing two different questions. They are reported alongside, each named for the
35
+ counterfactual it implies.
36
+ """
37
+ import logging
38
+ import math
39
+ from dataclasses import dataclass, field
40
+
41
+ import pandas as pd
42
+
43
+ from ..exceptions import CalculationError
44
+ from ..plot.base import PlotAccessor
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+ # Below this, a period return is treated as zero for the linking coefficient,
49
+ # whose limit as R -> 0 is 1. Computing ln(1+R)/R directly there loses
50
+ # precision to cancellation.
51
+ NEGLIGIBLE_RETURN = 1e-12
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class Contribution:
56
+ """One constituent's share of the total return.
57
+
58
+ Attributes:
59
+ asset_id: The constituent.
60
+ contribution: Its linked contribution. These sum to the total return.
61
+ average_weight: Mean weight across the window, for context — a large
62
+ contribution from a small average weight is a different story from
63
+ the same contribution from a large one.
64
+ total_return: The constituent's own return over the window.
65
+ """
66
+ asset_id: str
67
+ contribution: float
68
+ average_weight: float
69
+ total_return: float
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class AttributionResult:
74
+ """A decomposition of one return into per-constituent contributions.
75
+
76
+ Attributes:
77
+ start: First date of the window, ISO 8601.
78
+ end: Last date, ISO 8601.
79
+ periods: Return periods decomposed.
80
+ total_return: The return being explained.
81
+ contributions: Per constituent, largest first. Sums to *total_return*
82
+ up to *residual*.
83
+ residual: total_return minus the sum of contributions. Reported
84
+ always, expected to be at machine epsilon after linking. It is
85
+ never folded into a constituent.
86
+ cap_drag: Capped return minus uncapped return, when the index applies
87
+ a cap. Negative when capping cost the index. None when uncapped.
88
+ cost_drag: Portfolio return minus its gross return, when a backtest is
89
+ supplied. Negative by construction — costs only subtract.
90
+ """
91
+
92
+ #: Charts for this result. A descriptor that resolves on first
93
+ #: access, so matplotlib is imported only when something is drawn.
94
+ plot = PlotAccessor("AttributionPlots")
95
+ start: str
96
+ end: str
97
+ periods: int
98
+ total_return: float
99
+ contributions: list[Contribution]
100
+ residual: float
101
+ cap_drag: float | None = None
102
+ cost_drag: float | None = None
103
+ _weights: pd.DataFrame | None = field(default=None, repr=False, compare=False)
104
+
105
+ @property
106
+ def explained(self) -> float:
107
+ """Sum of the contributions."""
108
+ return float(sum(item.contribution for item in self.contributions))
109
+
110
+ def reconciles(self,
111
+ tolerance: float = 1e-9) -> bool:
112
+ """Whether the contributions account for the total return."""
113
+ return abs(self.residual) <= tolerance
114
+
115
+ def to_frame(self) -> pd.DataFrame:
116
+ """Contributions as a DataFrame, largest first."""
117
+ return pd.DataFrame([
118
+ {"asset_id": item.asset_id,
119
+ "contribution": item.contribution,
120
+ "average_weight": item.average_weight,
121
+ "total_return": item.total_return}
122
+ for item in self.contributions
123
+ ])
124
+
125
+
126
+ def carino_factor(period_return: float) -> float:
127
+ """The Carino coefficient for one period.
128
+
129
+ ``ln(1+R)/R``, with the removable singularity at R = 0 filled in with its
130
+ limit of 1.
131
+
132
+ Args:
133
+ period_return: The period's total return.
134
+
135
+ Returns:
136
+ float: The coefficient.
137
+
138
+ Raises:
139
+ CalculationError: If the return is -100% or worse, where the logarithm
140
+ is undefined. An index that goes to zero cannot have its return
141
+ attributed, and silently substituting a number would hide that.
142
+ """
143
+ if period_return <= -1.0:
144
+ raise CalculationError(
145
+ "Attribution",
146
+ f"a period return of {period_return:.4%} wipes out the index; the "
147
+ "linking coefficient is undefined there.")
148
+
149
+ if abs(period_return) < NEGLIGIBLE_RETURN:
150
+ return 1.0
151
+
152
+ return math.log1p(period_return) / period_return
153
+
154
+
155
+ def link_contributions(contributions: pd.DataFrame,
156
+ period_returns: pd.Series) -> pd.Series:
157
+ """Scale per-period contributions so they sum to the compounded return.
158
+
159
+ Args:
160
+ contributions: Periods on the index, constituents on the columns. Each
161
+ row must sum to that period's return.
162
+ period_returns: The total return of each period.
163
+
164
+ Returns:
165
+ pd.Series: One linked contribution per constituent. Their sum equals
166
+ the compounded total return exactly.
167
+
168
+ Raises:
169
+ CalculationError: If any period return is -100% or worse.
170
+ """
171
+ if contributions.empty:
172
+ return pd.Series(dtype=float)
173
+
174
+ total = float((1.0 + period_returns).prod() - 1.0)
175
+
176
+ factors = period_returns.map(carino_factor)
177
+ total_factor = carino_factor(total)
178
+
179
+ scaled = contributions.mul(factors / total_factor, axis=0)
180
+
181
+ return scaled.sum(axis=0)
182
+
183
+
184
+ def drifted_weights(snapshots: dict[pd.Timestamp, dict[str, float]],
185
+ prices: pd.DataFrame) -> pd.DataFrame:
186
+ """Reconstruct the index's daily weights from its rebalance snapshots.
187
+
188
+ Weights are set at a rebalance and then drift with relative performance
189
+ until the next one, because the index holds fixed units in between. Given
190
+ the weight at a rebalance and prices since, the drifted weight is
191
+
192
+ w_i,t ∝ w_i,rebalance × (p_i,t / p_i,rebalance)
193
+
194
+ normalised across constituents. The unit scale cancels, so nothing beyond
195
+ the snapshot and prices is needed.
196
+
197
+ Args:
198
+ snapshots: Rebalance date -> weights on that date.
199
+ prices: Dates on the index, constituents on the columns.
200
+
201
+ Returns:
202
+ pd.DataFrame: Weights for every date at or after the first rebalance,
203
+ each row summing to 1.
204
+
205
+ Raises:
206
+ CalculationError: If there are no snapshots to start from.
207
+ """
208
+ if not snapshots:
209
+ raise CalculationError(
210
+ "Attribution", "the index has no weight snapshots to attribute from.")
211
+
212
+ rebalances = sorted(snapshots)
213
+ rows: dict[pd.Timestamp, dict[str, float]] = {}
214
+
215
+ for date in prices.index:
216
+ active = [r for r in rebalances if r <= date]
217
+ if not active:
218
+ continue
219
+
220
+ rows[date] = _weights_on(snapshots[active[-1]], prices, active[-1], date)
221
+
222
+ return pd.DataFrame.from_dict(rows, orient="index").fillna(0.0)
223
+
224
+
225
+ def _weights_on(snapshot: dict[str, float],
226
+ prices: pd.DataFrame,
227
+ rebalance: pd.Timestamp,
228
+ date: pd.Timestamp) -> dict[str, float]:
229
+ """Drift one snapshot forward to *date* using relative price moves."""
230
+ values: dict[str, float] = {}
231
+
232
+ for asset_id, weight in snapshot.items():
233
+ if asset_id not in prices.columns:
234
+ continue
235
+
236
+ base = prices.at[rebalance, asset_id]
237
+ current = prices.at[date, asset_id]
238
+
239
+ if pd.isna(base) or pd.isna(current) or base == 0:
240
+ continue
241
+
242
+ values[asset_id] = weight * (current / base)
243
+
244
+ total = sum(values.values())
245
+ if total <= 0:
246
+ return dict.fromkeys(values, 0.0)
247
+
248
+ return {asset_id: value / total for asset_id, value in values.items()}
249
+
250
+
251
+ def attribute(period_returns: pd.Series,
252
+ weights: pd.DataFrame,
253
+ asset_returns: pd.DataFrame,
254
+ cap_drag: float | None = None,
255
+ cost_drag: float | None = None) -> AttributionResult:
256
+ """Decompose a return series into per-constituent contributions.
257
+
258
+ Args:
259
+ period_returns: The return being explained, per period.
260
+ weights: Weights per period, constituents on the columns. Aligned to
261
+ *period_returns*; the weight used for a period is the one held at
262
+ its start.
263
+ asset_returns: Constituent returns per period.
264
+ cap_drag: Optional capped-minus-uncapped return.
265
+ cost_drag: Optional cost effect on the portfolio return.
266
+
267
+ Returns:
268
+ AttributionResult: The decomposition, with contributions summing to the
269
+ compounded total return and a residual reported separately.
270
+
271
+ Raises:
272
+ CalculationError: If the inputs cannot be aligned, or a period wipes
273
+ out the index.
274
+ """
275
+ common = period_returns.index.intersection(weights.index).intersection(
276
+ asset_returns.index).sort_values()
277
+
278
+ if len(common) == 0:
279
+ raise CalculationError(
280
+ "Attribution",
281
+ "the return, weight and constituent-return series share no dates.")
282
+
283
+ returns = period_returns.loc[common]
284
+ assets = sorted(set(weights.columns) & set(asset_returns.columns))
285
+
286
+ # The weight that earns a period's return is the one held at its start,
287
+ # hence the shift. Using the end-of-period weight would credit a
288
+ # constituent for a move it was not yet holding.
289
+ lagged = weights[assets].reindex(common).shift(1)
290
+ contributions = lagged * asset_returns[assets].reindex(common)
291
+ contributions = contributions.dropna(how="all")
292
+
293
+ linked = link_contributions(contributions, returns.loc[contributions.index])
294
+ total = float((1.0 + returns.loc[contributions.index]).prod() - 1.0)
295
+
296
+ rows = [
297
+ Contribution(asset_id=asset_id,
298
+ contribution=float(linked.get(asset_id, 0.0)),
299
+ average_weight=float(lagged[asset_id].mean()),
300
+ total_return=float(
301
+ (1.0 + asset_returns[asset_id].reindex(
302
+ contributions.index).fillna(0.0)).prod() - 1.0))
303
+ for asset_id in assets
304
+ ]
305
+ rows.sort(key=lambda item: item.contribution, reverse=True)
306
+
307
+ return AttributionResult(
308
+ start=contributions.index[0].isoformat(),
309
+ end=contributions.index[-1].isoformat(),
310
+ periods=len(contributions),
311
+ total_return=total,
312
+ contributions=rows,
313
+ residual=total - float(linked.sum()),
314
+ cap_drag=cap_drag,
315
+ cost_drag=cost_drag,
316
+ _weights=lagged)
317
+
318
+
319
+ def cost_drag(total_costs: float,
320
+ initial_capital: float) -> float:
321
+ """Direct effect of transaction costs on a portfolio's return.
322
+
323
+ Costs paid as a fraction of starting capital, negated so it reads as a
324
+ drag. This is the **direct** effect only: it excludes the compounding of
325
+ the capital that was spent rather than invested, which is second-order but
326
+ not zero over a long window. Reporting the direct figure keeps the number
327
+ explainable — it is exactly the money that left the portfolio — and a
328
+ caller wanting the full effect can difference a zero-cost run instead.
329
+
330
+ Args:
331
+ total_costs: Sum of transaction costs paid.
332
+ initial_capital: Capital the portfolio started with.
333
+
334
+ Returns:
335
+ float: A non-positive drag.
336
+
337
+ Raises:
338
+ CalculationError: If *initial_capital* is not positive.
339
+ """
340
+ if initial_capital <= 0:
341
+ raise CalculationError(
342
+ "CostDrag", f"initial_capital must be positive, got {initial_capital}.")
343
+
344
+ return -abs(total_costs) / initial_capital
345
+
346
+
347
+ def cap_drag(capped_weights: dict[pd.Timestamp, dict[str, float]],
348
+ uncapped_weights: dict[pd.Timestamp, dict[str, float]],
349
+ prices: pd.DataFrame) -> float:
350
+ """What capping cost, or gained, over the window.
351
+
352
+ The capped index's return minus the return of the same methodology left
353
+ uncapped. Negative when the cap held back a name that went on to
354
+ outperform, which is the usual case and the reason the number is worth
355
+ reporting.
356
+
357
+ Both paths are built by drifting their own snapshots forward, so the
358
+ comparison isolates the effect of the cap rather than of any other
359
+ difference.
360
+
361
+ Args:
362
+ capped_weights: Rebalance date -> capped weights.
363
+ uncapped_weights: Rebalance date -> weights before capping.
364
+ prices: Constituent prices over the window.
365
+
366
+ Returns:
367
+ float: Capped total return minus uncapped total return.
368
+ """
369
+ asset_returns = prices.pct_change()
370
+
371
+ capped_return = _path_return(capped_weights, prices, asset_returns)
372
+ uncapped_return = _path_return(uncapped_weights, prices, asset_returns)
373
+
374
+ return capped_return - uncapped_return
375
+
376
+
377
+ def _path_return(snapshots: dict[pd.Timestamp, dict[str, float]],
378
+ prices: pd.DataFrame,
379
+ asset_returns: pd.DataFrame) -> float:
380
+ """Compounded return of an index following *snapshots*."""
381
+ weights = drifted_weights(snapshots, prices)
382
+ assets = sorted(set(weights.columns) & set(asset_returns.columns))
383
+
384
+ periods = (weights[assets].shift(1) * asset_returns[assets]).sum(axis=1)
385
+ periods = periods.loc[weights[assets].shift(1).dropna(how="all").index]
386
+
387
+ return float((1.0 + periods).prod() - 1.0)
388
+
389
+
390
+ class Attribution:
391
+ """Kept for the original portfolio-versus-benchmark helper."""
392
+
393
+ def simple_performance_attribution(self,
394
+ portfolio_returns: pd.Series,
395
+ benchmark_returns: pd.Series) -> dict[str, float]:
396
+ """Total return difference between a portfolio and a benchmark.
397
+
398
+ Args:
399
+ portfolio_returns: Portfolio periodic returns.
400
+ benchmark_returns: Benchmark periodic returns, same length.
401
+
402
+ Returns:
403
+ dict: total_portfolio_return, total_benchmark_return and
404
+ active_return.
405
+
406
+ Raises:
407
+ TypeError: If either input is not a Series.
408
+ ValueError: If the lengths differ or the inputs are empty.
409
+ """
410
+ if (not isinstance(portfolio_returns, pd.Series)
411
+ or not isinstance(benchmark_returns, pd.Series)):
412
+ raise TypeError("portfolio_returns and benchmark_returns must be pandas Series.")
413
+ if len(portfolio_returns) != len(benchmark_returns):
414
+ raise ValueError(
415
+ "Portfolio returns and benchmark returns Series must be of the same length.")
416
+ if portfolio_returns.empty:
417
+ raise ValueError("Input Series cannot be empty.")
418
+
419
+ total_portfolio_return = (1 + portfolio_returns).prod() - 1
420
+ total_benchmark_return = (1 + benchmark_returns).prod() - 1
421
+
422
+ return {
423
+ "total_portfolio_return": float(total_portfolio_return),
424
+ "total_benchmark_return": float(total_benchmark_return),
425
+ "active_return": float(total_portfolio_return - total_benchmark_return),
426
+ }
427
+
428
+
429
+ def simple_performance_attribution(portfolio_returns: pd.Series,
430
+ benchmark_returns: pd.Series) -> dict[str, float]:
431
+ """Total return difference between a portfolio and a benchmark."""
432
+ return Attribution().simple_performance_attribution(portfolio_returns,
433
+ benchmark_returns)