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
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-23 04:11 +0000
1"""Spec builders for the Monte Carlo charts.
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"""
8from __future__ import annotations
10import math
11from typing import TYPE_CHECKING
13import numpy as np
14import polars as pl
16from .._spec import Axis, FigureSpec, HistogramSeries, HoverSpec, LineSeries, Panel, RefLine
17from .._style import hex_to_rgba, ticker_colors
19if TYPE_CHECKING:
20 from jquantstats._protocol import DataLike
22__all__ = ["METRICS", "montecarlo_distribution_spec", "montecarlo_spec"]
24#: Metrics a simulated path can be reduced to, and how each is labelled.
25METRICS = {"sharpe": "Sharpe Ratio", "drawdown": "Max Drawdown", "cagr": "CAGR"}
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
33_OVERLAY_OPACITY = 0.6
34_PERIODS_PER_YEAR = 252.0
36# Fixed so a chart is reproducible: the same data always yields the same fan.
37_SEED = 42
40def _split_columns(frame: pl.DataFrame) -> tuple[str, list[str]]:
41 """Separate the date column from the value columns.
43 Args:
44 frame: A frame whose first column is the date axis.
46 Returns:
47 tuple[str, list[str]]: The date column name and every other column.
49 """
50 date_col = frame.columns[0]
51 return date_col, [c for c in frame.columns if c != date_col]
54def _require_positive(n: int, period: int) -> None:
55 """Reject non-positive simulation counts or path lengths.
57 Args:
58 n: Number of simulations.
59 period: Observations per simulation.
61 Raises:
62 ValueError: If either is not positive.
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
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.
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.
87 Returns:
88 FigureSpec: *n* faint simulated paths per asset, with the observed
89 path drawn over them.
91 Raises:
92 ValueError: If *n* or *period* is not positive.
94 """
95 _require_positive(n, period)
97 df = data.all
98 date_col, tickers = _split_columns(df)
99 colors = ticker_colors(tickers)
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)
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 )
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 )
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 )
157 panel = Panel(lines=tuple(lines), yaxis=Axis(title="Cumulative Return", tick_format="float2"))
158 return FigureSpec(title=title, panels=(panel,), figsize=figsize)
161def _metric_value(returns: np.ndarray, metric_key: str) -> float:
162 """Reduce one simulated return path to a single number.
164 Args:
165 returns: The path's per-period returns.
166 metric_key: One of ``"sharpe"``, ``"drawdown"`` or ``"cagr"``.
168 Returns:
169 float: The metric's value for this path.
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
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.
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.
202 Returns:
203 FigureSpec: One histogram per asset, each with a marker at the
204 observed value.
206 Raises:
207 ValueError: If *n* or *period* is not positive, or *metric* is not one
208 of the supported names.
210 """
211 _require_positive(n, period)
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
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)
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
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 )
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 )