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

60 statements  

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

1"""The tidy `summary` table and its calendar-year breakdown. 

2 

3Split out of `_reporting.py`. These are the aggregating metrics: they call 

4across every other mixin rather than computing anything themselves, which is 

5why they carry the largest block of cross-mixin type stubs in the package. 

6""" 

7 

8from __future__ import annotations 

9 

10from typing import TYPE_CHECKING, Any, cast 

11 

12import polars as pl 

13 

14if TYPE_CHECKING: 

15 from ..data import Data 

16 

17 

18class _SummaryStatsMixin: 

19 """Mixin providing the `summary` table and `annual_breakdown`. 

20 

21 Cross-mixin dependencies: 

22 - _BasicStatsMixin: avg_return, avg_win, avg_loss, win_rate, profit_factor, 

23 payoff_ratio, best, worst, volatility, skew, kurtosis, value_at_risk, 

24 conditional_value_at_risk 

25 - _RiskStatsMixin: sharpe 

26 - _DrawdownMixin: max_drawdown 

27 - _ReportingStatsMixin: monthly_win_rate, avg_drawdown, 

28 max_drawdown_duration, calmar, recovery_factor 

29 """ 

30 

31 _data: Data 

32 all: pl.DataFrame 

33 

34 if TYPE_CHECKING: 

35 from .._protocol import DataLike 

36 

37 data: DataLike 

38 

39 def avg_return(self) -> dict[str, float]: 

40 """Defined on _BasicStatsMixin.""" 

41 

42 def avg_win(self) -> dict[str, float]: 

43 """Defined on _BasicStatsMixin.""" 

44 

45 def avg_loss(self) -> dict[str, float]: 

46 """Defined on _BasicStatsMixin.""" 

47 

48 def win_rate(self) -> dict[str, float]: 

49 """Defined on _BasicStatsMixin.""" 

50 

51 def profit_factor(self) -> dict[str, float]: 

52 """Defined on _BasicStatsMixin.""" 

53 

54 def payoff_ratio(self) -> dict[str, float]: 

55 """Defined on _BasicStatsMixin.""" 

56 

57 def best(self) -> dict[str, float | None]: 

58 """Defined on _BasicStatsMixin.""" 

59 

60 def worst(self) -> dict[str, float | None]: 

61 """Defined on _BasicStatsMixin.""" 

62 

63 def volatility(self) -> dict[str, float]: 

64 """Defined on _BasicStatsMixin.""" 

65 

66 def sharpe(self) -> dict[str, float]: 

67 """Defined on _RiskStatsMixin.""" 

68 

69 def skew(self) -> dict[str, int | float | None]: 

70 """Defined on _BasicStatsMixin.""" 

71 

72 def kurtosis(self) -> dict[str, int | float | None]: 

73 """Defined on _BasicStatsMixin.""" 

74 

75 def value_at_risk(self) -> dict[str, float]: 

76 """Defined on _BasicStatsMixin.""" 

77 

78 def conditional_value_at_risk(self) -> dict[str, float]: 

79 """Defined on _BasicStatsMixin.""" 

80 

81 def max_drawdown(self) -> dict[str, float]: 

82 """Defined on _DrawdownMixin.""" 

83 

84 def monthly_win_rate(self) -> dict[str, float]: 

85 """Defined on _ReportingStatsMixin.""" 

86 

87 def avg_drawdown(self) -> dict[str, float]: 

88 """Defined on _ReportingStatsMixin.""" 

89 

90 def max_drawdown_duration(self) -> dict[str, float | int | None]: 

91 """Defined on _ReportingStatsMixin.""" 

92 

93 def calmar(self) -> dict[str, float]: 

94 """Defined on _ReportingStatsMixin.""" 

95 

96 def recovery_factor(self) -> dict[str, float]: 

97 """Defined on _ReportingStatsMixin.""" 

98 

99 def annual_breakdown(self) -> pl.DataFrame: 

100 """Summary statistics broken down by calendar year. 

101 

102 Groups the data by calendar year using the date index, computes a 

103 full `summary` for each year, and stacks the results with an 

104 additional ``year`` column. 

105 

106 Returns: 

107 pl.DataFrame: Columns ``year``, ``metric``, one per asset, sorted 

108 by ``year``. 

109 

110 Raises: 

111 ValueError: If the data has no date index. 

112 """ 

113 all_df = self.all 

114 date_col_name = self._data.date_col[0] if self._data.date_col else None 

115 has_temporal = date_col_name is not None and all_df[date_col_name].dtype.is_temporal() 

116 

117 if not has_temporal: 

118 return self._annual_breakdown_integer(all_df) 

119 if date_col_name is None: # unreachable: has_temporal guarantees non-None # pragma: no cover 

120 return pl.DataFrame() # pragma: no cover 

121 return self._annual_breakdown_temporal(all_df, date_col_name) 

122 

123 def _summary_frame(self, sub_all: pl.DataFrame, index_cols: list[str], label: int) -> pl.DataFrame: 

124 """Compute a `summary` for one sub-period and tag it with a ``year`` label. 

125 

126 Args: 

127 sub_all: The combined (index + returns + benchmark) rows for the period. 

128 index_cols: Column name(s) to use as the sub-period's date index. 

129 label: Value written to the ``year`` column (calendar year or chunk ordinal). 

130 

131 Returns: 

132 The summary DataFrame with an added ``year`` column. 

133 """ 

