Coverage for src/jquantstats/_plots/_data.py: 100%

442 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-23 06:13 +0000

1"""Plotting utilities for financial returns data.""" 

2 

3from __future__ import annotations 

4 

5import math 

6from typing import TYPE_CHECKING, Any 

7 

8import numpy as np 

9import plotly.express as px 

10import plotly.graph_objects as go 

11import polars as pl 

12from plotly.subplots import make_subplots 

13 

14if TYPE_CHECKING: 

15 from jquantstats._protocol import DataLike 

16 

17# ── Module-level styling helpers ────────────────────────────────────────────── 

18 

19 

20def _hex_to_rgba(hex_color: str, alpha: float = 0.5) -> str: 

21 """Convert a hex colour string to an RGBA CSS string. 

22 

23 Args: 

24 hex_color: A hex colour string (with or without a leading ``#``). 

25 alpha: Opacity in the range [0, 1]. Defaults to 0.5. 

26 

27 Returns: 

28 An RGBA CSS string suitable for use in Plotly colour arguments. 

29 

30 """ 

31 hex_color = hex_color.lstrip("#") 

32 r, g, b = tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4)) 

33 return f"rgba({r}, {g}, {b}, {alpha})" 

34 

35 

36def _ticker_colors(tickers: list[str]) -> dict[str, str]: 

37 """Map ticker names to Plotly qualitative palette colours. 

38 

39 Args: 

40 tickers: Ordered list of ticker / column names. 

41 

42 Returns: 

43 dict mapping each ticker to a hex colour string. 

44 

45 """ 

46 palette = px.colors.qualitative.Plotly 

47 return {ticker: palette[i % len(palette)] for i, ticker in enumerate(tickers)} 

48 

49 

50def _date_range_selector() -> dict[str, Any]: 

51 """Return a standard Plotly date range-selector configuration. 

52 

53 Returns: 

54 A dict suitable for ``xaxis.rangeselector``. 

55 

56 """ 

57 return { 

58 "buttons": [ 

59 {"count": 6, "label": "6m", "step": "month", "stepmode": "backward"}, 

60 {"count": 1, "label": "1y", "step": "year", "stepmode": "backward"}, 

61 {"count": 3, "label": "3y", "step": "year", "stepmode": "backward"}, 

62 {"step": "year", "stepmode": "todate", "label": "YTD"}, 

63 {"step": "all", "label": "All"}, 

64 ] 

65 } 

66 

67 

68def _apply_base_layout( 

69 fig: go.Figure, 

70 title: str, 

71 height: int = 600, 

72 with_range_selector: bool = True, 

73) -> go.Figure: 

74 """Apply the standard jquantstats Plotly layout to a figure. 

75 

76 Sets white background, light-grey grid, horizontal legend, and an 

77 optional date range-selector on the primary x-axis. 

78 

79 Args: 

80 fig: The Plotly figure to style in-place. 

81 title: Chart title. 

82 height: Figure height in pixels. Defaults to 600. 

83 with_range_selector: Attach a date range-selector to ``xaxis``. 

84 Defaults to True. 

85 

86 Returns: 

87 The same figure, mutated in-place and returned for chaining. 

88 

89 """ 

