sigma-terminal 2.0.1__py3-none-any.whl → 3.2.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/__init__.py +182 -6
- sigma/__main__.py +2 -2
- sigma/analytics/__init__.py +636 -0
- sigma/app.py +563 -898
- sigma/backtest.py +372 -0
- sigma/charts.py +407 -0
- sigma/cli.py +434 -0
- sigma/comparison.py +611 -0
- sigma/config.py +195 -0
- sigma/core/__init__.py +4 -17
- sigma/core/engine.py +493 -0
- sigma/core/intent.py +595 -0
- sigma/core/models.py +516 -125
- sigma/data/__init__.py +681 -0
- sigma/data/models.py +130 -0
- sigma/llm.py +401 -0
- sigma/monitoring.py +666 -0
- sigma/portfolio.py +697 -0
- sigma/reporting.py +658 -0
- sigma/robustness.py +675 -0
- sigma/setup.py +305 -402
- sigma/strategy.py +753 -0
- sigma/tools/backtest.py +23 -5
- sigma/tools.py +617 -0
- sigma/visualization.py +766 -0
- sigma_terminal-3.2.0.dist-info/METADATA +298 -0
- sigma_terminal-3.2.0.dist-info/RECORD +30 -0
- sigma_terminal-3.2.0.dist-info/entry_points.txt +6 -0
- sigma_terminal-3.2.0.dist-info/licenses/LICENSE +25 -0
- sigma/core/agent.py +0 -205
- sigma/core/config.py +0 -119
- sigma/core/llm.py +0 -794
- sigma/tools/__init__.py +0 -5
- sigma/tools/charts.py +0 -400
- sigma/tools/financial.py +0 -1457
- sigma/ui/__init__.py +0 -1
- sigma_terminal-2.0.1.dist-info/METADATA +0 -222
- sigma_terminal-2.0.1.dist-info/RECORD +0 -19
- sigma_terminal-2.0.1.dist-info/entry_points.txt +0 -2
- sigma_terminal-2.0.1.dist-info/licenses/LICENSE +0 -42
- {sigma_terminal-2.0.1.dist-info → sigma_terminal-3.2.0.dist-info}/WHEEL +0 -0
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}"
|