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

354 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-07 14:28 +0000

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

2 

3from __future__ import annotations 

4 

5import dataclasses 

6from typing import TYPE_CHECKING 

7 

8import plotly.express as px 

9import plotly.graph_objects as go 

10import polars as pl 

11from plotly.subplots import make_subplots 

12 

13if TYPE_CHECKING: 

14 from ._protocol import DataLike 

15 

16# ── Module-level styling helpers ────────────────────────────────────────────── 

17 

18 

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

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

21 

22 Args: 

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

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

25 

26 Returns: 

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

28 

29 """ 

30 hex_color = hex_color.lstrip("#") 

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

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

33 

34 

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

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

37 

38 Args: 

39 tickers: Ordered list of ticker / column names. 

40 

41 Returns: 

42 dict mapping each ticker to a hex colour string. 

43 

44 """ 

45 palette = px.colors.qualitative.Plotly 

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

47 

48 

49def _date_range_selector() -> dict: 

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

51 

52 Returns: 

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

54 

55 """ 

56 return { 

57 "buttons": [ 

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

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

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

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

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

63 ] 

64 } 

65 

66 

67def _apply_base_layout( 

68 fig: go.Figure, 

69 title: str, 

70 height: int = 600, 

71 with_range_selector: bool = True, 

72) -> go.Figure: 

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

74 

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

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

77 

78 Args: 

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

80 title: Chart title. 

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

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

83 Defaults to True. 

84 

85 Returns: 

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

87 

88 """ 

89 layout_kw: dict = { 

90 "title": title, 

91 "height": height, 

92 "hovermode": "x unified", 

93 "plot_bgcolor": "white", 

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

95 } 

96 if with_range_selector: 

97 layout_kw["xaxis"] = { 

98 "rangeselector": _date_range_selector(), 

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

100 "type": "date", 

101 } 

102 fig.update_layout(**layout_kw) 

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

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

105 return fig 

106 

107 

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

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

110 

111 Args: 

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

113 n: Maximum number of drawdown periods to return. 

114 

115 Returns: 

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

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

118 

119 """ 

120 length = len(prices) 

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

122 hwm[0] = prices[0] 

123 for i in range(1, length): 

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

125 

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

127 periods: list[dict] = [] 

128 i = 0 

129 while i < length: 

130 if not in_dd[i]: 

131 i += 1 

132 continue 

133 start = i 

134 while i < length and in_dd[i]: 

135 i += 1 

136 end = i - 1 

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

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

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

140 

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

142 return periods[:n] 

143 

144 

145# ── Dashboard (existing) ────────────────────────────────────────────────────── 

146 

147 

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

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

150 

151 Args: 

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

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

154 

155 Returns: 

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

157 

158 """ 

159 

160 def hex_to_rgba(hex_color: str, alpha: float = 0.5) -> str: 

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

162 

163 Args: 

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

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

166 

167 Returns: 

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

169 

