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
« 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)."""
3from __future__ import annotations
5import math
6from typing import TYPE_CHECKING
8import numpy as np
9import plotly.graph_objects as go
10import polars as pl
12from ._styling import _apply_base_layout, _apply_figsize, _hex_to_rgba, _ticker_colors
14if TYPE_CHECKING:
15 from jquantstats._protocol import DataLike
18class _MonteCarloPlotsMixin:
19 """Monte Carlo simulation plots for :class:`DataPlots`."""
21 __slots__ = ()
23 _data: DataLike
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.
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.
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.
44 Returns:
45 go.Figure: Interactive Plotly fan chart.
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
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)
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)
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 )
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 )
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 )
103 _apply_base_layout(fig, title)
104 fig.update_yaxes(title_text="Cumulative Return", tickformat=".2f")
105 _apply_figsize(fig, figsize)
106 return fig
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.
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.
122 Supported metrics:
123 - ``"sharpe"`` (annualized, 252 periods/year)
124 - ``"drawdown"`` (maximum drawdown, negative value)
125 - ``"cagr"`` (annualized geometric return)
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.
135 Returns:
136 go.Figure: Interactive Plotly histogram figure.
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
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
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
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)
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
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)
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 )
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