Coverage for src/jointview/plot.py: 100%

63 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-17 06:44 +0000

1"""Two price or NAV series drawn as two lines on one pair of axes. 

2 

3The two columns are put on a shared y-axis rather than one axis each: two scales on 

4one plot invent a relationship that is not in the data. Where the levels are far 

5apart, ``rebase`` indexes both to the same starting value instead, which is the 

6honest way to compare a series priced at 12 with one priced at 4,000. 

7 

8Choosing the columns and cutting them to their common sample happens before any of 

9this, in :mod:`jointview.columns`. 

10""" 

11 

12from __future__ import annotations 

13 

14import math 

15from typing import Literal, cast 

16 

17import altair as alt 

18import polars as pl 

19 

20from jointview.columns import PERIOD, aligned, date_column 

21 

22# Categorical slots 1 and 2 (blue, orange). The pair clears the contrast, chroma and 

23# colour-vision separation floors against both the light (#fcfcfb) and the dark 

24# (#1a1a19) chart surface, so it survives marimo's theme switch without being 

25# redefined. CONTEXT is chrome — the crosshair — not a series. 

26SERIES = ("#2a78d6", "#d95926") 

27CONTEXT = "#8a8a84" 

28 

29BASE = 100.0 

30MAX_POINTS = 4_000 

31 

32# Narrower than `str`, because altair's `type=` only accepts its four measurement kinds 

33# and these are the two an x-axis of periods can be. Inside one function the literal was 

34# inferred; passing it between them needs the type spelled out. 

35XType = Literal["temporal", "quantitative"] 

36 

37 

38def line_frame( 

39 frame: pl.DataFrame, 

40 a: str, 

41 b: str, 

42 *, 

43 rebase: bool = True, 

44 base: float = BASE, 

45 max_points: int = MAX_POINTS, 

46) -> pl.DataFrame: 

47 """What the chart draws: one ``period`` column and one column per named series. 

48 

49 Wide rather than long, because the crosshair reads every series at the hovered 

50 period out of a single row. 

51 

52 >>> import polars as pl 

53 >>> frame = pl.DataFrame({"cash": [1.0, 1.01, 1.02], "balanced": [1450.0, 1479.0, 1465.0]}) 

54 >>> drawn = line_frame(frame, "cash", "balanced") 

55 >>> drawn.columns 

56 ['period', 'cash', 'balanced'] 

57 

58 Rebasing is what lets those two share a y-axis at all: both leave the first 

59 period at ``base``, whatever they were priced at. 

60 

61 >>> round(drawn["cash"][0], 6), round(drawn["balanced"][0], 6) 

62 (100.0, 100.0) 

63 

64 A column against itself is one line rather than two identical ones: 

65 

66 >>> line_frame(frame, "cash", "cash").columns 

67 ['period', 'cash'] 

68 

69 A series starting at zero cannot be indexed — a cumulative P&L curve starts there 

70 by construction — so the pair keeps its own levels instead: 

71 

72 >>> pnl = pl.DataFrame({"strategy": [0.0, 5.0, 3.0], "benchmark": [0.0, 2.0, 4.0]}) 

73 >>> line_frame(pnl, "strategy", "benchmark")["strategy"].to_list() 

74 [0.0, 5.0, 3.0] 

75 """ 

76 wide, _ = _wide(frame, a, b, rebase=rebase, base=base, max_points=max_points) 

77 return wide 

78 

79 

80def line_chart( 

81 frame: pl.DataFrame, 

82 a: str, 

83 b: str, 

84 *, 

85 rebase: bool = True, 

86 base: float = BASE, 

87 width: int | str = "container", 

88 height: int | str = 700, 

89 max_points: int = MAX_POINTS, 

90) -> alt.LayerChart: 

