Coverage for src/jquantstats/_plots/_specs/_dashboard.py: 100%
64 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 two dashboards and the NAV comparison charts.
3A dashboard stacks several views of the same period over a shared time axis, so
4a drawdown lines up with the return that caused it. `data_snapshot_spec` builds
5the returns-series version and `portfolio_snapshot_spec` the portfolio one.
6"""
8from __future__ import annotations
10from typing import TYPE_CHECKING
12import polars as pl
14from .._spec import Axis, BarSeries, FigureSpec, HoverSpec, LineSeries, Panel, RefLine
15from .._style import hex_to_rgba, ticker_colors
17if TYPE_CHECKING:
18 from jquantstats._protocol import DataLike
20 from .._protocol import PortfolioLike
22__all__ = [
23 "data_snapshot_spec",
24 "lagged_performance_spec",
25 "portfolio_snapshot_spec",
26 "smoothed_holdings_performance_spec",
27]
29# A dashboard is tall: three panels of detail need the room.
30_DASHBOARD_HEIGHT_PX = 1200
31_PANEL_GAP = 0.05
33# The headline panel earns the most height; the supporting ones split the rest.
34_DATA_PANEL_HEIGHTS = (0.5, 0.25, 0.25)
35_PORTFOLIO_PANEL_HEIGHTS = (0.66, 0.33)
37# Drawdown fill and the faded half of a two-tone bar.
38_LIGHT_ALPHA = 0.5
39_MONTHLY_OPACITY = 0.8
41# With one asset a bar's colour can carry the sign outright.
42_SINGLE_POSITIVE = "green"
43_SINGLE_NEGATIVE = "red"
45# NAV comparison lines are drawn fine: there is one per lag or window, and the
46# point is the spread between them.
47_NAV_WIDTH = 1
49_DEFAULT_LAGS = [0, 1, 2, 3, 4]
52def _split_columns(frame: pl.DataFrame) -> tuple[str, list[str]]:
53 """Separate the date column from the value columns.
55 Args:
56 frame: A frame whose first column is the date axis.
58 Returns:
59 tuple[str, list[str]]: The date column name and every other column.
61 """
62 date_col = frame.columns[0]
63 return date_col, [c for c in frame.columns if c != date_col]
66def data_snapshot_spec(data: DataLike, log_scale: bool) -> FigureSpec:
67 """Describe the three-panel returns dashboard.
69 Cumulative returns, drawdowns and monthly returns over one shared time
70 axis, so a drawdown can be read against the months that produced it.
72 Args:
73 data: The dataset to plot.
74 log_scale: Use a logarithmic scale for cumulative returns.
76 Returns:
77 FigureSpec: Three stacked panels sharing an x-axis.
79 """
80 returns = data.all
81 date_col, tickers = _split_columns(returns)
82 colors = ticker_colors(tickers)
83 light = {ticker: hex_to_rgba(colors[ticker], _LIGHT_ALPHA) for ticker in tickers}
85 prices = returns.with_columns([((1 + pl.col(t)).cum_prod()).alias(f"{t}_price") for t in tickers])
86 monthly = returns.group_by_dynamic(
87 index_column=date_col, every="1mo", period="1mo", closed="right", label="right"
88 ).agg([((pl.col(t) + 1.0).product() - 1.0).alias(t) for t in tickers])
90 cumulative = Panel(
91 lines=tuple(
92 LineSeries(
93 name=ticker,
94 x=prices[date_col],
95 y=prices[f"{ticker}_price"],
96 color=colors[ticker],
97 legend_group=ticker,
98 show_legend=True,
99 hover=HoverSpec(label=ticker, value_format="float2", suffix="x"),
100 )
101 for ticker in tickers
102 ),
103 title="Cumulative Returns",
104 yaxis=Axis(title="Cumulative Return", tick_format="float2", log=log_scale),
105 height_ratio=_DATA_PANEL_HEIGHTS[0],
106 )
108 drawdowns = Panel(
109 lines=tuple(
110 LineSeries(
111 name=ticker,
112 x=prices[date_col],
113 y=_drawdown_values(prices, f"{ticker}_price"),
114 color=colors[ticker],
115 width=1,
116 fill=True,
117 fill_color=light[ticker],
118 legend_group=ticker,
119 show_legend=False,
120 hover=HoverSpec(label=f"{ticker} Drawdown", value_format="percent2", date_header=False),
121 )
122 for ticker in tickers
123 ),
124 ref_lines=(RefLine(value=0),),
125 title="Drawdowns",
126 yaxis=Axis(title="Drawdown", tick_format="percent0"),
127 height_ratio=_DATA_PANEL_HEIGHTS[1],
128 )
130 single = len(tickers) == 1
131 monthly_panel = Panel(
132 bars=tuple(
133 BarSeries(
134 name=ticker,
135 x=monthly[date_col],
136 y=monthly[ticker],
137 colors=tuple(_monthly_colors(monthly[ticker].to_list(), colors[ticker], light[ticker], single=single)),
138 opacity=_MONTHLY_OPACITY,
139 legend_group=ticker,
140 show_legend=False,
141 hover=HoverSpec(label=f"{ticker} Monthly Return", value_format="percent2", date_header=False),
142 )
143 for ticker in tickers
144 ),
145 title="Monthly Returns",
146 yaxis=Axis(title="Monthly Return", tick_format="percent0"),
147 height_ratio=_DATA_PANEL_HEIGHTS[2],
148 )
150 return FigureSpec(
151 title=f"{' vs '.join(tickers)} Performance Dashboard",
152 panels=(cumulative, drawdowns, monthly_panel),
153 height=_DASHBOARD_HEIGHT_PX,
154 arrangement="stacked",
155 shared_x=True,
156 vertical_spacing=_PANEL_GAP,
157 )
160def _drawdown_values(prices: pl.DataFrame, price_col: str) -> list[float]:
161 """Decline from the running peak, as a fraction.
163 Args:
164 prices: Frame holding the cumulative price series.
165 price_col: Column to measure.
167 Returns:
168 list[float]: One value per observation, at most zero.
170 """
171 series = prices[price_col]
172 cummax = prices.select(pl.col(price_col).cum_max().alias("cummax"))["cummax"]
173 return ((series - cummax) / cummax).to_list()
176def _monthly_colors(values: list[float], color: str, light: str, *, single: bool) -> list[str]:
177 """Colour each monthly bar by the sign of its return.
179 Args:
180 values: The monthly returns.
181 color: The asset's base colour.
182 light: A faded version of it, for negative months.
183 single: Whether the dataset holds exactly one asset, which allows
184 plain green and red.
186 Returns:
187 list[str]: One colour per value.
189 """
190 if single:
191 return [_SINGLE_POSITIVE if value > 0 else _SINGLE_NEGATIVE for value in values]
192 return [color if value > 0 else light for value in values]
195def portfolio_snapshot_spec(portfolio: PortfolioLike, log_scale: bool) -> FigureSpec:
196 """Describe the two-panel portfolio dashboard.
198 Accumulated NAV — with its tilt and timing components, and the net-of-cost
199 path when a cost model is active — over the drawdown it produced.
201 Args:
202 portfolio: The portfolio to plot.
203 log_scale: Use a logarithmic scale for NAV.
205 Returns:
206 FigureSpec: Two stacked panels sharing an x-axis.
208 """
209 components = [
210 ("NAV", portfolio.nav_accumulated),
211 ("Tilt", portfolio.tilt.nav_accumulated),
212 ("Timing", portfolio.timing.nav_accumulated),
213 ]
214 lines = [
215 LineSeries(name=name, x=frame["date"], y=frame["NAV_accumulated"], show_legend=False)
216 for name, frame in components
217 ]
219 if portfolio.cost_model.cost_per_unit > 0:
220 net = portfolio.net_cost_nav
221 lines.append(
222 LineSeries(
223 name="Net-of-Cost NAV",
224 # The frame may not carry a date column; the renderer then
225 # numbers the points, matching what this chart already did.
226 x=net["date"] if "date" in net.columns else None,
227 y=net["NAV_accumulated_net"],
228 dash="dash",
229 show_legend=True,
230 )
231 )
233 nav = Panel(
234 lines=tuple(lines),
235 title="Accumulated Profit",
236 yaxis=Axis(title="NAV (accumulated)", tick_format="si2", log=log_scale),
237 height_ratio=_PORTFOLIO_PANEL_HEIGHTS[0],
238 )
240 drawdown = portfolio.drawdown
241 drawdown_panel = Panel(
242 lines=(
243 LineSeries(
244 name="Drawdown",
245 x=drawdown["date"],
246 y=drawdown["drawdown_pct"],
247 fill=True,
248 show_legend=False,
249 ),
250 ),
251 ref_lines=(RefLine(value=0),),
252 title="Drawdown",
253 yaxis=Axis(title="Drawdown", tick_format="percent0"),
254 height_ratio=_PORTFOLIO_PANEL_HEIGHTS[1],
255 )
257 return FigureSpec(
258 title="Performance Dashboard",
259 panels=(nav, drawdown_panel),
260 height=_DASHBOARD_HEIGHT_PX,
261 arrangement="stacked",
262 shared_x=True,
263 vertical_spacing=_PANEL_GAP,
264 )
267def _nav_comparison_spec(
268 curves: list[tuple[str, pl.DataFrame]],
269 title: str,
270 log_scale: bool,
271) -> FigureSpec:
272 """Describe a set of NAV curves drawn on one pair of axes.
274 Args:
275 curves: ``(label, frame)`` pairs, each frame holding a NAV series.
276 title: Chart title.
277 log_scale: Use a logarithmic y-axis.
279 Returns:
280 FigureSpec: One line per curve.
282 """
283 panel = Panel(
284 lines=tuple(
285 LineSeries(name=label, x=frame["date"], y=frame["NAV_accumulated"], width=_NAV_WIDTH)
286 for label, frame in curves
287 ),
288 yaxis=Axis(title="NAV (accumulated)", log=log_scale),
289 )
290 return FigureSpec(title=title, panels=(panel,))
293def lagged_performance_spec(portfolio: PortfolioLike, lags: list[int] | None, log_scale: bool) -> FigureSpec:
294 """Describe the NAV curves of several execution-delayed portfolios.
296 Args:
297 portfolio: The portfolio to plot.
298 lags: Integer lags to apply, or None for 0 through 4.
299 log_scale: Use a logarithmic y-axis.
301 Returns:
302 FigureSpec: One line per lag.
304 Raises:
305 TypeError: If *lags* is not a list of integers.
307 """
308 lags = _DEFAULT_LAGS if lags is None else lags
309 if not isinstance(lags, list) or not all(isinstance(x, int) for x in lags):
310 raise TypeError
312 curves = [(f"lag {lag}", (portfolio if lag == 0 else portfolio.lag(lag)).nav_accumulated) for lag in lags]
313 return _nav_comparison_spec(curves, "NAV accumulated by lag", log_scale)
316def smoothed_holdings_performance_spec(
317 portfolio: PortfolioLike,
318 windows: list[int] | None,
319 log_scale: bool,
320) -> FigureSpec:
321 """Describe the NAV curves of several smoothed-holding portfolios.
323 Args:
324 portfolio: The portfolio to plot.
325 windows: Smoothing step counts, or None for 0 through 4.
326 log_scale: Use a logarithmic y-axis.
328 Returns:
329 FigureSpec: One line per smoothing level.
331 Raises:
332 TypeError: If *windows* is not a list of non-negative integers.
334 """
335 windows = _DEFAULT_LAGS if windows is None else windows
336 if not isinstance(windows, list) or not all(isinstance(x, int) and x >= 0 for x in windows):
337 raise TypeError
339 curves = [
340 (f"smooth {n}", (portfolio if n == 0 else portfolio.smoothed_holding(n)).nav_accumulated) for n in windows
341 ]
342 return _nav_comparison_spec(curves, "NAV accumulated by smoothed holdings", log_scale)