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

152 statements  

« prev     ^ index     » next       coverage.py v7.17.1, created at 2026-11-03 15: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 calendar 

11import datetime as dt 

12import math 

13from typing import Any, cast 

14 

15import polars as pl 

16 

17from ._formatting import _is_finite 

18 

19# ── Private helpers ─────────────────────────────────────────────────────────── 

20 

21 

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

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

24 try: 

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

26 except Exception: 

27 return {} 

28 return result 

29 

30 

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

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

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

34 

35 

36# ── Period-return helpers ───────────────────────────────────────────────────── 

37 

38 

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

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

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

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

43 for col in asset_cols: 

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

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

46 return result 

47 

48 

49def _cagr_since( 

50 all_df: pl.DataFrame, 

51 date_col: str, 

52 asset_cols: list[str], 

53 cutoff: Any, 

54 periods_per_year: float, 

55) -> dict[str, float]: 

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

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

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

59 for col in asset_cols: 

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

61 n = len(s) 

62 if n < 2: 

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

64 continue 

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

66 years = n / periods_per_year 

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

68 return result 

69 

70 

71# ── Metrics-row helpers ─────────────────────────────────────────────────────── 

72 

73 

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

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

76 

77 Args: 

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

79 n: Number of calendar months to subtract. 

80 

81 Returns: 

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

83 

84 """ 

85 y = today.year 

86 m = today.month 

87 for _ in range(n): 

88 m -= 1 

89 if m == 0: 

90 m = 12 

91 y -= 1 

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

93 return dt.date(y, m, d) 

94 

95 

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

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

98 

99 Args: 

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

101 s: Stats object providing the metric methods. 

102 ppy: Periods per year for annualisation. 

103 

104 """ 

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

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

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

108 

109 

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

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

112 

113 Args: 

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

115 s: Stats object providing the metric methods. 

116 ppy: Periods per year for annualisation. 

117 

118 """ 

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

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

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

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

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

124 

125 

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

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

128 

129 Args: 

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

131 s: Stats object providing the metric methods. 

132 

133 """ 

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

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

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

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

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

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

140 

141 

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

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

144 

145 Args: 

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

147 s: Stats object providing the metric methods. 

148 

149 """ 

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

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

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

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

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

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

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

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

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

159 

160 

161def _add_recent_returns_rows( 

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

163 all_df: pl.DataFrame, 

164 date_col: str, 

165 asset_cols: list[str], 

166 ppy: float, 

167 s: Any, 

168) -> None: 

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

170 

171 Args: 

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

173 all_df: Combined DataFrame containing date and return columns. 

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

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

176 ppy: Periods per year for annualisation. 

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

178 

179 """ 

180 today = cast(dt.date, all_df[date_col].max()) 

181 mtd_start = today.replace(day=1) 

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

183 

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

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

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

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

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

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

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

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

192 

193 

194def _add_full_mode_rows( 

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

196 s: Any, 

197 ppy: float, 

198 data: Any, 

199 all_df: pl.DataFrame | None, 

200 date_col: str | None, 

201 asset_cols: list[str], 

202) -> None: 

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

204 

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

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

207 

208 Args: 

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

210 s: Stats object providing the metric methods. 

211 ppy: Periods per year for annualisation. 

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

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

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

215 asset_cols: Asset column names. 

216 

217 """ 

218 # Smart ratios 

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

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

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

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

223 

224 # Risk 

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

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

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

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

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

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

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

232 

233 # Averages 

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

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

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

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

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

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

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

241 

242 # Expected returns 

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

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

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

246 

247 # Tail risk 

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

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

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

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

252 

253 # Streaks & best / worst 

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

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

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

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

258 

259 _append_benchmark_greeks(rows, s) 

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

261 

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

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

264 

265 

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

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

268 

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

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

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

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

273 

274 Args: 

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

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

277 

278 """ 

279 try: 

280 greeks = s.greeks() 

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

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

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

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

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

286 except AttributeError: 

287 pass 

288 

289 

290def _append_correlation( 

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

292 data: Any, 

293 all_df: pl.DataFrame | None, 

294 date_col: str | None, 

295 asset_cols: list[str], 

296) -> None: 

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

298 

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

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

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

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

303 

304 Args: 

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

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

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

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

309 asset_cols: Asset column names. 

310 

311 """ 

312 try: 

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

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

315 bench_col = bench_obj.columns[0] 

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

317 for ac in asset_cols: 

318 if ac == bench_col: 

319 continue 

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

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

322 corr_dict[ac] = corr_val * 100.0 

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

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

325 pass 

326 

327 

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

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

330 

331 Args: 

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

333 names to numeric results. 

334 

335 Returns: 

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

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

338 

339 """ 

340 all_assets: list[str] = [] 

341 seen: set[str] = set() 

342 for _, vals in rows: 

343 for k in vals: 

344 if k not in seen: 

345 all_assets.append(k) 

346 seen.add(k) 

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