Coverage for src/jquantstats/_plots/_specs/_drawdown.py: 100%
57 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 drawdown charts.
3Two views of the same thing: `drawdown_spec` plots the decline from the running
4peak directly, while `drawdowns_periods_spec` leaves the equity curve alone and
5shades the worst episodes on top of it.
6"""
8from __future__ import annotations
10from typing import TYPE_CHECKING, Any
12import polars as pl
14from .._spec import Axis, Band, FigureSpec, HoverSpec, LineSeries, Panel, RefLine
15from .._style import PALETTE, hex_to_rgba, ticker_colors
17if TYPE_CHECKING:
18 from jquantstats._protocol import DataLike
20__all__ = ["compute_drawdown_periods", "drawdown_spec", "drawdowns_periods_spec"]
22# The underwater curve's fill, and the shading over a drawdown episode. Both are
23# translucent so the line underneath stays readable.
24_FILL_ALPHA = 0.3
25_BAND_ALPHA = 0.2
27# The equity curve in the periods chart is a single fixed colour rather than a
28# palette entry: there is only ever one asset on it, so nothing to distinguish.
29_EQUITY_COLOR = "#1f77b4"
32def _split_columns(frame: pl.DataFrame) -> tuple[str, list[str]]:
33 """Separate the date column from the value columns.
35 Args:
36 frame: A frame whose first column is the date axis.
38 Returns:
39 tuple[str, list[str]]: The date column name and every other column.
41 """
42 date_col = frame.columns[0]
43 return date_col, [c for c in frame.columns if c != date_col]
46def compute_drawdown_periods(prices: list[float], n: int) -> list[dict[str, Any]]:
47 """Identify the *n* worst drawdown periods in a cumulative price series.
49 A period runs from the point the series falls below its running peak until
50 it recovers to it. Periods are ranked by depth, worst first.
52 Args:
53 prices: Cumulative price (NAV) values.
54 n: Maximum number of periods to return.
56 Returns:
57 list[dict[str, Any]]: Dicts with ``start_idx``, ``end_idx``,
58 ``valley_idx`` and ``max_drawdown`` (a fraction <= 0), worst first.
60 """
61 length = len(prices)
62 hwm: list[float] = [0.0] * length
63 hwm[0] = prices[0]
64 for i in range(1, length):
65 hwm[i] = max(hwm[i - 1], prices[i])
67 in_dd = [prices[i] < hwm[i] for i in range(length)]
68 periods: list[dict[str, Any]] = []
69 i = 0
70 while i < length:
71 if not in_dd[i]:
72 i += 1
73 continue
74 start = i
75 while i < length and in_dd[i]:
76 i += 1
77 end = i - 1
78 valley = start + min(range(end - start + 1), key=lambda k: prices[start + k])
79 max_dd = (prices[valley] - hwm[valley]) / hwm[valley]
80 periods.append({"start_idx": start, "end_idx": end, "valley_idx": valley, "max_drawdown": max_dd})
82 periods.sort(key=lambda p: p["max_drawdown"])
83 return periods[:n]
86def drawdown_spec(data: DataLike, title: str) -> FigureSpec:
87 """Describe the underwater equity curve.
89 Args:
90 data: The dataset to plot.
91 title: Chart title.
93 Returns:
94 FigureSpec: One filled series per column, with a break-even line.
96 """
97 df = data.all
98 date_col, tickers = _split_columns(df)
99 colors = ticker_colors(tickers)
100 prices = df.with_columns([(1.0 + pl.col(t)).cum_prod().alias(t) for t in tickers])
102 lines = []
103 for ticker in tickers:
104 price_s = prices[ticker]
105 hwm = price_s.cum_max()
106 lines.append(
107 LineSeries(
108 name=ticker,
109 x=prices[date_col],
110 y=((price_s - hwm) / hwm).to_list(),
111 color=colors[ticker],
112 width=1.5,
113 fill=True,
114 fill_color=hex_to_rgba(colors[ticker], _FILL_ALPHA),
115 hover=HoverSpec(label=ticker, value_format="percent2", date_header=False),
116 )
117 )
119 panel = Panel(
120 lines=tuple(lines),
121 ref_lines=(RefLine(value=0),),
122 yaxis=Axis(title="Drawdown", tick_format="percent0"),
123 )
124 return FigureSpec(title=title, panels=(panel,))
127def drawdowns_periods_spec(data: DataLike, n: int, title: str, asset: str | None) -> FigureSpec:
128 """Describe the equity curve with its worst drawdown episodes shaded.
130 One asset per chart: overlapping shaded spans from several assets would be
131 unreadable.
133 Args:
134 data: The dataset to plot.
135 n: How many of the worst episodes to shade.
136 title: Chart title, which gains the asset name.
137 asset: Asset column to display, or None for the first.
139 Returns:
140 FigureSpec: The equity curve, with one band per episode.
142 """
143 df = data.all
144 date_col, tickers = _split_columns(df)
145 col = asset if asset in tickers else tickers[0]
147 price_list = (1.0 + df[col].cast(pl.Float64)).cum_prod().to_list()
148 dates = df[date_col].to_list()
150 bands = []
151 for i, period in enumerate(compute_drawdown_periods(price_list, n)):
152 bands.append(
153 Band(
154 x0=dates[period["start_idx"]],
155 # Extend to the next point so the span covers the final day of
156 # the episode rather than stopping at its left edge.
157 x1=dates[min(period["end_idx"] + 1, len(dates) - 1)],
158 color=hex_to_rgba(PALETTE[i % len(PALETTE)], alpha=_BAND_ALPHA),
159 label=f"#{i + 1} {period['max_drawdown']:.1%}",
160 )
161 )
163 panel = Panel(
164 lines=(
165 LineSeries(
166 name=col,
167 x=dates,
168 y=price_list,
169 color=_EQUITY_COLOR,
170 hover=HoverSpec(label=col, value_format="float2", suffix="x"),
171 ),
172 ),
173 bands=tuple(bands),
174 yaxis=Axis(title="Cumulative Return", tick_format="float2"),
175 )
176 return FigureSpec(title=f"{title} — {col}", panels=(panel,))