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

1"""Spec builders for the two dashboards and the NAV comparison charts. 

2 

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""" 

7 

8from __future__ import annotations 

9 

10from typing import TYPE_CHECKING 

11 

12import polars as pl 

13 

14from .._spec import Axis, BarSeries, FigureSpec, HoverSpec, LineSeries, Panel, RefLine 

15from .._style import hex_to_rgba, ticker_colors 

16 

17if TYPE_CHECKING: 

18 from jquantstats._protocol import DataLike 

19 

20 from .._protocol import PortfolioLike 

21 

22__all__ = [ 

23 "data_snapshot_spec", 

24 "lagged_performance_spec", 

25 "portfolio_snapshot_spec", 

26 "smoothed_holdings_performance_spec", 

27] 

28 

29# A dashboard is tall: three panels of detail need the room. 

30_DASHBOARD_HEIGHT_PX = 1200 

31_PANEL_GAP = 0.05 

32 

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) 

36 

37# Drawdown fill and the faded half of a two-tone bar. 

38_LIGHT_ALPHA = 0.5 

39_MONTHLY_OPACITY = 0.8 

40 

41# With one asset a bar's colour can carry the sign outright. 

42_SINGLE_POSITIVE = "green" 

43_SINGLE_NEGATIVE = "red" 

44 

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 

48 

49_DEFAULT_LAGS = [0, 1, 2, 3, 4] 

50 

51 

52def _split_columns(frame: pl.DataFrame) -> tuple[str, list[str]]: 

53 """Separate the date column from the value columns. 

54 

55 Args: 

56 frame: A frame whose first column is the date axis. 

57 

58 Returns: 

59 tuple[str, list[str]]: The date column name and every other column. 

60 

61 """ 

62 date_col = frame.columns[0] 

63 return date_col, [c for c in frame.columns if c != date_col] 

64 

65 

66def data_snapshot_spec(data: DataLike, log_scale: bool) -> FigureSpec: 

67 """Describe the three-panel returns dashboard. 

68 

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. 

71 

72 Args: 

73 data: The dataset to plot. 

74 log_scale: Use a logarithmic scale for cumulative returns. 

75 

76 Returns: 

77 FigureSpec: Three stacked panels sharing an x-axis. 

78 

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} 

84 

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]) 

89 

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 ) 

107 

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 ) 

129 

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 ) 

149 

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 ) 

158 

159 

160def _drawdown_values(prices: pl.DataFrame, price_col: str) -> list[float]: 

161 """Decline from the running peak, as a fraction. 

162 

163 Args: 

164 prices: Frame holding the cumulative price series. 

165 price_col: Column to measure. 

166 

167 Returns: 

168 list[float]: One value per observation, at most zero. 

169 

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() 

174 

175 

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. 

178 

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. 

185 

186 Returns: 

187 list[str]: One colour per value. 

188 

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] 

193 

194 

195def portfolio_snapshot_spec(portfolio: PortfolioLike, log_scale: bool) -> FigureSpec: 

196 """Describe the two-panel portfolio dashboard. 

197 

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. 

200 

201 Args: 

202 portfolio: The portfolio to plot. 

203 log_scale: Use a logarithmic scale for NAV. 

204 

205 Returns: 

206 FigureSpec: Two stacked panels sharing an x-axis. 

207 

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 ] 

218 

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 ) 

232 

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 ) 

239 

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 ) 

256 

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 ) 

265 

266 

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. 

273 

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. 

278 

279 Returns: 

280 FigureSpec: One line per curve. 

281 

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,)) 

291 

292 

293def lagged_performance_spec(portfolio: PortfolioLike, lags: list[int] | None, log_scale: bool) -> FigureSpec: 

294 """Describe the NAV curves of several execution-delayed portfolios. 

295 

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. 

300 

301 Returns: 

302 FigureSpec: One line per lag. 

303 

304 Raises: 

305 TypeError: If *lags* is not a list of integers. 

306 

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 

311 

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) 

314 

315 

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. 

322 

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. 

327 

328 Returns: 

329 FigureSpec: One line per smoothing level. 

330 

331 Raises: 

332 TypeError: If *windows* is not a list of non-negative integers. 

333 

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 

338 

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)