90 layout_kw: dict[str, Any] = { 

91 "title": title, 

92 "height": height, 

93 "hovermode": "x unified", 

94 "plot_bgcolor": "white", 

95 "legend": {"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "right", "x": 1}, 

96 } 

97 if with_range_selector: 

98 layout_kw["xaxis"] = { 

99 "rangeselector": _date_range_selector(), 

100 "rangeslider": {"visible": False}, 

101 "type": "date", 

102 } 

103 fig.update_layout(**layout_kw) 

104 fig.update_xaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey") 

105 fig.update_yaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey") 

106 return fig 

107 

108 

109def _apply_figsize(fig: go.Figure, figsize: tuple[int, int] | None) -> go.Figure: 

110 """Apply optional ``(width, height)`` figure size to Plotly layout.""" 

111 if figsize is not None: 

112 fig.update_layout(width=figsize[0], height=figsize[1]) 

113 return fig 

114 

115 

116def _bar_colors(values: list[float | None], positive_color: str, single_asset: bool = False) -> list[str]: 

117 """Return the shared positive/negative bar colors for a series of values.""" 

118 if single_asset: 

119 return ["#2ca02c" if v is not None and v > 0 else "#d62728" for v in values] 

120 negative_color = _hex_to_rgba(positive_color, alpha=0.4) 

121 return [positive_color if v is not None and v > 0 else negative_color for v in values] 

122 

123 

124def _compute_drawdown_periods(prices: list[float], n: int) -> list[dict[str, Any]]: 

125 """Identify the top *n* drawdown periods from a cumulative price series. 

126 

127 Args: 

128 prices: Cumulative price (NAV) values as a plain Python list. 

129 n: Maximum number of drawdown periods to return. 

130 

131 Returns: 

132 List of dicts with keys ``start_idx``, ``end_idx``, ``valley_idx``, 

133 ``max_drawdown`` (fraction ≤ 0), sorted by severity (worst first). 

134 

135 """ 

136 length = len(prices) 

137 hwm: list[float] = [0.0] * length 

138 hwm[0] = prices[0] 

139 for i in range(1, length): 

140 hwm[i] = max(hwm[i - 1], prices[i]) 

141 

142 in_dd = [prices[i] < hwm[i] for i in range(length)] 

143 periods: list[dict[str, Any]] = [] 

144 i = 0 

145 while i < length: 

146 if not in_dd[i]: 

147 i += 1 

148 continue 

149 start = i 

150 while i < length and in_dd[i]: 

151 i += 1 

152 end = i - 1 

153 valley = start + min(range(end - start + 1), key=lambda k: prices[start + k]) 

154 max_dd = (prices[valley] - hwm[valley]) / hwm[valley] 

155 periods.append({"start_idx": start, "end_idx": end, "valley_idx": valley, "max_drawdown": max_dd}) 

156 

157 periods.sort(key=lambda p: p["max_drawdown"]) 

158 return periods[:n] 

159 

160 

161# ── Dashboard (existing) ────────────────────────────────────────────────────── 

162 

163 

164def _plot_performance_dashboard(returns: pl.DataFrame, log_scale: bool = False) -> go.Figure: 

165 """Build a multi-panel performance dashboard figure for the given returns. 

166 

167 Args: 

168 returns: A Polars DataFrame with a date column followed by one column per asset. 

169 log_scale: Whether to use a logarithmic y-axis for cumulative returns. 

170 

171 Returns: 

172 A Plotly Figure containing cumulative returns, drawdowns, and monthly returns panels. 

173 

174 """ 

175 # Get the date column name from the first column of the DataFrame 

176 date_col = returns.columns[0] 

177 

178 # Get the tickers (all columns except the date column) 

179 tickers = [col for col in returns.columns if col != date_col] 

180 

181 # Calculate cumulative returns (prices) 

182 prices = returns.with_columns([((1 + pl.col(ticker)).cum_prod()).alias(f"{ticker}_price") for ticker in tickers]) 

183 

184 colors = _ticker_colors(tickers) 

185 colors.update({f"{ticker}_light": _hex_to_rgba(colors[ticker]) for ticker in tickers}) 

186 

187 # Resample to monthly returns 

188 monthly_returns = returns.group_by_dynamic( 

189 index_column=date_col, every="1mo", period="1mo", closed="right", label="right" 

190 ).agg([((pl.col(ticker) + 1.0).product() - 1.0).alias(ticker) for ticker in tickers]) 

191 

192 # Create subplot grid with domain for stats table 

193 fig = make_subplots( 

194 rows=3, 

195 cols=1, 

196 shared_xaxes=True, 

197 row_heights=[0.5, 0.25, 0.25], 

198 subplot_titles=["Cumulative Returns", "Drawdowns", "Monthly Returns"], 

199 vertical_spacing=0.05, 

200 ) 

201 

202 # --- Row 1: Cumulative Returns 

203 for ticker in tickers: 

204 price_col = f"{ticker}_price" 

205 fig.add_trace( 

206 go.Scatter( 

207 x=prices[date_col], 

208 y=prices[price_col], 

209 mode="lines", 

210 name=ticker, 

211 legendgroup=ticker, 

212 line={"color": colors[ticker], "width": 2}, 

213 hovertemplate=f"<b>%{{x|%b %Y}}</b><br>{ticker}: %{{y:.2f}}x", 

214 showlegend=True, 

215 ), 

216 row=1, 

217 col=1, 

218 ) 

219 

220 # --- Row 2: Drawdowns 

221 for ticker in tickers: 

222 price_col = f"{ticker}_price" 

223 # Calculate drawdowns using polars 

224 price_series = prices[price_col] 

225 cummax = prices.select(pl.col(price_col).cum_max().alias("cummax")) 

226 dd_values = ((price_series - cummax["cummax"]) / cummax["cummax"]).to_list() 

227 

228 fig.add_trace( 

229 go.Scatter( 

230 x=prices[date_col], 

231 y=dd_values, 

232 mode="lines", 

233 fill="tozeroy", 

234 fillcolor=colors[f"{ticker}_light"], 

235 line={"color": colors[ticker], "width": 1}, 

236 name=ticker, 

237 legendgroup=ticker, 

238 hovertemplate=f"{ticker} Drawdown: %{{y:.2%}}", 

239 showlegend=False, 

240 ), 

241 row=2, 

242 col=1, 

243 ) 

244 

245 fig.add_hline(y=0, line_width=1, line_color="gray", row=2, col=1) 

246 

247 # --- Row 3: Monthly Returns 

248 for ticker in tickers: 

249 # Get monthly returns values as a list for coloring 

250 monthly_values = monthly_returns[ticker].to_list() 

251 

252 # If there's only one ticker, use green for positive returns and red for negative returns 

253 if len(tickers) == 1: 

254 bar_colors = ["green" if val > 0 else "red" for val in monthly_values] 

255 else: 

256 bar_colors = [colors[ticker] if val > 0 else colors[f"{ticker}_light"] for val in monthly_values] 

257 

258 fig.add_trace( 

259 go.Bar( 

260 x=monthly_returns[date_col], 

261 y=monthly_returns[ticker], 

262 name=ticker, 

263 legendgroup=ticker, 

264 marker={ 

265 "color": bar_colors, 

266 "line": {"width": 0}, 

267 }, 

268 opacity=0.8, 

269 hovertemplate=f"{ticker} Monthly Return: %{{y:.2%}}", 

270 showlegend=False, 

271 ), 

272 row=3, 

273 col=1, 

274 ) 

275 

276 _apply_base_layout(fig, f"{' vs '.join(tickers)} Performance Dashboard", height=1200) 

277 

278 fig.update_yaxes(title_text="Cumulative Return", row=1, col=1, tickformat=".2f") 

279 fig.update_yaxes(title_text="Drawdown", row=2, col=1, tickformat=".0%") 

280 fig.update_yaxes(title_text="Monthly Return", row=3, col=1, tickformat=".0%") 

281 

282 fig.update_xaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey") 

283 fig.update_yaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey") 

284 

285 if log_scale: 

286 fig.update_yaxes(type="log", row=1, col=1) 

287 

288 return fig 

289 

290 

291# ── DataPlots ────────────────────────────────────────────────────────────────── 

292 

293 

294class DataPlots: 

295 """Visualization tools for financial returns data. 

296 

297 This class provides methods for creating various plots and visualizations 

298 of financial returns data, including: 

299 

300 - Returns bar charts 

301 - Portfolio performance snapshots 

302 - Monthly returns heatmaps 

303 

304 The class is designed to work with the _Data class and uses Plotly 

305 for creating interactive visualizations. 

306 """ 

307 

308 __slots__ = ("_data",) 

309 

310 def __init__(self, data: DataLike) -> None: 

311 self._data = data 

312 

313 @property 

314 def assets(self) -> list[str]: 

315 """Asset column names from the underlying data.""" 

316 return self._data.assets 

317 

318 def __repr__(self) -> str: 

319 """Return a string representation of the DataPlots object.""" 

320 return f"DataPlots(assets={self._data.assets})" 

321 

322 def snapshot(self, title: str = "Portfolio Summary", log_scale: bool = False) -> go.Figure: 

323 """Create a comprehensive dashboard with multiple plots for portfolio analysis. 

324 

325 This function generates a three-panel plot showing: 

326 1. Cumulative returns over time 

327 2. Drawdowns over time 

328 3. Daily returns over time 

329 

330 This provides a complete visual summary of portfolio performance. 

331 

332 Args: 

333 title (str, optional): Title of the plot. Defaults to "Portfolio Summary". 

334 compounded (bool, optional): Whether to use compounded returns. Defaults to True. 

335 log_scale (bool, optional): Whether to use logarithmic scale for cumulative returns. 

336 Defaults to False. 

337 

338 Returns: 

339 go.Figure: A Plotly figure object containing the dashboard. 

340 

341 Example: 

342 >>> import polars as pl 

343 >>> from jquantstats import Data 

344 >>> # minimal demo dataset with a Date column and one asset 

345 >>> returns = pl.DataFrame({ 

346 ... "Date": ["2023-01-01", "2023-01-02", "2023-01-03"], 

347 ... "Asset": [0.01, -0.02, 0.03], 

348 ... }).with_columns(pl.col("Date").str.to_date()) 

349 >>> data = Data.from_returns(returns=returns) 

350 >>> fig = data.plots.snapshot(title="My Portfolio Performance") 

351 >>> # Optional: display the interactive figure 

352 >>> fig.show() # doctest: +SKIP 

353 

354 """ 

355 fig = _plot_performance_dashboard(returns=self._data.all, log_scale=log_scale) 

356 return fig 

357 

358 def returns(self, title: str = "Cumulative Returns", log_scale: bool = False) -> go.Figure: 

359 """Cumulative compounded returns over time. 

360 

361 Plots ``(1 + r).cumprod()`` for every column in the dataset (including 

362 benchmark when present). 

363 

364 Args: 

365 title: Chart title. Defaults to ``"Cumulative Returns"``. 

366 log_scale: Use a logarithmic y-axis. Defaults to False. 

367 

368 Returns: 

369 go.Figure: Interactive Plotly line chart. 

370 

371 """ 

372 df = self._data.all 

373 date_col = df.columns[0] 

374 tickers = [c for c in df.columns if c != date_col] 

375 colors = _ticker_colors(tickers) 

376 

377 prices = df.with_columns([(1.0 + pl.col(t)).cum_prod().alias(t) for t in tickers]) 

378 

379 fig = go.Figure() 

380 for ticker in tickers: 

381 fig.add_trace( 

382 go.Scatter( 

383 x=prices[date_col], 

384 y=prices[ticker], 

385 mode="lines", 

386 name=ticker, 

387 line={"color": colors[ticker], "width": 2}, 

388 hovertemplate=f"<b>%{{x|%b %Y}}</b><br>{ticker}: %{{y:.2f}}x", 

389 ) 

390 ) 

391 

392 _apply_base_layout(fig, title) 

393 fig.update_yaxes(title_text="Cumulative Return", tickformat=".2f") 

394 if log_scale: 

395 fig.update_yaxes(type="log") 

396 return fig 

397 

398 def compare(self, title: str = "Comparison vs Benchmark", figsize: tuple[int, int] | None = None) -> go.Figure: 

399 """Compare cumulative returns of each asset against the benchmark. 

400 

401 Args: 

402 title: Chart title. Defaults to ``"Comparison vs Benchmark"``. 

403 figsize: Optional ``(width, height)`` in pixels. 

404 

405 Returns: 

406 go.Figure: Interactive Plotly line chart. 

407 

408 Raises: 

409 AttributeError: If no benchmark data is available. 

410 

411 """ 

412 benchmark_df = getattr(self._data, "benchmark", None) 

413 if benchmark_df is None: 

414 raise AttributeError("compare() requires benchmark data to be set") # noqa: TRY003 

415 

416 df = self._data.all 

417 date_col = df.columns[0] 

418 assets = list(self._data.returns.columns) 

419 benchmarks = list(benchmark_df.columns) 

420 

421 series = assets + benchmarks 

422 colors = _ticker_colors(series) 

423 prices = df.with_columns([(1.0 + pl.col(col)).cum_prod().alias(col) for col in series]) 

424 

425 fig = go.Figure() 

426 for asset in assets: 

427 fig.add_trace( 

428 go.Scatter( 

429 x=prices[date_col], 

430 y=prices[asset], 

431 mode="lines", 

432 name=asset, 

433 line={"color": colors[asset], "width": 2}, 

434 hovertemplate=f"<b>%{{x|%b %Y}}</b><br>{asset}: %{{y:.2f}}x", 

435 ) 

436 ) 

437 for benchmark in benchmarks: 

438 fig.add_trace( 

439 go.Scatter( 

440 x=prices[date_col], 

441 y=prices[benchmark], 

442 mode="lines", 

443 name=benchmark, 

444 line={"color": colors[benchmark], "width": 2.5, "dash": "dash"}, 

445 hovertemplate=f"<b>%{{x|%b %Y}}</b><br>{benchmark}: %{{y:.2f}}x", 

446 ) 

447 ) 

448 

449 _apply_base_layout(fig, title) 

450 _apply_figsize(fig, figsize) 

451 fig.update_yaxes(title_text="Cumulative Return", tickformat=".2f") 

452 return fig 

453 

454 def log_returns(self, title: str = "Log Returns", figsize: tuple[int, int] | None = None) -> go.Figure: 

455 """Cumulative log returns over time. 

456 

457 Plots ``log((1 + r).cumprod())`` — the natural log of the compounded 

458 growth factor — which linearises exponential growth and makes 

459 multi-asset comparisons on a common scale. 

460 

461 Args: 

462 title: Chart title. Defaults to ``"Log Returns"``. 

463 figsize: Optional ``(width, height)`` in pixels. 

464 

465 Returns: 

466 go.Figure: Interactive Plotly line chart. 

467 

468 """ 

469 import math 

470 

471 df = self._data.all 

472 date_col = df.columns[0] 

473 tickers = [c for c in df.columns if c != date_col] 

474 colors = _ticker_colors(tickers) 

475 

476 log_prices = df.with_columns([(1.0 + pl.col(t)).cum_prod().log(math.e).alias(t) for t in tickers]) 

477 

478 fig = go.Figure() 

479 for ticker in tickers: 

480 fig.add_trace( 

481 go.Scatter( 

482 x=log_prices[date_col], 

483 y=log_prices[ticker], 

484 mode="lines", 

485 name=ticker, 

486 line={"color": colors[ticker], "width": 2}, 

487 hovertemplate=f"<b>%{{x|%b %Y}}</b><br>{ticker}: %{{y:.4f}}", 

488 ) 

489 ) 

490 

491 _apply_base_layout(fig, title) 

492 _apply_figsize(fig, figsize) 

493 fig.update_yaxes(title_text="Log Return") 

494 return fig 

495 

496 def daily_returns(self, title: str = "Daily Returns") -> go.Figure: 

497 """Daily returns as a bar chart. 

498 

499 Each bar is coloured green for positive returns and red for negative 

500 returns. When multiple assets are present each asset gets its own 

501 trace in the palette colour with opacity used for positive/negative 

502 differentiation. 

503 

504 Args: 

505 title: Chart title. Defaults to ``"Daily Returns"``. 

506 

507 Returns: 

508 go.Figure: Interactive Plotly bar chart. 

509 

510 """ 

511 df = self._data.all 

512 date_col = df.columns[0] 

513 tickers = [c for c in df.columns if c != date_col] 

514 colors = _ticker_colors(tickers) 

515 single = len(tickers) == 1 

516 

517 fig = go.Figure() 

518 for ticker in tickers: 

519 values = df[ticker].to_list() 

520 bar_colors = _bar_colors(values, colors[ticker], single_asset=single) 

521 

522 fig.add_trace( 

523 go.Bar( 

524 x=df[date_col], 

525 y=df[ticker], 

526 name=ticker, 

527 marker={"color": bar_colors, "line": {"width": 0}}, 

528 opacity=0.85, 

529 hovertemplate=f"{ticker}: %{{y:.2%}}", 

530 ) 

531 ) 

532 

533 _apply_base_layout(fig, title) 

534 fig.update_yaxes(title_text="Return", tickformat=".1%") 

535 return fig 

536 

537 def yearly_returns(self, title: str = "Yearly Returns", compounded: bool = True) -> go.Figure: 

538 """Annual compounded (or summed) returns as a grouped bar chart. 

539 

540 Args: 

541 title: Chart title. Defaults to ``"Yearly Returns"``. 

542 compounded: Compound returns within each year. Defaults to True. 

543 

544 Returns: 

545 go.Figure: Interactive Plotly grouped bar chart. 

546 

547 """ 

548 df = self._data.all 

549 date_col = df.columns[0] 

550 tickers = [c for c in df.columns if c != date_col] 

551 colors = _ticker_colors(tickers) 

552 

553 agg_exprs = ( 

554 [((1.0 + pl.col(t)).product() - 1.0).alias(t) for t in tickers] 

555 if compounded 

556 else [pl.col(t).sum().alias(t) for t in tickers] 

557 ) 

558 yearly = ( 

559 df.with_columns(pl.col(date_col).dt.year().alias("_year")).group_by("_year").agg(agg_exprs).sort("_year") 

560 ) 

561 

562 fig = go.Figure() 

563 for ticker in tickers: 

564 values = yearly[ticker].to_list() 

565 bar_colors = [ 

566 colors[ticker] if v is not None and v >= 0 else _hex_to_rgba(colors[ticker], 0.5) for v in values 

567 ] 

568 fig.add_trace( 

569 go.Bar( 

570 x=yearly["_year"], 

571 y=yearly[ticker], 

572 name=ticker, 

573 marker={"color": bar_colors, "line": {"width": 0}}, 

574 opacity=0.85, 

575 hovertemplate=f"{ticker}: %{{y:.2%}}", 

576 ) 

577 ) 

578 

579 _apply_base_layout(fig, title, with_range_selector=False) 

580 fig.update_layout(barmode="group", xaxis_title="Year") 

581 fig.update_yaxes(title_text="Annual Return", tickformat=".1%") 

582 return fig 

583 

584 def monthly_returns(self, title: str = "Monthly Returns", compounded: bool = True) -> go.Figure: 

585 """Monthly compounded (or summed) returns as a bar chart. 

586 

587 Args: 

588 title: Chart title. Defaults to ``"Monthly Returns"``. 

589 compounded: Compound returns within each month. Defaults to True. 

590 

591 Returns: 

592 go.Figure: Interactive Plotly bar chart. 

593 

594 """ 

595 df = self._data.all 

596 date_col = df.columns[0] 

597 tickers = [c for c in df.columns if c != date_col] 

598 colors = _ticker_colors(tickers) 

599 single = len(tickers) == 1 

600 

601 monthly = df.group_by_dynamic( 

602 index_column=date_col, every="1mo", period="1mo", closed="right", label="right" 

603 ).agg( 

604 [((1.0 + pl.col(t)).product() - 1.0).alias(t) if compounded else pl.col(t).sum().alias(t) for t in tickers] 

605 ) 

606 

607 fig = go.Figure() 

608 for ticker in tickers: 

609 values = monthly[ticker].to_list() 

610 bar_colors = _bar_colors(values, colors[ticker], single_asset=single) 

611 

612 fig.add_trace( 

613 go.Bar( 

614 x=monthly[date_col], 

615 y=monthly[ticker], 

616 name=ticker, 

617 marker={"color": bar_colors, "line": {"width": 0}}, 

618 opacity=0.85, 

619 hovertemplate=f"{ticker}: %{{y:.2%}}", 

620 ) 

621 ) 

622 

623 _apply_base_layout(fig, title) 

624 fig.update_yaxes(title_text="Monthly Return", tickformat=".1%") 

625 return fig 

626 

627 def monthly_heatmap( 

628 self, 

629 title: str = "Monthly Returns Heatmap", 

630 compounded: bool = True, 

631 asset: str | None = None, 

632 ) -> go.Figure: 

633 """Monthly returns calendar heatmap (year x month). 

634 

635 One heatmap is produced per call for a single asset. Green cells 

636 indicate positive months; red cells indicate negative months. 

637 

638 Args: 

639 title: Chart title. Defaults to ``"Monthly Returns Heatmap"``. 

640 compounded: Compound intra-month returns. Defaults to True. 

641 asset: Asset column name to display. Defaults to the first 

642 non-date column in the dataset. 

643 

644 Returns: 

645 go.Figure: Interactive Plotly heatmap. 

646 

647 """ 

648 df = self._data.all 

649 date_col = df.columns[0] 

650 tickers = [c for c in df.columns if c != date_col] 

651 col = asset if asset in tickers else tickers[0] 

652 

653 month_names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] 