170 """ 

171 hex_color = hex_color.lstrip("#") 

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

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

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 palette = px.colors.qualitative.Plotly 

185 colors = {ticker: palette[i % len(palette)] for i, ticker in enumerate(tickers)} 

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

187 

188 # Resample to monthly returns 

189 monthly_returns = returns.group_by_dynamic( 

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

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

192 

193 # Create subplot grid with domain for stats table 

194 fig = make_subplots( 

195 rows=3, 

196 cols=1, 

197 shared_xaxes=True, 

198 row_heights=[0.5, 0.25, 0.25], 

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

200 vertical_spacing=0.05, 

201 ) 

202 

203 # --- Row 1: Cumulative Returns 

204 for ticker in tickers: 

205 price_col = f"{ticker}_price" 

206 fig.add_trace( 

207 go.Scatter( 

208 x=prices[date_col], 

209 y=prices[price_col], 

210 mode="lines", 

211 name=ticker, 

212 legendgroup=ticker, 

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

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

215 showlegend=True, 

216 ), 

217 row=1, 

218 col=1, 

219 ) 

220 

221 # --- Row 2: Drawdowns 

222 for ticker in tickers: 

223 price_col = f"{ticker}_price" 

224 # Calculate drawdowns using polars 

225 price_series = prices[price_col] 

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

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

228 

229 fig.add_trace( 

230 go.Scatter( 

231 x=prices[date_col], 

232 y=dd_values, 

233 mode="lines", 

234 fill="tozeroy", 

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

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

237 name=ticker, 

238 legendgroup=ticker, 

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

240 showlegend=False, 

241 ), 

242 row=2, 

243 col=1, 

244 ) 

245 

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

247 

248 # --- Row 3: Monthly Returns 

249 for ticker in tickers: 

250 # Get monthly returns values as a list for coloring 

251 monthly_values = monthly_returns[ticker].to_list() 

252 

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

254 if len(tickers) == 1: 

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

256 else: 

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

258 

259 fig.add_trace( 

260 go.Bar( 

261 x=monthly_returns[date_col], 

262 y=monthly_returns[ticker], 

263 name=ticker, 

264 legendgroup=ticker, 

265 marker={ 

266 "color": bar_colors, 

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

268 }, 

269 opacity=0.8, 

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

271 showlegend=False, 

272 ), 

273 row=3, 

274 col=1, 

275 ) 

276 

277 # Layout 

278 fig.update_layout( 

279 title=f"{' vs '.join(tickers)} Performance Dashboard", 

280 height=1200, 

281 hovermode="x unified", 

282 plot_bgcolor="white", 

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

284 xaxis={ 

285 "rangeselector": { 

286 "buttons": [ 

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

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

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

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

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

292 ] 

293 }, 

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

295 "type": "date", 

296 }, 

297 ) 

298 

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

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

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

302 

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

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

305 

306 if log_scale: 

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

308 

309 return fig 

310 

311 

312# ── DataPlots ────────────────────────────────────────────────────────────────── 

313 

314 

315@dataclasses.dataclass(frozen=True) 

316class DataPlots: 

317 """Visualization tools for financial returns data. 

318 

319 This class provides methods for creating various plots and visualizations 

320 of financial returns data, including: 

321 

322 - Returns bar charts 

323 - Portfolio performance snapshots 

324 - Monthly returns heatmaps 

325 

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

327 for creating interactive visualizations. 

328 

329 Attributes: 

330 data: The _Data object containing returns and benchmark data to visualize. 

331 

332 """ 

333 

334 data: DataLike 

335 

336 def __repr__(self) -> str: 

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

338 return f"DataPlots(assets={self.data.assets})" 

339 

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

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

342 

343 This function generates a three-panel plot showing: 

344 1. Cumulative returns over time 

345 2. Drawdowns over time 

346 3. Daily returns over time 

347 

348 This provides a complete visual summary of portfolio performance. 

349 

350 Args: 

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

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

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

354 Defaults to False. 

355 

356 Returns: 

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

358 

359 Example: 

360 >>> import polars as pl 

361 >>> from jquantstats import Data 

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

363 >>> returns = pl.DataFrame({ 

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

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

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

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

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

369 >>> # Optional: display the interactive figure 

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

371 

372 """ 

373 fig = _plot_performance_dashboard(returns=self.data.all, log_scale=log_scale) 

374 return fig 

375 

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

377 """Cumulative compounded returns over time. 

378 

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

380 benchmark when present). 

381 

382 Args: 

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

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

385 

386 Returns: 

387 go.Figure: Interactive Plotly line chart. 

388 

389 """ 

390 df = self.data.all 

391 date_col = df.columns[0] 

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

393 colors = _ticker_colors(tickers) 

394 

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

396 

397 fig = go.Figure() 

398 for ticker in tickers: 

399 fig.add_trace( 

400 go.Scatter( 

401 x=prices[date_col], 

402 y=prices[ticker], 

403 mode="lines", 

404 name=ticker, 

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

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

407 ) 

408 ) 

409 

410 _apply_base_layout(fig, title) 

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

412 if log_scale: 

413 fig.update_yaxes(type="log") 

414 return fig 

415 

416 def log_returns(self, title: str = "Log Returns") -> go.Figure: 

