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

89 statements  

« prev     ^ index     » next       coverage.py v7.15.3, created at 2026-08-06 04:52 +0000

1"""Rolling risk/return metric line charts (Sharpe, Sortino, volatility, beta).""" 

2 

3from __future__ import annotations 

4 

5import math 

6from typing import TYPE_CHECKING 

7 

8import plotly.graph_objects as go 

9import polars as pl 

10 

11from jquantstats.exceptions import NoBenchmarkError 

12 

13from ._styling import _apply_base_layout, _apply_figsize, _ticker_colors 

14 

15if TYPE_CHECKING: 

16 from jquantstats._protocol import DataLike 

17 

18 

19def _rolling_beta_expr(asset: str, bench_col: str, window: int) -> pl.Expr: 

20 """Trailing-window OLS beta of *asset* against *bench_col*. 

21 

22 Beta is ``cov(asset, bench) / var(bench)``, expanded into rolling means so 

23 the whole estimate is a single Polars expression. 

24 

25 Args: 

26 asset: Asset column name. 

27 bench_col: Benchmark column name. 

28 window: Trailing window size in rows. 

29 

30 Returns: 

31 An expression aliased ``beta``. 

32 """ 

33 mean_x = pl.col(asset).rolling_mean(window_size=window) 

34 mean_y = pl.col(bench_col).rolling_mean(window_size=window) 

35 mean_xy = (pl.col(asset) * pl.col(bench_col)).rolling_mean(window_size=window) 

36 mean_y2 = (pl.col(bench_col) ** 2).rolling_mean(window_size=window) 

37 return ((mean_xy - mean_x * mean_y) / (mean_y2 - mean_y**2)).alias("beta") 

38 

39 

40class _RollingPlotsMixin: 

41 """Rolling-window metric plots for :class:`DataPlots`.""" 

42 

43 __slots__ = () 

44 

45 _data: DataLike 

46 

47 def _beta_assets(self, df: pl.DataFrame, date_col: str, bench_col: str) -> list[str]: 

48 """Asset columns to plot beta for. 

49 

50 Prefers the explicit ``returns`` frame when the data exposes one, and 

51 otherwise falls back to every column of *df* that is neither the date 

52 nor the benchmark. 

53 

54 Args: 

55 df: The combined index/returns/benchmark frame. 

56 date_col: Name of the date column. 

57 bench_col: Name of the benchmark column. 

58 

59 Returns: 

60 The asset column names. 

61 """ 

62 returns_df = getattr(self._data, "returns", None) 

63 if returns_df is not None: 

64 return list(returns_df.columns) 

65 return [c for c in df.columns if c != date_col and c != bench_col] 

66 

67 def rolling_sharpe( 

68 self, 

69 rolling_period: int = 126, 

70 periods_per_year: int = 252, 

71 title: str = "Rolling Sharpe Ratio", 

72 ) -> go.Figure: 

73 """Rolling annualised Sharpe ratio over time. 

74 

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

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

77 dataset (assets and benchmark when present). 

78 

79 Args: 

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

81 periods_per_year: Annualisation factor. Defaults to 252. 

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

83 

84 Returns: 

85 go.Figure: Interactive Plotly line chart. 

86 

87 """ 

88 df = self._data.all 

89 date_col = df.columns[0] 

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

91 colors = _ticker_colors(tickers) 

92 scale = math.sqrt(periods_per_year) 

93 

94 rolling = df.with_columns( 

95 [ 

96 ( 

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

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

99 * scale 

100 ).alias(t) 

101 for t in tickers 

102 ] 

103 ) 

104 

105 fig = go.Figure() 

106 for ticker in tickers: 

107 fig.add_trace( 

108 go.Scatter( 

109 x=rolling[date_col], 

110 y=rolling[ticker], 

111 mode="lines", 

112 name=ticker, 

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

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

115 ) 

116 ) 

117 

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

119 _apply_base_layout(fig, title) 

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

121 return fig 

122 