654 

655 agg_expr = ((1.0 + pl.col(col)).product() - 1.0).alias("ret") if compounded else pl.col(col).sum().alias("ret") 

656 monthly = ( 

657 df.with_columns( 

658 [ 

659 pl.col(date_col).dt.year().alias("_year"), 

660 pl.col(date_col).dt.month().alias("_month"), 

661 ] 

662 ) 

663 .group_by(["_year", "_month"]) 

664 .agg(agg_expr.alias("ret")) 

665 .sort(["_year", "_month"]) 

666 ) 

667 

668 years = sorted(monthly["_year"].unique().to_list()) 

669 z = [[None] * 12 for _ in years] 

670 text = [[""] * 12 for _ in years] 

671 year_idx = {y: i for i, y in enumerate(years)} 

672 

673 for row in monthly.iter_rows(named=True): 

674 yi = year_idx[row["_year"]] 

675 mi = row["_month"] - 1 

676 val = row["ret"] 

677 z[yi][mi] = val * 100 if val is not None else None 

678 text[yi][mi] = f"{val:.1%}" if val is not None else "" 

679 

680 fig = go.Figure( 

681 go.Heatmap( 

682 x=month_names, 

683 y=[str(y) for y in years], 

684 z=z, 

685 text=text, 

686 texttemplate="%{text}", 

687 colorscale=[[0, "#d62728"], [0.5, "#ffffff"], [1, "#2ca02c"]], 

688 zmid=0, 

689 showscale=True, 

690 colorbar={"title": "Return (%)"}, 

691 hovertemplate="<b>%{y} %{x}</b><br>Return: %{text}<extra></extra>", 

692 ) 

693 ) 