91 """Draw columns ``a`` and ``b`` of ``frame`` as two lines against time. 

92 

93 Four layers over one plotting area — the crosshair, the lines, the hover markers 

94 and the end labels — handed back as a plain Altair chart, so nothing here needs 

95 marimo to draw it: 

96 

97 >>> import polars as pl 

98 >>> frame = pl.DataFrame({"cash": [1.0, 1.01, 1.02], "balanced": [1450.0, 1479.0, 1465.0]}) 

99 >>> chart = line_chart(frame, "cash", "balanced") 

100 >>> type(chart).__name__ 

101 'LayerChart' 

102 >>> len(chart.to_dict()["layer"]) 

103 4 

104 

105 Width defaults to ``"container"``: the plot takes whatever the column around it 

106 gives it, which is the point of a full-width app. Height stays a number, because 

107 nothing in the page has a height for a chart to follow — 700 fills a laptop window 

108 once the notebook margins are out of the way, without spilling off a short one. 

109 

110 Asking to rebase a pair that cannot be indexed draws the raw levels, and the 

111 y-axis says ``level`` rather than claiming otherwise — see :func:`_rebasable`. 

112 The title is read back out of the compiled spec, because that is the only place it 

113 exists; layer 1 is :func:`_lines`, the series themselves: 

114 

115 >>> chart.to_dict()["layer"][1]["encoding"]["y"]["title"] 

116 'indexed to 100' 

117 >>> pnl = pl.DataFrame({"strategy": [0.0, 5.0, 3.0], "benchmark": [0.0, 2.0, 4.0]}) 

118 >>> line_chart(pnl, "strategy", "benchmark").to_dict()["layer"][1]["encoding"]["y"]["title"] 

119 'level' 

120 """ 

121 wide, rebased = _wide(frame, a, b, rebase=rebase, base=base, max_points=max_points) 

122 names = [name for name in wide.columns if name != PERIOD] 

123 

124 date = date_column(frame) 

125 x_type: XType = "temporal" if date else "quantitative" 

126 x_title = date or "row" 

127 # `rebased`, not `rebase`: the title names what the numbers underneath actually are. 

128 y_title = f"indexed to {base:g}" if rebased else "level" 

129 x = alt.X(PERIOD, type=x_type, title=x_title) 

130 

131 # Made here rather than inside a layer because two of them share it: the crosshair 

132 # carries the parameter, the markers only read it. 

133 hover = _hover() 

134 lines = _lines(wide, names, x, y_title) 

135 return ( 

136 alt.layer( 

137 _crosshair(wide, names, x, x_type, x_title, hover), 

138 lines, 

139 _markers(lines, hover), 

140 _end_labels(wide, names, x), 

141 ) 

142 .resolve_scale(color="shared") 

143 .configure_axis(grid=True, gridOpacity=0.3, domain=False, labelPadding=4, tickSize=4) 

144 .configure_view(stroke=None) 

145 .configure_legend(labelFontSize=12) 

146 .properties( 

147 # One plotting area for all four layers, sized at the top level so a 

148 # container width is measured once rather than per layer. 

149 width=width, 

150 height=height, 

151 # Only the right margin earns its keep: it is where the end labels go. 

152 padding={"left": 0, "top": 0, "bottom": 0, "right": 76}, 

153 # "pad", the default, grows the figure past the box it was given and puts 

154 # the gutter back; fitting spends the padding out of the size instead. 

155 autosize=_autosize(width, height), 

156 ) 

157 ) 

158 

159 

160def _wide( 

161 frame: pl.DataFrame, 

162 a: str, 

163 b: str, 

164 *, 

165 rebase: bool, 

166 base: float, 

167 max_points: int, 

168) -> tuple[pl.DataFrame, bool]: 

169 """The frame the chart is drawn from, and whether it ended up indexed after all. 

170 

171 One decision point for two callers. :func:`line_frame` wants the frame; 

172 :func:`line_chart` wants the answer too, because an axis labelled "indexed to 100" 

173 over unindexed levels is a wrong label rather than a missing one. 

174 """ 

175 data = aligned(frame, a, b) 

176 names = [a] if a == b else [a, b] 

177 # `aligned` always writes both, so a column against itself takes only the first. 

