Coverage for src/jquantstats/_stats/_rolling.py: 100%

83 statements  

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

1"""Rolling-window statistical metrics for financial returns data.""" 

2 

3from __future__ import annotations 

4 

5import math 

6from typing import TYPE_CHECKING 

7 

8import numpy as np 

9import polars as pl 

10 

11from ..exceptions import NoBenchmarkError, NonPositiveWindowError 

12from ._core import _to_float 

13from ._drawdown import _DrawdownMixin 

14from ._internals import _annualization_factor 

15 

16if TYPE_CHECKING: 

17 from ..data import Data 

18 

19# ── Rolling statistics mixin ───────────────────────────────────────────────── 

20 

21 

22class _RollingStatsMixin: 

23 """Mixin class providing rolling-window financial statistics methods. 

24 

25 Separates rolling-window computations from the core point-in-time metrics 

26 in `_core`. The concrete `Stats` class inherits from both. 

27 """ 

28 

29 _data: Data 

30 all: pl.DataFrame 

31 

32 if TYPE_CHECKING: 

33 from .._protocol import DataLike 

34 

35 data: DataLike 

36 

37 def implied_volatility(self, periods: int = 252, annualize: bool = True) -> pl.DataFrame | dict[str, float]: 

38 """Calculate implied volatility using log returns. 

39 

40 Uses log returns (ln(1 + r)) instead of simple returns for mathematical 

41 correctness with continuous compounding. 

42 

43 When ``annualize=True`` (default), returns a rolling DataFrame of 

44 annualised log-return volatility: ``rolling_std(periods) * sqrt(periods)``. 

45 When ``annualize=False``, returns a scalar standard deviation per asset. 

46 

47 Args: 

48 periods (int): Rolling window size and annualisation factor. Defaults to 252. 

49 annualize (bool): Whether to annualize and return a rolling series. 

50 Defaults to True. 

51 

52 Returns: 

53 pl.DataFrame: Rolling annualised implied volatility (one column per 

54 asset) when ``annualize=True``. 

55 dict[str, float]: Scalar log-return std per asset when 

56 ``annualize=False``. 

57 

58 """ 

59 if annualize: 

60 scale = _annualization_factor(periods) 

61 return self.all.select( 

62 [pl.col(name) for name in self._data.date_col] 

63 + [ 

64 ((1.0 + pl.col(col)).log(math.e).rolling_std(window_size=periods) * scale).alias(col) 

65 for col, _ in self._data.items() 

66 ] 

67 ) 

68 return { 

69 col: _to_float((1.0 + series.cast(pl.Float64)).log(math.e).cast(pl.Float64).std()) 

70 for col, series in self._data.items() 

71 } 

72 

73 @staticmethod 

74 def _pct_rank_series(s: pl.Series) -> float: 

75 """Percentile rank of the last element among all elements (pandas average method). 

76 

77 Args: 

78 s (pl.Series): Window of price values. 

79 

80 Returns: 

81 float: Rank of s[-1] in [0, 100]. 

82 

83 """ 

84 arr = s.to_numpy() 

85 current = arr[-1] 

86 n = len(arr) 

87 below = float(np.sum(arr < current)) 

88 equal = float(np.sum(arr == current)) 

89 return (below + (equal + 1) / 2) / n * 100.0 

90 

91 def pct_rank(self, window: int = 60) -> pl.DataFrame: 

92 """Calculate the rolling percentile rank of prices within a window. 

93 

94 Converts returns to a cumulative price series, then for each period 

95 returns the percentile rank (0-100) of the current price within the 

96 trailing ``window`` prices. Matches ``qs.stats.pct_rank`` (pandas 

97 ``rank(pct=True)`` with ``method='average'``). 

98 

99 Args: 

100 window (int): Rolling window size. Defaults to 60. 

101 

102 Returns: 

103 pl.DataFrame: Date column(s) plus one percentile-rank column per asset. 

104 

105 Raises: 

106 ValueError: If window is not a positive integer. 

107 

108 """ 

109 if not isinstance(window, int) or window <= 0: 

110 raise NonPositiveWindowError("window") 

111 

112 cols: list[pl.Expr | pl.Series] = [pl.col(name) for name in self._data.date_col] 

113 for col, series in self._data.items(): 

114 prices = _DrawdownMixin.prices(series) 

115 ranked = prices.rolling_map( 

116 function=self._pct_rank_series, 

117 window_size=window, 

118 ).alias(col) 

119 cols.append(ranked) 

120 

121 return self.all.select(cols) 

122 

123 def rolling_sortino( 

124 self, 

125 rolling_period: int = 126, 

126 periods_per_year: int | float | None = None, 

127 ) -> pl.DataFrame: 

128 """Calculate the rolling Sortino ratio. 

129 

130 Args: 

131 rolling_period: Rolling window size. Defaults to 126. 

132 periods_per_year: Periods per year for annualisation. 

133 

134 Returns: 

135 pl.DataFrame: Date column(s) plus one annualised rolling Sortino 

136 column per asset. 

137 

138 Raises: 

139 ValueError: If rolling_period is not a positive integer. 

140 

141 """ 

142 if not isinstance(rolling_period, int) or rolling_period <= 0: 

143 raise NonPositiveWindowError("rolling_period") 

144 ppy = periods_per_year or self._data._periods_per_year 

145 scale = _annualization_factor(ppy) 

146 exprs: list[pl.Expr] = [] 

147 for col, _ in self._data.items(): 

148 mean_ret = pl.col(col).rolling_mean(window_size=rolling_period) 

149 negative_squared = pl.when(pl.col(col) < 0).then(pl.col(col) ** 2).otherwise(0.0) 