694 

695 fig.update_layout( 

696 title=f"{title}{col}", 

697 height=max(300, 40 * len(years) + 100), 

698 plot_bgcolor="white", 

699 xaxis={"side": "top"}, 

700 ) 

701 return fig 

702 

703 def histogram(self, title: str = "Returns Distribution", bins: int = 50) -> go.Figure: 

704 """Return histogram with a kernel density overlay. 

705 

706 Each asset is shown as a semi-transparent histogram overlaid on the 

707 same axes so distributions can be compared visually. 

708 

709 Args: 

710 title: Chart title. Defaults to ``"Returns Distribution"``. 

711 bins: Number of histogram bins. Defaults to 50. 

712 

713 Returns: 

714 go.Figure: Interactive Plotly histogram figure. 

715 

716 """ 

717 df = self._data.all 

718 date_col = df.columns[0] 

719 tickers = [c for c in df.columns if c != date_col] 

720 colors = _ticker_colors(tickers) 

721 

722 fig = go.Figure() 

723 for ticker in tickers: 

724 values = df[ticker].drop_nulls().to_list() 

725 fig.add_trace( 

726 go.Histogram( 

727 x=values, 

728 name=ticker, 

729 nbinsx=bins, 

730 marker_color=colors[ticker], 

731 opacity=0.6, 

732 hovertemplate=f"{ticker}: %{{x:.2%}}<extra></extra>", 

733 ) 

734 ) 

