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.
- beacon/CHANGELOG.md +39 -0
- beacon/__init__.py +25 -0
- beacon/_optional.py +58 -0
- beacon/analysis/__init__.py +72 -0
- beacon/analysis/attribution.py +433 -0
- beacon/analysis/concentration.py +254 -0
- beacon/analysis/etf/analytics.py +134 -0
- beacon/analysis/liquidity.py +74 -0
- beacon/analysis/relative.py +215 -0
- beacon/analysis/risk.py +155 -0
- beacon/asset/__init__.py +19 -0
- beacon/asset/base.py +27 -0
- beacon/asset/bond.py +28 -0
- beacon/asset/commodity.py +25 -0
- beacon/asset/equity.py +84 -0
- beacon/asset/view.py +120 -0
- beacon/backtest/__init__.py +26 -0
- beacon/backtest/asset_view.py +270 -0
- beacon/backtest/engine.py +758 -0
- beacon/backtest/main.py +373 -0
- beacon/backtest/pricing.py +452 -0
- beacon/backtest/result.py +492 -0
- beacon/backtest/rules.py +214 -0
- beacon/catalogue.py +287 -0
- beacon/changelog.py +154 -0
- beacon/data/__init__.py +39 -0
- beacon/data/adjustment.py +167 -0
- beacon/data/base.py +454 -0
- beacon/data/corporate_actions.py +407 -0
- beacon/data/features.py +391 -0
- beacon/data/fetcher.py +1303 -0
- beacon/data/free_float.py +125 -0
- beacon/data/identifiers.py +399 -0
- beacon/data/ingest.py +334 -0
- beacon/data/loader.py +36 -0
- beacon/data/session.py +142 -0
- beacon/data/store.py +378 -0
- beacon/derivatives/__init__.py +34 -0
- beacon/derivatives/base.py +146 -0
- beacon/derivatives/curves.py +246 -0
- beacon/derivatives/forwards.py +6 -0
- beacon/derivatives/futures.py +228 -0
- beacon/derivatives/pricing.py +199 -0
- beacon/derivatives/swaps.py +263 -0
- beacon/derivatives/term_structure.py +221 -0
- beacon/environment/__init__.py +3 -0
- beacon/environment/config.py +105 -0
- beacon/exceptions.py +237 -0
- beacon/expressions/__init__.py +43 -0
- beacon/expressions/catalogue.py +92 -0
- beacon/expressions/core.py +454 -0
- beacon/expressions/namespaces.py +283 -0
- beacon/expressions/resolve.py +344 -0
- beacon/expressions/stubs.py +186 -0
- beacon/expressions/validation.py +227 -0
- beacon/fund/__init__.py +13 -0
- beacon/fund/base.py +234 -0
- beacon/fund/etf.py +146 -0
- beacon/index/__init__.py +39 -0
- beacon/index/asset_view.py +97 -0
- beacon/index/cache.py +621 -0
- beacon/index/calculation/__init__.py +23 -0
- beacon/index/calculation/calculator.py +990 -0
- beacon/index/calculation/corporate_actions.py +266 -0
- beacon/index/calculation/deletions.py +150 -0
- beacon/index/calculation/market_values.py +612 -0
- beacon/index/calculation/selection.py +238 -0
- beacon/index/calculation/total_return.py +258 -0
- beacon/index/capping.py +200 -0
- beacon/index/chaining.py +421 -0
- beacon/index/constructor.py +218 -0
- beacon/index/context.py +34 -0
- beacon/index/derived.py +363 -0
- beacon/index/expression_rules.py +159 -0
- beacon/index/feature_rules.py +158 -0
- beacon/index/methodology.py +842 -0
- beacon/index/requirements.py +179 -0
- beacon/index/result.py +276 -0
- beacon/index/schedule.py +766 -0
- beacon/optimise/__init__.py +84 -0
- beacon/optimise/config.py +167 -0
- beacon/optimise/constraints.py +612 -0
- beacon/optimise/frontier.py +490 -0
- beacon/optimise/result.py +170 -0
- beacon/optimise/solver.py +691 -0
- beacon/plot/__init__.py +61 -0
- beacon/plot/accessors.py +527 -0
- beacon/plot/base.py +75 -0
- beacon/plot/comparison.py +158 -0
- beacon/plot/style.py +243 -0
- beacon/portfolio/__init__.py +16 -0
- beacon/portfolio/asset_view.py +151 -0
- beacon/portfolio/base.py +571 -0
- beacon/portfolio/history.py +169 -0
- beacon/portfolio/reporting.py +139 -0
- beacon/py.typed +0 -0
- beacon/report/__init__.py +50 -0
- beacon/report/blocks.py +376 -0
- beacon/report/pdf.py +500 -0
- beacon/risk/__init__.py +81 -0
- beacon/risk/contribution.py +234 -0
- beacon/risk/covariance.py +381 -0
- beacon/risk/factors.py +441 -0
- beacon/risk/model.py +290 -0
- beacon/server/__init__.py +42 -0
- beacon/server/__main__.py +198 -0
- beacon/server/app.py +263 -0
- beacon/server/backtests.py +301 -0
- beacon/server/benchmarks.py +138 -0
- beacon/server/config.py +195 -0
- beacon/server/constraints.py +304 -0
- beacon/server/definitions.py +567 -0
- beacon/server/derivatives.py +480 -0
- beacon/server/documents.py +334 -0
- beacon/server/errors.py +334 -0
- beacon/server/jobs.py +537 -0
- beacon/server/methods.py +84 -0
- beacon/server/optimisation.py +373 -0
- beacon/server/preview.py +347 -0
- beacon/server/reference.py +602 -0
- beacon/server/reports.py +235 -0
- beacon/server/risk.py +150 -0
- beacon/server/routers/__init__.py +35 -0
- beacon/server/routers/beacon.py +317 -0
- beacon/server/routers/coverage.py +410 -0
- beacon/server/routers/data.py +726 -0
- beacon/server/routers/derivatives.py +105 -0
- beacon/server/routers/indices.py +633 -0
- beacon/server/routers/jobs.py +182 -0
- beacon/server/routers/optimise.py +242 -0
- beacon/server/routers/reports.py +247 -0
- beacon/server/routers/risk.py +150 -0
- beacon/server/routers/universes.py +540 -0
- beacon/server/routers/watchlists.py +92 -0
- beacon/server/runs.py +67 -0
- beacon/server/schemas.py +3571 -0
- beacon/server/security.py +50 -0
- beacon/server/serialisation.py +68 -0
- beacon/server/store.py +302 -0
- beacon/server/types.py +67 -0
- beacon/server/views.py +331 -0
- beacon/server/weights.py +382 -0
- beacon/sources.py +124 -0
- beacon/synthetic/__init__.py +48 -0
- beacon/synthetic/__main__.py +297 -0
- beacon/synthetic/dataset.py +236 -0
- beacon/synthetic/features.py +352 -0
- beacon/synthetic/fx.py +138 -0
- beacon/synthetic/listings.py +261 -0
- beacon/synthetic/prices.py +488 -0
- beacon/synthetic/profiles.py +358 -0
- beacon/synthetic/regimes.py +269 -0
- beacon/synthetic/regions.py +217 -0
- beacon/synthetic/returns.py +460 -0
- beacon/synthetic/universe.py +344 -0
- beacon/testing/__init__.py +70 -0
- beacon/testing/dataset.py +310 -0
- beacon/testing/weights.py +87 -0
- beacon/tokens/__init__.py +210 -0
- beacon/tokens/colors.json +298 -0
- beacon/universe.py +181 -0
- py_beacon_kit-0.1.0.dist-info/METADATA +503 -0
- py_beacon_kit-0.1.0.dist-info/RECORD +165 -0
- py_beacon_kit-0.1.0.dist-info/WHEEL +4 -0
- 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)
|