Coverage for src/jquantstats/_plots/_specs/_montecarlo.py: 100%

69 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-23 04:11 +0000

1"""Spec builders for the Monte Carlo charts. 

2 

3Both bootstrap from the trailing return history: `montecarlo_spec` draws the 

4simulated paths themselves, `montecarlo_distribution_spec` reduces each path to 

5one metric and shows where the observed value falls among them. 

6""" 

7 

8from __future__ import annotations 

9 

10import math 

11from typing import TYPE_CHECKING 

12 

13import numpy as np 

14import polars as pl 

15 

16from .._spec import Axis, FigureSpec, HistogramSeries, HoverSpec, LineSeries, Panel, RefLine 

17from .._style import hex_to_rgba, ticker_colors 

18 

19if TYPE_CHECKING: 

20 from jquantstats._protocol import DataLike 

21 

22__all__ = ["METRICS", "montecarlo_distribution_spec", "montecarlo_spec"] 

23 

24#: Metrics a simulated path can be reduced to, and how each is labelled. 

25METRICS = {"sharpe": "Sharpe Ratio", "drawdown": "Max Drawdown", "cagr": "CAGR"} 

26 

27# Simulated paths are drawn very faintly: the point is the shape of the bundle, 

28# not any individual path. The observed path is heavier so it stands clear. 

29_SIM_ALPHA = 0.12 

30_SIM_WIDTH = 1 

31_OBSERVED_WIDTH = 2.5 

32 

33_OVERLAY_OPACITY = 0.6 

34_PERIODS_PER_YEAR = 252.0 

35 

36# Fixed so a chart is reproducible: the same data always yields the same fan. 

37_SEED = 42 

38 

39 

40def _split_columns(frame: pl.DataFrame) -> tuple[str, list[str]]: 

41 """Separate the date column from the value columns. 

42 

43 Args: 

44 frame: A frame whose first column is the date axis. 

45 

46 Returns: 

47 tuple[str, list[str]]: The date column name and every other column. 

48 

49 """ 

50 date_col = frame.columns[0] 

51 return date_col, [c for c in frame.columns if c != date_col] 

52 

53 

54def _require_positive(n: int, period: int) -> None: 

55 """Reject non-positive simulation counts or path lengths. 

56 

57 Args: 

58 n: Number of simulations. 

59 period: Observations per simulation. 

60 

61 Raises: 

62 ValueError: If either is not positive. 

63 

64 """ 

65 if n <= 0: 

66 raise ValueError("n must be a positive integer") # noqa: TRY003 

67 if period <= 0: 

68 raise ValueError("period must be a positive integer") # noqa: TRY003 

69 

70 

71def montecarlo_spec( 

72 data: DataLike, 

73 n: int, 

74 period: int, 

75 title: str, 

76 figsize: tuple[int, int] | None, 

77) -> FigureSpec: 

78 """Describe the Monte Carlo fan chart. 

79 

80 Args: 

81 data: The dataset to plot. 

82 n: Number of simulated paths per asset. 

83 period: Observations per path. 

84 title: Chart title. 

85 figsize: Optional ``(width, height)`` in pixels. 

86 

87 Returns: 

88 FigureSpec: *n* faint simulated paths per asset, with the observed 

89 path drawn over them. 

90 

91 Raises: 

92 ValueError: If *n* or *period* is not positive. 

93 

94 """ 

95 _require_positive(n, period) 

96 

97 df = data.all 

98 date_col, tickers = _split_columns(df) 

99 colors = ticker_colors(tickers) 

100 

101 sample_len = min(period, df.height) 

102 dates = df[date_col].tail(sample_len).to_list() 

103 rng = np.random.default_rng(seed=_SEED) 

104 

105 lines = [] 

106 for ticker in tickers: 

107 trailing = ( 

108 df[ticker].tail(sample_len - 1).fill_null(0.0).cast(pl.Float64).to_numpy() 

109 if sample_len > 1 

110 else np.array([], dtype=np.float64) 

111 ) 

112 

113 for i in range(n): 

114 draws = ( 

115 rng.choice(trailing, size=sample_len - 1, replace=True) 

116 if sample_len > 1 

117 else np.array([], dtype=np.float64) 

118 ) 

119 lines.append( 

120 LineSeries( 

121 name=f"{ticker} Sim", 

122 x=dates, 

123 y=np.cumprod(np.concatenate(([1.0], 1.0 + draws))), 

124 color=hex_to_rgba(colors[ticker], alpha=_SIM_ALPHA), 

125 width=_SIM_WIDTH, 

126 # One legend entry for the whole bundle, not n of them. 

127 legend_group=f"{ticker}_sim", 

128 show_legend=(i == 0), 

129 hover=HoverSpec( 

130 label=f"{ticker} Sim", 

131 value_format="float2", 

132 suffix="x", 

133 date_header=False, 

134 hide_extra=True, 

135 ), 

136 ) 

137 ) 

