sigma-terminal 2.0.2__py3-none-any.whl → 3.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sigma/visualization.py ADDED
@@ -0,0 +1,766 @@
1
+ """Advanced charting engine - Publication-grade visualizations."""
2
+
3
+ import json
4
+ from datetime import date, datetime, timedelta
5
+ from typing import Any, Dict, List, Optional, Tuple, Union
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+ import plotly.graph_objects as go
10
+ from plotly.subplots import make_subplots
11
+
12
+
13
+ # ============================================================================
14
+ # CHART THEMES
15
+ # ============================================================================
16
+
17
+ SIGMA_THEME = {
18
+ "dark": {
19
+ "bg": "#0d1117",
20
+ "paper_bg": "#0d1117",
21
+ "grid": "#21262d",
22
+ "text": "#c9d1d9",
23
+ "primary": "#58a6ff",
24
+ "secondary": "#8b949e",
25
+ "positive": "#3fb950",
26
+ "negative": "#f85149",
27
+ "accent1": "#a371f7",
28
+ "accent2": "#f0883e",
29
+ "accent3": "#56d4dd",
30
+ },
31
+ "light": {
32
+ "bg": "#ffffff",
33
+ "paper_bg": "#ffffff",
34
+ "grid": "#d0d7de",
35
+ "text": "#1f2328",
36
+ "primary": "#0969da",
37
+ "secondary": "#57606a",
38
+ "positive": "#1a7f37",
39
+ "negative": "#cf222e",
40
+ "accent1": "#8250df",
41
+ "accent2": "#bf8700",
42
+ "accent3": "#0550ae",
43
+ }
44
+ }
45
+
46
+
47
+ # ============================================================================
48
+ # CHART RECIPES
49
+ # ============================================================================
50
+
51
+ class ChartRecipes:
52
+ """Pre-built chart configurations for common use cases."""
53
+
54
+ @staticmethod
55
+ def equity_curve() -> Dict[str, Any]:
56
+ """Equity curve with drawdown overlay."""
57
+ return {
58
+ "type": "equity_drawdown",
59
+ "rows": 2,
60
+ "row_heights": [0.7, 0.3],
61
+ "components": ["equity_line", "drawdown_fill"],
62
+ }
63
+
64
+ @staticmethod
65
+ def returns_analysis() -> Dict[str, Any]:
66
+ """Returns distribution and time series."""
67
+ return {
68
+ "type": "returns_analysis",
69
+ "rows": 2,
70
+ "cols": 2,
71
+ "components": ["returns_bar", "distribution", "rolling_stats", "calendar"],
72
+ }
73
+
74
+ @staticmethod
75
+ def comparison_dashboard() -> Dict[str, Any]:
76
+ """Multi-asset comparison."""
77
+ return {
78
+ "type": "comparison",
79
+ "rows": 2,
80
+ "cols": 2,
81
+ "components": ["normalized_prices", "rolling_correlation", "risk_return_scatter", "metrics_table"],
82
+ }
83
+
84
+ @staticmethod
85
+ def strategy_tearsheet() -> Dict[str, Any]:
86
+ """Full strategy analysis."""
87
+ return {
88
+ "type": "tearsheet",
89
+ "rows": 3,
90
+ "cols": 2,
91
+ "components": [
92
+ "equity_curve", "monthly_returns_heatmap",
93
+ "drawdown", "rolling_metrics",
94
+ "returns_distribution", "trade_analysis"
95
+ ],
96
+ }
97
+
98
+ @staticmethod
99
+ def market_regime() -> Dict[str, Any]:
100
+ """Market regime analysis."""
101
+ return {
102
+ "type": "regime",
103
+ "rows": 2,
104
+ "components": ["price_with_regime_shading", "regime_statistics"],
105
+ }
106
+
107
+
108
+ # ============================================================================
109
+ # CHART BUILDER
110
+ # ============================================================================
111
+
112
+ class ChartBuilder:
113
+ """
114
+ Build publication-grade financial charts with Plotly.
115
+ Features:
116
+ - Regime shading
117
+ - Event markers
118
+ - Drawdown overlays
119
+ - Multi-axis layouts
120
+ - Auto-captions
121
+ """
122
+
123
+ def __init__(self, theme: str = "dark"):
124
+ self.colors = SIGMA_THEME.get(theme, SIGMA_THEME["dark"])
125
+ self.theme = theme
126
+
127
+ def _get_base_layout(
128
+ self,
129
+ title: str = "",
130
+ height: int = 600,
131
+ width: int = 1000,
132
+ showlegend: bool = True,
133
+ ) -> Dict[str, Any]:
134
+ """Get base layout configuration."""
135
+
136
+ return {
137
+ "title": {
138
+ "text": title,
139
+ "font": {"size": 18, "color": self.colors["text"]},
140
+ "x": 0.5,
141
+ },
142
+ "paper_bgcolor": self.colors["paper_bg"],
143
+ "plot_bgcolor": self.colors["bg"],
144
+ "font": {"color": self.colors["text"], "family": "SF Pro Display, -apple-system, sans-serif"},
145
+ "height": height,
146
+ "width": width,
147
+ "showlegend": showlegend,
148
+ "legend": {
149
+ "bgcolor": "rgba(0,0,0,0)",
150
+ "font": {"color": self.colors["text"]},
151
+ },
152
+ "margin": {"l": 60, "r": 40, "t": 60, "b": 60},
153
+ "xaxis": {
154
+ "gridcolor": self.colors["grid"],
155
+ "zerolinecolor": self.colors["grid"],
156
+ },
157
+ "yaxis": {
158
+ "gridcolor": self.colors["grid"],
159
+ "zerolinecolor": self.colors["grid"],
160
+ },
161
+ }
162
+
163
+ # ==========================================================================
164
+ # CORE CHART TYPES
165
+ # ==========================================================================
166
+
167
+ def price_chart(
168
+ self,
169
+ df: pd.DataFrame,
170
+ title: str = "Price Chart",
171
+ ohlc: bool = False,
172
+ volume: bool = True,
173
+ ma_periods: Optional[List[int]] = None,
174
+ events: Optional[List[Dict[str, Any]]] = None,
175
+ regimes: Optional[pd.Series] = None,
176
+ ) -> go.Figure:
177
+ """
178
+ Create price chart with optional overlays.
179
+
180
+ Args:
181
+ df: DataFrame with OHLCV data
182
+ title: Chart title
183
+ ohlc: Use candlestick (True) or line (False)
184
+ volume: Include volume subplot
185
+ ma_periods: Moving average periods to overlay
186
+ events: List of event markers
187
+ regimes: Series of regime labels for shading
188
+ """
189
+
190
+ n_rows = 2 if volume else 1
191
+ row_heights = [0.75, 0.25] if volume else [1.0]
192
+
193
+ fig = make_subplots(
194
+ rows=n_rows,
195
+ cols=1,
196
+ shared_xaxes=True,
197
+ vertical_spacing=0.03,
198
+ row_heights=row_heights,
199
+ )
200
+
201
+ # Main price trace
202
+ if ohlc and all(col in df.columns for col in ["open", "high", "low", "close"]):
203
+ fig.add_trace(
204
+ go.Candlestick(
205
+ x=df.index,
206
+ open=df["open"],
207
+ high=df["high"],
208
+ low=df["low"],
209
+ close=df["close"],
210
+ name="Price",
211
+ increasing_line_color=self.colors["positive"],
212
+ decreasing_line_color=self.colors["negative"],
213
+ ),
214
+ row=1, col=1
215
+ )
216
+ else:
217
+ price_col = "close" if "close" in df.columns else df.columns[0]
218
+ fig.add_trace(
219
+ go.Scatter(
220
+ x=df.index,
221
+ y=df[price_col],
222
+ name="Price",
223
+ line={"color": self.colors["primary"], "width": 2},
224
+ ),
225
+ row=1, col=1
226
+ )
227
+
228
+ # Moving averages
229
+ if ma_periods:
230
+ colors = [self.colors["accent1"], self.colors["accent2"], self.colors["accent3"]]
231
+ price_col = "close" if "close" in df.columns else df.columns[0]
232
+
233
+ for i, period in enumerate(ma_periods):
234
+ ma = df[price_col].rolling(period).mean()
235
+ fig.add_trace(
236
+ go.Scatter(
237
+ x=df.index,
238
+ y=ma,
239
+ name=f"MA{period}",
240
+ line={"color": colors[i % len(colors)], "width": 1, "dash": "dot"},
241
+ ),
242
+ row=1, col=1
243
+ )
244
+
245
+ # Volume
246
+ if volume and "volume" in df.columns:
247
+ colors = [
248
+ self.colors["positive"] if c >= o else self.colors["negative"]
249
+ for c, o in zip(df.get("close", df.iloc[:, 0]), df.get("open", df.iloc[:, 0].shift(1)))
250
+ ]
251
+
252
+ fig.add_trace(
253
+ go.Bar(
254
+ x=df.index,
255
+ y=df["volume"],
256
+ name="Volume",
257
+ marker_color=colors,
258
+ opacity=0.5,
259
+ ),
260
+ row=2, col=1
261
+ )
262
+
263
+ # Event markers
264
+ if events:
265
+ for event in events:
266
+ fig.add_vline(
267
+ x=event.get("date"),
268
+ line={"color": event.get("color", self.colors["accent1"]), "dash": "dash"},
269
+ annotation_text=event.get("label", ""),
270
+ annotation_position="top",
271
+ )
272
+
273
+ # Regime shading
274
+ if regimes is not None:
275
+ self._add_regime_shading(fig, regimes)
276
+
277
+ # Layout
278
+ layout = self._get_base_layout(title)
279
+ layout["xaxis_rangeslider_visible"] = False
280
+ fig.update_layout(**layout)
281
+
282
+ return fig
283
+
284
+ def equity_curve(
285
+ self,
286
+ returns: pd.Series,
287
+ benchmark_returns: Optional[pd.Series] = None,
288
+ title: str = "Equity Curve",
289
+ show_drawdown: bool = True,
290
+ regimes: Optional[pd.Series] = None,
291
+ ) -> go.Figure:
292
+ """
293
+ Create equity curve with drawdown overlay.
294
+ """
295
+
296
+ n_rows = 2 if show_drawdown else 1
297
+ row_heights = [0.7, 0.3] if show_drawdown else [1.0]
298
+
299
+ fig = make_subplots(
300
+ rows=n_rows,
301
+ cols=1,
302
+ shared_xaxes=True,
303
+ vertical_spacing=0.05,
304
+ row_heights=row_heights,
305
+ )
306
+
307
+ # Calculate cumulative returns
308
+ equity = (1 + returns).cumprod()
309
+
310
+ fig.add_trace(
311
+ go.Scatter(
312
+ x=equity.index,
313
+ y=equity.values,
314
+ name="Strategy",
315
+ line={"color": self.colors["primary"], "width": 2},
316
+ fill="tozeroy" if not benchmark_returns else None,
317
+ fillcolor=f"rgba({int(self.colors['primary'][1:3], 16)}, {int(self.colors['primary'][3:5], 16)}, {int(self.colors['primary'][5:7], 16)}, 0.1)",
318
+ ),
319
+ row=1, col=1
320
+ )
321
+
322
+ # Benchmark
323
+ if benchmark_returns is not None:
324
+ benchmark_equity = (1 + benchmark_returns).cumprod()
325
+ fig.add_trace(
326
+ go.Scatter(
327
+ x=benchmark_equity.index,
328
+ y=benchmark_equity.values,
329
+ name="Benchmark",
330
+ line={"color": self.colors["secondary"], "width": 1.5, "dash": "dash"},
331
+ ),
332
+ row=1, col=1
333
+ )
334
+
335
+ # Drawdown
336
+ if show_drawdown:
337
+ running_max = equity.expanding().max()
338
+ drawdown = (equity - running_max) / running_max
339
+
340
+ fig.add_trace(
341
+ go.Scatter(
342
+ x=drawdown.index,
343
+ y=drawdown.values * 100,
344
+ name="Drawdown",
345
+ line={"color": self.colors["negative"], "width": 1},
346
+ fill="tozeroy",
347
+ fillcolor=f"rgba({int(self.colors['negative'][1:3], 16)}, {int(self.colors['negative'][3:5], 16)}, {int(self.colors['negative'][5:7], 16)}, 0.3)",
348
+ ),
349
+ row=2, col=1
350
+ )
351
+
352
+ fig.update_yaxes(title_text="Drawdown %", row=2, col=1)
353
+
354
+ # Regime shading
355
+ if regimes is not None:
356
+ self._add_regime_shading(fig, regimes)
357
+
358
+ layout = self._get_base_layout(title)
359
+ fig.update_layout(**layout)
360
+ fig.update_yaxes(title_text="Growth of $1", row=1, col=1)
361
+
362
+ return fig
363
+
364
+ def returns_distribution(
365
+ self,
366
+ returns: pd.Series,
367
+ title: str = "Returns Distribution",
368
+ benchmark_returns: Optional[pd.Series] = None,
369
+ ) -> go.Figure:
370
+ """Create returns distribution histogram with statistics."""
371
+
372
+ fig = go.Figure()
373
+
374
+ # Main histogram
375
+ fig.add_trace(
376
+ go.Histogram(
377
+ x=returns.values * 100,
378
+ name="Strategy",
379
+ marker_color=self.colors["primary"],
380
+ opacity=0.7,
381
+ nbinsx=50,
382
+ )
383
+ )
384
+
385
+ # Benchmark histogram
386
+ if benchmark_returns is not None:
387
+ fig.add_trace(
388
+ go.Histogram(
389
+ x=benchmark_returns.values * 100,
390
+ name="Benchmark",
391
+ marker_color=self.colors["secondary"],
392
+ opacity=0.5,
393
+ nbinsx=50,
394
+ )
395
+ )
396
+
397
+ # Add mean line
398
+ mean_ret = returns.mean() * 100
399
+ fig.add_vline(
400
+ x=mean_ret,
401
+ line={"color": self.colors["accent1"], "dash": "dash", "width": 2},
402
+ annotation_text=f"Mean: {mean_ret:.2f}%",
403
+ )
404
+
405
+ # Add VaR lines
406
+ var_95 = returns.quantile(0.05) * 100
407
+ fig.add_vline(
408
+ x=var_95,
409
+ line={"color": self.colors["negative"], "dash": "dot", "width": 1},
410
+ annotation_text=f"5% VaR: {var_95:.2f}%",
411
+ )
412
+
413
+ layout = self._get_base_layout(title)
414
+ layout["barmode"] = "overlay"
415
+ layout["xaxis_title"] = "Daily Returns (%)"
416
+ layout["yaxis_title"] = "Frequency"
417
+
418
+ fig.update_layout(**layout)
419
+
420
+ return fig
421
+
422
+ def rolling_metrics(
423
+ self,
424
+ returns: pd.Series,
425
+ window: int = 63,
426
+ title: str = "Rolling Metrics",
427
+ metrics: Optional[List[str]] = None,
428
+ ) -> go.Figure:
429
+ """Create rolling metrics chart."""
430
+
431
+ metrics = metrics or ["volatility", "sharpe", "beta"]
432
+ n_metrics = len(metrics)
433
+
434
+ fig = make_subplots(
435
+ rows=n_metrics,
436
+ cols=1,
437
+ shared_xaxes=True,
438
+ vertical_spacing=0.05,
439
+ subplot_titles=[m.replace("_", " ").title() for m in metrics],
440
+ )
441
+
442
+ colors = [self.colors["primary"], self.colors["accent1"], self.colors["accent2"]]
443
+
444
+ for i, metric in enumerate(metrics, 1):
445
+ if metric == "volatility":
446
+ values = returns.rolling(window).std() * np.sqrt(252) * 100
447
+ ylabel = "Volatility (%)"
448
+ elif metric == "sharpe":
449
+ rolling_ret = returns.rolling(window).mean() * 252
450
+ rolling_vol = returns.rolling(window).std() * np.sqrt(252)
451
+ values = rolling_ret / rolling_vol
452
+ ylabel = "Sharpe Ratio"
453
+ elif metric == "returns":
454
+ values = returns.rolling(window).apply(lambda x: (1 + x).prod() - 1) * 100
455
+ ylabel = "Return (%)"
456
+ else:
457
+ values = returns.rolling(window).mean() * 252 * 100
458
+ ylabel = metric.title()
459
+
460
+ fig.add_trace(
461
+ go.Scatter(
462
+ x=values.index,
463
+ y=values.values,
464
+ name=metric.title(),
465
+ line={"color": colors[i-1 % len(colors)], "width": 1.5},
466
+ ),
467
+ row=i, col=1
468
+ )
469
+
470
+ fig.update_yaxes(title_text=ylabel, row=i, col=1)
471
+
472
+ layout = self._get_base_layout(title, height=400 * n_metrics)
473
+ fig.update_layout(**layout)
474
+
475
+ return fig
476
+
477
+ def monthly_returns_heatmap(
478
+ self,
479
+ returns: pd.Series,
480
+ title: str = "Monthly Returns",
481
+ ) -> go.Figure:
482
+ """Create monthly returns heatmap."""
483
+
484
+ # Resample to monthly
485
+ monthly = returns.resample('M').apply(lambda x: (1 + x).prod() - 1)
486
+
487
+ # Pivot to year x month
488
+ monthly_df = pd.DataFrame({
489
+ 'year': monthly.index.year,
490
+ 'month': monthly.index.month,
491
+ 'return': monthly.values * 100
492
+ })
493
+
494
+ pivot = monthly_df.pivot(index='year', columns='month', values='return')
495
+ pivot.columns = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
496
+ 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
497
+
498
+ # Custom colorscale
499
+ colorscale = [
500
+ [0, self.colors["negative"]],
501
+ [0.5, self.colors["bg"]],
502
+ [1, self.colors["positive"]]
503
+ ]
504
+
505
+ fig = go.Figure(data=go.Heatmap(
506
+ z=pivot.values,
507
+ x=pivot.columns,
508
+ y=pivot.index,
509
+ colorscale=colorscale,
510
+ zmid=0,
511
+ text=np.round(pivot.values, 1),
512
+ texttemplate="%{text}%",
513
+ textfont={"size": 10},
514
+ hovertemplate="Year: %{y}<br>Month: %{x}<br>Return: %{z:.2f}%<extra></extra>",
515
+ ))
516
+
517
+ layout = self._get_base_layout(title, height=max(400, len(pivot) * 30))
518
+ fig.update_layout(**layout)
519
+
520
+ return fig
521
+
522
+ def correlation_matrix(
523
+ self,
524
+ returns_df: pd.DataFrame,
525
+ title: str = "Correlation Matrix",
526
+ ) -> go.Figure:
527
+ """Create correlation matrix heatmap."""
528
+
529
+ corr = returns_df.corr()
530
+
531
+ # Custom colorscale
532
+ colorscale = [
533
+ [0, self.colors["negative"]],
534
+ [0.5, self.colors["bg"]],
535
+ [1, self.colors["positive"]]
536
+ ]
537
+
538
+ fig = go.Figure(data=go.Heatmap(
539
+ z=corr.values,
540
+ x=corr.columns,
541
+ y=corr.index,
542
+ colorscale=colorscale,
543
+ zmid=0,
544
+ text=np.round(corr.values, 2),
545
+ texttemplate="%{text}",
546
+ textfont={"size": 10},
547
+ hovertemplate="%{x} vs %{y}: %{z:.3f}<extra></extra>",
548
+ ))
549
+
550
+ layout = self._get_base_layout(title, height=max(400, len(corr) * 40))
551
+ fig.update_layout(**layout)
552
+
553
+ return fig
554
+
555
+ def risk_return_scatter(
556
+ self,
557
+ returns_dict: Dict[str, pd.Series],
558
+ title: str = "Risk-Return",
559
+ annualize: bool = True,
560
+ ) -> go.Figure:
561
+ """Create risk-return scatter plot."""
562
+
563
+ fig = go.Figure()
564
+
565
+ points = []
566
+ for name, returns in returns_dict.items():
567
+ if annualize:
568
+ ret = returns.mean() * 252 * 100
569
+ vol = returns.std() * np.sqrt(252) * 100
570
+ else:
571
+ ret = returns.mean() * 100
572
+ vol = returns.std() * 100
573
+
574
+ sharpe = ret / vol if vol > 0 else 0
575
+ points.append((name, vol, ret, sharpe))
576
+
577
+ # Create scatter
578
+ for name, vol, ret, sharpe in points:
579
+ color = self.colors["positive"] if ret > 0 else self.colors["negative"]
580
+
581
+ fig.add_trace(
582
+ go.Scatter(
583
+ x=[vol],
584
+ y=[ret],
585
+ mode="markers+text",
586
+ name=name,
587
+ text=[name],
588
+ textposition="top center",
589
+ marker={
590
+ "size": 15 + sharpe * 5,
591
+ "color": color,
592
+ "line": {"width": 1, "color": self.colors["text"]},
593
+ },
594
+ hovertemplate=f"{name}<br>Return: {ret:.2f}%<br>Vol: {vol:.2f}%<br>Sharpe: {sharpe:.2f}<extra></extra>",
595
+ )
596
+ )
597
+
598
+ # Add efficient frontier reference line
599
+ if len(points) > 1:
600
+ vols = [p[1] for p in points]
601
+ rets = [p[2] for p in points]
602
+
603
+ max_sharpe_point = max(points, key=lambda p: p[3])
604
+
605
+ fig.add_shape(
606
+ type="line",
607
+ x0=0, y0=0,
608
+ x1=max_sharpe_point[1], y1=max_sharpe_point[2],
609
+ line={"color": self.colors["secondary"], "dash": "dash"},
610
+ )
611
+
612
+ layout = self._get_base_layout(title)
613
+ layout["xaxis_title"] = "Volatility (%)"
614
+ layout["yaxis_title"] = "Return (%)"
615
+ fig.update_layout(**layout)
616
+
617
+ return fig
618
+
619
+ def comparison_chart(
620
+ self,
621
+ returns_dict: Dict[str, pd.Series],
622
+ title: str = "Performance Comparison",
623
+ normalize: bool = True,
624
+ ) -> go.Figure:
625
+ """Create multi-asset comparison chart."""
626
+
627
+ fig = go.Figure()
628
+
629
+ colors = [
630
+ self.colors["primary"],
631
+ self.colors["accent1"],
632
+ self.colors["accent2"],
633
+ self.colors["accent3"],
634
+ self.colors["positive"],
635
+ self.colors["secondary"],
636
+ ]
637
+
638
+ for i, (name, returns) in enumerate(returns_dict.items()):
639
+ if normalize:
640
+ values = (1 + returns).cumprod()
641
+ else:
642
+ values = returns.cumsum()
643
+
644
+ fig.add_trace(
645
+ go.Scatter(
646
+ x=values.index,
647
+ y=values.values,
648
+ name=name,
649
+ line={"color": colors[i % len(colors)], "width": 2},
650
+ )
651
+ )
652
+
653
+ layout = self._get_base_layout(title)
654
+ layout["yaxis_title"] = "Growth of $1" if normalize else "Cumulative Return"
655
+ layout["hovermode"] = "x unified"
656
+ fig.update_layout(**layout)
657
+
658
+ return fig
659
+
660
+ def _add_regime_shading(
661
+ self,
662
+ fig: go.Figure,
663
+ regimes: pd.Series,
664
+ row: int = 1,
665
+ ) -> None:
666
+ """Add regime shading to a figure."""
667
+
668
+ regime_colors = {
669
+ "expansion": "rgba(59, 185, 80, 0.1)",
670
+ "contraction": "rgba(248, 81, 73, 0.1)",
671
+ "high_vol": "rgba(248, 81, 73, 0.15)",
672
+ "low_vol": "rgba(59, 185, 80, 0.08)",
673
+ "bull": "rgba(59, 185, 80, 0.1)",
674
+ "bear": "rgba(248, 81, 73, 0.1)",
675
+ "neutral": "rgba(139, 148, 158, 0.05)",
676
+ }
677
+
678
+ # Find regime changes
679
+ regime_changes = regimes != regimes.shift(1)
680
+ change_points = regimes.index[regime_changes].tolist()
681
+
682
+ if not change_points:
683
+ return
684
+
685
+ change_points.append(regimes.index[-1])
686
+
687
+ for i in range(len(change_points) - 1):
688
+ start = change_points[i]
689
+ end = change_points[i + 1]
690
+ regime = regimes.loc[start]
691
+
692
+ color = regime_colors.get(str(regime).lower(), "rgba(139, 148, 158, 0.05)")
693
+
694
+ fig.add_vrect(
695
+ x0=start,
696
+ x1=end,
697
+ fillcolor=color,
698
+ layer="below",
699
+ line_width=0,
700
+ row=row,
701
+ col=1,
702
+ )
703
+
704
+ def save_chart(
705
+ self,
706
+ fig: go.Figure,
707
+ filepath: str,
708
+ format: str = "png",
709
+ scale: int = 2,
710
+ ) -> str:
711
+ """Save chart to file."""
712
+
713
+ if format == "html":
714
+ fig.write_html(filepath)
715
+ else:
716
+ fig.write_image(filepath, format=format, scale=scale)
717
+
718
+ return filepath
719
+
720
+
721
+ # ============================================================================
722
+ # AUTO CAPTION GENERATOR
723
+ # ============================================================================
724
+
725
+ class AutoCaptionGenerator:
726
+ """Generate captions for charts automatically."""
727
+
728
+ @staticmethod
729
+ def equity_curve_caption(
730
+ total_return: float,
731
+ max_drawdown: float,
732
+ sharpe: float,
733
+ benchmark_return: Optional[float] = None,
734
+ ) -> str:
735
+ """Generate caption for equity curve."""
736
+
737
+ caption = f"Total return of {total_return:.1%}"
738
+
739
+ if benchmark_return is not None:
740
+ excess = total_return - benchmark_return
741
+ caption += f" ({'+' if excess >= 0 else ''}{excess:.1%} vs benchmark)"
742
+
743
+ caption += f" with {abs(max_drawdown):.1%} maximum drawdown"
744
+ caption += f" and {sharpe:.2f} Sharpe ratio."
745
+
746
+ return caption
747
+
748
+ @staticmethod
749
+ def comparison_caption(
750
+ winner: str,
751
+ margin: float,
752
+ metric: str,
753
+ ) -> str:
754
+ """Generate caption for comparison chart."""
755
+
756
+ return f"{winner} outperformed by {margin:.1%} in {metric} over the period."
757
+
758
+ @staticmethod
759
+ def regime_caption(
760
+ current_regime: str,
761
+ duration: int,
762
+ historical_context: str,
763
+ ) -> str:
764
+ """Generate caption for regime chart."""
765
+
766
+ return f"Currently in {current_regime} regime for {duration} days. {historical_context}"