178 sources = ["a", "b"][: len(names)] 

179 rebased = rebase and _rebasable(data, sources) 

180 

181 columns = [pl.col(source).alias(name) for source, name in zip(sources, names, strict=True)] 

182 if rebased: 

183 columns = [column / column.first() * base for column in columns] 

184 

185 return _thin(data.select(PERIOD, *columns), max_points), rebased 

186 

187 

188def _rebasable(data: pl.DataFrame, sources: list[str]) -> bool: 

189 """Whether dividing by the first value leaves a number for every series. 

190 

191 A first value of zero is not a broken frame — a cumulative P&L curve starts there 

192 by construction — but dividing by it turns the rest of the line into infinities, 

193 and Vega-Lite drops those silently. The reader picks two series, gets one, and 

194 nothing on the plot says where the other went. 

195 

196 Answered for the pair together rather than per series. Indexing one to 100 while 

197 the other kept its own units would put two unrelated scales on one axis, which is 

198 the relationship this module exists in order not to invent. 

199 

200 A pair with no overlap has no first value to divide by, so there is nothing to 

201 check and nothing to break: an empty frame comes out empty either way. 

202 """ 

203 firsts = [value for row in data.head(1).select(sources).rows() for value in row] 

204 return all(value != 0.0 and math.isfinite(value) for value in firsts) 

205 

206 

207def _hover() -> alt.Parameter: 

208 """Which period the pointer is nearest, shared by the crosshair and the markers. 

209 

210 Nearest-point rather than a hit on the mark itself: nobody can be asked to hover a 

211 2px line. It resolves to a period, not to a series, which is what lets one tooltip 

212 report every line at that x. 

213 """ 

214 return alt.selection_point( 

215 name="hover", 

216 fields=[PERIOD], 

217 nearest=True, 

218 on="pointerover", 

219 clear="pointerout", 

220 empty=False, 

221 ) 

222 

223 

224def _lines(wide: pl.DataFrame, names: list[str], x: alt.X, y_title: str) -> alt.Chart: 

225 """The series themselves — the layer everything else is chrome around. 

226 

227 Drawn from the long form, because one line per ``series`` value is what lets a 

228 single colour encoding paint both. 

229 """ 

230 long = wide.unpivot(index=PERIOD, variable_name="series", value_name="value") 

231 colour = alt.Color( 

232 "series", 

233 type="nominal", 

234 title=None, 

235 # Bound to the names, so picking a different pair never repaints a series that 

236 # stayed on screen. 

237 scale=alt.Scale(domain=names, range=list(SERIES[: len(names)])), 

238 legend=alt.Legend(orient="top", offset=4, symbolType="stroke", symbolStrokeWidth=2), 

239 ) 

240 # The `ty: ignore` here and in the layers below is altair's `mark_*` returning an 

241 # unresolved TypeVar rather than a chart, so the checker cannot see `.encode` on it. 

242 # It is a limitation of the stubs, not of the call — the same chain is what altair's 

243 # own documentation shows. 

244 return ( 

245 alt.Chart(long) # ty: ignore[unresolved-attribute] 

246 .mark_line(strokeWidth=2, clip=True) 

247 .encode( 

248 x, 

249 # The levels frame the data; a zero baseline on a NAV is wasted panel. 

250 alt.Y("value", type="quantitative", title=y_title, scale=alt.Scale(zero=False)), 

251 colour, 

252 ) 

253 ) 

254 

255 

256def _crosshair( 

257 wide: pl.DataFrame, 

258 names: list[str], 

259 x: alt.X, 

260 x_type: XType, 

261 x_title: str, 

262 hover: alt.Parameter, 

263) -> alt.Chart: 

264 """The vertical rule under the pointer, carrying the tooltip for every series. 

265 

266 Drawn from the wide form: the tooltip reads all the series at the hovered period 

267 out of a single row, which is the whole reason :func:`line_frame` is wide. 

268 """ 

