Coverage for src/jquantstats/_reports/_metrics.py: 100%

153 statements  

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

1"""Metric-row builders for the performance metrics table. 

2 

3These helpers assemble the ordered ``(label, values)`` rows that 

4``Reports.metrics`` turns into a tidy DataFrame, and the period-window 

5calculations (CAGR/compounding since a cutoff) they rely on. 

6""" 

7 

8from __future__ import annotations 

9 

10import datetime 

11import math 

12from typing import Any, cast 

13 

14import polars as pl 

15 

16from ._formatting import _is_finite 

17 

18# ── Private helpers ─────────────────────────────────────────────────────────── 

19 

20 

21def _safe(fn: Any, *args: Any, **kwargs: Any) -> dict[str, float]: 

22 """Call ``fn(*args, **kwargs)`` and return ``{}`` on any exception.""" 

23 try: 

24 result: dict[str, float] = fn(*args, **kwargs) 

25 except Exception: 

26 return {} 

27 return result 

28 

29 

30def _pct(d: dict[str, float]) -> dict[str, float]: 

31 """Multiply every finite value in *d* by 100.""" 

32 return {k: v * 100.0 if _is_finite(v) else float("nan") for k, v in d.items()} 

33 

34 

35# ── Period-return helpers ───────────────────────────────────────────────────── 

36 

37 

38def _comp_since(all_df: pl.DataFrame, date_col: str, asset_cols: list[str], cutoff: Any) -> dict[str, float]: 

39 """Compounded return for each asset from *cutoff* to the last date.""" 

40 filtered = all_df.filter(pl.col(date_col) >= cutoff) 

41 result: dict[str, float] = {} 

42 for col in asset_cols: 

43 s = filtered[col].drop_nulls().cast(pl.Float64) 

44 result[col] = float((1.0 + s).product()) - 1.0 if len(s) > 0 else float("nan") 

45 return result 

46 

47 

48def _cagr_since( 

49 all_df: pl.DataFrame, 

50 date_col: str, 

51 asset_cols: list[str], 

52 cutoff: Any, 

53 periods_per_year: float, 

54) -> dict[str, float]: 

55 """Annualised CAGR for each asset from *cutoff* to the last date.""" 

56 filtered = all_df.filter(pl.col(date_col) >= cutoff) 

57 result: dict[str, float] = {} 

58 for col in asset_cols: 

59 s = filtered[col].drop_nulls().cast(pl.Float64) 

60 n = len(s) 

61 if n < 2: 

62 result[col] = float("nan") 

63 continue 

64 total = float((1.0 + s).product()) - 1.0 

65 years = n / periods_per_year 

66 result[col] = float(abs(1.0 + total) ** (1.0 / years) - 1.0) 

67 return result 

68 

69 

70# ── Metrics-row helpers ─────────────────────────────────────────────────────── 

71 

72 

73def _cutoff_months(today: Any, n: int) -> Any: 

74 """Return the date *n* calendar months before *today*. 

75 

76 Args: 

77 today: Reference date (must support ``.year``, ``.month``, ``.day``). 

78 n: Number of calendar months to subtract. 

79 

80 Returns: 

81 A `datetime.date` exactly *n* months before *today*. 

82 

83 """ 

84 import calendar 

85 from datetime import date as _date 

86 

87 y = today.year 

88 m = today.month 

89 for _ in range(n): 

90 m -= 1 

91 if m == 0: 

92 m = 12 

93 y -= 1 

94 d = min(today.day, calendar.monthrange(y, m)[1]) 

95 return _date(y, m, d) 

96 

97 

98def _add_overview_rows(rows: list[tuple[str, dict[str, Any]]], s: Any, ppy: float) -> None: 

99 """Append overview metric rows to *rows*. 

100 

101 Args: 

102 rows: Accumulator list of ``(label, values)`` tuples. 

103 s: Stats object providing the metric methods. 

104 ppy: Periods per year for annualisation. 

105 

106 """ 

107 rows.append(("Time in Market", _pct(_safe(s.exposure)))) 

108 rows.append(("Cumulative Return", _pct(_safe(s.comp)))) 

109 rows.append(("CAGR", _pct(_safe(s.cagr, periods=ppy)))) 

110 

111 

