ml4t-diagnostic 0.1.0a1__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.
- ml4t/diagnostic/AGENT.md +25 -0
- ml4t/diagnostic/__init__.py +166 -0
- ml4t/diagnostic/backends/__init__.py +10 -0
- ml4t/diagnostic/backends/adapter.py +192 -0
- ml4t/diagnostic/backends/polars_backend.py +899 -0
- ml4t/diagnostic/caching/__init__.py +40 -0
- ml4t/diagnostic/caching/cache.py +331 -0
- ml4t/diagnostic/caching/decorators.py +131 -0
- ml4t/diagnostic/caching/smart_cache.py +339 -0
- ml4t/diagnostic/config/AGENT.md +24 -0
- ml4t/diagnostic/config/README.md +267 -0
- ml4t/diagnostic/config/__init__.py +219 -0
- ml4t/diagnostic/config/barrier_config.py +277 -0
- ml4t/diagnostic/config/base.py +301 -0
- ml4t/diagnostic/config/event_config.py +148 -0
- ml4t/diagnostic/config/feature_config.py +404 -0
- ml4t/diagnostic/config/multi_signal_config.py +55 -0
- ml4t/diagnostic/config/portfolio_config.py +215 -0
- ml4t/diagnostic/config/report_config.py +391 -0
- ml4t/diagnostic/config/sharpe_config.py +202 -0
- ml4t/diagnostic/config/signal_config.py +206 -0
- ml4t/diagnostic/config/trade_analysis_config.py +310 -0
- ml4t/diagnostic/config/validation.py +279 -0
- ml4t/diagnostic/core/__init__.py +29 -0
- ml4t/diagnostic/core/numba_utils.py +315 -0
- ml4t/diagnostic/core/purging.py +372 -0
- ml4t/diagnostic/core/sampling.py +471 -0
- ml4t/diagnostic/errors/__init__.py +205 -0
- ml4t/diagnostic/evaluation/AGENT.md +26 -0
- ml4t/diagnostic/evaluation/__init__.py +437 -0
- ml4t/diagnostic/evaluation/autocorrelation.py +531 -0
- ml4t/diagnostic/evaluation/barrier_analysis.py +1050 -0
- ml4t/diagnostic/evaluation/binary_metrics.py +910 -0
- ml4t/diagnostic/evaluation/dashboard.py +715 -0
- ml4t/diagnostic/evaluation/diagnostic_plots.py +1037 -0
- ml4t/diagnostic/evaluation/distribution/__init__.py +499 -0
- ml4t/diagnostic/evaluation/distribution/moments.py +299 -0
- ml4t/diagnostic/evaluation/distribution/tails.py +777 -0
- ml4t/diagnostic/evaluation/distribution/tests.py +470 -0
- ml4t/diagnostic/evaluation/drift/__init__.py +139 -0
- ml4t/diagnostic/evaluation/drift/analysis.py +432 -0
- ml4t/diagnostic/evaluation/drift/domain_classifier.py +517 -0
- ml4t/diagnostic/evaluation/drift/population_stability_index.py +310 -0
- ml4t/diagnostic/evaluation/drift/wasserstein.py +388 -0
- ml4t/diagnostic/evaluation/event_analysis.py +647 -0
- ml4t/diagnostic/evaluation/excursion.py +390 -0
- ml4t/diagnostic/evaluation/feature_diagnostics.py +873 -0
- ml4t/diagnostic/evaluation/feature_outcome.py +666 -0
- ml4t/diagnostic/evaluation/framework.py +935 -0
- ml4t/diagnostic/evaluation/metric_registry.py +255 -0
- ml4t/diagnostic/evaluation/metrics/AGENT.md +23 -0
- ml4t/diagnostic/evaluation/metrics/__init__.py +133 -0
- ml4t/diagnostic/evaluation/metrics/basic.py +160 -0
- ml4t/diagnostic/evaluation/metrics/conditional_ic.py +469 -0
- ml4t/diagnostic/evaluation/metrics/feature_outcome.py +475 -0
- ml4t/diagnostic/evaluation/metrics/ic_statistics.py +446 -0
- ml4t/diagnostic/evaluation/metrics/importance_analysis.py +338 -0
- ml4t/diagnostic/evaluation/metrics/importance_classical.py +375 -0
- ml4t/diagnostic/evaluation/metrics/importance_mda.py +371 -0
- ml4t/diagnostic/evaluation/metrics/importance_shap.py +715 -0
- ml4t/diagnostic/evaluation/metrics/information_coefficient.py +527 -0
- ml4t/diagnostic/evaluation/metrics/interactions.py +772 -0
- ml4t/diagnostic/evaluation/metrics/monotonicity.py +226 -0
- ml4t/diagnostic/evaluation/metrics/risk_adjusted.py +324 -0
- ml4t/diagnostic/evaluation/multi_signal.py +550 -0
- ml4t/diagnostic/evaluation/portfolio_analysis/__init__.py +83 -0
- ml4t/diagnostic/evaluation/portfolio_analysis/analysis.py +734 -0
- ml4t/diagnostic/evaluation/portfolio_analysis/metrics.py +589 -0
- ml4t/diagnostic/evaluation/portfolio_analysis/results.py +334 -0
- ml4t/diagnostic/evaluation/report_generation.py +824 -0
- ml4t/diagnostic/evaluation/signal_selector.py +452 -0
- ml4t/diagnostic/evaluation/stat_registry.py +139 -0
- ml4t/diagnostic/evaluation/stationarity/__init__.py +97 -0
- ml4t/diagnostic/evaluation/stationarity/analysis.py +518 -0
- ml4t/diagnostic/evaluation/stationarity/augmented_dickey_fuller.py +296 -0
- ml4t/diagnostic/evaluation/stationarity/kpss_test.py +308 -0
- ml4t/diagnostic/evaluation/stationarity/phillips_perron.py +365 -0
- ml4t/diagnostic/evaluation/stats/AGENT.md +43 -0
- ml4t/diagnostic/evaluation/stats/__init__.py +191 -0
- ml4t/diagnostic/evaluation/stats/backtest_overfitting.py +219 -0
- ml4t/diagnostic/evaluation/stats/bootstrap.py +228 -0
- ml4t/diagnostic/evaluation/stats/deflated_sharpe_ratio.py +591 -0
- ml4t/diagnostic/evaluation/stats/false_discovery_rate.py +295 -0
- ml4t/diagnostic/evaluation/stats/hac_standard_errors.py +108 -0
- ml4t/diagnostic/evaluation/stats/minimum_track_record.py +408 -0
- ml4t/diagnostic/evaluation/stats/moments.py +164 -0
- ml4t/diagnostic/evaluation/stats/rademacher_adjustment.py +436 -0
- ml4t/diagnostic/evaluation/stats/reality_check.py +155 -0
- ml4t/diagnostic/evaluation/stats/sharpe_inference.py +219 -0
- ml4t/diagnostic/evaluation/themes.py +330 -0
- ml4t/diagnostic/evaluation/threshold_analysis.py +957 -0
- ml4t/diagnostic/evaluation/trade_analysis.py +1136 -0
- ml4t/diagnostic/evaluation/trade_dashboard/__init__.py +32 -0
- ml4t/diagnostic/evaluation/trade_dashboard/app.py +315 -0
- ml4t/diagnostic/evaluation/trade_dashboard/export/__init__.py +18 -0
- ml4t/diagnostic/evaluation/trade_dashboard/export/csv.py +82 -0
- ml4t/diagnostic/evaluation/trade_dashboard/export/html.py +276 -0
- ml4t/diagnostic/evaluation/trade_dashboard/io.py +166 -0
- ml4t/diagnostic/evaluation/trade_dashboard/normalize.py +304 -0
- ml4t/diagnostic/evaluation/trade_dashboard/stats.py +386 -0
- ml4t/diagnostic/evaluation/trade_dashboard/style.py +79 -0
- ml4t/diagnostic/evaluation/trade_dashboard/tabs/__init__.py +21 -0
- ml4t/diagnostic/evaluation/trade_dashboard/tabs/patterns.py +354 -0
- ml4t/diagnostic/evaluation/trade_dashboard/tabs/shap_analysis.py +280 -0
- ml4t/diagnostic/evaluation/trade_dashboard/tabs/stat_validation.py +186 -0
- ml4t/diagnostic/evaluation/trade_dashboard/tabs/worst_trades.py +236 -0
- ml4t/diagnostic/evaluation/trade_dashboard/types.py +129 -0
- ml4t/diagnostic/evaluation/trade_shap/__init__.py +102 -0
- ml4t/diagnostic/evaluation/trade_shap/alignment.py +188 -0
- ml4t/diagnostic/evaluation/trade_shap/characterize.py +413 -0
- ml4t/diagnostic/evaluation/trade_shap/cluster.py +302 -0
- ml4t/diagnostic/evaluation/trade_shap/explain.py +208 -0
- ml4t/diagnostic/evaluation/trade_shap/hypotheses/__init__.py +23 -0
- ml4t/diagnostic/evaluation/trade_shap/hypotheses/generator.py +290 -0
- ml4t/diagnostic/evaluation/trade_shap/hypotheses/matcher.py +251 -0
- ml4t/diagnostic/evaluation/trade_shap/hypotheses/templates.yaml +467 -0
- ml4t/diagnostic/evaluation/trade_shap/models.py +386 -0
- ml4t/diagnostic/evaluation/trade_shap/normalize.py +116 -0
- ml4t/diagnostic/evaluation/trade_shap/pipeline.py +263 -0
- ml4t/diagnostic/evaluation/trade_shap_dashboard.py +283 -0
- ml4t/diagnostic/evaluation/trade_shap_diagnostics.py +588 -0
- ml4t/diagnostic/evaluation/validated_cv.py +535 -0
- ml4t/diagnostic/evaluation/visualization.py +1050 -0
- ml4t/diagnostic/evaluation/volatility/__init__.py +45 -0
- ml4t/diagnostic/evaluation/volatility/analysis.py +351 -0
- ml4t/diagnostic/evaluation/volatility/arch.py +258 -0
- ml4t/diagnostic/evaluation/volatility/garch.py +460 -0
- ml4t/diagnostic/integration/__init__.py +48 -0
- ml4t/diagnostic/integration/backtest_contract.py +671 -0
- ml4t/diagnostic/integration/data_contract.py +316 -0
- ml4t/diagnostic/integration/engineer_contract.py +226 -0
- ml4t/diagnostic/logging/__init__.py +77 -0
- ml4t/diagnostic/logging/logger.py +245 -0
- ml4t/diagnostic/logging/performance.py +234 -0
- ml4t/diagnostic/logging/progress.py +234 -0
- ml4t/diagnostic/logging/wandb.py +412 -0
- ml4t/diagnostic/metrics/__init__.py +9 -0
- ml4t/diagnostic/metrics/percentiles.py +128 -0
- ml4t/diagnostic/py.typed +1 -0
- ml4t/diagnostic/reporting/__init__.py +43 -0
- ml4t/diagnostic/reporting/base.py +130 -0
- ml4t/diagnostic/reporting/html_renderer.py +275 -0
- ml4t/diagnostic/reporting/json_renderer.py +51 -0
- ml4t/diagnostic/reporting/markdown_renderer.py +117 -0
- ml4t/diagnostic/results/AGENT.md +24 -0
- ml4t/diagnostic/results/__init__.py +105 -0
- ml4t/diagnostic/results/barrier_results/__init__.py +36 -0
- ml4t/diagnostic/results/barrier_results/hit_rate.py +304 -0
- ml4t/diagnostic/results/barrier_results/precision_recall.py +266 -0
- ml4t/diagnostic/results/barrier_results/profit_factor.py +297 -0
- ml4t/diagnostic/results/barrier_results/tearsheet.py +397 -0
- ml4t/diagnostic/results/barrier_results/time_to_target.py +305 -0
- ml4t/diagnostic/results/barrier_results/validation.py +38 -0
- ml4t/diagnostic/results/base.py +177 -0
- ml4t/diagnostic/results/event_results.py +349 -0
- ml4t/diagnostic/results/feature_results.py +787 -0
- ml4t/diagnostic/results/multi_signal_results.py +431 -0
- ml4t/diagnostic/results/portfolio_results.py +281 -0
- ml4t/diagnostic/results/sharpe_results.py +448 -0
- ml4t/diagnostic/results/signal_results/__init__.py +74 -0
- ml4t/diagnostic/results/signal_results/ic.py +581 -0
- ml4t/diagnostic/results/signal_results/irtc.py +110 -0
- ml4t/diagnostic/results/signal_results/quantile.py +392 -0
- ml4t/diagnostic/results/signal_results/tearsheet.py +456 -0
- ml4t/diagnostic/results/signal_results/turnover.py +213 -0
- ml4t/diagnostic/results/signal_results/validation.py +147 -0
- ml4t/diagnostic/signal/AGENT.md +17 -0
- ml4t/diagnostic/signal/__init__.py +69 -0
- ml4t/diagnostic/signal/_report.py +152 -0
- ml4t/diagnostic/signal/_utils.py +261 -0
- ml4t/diagnostic/signal/core.py +275 -0
- ml4t/diagnostic/signal/quantile.py +148 -0
- ml4t/diagnostic/signal/result.py +214 -0
- ml4t/diagnostic/signal/signal_ic.py +129 -0
- ml4t/diagnostic/signal/turnover.py +182 -0
- ml4t/diagnostic/splitters/AGENT.md +19 -0
- ml4t/diagnostic/splitters/__init__.py +36 -0
- ml4t/diagnostic/splitters/base.py +501 -0
- ml4t/diagnostic/splitters/calendar.py +421 -0
- ml4t/diagnostic/splitters/calendar_config.py +91 -0
- ml4t/diagnostic/splitters/combinatorial.py +1064 -0
- ml4t/diagnostic/splitters/config.py +322 -0
- ml4t/diagnostic/splitters/cpcv/__init__.py +57 -0
- ml4t/diagnostic/splitters/cpcv/combinations.py +119 -0
- ml4t/diagnostic/splitters/cpcv/partitioning.py +263 -0
- ml4t/diagnostic/splitters/cpcv/purge_engine.py +379 -0
- ml4t/diagnostic/splitters/cpcv/windows.py +190 -0
- ml4t/diagnostic/splitters/group_isolation.py +329 -0
- ml4t/diagnostic/splitters/persistence.py +316 -0
- ml4t/diagnostic/splitters/utils.py +207 -0
- ml4t/diagnostic/splitters/walk_forward.py +757 -0
- ml4t/diagnostic/utils/__init__.py +42 -0
- ml4t/diagnostic/utils/config.py +542 -0
- ml4t/diagnostic/utils/dependencies.py +318 -0
- ml4t/diagnostic/utils/sessions.py +127 -0
- ml4t/diagnostic/validation/__init__.py +54 -0
- ml4t/diagnostic/validation/dataframe.py +274 -0
- ml4t/diagnostic/validation/returns.py +280 -0
- ml4t/diagnostic/validation/timeseries.py +299 -0
- ml4t/diagnostic/visualization/AGENT.md +19 -0
- ml4t/diagnostic/visualization/__init__.py +223 -0
- ml4t/diagnostic/visualization/backtest/__init__.py +98 -0
- ml4t/diagnostic/visualization/backtest/cost_attribution.py +762 -0
- ml4t/diagnostic/visualization/backtest/executive_summary.py +895 -0
- ml4t/diagnostic/visualization/backtest/interactive_controls.py +673 -0
- ml4t/diagnostic/visualization/backtest/statistical_validity.py +874 -0
- ml4t/diagnostic/visualization/backtest/tearsheet.py +565 -0
- ml4t/diagnostic/visualization/backtest/template_system.py +373 -0
- ml4t/diagnostic/visualization/backtest/trade_plots.py +1172 -0
- ml4t/diagnostic/visualization/barrier_plots.py +782 -0
- ml4t/diagnostic/visualization/core.py +1060 -0
- ml4t/diagnostic/visualization/dashboards/__init__.py +36 -0
- ml4t/diagnostic/visualization/dashboards/base.py +582 -0
- ml4t/diagnostic/visualization/dashboards/importance.py +801 -0
- ml4t/diagnostic/visualization/dashboards/interaction.py +263 -0
- ml4t/diagnostic/visualization/dashboards.py +43 -0
- ml4t/diagnostic/visualization/data_extraction/__init__.py +48 -0
- ml4t/diagnostic/visualization/data_extraction/importance.py +649 -0
- ml4t/diagnostic/visualization/data_extraction/interaction.py +504 -0
- ml4t/diagnostic/visualization/data_extraction/types.py +113 -0
- ml4t/diagnostic/visualization/data_extraction/validation.py +66 -0
- ml4t/diagnostic/visualization/feature_plots.py +888 -0
- ml4t/diagnostic/visualization/interaction_plots.py +618 -0
- ml4t/diagnostic/visualization/portfolio/__init__.py +41 -0
- ml4t/diagnostic/visualization/portfolio/dashboard.py +514 -0
- ml4t/diagnostic/visualization/portfolio/drawdown_plots.py +341 -0
- ml4t/diagnostic/visualization/portfolio/returns_plots.py +487 -0
- ml4t/diagnostic/visualization/portfolio/risk_plots.py +301 -0
- ml4t/diagnostic/visualization/report_generation.py +1343 -0
- ml4t/diagnostic/visualization/signal/__init__.py +103 -0
- ml4t/diagnostic/visualization/signal/dashboard.py +911 -0
- ml4t/diagnostic/visualization/signal/event_plots.py +514 -0
- ml4t/diagnostic/visualization/signal/ic_plots.py +635 -0
- ml4t/diagnostic/visualization/signal/multi_signal_dashboard.py +974 -0
- ml4t/diagnostic/visualization/signal/multi_signal_plots.py +603 -0
- ml4t/diagnostic/visualization/signal/quantile_plots.py +625 -0
- ml4t/diagnostic/visualization/signal/turnover_plots.py +400 -0
- ml4t/diagnostic/visualization/trade_shap/__init__.py +90 -0
- ml4t_diagnostic-0.1.0a1.dist-info/METADATA +1044 -0
- ml4t_diagnostic-0.1.0a1.dist-info/RECORD +242 -0
- ml4t_diagnostic-0.1.0a1.dist-info/WHEEL +4 -0
- ml4t_diagnostic-0.1.0a1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
"""Core metric functions for portfolio analysis.
|
|
2
|
+
|
|
3
|
+
This module provides standalone utility functions for computing
|
|
4
|
+
portfolio performance metrics:
|
|
5
|
+
- Risk-adjusted returns (Sharpe, Sortino, Calmar, Omega, Tail)
|
|
6
|
+
- Return metrics (annual return, volatility, max drawdown)
|
|
7
|
+
- Risk metrics (VaR, CVaR)
|
|
8
|
+
- Benchmark-relative metrics (alpha, beta, information ratio, capture ratios)
|
|
9
|
+
- Portfolio turnover
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import TYPE_CHECKING, Any, Union, cast
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
from scipy import stats
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
import polars as pl
|
|
21
|
+
|
|
22
|
+
# Type aliases - use Union for Python 3.9 compatibility
|
|
23
|
+
ArrayLike = Union[np.ndarray, "pl.Series", "list[float]"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _to_numpy(data: ArrayLike) -> np.ndarray:
|
|
27
|
+
"""Convert various types to numpy array."""
|
|
28
|
+
if isinstance(data, np.ndarray):
|
|
29
|
+
return data
|
|
30
|
+
elif hasattr(data, "to_numpy"): # Polars Series
|
|
31
|
+
return np.asarray(cast(Any, data).to_numpy())
|
|
32
|
+
elif hasattr(data, "values"): # pandas Series
|
|
33
|
+
return np.asarray(cast(Any, data).values)
|
|
34
|
+
else:
|
|
35
|
+
return np.asarray(data)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _safe_prod(arr: np.ndarray) -> float:
|
|
39
|
+
"""Compute product, ignoring NaN values.
|
|
40
|
+
|
|
41
|
+
Uses np.nanprod to handle NaN gracefully instead of propagating NaN
|
|
42
|
+
through the entire result.
|
|
43
|
+
"""
|
|
44
|
+
return float(np.nanprod(arr))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _safe_cumprod(arr: np.ndarray) -> np.ndarray:
|
|
48
|
+
"""Compute cumulative product with NaN handling.
|
|
49
|
+
|
|
50
|
+
If NaN values are present, they are forward-filled from the previous
|
|
51
|
+
valid cumulative product value. This prevents NaN from corrupting
|
|
52
|
+
the entire equity curve.
|
|
53
|
+
"""
|
|
54
|
+
if not np.any(np.isnan(arr)):
|
|
55
|
+
return np.cumprod(arr)
|
|
56
|
+
|
|
57
|
+
# Handle NaN by treating as 1.0 (no change) in the product
|
|
58
|
+
arr_clean = np.where(np.isnan(arr), 1.0, arr)
|
|
59
|
+
return np.cumprod(arr_clean)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _annualization_factor(periods_per_year: int = 252) -> float:
|
|
63
|
+
"""Get annualization factor."""
|
|
64
|
+
return np.sqrt(periods_per_year)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def sharpe_ratio(
|
|
68
|
+
returns: ArrayLike,
|
|
69
|
+
risk_free: float = 0.0,
|
|
70
|
+
periods_per_year: int = 252,
|
|
71
|
+
) -> float:
|
|
72
|
+
"""Compute annualized Sharpe ratio.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
returns: Daily returns (non-cumulative)
|
|
76
|
+
risk_free: Annual risk-free rate
|
|
77
|
+
periods_per_year: Trading periods per year
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Annualized Sharpe ratio
|
|
81
|
+
"""
|
|
82
|
+
returns = _to_numpy(returns)
|
|
83
|
+
|
|
84
|
+
# Convert annual risk-free to daily
|
|
85
|
+
daily_rf = (1 + risk_free) ** (1 / periods_per_year) - 1
|
|
86
|
+
|
|
87
|
+
excess_returns = returns - daily_rf
|
|
88
|
+
|
|
89
|
+
if len(excess_returns) < 2:
|
|
90
|
+
return np.nan
|
|
91
|
+
|
|
92
|
+
mean_excess = np.nanmean(excess_returns)
|
|
93
|
+
std_excess = np.nanstd(excess_returns, ddof=1)
|
|
94
|
+
|
|
95
|
+
if std_excess == 0:
|
|
96
|
+
return np.nan
|
|
97
|
+
|
|
98
|
+
return (mean_excess / std_excess) * _annualization_factor(periods_per_year)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def sortino_ratio(
|
|
102
|
+
returns: ArrayLike,
|
|
103
|
+
risk_free: float = 0.0,
|
|
104
|
+
periods_per_year: int = 252,
|
|
105
|
+
target: float = 0.0,
|
|
106
|
+
) -> float:
|
|
107
|
+
"""Compute annualized Sortino ratio.
|
|
108
|
+
|
|
109
|
+
Uses downside deviation (semi-deviation) instead of full volatility.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
returns: Daily returns (non-cumulative)
|
|
113
|
+
risk_free: Annual risk-free rate
|
|
114
|
+
periods_per_year: Trading periods per year
|
|
115
|
+
target: Target return threshold for downside calculation (daily, relative
|
|
116
|
+
to risk-free rate). When target=0, downside is measured below the
|
|
117
|
+
risk-free rate.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Annualized Sortino ratio
|
|
121
|
+
"""
|
|
122
|
+
returns = _to_numpy(returns)
|
|
123
|
+
|
|
124
|
+
# Convert annual risk-free to daily
|
|
125
|
+
daily_rf = (1 + risk_free) ** (1 / periods_per_year) - 1
|
|
126
|
+
|
|
127
|
+
excess_returns = returns - daily_rf
|
|
128
|
+
|
|
129
|
+
# Downside returns: excess returns below target
|
|
130
|
+
# Uses excess returns for consistency with numerator
|
|
131
|
+
downside_returns = np.minimum(excess_returns - target, 0)
|
|
132
|
+
|
|
133
|
+
if len(downside_returns) < 2:
|
|
134
|
+
return np.nan
|
|
135
|
+
|
|
136
|
+
mean_excess = np.nanmean(excess_returns)
|
|
137
|
+
downside_std = np.sqrt(np.nanmean(downside_returns**2))
|
|
138
|
+
|
|
139
|
+
if downside_std == 0:
|
|
140
|
+
return np.nan
|
|
141
|
+
|
|
142
|
+
return (mean_excess / downside_std) * _annualization_factor(periods_per_year)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def calmar_ratio(
|
|
146
|
+
returns: ArrayLike,
|
|
147
|
+
periods_per_year: int = 252,
|
|
148
|
+
) -> float:
|
|
149
|
+
"""Compute Calmar ratio (annual return / max drawdown).
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
returns: Daily returns (non-cumulative)
|
|
153
|
+
periods_per_year: Trading periods per year
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Calmar ratio
|
|
157
|
+
"""
|
|
158
|
+
returns = _to_numpy(returns)
|
|
159
|
+
|
|
160
|
+
ann_return = annual_return(returns, periods_per_year)
|
|
161
|
+
max_dd = max_drawdown(returns)
|
|
162
|
+
|
|
163
|
+
if max_dd == 0:
|
|
164
|
+
return np.nan
|
|
165
|
+
|
|
166
|
+
return ann_return / abs(max_dd)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def omega_ratio(
|
|
170
|
+
returns: ArrayLike,
|
|
171
|
+
threshold: float = 0.0,
|
|
172
|
+
) -> float:
|
|
173
|
+
"""Compute Omega ratio.
|
|
174
|
+
|
|
175
|
+
Omega = P(gain) * E[gain|gain] / (P(loss) * E[loss|loss])
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
returns: Daily returns (non-cumulative)
|
|
179
|
+
threshold: Return threshold (default 0)
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Omega ratio
|
|
183
|
+
"""
|
|
184
|
+
returns = _to_numpy(returns)
|
|
185
|
+
|
|
186
|
+
returns_above = returns[returns > threshold] - threshold
|
|
187
|
+
returns_below = threshold - returns[returns <= threshold]
|
|
188
|
+
|
|
189
|
+
sum_above = np.sum(returns_above)
|
|
190
|
+
sum_below = np.sum(returns_below)
|
|
191
|
+
|
|
192
|
+
if sum_below == 0:
|
|
193
|
+
return np.inf if sum_above > 0 else np.nan
|
|
194
|
+
|
|
195
|
+
return sum_above / sum_below
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def tail_ratio(returns: ArrayLike) -> float:
|
|
199
|
+
"""Compute tail ratio (95th percentile / abs(5th percentile)).
|
|
200
|
+
|
|
201
|
+
Measures asymmetry of return distribution tails.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
returns: Daily returns
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
Tail ratio (>1 means right tail heavier)
|
|
208
|
+
"""
|
|
209
|
+
returns = _to_numpy(returns)
|
|
210
|
+
|
|
211
|
+
p95 = np.nanpercentile(returns, 95)
|
|
212
|
+
p5 = np.nanpercentile(returns, 5)
|
|
213
|
+
|
|
214
|
+
if p5 == 0:
|
|
215
|
+
return np.nan
|
|
216
|
+
|
|
217
|
+
# Docstring: p95 / abs(p5) - use abs on denominator only, not the whole ratio
|
|
218
|
+
return float(p95 / abs(p5))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def max_drawdown(returns: ArrayLike) -> float:
|
|
222
|
+
"""Compute maximum drawdown.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
returns: Daily returns (non-cumulative)
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
Maximum drawdown (negative value)
|
|
229
|
+
"""
|
|
230
|
+
returns = _to_numpy(returns)
|
|
231
|
+
|
|
232
|
+
# Compute cumulative returns
|
|
233
|
+
cum_returns = _safe_cumprod(1 + returns)
|
|
234
|
+
|
|
235
|
+
# Running maximum
|
|
236
|
+
running_max = np.maximum.accumulate(cum_returns)
|
|
237
|
+
|
|
238
|
+
# Drawdown
|
|
239
|
+
drawdown = (cum_returns - running_max) / running_max
|
|
240
|
+
|
|
241
|
+
return np.nanmin(drawdown)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def annual_return(
|
|
245
|
+
returns: ArrayLike,
|
|
246
|
+
periods_per_year: int = 252,
|
|
247
|
+
) -> float:
|
|
248
|
+
"""Compute annualized return (CAGR).
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
returns: Daily returns (non-cumulative)
|
|
252
|
+
periods_per_year: Trading periods per year
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
Annualized return
|
|
256
|
+
"""
|
|
257
|
+
returns = _to_numpy(returns)
|
|
258
|
+
|
|
259
|
+
total = _safe_prod(1 + returns)
|
|
260
|
+
n_periods = len(returns)
|
|
261
|
+
|
|
262
|
+
if n_periods == 0:
|
|
263
|
+
return np.nan
|
|
264
|
+
|
|
265
|
+
years = n_periods / periods_per_year
|
|
266
|
+
|
|
267
|
+
if years == 0:
|
|
268
|
+
return np.nan
|
|
269
|
+
|
|
270
|
+
return total ** (1 / years) - 1
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def annual_volatility(
|
|
274
|
+
returns: ArrayLike,
|
|
275
|
+
periods_per_year: int = 252,
|
|
276
|
+
) -> float:
|
|
277
|
+
"""Compute annualized volatility.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
returns: Daily returns (non-cumulative)
|
|
281
|
+
periods_per_year: Trading periods per year
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
Annualized volatility
|
|
285
|
+
"""
|
|
286
|
+
returns = _to_numpy(returns)
|
|
287
|
+
return float(np.nanstd(returns, ddof=1) * _annualization_factor(periods_per_year))
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def value_at_risk(
|
|
291
|
+
returns: ArrayLike,
|
|
292
|
+
confidence: float = 0.95,
|
|
293
|
+
) -> float:
|
|
294
|
+
"""Compute Value at Risk.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
returns: Daily returns
|
|
298
|
+
confidence: Confidence level (e.g., 0.95 for 95% VaR)
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
VaR (negative value representing potential loss)
|
|
302
|
+
"""
|
|
303
|
+
returns = _to_numpy(returns)
|
|
304
|
+
return float(np.nanpercentile(returns, (1 - confidence) * 100))
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def conditional_var(
|
|
308
|
+
returns: ArrayLike,
|
|
309
|
+
confidence: float = 0.95,
|
|
310
|
+
) -> float:
|
|
311
|
+
"""Compute Conditional Value at Risk (Expected Shortfall).
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
returns: Daily returns
|
|
315
|
+
confidence: Confidence level
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
CVaR (expected loss given loss exceeds VaR)
|
|
319
|
+
"""
|
|
320
|
+
returns = _to_numpy(returns)
|
|
321
|
+
var = value_at_risk(returns, confidence)
|
|
322
|
+
return float(np.nanmean(returns[returns <= var]))
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def stability_of_timeseries(returns: ArrayLike) -> float:
|
|
326
|
+
"""Compute stability (R² of cumulative returns vs time).
|
|
327
|
+
|
|
328
|
+
Higher stability indicates more consistent returns.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
returns: Daily returns
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
R² value (0 to 1)
|
|
335
|
+
"""
|
|
336
|
+
returns = _to_numpy(returns)
|
|
337
|
+
|
|
338
|
+
cum_returns = _safe_cumprod(1 + returns)
|
|
339
|
+
|
|
340
|
+
# Fit linear regression
|
|
341
|
+
x = np.arange(len(cum_returns))
|
|
342
|
+
|
|
343
|
+
# Handle NaN
|
|
344
|
+
mask = ~np.isnan(cum_returns)
|
|
345
|
+
if mask.sum() < 2:
|
|
346
|
+
return np.nan
|
|
347
|
+
|
|
348
|
+
slope, intercept, r_value, _, _ = stats.linregress(x[mask], cum_returns[mask])
|
|
349
|
+
|
|
350
|
+
return r_value**2
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def alpha_beta(
|
|
354
|
+
returns: ArrayLike,
|
|
355
|
+
benchmark_returns: ArrayLike,
|
|
356
|
+
risk_free: float = 0.0,
|
|
357
|
+
periods_per_year: int = 252,
|
|
358
|
+
) -> tuple[float, float]:
|
|
359
|
+
"""Compute CAPM alpha and beta.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
returns: Strategy daily returns
|
|
363
|
+
benchmark_returns: Benchmark daily returns
|
|
364
|
+
risk_free: Annual risk-free rate
|
|
365
|
+
periods_per_year: Trading periods per year
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
(alpha, beta) tuple - alpha is annualized
|
|
369
|
+
"""
|
|
370
|
+
returns = _to_numpy(returns)
|
|
371
|
+
benchmark = _to_numpy(benchmark_returns)
|
|
372
|
+
|
|
373
|
+
# Convert annual risk-free to daily
|
|
374
|
+
daily_rf = (1 + risk_free) ** (1 / periods_per_year) - 1
|
|
375
|
+
|
|
376
|
+
# Excess returns
|
|
377
|
+
excess_returns = returns - daily_rf
|
|
378
|
+
excess_benchmark = benchmark - daily_rf
|
|
379
|
+
|
|
380
|
+
# Align lengths
|
|
381
|
+
min_len = min(len(excess_returns), len(excess_benchmark))
|
|
382
|
+
excess_returns = excess_returns[:min_len]
|
|
383
|
+
excess_benchmark = excess_benchmark[:min_len]
|
|
384
|
+
|
|
385
|
+
# Remove NaN
|
|
386
|
+
mask = ~(np.isnan(excess_returns) | np.isnan(excess_benchmark))
|
|
387
|
+
if mask.sum() < 2:
|
|
388
|
+
return np.nan, np.nan
|
|
389
|
+
|
|
390
|
+
# Linear regression
|
|
391
|
+
slope, intercept, _, _, _ = stats.linregress(excess_benchmark[mask], excess_returns[mask])
|
|
392
|
+
|
|
393
|
+
beta = slope
|
|
394
|
+
# Annualize alpha
|
|
395
|
+
alpha = intercept * periods_per_year
|
|
396
|
+
|
|
397
|
+
return alpha, beta
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def information_ratio(
|
|
401
|
+
returns: ArrayLike,
|
|
402
|
+
benchmark_returns: ArrayLike,
|
|
403
|
+
periods_per_year: int = 252,
|
|
404
|
+
) -> float:
|
|
405
|
+
"""Compute Information Ratio (alpha / tracking error).
|
|
406
|
+
|
|
407
|
+
Args:
|
|
408
|
+
returns: Strategy daily returns
|
|
409
|
+
benchmark_returns: Benchmark daily returns
|
|
410
|
+
periods_per_year: Trading periods per year
|
|
411
|
+
|
|
412
|
+
Returns:
|
|
413
|
+
Information ratio
|
|
414
|
+
"""
|
|
415
|
+
returns = _to_numpy(returns)
|
|
416
|
+
benchmark = _to_numpy(benchmark_returns)
|
|
417
|
+
|
|
418
|
+
# Align lengths
|
|
419
|
+
min_len = min(len(returns), len(benchmark))
|
|
420
|
+
returns = returns[:min_len]
|
|
421
|
+
benchmark = benchmark[:min_len]
|
|
422
|
+
|
|
423
|
+
# Active return
|
|
424
|
+
active_return = returns - benchmark
|
|
425
|
+
|
|
426
|
+
# Tracking error (annualized)
|
|
427
|
+
tracking_error = np.nanstd(active_return, ddof=1) * _annualization_factor(periods_per_year)
|
|
428
|
+
|
|
429
|
+
if tracking_error == 0:
|
|
430
|
+
return np.nan
|
|
431
|
+
|
|
432
|
+
# Annualized active return
|
|
433
|
+
ann_active = np.nanmean(active_return) * periods_per_year
|
|
434
|
+
|
|
435
|
+
return ann_active / tracking_error
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def up_down_capture(
|
|
439
|
+
returns: ArrayLike,
|
|
440
|
+
benchmark_returns: ArrayLike,
|
|
441
|
+
) -> tuple[float, float]:
|
|
442
|
+
"""Compute up and down capture ratios.
|
|
443
|
+
|
|
444
|
+
Args:
|
|
445
|
+
returns: Strategy daily returns
|
|
446
|
+
benchmark_returns: Benchmark daily returns
|
|
447
|
+
|
|
448
|
+
Returns:
|
|
449
|
+
(up_capture, down_capture) tuple
|
|
450
|
+
"""
|
|
451
|
+
returns = _to_numpy(returns)
|
|
452
|
+
benchmark = _to_numpy(benchmark_returns)
|
|
453
|
+
|
|
454
|
+
# Align lengths
|
|
455
|
+
min_len = min(len(returns), len(benchmark))
|
|
456
|
+
returns = returns[:min_len]
|
|
457
|
+
benchmark = benchmark[:min_len]
|
|
458
|
+
|
|
459
|
+
# Up markets
|
|
460
|
+
up_mask = benchmark > 0
|
|
461
|
+
if up_mask.sum() > 0:
|
|
462
|
+
up_capture = _safe_prod(1 + returns[up_mask]) / _safe_prod(1 + benchmark[up_mask])
|
|
463
|
+
else:
|
|
464
|
+
up_capture = np.nan
|
|
465
|
+
|
|
466
|
+
# Down markets
|
|
467
|
+
down_mask = benchmark < 0
|
|
468
|
+
if down_mask.sum() > 0:
|
|
469
|
+
down_capture = _safe_prod(1 + returns[down_mask]) / _safe_prod(1 + benchmark[down_mask])
|
|
470
|
+
else:
|
|
471
|
+
down_capture = np.nan
|
|
472
|
+
|
|
473
|
+
return up_capture, down_capture
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def compute_portfolio_turnover(
|
|
477
|
+
weights: ArrayLike,
|
|
478
|
+
dates: ArrayLike | None = None,
|
|
479
|
+
annualize: bool = True,
|
|
480
|
+
periods_per_year: int = 252,
|
|
481
|
+
) -> dict[str, float]:
|
|
482
|
+
"""Compute portfolio turnover from a time series of weights.
|
|
483
|
+
|
|
484
|
+
Turnover measures how much the portfolio is traded over time. It's defined
|
|
485
|
+
as the average absolute change in weights across all positions.
|
|
486
|
+
|
|
487
|
+
**Definition**:
|
|
488
|
+
Turnover_t = (1/2) * Σ_i |w_{i,t} - w_{i,t-1}|
|
|
489
|
+
|
|
490
|
+
The 1/2 factor accounts for the fact that selling one asset requires
|
|
491
|
+
buying another (double-counting).
|
|
492
|
+
|
|
493
|
+
**Interpretation**:
|
|
494
|
+
- Turnover = 0%: Buy-and-hold (no rebalancing)
|
|
495
|
+
- Turnover = 100%: Full portfolio replacement each period
|
|
496
|
+
- Turnover > 200%: Aggressive trading (likely high transaction costs)
|
|
497
|
+
|
|
498
|
+
Parameters
|
|
499
|
+
----------
|
|
500
|
+
weights : array-like, shape (n_periods, n_assets)
|
|
501
|
+
Portfolio weights over time. Each row should sum to 1 (or close to it).
|
|
502
|
+
dates : array-like, optional
|
|
503
|
+
Date index for the weights. If provided, used for reporting.
|
|
504
|
+
annualize : bool, default=True
|
|
505
|
+
Whether to annualize the turnover (multiply by periods_per_year).
|
|
506
|
+
periods_per_year : int, default=252
|
|
507
|
+
Number of trading periods per year.
|
|
508
|
+
|
|
509
|
+
Returns
|
|
510
|
+
-------
|
|
511
|
+
dict[str, float]
|
|
512
|
+
- 'turnover_mean': Mean turnover per period (or annualized)
|
|
513
|
+
- 'turnover_median': Median turnover per period
|
|
514
|
+
- 'turnover_std': Standard deviation of turnover
|
|
515
|
+
- 'turnover_max': Maximum single-period turnover
|
|
516
|
+
- 'turnover_total': Total turnover over the entire period
|
|
517
|
+
- 'n_periods': Number of periods in the sample
|
|
518
|
+
- 'is_annualized': Whether turnover_mean is annualized
|
|
519
|
+
"""
|
|
520
|
+
weights = np.asarray(weights)
|
|
521
|
+
|
|
522
|
+
if weights.ndim != 2:
|
|
523
|
+
raise ValueError(
|
|
524
|
+
f"weights must be 2D array (n_periods, n_assets), got shape {weights.shape}"
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
n_periods, n_assets = weights.shape
|
|
528
|
+
|
|
529
|
+
if n_periods < 2:
|
|
530
|
+
raise ValueError(f"Need at least 2 periods for turnover, got {n_periods}")
|
|
531
|
+
|
|
532
|
+
# Compute period-by-period turnover
|
|
533
|
+
# Turnover_t = (1/2) * sum(|w_t - w_{t-1}|)
|
|
534
|
+
weight_changes = np.abs(np.diff(weights, axis=0)) # (n_periods-1, n_assets)
|
|
535
|
+
period_turnover = 0.5 * weight_changes.sum(axis=1) # (n_periods-1,)
|
|
536
|
+
|
|
537
|
+
# Compute statistics
|
|
538
|
+
mean_turnover = float(np.mean(period_turnover))
|
|
539
|
+
median_turnover = float(np.median(period_turnover))
|
|
540
|
+
std_turnover = float(np.std(period_turnover))
|
|
541
|
+
max_turnover = float(np.max(period_turnover))
|
|
542
|
+
total_turnover = float(np.sum(period_turnover))
|
|
543
|
+
|
|
544
|
+
# Annualize if requested
|
|
545
|
+
if annualize:
|
|
546
|
+
mean_turnover_output = mean_turnover * periods_per_year
|
|
547
|
+
else:
|
|
548
|
+
mean_turnover_output = mean_turnover
|
|
549
|
+
|
|
550
|
+
return {
|
|
551
|
+
"turnover_mean": mean_turnover_output * 100, # As percentage
|
|
552
|
+
"turnover_median": median_turnover * 100,
|
|
553
|
+
"turnover_std": std_turnover * 100,
|
|
554
|
+
"turnover_max": max_turnover * 100,
|
|
555
|
+
"turnover_total": total_turnover * 100,
|
|
556
|
+
"n_periods": n_periods,
|
|
557
|
+
"is_annualized": annualize,
|
|
558
|
+
"periods_per_year": periods_per_year,
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
__all__ = [
|
|
563
|
+
# Internal helpers (exported for testing)
|
|
564
|
+
"_to_numpy",
|
|
565
|
+
"_safe_prod",
|
|
566
|
+
"_safe_cumprod",
|
|
567
|
+
"_annualization_factor",
|
|
568
|
+
# Risk-adjusted return metrics
|
|
569
|
+
"sharpe_ratio",
|
|
570
|
+
"sortino_ratio",
|
|
571
|
+
"calmar_ratio",
|
|
572
|
+
"omega_ratio",
|
|
573
|
+
"tail_ratio",
|
|
574
|
+
# Return metrics
|
|
575
|
+
"max_drawdown",
|
|
576
|
+
"annual_return",
|
|
577
|
+
"annual_volatility",
|
|
578
|
+
# Risk metrics
|
|
579
|
+
"value_at_risk",
|
|
580
|
+
"conditional_var",
|
|
581
|
+
# Stability
|
|
582
|
+
"stability_of_timeseries",
|
|
583
|
+
# Benchmark-relative
|
|
584
|
+
"alpha_beta",
|
|
585
|
+
"information_ratio",
|
|
586
|
+
"up_down_capture",
|
|
587
|
+
# Portfolio turnover
|
|
588
|
+
"compute_portfolio_turnover",
|
|
589
|
+
]
|