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,625 @@
1
+ """Quantile returns visualization plots.
2
+
3
+ This module provides interactive Plotly visualizations for quantile analysis:
4
+ - plot_quantile_returns_bar: Mean returns by quantile (bar chart)
5
+ - plot_quantile_returns_violin: Return distributions by quantile
6
+ - plot_cumulative_returns: Cumulative returns by quantile over time
7
+ - plot_spread_timeseries: Top-bottom spread time series
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from datetime import datetime
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ import numpy as np
16
+ import plotly.graph_objects as go
17
+
18
+ from ml4t.diagnostic.visualization.core import (
19
+ create_base_figure,
20
+ format_percentage,
21
+ get_colorscale,
22
+ get_theme_config,
23
+ validate_theme,
24
+ )
25
+
26
+ if TYPE_CHECKING:
27
+ from ml4t.diagnostic.results.signal_results import QuantileAnalysisResult
28
+
29
+
30
+ def _get_quantile_colors(n_quantiles: int, theme_config: dict[str, Any]) -> list[str]:
31
+ """Get diverging colors for quantiles (red → green progression)."""
32
+ # Use a custom diverging scale: red for bottom, gray for middle, green for top
33
+ colors: list[str]
34
+ if n_quantiles <= 5:
35
+ colors = ["#D32F2F", "#F57C00", "#FBC02D", "#689F38", "#388E3C"][:n_quantiles]
36
+ else:
37
+ # Generate more colors via interpolation
38
+ try:
39
+ raw_colors = get_colorscale("rdylgn", n_colors=n_quantiles, reverse=False)
40
+ if isinstance(raw_colors[0], tuple): # Continuous colorscale format
41
+ colors = [str(c[1]) if isinstance(c, tuple) else str(c) for c in raw_colors]
42
+ else:
43
+ colors = [str(c) for c in raw_colors]
44
+ except (ValueError, IndexError):
45
+ # Fallback to theme colorway
46
+ colorway = theme_config.get("colorway", ["#1f77b4"])
47
+ colors = (colorway * ((n_quantiles // len(colorway)) + 1))[:n_quantiles]
48
+ return colors
49
+
50
+
51
+ def plot_quantile_returns_bar(
52
+ quantile_result: QuantileAnalysisResult,
53
+ period: str | None = None,
54
+ show_error_bars: bool = True,
55
+ show_spread: bool = True,
56
+ theme: str | None = None,
57
+ width: int | None = None,
58
+ height: int | None = None,
59
+ ) -> go.Figure:
60
+ """Plot mean returns by quantile as a bar chart.
61
+
62
+ Parameters
63
+ ----------
64
+ quantile_result : QuantileAnalysisResult
65
+ Quantile analysis result from SignalAnalysis.compute_quantile_analysis()
66
+ period : str | None
67
+ Period to plot (e.g., "1D", "5D"). If None, uses first period.
68
+ show_error_bars : bool, default True
69
+ Show standard error bars
70
+ show_spread : bool, default True
71
+ Show top-bottom spread annotation
72
+ theme : str | None
73
+ Plot theme (default, dark, print, presentation)
74
+ width : int | None
75
+ Figure width in pixels
76
+ height : int | None
77
+ Figure height in pixels
78
+
79
+ Returns
80
+ -------
81
+ go.Figure
82
+ Interactive Plotly figure
83
+
84
+ Examples
85
+ --------
86
+ >>> quantile_result = analyzer.compute_quantile_analysis()
87
+ >>> fig = plot_quantile_returns_bar(quantile_result, period="5D")
88
+ >>> fig.show()
89
+ """
90
+ theme = validate_theme(theme)
91
+ theme_config = get_theme_config(theme)
92
+
93
+ # Get period data
94
+ periods = quantile_result.periods
95
+ if period is None:
96
+ period = periods[0]
97
+ elif period not in periods:
98
+ raise ValueError(f"Period '{period}' not found. Available: {periods}")
99
+
100
+ n_quantiles = quantile_result.n_quantiles
101
+ quantile_labels = quantile_result.quantile_labels
102
+ mean_returns = quantile_result.mean_returns[period]
103
+ std_returns = quantile_result.std_returns[period]
104
+ counts = quantile_result.count_by_quantile
105
+
106
+ # Get colors
107
+ colors = _get_quantile_colors(n_quantiles, theme_config)
108
+
109
+ # Create figure
110
+ fig = create_base_figure(
111
+ title=f"Mean Returns by Quantile ({period})",
112
+ xaxis_title="Quantile",
113
+ yaxis_title="Mean Forward Return",
114
+ width=width or theme_config["defaults"]["bar_height"],
115
+ height=height or theme_config["defaults"]["bar_height"],
116
+ theme=theme,
117
+ )
118
+
119
+ # Prepare data
120
+ x_labels = quantile_labels
121
+ y_values = [mean_returns.get(q, 0) for q in quantile_labels]
122
+ y_std = [std_returns.get(q, 0) for q in quantile_labels]
123
+
124
+ # Compute standard errors
125
+ y_stderr = []
126
+ for q, std in zip(quantile_labels, y_std, strict=False):
127
+ count = counts.get(q, 1)
128
+ y_stderr.append(std / np.sqrt(count) if count > 0 else 0)
129
+
130
+ # Bar chart
131
+ fig.add_trace(
132
+ go.Bar(
133
+ x=x_labels,
134
+ y=y_values,
135
+ marker_color=colors,
136
+ error_y={
137
+ "type": "data",
138
+ "array": y_stderr,
139
+ "visible": show_error_bars,
140
+ "color": "gray",
141
+ }
142
+ if show_error_bars
143
+ else None,
144
+ hovertemplate=("Quantile: %{x}<br>Mean Return: %{y:.4f}<br><extra></extra>"),
145
+ name="Mean Return",
146
+ )
147
+ )
148
+
149
+ # Zero line
150
+ fig.add_hline(y=0, line_dash="dash", line_color="gray", opacity=0.5)
151
+
152
+ # Spread annotation
153
+ if show_spread:
154
+ spread = quantile_result.spread_mean.get(period, 0)
155
+ spread_t = quantile_result.spread_t_stat.get(period, 0)
156
+ spread_p = quantile_result.spread_p_value.get(period, 1)
157
+ monotonic = quantile_result.is_monotonic.get(period, False)
158
+ direction = quantile_result.monotonicity_direction.get(period, "none")
159
+
160
+ spread_text = (
161
+ f"<b>Spread Analysis:</b><br>"
162
+ f"Top - Bottom: {format_percentage(spread)}<br>"
163
+ f"t-stat: {spread_t:.2f} (p={spread_p:.4f})<br>"
164
+ f"Monotonic: {'✓ ' + direction if monotonic else '✗ No'}"
165
+ )
166
+
167
+ fig.add_annotation(
168
+ text=spread_text,
169
+ xref="paper",
170
+ yref="paper",
171
+ x=0.98,
172
+ y=0.98,
173
+ showarrow=False,
174
+ bgcolor="rgba(255,255,255,0.8)" if theme != "dark" else "rgba(50,50,50,0.8)",
175
+ bordercolor="gray",
176
+ borderwidth=1,
177
+ align="left",
178
+ xanchor="right",
179
+ yanchor="top",
180
+ )
181
+
182
+ # Format y-axis as percentage
183
+ fig.update_yaxes(tickformat=".2%")
184
+
185
+ return fig
186
+
187
+
188
+ def plot_quantile_returns_violin(
189
+ quantile_result: QuantileAnalysisResult,
190
+ factor_data: dict | None = None,
191
+ period: str | None = None,
192
+ show_box: bool = True,
193
+ theme: str | None = None,
194
+ width: int | None = None,
195
+ height: int | None = None,
196
+ ) -> go.Figure:
197
+ """Plot return distributions by quantile as violin plots.
198
+
199
+ Parameters
200
+ ----------
201
+ quantile_result : QuantileAnalysisResult
202
+ Quantile analysis result from SignalAnalysis.compute_quantile_analysis()
203
+ factor_data : dict | None
204
+ Raw factor data dict with 'quantile' and return columns.
205
+ If None, uses synthetic data from result statistics.
206
+ period : str | None
207
+ Period to plot. If None, uses first period.
208
+ show_box : bool, default True
209
+ Show box plot inside violin
210
+ theme : str | None
211
+ Plot theme
212
+ width : int | None
213
+ Figure width
214
+ height : int | None
215
+ Figure height
216
+
217
+ Returns
218
+ -------
219
+ go.Figure
220
+ Interactive Plotly figure
221
+ """
222
+ theme = validate_theme(theme)
223
+ theme_config = get_theme_config(theme)
224
+
225
+ # Get period data
226
+ periods = quantile_result.periods
227
+ if period is None:
228
+ period = periods[0]
229
+ elif period not in periods:
230
+ raise ValueError(f"Period '{period}' not found. Available: {periods}")
231
+
232
+ n_quantiles = quantile_result.n_quantiles
233
+ quantile_labels = quantile_result.quantile_labels
234
+ colors = _get_quantile_colors(n_quantiles, theme_config)
235
+
236
+ # Create figure
237
+ fig = create_base_figure(
238
+ title=f"Return Distribution by Quantile ({period})",
239
+ xaxis_title="Quantile",
240
+ yaxis_title="Forward Return",
241
+ width=width or theme_config["defaults"]["bar_height"] + 200,
242
+ height=height or theme_config["defaults"]["bar_height"],
243
+ theme=theme,
244
+ )
245
+
246
+ # If we have raw data, use it; otherwise generate synthetic
247
+ if factor_data is not None and "quantile" in factor_data:
248
+ import polars as pl
249
+
250
+ if isinstance(factor_data, pl.DataFrame):
251
+ # Extract return column for this period
252
+ return_col = period.replace("D", "D_fwd_return")
253
+ for i, q_label in enumerate(quantile_labels):
254
+ q_num = i + 1
255
+ q_data = factor_data.filter(pl.col("quantile") == q_num)
256
+ returns = q_data[return_col].to_numpy()
257
+ returns = returns[~np.isnan(returns)]
258
+
259
+ fig.add_trace(
260
+ go.Violin(
261
+ y=returns,
262
+ name=q_label,
263
+ box_visible=show_box,
264
+ meanline_visible=True,
265
+ fillcolor=colors[i],
266
+ line_color=colors[i],
267
+ opacity=0.6,
268
+ )
269
+ )
270
+ else:
271
+ # Generate synthetic violin data from mean/std
272
+ # This is approximate but useful when raw data isn't available
273
+ mean_returns = quantile_result.mean_returns[period]
274
+ std_returns = quantile_result.std_returns[period]
275
+ counts = quantile_result.count_by_quantile
276
+
277
+ for i, q_label in enumerate(quantile_labels):
278
+ mean = mean_returns.get(q_label, 0)
279
+ std = std_returns.get(q_label, 0.01)
280
+ n = counts.get(q_label, 100)
281
+
282
+ # Generate synthetic sample
283
+ np.random.seed(42 + i) # Reproducible
284
+ synthetic = np.random.normal(mean, std, min(n, 1000))
285
+
286
+ fig.add_trace(
287
+ go.Violin(
288
+ y=synthetic,
289
+ name=q_label,
290
+ box_visible=show_box,
291
+ meanline_visible=True,
292
+ fillcolor=colors[i],
293
+ line_color=colors[i],
294
+ opacity=0.6,
295
+ )
296
+ )
297
+
298
+ # Zero line
299
+ fig.add_hline(y=0, line_dash="dash", line_color="gray", opacity=0.5)
300
+
301
+ # Format y-axis
302
+ fig.update_yaxes(tickformat=".2%")
303
+ fig.update_layout(showlegend=False)
304
+
305
+ return fig
306
+
307
+
308
+ def plot_cumulative_returns(
309
+ quantile_result: QuantileAnalysisResult,
310
+ period: str | None = None,
311
+ show_spread: bool = True,
312
+ theme: str | None = None,
313
+ width: int | None = None,
314
+ height: int | None = None,
315
+ ) -> go.Figure:
316
+ """Plot cumulative returns by quantile over time.
317
+
318
+ Parameters
319
+ ----------
320
+ quantile_result : QuantileAnalysisResult
321
+ Quantile analysis result with cumulative_returns computed.
322
+ period : str | None
323
+ Period to plot. If None, uses first period.
324
+ show_spread : bool, default True
325
+ Show top-bottom spread as shaded area
326
+ theme : str | None
327
+ Plot theme
328
+ width : int | None
329
+ Figure width
330
+ height : int | None
331
+ Figure height
332
+
333
+ Returns
334
+ -------
335
+ go.Figure
336
+ Interactive Plotly figure
337
+
338
+ Raises
339
+ ------
340
+ ValueError
341
+ If cumulative_returns not available in result
342
+ """
343
+ theme = validate_theme(theme)
344
+ theme_config = get_theme_config(theme)
345
+
346
+ # Check for cumulative returns
347
+ if quantile_result.cumulative_returns is None:
348
+ raise ValueError(
349
+ "Cumulative returns not computed. Set cumulative_returns=True in SignalConfig."
350
+ )
351
+
352
+ # Get period data
353
+ periods = quantile_result.periods
354
+ if period is None:
355
+ period = periods[0]
356
+ elif period not in periods:
357
+ raise ValueError(f"Period '{period}' not found. Available: {periods}")
358
+
359
+ n_quantiles = quantile_result.n_quantiles
360
+ quantile_labels = quantile_result.quantile_labels
361
+ colors = _get_quantile_colors(n_quantiles, theme_config)
362
+
363
+ cum_returns = quantile_result.cumulative_returns[period]
364
+ dates_raw = quantile_result.cumulative_dates
365
+
366
+ # Convert dates or create fallback indices
367
+ if dates_raw is not None and len(dates_raw) > 0:
368
+ if isinstance(dates_raw[0], str):
369
+ try:
370
+ dates: list[Any] = [datetime.fromisoformat(d) for d in dates_raw]
371
+ except ValueError:
372
+ dates = list(dates_raw)
373
+ else:
374
+ dates = list(dates_raw)
375
+ else:
376
+ # Fallback to integer indices if no dates provided
377
+ max_len = max(len(v) for v in cum_returns.values()) if cum_returns else 0
378
+ dates = list(range(max_len))
379
+
380
+ # Create figure
381
+ fig = create_base_figure(
382
+ title=f"Cumulative Returns by Quantile ({period})",
383
+ xaxis_title="Date",
384
+ yaxis_title="Cumulative Return",
385
+ width=width or theme_config["defaults"]["line_height"] + 300,
386
+ height=height or theme_config["defaults"]["line_height"],
387
+ theme=theme,
388
+ )
389
+
390
+ # Plot each quantile
391
+ for i, q_label in enumerate(quantile_labels):
392
+ cum_ret = cum_returns.get(q_label, [])
393
+ if len(cum_ret) == 0:
394
+ continue
395
+
396
+ fig.add_trace(
397
+ go.Scatter(
398
+ x=dates[: len(cum_ret)],
399
+ y=cum_ret,
400
+ mode="lines",
401
+ name=q_label,
402
+ line={"color": colors[i], "width": 2},
403
+ hovertemplate=f"{q_label}<br>Date: %{{x}}<br>Cum. Return: %{{y:.2%}}<extra></extra>",
404
+ )
405
+ )
406
+
407
+ # Spread area (top minus bottom)
408
+ if show_spread and n_quantiles >= 2:
409
+ top_ret = cum_returns.get(quantile_labels[-1], [])
410
+ bottom_ret = cum_returns.get(quantile_labels[0], [])
411
+
412
+ if len(top_ret) > 0 and len(bottom_ret) > 0:
413
+ min_len = min(len(top_ret), len(bottom_ret))
414
+ spread = [top_ret[i] - bottom_ret[i] for i in range(min_len)]
415
+
416
+ fig.add_trace(
417
+ go.Scatter(
418
+ x=dates[:min_len],
419
+ y=spread,
420
+ mode="lines",
421
+ name="Spread (Top - Bottom)",
422
+ line={"color": "purple", "width": 2, "dash": "dash"},
423
+ hovertemplate="Spread<br>Date: %{x}<br>Spread: %{y:.2%}<extra></extra>",
424
+ )
425
+ )
426
+
427
+ # Zero line
428
+ fig.add_hline(y=0, line_dash="dot", line_color="gray", opacity=0.5)
429
+
430
+ # Format y-axis
431
+ fig.update_yaxes(tickformat=".0%")
432
+
433
+ fig.update_layout(
434
+ legend={
435
+ "yanchor": "top",
436
+ "y": 0.99,
437
+ "xanchor": "left",
438
+ "x": 0.01,
439
+ },
440
+ )
441
+
442
+ return fig
443
+
444
+
445
+ def plot_spread_timeseries(
446
+ quantile_result: QuantileAnalysisResult,
447
+ period: str | None = None,
448
+ rolling_window: int = 21,
449
+ show_confidence: bool = True,
450
+ theme: str | None = None,
451
+ width: int | None = None,
452
+ height: int | None = None,
453
+ ) -> go.Figure:
454
+ """Plot top-bottom spread over time with rolling statistics.
455
+
456
+ Parameters
457
+ ----------
458
+ quantile_result : QuantileAnalysisResult
459
+ Quantile analysis result with cumulative_returns computed.
460
+ period : str | None
461
+ Period to plot. If None, uses first period.
462
+ rolling_window : int, default 21
463
+ Window for rolling mean/std calculation
464
+ show_confidence : bool, default True
465
+ Show confidence band around rolling mean
466
+ theme : str | None
467
+ Plot theme
468
+ width : int | None
469
+ Figure width
470
+ height : int | None
471
+ Figure height
472
+
473
+ Returns
474
+ -------
475
+ go.Figure
476
+ Interactive Plotly figure
477
+ """
478
+ theme = validate_theme(theme)
479
+ theme_config = get_theme_config(theme)
480
+
481
+ # Check for cumulative returns
482
+ if quantile_result.cumulative_returns is None:
483
+ raise ValueError(
484
+ "Cumulative returns not computed. Set cumulative_returns=True in SignalConfig."
485
+ )
486
+
487
+ # Get period data
488
+ periods = quantile_result.periods
489
+ if period is None:
490
+ period = periods[0]
491
+ elif period not in periods:
492
+ raise ValueError(f"Period '{period}' not found. Available: {periods}")
493
+
494
+ quantile_labels = quantile_result.quantile_labels
495
+ cum_returns = quantile_result.cumulative_returns[period]
496
+ dates_raw = quantile_result.cumulative_dates
497
+
498
+ # Convert dates or create fallback indices
499
+ if dates_raw is not None and len(dates_raw) > 0:
500
+ if isinstance(dates_raw[0], str):
501
+ try:
502
+ dates: list[Any] = [datetime.fromisoformat(d) for d in dates_raw]
503
+ except ValueError:
504
+ dates = list(dates_raw)
505
+ else:
506
+ dates = list(dates_raw)
507
+ else:
508
+ # Fallback to integer indices if no dates provided
509
+ max_len = max(len(v) for v in cum_returns.values()) if cum_returns else 0
510
+ dates = list(range(max_len))
511
+
512
+ # Compute daily spread (difference in daily returns)
513
+ top_cum = np.array(cum_returns.get(quantile_labels[-1], []))
514
+ bottom_cum = np.array(cum_returns.get(quantile_labels[0], []))
515
+
516
+ if len(top_cum) < 2 or len(bottom_cum) < 2:
517
+ raise ValueError("Insufficient data for spread calculation")
518
+
519
+ min_len = min(len(top_cum), len(bottom_cum))
520
+ top_cum = top_cum[:min_len]
521
+ bottom_cum = bottom_cum[:min_len]
522
+ dates = dates[:min_len]
523
+
524
+ # Daily returns from cumulative
525
+ top_daily = np.diff(top_cum, prepend=0)
526
+ bottom_daily = np.diff(bottom_cum, prepend=0)
527
+ spread_daily = top_daily - bottom_daily
528
+
529
+ # Rolling statistics
530
+ rolling_mean = np.full_like(spread_daily, np.nan)
531
+ rolling_std = np.full_like(spread_daily, np.nan)
532
+
533
+ for i in range(rolling_window - 1, len(spread_daily)):
534
+ window = spread_daily[i - rolling_window + 1 : i + 1]
535
+ rolling_mean[i] = np.mean(window)
536
+ rolling_std[i] = np.std(window, ddof=1)
537
+
538
+ # Create figure
539
+ fig = create_base_figure(
540
+ title=f"Spread Time Series ({period}) - Top vs Bottom Quantile",
541
+ xaxis_title="Date",
542
+ yaxis_title="Daily Spread Return",
543
+ width=width or theme_config["defaults"]["line_height"] + 300,
544
+ height=height or theme_config["defaults"]["line_height"],
545
+ theme=theme,
546
+ )
547
+
548
+ # Daily spread scatter
549
+ fig.add_trace(
550
+ go.Scatter(
551
+ x=dates,
552
+ y=spread_daily,
553
+ mode="markers",
554
+ name="Daily Spread",
555
+ marker={
556
+ "size": 4,
557
+ "color": theme_config["colorway"][0],
558
+ "opacity": 0.4,
559
+ },
560
+ hovertemplate="Date: %{x}<br>Spread: %{y:.4f}<extra></extra>",
561
+ )
562
+ )
563
+
564
+ # Rolling mean
565
+ fig.add_trace(
566
+ go.Scatter(
567
+ x=dates,
568
+ y=rolling_mean,
569
+ mode="lines",
570
+ name=f"{rolling_window}-Day Rolling Mean",
571
+ line={"color": theme_config["colorway"][1], "width": 2},
572
+ hovertemplate="Date: %{x}<br>Rolling Mean: %{y:.4f}<extra></extra>",
573
+ )
574
+ )
575
+
576
+ # Confidence band
577
+ if show_confidence:
578
+ upper = rolling_mean + 1.96 * rolling_std
579
+ lower = rolling_mean - 1.96 * rolling_std
580
+
581
+ fig.add_trace(
582
+ go.Scatter(
583
+ x=list(dates) + list(reversed(dates)),
584
+ y=list(upper) + list(reversed(lower)),
585
+ fill="toself",
586
+ fillcolor="rgba(128, 128, 128, 0.2)",
587
+ line={"width": 0},
588
+ showlegend=True,
589
+ name="95% CI",
590
+ hoverinfo="skip",
591
+ )
592
+ )
593
+
594
+ # Zero line
595
+ fig.add_hline(y=0, line_dash="dash", line_color="gray", opacity=0.5)
596
+
597
+ # Summary statistics
598
+ mean_spread = quantile_result.spread_mean.get(period, 0)
599
+ t_stat = quantile_result.spread_t_stat.get(period, 0)
600
+ p_value = quantile_result.spread_p_value.get(period, 1)
601
+
602
+ summary_text = (
603
+ f"<b>Spread Statistics:</b><br>"
604
+ f"Mean: {format_percentage(mean_spread)}<br>"
605
+ f"t-stat: {t_stat:.2f}<br>"
606
+ f"p-value: {p_value:.4f}"
607
+ )
608
+
609
+ fig.add_annotation(
610
+ text=summary_text,
611
+ xref="paper",
612
+ yref="paper",
613
+ x=0.02,
614
+ y=0.98,
615
+ showarrow=False,
616
+ bgcolor="rgba(255,255,255,0.8)" if theme != "dark" else "rgba(50,50,50,0.8)",
617
+ bordercolor="gray",
618
+ borderwidth=1,
619
+ align="left",
620
+ )
621
+
622
+ # Format y-axis
623
+ fig.update_yaxes(tickformat=".2%")
624
+
625
+ return fig