Coverage for src/jointview/stats.py: 100%

46 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-17 06:44 +0000

1"""Summary statistics for a single price or NAV series, computed by jQuantStats. 

2 

3Everything here takes a frame of *levels* — a net asset value, an index, a price — 

4alongside the column that carries the period, and lets 

5[jQuantStats](https://github.com/jebel-quant/jquantstats) derive the returns and the 

6statistics from them. 

7 

8Passing the period column rather than a bare series is what buys the accuracy: the 

9annualisation factor is read from the actual spacing of the observations, so a weekly 

10or monthly series is annualised as one, instead of every series being assumed daily. 

11A frame numbered by row rather than dated falls back to 252 periods a year, which is 

12what a bare series had to assume in every case. 

13""" 

14 

15from __future__ import annotations 

16 

17import math 

18from typing import TYPE_CHECKING 

19 

20import polars as pl 

21from jquantstats.data import Data 

22 

23# The column `aligned` writes is the column these functions read, so the name is taken 

24# from where it is written rather than spelled a second time here — two equal literals 

25# in two files fail only where the app joins them. It comes from `columns`, which owns 

26# the contract, rather than from `plot`: drawing and summarising are two things done 

27# to the same frame, and neither should have to import the other to name a column. 

28from jointview.columns import PERIOD 

29 

30if TYPE_CHECKING: # pragma: no cover 

31 from jquantstats._stats import Stats 

32 

33MISSING = "—" 

34 

35 

36def _stats(frame: pl.DataFrame, column: str, *, date_col: str = PERIOD, rf: float = 0.0) -> Stats: 

37 """The jQuantStats view of one column of ``frame``. 

38 

39 Raises: 

40 KeyError: if ``column`` or ``date_col`` is not in the frame. 

41 """ 

42 for name in (date_col, column): 

43 if name not in frame.columns: 

44 raise KeyError(f"no column {name!r} in frame") # noqa: TRY003 

45 # Gaps are dropped here rather than left to jQuantStats' own `null_strategy`, which 

46 # empties the frame outright when the price column carries a null. 

47 levels = frame.select(pl.col(date_col), pl.col(column).cast(pl.Float64)).drop_nulls() 

48 return Data.from_prices(levels, date_col=date_col, rf=rf).stats 

49 

50 

51def returns(frame: pl.DataFrame, column: str, *, date_col: str = PERIOD) -> pl.Series: 

52 """Simple period-over-period returns of a level series. 

53 

54 >>> import polars as pl 

55 >>> frame = pl.DataFrame({"period": [0, 1, 2], "nav": [100.0, 110.0, 99.0]}) 

56 >>> [round(value, 4) for value in returns(frame, "nav")] 

57 [0.1, -0.1] 

58 """ 

59 stats = _stats(frame, column, date_col=date_col) 

60 return stats.returns[column] 

61 

62 

63def drawdown(frame: pl.DataFrame, column: str, *, date_col: str = PERIOD) -> pl.Series: 

64 """Distance below the running maximum, as a fraction — zero or negative. 

65 

66 jQuantStats reports the same quantity as a positive depth, so the sign is flipped 

67 here: a drawdown is a fall, and every other rate in this module carries its 

68 direction in its sign. 

69 

70 >>> import polars as pl 

71 >>> frame = pl.DataFrame({"period": [0, 1, 2], "nav": [100.0, 120.0, 60.0]}) 

72 >>> round(drawdown(frame, "nav").min(), 4) 

73 -0.5 

74 """ 

75 stats = _stats(frame, column, date_col=date_col) 

76 return -stats.drawdown()[column] 

77 

78 

79# Every entry is one call on the jQuantStats `Stats` object, in the order the panel 

80# shows them. Keeping it as data rather than fifteen lines of dict literal is what 

81# lets `metrics` stay a loop, and makes adding a statistic a one-line change. 

82STATISTICS: dict[str, str] = { 

83 "Total return": "comp", 

84 "Annual return": "cagr", 

85 "Annual volatility": "volatility", 

86 "Sharpe ratio": "sharpe", 

87 "Sortino ratio": "sortino", 

88 "Calmar ratio": "calmar", 

89 "Max drawdown": "max_drawdown", 

90 "Ulcer index": "ulcer_index", 

91 "Value at risk": "value_at_risk", 

92 "Hit rate": "win_rate", 

93 "Best period": "best", 

94 "Worst period": "worst", 

95 "Skew": "skew", 

96 "Kurtosis": "kurtosis", 

97} 

98 

99 

100def metrics(frame: pl.DataFrame, column: str, *, date_col: str = PERIOD, rf: float = 0.0) -> dict[str, float]: 

101 """The raw numbers behind the summary table, in natural units (0.07 is 7%). 

102 

103 A figure that cannot be formed — a Sharpe ratio for a flat series, a growth rate 

104 for a series that touches zero — comes back as NaN or infinity rather than 

105 raising, so one odd column never blanks the whole table. 

106 

107 Raises: 

108 ValueError: if there are too few observations to derive anything. 

109 

110 >>> import polars as pl 

111 >>> frame = pl.DataFrame({"period": [0, 1, 2, 3], "nav": [100.0, 110.0, 105.0, 120.0]}) 

112 >>> numbers = metrics(frame, "nav") 

113 >>> numbers["Observations"] 

114 4.0 

115 >>> round(numbers["Total return"], 4) 

116 0.2 

117 """ 