269 return ( 

270 alt.Chart(wide) # ty: ignore[unresolved-attribute] 

271 .mark_rule(color=CONTEXT, strokeWidth=1) 

272 .encode( 

273 x, 

274 opacity=alt.condition(hover, alt.value(0.6), alt.value(0.0)), 

275 tooltip=[ 

276 alt.Tooltip(PERIOD, type=x_type, title=x_title), 

277 *(alt.Tooltip(name, type="quantitative", format=",.2f") for name in names), 

278 ], 

279 ) 

280 .add_params(hover) 

281 ) 

282 

283 

284def _markers(lines: alt.Chart, hover: alt.Parameter) -> alt.Chart: 

285 """A dot on each line at the hovered period — the lines' own marks, made visible. 

286 

287 Built off ``lines`` rather than from scratch so the points inherit its data and 

288 colour encoding, and cannot drift from the curve they sit on. 

289 """ 

290 return lines.mark_point(size=64, filled=True).encode( # ty: ignore[unresolved-attribute] 

291 opacity=alt.condition(hover, alt.value(1.0), alt.value(0.0)) 

292 ) 

293 

294 

295def _autosize(width: int | str, height: int | str) -> alt.AutoSizeParams: 

296 """Fit whichever axes were asked to follow their container, and leave the rest. 

297 

298 A figure given two numbers keeps Vega-Lite's own ``pad``: it was asked for a 

299 drawing of exactly that size, and fitting would quietly shrink it. 

300 """ 

301 follows = ((width == "container", "x"), (height == "container", "y")) 

302 axes = "".join(axis for container, axis in follows if container) 

303 # Built from the axes rather than spelled out, so the cast is what tells the type 

304 # checker that the four strings this can produce are exactly Vega-Lite's four. 

305 kind = cast( 

306 "Literal['pad', 'fit', 'fit-x', 'fit-y']", 

307 {"": "pad", "xy": "fit"}.get(axes, f"fit-{axes}"), 

308 ) 

309 return alt.AutoSizeParams(type=kind, contains="padding") 

310 

311 

312def _end_labels(wide: pl.DataFrame, names: list[str], x: alt.X) -> alt.LayerChart: 

313 """The series name at the end of its own line, so identity is never colour alone. 

314 

315 The labels wear chrome ink rather than the series colour — the line they sit on 

316 carries the identity — and the upper one is nudged up, the lower one down, so a 

317 pair that ends at the same level does not print on top of itself. 

318 """ 

319 last = wide.tail(1) 

320 order = sorted(names, key=lambda name: -float(last[name][0])) 

321 dodge = dict(zip(order, (-8, 8), strict=False)) if len(order) > 1 else {order[0]: 0} 

322 

323 # alt.layer widens to LayerChart | FacetChart for the general case; none of these 

324 # labels is faceted, so the layer it returns is always the former. 

325 return cast( 

326 "alt.LayerChart", 

327 alt.layer( 

328 *( 

329 alt.Chart(last.select(PERIOD, pl.col(name).alias("value"))) # ty: ignore[unresolved-attribute] 

330 .mark_text(align="left", dx=8, dy=dodge[name], fontSize=11, fontWeight=600, color=CONTEXT) 

331 .encode(x, alt.Y("value", type="quantitative"), text=alt.value(name)) 

332 for name in names 

333 ) 

334 ), 

335 ) 

336 

337 

338def _thin(frame: pl.DataFrame, max_points: int) -> pl.DataFrame: 

339 """Every k-th row of an over-long curve, with the last one kept. 

340 

341 A line is a shape, not a scatter: dropping intermediate points leaves the shape 

342 intact, and it is the browser rather than the reader that notices the difference. 

343 """ 

344 if frame.height <= max_points or max_points < 2: 

345 return frame 

346 

347 # Counting gaps rather than rows: keeping the last point costs one of the budget. 

348 stride = -(-(frame.height - 1) // (max_points - 1)) 

349 index = pl.int_range(pl.len()) 

350 return frame.filter((index % stride == 0) | (index == frame.height - 1))