Files
manifoldbt/docs/plotly_migration_plan.md
T
2026-07-15 00:01:41 +00:00

5.0 KiB

Plan: replace matplotlib with plotly in manifoldbt.plot

Branch: feat/plot-plotly-backend

Why

Decision from the plotting benchmark (research/plotting_bench/): plotly is the single interactive renderer going forward. It has a native Python API (already an optional dependency and already used by chart(interactive=True)), covers 2D and 3D, has no watermark or attribution constraint (MIT), and its rendered output is markedly more modern than the current matplotlib charts. Every chart gains crosshair, hover tooltips, wheel-zoom and pan for free, and the tearsheet upgrades from static base64 PNGs to fully interactive embedded charts.

Scope

crates/bt-python/python/manifoldbt/plot/ (2,922 lines, 19 public functions) plus sweep.py:plot_metric and packaging metadata. The Rust side is untouched.

Public API contract (kept)

Every public function keeps its name, module, required arguments and data semantics. What changes:

Aspect Before After
Return type matplotlib Figure plotly go.Figure
show=True plt.show() window browser tab (plotly fig.show())
save= .png via Agg .html (interactive, responsive) or .png/.svg/.pdf via kaleido
ax= param draw into given Axes accepted, ignored (deprecation note in docstring)
figsize= inches accepted, mapped to pixels (x80) for the default layout size
Theme rcParams dict plotly template registered as manifoldbt

Composition changes: tearsheet() no longer renders sub-charts through ax=; it embeds each chart's interactive div directly (see below).

File-by-file

  1. _theme.py -- keep the palette constants (they are imported across the module and by user code). Replace the rcParams THEME with a plotly layout template (go.layout.Template) using the same colors, fonts and grid alpha. apply_theme() registers it and sets it as default; theme_context() kept as a no-op context manager for backcompat. Colorscales bt_diverging, bt_sequential, bt_correlation become plain colorscale lists.

  2. _utils.py -- finalize(fig, show, save) routes: .html via write_html (responsive full-window CSS, displayModeBar: False), image extensions via write_image with a clear error if kaleido is missing, show via fig.show(). get_or_create_ax replaced by new_figure(figsize, title). format_pct, format_currency, auto_title unchanged.

  3. _decimate.py (new) -- min/max per pixel-column decimation (pure numpy, from research/plotting_bench/decimate.py, measured: 1.16 ms at 1M points, exact on extremes). Applied to equity/drawdown/benchmark series above ~20k points so saved HTML stays light at 1m resolution.

  4. backtest.py -- port all 10 functions to plotly. Equity gets the gradient fill + crosshair look validated in research/plotting_bench/equity/. monthly_returns becomes go.Heatmap with annotations, annual_returns a colored bar, histograms are prebinned with numpy then drawn as go.Bar so per-bin green/red coloring is preserved, summary is a 3-row make_subplots with shared x. Rolling charts keep index x (as today).

  5. chart.py -- _chart_interactive (already plotly) becomes the only path; _draw_candles and the matplotlib branch are deleted. interactive= kept and ignored. The n_bars default can later be raised now that candles are vectorized, out of scope here.

  6. research.py -- heatmap_2d and correlation_matrix become go.Heatmap; surface_3d becomes go.Surface (camera/lighting tuned in research/plotting_bench/equity/plot_surface_plotly.py); walk_forward keeps its three modes on make_subplots; stability line + band; monte_carlo / stochastic_paths keep their simulation logic (including the Community 1,000-sim cap) and render the fan with one batched trace for sample paths (None-separated) plus percentile fills and a stats annotation.

  7. tearsheet.py -- the report keeps its layout and CSS but each chart slot embeds fig.to_html(full_html=False, include_plotlyjs=False) instead of a base64 PNG; plotly.js is included once (param plotlyjs="cdn"|"inline", default cdn; inline gives a fully offline report at +4.4 MB). research_report returns plotly figures and saves .html per figure.

  8. Packaging and stragglers -- pyproject.toml: plot = ["plotly>=5.0"] (kaleido documented for static export, not forced: it ships Chromium, portability-first). all/dev extras updated. plot/__init__.py import guard checks plotly. sweep.py:plot_metric rewritten to delegate to plot.heatmap_2d / a plotly bar.

Testing

Smoke script (scratchpad) renders every public function against a real backtest (RSI long-only, 10 perps, 2021-2026) and the real 156k sweep grid, saving .html + .png for each; PNGs eyeballed before commit. Existing pytest suite run to catch import regressions.

Out of scope

Window mode (--app) helper, decimation inside the Rust core, raising the candlestick n_bars default, removing matplotlib from the dev extra while other tooling still uses it (bench scripts).