118 stats = _stats(frame, column, date_col=date_col, rf=rf) 

119 levels = frame.get_column(column).drop_nulls().cast(pl.Float64) 

120 

121 numbers: dict[str, float] = { 

122 "Observations": float(levels.len()), 

123 "Start": float(levels[0]), 

124 "End": float(levels[-1]), 

125 } 

126 for label, name in STATISTICS.items(): 

127 numbers[label] = _number(stats, name, column) 

128 return numbers 

129 

130 

131def _number(stats: Stats, name: str, column: str) -> float: 

132 """One statistic as a float, with "could not be formed" spelled as NaN. 

133 

134 Two things arrive here that are not numbers, and both mean the same thing. A 

135 sample too short to support a figure comes back as ``None`` — a kurtosis from four 

136 observations, say. And a series that never moves divides by zero on its way to a 

137 hit rate, because no period is a winner or a loser. 

138 

139 Neither is a defect, and neither should reach the caller as an exception: this 

140 module's contract is that an unformable figure is NaN, which :func:`_format` 

141 already renders as a dash. A flat series is an ordinary column — a cash line — not 

142 an error condition. 

143 """ 

144 try: 

145 value = getattr(stats, name)()[column] 

146 except ZeroDivisionError: 

147 return math.nan 

148 return math.nan if value is None else float(value) 

149 

150 

151# Levels keep their own units, rates get a sign, and the figures with no direction — 

152# volatility, the ratios, the shape statistics — do not. 

153FORMATS: dict[str, str] = { 

154 "Observations": "{:,.0f}", 

155 "Start": "{:,.2f}", 

156 "End": "{:,.2f}", 

157 "Total return": "{:+.2%}", 

158 "Annual return": "{:+.2%}", 

159 "Annual volatility": "{:.2%}", 

160 "Sharpe ratio": "{:.2f}", 

161 "Sortino ratio": "{:.2f}", 

162 "Calmar ratio": "{:.2f}", 

163 "Max drawdown": "{:.2%}", 

164 "Ulcer index": "{:.2%}", 

165 "Value at risk": "{:+.2%}", 

166 "Hit rate": "{:.1%}", 

167 "Best period": "{:+.2%}", 

168 "Worst period": "{:+.2%}", 

169 "Skew": "{:.2f}", 

170 "Kurtosis": "{:.2f}", 

171} 

172 

173 

174def summary(frame: pl.DataFrame, column: str, *, date_col: str = PERIOD, rf: float = 0.0) -> pl.DataFrame: 

175 """The same numbers as :func:`metrics`, formatted for display. 

176 

177 >>> import polars as pl 

178 >>> frame = pl.DataFrame({"period": [0, 1, 2, 3], "nav": [100.0, 110.0, 105.0, 120.0]}) 

179 >>> table = summary(frame, "nav") 

180 >>> table.columns 

181 ['metric', 'value'] 

182 >>> table.row(by_predicate=pl.col("metric") == "Total return")[1] 

183 '+20.00%' 

184 """ 

185 numbers = metrics(frame, column, date_col=date_col, rf=rf) 

186 return pl.DataFrame( 

187 { 

188 "metric": list(numbers), 

189 "value": [_format(name, value) for name, value in numbers.items()], 

190 } 

191 ) 

192 

193 

194def summary_markdown( 

195 frame: pl.DataFrame, 

196 column: str, 

197 *, 

198 title: str | None = None, 

199 date_col: str = PERIOD, 

200 rf: float = 0.0, 

201) -> str: 

202 """A two-column markdown table, ready for ``mo.md``. 

203 

204 >>> import polars as pl 

205 >>> frame = pl.DataFrame({"period": [0, 1, 2, 3], "nav": [100.0, 110.0, 105.0, 120.0]}) 

206 >>> print(summary_markdown(frame, "nav", title="fund").splitlines()[0]) 

207 | fund | | 

208 """ 

209 table = summary(frame, column, date_col=date_col, rf=rf) 

210 header = f"| {title or column} | |", "|:---|---:|" 

211 body = (f"| {row['metric']} | {row['value']} |" for row in table.iter_rows(named=True)) 

212 return "\n".join((*header, *body)) 

213 

214 

215def _format(name: str, value: float) -> str: 

216 """Render one metric, falling back to ``MISSING`` for anything not finite. 

217 

218 A NaN here is a figure that could not be formed rather than a bug — a Sharpe 

219 ratio without volatility, a growth rate from a start of zero — so it shows as 

220 a dash instead of blanking the row. 

221 

222 >>> _format("Total return", 0.25) 

223 '+25.00%' 

224 >>> _format("Sharpe ratio", float("nan")) 

225 '—' 

226 """ 

227 if not math.isfinite(value): 

228 return MISSING 

229 return FORMATS.get(name, "{:,.4g}").format(value)