134 # Construct the sub-period Data via type(self._data) rather than importing 

135 # the concrete class: a lazy `from ..data import Data` would put the upper 

136 # layer back into this subpackage's import graph, which is exactly the 

137 # coupling _protocol.py exists to prevent. Mirrors the type(self) call below. 

138 data_factory = cast(Any, type(self._data)) 

139 sub_returns = sub_all.select(self._data.returns.columns) 

140 sub_benchmark = sub_all.select(self._data.benchmark.columns) if self._data.benchmark is not None else None 

141 sub_data = data_factory(returns=sub_returns, index=sub_all.select(index_cols), benchmark=sub_benchmark) 

142 summary: pl.DataFrame = cast(Any, type(self))(sub_data).summary() 

143 return summary.with_columns(pl.lit(label).alias("year")) 

144 

145 @staticmethod 

146 def _order_breakdown(result: pl.DataFrame) -> pl.DataFrame: 

147 """Reorder breakdown columns so ``year`` and ``metric`` lead.""" 

148 ordered = ["year", "metric", *[c for c in result.columns if c not in ("year", "metric")]] 

149 return result.select(ordered) 

150 

151 def _annual_breakdown_integer(self, all_df: pl.DataFrame) -> pl.DataFrame: 

152 """Break down by fixed row chunks (~one year each) for an integer index.""" 

153 chunk = round(self._data._periods_per_year) 

154 total = all_df.height 

155 frames: list[pl.DataFrame] = [] 

156 for i, start in enumerate(range(0, total, chunk), start=1): 

157 chunk_all = all_df.slice(start, chunk) 

158 if chunk_all.height < max(5, chunk // 4): 

159 continue 

160 frames.append(self._summary_frame(chunk_all, self._data.date_col, i)) 

161 if not frames: 

162 return pl.DataFrame() 

163 return self._order_breakdown(pl.concat(frames)) 

164 

165 def _annual_breakdown_temporal(self, all_df: pl.DataFrame, date_col_name: str) -> pl.DataFrame: 

166 """Break down by calendar year for a temporal index.""" 

167 years = all_df[date_col_name].dt.year().unique().sort().to_list() 

168 frames: list[pl.DataFrame] = [] 

169 for year in years: 

170 year_all = all_df.filter(pl.col(date_col_name).dt.year() == year) 

171 if year_all.height < 2: 

172 continue 

173 frames.append(self._summary_frame(year_all, [date_col_name], year)) 

174 if not frames: 

175 asset_cols = list(self._data.returns.columns) 

176 schema: dict[str, type[pl.DataType]] = { 

177 "year": pl.Int32, 

178 "metric": pl.String, 

179 **dict.fromkeys(asset_cols, pl.Float64), 

180 } 

181 return pl.DataFrame(schema=schema) 

182 return self._order_breakdown(pl.concat(frames)) 

183 

184 def summary(self) -> pl.DataFrame: 

185 """Summary statistics for each asset as a tidy DataFrame. 

186 

187 Each row is one metric; each column beyond ``metric`` is one asset. 

188 

189 Returns: 

190 pl.DataFrame: A DataFrame with a ``metric`` column followed by one 

191 column per asset. 

192 

193 Returns NaN when: 

194 Cells are ``float("nan")`` when the underlying metric is unavailable 

195 for the data (e.g. no temporal index or no benchmark). 

196 """ 

197 assets = [col for col, _ in self._data.items()] 

198 

199 def _safe(fn: Any) -> dict[str, Any]: 

200 """Call *fn()* and return its result; return NaN for each asset on any exception.""" 

201 try: 

202 result: dict[str, Any] = fn() 

203 except Exception: 

204 return dict.fromkeys(assets, float("nan")) 

205 return result 

206 

207 metrics: dict[str, dict[str, Any]] = { 

208 "avg_return": _safe(self.avg_return), 

209 "avg_win": _safe(self.avg_win), 

210 "avg_loss": _safe(self.avg_loss), 

211 "win_rate": _safe(self.win_rate), 

212 "profit_factor": _safe(self.profit_factor), 

213 "payoff_ratio": _safe(self.payoff_ratio), 

214 "monthly_win_rate": _safe(self.monthly_win_rate), 

215 "best": _safe(self.best), 

216 "worst": _safe(self.worst), 

217 "volatility": _safe(self.volatility), 

218 "sharpe": _safe(self.sharpe), 

219 "skew": _safe(self.skew), 

220 "kurtosis": _safe(self.kurtosis), 

221 "value_at_risk": _safe(self.value_at_risk), 

222 "conditional_value_at_risk": _safe(self.conditional_value_at_risk), 

223 "max_drawdown": _safe(self.max_drawdown), 

224 "avg_drawdown": _safe(self.avg_drawdown), 

225 "max_drawdown_duration": _safe(self.max_drawdown_duration), 

226 "calmar": _safe(self.calmar), 

227 "recovery_factor": _safe(self.recovery_factor), 

228 } 

229 

230 rows: list[dict[str, Any]] = [ 

231 {"metric": name, **{asset: values.get(asset) for asset in assets}} for name, values in metrics.items() 

232 ] 

233 return pl.DataFrame(rows)