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

1"""Rolling-window and per-year risk charts for a portfolio. 

2 

3Split out of the former single-module `_plots/_portfolio.py`; composed into 

4:class:`PortfolioPlots` by `_core.py`. 

5""" 

6 

7from __future__ import annotations 

8 

9from typing import TYPE_CHECKING 

10 

11import plotly.graph_objects as go 

12import polars as pl 

13 

14from .._data._styling import _apply_base_layout 

15 

16if TYPE_CHECKING: 

17 from .._protocol import PortfolioLike 

18 

19 

20class _RollingPortfolioPlotsMixin: 

21 """Rolling-window and annual risk charts for :class:`PortfolioPlots`.""" 

22 

23 __slots__ = () 

24 

25 _portfolio: PortfolioLike 

26 

27 @staticmethod 

28 def _validate_window(window: int) -> None: 

29 """Reject a non-positive or non-integer rolling window. 

30 

31 Args: 

32 window: The candidate rolling-window size. 

33 

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 

39 

40 @staticmethod 

41 def _line_per_column(rolling: pl.DataFrame) -> go.Figure: 

42 """Render one line trace per non-date column of *rolling*. 

43 

44 Shared by `rolling_sharpe_plot` and `rolling_volatility_plot`, which 

45 differ only in the metric they fetch and the labels they apply. 

46 

47 Args: 

48 rolling: A frame with an optional ``date`` column and one column 

49 per asset. 

50 

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 

69 

70 def rolling_sharpe_plot(self, window: int = 63) -> go.Figure: 

71 """Plot rolling annualised Sharpe ratio over time. 

72 

73 Computes the rolling Sharpe for each asset column using the given 

74 window and renders one line per asset. 

75 

76 Args: 

77 window: Rolling-window size in periods. Defaults to 63. 

78 

79 Returns: 

80 A Plotly Figure with one trace per asset. 

81 

82 Raises: 

83 ValueError: If ``window`` is not a positive integer. 

84 """ 

85 self._validate_window(window) 

86 

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") 

89 

90 _apply_base_layout(fig, f"Rolling Sharpe Ratio ({window}-period window)") 

91 fig.update_yaxes(title_text="Sharpe ratio") 

92 return fig 

93 

94 def rolling_volatility_plot(self, window: int = 63) -> go.Figure: 

95 """Plot rolling annualised volatility over time. 

96 

97 Computes the rolling volatility for each asset column using the given 

98 window and renders one line per asset. 

99 

100 Args: 

101 window: Rolling-window size in periods. Defaults to 63. 

102 

103 Returns: 

104 A Plotly Figure with one trace per asset. 

105 

106 Raises: 

107 ValueError: If ``window`` is not a positive integer. 

108 """ 

109 self._validate_window(window) 

110 

111 fig = self._line_per_column(self._portfolio.stats.rolling_volatility(rolling_period=window)) 

112 

113 _apply_base_layout(fig, f"Rolling Volatility ({window}-period window)") 

114 fig.update_yaxes(title_text="Annualised volatility") 

115 return fig 

116 

117 def annual_sharpe_plot(self) -> go.Figure: 

118 """Plot annualised Sharpe ratio broken down by calendar year. 

119 

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. 

123 

124 Returns: 

125 A Plotly Figure with one bar group per asset. 

126 """ 

127 breakdown = self._portfolio.stats.annual_breakdown() 

128 

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")] 

132 

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 ) 

142 

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

144 

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