417 """Cumulative log returns over time. 

418 

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

420 growth factor — which linearises exponential growth and makes 

421 multi-asset comparisons on a common scale. 

422 

423 Args: 

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

425 

426 Returns: 

427 go.Figure: Interactive Plotly line chart. 

428 

429 """ 

430 import math 

431 

432 df = self.data.all 

433 date_col = df.columns[0] 

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

435 colors = _ticker_colors(tickers) 

436 

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

438 

439 fig = go.Figure() 

440 for ticker in tickers: 

441 fig.add_trace( 

442 go.Scatter( 

443 x=log_prices[date_col], 

444 y=log_prices[ticker], 

445 mode="lines", 

446 name=ticker, 

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

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

449 ) 

450 ) 

451 

452 _apply_base_layout(fig, title) 

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

454 return fig 

455 

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

457 """Daily returns as a bar chart. 

458 

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

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

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

462 differentiation. 

463 

464 Args: 

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

466 

467 Returns: 

468 go.Figure: Interactive Plotly bar chart. 

469 

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 single = len(tickers) == 1 

476 

477 fig = go.Figure() 

478 for ticker in tickers: 

479 values = df[ticker].to_list() 

480 if single: 

481 bar_colors = ["#2ca02c" if v is not None and v > 0 else "#d62728" for v in values] 

482 else: 

483 pos_color = colors[ticker] 

484 neg_color = _hex_to_rgba(pos_color, alpha=0.4) 

485 bar_colors = [pos_color if v is not None and v > 0 else neg_color for v in values] 

486 

487 fig.add_trace( 

488 go.Bar( 

489 x=df[date_col], 

490 y=df[ticker], 

491 name=ticker, 

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

493 opacity=0.85, 

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

495 ) 

496 ) 

497 

498 _apply_base_layout(fig, title) 

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

500 return fig 

501 

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

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

504 

505 Args: 

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

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

508 

509 Returns: 

510 go.Figure: Interactive Plotly grouped bar chart. 

511 

512 """ 

513 df = self.data.all 

514 date_col = df.columns[0] 

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

516 colors = _ticker_colors(tickers) 

517 

518 agg_exprs = ( 

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

520 if compounded 

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

522 ) 

523 yearly = ( 

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

525 ) 

526 

527 fig = go.Figure() 

528 for ticker in tickers: 

529 values = yearly[ticker].to_list() 

530 bar_colors = [ 

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

532 ] 

533 fig.add_trace( 

534 go.Bar( 

535 x=yearly["_year"], 

536 y=yearly[ticker], 

537 name=ticker, 

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

539 opacity=0.85, 

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

541 ) 

542 ) 

543 

544 _apply_base_layout(fig, title, with_range_selector=False) 

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

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

547 return fig 

548 

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

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

551 

552 Args: 

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

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

555 

556 Returns: 

557 go.Figure: Interactive Plotly bar chart. 

558 

559 """ 

560 df = self.data.all 

561 date_col = df.columns[0] 

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

563 colors = _ticker_colors(tickers) 

564 single = len(tickers) == 1 

565 

566 monthly = df.group_by_dynamic( 

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

568 ).agg( 

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

570 ) 

571 

572 fig = go.Figure() 

573 for ticker in tickers: 

574 values = monthly[ticker].to_list() 

575 if single: 

576 bar_colors = ["#2ca02c" if v is not None and v > 0 else "#d62728" for v in values] 

577 else: 

578 pos_color = colors[ticker] 

579 neg_color = _hex_to_rgba(pos_color, alpha=0.4) 

580 bar_colors = [pos_color if v is not None and v > 0 else neg_color for v in values] 

581 

582 fig.add_trace( 

583 go.Bar( 

584 x=monthly[date_col], 

585 y=monthly[ticker], 

586 name=ticker, 

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

588 opacity=0.85, 

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

590 ) 

591 ) 

592 

593 _apply_base_layout(fig, title) 

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

595 return fig 

596 

597 def monthly_heatmap( 

598 self, 

599 title: str = "Monthly Returns Heatmap", 

600 compounded: bool = True, 

601 asset: str | None = None, 

602 ) -> go.Figure: 

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

604 

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

606 indicate positive months; red cells indicate negative months. 

607 

608 Args: 

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

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

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

612 non-date column in the dataset. 

613 

614 Returns: 

615 go.Figure: Interactive Plotly heatmap. 

616 

617 """ 