735 

736 _apply_base_layout(fig, title, with_range_selector=False) 

737 fig.update_layout(barmode="overlay") 

738 fig.update_xaxes(title_text="Return", tickformat=".1%") 

739 fig.update_yaxes(title_text="Count") 

740 return fig 

741 

742 def distribution( 

743 self, 

744 title: str = "Return Distribution by Period", 

745 compounded: bool = True, 

746 ) -> go.Figure: 

747 """Return distributions across daily, weekly, monthly, quarterly and yearly periods. 

748 

749 Renders a box plot for each aggregation period so the user can compare 

750 how the distribution widens as the holding period lengthens. One 

751 subplot column is produced per asset. 

752 

753 Args: 

754 title: Chart title. Defaults to ``"Return Distribution by Period"``. 

755 compounded: Compound returns within each period. Defaults to True. 

756 

757 Returns: 

758 go.Figure: Interactive Plotly figure with one subplot per asset. 

759 

760 """ 

761 df = self._data.all 

762 date_col = df.columns[0] 

763 tickers = [c for c in df.columns if c != date_col] 

764 colors = _ticker_colors(tickers) 

765 

766 periods = [ 

767 ("Daily", None), 

768 ("Weekly", "1w"), 

769 ("Monthly", "1mo"), 

770 ("Quarterly", "3mo"), 

771 ("Yearly", "1y"), 

772 ] 

773 

774 n_assets = len(tickers) 

775 fig = make_subplots( 

776 rows=1, 

777 cols=n_assets, 

778 subplot_titles=tickers, 

779 shared_yaxes=True, 

780 ) 

781 

782 for col_idx, ticker in enumerate(tickers, start=1): 

783 for period_name, trunc in periods: 

784 if trunc is None: 

785 values = df[ticker].drop_nulls().to_list() 

786 else: 

787 agg_expr = ( 

788 ((1.0 + pl.col(ticker)).product() - 1.0).alias("ret") 

789 if compounded 

790 else pl.col(ticker).sum().alias("ret") 

791 ) 

792 agg_df = ( 

793 df.with_columns(pl.col(date_col).dt.truncate(trunc).alias("_period")) 

794 .group_by("_period") 

795 .agg(agg_expr) 

796 ) 

797 values = agg_df["ret"].drop_nulls().to_list() 

798 

799 fig.add_trace( 

800 go.Box( 

801 y=values, 

802 name=period_name, 

803 marker_color=colors[ticker], 

804 showlegend=(col_idx == 1), 

805 legendgroup=period_name, 

806 boxpoints="outliers", 

807 hovertemplate=f"{period_name}: %{{y:.2%}}<extra></extra>", 

808 ), 

809 row=1, 

810 col=col_idx, 

811 ) 

812 

813 fig.update_layout( 

814 title=title, 

815 height=500, 

816 plot_bgcolor="white", 

817 legend={"orientation": "h", "yanchor": "bottom", "y": 1.05, "xanchor": "right", "x": 1}, 

818 ) 

819 fig.update_yaxes(tickformat=".1%", showgrid=True, gridwidth=0.5, gridcolor="lightgrey") 

820 fig.update_xaxes(showgrid=False) 

821 return fig 

822 

823 def montecarlo( 

824 self, 

825 n: int = 100, 

826 period: int = 252, 

827 title: str = "Monte Carlo Simulation", 

828 figsize: tuple[int, int] | None = None, 

829 ) -> go.Figure: 

830 """Fan chart of Monte Carlo simulated cumulative return paths. 

831 

832 For each asset column, draws ``n`` bootstrapped paths sampled with 

833 replacement from historical returns and overlays the observed path for 

834 the trailing *period* observations. 

835 

836 Args: 

837 n: Number of simulated paths per asset. Defaults to 100. 

838 period: Number of observations per path. Defaults to 252. 

839 title: Chart title. Defaults to ``"Monte Carlo Simulation"``. 

840 figsize: Optional figure ``(width, height)`` in pixels. 

841 

842 Returns: 

843 go.Figure: Interactive Plotly fan chart. 

844 

845 """ 

846 if n <= 0: 

847 raise ValueError("n must be a positive integer") # noqa: TRY003 

848 if period <= 0: 

849 raise ValueError("period must be a positive integer") # noqa: TRY003 

850 

851 df = self._data.all 

852 date_col = df.columns[0] 

853 tickers = [c for c in df.columns if c != date_col] 

854 colors = _ticker_colors(tickers) 

855 

856 sample_len = min(period, df.height) 

857 dates = df[date_col].tail(sample_len).to_list() 

858 rng = np.random.default_rng(seed=42) 

859 

860 fig = go.Figure() 

861 for ticker in tickers: 

862 trailing_returns = ( 

863 df[ticker].tail(sample_len - 1).fill_null(0.0).cast(pl.Float64).to_numpy() 

864 if sample_len > 1 

865 else np.array([], dtype=np.float64) 

866 ) 

867 

868 for i in range(n): 

869 draws = ( 

870 rng.choice(trailing_returns, size=sample_len - 1, replace=True) 

871 if sample_len > 1 

872 else np.array([], dtype=np.float64) 

873 ) 

874 sim_path = np.cumprod(np.concatenate(([1.0], 1.0 + draws))) 

875 fig.add_trace( 

876 go.Scatter( 

877 x=dates, 

878 y=sim_path, 

879 mode="lines", 

880 name=f"{ticker} Sim", 

881 legendgroup=f"{ticker}_sim", 

882 showlegend=(i == 0), 

883 line={"color": _hex_to_rgba(colors[ticker], alpha=0.12), "width": 1}, 

884 hovertemplate=f"{ticker} Sim: %{{y:.2f}}x<extra></extra>", 

885 ) 

886 ) 

887 

888 observed_path = np.cumprod(np.concatenate(([1.0], 1.0 + trailing_returns))) 

889 fig.add_trace( 

890 go.Scatter( 

891 x=dates, 

892 y=observed_path, 

893 mode="lines", 

894 name=f"{ticker} Observed", 

895 legendgroup=f"{ticker}_obs", 

896 line={"color": colors[ticker], "width": 2.5}, 

897 hovertemplate=f"{ticker} Observed: %{{y:.2f}}x<extra></extra>", 

898 ) 

899 ) 

900 

901 _apply_base_layout(fig, title) 