138 

139 lines.append( 

140 LineSeries( 

141 name=f"{ticker} Observed", 

142 x=dates, 

143 y=np.cumprod(np.concatenate(([1.0], 1.0 + trailing))), 

144 color=colors[ticker], 

145 width=_OBSERVED_WIDTH, 

146 legend_group=f"{ticker}_obs", 

147 hover=HoverSpec( 

148 label=f"{ticker} Observed", 

149 value_format="float2", 

150 suffix="x", 

151 date_header=False, 

152 hide_extra=True, 

153 ), 

154 ) 

155 ) 

156 

157 panel = Panel(lines=tuple(lines), yaxis=Axis(title="Cumulative Return", tick_format="float2")) 

158 return FigureSpec(title=title, panels=(panel,), figsize=figsize) 

159 

160 

161def _metric_value(returns: np.ndarray, metric_key: str) -> float: 

162 """Reduce one simulated return path to a single number. 

163 

164 Args: 

165 returns: The path's per-period returns. 

166 metric_key: One of ``"sharpe"``, ``"drawdown"`` or ``"cagr"``. 

167 

168 Returns: 

169 float: The metric's value for this path. 

170 

171 """ 

172 if metric_key == "sharpe": 

173 std = returns.std(ddof=1) 

174 return float(math.sqrt(_PERIODS_PER_YEAR) * returns.mean() / std) if std > 0 else 0.0 

175 if metric_key == "drawdown": 

176 path = np.cumprod(1.0 + returns) 

177 hwm = np.maximum.accumulate(path) 

178 dd = (path - hwm) / hwm 

179 return float(dd.min()) if dd.size else 0.0 

180 total_return = float(np.prod(1.0 + returns)) 

181 return float(total_return ** (_PERIODS_PER_YEAR / len(returns)) - 1.0) if len(returns) > 0 else 0.0 

182 

183 

184def montecarlo_distribution_spec( 

185 data: DataLike, 

186 n: int, 

187 period: int, 

188 metric: str, 

189 title: str, 

190 figsize: tuple[int, int] | None, 

191) -> FigureSpec: 

192 """Describe the distribution of a metric across simulated paths. 

193 

194 Args: 

195 data: The dataset to plot. 

196 n: Number of simulations per asset. 

197 period: Observations per simulation. 

198 metric: One of ``"sharpe"``, ``"drawdown"`` or ``"cagr"``. 

199 title: Chart title. 

200 figsize: Optional ``(width, height)`` in pixels. 

201 

202 Returns: 

203 FigureSpec: One histogram per asset, each with a marker at the 

204 observed value. 

205 

206 Raises: 

207 ValueError: If *n* or *period* is not positive, or *metric* is not one 

208 of the supported names. 

209 

210 """ 

211 _require_positive(n, period) 

212 

213 metric_key = metric.strip().lower() 

214 if metric_key not in METRICS: 

215 raise ValueError("metric must be one of: sharpe, drawdown, cagr") # noqa: TRY003 

216 

217 df = data.all 

218 _, tickers = _split_columns(df) 

219 colors = ticker_colors(tickers) 

220 sample_len = min(period, df.height) 

221 rng = np.random.default_rng(seed=_SEED) 

222 

223 histograms = [] 

224 ref_lines = [] 

225 for ticker in tickers: 

226 history = df[ticker].tail(sample_len).fill_null(0.0).cast(pl.Float64).to_numpy() 

227 if history.size == 0: # pragma: no cover - a frame with no rows cannot reach a plot method 

228 continue 

229 

230 histograms.append( 

231 HistogramSeries( 

232 name=ticker, 

233 values=[ 

234 _metric_value(rng.choice(history, size=sample_len, replace=True), metric_key) for _ in range(n) 

235 ], 

236 color=colors[ticker], 

237 opacity=_OVERLAY_OPACITY, 

238 hover=HoverSpec( 

239 label=ticker, 

240 value_format="float4", 

241 date_header=False, 

242 axis="x", 

243 hide_extra=True, 

244 ), 

245 ) 

246 ) 

247 ref_lines.append( 

248 RefLine( 

249 value=_metric_value(history, metric_key), 

250 orientation="v", 

251 color=colors[ticker], 

252 width=2, 

253 dash="dash", 

254 label=f"{ticker} observed", 

255 ) 

256 ) 

257 

258 panel = Panel( 

259 histograms=tuple(histograms), 

260 ref_lines=tuple(ref_lines), 

261 xaxis=Axis(title=METRICS[metric_key]), 

262 yaxis=Axis(title="Count"), 

263 ) 

264 # No range selector: the x-axis is the metric's value, not time. 

265 return FigureSpec( 

266 title=title, 

267 panels=(panel,), 

268 figsize=figsize, 

269 date_range_selector=False, 

270 bar_mode="overlay", 

271 )