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

75 statements  

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

1"""Monte Carlo simulation charts (fan chart and metric distribution).""" 

2 

3from __future__ import annotations 

4 

5import math 

6from typing import TYPE_CHECKING 

7 

8import numpy as np 

9import plotly.graph_objects as go 

10import polars as pl 

11 

12from ._styling import _apply_base_layout, _apply_figsize, _hex_to_rgba, _ticker_colors 

13 

14if TYPE_CHECKING: 

15 from jquantstats._protocol import DataLike 

16 

17 

18class _MonteCarloPlotsMixin: 

19 """Monte Carlo simulation plots for :class:`DataPlots`.""" 

20 

21 __slots__ = () 

22 

23 _data: DataLike 

24 

25 def montecarlo( 

26 self, 

27 n: int = 100, 

28 period: int = 252, 

29 title: str = "Monte Carlo Simulation", 

30 figsize: tuple[int, int] | None = None, 

31 ) -> go.Figure: 

32 """Fan chart of Monte Carlo simulated cumulative return paths. 

33 

34 For each asset column, draws ``n`` bootstrapped paths sampled with 

35 replacement from historical returns and overlays the observed path for 

36 the trailing *period* observations. 

37 

38 Args: 

39 n: Number of simulated paths per asset. Defaults to 100. 

40 period: Number of observations per path. Defaults to 252. 

41 title: Chart title. Defaults to ``"Monte Carlo Simulation"``. 

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

43 

44 Returns: 

45 go.Figure: Interactive Plotly fan chart. 

46 

47 """ 

48 if n <= 0: 

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

50 if period <= 0: 

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

52 

53 df = self._data.all 

54 date_col = df.columns[0] 

55 tickers = [c for c in df.columns if c != date_col] 

56 colors = _ticker_colors(tickers) 

57 

58 sample_len = min(period, df.height) 

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

60 rng = np.random.default_rng(seed=42) 

61 

62 fig = go.Figure() 

63 for ticker in tickers: 

64 trailing_returns = ( 

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

66 if sample_len > 1 

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

68 ) 

69 

70 for i in range(n): 

71 draws = ( 

72 rng.choice(trailing_returns, size=sample_len - 1, replace=True) 

73 if sample_len > 1 

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

75 ) 

76 sim_path = np.cumprod(np.concatenate(([1.0], 1.0 + draws))) 

77 fig.add_trace( 

78 go.Scatter( 

79 x=dates, 

80 y=sim_path, 

81 mode="lines", 

82 name=f"{ticker} Sim", 

83 legendgroup=f"{ticker}_sim", 

84 showlegend=(i == 0), 

85 line={"color": _hex_to_rgba(colors[ticker], alpha=0.12), "width": 1}, 

86 hovertemplate=f"{ticker} Sim: %{{y:.2f}}x<extra></extra>", 

87 ) 

88 ) 

89 

90 observed_path = np.cumprod(np.concatenate(([1.0], 1.0 + trailing_returns))) 

91 fig.add_trace( 

92 go.Scatter( 

93 x=dates, 

94 y=observed_path, 

95 mode="lines", 

96 name=f"{ticker} Observed", 

97 legendgroup=f"{ticker}_obs", 

98 line={"color": colors[ticker], "width": 2.5}, 

99 hovertemplate=f"{ticker} Observed: %{{y:.2f}}x<extra></extra>", 

100 ) 

101 ) 

102 

103 _apply_base_layout(fig, title) 

104 fig.update_yaxes(title_text="Cumulative Return", tickformat=".2f") 

105 _apply_figsize(fig, figsize) 

106 return fig 

107 

108 def montecarlo_distribution( 

109 self, 

110 n: int = 1000, 

111 period: int = 252, 

112 metric: str = "sharpe", 

113 title: str = "Monte Carlo Distribution", 

114 figsize: tuple[int, int] | None = None, 

115 ) -> go.Figure: 

116 """Distribution of Monte Carlo simulation metrics. 

117 

118 Computes one metric per simulated path and shows the resulting 

119 distribution as a histogram with the observed trailing-period value 

120 overlaid as a vertical reference line. 

121 

122 Supported metrics: 

123 - ``"sharpe"`` (annualized, 252 periods/year) 

124 - ``"drawdown"`` (maximum drawdown, negative value) 

125 - ``"cagr"`` (annualized geometric return) 

126 

127 Args: 

128 n: Number of simulations per asset. Defaults to 1000. 

129 period: Number of observations in each simulation. Defaults to 252. 

130 metric: Metric to evaluate. One of ``"sharpe"``, ``"drawdown"``, 

131 or ``"cagr"``. 

132 title: Chart title. Defaults to ``"Monte Carlo Distribution"``. 

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

134 

135 Returns: 

136 go.Figure: Interactive Plotly histogram figure. 

137 

138 """ 

139 if n <= 0: 

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

141 if period <= 0: 

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

143 

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

145 if metric_key not in {"sharpe", "drawdown", "cagr"}: 

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

147 periods_per_year = 252.0 

148 

149 def _metric_value(returns: np.ndarray) -> float: 

150 """Compute the selected metric for a simulated return path.""" 

151 if metric_key == "sharpe": 

152 std = returns.std(ddof=1) 

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

154 if metric_key == "drawdown": 

155 path = np.cumprod(1.0 + returns) 

156 hwm = np.maximum.accumulate(path) 

157 dd = (path - hwm) / hwm 

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

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

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

161 

162 df = self._data.all 

163 date_col = df.columns[0] 

164 tickers = [c for c in df.columns if c != date_col] 

165 colors = _ticker_colors(tickers) 

166 sample_len = min(period, df.height) 

167 rng = np.random.default_rng(seed=42) 

168 

169 fig = go.Figure() 

170 for ticker in tickers: 

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

172 if hist_returns.size == 0: # pragma: no cover 

173 continue 

174 

175 simulated_metrics = [ 

176 _metric_value(rng.choice(hist_returns, size=sample_len, replace=True)) for _ in range(n) 

177 ] 

178 observed_metric = _metric_value(hist_returns) 

179 

180 fig.add_trace( 

181 go.Histogram( 

182 x=simulated_metrics, 

183 name=ticker, 

184 marker_color=colors[ticker], 

185 opacity=0.6, 

186 hovertemplate=f"{ticker}: %{{x:.4f}}<extra></extra>", 

187 ) 

188 ) 

189 fig.add_vline( 

190 x=observed_metric, 

191 line={"color": colors[ticker], "width": 2, "dash": "dash"}, 

192 annotation_text=f"{ticker} observed", 

193 annotation_position="top right", 

194 annotation_font_size=10, 

195 ) 

196 

197 metric_title = {"sharpe": "Sharpe Ratio", "drawdown": "Max Drawdown", "cagr": "CAGR"}[metric_key] 

198 _apply_base_layout(fig, title, with_range_selector=False) 

199 fig.update_layout(barmode="overlay") 

200 fig.update_xaxes(title_text=metric_title) 

201 fig.update_yaxes(title_text="Count") 

202 _apply_figsize(fig, figsize) 

203 return fig