123 def rolling_sortino( 

124 self, 

125 rolling_period: int = 126, 

126 periods_per_year: int = 252, 

127 title: str = "Rolling Sortino Ratio", 

128 ) -> go.Figure: 

129 """Rolling annualised Sortino ratio over time. 

130 

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

132 where downside deviation considers only negative returns. 

133 

134 Args: 

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

136 periods_per_year: Annualisation factor. Defaults to 252. 

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

138 

139 Returns: 

140 go.Figure: Interactive Plotly line chart. 

141 

142 """ 

143 df = self._data.all 

144 date_col = df.columns[0] 

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

146 colors = _ticker_colors(tickers) 

147 scale = math.sqrt(periods_per_year) 

148 

149 exprs = [] 

150 for t in tickers: 

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

152 downside = ( 

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

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

155 .otherwise(0.0) 

156 .rolling_mean(window_size=rolling_period) 

157 .sqrt() 

158 ) 

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

160 

161 rolling = df.with_columns(exprs) 

162 

163 fig = go.Figure() 

164 for ticker in tickers: 

165 fig.add_trace( 

166 go.Scatter( 

167 x=rolling[date_col], 

168 y=rolling[ticker], 

169 mode="lines", 

170 name=ticker, 

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

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

173 ) 

174 ) 

175 

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

177 _apply_base_layout(fig, title) 

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

179 return fig 

180 

181 def rolling_volatility( 

182 self, 

183 rolling_period: int = 126, 

184 periods_per_year: int = 252, 

185 title: str = "Rolling Volatility", 

186 ) -> go.Figure: 

187 """Rolling annualised volatility over time. 

188 

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

190 the dataset. 

191 

192 Args: 

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

194 periods_per_year: Annualisation factor. Defaults to 252. 

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

196 

197 Returns: 

198 go.Figure: Interactive Plotly line chart. 

199 

200 """ 

201 df = self._data.all 

202 date_col = df.columns[0] 

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

204 colors = _ticker_colors(tickers) 

205 scale = math.sqrt(periods_per_year) 

206 

207 rolling = df.with_columns( 

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

209 ) 

210 

211 fig = go.Figure() 

212 for ticker in tickers: 

213 fig.add_trace( 

214 go.Scatter( 

215 x=rolling[date_col], 

216 y=rolling[ticker], 

217 mode="lines", 

218 name=ticker, 

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

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

221 ) 

222 ) 

223 

224 _apply_base_layout(fig, title) 

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

226 return fig 

227 

228 def rolling_beta( 

229 self, 

230 rolling_period: int = 126, 

231 rolling_period2: int | None = 252, 

232 title: str = "Rolling Beta", 

233 figsize: tuple[int, int] | None = None, 

234 ) -> go.Figure: 

235 """Rolling beta versus the benchmark. 

236 

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

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

239 a trailing window. 

240 

241 Args: 

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

243 rolling_period2: Optional second window size overlaid on the same 

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

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

246 figsize: Optional ``(width, height)`` in pixels. 

247 

248 Returns: 

249 go.Figure: Interactive Plotly line chart. 

250 

251 Raises: 

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

253 

254 """ 

255 df = self._data.all 

256 date_col = df.columns[0] 

257 

258 benchmark_df = getattr(self._data, "benchmark", None) 

259 if benchmark_df is None: 

260 raise NoBenchmarkError 

261 

262 bench_col = benchmark_df.columns[0] 

263 assets = self._beta_assets(df, date_col, bench_col) 

264 colors = _ticker_colors(assets) 

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

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

267 

268 fig = go.Figure() 

269 for asset in assets: 

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

271 beta_df = df.with_columns(_rolling_beta_expr(asset, bench_col, w)) 

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

273 fig.add_trace( 

274 go.Scatter( 

275 x=beta_df[date_col], 

276 y=beta_df["beta"], 

277 mode="lines", 

278 name=label, 

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

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

281 ) 

282 ) 

283 

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

285 _apply_base_layout(fig, title) 

286 _apply_figsize(fig, figsize) 

287 fig.update_yaxes(title_text="Beta") 

288 return fig