618 df = self.data.all 

619 date_col = df.columns[0] 

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

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

622 

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

624 

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

626 monthly = ( 

627 df.with_columns( 

628 [ 

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

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

631 ] 

632 ) 

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

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

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

636 ) 

637 

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

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

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

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

642 

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

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

645 mi = row["_month"] - 1 

646 val = row["ret"] 

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

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

649 

650 fig = go.Figure( 

651 go.Heatmap( 

652 x=month_names, 

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

654 z=z, 

655 text=text, 

656 texttemplate="%{text}", 

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

658 zmid=0, 

659 showscale=True, 

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

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

662 ) 

663 ) 

664 

665 fig.update_layout( 

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

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

668 plot_bgcolor="white", 

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

670 ) 

671 return fig 

672 

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

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

675 

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

677 same axes so distributions can be compared visually. 

678 

679 Args: 

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

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

682 

683 Returns: 

684 go.Figure: Interactive Plotly histogram figure. 

685 

686 """ 

687 df = self.data.all 

688 date_col = df.columns[0] 

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

690 colors = _ticker_colors(tickers) 

691 

692 fig = go.Figure() 

693 for ticker in tickers: 

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

695 fig.add_trace( 

696 go.Histogram( 

697 x=values, 

698 name=ticker, 

699 nbinsx=bins, 

700 marker_color=colors[ticker], 

701 opacity=0.6, 

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

703 ) 

704 ) 

705 

706 _apply_base_layout(fig, title, with_range_selector=False) 

707 fig.update_layout(barmode="overlay") 

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

709 fig.update_yaxes(title_text="Count") 

710 return fig 

711 

712 def distribution( 

713 self, 

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

715 compounded: bool = True, 

716 ) -> go.Figure: 

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

718 

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

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

721 subplot column is produced per asset. 

722 

723 Args: 

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

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

726 

727 Returns: 

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

729 

730 """ 

731 df = self.data.all 

732 date_col = df.columns[0] 

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

734 colors = _ticker_colors(tickers) 

735 

736 periods = [ 

737 ("Daily", None), 

738 ("Weekly", "1w"), 

739 ("Monthly", "1mo"), 

740 ("Quarterly", "3mo"), 

741 ("Yearly", "1y"), 

742 ] 

743 

744 n_assets = len(tickers) 

745 fig = make_subplots( 

746 rows=1, 

747 cols=n_assets, 

748 subplot_titles=tickers, 

749 shared_yaxes=True, 

750 ) 

751 

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

753 for period_name, trunc in periods: 

754 if trunc is None: 

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

756 else: 

757 agg_expr = ( 

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

759 if compounded 

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

761 ) 

762 agg_df = ( 

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

764 .group_by("_period") 

765 .agg(agg_expr) 

766 ) 

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

768 

769 fig.add_trace( 

770 go.Box( 

771 y=values, 

772 name=period_name, 

773 marker_color=colors[ticker], 

774 showlegend=(col_idx == 1), 

775 legendgroup=period_name, 

776 boxpoints="outliers", 

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

778 ), 

779 row=1, 

780 col=col_idx, 

781 ) 

782 

783 fig.update_layout( 

784 title=title, 

785 height=500, 

786 plot_bgcolor="white", 

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

788 ) 

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

790 fig.update_xaxes(showgrid=False) 

791 return fig 

792 

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

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

795 

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

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

798 

799 Args: 

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

801 

802 Returns: 

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

804 

805 """ 

806 df = self.data.all 

807 date_col = df.columns[0] 

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

809 colors = _ticker_colors(tickers) 

810 

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

812 

813 fig = go.Figure() 

814 for ticker in tickers: 

815 price_s = prices[ticker] 

816 hwm = price_s.cum_max() 

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

818 

819 fig.add_trace( 

820 go.Scatter( 

821 x=prices[date_col], 

822 y=dd, 

823 mode="lines", 

824 fill="tozeroy", 

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

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

827 name=ticker, 

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

829 ) 

830 ) 

831 

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

833 _apply_base_layout(fig, title) 

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

835 return fig 

836 

837 def drawdowns_periods( 

838 self, 

839 n: int = 5, 

840 title: str = "Top Drawdown Periods", 

841 asset: str | None = None, 

842 ) -> go.Figure: 

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

844 

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

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

847 shown per call. 

848 

849 Args: 

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

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

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

853 

854 Returns: 

855 go.Figure: Interactive Plotly figure. 

856 

857 """ 