112def _add_risk_adjusted_rows(rows: list[tuple[str, dict[str, Any]]], s: Any, ppy: float) -> None: 

113 """Append risk-adjusted ratio rows to *rows*. 

114 

115 Args: 

116 rows: Accumulator list of ``(label, values)`` tuples. 

117 s: Stats object providing the metric methods. 

118 ppy: Periods per year for annualisation. 

119 

120 """ 

121 rows.append(("Sharpe", _safe(s.sharpe, periods=ppy))) 

122 rows.append(("Prob. Sharpe Ratio", _pct(_safe(s.probabilistic_sharpe_ratio)))) 

123 rows.append(("Sortino", _safe(s.sortino, periods=ppy))) 

124 rows.append(("Sortino / √2", _safe(s.adjusted_sortino, periods=ppy))) 

125 rows.append(("Omega", _safe(s.omega, periods=ppy))) 

126 

127 

128def _add_drawdown_rows(rows: list[tuple[str, dict[str, Any]]], s: Any) -> None: 

129 """Append drawdown metric rows to *rows*. 

130 

131 Args: 

132 rows: Accumulator list of ``(label, values)`` tuples. 

133 s: Stats object providing the metric methods. 

134 

135 """ 

136 rows.append(("Max Drawdown", _pct(_safe(s.max_drawdown)))) 

137 rows.append(("Max DD Duration", _safe(s.max_drawdown_duration))) 

138 rows.append(("Avg Drawdown", _pct(_safe(s.avg_drawdown)))) 

139 rows.append(("Recovery Factor", _safe(s.recovery_factor))) 

140 rows.append(("Ulcer Index", _safe(s.ulcer_index))) 

141 rows.append(("Serenity Index", _safe(s.serenity_index))) 

142 

143 

144def _add_trading_rows(rows: list[tuple[str, dict[str, Any]]], s: Any) -> None: 

145 """Append trading metric rows to *rows*. 

146 

147 Args: 

148 rows: Accumulator list of ``(label, values)`` tuples. 

149 s: Stats object providing the metric methods. 

150 

151 """ 

152 rows.append(("Gain/Pain Ratio", _safe(s.gain_to_pain_ratio))) 

153 rows.append(("Gain/Pain (1M)", _safe(s.gain_to_pain_ratio, aggregate="ME"))) 

154 rows.append(("Payoff Ratio", _safe(s.payoff_ratio))) 

155 rows.append(("Profit Factor", _safe(s.profit_factor))) 

156 rows.append(("Common Sense Ratio", _safe(s.common_sense_ratio))) 

157 rows.append(("CPC Index", _safe(s.cpc_index))) 

158 rows.append(("Tail Ratio", _safe(s.tail_ratio))) 

159 rows.append(("Outlier Win Ratio", _safe(s.outlier_win_ratio))) 

160 rows.append(("Outlier Loss Ratio", _safe(s.outlier_loss_ratio))) 

161 

162 

163def _add_recent_returns_rows( 

164 rows: list[tuple[str, dict[str, Any]]], 

165 all_df: pl.DataFrame, 

166 date_col: str, 

167 asset_cols: list[str], 

168 ppy: float, 

169 s: Any, 

170) -> None: 

171 """Append date-filtered recent return rows to *rows*. 

172 

173 Args: 

174 rows: Accumulator list of ``(label, values)`` tuples. 

175 all_df: Combined DataFrame containing date and return columns. 

176 date_col: Name of the date column in *all_df*. 

177 asset_cols: Names of asset return columns in *all_df*. 

178 ppy: Periods per year for annualisation. 

179 s: Stats object used for the all-time CAGR. 

180 

181 """ 

182 today = cast(datetime.date, all_df[date_col].max()) 

183 mtd_start = today.replace(day=1) 

184 ytd_start = today.replace(month=1, day=1) 

185 

186 rows.append(("MTD", _pct(_comp_since(all_df, date_col, asset_cols, mtd_start)))) 

187 rows.append(("3M", _pct(_comp_since(all_df, date_col, asset_cols, _cutoff_months(today, 3))))) 

188 rows.append(("6M", _pct(_comp_since(all_df, date_col, asset_cols, _cutoff_months(today, 6))))) 