150 downside = negative_squared.rolling_mean(window_size=rolling_period) 

151 exprs.append(((mean_ret / downside.sqrt()) * scale).alias(col)) 

152 return self.all.select([pl.col(name) for name in self._data.date_col] + exprs) 

153 

154 def rolling_sharpe( 

155 self, 

156 rolling_period: int = 126, 

157 periods_per_year: int | float | None = None, 

158 ) -> pl.DataFrame: 

159 """Calculate the rolling Sharpe ratio. 

160 

161 Args: 

162 rolling_period: Rolling window size. Defaults to 126. 

163 periods_per_year: Periods per year for annualisation. 

164 

165 Returns: 

166 pl.DataFrame: Date column(s) plus one annualised rolling Sharpe 

167 column per asset. 

168 

169 Raises: 

170 ValueError: If rolling_period is not a positive integer. 

171 

172 """ 

173 actual_window = rolling_period 

174 actual_periods = periods_per_year or self._data._periods_per_year 

175 if not isinstance(actual_window, int) or actual_window <= 0: 

176 raise NonPositiveWindowError("rolling_period") 

177 scale = _annualization_factor(actual_periods) 

178 return self.all.select( 

179 [pl.col(name) for name in self._data.date_col] 

180 + [ 

181 ( 

182 pl.col(col).rolling_mean(window_size=actual_window) 

183 / pl.col(col).rolling_std(window_size=actual_window) 

184 * scale 

185 ).alias(col) 

186 for col, _ in self._data.items() 

187 ] 

188 ) 

189 

190 def rolling_greeks( 

191 self, 

192 rolling_period: int = 126, 

193 periods_per_year: int | float | None = None, 

194 benchmark: str | None = None, 

195 ) -> pl.DataFrame: 

196 """Rolling alpha and beta versus the benchmark. 

197 

198 Computes rolling alpha (annualised) and beta for each asset against the 

199 benchmark using a trailing window. Beta is estimated via the standard 

200 OLS formula: ``cov(asset, bench) / var(bench)``. Alpha is the 

201 per-period intercept annualised by multiplying by *periods_per_year*. 

202 

203 Args: 

204 rolling_period (int): Trailing window size. Defaults to 126. 

205 periods_per_year (int | float, optional): Periods per year used to 

206 annualise alpha. Defaults to the value inferred from the data. 

207 benchmark (str, optional): Benchmark column name. Defaults to the 

208 first benchmark column. 

209 

210 Returns: 

211 pl.DataFrame: Date column(s) followed by ``{asset}_alpha`` and 

212 ``{asset}_beta`` columns for every asset. 

213 

214 Raises: 

215 AttributeError: If no benchmark data is attached. 

216 ValueError: If *rolling_period* is not a positive integer. 

217 """ 

218 if self._data.benchmark is None: 

219 raise NoBenchmarkError 

220 if not isinstance(rolling_period, int) or rolling_period <= 0: 

221 raise NonPositiveWindowError("rolling_period") 

222 

223 ppy = periods_per_year or self._data._periods_per_year 

224 all_df = self.all 

225 bench_col = benchmark or self._data.benchmark.columns[0] 

226 

227 w = rolling_period 

228 exprs: list[pl.Expr] = [] 

229 for col, _ in self._data.items(): 

230 mean_x = pl.col(col).rolling_mean(window_size=w) 

231 mean_y = pl.col(bench_col).rolling_mean(window_size=w) 

232 mean_xy = (pl.col(col) * pl.col(bench_col)).rolling_mean(window_size=w) 

233 mean_y2 = (pl.col(bench_col) ** 2).rolling_mean(window_size=w) 

234 

235 bench_var = mean_y2 - mean_y**2 

236 bench_cov = mean_xy - mean_x * mean_y 

237 

238 # beta = cov(asset, bench) / var(bench); NaN when var(bench) = 0 

239 beta_expr = (bench_cov / bench_var).alias(f"{col}_beta") 

240 # alpha (per period) = mean(asset) - beta * mean(bench), annualised 

241 alpha_expr = ((mean_x - (bench_cov / bench_var) * mean_y) * ppy).alias(f"{col}_alpha") 

242 

243 exprs.extend([beta_expr, alpha_expr]) 

244 

245 return all_df.select([pl.col(name) for name in self._data.date_col] + exprs) 

246 

247 def rolling_volatility( 

248 self, 

249 rolling_period: int = 126, 

250 periods_per_year: int | float | None = None, 

251 annualize: bool = True, 

252 ) -> pl.DataFrame: 

253 """Calculate the rolling volatility of returns. 

254 

255 Args: 

256 rolling_period: Rolling window size. Defaults to 126. 

257 periods_per_year: Periods per year for annualisation. 

258 annualize: Multiply by ``sqrt(periods_per_year)`` when True (default). 

259 

260 Returns: 

261 pl.DataFrame: Date column(s) plus one rolling volatility column 

262 per asset. 

263 

264 Raises: 

265 ValueError: If rolling_period is not a positive integer. 

266 TypeError: If periods_per_year is not numeric. 

267 

268 """ 

269 actual_window = rolling_period 

270 actual_periods = periods_per_year or self._data._periods_per_year 

271 if not isinstance(actual_window, int) or actual_window <= 0: 

272 raise NonPositiveWindowError("rolling_period") 

273 if not isinstance(actual_periods, int | float): 

274 raise TypeError 

275 factor = _annualization_factor(actual_periods) if annualize else 1.0 

276 return self.all.select( 

277 [pl.col(name) for name in self._data.date_col] 

278 + [ 

279 (pl.col(col).rolling_std(window_size=actual_window) * factor).alias(col) 

280 for col, _ in self._data.items() 

281 ] 

282 )