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
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-23 06:13 +0000
1"""Plotting utilities for financial returns data."""
3from __future__ import annotations
5import math
6from typing import TYPE_CHECKING, Any
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
14if TYPE_CHECKING:
15 from jquantstats._protocol import DataLike
17# ── Module-level styling helpers ──────────────────────────────────────────────
20def _hex_to_rgba(hex_color: str, alpha: float = 0.5) -> str:
21 """Convert a hex colour string to an RGBA CSS string.
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.
27 Returns:
28 An RGBA CSS string suitable for use in Plotly colour arguments.
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})"
36def _ticker_colors(tickers: list[str]) -> dict[str, str]:
37 """Map ticker names to Plotly qualitative palette colours.
39 Args:
40 tickers: Ordered list of ticker / column names.
42 Returns:
43 dict mapping each ticker to a hex colour string.
45 """
46 palette = px.colors.qualitative.Plotly
47 return {ticker: palette[i % len(palette)] for i, ticker in enumerate(tickers)}
50def _date_range_selector() -> dict[str, Any]:
51 """Return a standard Plotly date range-selector configuration.
53 Returns:
54 A dict suitable for ``xaxis.rangeselector``.
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 }
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.
76 Sets white background, light-grey grid, horizontal legend, and an
77 optional date range-selector on the primary x-axis.
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.
86 Returns:
87 The same figure, mutated in-place and returned for chaining.
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
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
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]
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.
127 Args:
128 prices: Cumulative price (NAV) values as a plain Python list.
129 n: Maximum number of drawdown periods to return.
131 Returns:
132 List of dicts with keys ``start_idx``, ``end_idx``, ``valley_idx``,
133 ``max_drawdown`` (fraction ≤ 0), sorted by severity (worst first).
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])
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})
157 periods.sort(key=lambda p: p["max_drawdown"])
158 return periods[:n]
161# ── Dashboard (existing) ──────────────────────────────────────────────────────
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.
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.
171 Returns:
172 A Plotly Figure containing cumulative returns, drawdowns, and monthly returns panels.
174 """
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 colors = _ticker_colors(tickers)
185 colors.update({f"{ticker}_light": _hex_to_rgba(colors[ticker]) for ticker in tickers})
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])
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 )
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 )
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()
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 )
245 fig.add_hline(y=0, line_width=1, line_color="gray", row=2, col=1)
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()
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]
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 )
276 _apply_base_layout(fig, f"{' vs '.join(tickers)} Performance Dashboard", height=1200)
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%")
282 fig.update_xaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey")
283 fig.update_yaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey")
285 if log_scale:
286 fig.update_yaxes(type="log", row=1, col=1)
288 return fig
291# ── DataPlots ──────────────────────────────────────────────────────────────────
294class DataPlots:
295 """Visualization tools for financial returns data.
297 This class provides methods for creating various plots and visualizations
298 of financial returns data, including:
300 - Returns bar charts
301 - Portfolio performance snapshots
302 - Monthly returns heatmaps
304 The class is designed to work with the _Data class and uses Plotly
305 for creating interactive visualizations.
306 """
308 __slots__ = ("_data",)
310 def __init__(self, data: DataLike) -> None:
311 self._data = data
313 @property
314 def assets(self) -> list[str]:
315 """Asset column names from the underlying data."""
316 return self._data.assets
318 def __repr__(self) -> str:
319 """Return a string representation of the DataPlots object."""
320 return f"DataPlots(assets={self._data.assets})"
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.
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
330 This provides a complete visual summary of portfolio performance.
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.
338 Returns:
339 go.Figure: A Plotly figure object containing the dashboard.
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
354 """
355 fig = _plot_performance_dashboard(returns=self._data.all, log_scale=log_scale)
356 return fig
358 def returns(self, title: str = "Cumulative Returns", log_scale: bool = False) -> go.Figure:
359 """Cumulative compounded returns over time.
361 Plots ``(1 + r).cumprod()`` for every column in the dataset (including
362 benchmark when present).
364 Args:
365 title: Chart title. Defaults to ``"Cumulative Returns"``.
366 log_scale: Use a logarithmic y-axis. Defaults to False.
368 Returns:
369 go.Figure: Interactive Plotly line chart.
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)
377 prices = df.with_columns([(1.0 + pl.col(t)).cum_prod().alias(t) for t in tickers])
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 )
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
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.
401 Args:
402 title: Chart title. Defaults to ``"Comparison vs Benchmark"``.
403 figsize: Optional ``(width, height)`` in pixels.
405 Returns:
406 go.Figure: Interactive Plotly line chart.
408 Raises:
409 AttributeError: If no benchmark data is available.
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
416 df = self._data.all
417 date_col = df.columns[0]
418 assets = list(self._data.returns.columns)
419 benchmarks = list(benchmark_df.columns)
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])
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 )
449 _apply_base_layout(fig, title)
450 _apply_figsize(fig, figsize)
451 fig.update_yaxes(title_text="Cumulative Return", tickformat=".2f")
452 return fig
454 def log_returns(self, title: str = "Log Returns", figsize: tuple[int, int] | None = None) -> go.Figure:
455 """Cumulative log returns over time.
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.
461 Args:
462 title: Chart title. Defaults to ``"Log Returns"``.
463 figsize: Optional ``(width, height)`` in pixels.
465 Returns:
466 go.Figure: Interactive Plotly line chart.
468 """
469 import math
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)
476 log_prices = df.with_columns([(1.0 + pl.col(t)).cum_prod().log(math.e).alias(t) for t in tickers])
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 )
491 _apply_base_layout(fig, title)
492 _apply_figsize(fig, figsize)
493 fig.update_yaxes(title_text="Log Return")
494 return fig
496 def daily_returns(self, title: str = "Daily Returns") -> go.Figure:
497 """Daily returns as a bar chart.
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.
504 Args:
505 title: Chart title. Defaults to ``"Daily Returns"``.
507 Returns:
508 go.Figure: Interactive Plotly bar chart.
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
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)
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 )
533 _apply_base_layout(fig, title)
534 fig.update_yaxes(title_text="Return", tickformat=".1%")
535 return fig
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.
540 Args:
541 title: Chart title. Defaults to ``"Yearly Returns"``.
542 compounded: Compound returns within each year. Defaults to True.
544 Returns:
545 go.Figure: Interactive Plotly grouped bar chart.
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)
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 )
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 )
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
584 def monthly_returns(self, title: str = "Monthly Returns", compounded: bool = True) -> go.Figure:
585 """Monthly compounded (or summed) returns as a bar chart.
587 Args:
588 title: Chart title. Defaults to ``"Monthly Returns"``.
589 compounded: Compound returns within each month. Defaults to True.
591 Returns:
592 go.Figure: Interactive Plotly bar chart.
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
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 )
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)
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 )
623 _apply_base_layout(fig, title)
624 fig.update_yaxes(title_text="Monthly Return", tickformat=".1%")
625 return fig
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).
635 One heatmap is produced per call for a single asset. Green cells
636 indicate positive months; red cells indicate negative months.
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.
644 Returns:
645 go.Figure: Interactive Plotly heatmap.
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]
653 month_names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
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 )
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)}
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 ""
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 )
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
703 def histogram(self, title: str = "Returns Distribution", bins: int = 50) -> go.Figure:
704 """Return histogram with a kernel density overlay.
706 Each asset is shown as a semi-transparent histogram overlaid on the
707 same axes so distributions can be compared visually.
709 Args:
710 title: Chart title. Defaults to ``"Returns Distribution"``.
711 bins: Number of histogram bins. Defaults to 50.
713 Returns:
714 go.Figure: Interactive Plotly histogram figure.
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)
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 )
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
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.
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.
753 Args:
754 title: Chart title. Defaults to ``"Return Distribution by Period"``.
755 compounded: Compound returns within each period. Defaults to True.
757 Returns:
758 go.Figure: Interactive Plotly figure with one subplot per asset.
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)
766 periods = [
767 ("Daily", None),
768 ("Weekly", "1w"),
769 ("Monthly", "1mo"),
770 ("Quarterly", "3mo"),
771 ("Yearly", "1y"),
772 ]
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 )
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()
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 )
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
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.
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.
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.
842 Returns:
843 go.Figure: Interactive Plotly fan chart.
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
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)
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)
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 )
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 )
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 )
901 _apply_base_layout(fig, title)
902 fig.update_yaxes(title_text="Cumulative Return", tickformat=".2f")
903 _apply_figsize(fig, figsize)
904 return fig
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.
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.
920 Supported metrics:
921 - ``"sharpe"`` (annualized, 252 periods/year)
922 - ``"drawdown"`` (maximum drawdown, negative value)
923 - ``"cagr"`` (annualized geometric return)
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.
933 Returns:
934 go.Figure: Interactive Plotly histogram figure.
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
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
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
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)
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
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)
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 )
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
1003 def drawdown(self, title: str = "Drawdowns") -> go.Figure:
1004 """Underwater equity curve (drawdown) chart.
1006 Shows the percentage decline from the running peak for every column
1007 in the dataset (assets and benchmark where present).
1009 Args:
1010 title: Chart title. Defaults to ``"Drawdowns"``.
1012 Returns:
1013 go.Figure: Interactive Plotly filled-area chart.
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)
1021 prices = df.with_columns([(1.0 + pl.col(t)).cum_prod().alias(t) for t in tickers])
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()
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 )
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
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.
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.
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.
1064 Returns:
1065 go.Figure: Interactive Plotly figure.
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]
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()
1077 drawdown_periods = _compute_drawdown_periods(price_list, n)
1079 dd_colors = px.colors.qualitative.Plotly
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 )
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)
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 )
1109 _apply_base_layout(fig, f"{title} — {col}")
1110 fig.update_yaxes(title_text="Cumulative Return", tickformat=".2f")
1111 return fig
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.
1121 Scales cumulative returns by *start_balance* so the y-axis reflects
1122 an absolute portfolio value rather than a dimensionless growth factor.
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.
1131 Returns:
1132 go.Figure: Interactive Plotly line chart.
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)
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])
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 )
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
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.
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).
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"``.
1183 Returns:
1184 go.Figure: Interactive Plotly line chart.
1186 """
1187 import math
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)
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 )
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 )
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
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.
1232 Computes ``rolling_mean / rolling_downside_std * sqrt(periods_per_year)``
1233 where downside deviation considers only negative returns.
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"``.
1240 Returns:
1241 go.Figure: Interactive Plotly line chart.
1243 """
1244 import math
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)
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))
1264 rolling = df.with_columns(exprs)
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 )
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
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.
1292 Computes ``rolling_std * sqrt(periods_per_year)`` for every column in
1293 the dataset.
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"``.
1300 Returns:
1301 go.Figure: Interactive Plotly line chart.
1303 """
1304 import math
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)
1312 rolling = df.with_columns(
1313 [(pl.col(t).rolling_std(window_size=rolling_period) * scale).alias(t) for t in tickers]
1314 )
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 )
1329 _apply_base_layout(fig, title)
1330 fig.update_yaxes(title_text=f"Volatility ({rolling_period}-period rolling)", tickformat=".0%")
1331 return fig
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.
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.
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.
1353 Returns:
1354 go.Figure: Interactive Plotly line chart.
1356 Raises:
1357 AttributeError: If no benchmark columns are present in the data.
1359 """
1360 df = self._data.all
1361 date_col = df.columns[0]
1363 benchmark_df = getattr(self._data, "benchmark", None)
1364 if benchmark_df is None:
1365 raise AttributeError("No benchmark data available") # noqa: TRY003
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"]
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")
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 )
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