858 df = self.data.all 

859 date_col = df.columns[0] 

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

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

862 

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

864 price_list = price_series.to_list() 

865 dates = df[date_col].to_list() 

866 

867 drawdown_periods = _compute_drawdown_periods(price_list, n) 

868 

869 dd_colors = px.colors.qualitative.Plotly 

870 

871 fig = go.Figure() 

872 fig.add_trace( 

873 go.Scatter( 

874 x=dates, 

875 y=price_list, 

876 mode="lines", 

877 name=col, 

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

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

880 ) 

881 ) 

882 

883 for i, period in enumerate(drawdown_periods): 

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

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

886 max_dd = period["max_drawdown"] 

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

888 

889 fig.add_vrect( 

890 x0=start_date, 

891 x1=end_date, 

892 fillcolor=shade_color, 

893 line_width=0, 

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

895 annotation_position="top left", 

896 annotation_font_size=10, 

897 ) 

898 

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

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

901 return fig 

902 

903 def earnings( 

904 self, 

905 start_balance: float = 1e5, 

906 title: str = "Portfolio Earnings", 

907 compounded: bool = True, 

908 ) -> go.Figure: 

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

910 

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

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

913 

914 Args: 

915 start_balance: Starting portfolio value in currency units. 

916 Defaults to 100 000. 

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

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

919 cumulative sum. Defaults to True. 

920 

921 Returns: 

922 go.Figure: Interactive Plotly line chart. 

923 

924 """ 

925 df = self.data.all 

926 date_col = df.columns[0] 

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

928 colors = _ticker_colors(tickers) 

929 

930 if compounded: 

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

932 else: 

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

934 

935 fig = go.Figure() 

936 for ticker in tickers: 

937 fig.add_trace( 

938 go.Scatter( 

939 x=equity[date_col], 

940 y=equity[ticker], 

941 mode="lines", 

942 name=ticker, 

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

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

945 ) 

946 ) 

947 

948 _apply_base_layout(fig, title) 

949 fig.update_yaxes( 

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

951 tickprefix="$", 

952 tickformat=",.0f", 

953 ) 

954 return fig 

955 

956 def rolling_sharpe( 

957 self, 

958 rolling_period: int = 126, 

959 periods_per_year: int = 252, 

960 title: str = "Rolling Sharpe Ratio", 

961 ) -> go.Figure: 

962 """Rolling annualised Sharpe ratio over time. 

963 

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

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

966 dataset (assets and benchmark when present). 

967 

968 Args: 

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

970 periods_per_year: Annualisation factor. Defaults to 252. 

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

972 

973 Returns: 

974 go.Figure: Interactive Plotly line chart. 

975 

976 """ 

977 import math 

978 

979 df = self.data.all 

980 date_col = df.columns[0] 

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

982 colors = _ticker_colors(tickers) 

983 scale = math.sqrt(periods_per_year) 

984 

985 rolling = df.with_columns( 

986 [ 

987 ( 

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

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

990 * scale 

991 ).alias(t) 

992 for t in tickers 

993 ] 

994 ) 

995 

996 fig = go.Figure() 

997 for ticker in tickers: 

998 fig.add_trace( 

999 go.Scatter( 

1000 x=rolling[date_col], 

1001 y=rolling[ticker], 

1002 mode="lines", 

1003 name=ticker, 

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

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

1006 ) 

1007 ) 

1008 

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

1010 _apply_base_layout(fig, title) 

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

1012 return fig 

1013 

1014 def rolling_sortino( 

1015 self, 

1016 rolling_period: int = 126, 

1017 periods_per_year: int = 252, 

1018 title: str = "Rolling Sortino Ratio", 

1019 ) -> go.Figure: 

1020 """Rolling annualised Sortino ratio over time. 