189 rows.append(("YTD", _pct(_comp_since(all_df, date_col, asset_cols, ytd_start)))) 

190 rows.append(("1Y", _pct(_comp_since(all_df, date_col, asset_cols, _cutoff_months(today, 12))))) 

191 rows.append(("3Y (ann.)", _pct(_cagr_since(all_df, date_col, asset_cols, _cutoff_months(today, 36), ppy)))) 

192 rows.append(("5Y (ann.)", _pct(_cagr_since(all_df, date_col, asset_cols, _cutoff_months(today, 60), ppy)))) 

193 rows.append(("All-time (ann.)", _pct(_safe(s.cagr, periods=ppy)))) 

194 

195 

196def _add_full_mode_rows( 

197 rows: list[tuple[str, dict[str, Any]]], 

198 s: Any, 

199 ppy: float, 

200 data: Any, 

201 all_df: pl.DataFrame | None, 

202 date_col: str | None, 

203 asset_cols: list[str], 

204) -> None: 

205 """Append all full-mode extension rows to *rows*. 

206 

207 Covers smart ratios, extended risk, averages, expected returns, tail risk, 

208 streaks, best/worst periods, and benchmark metrics. 

209 

210 Args: 

211 rows: Accumulator list of ``(label, values)`` tuples. 

212 s: Stats object providing the metric methods. 

213 ppy: Periods per year for annualisation. 

214 data: The DataLike object (used for benchmark access). 

215 all_df: Combined DataFrame or ``None`` if unavailable. 

216 date_col: Name of the date column or ``None`` if unavailable. 

217 asset_cols: Asset column names. 

218 

219 """ 

220 # Smart ratios 

221 rows.append(("Smart Sharpe", _safe(s.smart_sharpe, periods=ppy))) 

222 ss = _safe(s.smart_sortino, periods=ppy) 

223 rows.append(("Smart Sortino", ss)) 

224 rows.append(("Smart Sortino / √2", {k: v / math.sqrt(2) for k, v in ss.items() if _is_finite(v)})) 

225 

226 # Risk 

227 rows.append(("Volatility (ann.)", _pct(_safe(s.volatility, periods=ppy)))) 

228 rows.append(("Calmar", _safe(s.calmar, periods=ppy))) 

229 rows.append(("Risk-Adjusted Return", _pct(_safe(s.rar, periods=ppy)))) 

230 rows.append(("Risk-Return Ratio", _safe(s.risk_return_ratio))) 

231 rows.append(("Ulcer Performance Index", _safe(s.ulcer_performance_index))) 

232 rows.append(("Skew", _safe(s.skew))) 

233 rows.append(("Kurtosis", _safe(s.kurtosis))) 

234 

235 # Averages 

236 rows.append(("Avg. Return", _pct(_safe(s.avg_return)))) 

237 rows.append(("Avg. Win", _pct(_safe(s.avg_win)))) 

238 rows.append(("Avg. Loss", _pct(_safe(s.avg_loss)))) 

239 rows.append(("Win/Loss Ratio", _safe(s.payoff_ratio))) 

240 rows.append(("Profit Ratio", _safe(s.profit_ratio))) 

241 rows.append(("Win Rate", _pct(_safe(s.win_rate)))) 

242 rows.append(("Monthly Win Rate", _pct(_safe(s.monthly_win_rate)))) 

243 

244 # Expected returns 

245 rows.append(("Expected Daily", _pct(_safe(s.expected_return)))) 

246 rows.append(("Expected Monthly", _pct(_safe(s.expected_return, aggregate="monthly")))) 

247 rows.append(("Expected Yearly", _pct(_safe(s.expected_return, aggregate="yearly")))) 

248 

249 # Tail risk 

250 rows.append(("Kelly Criterion", _pct(_safe(s.kelly_criterion)))) 

251 rows.append(("Risk of Ruin", _pct(_safe(s.risk_of_ruin)))) 

252 rows.append(("Daily VaR", _pct(_safe(s.value_at_risk)))) 

253 rows.append(("Expected Shortfall (cVaR)", _pct(_safe(s.conditional_value_at_risk)))) 

254 

255 # Streaks & best / worst 

256 rows.append(("Max Consecutive Wins", _safe(s.consecutive_wins))) 