902 fig.update_yaxes(title_text="Cumulative Return", tickformat=".2f") 

903 _apply_figsize(fig, figsize) 

904 return fig 

905 

906 def montecarlo_distribution( 

907 self, 

908 n: int = 1000, 

909 period: int = 252, 

910 metric: str = "sharpe", 

911 title: str = "Monte Carlo Distribution", 

912 figsize: tuple[int, int] | None = None, 

913 ) -> go.Figure: 

914 """Distribution of Monte Carlo simulation metrics. 

915 

916 Computes one metric per simulated path and shows the resulting 

917 distribution as a histogram with the observed trailing-period value 

918 overlaid as a vertical reference line. 

919 

920 Supported metrics: 

921 - ``"sharpe"`` (annualized, 252 periods/year) 

922 - ``"drawdown"`` (maximum drawdown, negative value) 

923 - ``"cagr"`` (annualized geometric return) 

924 

925 Args: 

926 n: Number of simulations per asset. Defaults to 1000. 

927 period: Number of observations in each simulation. Defaults to 252. 

928 metric: Metric to evaluate. One of ``"sharpe"``, ``"drawdown"``, 

929 or ``"cagr"``. 

930 title: Chart title. Defaults to ``"Monte Carlo Distribution"``. 

931 figsize: Optional figure ``(width, height)`` in pixels. 

932 

933 Returns: 

934 go.Figure: Interactive Plotly histogram figure. 

935 

936 """ 

937 if n <= 0: 

938 raise ValueError("n must be a positive integer") # noqa: TRY003 

939 if period <= 0: 

940 raise ValueError("period must be a positive integer") # noqa: TRY003 

941 

942 metric_key = metric.strip().lower() 

943 if metric_key not in {"sharpe", "drawdown", "cagr"}: 

944 raise ValueError("metric must be one of: sharpe, drawdown, cagr") # noqa: TRY003 

945 periods_per_year = 252.0 

946 

947 def _metric_value(returns: np.ndarray) -> float: 

948 """Compute the selected metric for a simulated return path.""" 

949 if metric_key == "sharpe": 

950 std = returns.std(ddof=1) 

951 return float(math.sqrt(periods_per_year) * returns.mean() / std) if std > 0 else 0.0 

952 if metric_key == "drawdown": 

953 path = np.cumprod(1.0 + returns) 

954 hwm = np.maximum.accumulate(path) 

955 dd = (path - hwm) / hwm 

956 return float(dd.min()) if dd.size else 0.0 

957 total_return = float(np.prod(1.0 + returns)) 

958 return float(total_return ** (periods_per_year / len(returns)) - 1.0) if len(returns) > 0 else 0.0 

959 

960 df = self._data.all 

961 date_col = df.columns[0] 

962 tickers = [c for c in df.columns if c != date_col] 

963 colors = _ticker_colors(tickers) 

964 sample_len = min(period, df.height) 

965 rng = np.random.default_rng(seed=42) 

966 

967 fig = go.Figure() 

968 for ticker in tickers: 

969 hist_returns = df[ticker].tail(sample_len).fill_null(0.0).cast(pl.Float64).to_numpy() 

970 if hist_returns.size == 0: # pragma: no cover 

971 continue 

972 

973 simulated_metrics = [ 

974 _metric_value(rng.choice(hist_returns, size=sample_len, replace=True)) for _ in range(n) 

975 ] 

976 observed_metric = _metric_value(hist_returns) 

977 

978 fig.add_trace( 

979 go.Histogram( 

980 x=simulated_metrics, 

981 name=ticker, 

982 marker_color=colors[ticker], 

983 opacity=0.6, 

984 hovertemplate=f"{ticker}: %{{x:.4f}}<extra></extra>", 

985 ) 

986 ) 

987 fig.add_vline( 

988 x=observed_metric, 

989 line={"color": colors[ticker], "width": 2, "dash": "dash"}, 

990 annotation_text=f"{ticker} observed", 

991 annotation_position="top right", 

992 annotation_font_size=10, 

993 ) 

994 

995 metric_title = {"sharpe": "Sharpe Ratio", "drawdown": "Max Drawdown", "cagr": "CAGR"}[metric_key] 

996 _apply_base_layout(fig, title, with_range_selector=False) 

997 fig.update_layout(barmode="overlay") 

998 fig.update_xaxes(title_text=metric_title) 

999 fig.update_yaxes(title_text="Count") 

1000 _apply_figsize(fig, figsize) 

1001 return fig 

1002 

1003 def drawdown(self, title: str = "Drawdowns") -> go.Figure: 

1004 """Underwater equity curve (drawdown) chart. 

1005 

1006 Shows the percentage decline from the running peak for every column 

1007 in the dataset (assets and benchmark where present). 

1008 

1009 Args: 

1010 title: Chart title. Defaults to ``"Drawdowns"``. 

1011 

1012 Returns: 

1013 go.Figure: Interactive Plotly filled-area chart. 

1014 

1015 """ 

1016 df = self._data.all 

1017 date_col = df.columns[0] 

1018 tickers = [c for c in df.columns if c != date_col] 

1019 colors = _ticker_colors(tickers) 

1020 

1021 prices = df.with_columns([(1.0 + pl.col(t)).cum_prod().alias(t) for t in tickers]) 

1022 

1023 fig = go.Figure() 

1024 for ticker in tickers: 

1025 price_s = prices[ticker] 

1026 hwm = price_s.cum_max() 

1027 dd = ((price_s - hwm) / hwm).to_list() 

1028 

1029 fig.add_trace( 

1030 go.Scatter( 

1031 x=prices[date_col], 

1032 y=dd, 

1033 mode="lines", 

1034 fill="tozeroy", 

1035 fillcolor=_hex_to_rgba(colors[ticker], 0.3), 

1036 line={"color": colors[ticker], "width": 1.5}, 

1037 name=ticker, 

1038 hovertemplate=f"{ticker}: %{{y:.2%}}", 

1039 ) 

1040 ) 

1041 

1042 fig.add_hline(y=0, line_width=1, line_color="gray") 

1043 _apply_base_layout(fig, title) 

1044 fig.update_yaxes(title_text="Drawdown", tickformat=".0%") 

1045 return fig 

1046 

1047 def drawdowns_periods( 

1048 self, 

1049 n: int = 5, 

1050 title: str = "Top Drawdown Periods", 

1051 asset: str | None = None, 

1052 ) -> go.Figure: 

1053 """Cumulative returns chart with the worst *n* drawdown periods shaded. 

1054 

1055 Identifies the *n* deepest drawdown periods and overlays coloured 

1056 rectangular shading on the cumulative returns line. One asset is 

1057 shown per call. 

1058 

1059 Args: 

1060 n: Number of worst drawdown periods to highlight. Defaults to 5. 

1061 title: Chart title. Defaults to ``"Top Drawdown Periods"``. 

1062 asset: Asset column name. Defaults to the first non-date column. 

1063 

1064 Returns: 

1065 go.Figure: Interactive Plotly figure. 

1066 

1067 """ 