1021 

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

1023 where downside deviation considers only negative returns. 

1024 

1025 Args: 

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

1027 periods_per_year: Annualisation factor. Defaults to 252. 

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

1029 

1030 Returns: 

1031 go.Figure: Interactive Plotly line chart. 

1032 

1033 """ 

1034 import math 

1035 

1036 df = self.data.all 

1037 date_col = df.columns[0] 

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

1039 colors = _ticker_colors(tickers) 

1040 scale = math.sqrt(periods_per_year) 

1041 

1042 exprs = [] 

1043 for t in tickers: 

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

1045 downside = ( 

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

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

1048 .otherwise(0.0) 

1049 .rolling_mean(window_size=rolling_period) 

1050 .sqrt() 

1051 ) 

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

1053 

1054 rolling = df.with_columns(exprs) 

1055 

1056 fig = go.Figure() 

1057 for ticker in tickers: 

1058 fig.add_trace( 

1059 go.Scatter( 

1060 x=rolling[date_col], 

1061 y=rolling[ticker], 

1062 mode="lines", 

1063 name=ticker, 

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

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

1066 ) 

1067 ) 

1068 

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

1070 _apply_base_layout(fig, title) 

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

1072 return fig 

1073 

1074 def rolling_volatility( 

1075 self, 

1076 rolling_period: int = 126, 

1077 periods_per_year: int = 252, 

1078 title: str = "Rolling Volatility", 

1079 ) -> go.Figure: 

1080 """Rolling annualised volatility over time. 

1081 

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

1083 the dataset. 

1084 

1085 Args: 

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

1087 periods_per_year: Annualisation factor. Defaults to 252. 

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

1089 

1090 Returns: 

1091 go.Figure: Interactive Plotly line chart. 

1092 

1093 """ 

1094 import math 

1095 

1096 df = self.data.all 

1097 date_col = df.columns[0] 

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

1099 colors = _ticker_colors(tickers) 

1100 scale = math.sqrt(periods_per_year) 

1101 

1102 rolling = df.with_columns( 

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

1104 ) 

1105 

1106 fig = go.Figure() 

1107 for ticker in tickers: 

1108 fig.add_trace( 

1109 go.Scatter( 

1110 x=rolling[date_col], 

1111 y=rolling[ticker], 

1112 mode="lines", 

1113 name=ticker, 

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

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

1116 ) 

1117 ) 

1118 

1119 _apply_base_layout(fig, title) 

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

1121 return fig 

1122 

1123 def rolling_beta( 

1124 self, 

1125 rolling_period: int = 126, 

1126 rolling_period2: int | None = 252, 

1127 title: str = "Rolling Beta", 

1128 ) -> go.Figure: 

1129 """Rolling beta versus the benchmark. 

1130 

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

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

1133 a trailing window. 

1134 

1135 Args: 

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

1137 rolling_period2: Optional second window size overlaid on the same 

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

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

1140 

1141 Returns: 

1142 go.Figure: Interactive Plotly line chart. 

1143 

1144 Raises: 

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

1146 

1147 """ 

1148 df = self.data.all 

1149 date_col = df.columns[0] 

1150 

1151 benchmark_df = getattr(self.data, "benchmark", None) 

1152 if benchmark_df is None: 

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

1154 

1155 bench_col = benchmark_df.columns[0] 

1156 returns_df = getattr(self.data, "returns", None) 

1157 assets = ( 

1158 list(returns_df.columns) 

1159 if returns_df is not None 

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

1161 ) 

1162 colors = _ticker_colors(assets) 

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

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

1165 

1166 fig = go.Figure() 

1167 for asset in assets: 

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

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

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

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

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

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

1174 

1175 beta_df = df.with_columns(beta_expr) 

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

1177 fig.add_trace( 

1178 go.Scatter( 

1179 x=beta_df[date_col], 

1180 y=beta_df["beta"], 

1181 mode="lines", 

1182 name=label, 

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

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

1185 ) 

1186 ) 

1187 

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

1189 _apply_base_layout(fig, title) 

1190 fig.update_yaxes(title_text="Beta") 

1191 return fig