Coverage for src/jquantstats/_plots/_portfolio/_rolling.py: 100%
47 statements
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-06 04:52 +0000
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-06 04:52 +0000
1"""Rolling-window and per-year risk charts for a portfolio.
3Split out of the former single-module `_plots/_portfolio.py`; composed into
4:class:`PortfolioPlots` by `_core.py`.
5"""
7from __future__ import annotations
9from typing import TYPE_CHECKING
11import plotly.graph_objects as go
12import polars as pl
14from .._data._styling import _apply_base_layout
16if TYPE_CHECKING:
17 from .._protocol import PortfolioLike
20class _RollingPortfolioPlotsMixin:
21 """Rolling-window and annual risk charts for :class:`PortfolioPlots`."""
23 __slots__ = ()
25 _portfolio: PortfolioLike
27 @staticmethod
28 def _validate_window(window: int) -> None:
29 """Reject a non-positive or non-integer rolling window.
31 Args:
32 window: The candidate rolling-window size.
34 Raises:
35 ValueError: If ``window`` is not a positive integer.
36 """
37 if not isinstance(window, int) or window <= 0:
38 raise ValueError(f"window must be a positive integer, got {window!r}") # noqa: TRY003
40 @staticmethod
41 def _line_per_column(rolling: pl.DataFrame) -> go.Figure:
42 """Render one line trace per non-date column of *rolling*.
44 Shared by `rolling_sharpe_plot` and `rolling_volatility_plot`, which
45 differ only in the metric they fetch and the labels they apply.
47 Args:
48 rolling: A frame with an optional ``date`` column and one column
49 per asset.
51 Returns:
52 A Figure carrying the traces, with no layout applied yet.
53 """
54 fig = go.Figure()
55 date_col = rolling["date"] if "date" in rolling.columns else None
56 for col in rolling.columns:
57 if col == "date":
58 continue
59 fig.add_trace(
60 go.Scatter(
61 x=date_col,
62 y=rolling[col],
63 mode="lines",
64 name=col,
65 line={"width": 1},
66 )
67 )
68 return fig
70 def rolling_sharpe_plot(self, window: int = 63) -> go.Figure:
71 """Plot rolling annualised Sharpe ratio over time.
73 Computes the rolling Sharpe for each asset column using the given
74 window and renders one line per asset.
76 Args:
77 window: Rolling-window size in periods. Defaults to 63.
79 Returns:
80 A Plotly Figure with one trace per asset.
82 Raises:
83 ValueError: If ``window`` is not a positive integer.
84 """
85 self._validate_window(window)
87 fig = self._line_per_column(self._portfolio.stats.rolling_sharpe(rolling_period=window))
88 fig.add_hline(y=0, line_width=1, line_dash="dash", line_color="gray")
90 _apply_base_layout(fig, f"Rolling Sharpe Ratio ({window}-period window)")
91 fig.update_yaxes(title_text="Sharpe ratio")
92 return fig
94 def rolling_volatility_plot(self, window: int = 63) -> go.Figure:
95 """Plot rolling annualised volatility over time.
97 Computes the rolling volatility for each asset column using the given
98 window and renders one line per asset.
100 Args:
101 window: Rolling-window size in periods. Defaults to 63.
103 Returns:
104 A Plotly Figure with one trace per asset.
106 Raises:
107 ValueError: If ``window`` is not a positive integer.
108 """
109 self._validate_window(window)
111 fig = self._line_per_column(self._portfolio.stats.rolling_volatility(rolling_period=window))
113 _apply_base_layout(fig, f"Rolling Volatility ({window}-period window)")
114 fig.update_yaxes(title_text="Annualised volatility")
115 return fig
117 def annual_sharpe_plot(self) -> go.Figure:
118 """Plot annualised Sharpe ratio broken down by calendar year.
120 Computes the Sharpe ratio for each calendar year from the portfolio
121 returns and renders a grouped bar chart with one bar per year per
122 asset.
124 Returns:
125 A Plotly Figure with one bar group per asset.
126 """
127 breakdown = self._portfolio.stats.annual_breakdown()
129 # Extract the sharpe row for each year
130 sharpe_rows = breakdown.filter(pl.col("metric") == "sharpe")
131 asset_cols = [c for c in sharpe_rows.columns if c not in ("year", "metric")]
133 fig = go.Figure()
134 for asset in asset_cols:
135 fig.add_trace(
136 go.Bar(
137 x=sharpe_rows["year"],
138 y=sharpe_rows[asset],
139 name=asset,
140 )
141 )
143 fig.add_hline(y=0, line_width=1, line_color="gray")
145 fig.update_layout(
146 title="Annual Sharpe Ratio by Year",
147 barmode="group",
148 hovermode="x unified",
149 plot_bgcolor="white",
150 legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "right", "x": 1},
151 )
152 fig.update_yaxes(title_text="Sharpe ratio")
153 fig.update_xaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey", title_text="Year")
154 fig.update_yaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey")
155 return fig