1068 df = self._data.all 

1069 date_col = df.columns[0] 

1070 tickers = [c for c in df.columns if c != date_col] 

1071 col = asset if asset in tickers else tickers[0] 

1072 

1073 price_series = (1.0 + df[col].cast(pl.Float64)).cum_prod() 

1074 price_list = price_series.to_list() 

1075 dates = df[date_col].to_list() 

1076 

1077 drawdown_periods = _compute_drawdown_periods(price_list, n) 

1078 

1079 dd_colors = px.colors.qualitative.Plotly 

1080 

1081 fig = go.Figure() 

1082 fig.add_trace( 

1083 go.Scatter( 

1084 x=dates, 

1085 y=price_list, 

1086 mode="lines", 

1087 name=col, 

1088 line={"color": "#1f77b4", "width": 2}, 

1089 hovertemplate=f"<b>%{{x|%b %Y}}</b><br>{col}: %{{y:.2f}}x", 

1090 ) 

1091 ) 

1092 

1093 for i, period in enumerate(drawdown_periods): 

1094 start_date = dates[period["start_idx"]] 

1095 end_date = dates[min(period["end_idx"] + 1, len(dates) - 1)] 

1096 max_dd = period["max_drawdown"] 

1097 shade_color = _hex_to_rgba(dd_colors[i % len(dd_colors)], alpha=0.2) 

1098 

1099 fig.add_vrect( 

1100 x0=start_date, 

1101 x1=end_date, 

1102 fillcolor=shade_color, 

1103 line_width=0, 

1104 annotation_text=f"#{i + 1} {max_dd:.1%}", 

1105 annotation_position="top left", 

1106 annotation_font_size=10, 

1107 ) 

1108 

1109 _apply_base_layout(fig, f"{title}{col}") 

1110 fig.update_yaxes(title_text="Cumulative Return", tickformat=".2f") 

1111 return fig 

1112 

1113 def earnings( 

1114 self, 

1115 start_balance: float = 1e5, 

1116 title: str = "Portfolio Earnings", 

1117 compounded: bool = True, 

1118 ) -> go.Figure: 

1119 """Dollar equity curve showing portfolio value over time. 

1120 

1121 Scales cumulative returns by *start_balance* so the y-axis reflects 

1122 an absolute portfolio value rather than a dimensionless growth factor. 

1123 

1124 Args: 

1125 start_balance: Starting portfolio value in currency units. 

1126 Defaults to 100 000. 

1127 title: Chart title. Defaults to ``"Portfolio Earnings"``. 

1128 compounded: Use compounded returns (``cumprod``). When False uses 

1129 cumulative sum. Defaults to True. 

1130 

1131 Returns: 

1132 go.Figure: Interactive Plotly line chart. 

1133 

1134 """ 

1135 df = self._data.all 

1136 date_col = df.columns[0] 

1137 tickers = [c for c in df.columns if c != date_col] 

1138 colors = _ticker_colors(tickers) 

1139 

1140 if compounded: 

1141 equity = df.with_columns([(start_balance * (1.0 + pl.col(t)).cum_prod()).alias(t) for t in tickers]) 

1142 else: 

1143 equity = df.with_columns([(start_balance * (1.0 + pl.col(t).cum_sum())).alias(t) for t in tickers]) 

1144 

1145 fig = go.Figure() 

1146 for ticker in tickers: 

1147 fig.add_trace( 

1148 go.Scatter( 

1149 x=equity[date_col], 

1150 y=equity[ticker], 

1151 mode="lines", 

1152 name=ticker, 

1153 line={"color": colors[ticker], "width": 2}, 

1154 hovertemplate=f"<b>%{{x|%b %Y}}</b><br>{ticker}: $%{{y:,.0f}}", 

1155 ) 

1156 ) 

1157 

1158 _apply_base_layout(fig, title) 

1159 fig.update_yaxes( 

1160 title_text=f"Portfolio Value (starting ${start_balance:,.0f})", 

1161 tickprefix="$", 

1162 tickformat=",.0f", 

1163 ) 

1164 return fig 

1165 

1166 def rolling_sharpe( 

1167 self, 

1168 rolling_period: int = 126, 

1169 periods_per_year: int = 252, 

1170 title: str = "Rolling Sharpe Ratio", 

1171 ) -> go.Figure: 

1172 """Rolling annualised Sharpe ratio over time. 

1173 

1174 Computes ``rolling_mean / rolling_std * sqrt(periods_per_year)`` with a 

1175 trailing window of *rolling_period* observations for every column in the 

1176 dataset (assets and benchmark when present). 

1177 

1178 Args: 

1179 rolling_period: Trailing window size. Defaults to 126 (6 months). 

1180 periods_per_year: Annualisation factor. Defaults to 252. 

1181 title: Chart title. Defaults to ``"Rolling Sharpe Ratio"``. 

1182 

1183 Returns: 

1184 go.Figure: Interactive Plotly line chart. 

1185 

1186 """ 

1187 import math 

1188 

1189 df = self._data.all 

1190 date_col = df.columns[0] 

1191 tickers = [c for c in df.columns if c != date_col] 

1192 colors = _ticker_colors(tickers) 

1193 scale = math.sqrt(periods_per_year) 

1194 

1195 rolling = df.with_columns( 

1196 [ 

1197 ( 

1198 pl.col(t).rolling_mean(window_size=rolling_period) 

1199 / pl.col(t).rolling_std(window_size=rolling_period) 

1200 * scale 

1201 ).alias(t) 

1202 for t in tickers 

1203 ] 

1204 ) 

1205 

1206 fig = go.Figure() 

1207 for ticker in tickers: 

1208 fig.add_trace( 

1209 go.Scatter( 

1210 x=rolling[date_col], 

1211 y=rolling[ticker], 

1212 mode="lines", 

1213 name=ticker, 

1214 line={"color": colors[ticker], "width": 1.5}, 

1215 hovertemplate=f"{ticker}: %{{y:.2f}}", 

1216 ) 

1217 ) 

1218 

1219 fig.add_hline(y=0, line_width=1, line_color="gray", line_dash="dash") 

1220 _apply_base_layout(fig, title) 

1221 fig.update_yaxes(title_text=f"Sharpe ({rolling_period}-period rolling)") 

1222 return fig 

1223 

1224 def rolling_sortino( 

1225 self, 

1226 rolling_period: int = 126, 

1227 periods_per_year: int = 252, 

1228 title: str = "Rolling Sortino Ratio", 

1229 ) -> go.Figure: 

1230 """Rolling annualised Sortino ratio over time. 

1231 

1232 Computes ``rolling_mean / rolling_downside_std * sqrt(periods_per_year)`` 

1233 where downside deviation considers only negative returns. 

1234 

1235 Args: 

1236 rolling_period: Trailing window size. Defaults to 126 (6 months). 

1237 periods_per_year: Annualisation factor. Defaults to 252. 

1238 title: Chart title. Defaults to ``"Rolling Sortino Ratio"``. 

1239 

1240 Returns: 

1241 go.Figure: Interactive Plotly line chart. 

1242 

1243 """ 

