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,874 @@
1
+ """Statistical validity visualizations for backtest analysis.
2
+
3
+ Provides interactive Plotly visualizations for statistical rigor:
4
+ - DSR (Deflated Sharpe Ratio) gauge with probability zones
5
+ - Confidence interval forest plots
6
+ - RAS (Rademacher Anti-Serum) overfitting detection
7
+ - MinTRL (Minimum Track Record Length) analysis
8
+
9
+ These visualizations help traders understand whether their backtest results
10
+ are statistically significant or likely due to overfitting/chance.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import TYPE_CHECKING, Any, Literal
16
+
17
+ import numpy as np
18
+ import plotly.graph_objects as go
19
+
20
+ from ml4t.diagnostic.visualization.core import get_theme_config
21
+
22
+ if TYPE_CHECKING:
23
+ pass
24
+
25
+
26
+ def plot_dsr_gauge(
27
+ dsr_probability: float,
28
+ observed_sharpe: float,
29
+ expected_max_sharpe: float | None = None,
30
+ n_trials: int | None = None,
31
+ title: str = "Deflated Sharpe Ratio",
32
+ show_legend: bool = True,
33
+ theme: str | None = None,
34
+ height: int = 350,
35
+ width: int = 500,
36
+ ) -> go.Figure:
37
+ """Create a gauge chart showing DSR probability.
38
+
39
+ The Deflated Sharpe Ratio corrects for selection bias when choosing
40
+ the best strategy from multiple tests. A DSR probability < 0.05
41
+ suggests the performance is statistically significant.
42
+
43
+ Parameters
44
+ ----------
45
+ dsr_probability : float
46
+ DSR probability value (0-1), where lower is more significant.
47
+ Typically displayed as 1 - dsr for "confidence" interpretation.
48
+ observed_sharpe : float
49
+ The observed Sharpe ratio being tested
50
+ expected_max_sharpe : float, optional
51
+ The expected maximum Sharpe under null hypothesis
52
+ n_trials : int, optional
53
+ Number of trials/strategies tested (for annotation)
54
+ title : str
55
+ Chart title
56
+ show_legend : bool
57
+ Whether to show the color zone legend
58
+ theme : str, optional
59
+ Theme name (default, dark, print, presentation)
60
+ height : int
61
+ Figure height in pixels
62
+ width : int
63
+ Figure width in pixels
64
+
65
+ Returns
66
+ -------
67
+ go.Figure
68
+ Plotly figure with gauge chart
69
+
70
+ Examples
71
+ --------
72
+ >>> fig = plot_dsr_gauge(
73
+ ... dsr_probability=0.03,
74
+ ... observed_sharpe=2.1,
75
+ ... n_trials=100,
76
+ ... )
77
+ >>> fig.show()
78
+ """
79
+ theme_config = get_theme_config(theme)
80
+
81
+ # Convert to "confidence" (1 - p-value style)
82
+ # High confidence = good, Low confidence = bad
83
+ confidence = (1 - dsr_probability) * 100
84
+
85
+ # Color zones: Red (not significant) -> Yellow (marginal) -> Green (significant)
86
+ # Standard thresholds: p < 0.05 (95%), p < 0.01 (99%)
87
+ fig = go.Figure(
88
+ go.Indicator(
89
+ mode="gauge+number",
90
+ value=confidence,
91
+ number={"suffix": "%", "font": {"size": 36}},
92
+ title={"text": title, "font": {"size": 18}},
93
+ gauge={
94
+ "axis": {
95
+ "range": [0, 100],
96
+ "tickwidth": 1,
97
+ "tickcolor": "darkgray",
98
+ "tickvals": [0, 50, 90, 95, 99, 100],
99
+ "ticktext": ["0%", "50%", "90%", "95%", "99%", "100%"],
100
+ },
101
+ "bar": {"color": "darkblue"},
102
+ "bgcolor": "white",
103
+ "borderwidth": 2,
104
+ "bordercolor": "gray",
105
+ "steps": [
106
+ {"range": [0, 50], "color": "#EF553B"}, # Red - not significant
107
+ {"range": [50, 90], "color": "#FFA15A"}, # Orange - weak
108
+ {"range": [90, 95], "color": "#FECB52"}, # Yellow - marginal
109
+ {"range": [95, 99], "color": "#00CC96"}, # Green - significant
110
+ {"range": [99, 100], "color": "#19D3F3"}, # Cyan - highly significant
111
+ ],
112
+ "threshold": {
113
+ "line": {"color": "black", "width": 4},
114
+ "thickness": 0.75,
115
+ "value": confidence,
116
+ },
117
+ },
118
+ )
119
+ )
120
+
121
+ # Add annotations
122
+ annotations = []
123
+
124
+ # DSR probability annotation
125
+ annotations.append(
126
+ {
127
+ "x": 0.5,
128
+ "y": 0.25,
129
+ "text": f"DSR p-value: {dsr_probability:.4f}",
130
+ "showarrow": False,
131
+ "font": {"size": 14},
132
+ "xref": "paper",
133
+ "yref": "paper",
134
+ }
135
+ )
136
+
137
+ # Observed Sharpe
138
+ annotations.append(
139
+ {
140
+ "x": 0.5,
141
+ "y": 0.15,
142
+ "text": f"Observed Sharpe: {observed_sharpe:.2f}",
143
+ "showarrow": False,
144
+ "font": {"size": 12},
145
+ "xref": "paper",
146
+ "yref": "paper",
147
+ }
148
+ )
149
+
150
+ # Expected max Sharpe if provided
151
+ if expected_max_sharpe is not None:
152
+ annotations.append(
153
+ {
154
+ "x": 0.5,
155
+ "y": 0.08,
156
+ "text": f"E[max SR]: {expected_max_sharpe:.2f}",
157
+ "showarrow": False,
158
+ "font": {"size": 12},
159
+ "xref": "paper",
160
+ "yref": "paper",
161
+ }
162
+ )
163
+
164
+ # Number of trials
165
+ if n_trials is not None:
166
+ annotations.append(
167
+ {
168
+ "x": 0.5,
169
+ "y": 0.01,
170
+ "text": f"(N={n_trials} trials)",
171
+ "showarrow": False,
172
+ "font": {"size": 11, "color": "gray"},
173
+ "xref": "paper",
174
+ "yref": "paper",
175
+ }
176
+ )
177
+
178
+ # Build layout
179
+ layout_updates = {
180
+ "height": height,
181
+ "width": width,
182
+ "annotations": annotations,
183
+ "margin": {"l": 40, "r": 40, "t": 60, "b": 40},
184
+ }
185
+
186
+ for key, value in theme_config["layout"].items():
187
+ if key not in layout_updates:
188
+ layout_updates[key] = value
189
+
190
+ fig.update_layout(**layout_updates)
191
+
192
+ return fig
193
+
194
+
195
+ def plot_confidence_intervals(
196
+ metrics: dict[str, dict[str, float]],
197
+ confidence_levels: list[float] | None = None,
198
+ title: str = "Metric Confidence Intervals",
199
+ orientation: Literal["h", "v"] = "h",
200
+ show_point_estimate: bool = True,
201
+ theme: str | None = None,
202
+ height: int = 400,
203
+ width: int | None = None,
204
+ ) -> go.Figure:
205
+ """Create a forest plot showing confidence intervals for multiple metrics.
206
+
207
+ Visualizes bootstrap or analytical confidence intervals at multiple
208
+ confidence levels (e.g., 90%, 95%, 99%).
209
+
210
+ Parameters
211
+ ----------
212
+ metrics : dict[str, dict[str, float]]
213
+ Dictionary mapping metric names to their CI values.
214
+ Each value should have keys: 'point', 'lower_90', 'upper_90',
215
+ 'lower_95', 'upper_95', 'lower_99', 'upper_99' (based on levels).
216
+ confidence_levels : list[float], optional
217
+ Confidence levels to display (default: [0.90, 0.95, 0.99])
218
+ title : str
219
+ Chart title
220
+ orientation : {"h", "v"}
221
+ Horizontal or vertical orientation
222
+ show_point_estimate : bool
223
+ Whether to show the point estimate marker
224
+ theme : str, optional
225
+ Theme name
226
+ height : int
227
+ Figure height in pixels
228
+ width : int, optional
229
+ Figure width in pixels
230
+
231
+ Returns
232
+ -------
233
+ go.Figure
234
+ Plotly figure with forest plot
235
+
236
+ Examples
237
+ --------
238
+ >>> metrics = {
239
+ ... "Sharpe": {"point": 1.5, "lower_95": 0.8, "upper_95": 2.2},
240
+ ... "CAGR": {"point": 0.15, "lower_95": 0.08, "upper_95": 0.22},
241
+ ... }
242
+ >>> fig = plot_confidence_intervals(metrics)
243
+ >>> fig.show()
244
+ """
245
+ theme_config = get_theme_config(theme)
246
+ colors = theme_config["colorway"]
247
+
248
+ if confidence_levels is None:
249
+ confidence_levels = [0.90, 0.95, 0.99]
250
+
251
+ # Sort confidence levels (widest first for plotting)
252
+ confidence_levels = sorted(confidence_levels, reverse=True)
253
+
254
+ fig = go.Figure()
255
+
256
+ metric_names = list(metrics.keys())
257
+ n_metrics = len(metric_names)
258
+
259
+ # Colors for different confidence levels (lighter to darker)
260
+ level_colors = {
261
+ 0.99: "rgba(99, 110, 250, 0.3)", # Lightest - widest CI
262
+ 0.95: "rgba(99, 110, 250, 0.5)",
263
+ 0.90: "rgba(99, 110, 250, 0.7)", # Darkest - narrowest CI
264
+ }
265
+
266
+ for i, metric_name in enumerate(metric_names):
267
+ metric_data = metrics[metric_name]
268
+ point = metric_data.get("point", metric_data.get("estimate", 0))
269
+
270
+ # Plot confidence intervals from widest to narrowest
271
+ for level in confidence_levels:
272
+ level_pct = int(level * 100)
273
+ lower_key = f"lower_{level_pct}"
274
+ upper_key = f"upper_{level_pct}"
275
+
276
+ if lower_key in metric_data and upper_key in metric_data:
277
+ lower = metric_data[lower_key]
278
+ upper = metric_data[upper_key]
279
+
280
+ color = level_colors.get(level, "rgba(99, 110, 250, 0.5)")
281
+
282
+ if orientation == "h":
283
+ fig.add_trace(
284
+ go.Scatter(
285
+ x=[lower, upper],
286
+ y=[i, i],
287
+ mode="lines",
288
+ line={"color": color, "width": 8 if level == 0.95 else 5},
289
+ name=f"{level_pct}% CI" if i == 0 else None,
290
+ showlegend=(i == 0),
291
+ hovertemplate=f"{metric_name}<br>{level_pct}% CI: [{lower:.3f}, {upper:.3f}]<extra></extra>",
292
+ )
293
+ )
294
+ else:
295
+ fig.add_trace(
296
+ go.Scatter(
297
+ x=[i, i],
298
+ y=[lower, upper],
299
+ mode="lines",
300
+ line={"color": color, "width": 8 if level == 0.95 else 5},
301
+ name=f"{level_pct}% CI" if i == 0 else None,
302
+ showlegend=(i == 0),
303
+ hovertemplate=f"{metric_name}<br>{level_pct}% CI: [{lower:.3f}, {upper:.3f}]<extra></extra>",
304
+ )
305
+ )
306
+
307
+ # Add point estimate
308
+ if show_point_estimate:
309
+ if orientation == "h":
310
+ fig.add_trace(
311
+ go.Scatter(
312
+ x=[point],
313
+ y=[i],
314
+ mode="markers",
315
+ marker={"color": colors[0], "size": 12, "symbol": "diamond"},
316
+ name="Point Estimate" if i == 0 else None,
317
+ showlegend=(i == 0),
318
+ hovertemplate=f"{metric_name}: {point:.3f}<extra></extra>",
319
+ )
320
+ )
321
+ else:
322
+ fig.add_trace(
323
+ go.Scatter(
324
+ x=[i],
325
+ y=[point],
326
+ mode="markers",
327
+ marker={"color": colors[0], "size": 12, "symbol": "diamond"},
328
+ name="Point Estimate" if i == 0 else None,
329
+ showlegend=(i == 0),
330
+ hovertemplate=f"{metric_name}: {point:.3f}<extra></extra>",
331
+ )
332
+ )
333
+
334
+ # Add zero reference line for Sharpe-like metrics
335
+ if orientation == "h":
336
+ fig.add_vline(x=0, line_dash="dash", line_color="gray", line_width=1)
337
+ else:
338
+ fig.add_hline(y=0, line_dash="dash", line_color="gray", line_width=1)
339
+
340
+ # Build layout
341
+ if orientation == "h":
342
+ layout_updates = {
343
+ "title": {"text": title, "font": {"size": 18}},
344
+ "height": max(height, n_metrics * 60 + 100),
345
+ "xaxis": {"title": "Value", "zeroline": True},
346
+ "yaxis": {
347
+ "tickvals": list(range(n_metrics)),
348
+ "ticktext": metric_names,
349
+ "autorange": "reversed",
350
+ },
351
+ "legend": {"yanchor": "top", "y": 0.99, "xanchor": "right", "x": 0.99},
352
+ }
353
+ else:
354
+ layout_updates = {
355
+ "title": {"text": title, "font": {"size": 18}},
356
+ "height": height,
357
+ "yaxis": {"title": "Value", "zeroline": True},
358
+ "xaxis": {
359
+ "tickvals": list(range(n_metrics)),
360
+ "ticktext": metric_names,
361
+ },
362
+ "legend": {"yanchor": "top", "y": 0.99, "xanchor": "right", "x": 0.99},
363
+ }
364
+ if width:
365
+ layout_updates["width"] = width
366
+
367
+ for key, value in theme_config["layout"].items():
368
+ if key not in layout_updates:
369
+ layout_updates[key] = value
370
+
371
+ fig.update_layout(**layout_updates)
372
+
373
+ return fig
374
+
375
+
376
+ def plot_ras_analysis(
377
+ original_ic: float,
378
+ adjusted_ic: float,
379
+ rademacher_complexity: float,
380
+ kappa: float = 0.02,
381
+ n_features: int | None = None,
382
+ n_observations: int | None = None,
383
+ title: str = "Rademacher Anti-Serum Analysis",
384
+ theme: str | None = None,
385
+ height: int = 400,
386
+ width: int = 600,
387
+ ) -> go.Figure:
388
+ """Visualize Rademacher Anti-Serum (RAS) overfitting adjustment.
389
+
390
+ The RAS method adjusts Information Coefficients for data mining bias
391
+ by estimating the Rademacher complexity of the strategy search space.
392
+
393
+ Parameters
394
+ ----------
395
+ original_ic : float
396
+ Original (unadjusted) Information Coefficient
397
+ adjusted_ic : float
398
+ RAS-adjusted Information Coefficient
399
+ rademacher_complexity : float
400
+ Estimated Rademacher complexity R̂
401
+ kappa : float
402
+ The practical bound parameter used (default: 0.02)
403
+ n_features : int, optional
404
+ Number of features/strategies tested
405
+ n_observations : int, optional
406
+ Number of observations
407
+ title : str
408
+ Chart title
409
+ theme : str, optional
410
+ Theme name
411
+ height : int
412
+ Figure height in pixels
413
+ width : int
414
+ Figure width in pixels
415
+
416
+ Returns
417
+ -------
418
+ go.Figure
419
+ Plotly figure with RAS analysis
420
+
421
+ Notes
422
+ -----
423
+ The RAS adjustment is:
424
+ IC_adj = max(0, IC_original - 2 * (R̂ + κ))
425
+
426
+ where R̂ is the Rademacher complexity and κ is a practical bound.
427
+ """
428
+ theme_config = get_theme_config(theme)
429
+ colors = theme_config["colorway"]
430
+
431
+ # Calculate the haircut percentage
432
+ haircut_pct = (1 - adjusted_ic / original_ic) * 100 if original_ic != 0 else 100
433
+
434
+ # Create waterfall chart
435
+ fig = go.Figure()
436
+
437
+ categories = ["Original IC", "Rademacher (2R̂)", "Practical κ", "Adjusted IC"]
438
+ values = [original_ic, -2 * rademacher_complexity, -2 * kappa, adjusted_ic]
439
+ measures = ["absolute", "relative", "relative", "total"]
440
+
441
+ fig.add_trace(
442
+ go.Waterfall(
443
+ name="RAS Adjustment",
444
+ orientation="v",
445
+ x=categories,
446
+ y=values,
447
+ measure=measures,
448
+ text=[f"{v:.4f}" for v in values],
449
+ textposition="outside",
450
+ decreasing={"marker": {"color": "#EF553B"}},
451
+ increasing={"marker": {"color": colors[0]}},
452
+ totals={"marker": {"color": "#00CC96" if adjusted_ic > 0 else "#EF553B"}},
453
+ connector={"line": {"color": "rgba(128, 128, 128, 0.5)", "width": 2}},
454
+ )
455
+ )
456
+
457
+ # Add annotations
458
+ annotations = []
459
+
460
+ # Haircut percentage
461
+ annotations.append(
462
+ {
463
+ "x": 0.5,
464
+ "y": -0.15,
465
+ "text": f"IC Haircut: {haircut_pct:.1f}% | R̂ = {rademacher_complexity:.4f} | κ = {kappa:.4f}",
466
+ "showarrow": False,
467
+ "font": {"size": 12},
468
+ "xref": "paper",
469
+ "yref": "paper",
470
+ }
471
+ )
472
+
473
+ # Significance indicator
474
+ if adjusted_ic > 0:
475
+ sig_text = "Statistically significant after RAS adjustment"
476
+ sig_color = "#00CC96"
477
+ else:
478
+ sig_text = "Not significant after RAS adjustment (IC ≤ 0)"
479
+ sig_color = "#EF553B"
480
+
481
+ annotations.append(
482
+ {
483
+ "x": 0.5,
484
+ "y": -0.22,
485
+ "text": sig_text,
486
+ "showarrow": False,
487
+ "font": {"size": 13, "color": sig_color, "weight": "bold"},
488
+ "xref": "paper",
489
+ "yref": "paper",
490
+ }
491
+ )
492
+
493
+ # N and T if provided
494
+ if n_features is not None and n_observations is not None:
495
+ annotations.append(
496
+ {
497
+ "x": 0.5,
498
+ "y": 1.08,
499
+ "text": f"N={n_features} features, T={n_observations} observations",
500
+ "showarrow": False,
501
+ "font": {"size": 11, "color": "gray"},
502
+ "xref": "paper",
503
+ "yref": "paper",
504
+ }
505
+ )
506
+
507
+ # Build layout
508
+ layout_updates = {
509
+ "title": {"text": title, "font": {"size": 18}},
510
+ "height": height,
511
+ "width": width,
512
+ "yaxis": {"title": "Information Coefficient"},
513
+ "showlegend": False,
514
+ "annotations": annotations,
515
+ "margin": {"l": 60, "r": 40, "t": 80, "b": 100},
516
+ }
517
+
518
+ for key, value in theme_config["layout"].items():
519
+ if key not in layout_updates:
520
+ layout_updates[key] = value
521
+
522
+ fig.update_layout(**layout_updates)
523
+
524
+ return fig
525
+
526
+
527
+ def plot_minimum_track_record(
528
+ observed_sharpe: float,
529
+ current_periods: int,
530
+ sr_benchmark: float = 0.0,
531
+ confidence: float = 0.95,
532
+ max_periods: int | None = None,
533
+ periods_per_year: int = 252,
534
+ title: str = "Minimum Track Record Length",
535
+ theme: str | None = None,
536
+ height: int = 400,
537
+ width: int | None = None,
538
+ ) -> go.Figure:
539
+ """Visualize minimum track record length (MinTRL) analysis.
540
+
541
+ Shows how many periods are needed to achieve statistical significance
542
+ for the observed Sharpe ratio, and whether the current track record
543
+ is sufficient.
544
+
545
+ Parameters
546
+ ----------
547
+ observed_sharpe : float
548
+ The observed Sharpe ratio (annualized)
549
+ current_periods : int
550
+ Current number of observation periods
551
+ sr_benchmark : float
552
+ Benchmark Sharpe ratio for comparison (default: 0)
553
+ confidence : float
554
+ Target confidence level (default: 0.95)
555
+ max_periods : int, optional
556
+ Maximum periods to show on x-axis
557
+ periods_per_year : int
558
+ Periods per year for time conversion (default: 252 for daily)
559
+ title : str
560
+ Chart title
561
+ theme : str, optional
562
+ Theme name
563
+ height : int
564
+ Figure height in pixels
565
+ width : int, optional
566
+ Figure width in pixels
567
+
568
+ Returns
569
+ -------
570
+ go.Figure
571
+ Plotly figure with MinTRL analysis
572
+
573
+ Notes
574
+ -----
575
+ The minimum track record length formula is:
576
+ MinTRL = 1 + (1 - γ₃*SR + γ₄*SR²/4) * (z_α / SR)²
577
+
578
+ where γ₃ is skewness, γ₄ is excess kurtosis, and z_α is the
579
+ critical value for confidence level α.
580
+ """
581
+ from scipy import stats
582
+
583
+ theme_config = get_theme_config(theme)
584
+ colors = theme_config["colorway"]
585
+
586
+ # Calculate MinTRL (simplified, assuming normal returns)
587
+ z_alpha = stats.norm.ppf(confidence)
588
+ sharpe_diff = observed_sharpe - sr_benchmark
589
+
590
+ if sharpe_diff <= 0:
591
+ min_trl = float("inf")
592
+ else:
593
+ # Simplified MinTRL (assuming γ₃=0, γ₄=3)
594
+ min_trl = (z_alpha / sharpe_diff) ** 2
595
+
596
+ # Convert to years
597
+ min_trl_years = min_trl / periods_per_year if min_trl != float("inf") else float("inf")
598
+ current_years = current_periods / periods_per_year
599
+
600
+ # Determine max periods for x-axis
601
+ if max_periods is None:
602
+ if min_trl != float("inf"):
603
+ max_periods = int(max(min_trl * 1.5, current_periods * 1.2))
604
+ else:
605
+ max_periods = current_periods * 2
606
+
607
+ # Generate data for the required SR curve at different track record lengths
608
+ periods_range = np.linspace(10, max_periods, 100)
609
+
610
+ # Required SR to achieve significance at each track record length
611
+ # SR_required = z_alpha / sqrt(T)
612
+ required_sr = z_alpha / np.sqrt(periods_range) + sr_benchmark
613
+
614
+ fig = go.Figure()
615
+
616
+ # Required SR curve
617
+ fig.add_trace(
618
+ go.Scatter(
619
+ x=periods_range / periods_per_year,
620
+ y=required_sr,
621
+ mode="lines",
622
+ name=f"{int(confidence * 100)}% Significance Threshold",
623
+ line={"color": colors[1] if len(colors) > 1 else "orange", "width": 2, "dash": "dash"},
624
+ fill="tozeroy",
625
+ fillcolor="rgba(239, 85, 59, 0.2)",
626
+ hovertemplate="Track Record: %{x:.1f} years<br>Required SR: %{y:.2f}<extra></extra>",
627
+ )
628
+ )
629
+
630
+ # Horizontal line at observed Sharpe
631
+ fig.add_trace(
632
+ go.Scatter(
633
+ x=[0, max_periods / periods_per_year],
634
+ y=[observed_sharpe, observed_sharpe],
635
+ mode="lines",
636
+ name=f"Observed SR: {observed_sharpe:.2f}",
637
+ line={"color": colors[0], "width": 3},
638
+ hovertemplate="Observed Sharpe: %{y:.2f}<extra></extra>",
639
+ )
640
+ )
641
+
642
+ # Current position marker
643
+ is_significant = current_periods >= min_trl
644
+ marker_color = "#00CC96" if is_significant else "#EF553B"
645
+
646
+ fig.add_trace(
647
+ go.Scatter(
648
+ x=[current_years],
649
+ y=[observed_sharpe],
650
+ mode="markers",
651
+ name="Current Position",
652
+ marker={"color": marker_color, "size": 15, "symbol": "star"},
653
+ hovertemplate=f"Current: {current_years:.1f} years<br>SR: {observed_sharpe:.2f}<extra></extra>",
654
+ )
655
+ )
656
+
657
+ # Add vertical line at MinTRL
658
+ if min_trl != float("inf") and min_trl <= max_periods:
659
+ fig.add_vline(
660
+ x=min_trl_years,
661
+ line_dash="dot",
662
+ line_color="gray",
663
+ annotation_text=f"MinTRL: {min_trl_years:.1f}y",
664
+ annotation_position="top",
665
+ )
666
+
667
+ # Add significance zone annotation
668
+ annotations = []
669
+
670
+ if is_significant:
671
+ status_text = (
672
+ f"Track record sufficient ({current_years:.1f}y ≥ MinTRL {min_trl_years:.1f}y)"
673
+ )
674
+ status_color = "#00CC96"
675
+ elif min_trl == float("inf"):
676
+ status_text = "Cannot achieve significance (SR ≤ benchmark)"
677
+ status_color = "#EF553B"
678
+ else:
679
+ deficit = min_trl_years - current_years
680
+ status_text = f"Need {deficit:.1f} more years (MinTRL: {min_trl_years:.1f}y)"
681
+ status_color = "#FFA15A"
682
+
683
+ annotations.append(
684
+ {
685
+ "x": 0.5,
686
+ "y": -0.15,
687
+ "text": status_text,
688
+ "showarrow": False,
689
+ "font": {"size": 13, "color": status_color, "weight": "bold"},
690
+ "xref": "paper",
691
+ "yref": "paper",
692
+ }
693
+ )
694
+
695
+ # Build layout
696
+ layout_updates = {
697
+ "title": {"text": title, "font": {"size": 18}},
698
+ "height": height,
699
+ "xaxis": {"title": "Track Record Length (Years)", "rangemode": "tozero"},
700
+ "yaxis": {"title": "Sharpe Ratio", "rangemode": "tozero"},
701
+ "legend": {"yanchor": "top", "y": 0.99, "xanchor": "right", "x": 0.99},
702
+ "annotations": annotations,
703
+ "margin": {"b": 80},
704
+ }
705
+ if width:
706
+ layout_updates["width"] = width
707
+
708
+ for key, value in theme_config["layout"].items():
709
+ if key not in layout_updates:
710
+ layout_updates[key] = value
711
+
712
+ fig.update_layout(**layout_updates)
713
+
714
+ return fig
715
+
716
+
717
+ def plot_statistical_summary_card(
718
+ metrics: dict[str, Any],
719
+ title: str = "Statistical Validity Summary",
720
+ theme: str | None = None,
721
+ height: int = 300,
722
+ width: int = 700,
723
+ ) -> go.Figure:
724
+ """Create an executive summary card for statistical validity checks.
725
+
726
+ Combines multiple statistical tests into a single traffic-light display
727
+ showing overall strategy validity.
728
+
729
+ Parameters
730
+ ----------
731
+ metrics : dict[str, Any]
732
+ Dictionary with statistical metrics. Expected keys:
733
+ - dsr_probability: DSR p-value
734
+ - dsr_significant: bool
735
+ - min_trl: minimum track record length
736
+ - current_trl: current track record length
737
+ - trl_sufficient: bool
738
+ - ras_adjusted_ic: RAS-adjusted IC (optional)
739
+ - ras_significant: bool (optional)
740
+ title : str
741
+ Chart title
742
+ theme : str, optional
743
+ Theme name
744
+ height : int
745
+ Figure height in pixels
746
+ width : int
747
+ Figure width in pixels
748
+
749
+ Returns
750
+ -------
751
+ go.Figure
752
+ Plotly figure with summary card
753
+ """
754
+ theme_config = get_theme_config(theme)
755
+
756
+ # Extract metrics with defaults
757
+ dsr_prob = metrics.get("dsr_probability", None)
758
+ dsr_sig = metrics.get("dsr_significant", None)
759
+ min_trl = metrics.get("min_trl", None)
760
+ current_trl = metrics.get("current_trl", None)
761
+ trl_sufficient = metrics.get("trl_sufficient", None)
762
+ ras_ic = metrics.get("ras_adjusted_ic", None)
763
+ ras_sig = metrics.get("ras_significant", None)
764
+
765
+ # Build indicators
766
+ indicators = []
767
+
768
+ # DSR check
769
+ if dsr_prob is not None:
770
+ if dsr_sig:
771
+ indicators.append(("DSR", f"p={dsr_prob:.3f}", "green", "Significant"))
772
+ elif dsr_prob < 0.10:
773
+ indicators.append(("DSR", f"p={dsr_prob:.3f}", "yellow", "Marginal"))
774
+ else:
775
+ indicators.append(("DSR", f"p={dsr_prob:.3f}", "red", "Not Significant"))
776
+
777
+ # MinTRL check
778
+ if min_trl is not None and current_trl is not None:
779
+ if trl_sufficient:
780
+ indicators.append(
781
+ ("Track Record", f"{current_trl:.0f}/{min_trl:.0f}", "green", "Sufficient")
782
+ )
783
+ else:
784
+ indicators.append(
785
+ ("Track Record", f"{current_trl:.0f}/{min_trl:.0f}", "red", "Insufficient")
786
+ )
787
+
788
+ # RAS check
789
+ if ras_ic is not None:
790
+ if ras_sig:
791
+ indicators.append(("RAS IC", f"{ras_ic:.4f}", "green", "Significant"))
792
+ else:
793
+ indicators.append(("RAS IC", f"{ras_ic:.4f}", "red", "Not Significant"))
794
+
795
+ if not indicators:
796
+ indicators = [("No Data", "-", "gray", "No statistical tests available")]
797
+
798
+ # Create table-like figure
799
+ n_cols = len(indicators)
800
+
801
+ # Color mapping
802
+ color_map = {
803
+ "green": "#00CC96",
804
+ "yellow": "#FECB52",
805
+ "red": "#EF553B",
806
+ "gray": "#888888",
807
+ }
808
+
809
+ fig = go.Figure()
810
+
811
+ for i, (name, value, color, status) in enumerate(indicators):
812
+ x_pos = (i + 0.5) / n_cols
813
+
814
+ # Status icon (colored circle)
815
+ fig.add_annotation(
816
+ x=x_pos,
817
+ y=0.75,
818
+ text="●",
819
+ showarrow=False,
820
+ font={"size": 40, "color": color_map[color]},
821
+ xref="paper",
822
+ yref="paper",
823
+ )
824
+
825
+ # Metric name
826
+ fig.add_annotation(
827
+ x=x_pos,
828
+ y=0.5,
829
+ text=f"<b>{name}</b>",
830
+ showarrow=False,
831
+ font={"size": 14},
832
+ xref="paper",
833
+ yref="paper",
834
+ )
835
+
836
+ # Value
837
+ fig.add_annotation(
838
+ x=x_pos,
839
+ y=0.35,
840
+ text=value,
841
+ showarrow=False,
842
+ font={"size": 12},
843
+ xref="paper",
844
+ yref="paper",
845
+ )
846
+
847
+ # Status text
848
+ fig.add_annotation(
849
+ x=x_pos,
850
+ y=0.2,
851
+ text=status,
852
+ showarrow=False,
853
+ font={"size": 11, "color": color_map[color]},
854
+ xref="paper",
855
+ yref="paper",
856
+ )
857
+
858
+ # Build layout
859
+ layout_updates = {
860
+ "title": {"text": title, "font": {"size": 18}, "x": 0.5},
861
+ "height": height,
862
+ "width": width,
863
+ "xaxis": {"visible": False, "range": [0, 1]},
864
+ "yaxis": {"visible": False, "range": [0, 1]},
865
+ "margin": {"l": 20, "r": 20, "t": 60, "b": 20},
866
+ }
867
+
868
+ for key, value in theme_config["layout"].items():
869
+ if key not in layout_updates:
870
+ layout_updates[key] = value
871
+
872
+ fig.update_layout(**layout_updates)
873
+
874
+ return fig