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.
Files changed (242) hide show
  1. ml4t/diagnostic/AGENT.md +25 -0
  2. ml4t/diagnostic/__init__.py +166 -0
  3. ml4t/diagnostic/backends/__init__.py +10 -0
  4. ml4t/diagnostic/backends/adapter.py +192 -0
  5. ml4t/diagnostic/backends/polars_backend.py +899 -0
  6. ml4t/diagnostic/caching/__init__.py +40 -0
  7. ml4t/diagnostic/caching/cache.py +331 -0
  8. ml4t/diagnostic/caching/decorators.py +131 -0
  9. ml4t/diagnostic/caching/smart_cache.py +339 -0
  10. ml4t/diagnostic/config/AGENT.md +24 -0
  11. ml4t/diagnostic/config/README.md +267 -0
  12. ml4t/diagnostic/config/__init__.py +219 -0
  13. ml4t/diagnostic/config/barrier_config.py +277 -0
  14. ml4t/diagnostic/config/base.py +301 -0
  15. ml4t/diagnostic/config/event_config.py +148 -0
  16. ml4t/diagnostic/config/feature_config.py +404 -0
  17. ml4t/diagnostic/config/multi_signal_config.py +55 -0
  18. ml4t/diagnostic/config/portfolio_config.py +215 -0
  19. ml4t/diagnostic/config/report_config.py +391 -0
  20. ml4t/diagnostic/config/sharpe_config.py +202 -0
  21. ml4t/diagnostic/config/signal_config.py +206 -0
  22. ml4t/diagnostic/config/trade_analysis_config.py +310 -0
  23. ml4t/diagnostic/config/validation.py +279 -0
  24. ml4t/diagnostic/core/__init__.py +29 -0
  25. ml4t/diagnostic/core/numba_utils.py +315 -0
  26. ml4t/diagnostic/core/purging.py +372 -0
  27. ml4t/diagnostic/core/sampling.py +471 -0
  28. ml4t/diagnostic/errors/__init__.py +205 -0
  29. ml4t/diagnostic/evaluation/AGENT.md +26 -0
  30. ml4t/diagnostic/evaluation/__init__.py +437 -0
  31. ml4t/diagnostic/evaluation/autocorrelation.py +531 -0
  32. ml4t/diagnostic/evaluation/barrier_analysis.py +1050 -0
  33. ml4t/diagnostic/evaluation/binary_metrics.py +910 -0
  34. ml4t/diagnostic/evaluation/dashboard.py +715 -0
  35. ml4t/diagnostic/evaluation/diagnostic_plots.py +1037 -0
  36. ml4t/diagnostic/evaluation/distribution/__init__.py +499 -0
  37. ml4t/diagnostic/evaluation/distribution/moments.py +299 -0
  38. ml4t/diagnostic/evaluation/distribution/tails.py +777 -0
  39. ml4t/diagnostic/evaluation/distribution/tests.py +470 -0
  40. ml4t/diagnostic/evaluation/drift/__init__.py +139 -0
  41. ml4t/diagnostic/evaluation/drift/analysis.py +432 -0
  42. ml4t/diagnostic/evaluation/drift/domain_classifier.py +517 -0
  43. ml4t/diagnostic/evaluation/drift/population_stability_index.py +310 -0
  44. ml4t/diagnostic/evaluation/drift/wasserstein.py +388 -0
  45. ml4t/diagnostic/evaluation/event_analysis.py +647 -0
  46. ml4t/diagnostic/evaluation/excursion.py +390 -0
  47. ml4t/diagnostic/evaluation/feature_diagnostics.py +873 -0
  48. ml4t/diagnostic/evaluation/feature_outcome.py +666 -0
  49. ml4t/diagnostic/evaluation/framework.py +935 -0
  50. ml4t/diagnostic/evaluation/metric_registry.py +255 -0
  51. ml4t/diagnostic/evaluation/metrics/AGENT.md +23 -0
  52. ml4t/diagnostic/evaluation/metrics/__init__.py +133 -0
  53. ml4t/diagnostic/evaluation/metrics/basic.py +160 -0
  54. ml4t/diagnostic/evaluation/metrics/conditional_ic.py +469 -0
  55. ml4t/diagnostic/evaluation/metrics/feature_outcome.py +475 -0
  56. ml4t/diagnostic/evaluation/metrics/ic_statistics.py +446 -0
  57. ml4t/diagnostic/evaluation/metrics/importance_analysis.py +338 -0
  58. ml4t/diagnostic/evaluation/metrics/importance_classical.py +375 -0
  59. ml4t/diagnostic/evaluation/metrics/importance_mda.py +371 -0
  60. ml4t/diagnostic/evaluation/metrics/importance_shap.py +715 -0
  61. ml4t/diagnostic/evaluation/metrics/information_coefficient.py +527 -0
  62. ml4t/diagnostic/evaluation/metrics/interactions.py +772 -0
  63. ml4t/diagnostic/evaluation/metrics/monotonicity.py +226 -0
  64. ml4t/diagnostic/evaluation/metrics/risk_adjusted.py +324 -0
  65. ml4t/diagnostic/evaluation/multi_signal.py +550 -0
  66. ml4t/diagnostic/evaluation/portfolio_analysis/__init__.py +83 -0
  67. ml4t/diagnostic/evaluation/portfolio_analysis/analysis.py +734 -0
  68. ml4t/diagnostic/evaluation/portfolio_analysis/metrics.py +589 -0
  69. ml4t/diagnostic/evaluation/portfolio_analysis/results.py +334 -0
  70. ml4t/diagnostic/evaluation/report_generation.py +824 -0
  71. ml4t/diagnostic/evaluation/signal_selector.py +452 -0
  72. ml4t/diagnostic/evaluation/stat_registry.py +139 -0
  73. ml4t/diagnostic/evaluation/stationarity/__init__.py +97 -0
  74. ml4t/diagnostic/evaluation/stationarity/analysis.py +518 -0
  75. ml4t/diagnostic/evaluation/stationarity/augmented_dickey_fuller.py +296 -0
  76. ml4t/diagnostic/evaluation/stationarity/kpss_test.py +308 -0
  77. ml4t/diagnostic/evaluation/stationarity/phillips_perron.py +365 -0
  78. ml4t/diagnostic/evaluation/stats/AGENT.md +43 -0
  79. ml4t/diagnostic/evaluation/stats/__init__.py +191 -0
  80. ml4t/diagnostic/evaluation/stats/backtest_overfitting.py +219 -0
  81. ml4t/diagnostic/evaluation/stats/bootstrap.py +228 -0
  82. ml4t/diagnostic/evaluation/stats/deflated_sharpe_ratio.py +591 -0
  83. ml4t/diagnostic/evaluation/stats/false_discovery_rate.py +295 -0
  84. ml4t/diagnostic/evaluation/stats/hac_standard_errors.py +108 -0
  85. ml4t/diagnostic/evaluation/stats/minimum_track_record.py +408 -0
  86. ml4t/diagnostic/evaluation/stats/moments.py +164 -0
  87. ml4t/diagnostic/evaluation/stats/rademacher_adjustment.py +436 -0
  88. ml4t/diagnostic/evaluation/stats/reality_check.py +155 -0
  89. ml4t/diagnostic/evaluation/stats/sharpe_inference.py +219 -0
  90. ml4t/diagnostic/evaluation/themes.py +330 -0
  91. ml4t/diagnostic/evaluation/threshold_analysis.py +957 -0
  92. ml4t/diagnostic/evaluation/trade_analysis.py +1136 -0
  93. ml4t/diagnostic/evaluation/trade_dashboard/__init__.py +32 -0
  94. ml4t/diagnostic/evaluation/trade_dashboard/app.py +315 -0
  95. ml4t/diagnostic/evaluation/trade_dashboard/export/__init__.py +18 -0
  96. ml4t/diagnostic/evaluation/trade_dashboard/export/csv.py +82 -0
  97. ml4t/diagnostic/evaluation/trade_dashboard/export/html.py +276 -0
  98. ml4t/diagnostic/evaluation/trade_dashboard/io.py +166 -0
  99. ml4t/diagnostic/evaluation/trade_dashboard/normalize.py +304 -0
  100. ml4t/diagnostic/evaluation/trade_dashboard/stats.py +386 -0
  101. ml4t/diagnostic/evaluation/trade_dashboard/style.py +79 -0
  102. ml4t/diagnostic/evaluation/trade_dashboard/tabs/__init__.py +21 -0
  103. ml4t/diagnostic/evaluation/trade_dashboard/tabs/patterns.py +354 -0
  104. ml4t/diagnostic/evaluation/trade_dashboard/tabs/shap_analysis.py +280 -0
  105. ml4t/diagnostic/evaluation/trade_dashboard/tabs/stat_validation.py +186 -0
  106. ml4t/diagnostic/evaluation/trade_dashboard/tabs/worst_trades.py +236 -0
  107. ml4t/diagnostic/evaluation/trade_dashboard/types.py +129 -0
  108. ml4t/diagnostic/evaluation/trade_shap/__init__.py +102 -0
  109. ml4t/diagnostic/evaluation/trade_shap/alignment.py +188 -0
  110. ml4t/diagnostic/evaluation/trade_shap/characterize.py +413 -0
  111. ml4t/diagnostic/evaluation/trade_shap/cluster.py +302 -0
  112. ml4t/diagnostic/evaluation/trade_shap/explain.py +208 -0
  113. ml4t/diagnostic/evaluation/trade_shap/hypotheses/__init__.py +23 -0
  114. ml4t/diagnostic/evaluation/trade_shap/hypotheses/generator.py +290 -0
  115. ml4t/diagnostic/evaluation/trade_shap/hypotheses/matcher.py +251 -0
  116. ml4t/diagnostic/evaluation/trade_shap/hypotheses/templates.yaml +467 -0
  117. ml4t/diagnostic/evaluation/trade_shap/models.py +386 -0
  118. ml4t/diagnostic/evaluation/trade_shap/normalize.py +116 -0
  119. ml4t/diagnostic/evaluation/trade_shap/pipeline.py +263 -0
  120. ml4t/diagnostic/evaluation/trade_shap_dashboard.py +283 -0
  121. ml4t/diagnostic/evaluation/trade_shap_diagnostics.py +588 -0
  122. ml4t/diagnostic/evaluation/validated_cv.py +535 -0
  123. ml4t/diagnostic/evaluation/visualization.py +1050 -0
  124. ml4t/diagnostic/evaluation/volatility/__init__.py +45 -0
  125. ml4t/diagnostic/evaluation/volatility/analysis.py +351 -0
  126. ml4t/diagnostic/evaluation/volatility/arch.py +258 -0
  127. ml4t/diagnostic/evaluation/volatility/garch.py +460 -0
  128. ml4t/diagnostic/integration/__init__.py +48 -0
  129. ml4t/diagnostic/integration/backtest_contract.py +671 -0
  130. ml4t/diagnostic/integration/data_contract.py +316 -0
  131. ml4t/diagnostic/integration/engineer_contract.py +226 -0
  132. ml4t/diagnostic/logging/__init__.py +77 -0
  133. ml4t/diagnostic/logging/logger.py +245 -0
  134. ml4t/diagnostic/logging/performance.py +234 -0
  135. ml4t/diagnostic/logging/progress.py +234 -0
  136. ml4t/diagnostic/logging/wandb.py +412 -0
  137. ml4t/diagnostic/metrics/__init__.py +9 -0
  138. ml4t/diagnostic/metrics/percentiles.py +128 -0
  139. ml4t/diagnostic/py.typed +1 -0
  140. ml4t/diagnostic/reporting/__init__.py +43 -0
  141. ml4t/diagnostic/reporting/base.py +130 -0
  142. ml4t/diagnostic/reporting/html_renderer.py +275 -0
  143. ml4t/diagnostic/reporting/json_renderer.py +51 -0
  144. ml4t/diagnostic/reporting/markdown_renderer.py +117 -0
  145. ml4t/diagnostic/results/AGENT.md +24 -0
  146. ml4t/diagnostic/results/__init__.py +105 -0
  147. ml4t/diagnostic/results/barrier_results/__init__.py +36 -0
  148. ml4t/diagnostic/results/barrier_results/hit_rate.py +304 -0
  149. ml4t/diagnostic/results/barrier_results/precision_recall.py +266 -0
  150. ml4t/diagnostic/results/barrier_results/profit_factor.py +297 -0
  151. ml4t/diagnostic/results/barrier_results/tearsheet.py +397 -0
  152. ml4t/diagnostic/results/barrier_results/time_to_target.py +305 -0
  153. ml4t/diagnostic/results/barrier_results/validation.py +38 -0
  154. ml4t/diagnostic/results/base.py +177 -0
  155. ml4t/diagnostic/results/event_results.py +349 -0
  156. ml4t/diagnostic/results/feature_results.py +787 -0
  157. ml4t/diagnostic/results/multi_signal_results.py +431 -0
  158. ml4t/diagnostic/results/portfolio_results.py +281 -0
  159. ml4t/diagnostic/results/sharpe_results.py +448 -0
  160. ml4t/diagnostic/results/signal_results/__init__.py +74 -0
  161. ml4t/diagnostic/results/signal_results/ic.py +581 -0
  162. ml4t/diagnostic/results/signal_results/irtc.py +110 -0
  163. ml4t/diagnostic/results/signal_results/quantile.py +392 -0
  164. ml4t/diagnostic/results/signal_results/tearsheet.py +456 -0
  165. ml4t/diagnostic/results/signal_results/turnover.py +213 -0
  166. ml4t/diagnostic/results/signal_results/validation.py +147 -0
  167. ml4t/diagnostic/signal/AGENT.md +17 -0
  168. ml4t/diagnostic/signal/__init__.py +69 -0
  169. ml4t/diagnostic/signal/_report.py +152 -0
  170. ml4t/diagnostic/signal/_utils.py +261 -0
  171. ml4t/diagnostic/signal/core.py +275 -0
  172. ml4t/diagnostic/signal/quantile.py +148 -0
  173. ml4t/diagnostic/signal/result.py +214 -0
  174. ml4t/diagnostic/signal/signal_ic.py +129 -0
  175. ml4t/diagnostic/signal/turnover.py +182 -0
  176. ml4t/diagnostic/splitters/AGENT.md +19 -0
  177. ml4t/diagnostic/splitters/__init__.py +36 -0
  178. ml4t/diagnostic/splitters/base.py +501 -0
  179. ml4t/diagnostic/splitters/calendar.py +421 -0
  180. ml4t/diagnostic/splitters/calendar_config.py +91 -0
  181. ml4t/diagnostic/splitters/combinatorial.py +1064 -0
  182. ml4t/diagnostic/splitters/config.py +322 -0
  183. ml4t/diagnostic/splitters/cpcv/__init__.py +57 -0
  184. ml4t/diagnostic/splitters/cpcv/combinations.py +119 -0
  185. ml4t/diagnostic/splitters/cpcv/partitioning.py +263 -0
  186. ml4t/diagnostic/splitters/cpcv/purge_engine.py +379 -0
  187. ml4t/diagnostic/splitters/cpcv/windows.py +190 -0
  188. ml4t/diagnostic/splitters/group_isolation.py +329 -0
  189. ml4t/diagnostic/splitters/persistence.py +316 -0
  190. ml4t/diagnostic/splitters/utils.py +207 -0
  191. ml4t/diagnostic/splitters/walk_forward.py +757 -0
  192. ml4t/diagnostic/utils/__init__.py +42 -0
  193. ml4t/diagnostic/utils/config.py +542 -0
  194. ml4t/diagnostic/utils/dependencies.py +318 -0
  195. ml4t/diagnostic/utils/sessions.py +127 -0
  196. ml4t/diagnostic/validation/__init__.py +54 -0
  197. ml4t/diagnostic/validation/dataframe.py +274 -0
  198. ml4t/diagnostic/validation/returns.py +280 -0
  199. ml4t/diagnostic/validation/timeseries.py +299 -0
  200. ml4t/diagnostic/visualization/AGENT.md +19 -0
  201. ml4t/diagnostic/visualization/__init__.py +223 -0
  202. ml4t/diagnostic/visualization/backtest/__init__.py +98 -0
  203. ml4t/diagnostic/visualization/backtest/cost_attribution.py +762 -0
  204. ml4t/diagnostic/visualization/backtest/executive_summary.py +895 -0
  205. ml4t/diagnostic/visualization/backtest/interactive_controls.py +673 -0
  206. ml4t/diagnostic/visualization/backtest/statistical_validity.py +874 -0
  207. ml4t/diagnostic/visualization/backtest/tearsheet.py +565 -0
  208. ml4t/diagnostic/visualization/backtest/template_system.py +373 -0
  209. ml4t/diagnostic/visualization/backtest/trade_plots.py +1172 -0
  210. ml4t/diagnostic/visualization/barrier_plots.py +782 -0
  211. ml4t/diagnostic/visualization/core.py +1060 -0
  212. ml4t/diagnostic/visualization/dashboards/__init__.py +36 -0
  213. ml4t/diagnostic/visualization/dashboards/base.py +582 -0
  214. ml4t/diagnostic/visualization/dashboards/importance.py +801 -0
  215. ml4t/diagnostic/visualization/dashboards/interaction.py +263 -0
  216. ml4t/diagnostic/visualization/dashboards.py +43 -0
  217. ml4t/diagnostic/visualization/data_extraction/__init__.py +48 -0
  218. ml4t/diagnostic/visualization/data_extraction/importance.py +649 -0
  219. ml4t/diagnostic/visualization/data_extraction/interaction.py +504 -0
  220. ml4t/diagnostic/visualization/data_extraction/types.py +113 -0
  221. ml4t/diagnostic/visualization/data_extraction/validation.py +66 -0
  222. ml4t/diagnostic/visualization/feature_plots.py +888 -0
  223. ml4t/diagnostic/visualization/interaction_plots.py +618 -0
  224. ml4t/diagnostic/visualization/portfolio/__init__.py +41 -0
  225. ml4t/diagnostic/visualization/portfolio/dashboard.py +514 -0
  226. ml4t/diagnostic/visualization/portfolio/drawdown_plots.py +341 -0
  227. ml4t/diagnostic/visualization/portfolio/returns_plots.py +487 -0
  228. ml4t/diagnostic/visualization/portfolio/risk_plots.py +301 -0
  229. ml4t/diagnostic/visualization/report_generation.py +1343 -0
  230. ml4t/diagnostic/visualization/signal/__init__.py +103 -0
  231. ml4t/diagnostic/visualization/signal/dashboard.py +911 -0
  232. ml4t/diagnostic/visualization/signal/event_plots.py +514 -0
  233. ml4t/diagnostic/visualization/signal/ic_plots.py +635 -0
  234. ml4t/diagnostic/visualization/signal/multi_signal_dashboard.py +974 -0
  235. ml4t/diagnostic/visualization/signal/multi_signal_plots.py +603 -0
  236. ml4t/diagnostic/visualization/signal/quantile_plots.py +625 -0
  237. ml4t/diagnostic/visualization/signal/turnover_plots.py +400 -0
  238. ml4t/diagnostic/visualization/trade_shap/__init__.py +90 -0
  239. ml4t_diagnostic-0.1.0a1.dist-info/METADATA +1044 -0
  240. ml4t_diagnostic-0.1.0a1.dist-info/RECORD +242 -0
  241. ml4t_diagnostic-0.1.0a1.dist-info/WHEEL +4 -0
  242. 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
+ ]