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
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-07 14:28 +0000
1"""Plotting utilities for financial returns data."""
3from __future__ import annotations
5import dataclasses
6from typing import TYPE_CHECKING
8import plotly.express as px
9import plotly.graph_objects as go
10import polars as pl
11from plotly.subplots import make_subplots
13if TYPE_CHECKING:
14 from ._protocol import DataLike
16# ── Module-level styling helpers ──────────────────────────────────────────────
19def _hex_to_rgba(hex_color: str, alpha: float = 0.5) -> str:
20 """Convert a hex colour string to an RGBA CSS string.
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.
26 Returns:
27 An RGBA CSS string suitable for use in Plotly colour arguments.
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})"
35def _ticker_colors(tickers: list[str]) -> dict[str, str]:
36 """Map ticker names to Plotly qualitative palette colours.
38 Args:
39 tickers: Ordered list of ticker / column names.
41 Returns:
42 dict mapping each ticker to a hex colour string.
44 """
45 palette = px.colors.qualitative.Plotly
46 return {ticker: palette[i % len(palette)] for i, ticker in enumerate(tickers)}
49def _date_range_selector() -> dict:
50 """Return a standard Plotly date range-selector configuration.
52 Returns:
53 A dict suitable for ``xaxis.rangeselector``.
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 }
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.
75 Sets white background, light-grey grid, horizontal legend, and an
76 optional date range-selector on the primary x-axis.
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.
85 Returns:
86 The same figure, mutated in-place and returned for chaining.
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
108def _compute_drawdown_periods(prices: list[float], n: int) -> list[dict]:
109 """Identify the top *n* drawdown periods from a cumulative price series.
111 Args:
112 prices: Cumulative price (NAV) values as a plain Python list.
113 n: Maximum number of drawdown periods to return.
115 Returns:
116 List of dicts with keys ``start_idx``, ``end_idx``, ``valley_idx``,
117 ``max_drawdown`` (fraction ≤ 0), sorted by severity (worst first).
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])
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})
141 periods.sort(key=lambda p: p["max_drawdown"])
142 return periods[:n]
145# ── Dashboard (existing) ──────────────────────────────────────────────────────
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.
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.
155 Returns:
156 A Plotly Figure containing cumulative returns, drawdowns, and monthly returns panels.
158 """
160 def hex_to_rgba(hex_color: str, alpha: float = 0.5) -> str:
161 """Convert a hex colour string to an RGBA CSS string.
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.
167 Returns:
168 An RGBA CSS string suitable for use in Plotly colour arguments.
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})"
175 # Get the date column name from the first column of the DataFrame
176 date_col = returns.columns[0]
178 # Get the tickers (all columns except the date column)
179 tickers = [col for col in returns.columns if col != date_col]
181 # Calculate cumulative returns (prices)
182 prices = returns.with_columns([((1 + pl.col(ticker)).cum_prod()).alias(f"{ticker}_price") for ticker in tickers])
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})
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])
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 )
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 )
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()
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 )
246 fig.add_hline(y=0, line_width=1, line_color="gray", row=2, col=1)
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()
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]
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 )
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 )
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%")
303 fig.update_xaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey")
304 fig.update_yaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey")
306 if log_scale:
307 fig.update_yaxes(type="log", row=1, col=1)
309 return fig
312# ── DataPlots ──────────────────────────────────────────────────────────────────
315@dataclasses.dataclass(frozen=True)
316class DataPlots:
317 """Visualization tools for financial returns data.
319 This class provides methods for creating various plots and visualizations
320 of financial returns data, including:
322 - Returns bar charts
323 - Portfolio performance snapshots
324 - Monthly returns heatmaps
326 The class is designed to work with the _Data class and uses Plotly
327 for creating interactive visualizations.
329 Attributes:
330 data: The _Data object containing returns and benchmark data to visualize.
332 """
334 data: DataLike
336 def __repr__(self) -> str:
337 """Return a string representation of the DataPlots object."""
338 return f"DataPlots(assets={self.data.assets})"
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.
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
348 This provides a complete visual summary of portfolio performance.
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.
356 Returns:
357 go.Figure: A Plotly figure object containing the dashboard.
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
372 """
373 fig = _plot_performance_dashboard(returns=self.data.all, log_scale=log_scale)
374 return fig
376 def returns(self, title: str = "Cumulative Returns", log_scale: bool = False) -> go.Figure:
377 """Cumulative compounded returns over time.
379 Plots ``(1 + r).cumprod()`` for every column in the dataset (including
380 benchmark when present).
382 Args:
383 title: Chart title. Defaults to ``"Cumulative Returns"``.
384 log_scale: Use a logarithmic y-axis. Defaults to False.
386 Returns:
387 go.Figure: Interactive Plotly line chart.
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)
395 prices = df.with_columns([(1.0 + pl.col(t)).cum_prod().alias(t) for t in tickers])
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 )
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
416 def log_returns(self, title: str = "Log Returns") -> go.Figure:
417 """Cumulative log returns over time.
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.
423 Args:
424 title: Chart title. Defaults to ``"Log Returns"``.
426 Returns:
427 go.Figure: Interactive Plotly line chart.
429 """
430 import math
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)
437 log_prices = df.with_columns([(1.0 + pl.col(t)).cum_prod().log(math.e).alias(t) for t in tickers])
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 )
452 _apply_base_layout(fig, title)
453 fig.update_yaxes(title_text="Log Return")
454 return fig
456 def daily_returns(self, title: str = "Daily Returns") -> go.Figure:
457 """Daily returns as a bar chart.
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.
464 Args:
465 title: Chart title. Defaults to ``"Daily Returns"``.
467 Returns:
468 go.Figure: Interactive Plotly bar chart.
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
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]
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 )
498 _apply_base_layout(fig, title)
499 fig.update_yaxes(title_text="Return", tickformat=".1%")
500 return fig
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.
505 Args:
506 title: Chart title. Defaults to ``"Yearly Returns"``.
507 compounded: Compound returns within each year. Defaults to True.
509 Returns:
510 go.Figure: Interactive Plotly grouped bar chart.
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)
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 )
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 )
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
549 def monthly_returns(self, title: str = "Monthly Returns", compounded: bool = True) -> go.Figure:
550 """Monthly compounded (or summed) returns as a bar chart.
552 Args:
553 title: Chart title. Defaults to ``"Monthly Returns"``.
554 compounded: Compound returns within each month. Defaults to True.
556 Returns:
557 go.Figure: Interactive Plotly bar chart.
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
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 )
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]
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 )
593 _apply_base_layout(fig, title)
594 fig.update_yaxes(title_text="Monthly Return", tickformat=".1%")
595 return fig
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).
605 One heatmap is produced per call for a single asset. Green cells
606 indicate positive months; red cells indicate negative months.
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.
614 Returns:
615 go.Figure: Interactive Plotly heatmap.
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]
623 month_names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
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 )
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)}
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 ""
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 )
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
673 def histogram(self, title: str = "Returns Distribution", bins: int = 50) -> go.Figure:
674 """Return histogram with a kernel density overlay.
676 Each asset is shown as a semi-transparent histogram overlaid on the
677 same axes so distributions can be compared visually.
679 Args:
680 title: Chart title. Defaults to ``"Returns Distribution"``.
681 bins: Number of histogram bins. Defaults to 50.
683 Returns:
684 go.Figure: Interactive Plotly histogram figure.
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)
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 )
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
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.
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.
723 Args:
724 title: Chart title. Defaults to ``"Return Distribution by Period"``.
725 compounded: Compound returns within each period. Defaults to True.
727 Returns:
728 go.Figure: Interactive Plotly figure with one subplot per asset.
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)
736 periods = [
737 ("Daily", None),
738 ("Weekly", "1w"),
739 ("Monthly", "1mo"),
740 ("Quarterly", "3mo"),
741 ("Yearly", "1y"),
742 ]
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 )
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()
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 )
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
793 def drawdown(self, title: str = "Drawdowns") -> go.Figure:
794 """Underwater equity curve (drawdown) chart.
796 Shows the percentage decline from the running peak for every column
797 in the dataset (assets and benchmark where present).
799 Args:
800 title: Chart title. Defaults to ``"Drawdowns"``.
802 Returns:
803 go.Figure: Interactive Plotly filled-area chart.
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)
811 prices = df.with_columns([(1.0 + pl.col(t)).cum_prod().alias(t) for t in tickers])
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()
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 )
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
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.
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.
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.
854 Returns:
855 go.Figure: Interactive Plotly figure.
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]
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()
867 drawdown_periods = _compute_drawdown_periods(price_list, n)
869 dd_colors = px.colors.qualitative.Plotly
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 )
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)
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 )
899 _apply_base_layout(fig, f"{title} — {col}")
900 fig.update_yaxes(title_text="Cumulative Return", tickformat=".2f")
901 return fig
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.
911 Scales cumulative returns by *start_balance* so the y-axis reflects
912 an absolute portfolio value rather than a dimensionless growth factor.
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.
921 Returns:
922 go.Figure: Interactive Plotly line chart.
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)
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])
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 )
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
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.
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).
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"``.
973 Returns:
974 go.Figure: Interactive Plotly line chart.
976 """
977 import math
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)
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 )
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 )
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
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.
1022 Computes ``rolling_mean / rolling_downside_std * sqrt(periods_per_year)``
1023 where downside deviation considers only negative returns.
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"``.
1030 Returns:
1031 go.Figure: Interactive Plotly line chart.
1033 """
1034 import math
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)
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))
1054 rolling = df.with_columns(exprs)
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 )
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
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.
1082 Computes ``rolling_std * sqrt(periods_per_year)`` for every column in
1083 the dataset.
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"``.
1090 Returns:
1091 go.Figure: Interactive Plotly line chart.
1093 """
1094 import math
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)
1102 rolling = df.with_columns(
1103 [(pl.col(t).rolling_std(window_size=rolling_period) * scale).alias(t) for t in tickers]
1104 )
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 )
1119 _apply_base_layout(fig, title)
1120 fig.update_yaxes(title_text=f"Volatility ({rolling_period}-period rolling)", tickformat=".0%")
1121 return fig
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.
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.
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"``.
1141 Returns:
1142 go.Figure: Interactive Plotly line chart.
1144 Raises:
1145 AttributeError: If no benchmark columns are present in the data.
1147 """
1148 df = self.data.all
1149 date_col = df.columns[0]
1151 benchmark_df = getattr(self.data, "benchmark", None)
1152 if benchmark_df is None:
1153 raise AttributeError("No benchmark data available") # noqa: TRY003
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"]
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")
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 )
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