1244 import math 

1245 

1246 df = self._data.all 

1247 date_col = df.columns[0] 

1248 tickers = [c for c in df.columns if c != date_col] 

1249 colors = _ticker_colors(tickers) 

1250 scale = math.sqrt(periods_per_year) 

1251 

1252 exprs = [] 

1253 for t in tickers: 

1254 mean_r = pl.col(t).rolling_mean(window_size=rolling_period) 

1255 downside = ( 

1256 pl.when(pl.col(t) < 0) 

1257 .then(pl.col(t) ** 2) 

1258 .otherwise(0.0) 

1259 .rolling_mean(window_size=rolling_period) 

1260 .sqrt() 

1261 ) 

1262 exprs.append((mean_r / downside * scale).alias(t)) 

1263 

1264 rolling = df.with_columns(exprs) 

1265 

1266 fig = go.Figure() 

1267 for ticker in tickers: 

1268 fig.add_trace( 

1269 go.Scatter( 

1270 x=rolling[date_col], 

1271 y=rolling[ticker], 

1272 mode="lines", 

1273 name=ticker, 

1274 line={"color": colors[ticker], "width": 1.5}, 

1275 hovertemplate=f"{ticker}: %{{y:.2f}}", 

1276 ) 

1277 ) 

1278 

1279 fig.add_hline(y=0, line_width=1, line_color="gray", line_dash="dash") 

1280 _apply_base_layout(fig, title) 

1281 fig.update_yaxes(title_text=f"Sortino ({rolling_period}-period rolling)") 

1282 return fig 

1283 

1284 def rolling_volatility( 

1285 self, 

1286 rolling_period: int = 126, 

1287 periods_per_year: int = 252, 

1288 title: str = "Rolling Volatility", 

1289 ) -> go.Figure: 

1290 """Rolling annualised volatility over time. 

1291 

1292 Computes ``rolling_std * sqrt(periods_per_year)`` for every column in 

1293 the dataset. 

1294 

1295 Args: 

1296 rolling_period: Trailing window size. Defaults to 126 (6 months). 

1297 periods_per_year: Annualisation factor. Defaults to 252. 

1298 title: Chart title. Defaults to ``"Rolling Volatility"``. 

1299 

1300 Returns: 

1301 go.Figure: Interactive Plotly line chart. 

1302 

1303 """ 

1304 import math 

1305 

1306 df = self._data.all 

1307 date_col = df.columns[0] 

1308 tickers = [c for c in df.columns if c != date_col] 

1309 colors = _ticker_colors(tickers) 

1310 scale = math.sqrt(periods_per_year) 

1311 

1312 rolling = df.with_columns( 

1313 [(pl.col(t).rolling_std(window_size=rolling_period) * scale).alias(t) for t in tickers] 

1314 ) 

1315 

1316 fig = go.Figure() 

1317 for ticker in tickers: 

1318 fig.add_trace( 

1319 go.Scatter( 

1320 x=rolling[date_col], 

1321 y=rolling[ticker], 

1322 mode="lines", 

1323 name=ticker, 

1324 line={"color": colors[ticker], "width": 1.5}, 

1325 hovertemplate=f"{ticker}: %{{y:.2%}}", 

1326 ) 

1327 ) 

1328 

1329 _apply_base_layout(fig, title) 

1330 fig.update_yaxes(title_text=f"Volatility ({rolling_period}-period rolling)", tickformat=".0%") 

1331 return fig 

1332 

1333 def rolling_beta( 

1334 self, 

1335 rolling_period: int = 126, 

1336 rolling_period2: int | None = 252, 

1337 title: str = "Rolling Beta", 

1338 figsize: tuple[int, int] | None = None, 

1339 ) -> go.Figure: 

1340 """Rolling beta versus the benchmark. 

1341 

1342 Plots one line per asset per window size. Beta is estimated via the 

1343 standard OLS formula: ``cov(asset, bench) / var(bench)`` computed over 

1344 a trailing window. 

1345 

1346 Args: 

1347 rolling_period: Primary trailing window size. Defaults to 126. 

1348 rolling_period2: Optional second window size overlaid on the same 

1349 chart. Defaults to 252. Pass ``None`` to omit. 

1350 title: Chart title. Defaults to ``"Rolling Beta"``. 

1351 figsize: Optional ``(width, height)`` in pixels. 

1352 

1353 Returns: 

1354 go.Figure: Interactive Plotly line chart. 

1355 

1356 Raises: 

1357 AttributeError: If no benchmark columns are present in the data. 

1358 

1359 """ 

1360 df = self._data.all 

1361 date_col = df.columns[0] 

1362 

1363 benchmark_df = getattr(self._data, "benchmark", None) 

1364 if benchmark_df is None: 

1365 raise AttributeError("No benchmark data available") # noqa: TRY003 

1366 

1367 bench_col = benchmark_df.columns[0] 

1368 returns_df = getattr(self._data, "returns", None) 

1369 assets = ( 

1370 list(returns_df.columns) 

1371 if returns_df is not None 

1372 else [c for c in df.columns if c != date_col and c != bench_col] 

1373 ) 

1374 colors = _ticker_colors(assets) 

1375 windows = [w for w in (rolling_period, rolling_period2) if w is not None] 

1376 line_styles = ["solid", "dash"] 

1377 

1378 fig = go.Figure() 

1379 for asset in assets: 

1380 for w, dash in zip(windows, line_styles, strict=False): 

1381 mean_x = pl.col(asset).rolling_mean(window_size=w) 

1382 mean_y = pl.col(bench_col).rolling_mean(window_size=w) 

1383 mean_xy = (pl.col(asset) * pl.col(bench_col)).rolling_mean(window_size=w) 

1384 mean_y2 = (pl.col(bench_col) ** 2).rolling_mean(window_size=w) 

1385 beta_expr = ((mean_xy - mean_x * mean_y) / (mean_y2 - mean_y**2)).alias("beta") 

1386 

1387 beta_df = df.with_columns(beta_expr) 

1388 label = f"{asset} ({w}d)" 

1389 fig.add_trace( 

1390 go.Scatter( 

1391 x=beta_df[date_col], 

1392 y=beta_df["beta"], 

1393 mode="lines", 

1394 name=label, 

1395 line={"color": colors[asset], "width": 1.5, "dash": dash}, 

1396 hovertemplate=f"{label}: %{{y:.2f}}", 

1397 ) 

1398 ) 

1399 

1400 fig.add_hline(y=1, line_width=1, line_color="gray", line_dash="dash") 

1401 _apply_base_layout(fig, title) 

1402 _apply_figsize(fig, figsize) 

1403 fig.update_yaxes(title_text="Beta") 

1404 return fig