257 rows.append(("Max Consecutive Losses", _safe(s.consecutive_losses))) 

258 rows.append(("Best Day", _pct(_safe(s.best)))) 

259 rows.append(("Worst Day", _pct(_safe(s.worst)))) 

260 

261 _append_benchmark_greeks(rows, s) 

262 _append_correlation(rows, data, all_df, date_col, asset_cols) 

263 

264 rows.append(("R²", _safe(s.r_squared))) 

265 rows.append(("Treynor Ratio", _safe(s.treynor_ratio, periods=ppy))) 

266 

267 

268def _append_benchmark_greeks(rows: list[tuple[str, dict[str, Any]]], s: Any) -> None: 

269 """Append benchmark Beta/Alpha rows when a benchmark is present. 

270 

271 Without a benchmark, ``s.greeks()`` reaches ``benchmark_data.columns`` on a 

272 ``None`` benchmark and raises ``AttributeError``; we tolerate only that case 

273 and omit the rows. Any other error (malformed column, polars schema/ 

274 arithmetic bug) propagates so genuine bugs are not silently swallowed. 

275 

276 Args: 

277 rows: Accumulator list of ``(label, values)`` tuples. 

278 s: Stats object providing ``greeks()``. 

279 

280 """ 

281 try: 

282 greeks = s.greeks() 

283 if greeks: # pragma: no branch — greeks() raises without a benchmark, so falsy is unreachable 

284 beta = {k: v["beta"] for k, v in greeks.items()} 

285 alpha = {k: v["alpha"] * 100.0 for k, v in greeks.items()} 

286 rows.append(("Beta", beta)) 

287 rows.append(("Alpha", alpha)) 

288 except AttributeError: 

289 pass 

290 

291 

292def _append_correlation( 

293 rows: list[tuple[str, dict[str, Any]]], 

294 data: Any, 

295 all_df: pl.DataFrame | None, 

296 date_col: str | None, 

297 asset_cols: list[str], 

298) -> None: 

299 """Append the per-asset correlation-against-benchmark row when possible. 

300 

301 When the benchmark column is absent (no benchmark configured, or it is 

302 missing from the joined frame) polars raises ``ColumnNotFoundError`` and 

303 ``None.columns`` raises ``AttributeError`` — both mean "no benchmark to 

304 correlate against", so the row is omitted. Any other error propagates. 

305 

306 Args: 

307 rows: Accumulator list of ``(label, values)`` tuples. 

308 data: The DataLike object (used for benchmark access). 

309 all_df: Combined DataFrame or ``None`` if unavailable. 

310 date_col: Name of the date column or ``None`` if unavailable. 

311 asset_cols: Asset column names. 

312 

313 """ 

314 try: 

315 bench_obj = getattr(data, "benchmark", None) 

316 if bench_obj is not None and all_df is not None and date_col is not None: # pragma: no branch 

317 bench_col = bench_obj.columns[0] 

318 corr_dict: dict[str, float] = {} 

319 for ac in asset_cols: 

320 if ac == bench_col: 

321 continue 

322 sub = all_df.select([date_col, ac, bench_col]).drop_nulls() 

323 corr_val = float(sub.select(pl.corr(ac, bench_col))[0, 0]) 

324 corr_dict[ac] = corr_val * 100.0 

325 rows.append(("Correlation", corr_dict)) 

326 except (AttributeError, pl.exceptions.ColumnNotFoundError): 

327 pass 

328 

329 

330def _build_metrics_df(rows: list[tuple[str, dict[str, Any]]]) -> pl.DataFrame: 

331 """Build a metrics `pl.DataFrame` from accumulated row data. 

332 

333 Args: 

334 rows: List of ``(label, values)`` tuples where *values* maps asset 

335 names to numeric results. 

336 

337 Returns: 

338 A DataFrame with a leading ``"Metric"`` column and one column per 

339 asset, preserving the insertion order of both metrics and assets. 

340 

341 """ 

342 all_assets: list[str] = [] 

343 seen: set[str] = set() 

344 for _, vals in rows: 

345 for k in vals: 

346 if k not in seen: 

347 all_assets.append(k) 

348 seen.add(k) 

349 return pl.DataFrame([{"Metric": label, **{a: vals.get(a) for a in all_assets}} for label, vals in rows])