diff --git a/docs/plotly_migration_plan.md b/docs/plotly_migration_plan.md
new file mode 100644
index 0000000..c00b75c
--- /dev/null
+++ b/docs/plotly_migration_plan.md
@@ -0,0 +1,100 @@
+# 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).
diff --git a/pyproject.toml b/pyproject.toml
index 8d1a6b3..ffcd8ae 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "manifoldbt"
-version = "0.11.0"
+version = "0.12.0"
description = "Rust-powered backtesting engine for quantitative research"
requires-python = ">=3.9"
license = { file = "LICENSE" }
diff --git a/python/manifoldbt/__init__.py b/python/manifoldbt/__init__.py
index 19061c4..3626cc5 100644
--- a/python/manifoldbt/__init__.py
+++ b/python/manifoldbt/__init__.py
@@ -310,7 +310,12 @@ _PREPARED_CFG_CACHE_MAX = 256
def _prepared_config_json(config: BacktestConfig, strategy, store: DataStore) -> str:
- """Content-memoised equivalent of ``_prepare_config(...).to_json()``."""
+ """Content-memoised equivalent of ``_prepare_config(...).to_json()``.
+
+ The prepared config no longer depends on the strategy (orders travel in the
+ strategy JSON now), so the memo key is just the config content plus the
+ metadata DB; the ``strategy`` argument is accepted for call-site symmetry.
+ """
try:
meta_db = store.metadata_db()
except Exception:
@@ -318,10 +323,8 @@ def _prepared_config_json(config: BacktestConfig, strategy, store: DataStore) ->
if meta_db is None:
return _prepare_config(config, strategy, store).to_json()
- orders = getattr(strategy, "_orders", None) if strategy is not None else None
try:
- orders_key = json.dumps(orders, sort_keys=True, default=str) if orders else ""
- key = (config.to_json(), orders_key, meta_db)
+ key = (config.to_json(), meta_db)
except (TypeError, ValueError):
# Unserialisable config content — skip memoisation, never fail.
return _prepare_config(config, strategy, store).to_json()
@@ -397,13 +400,11 @@ def _prepare_config(config: BacktestConfig, strategy, store: DataStore) -> Backt
resolved_sv[int(store.resolve_symbol(key))] = venue
fees.symbol_venue = resolved_sv
- # Merge orders from strategy into execution config
- if strategy and hasattr(strategy, '_orders') and strategy._orders:
- if cfg.execution.orders is None:
- cfg.execution.orders = OrderConfig()
- for key, val in strategy._orders.items():
- setattr(cfg.execution.orders, key, val)
-
+ # Per-strategy SL/TP/trailing orders are NOT merged into the config anymore:
+ # they travel inside the strategy JSON (Strategy.to_json -> StrategyDef.orders)
+ # so the engine applies them per-strategy. This lets one batch/sweep call run
+ # strategies carrying different brackets over a single data load. A bracket
+ # set directly on config.execution.orders still applies as the fallback.
return cfg
@@ -789,6 +790,11 @@ def run_batch(
Loads bars once, aligns timestamps once, then evaluates each strategy
on a separate rayon thread. Much faster than calling ``run()`` in a loop.
+ Per-strategy ``stop_loss``/``take_profit``/``trailing_stop`` are honored:
+ each strategy's orders travel inside its JSON and the engine applies them
+ per-strategy, so a batch of strategies with DIFFERENT brackets still runs
+ over a single data load.
+
Args:
strategies: List of Strategy definitions.
config: Shared backtest configuration (same universe/time range).
@@ -800,13 +806,12 @@ def run_batch(
"""
_require_pro_over_combos(len(strategies), "Batch backtesting")
try:
- config = _prepare_config(config, None, store)
config = _cap_output_resolution(config)
store = _resolve_store(config, store)
- strategy_jsons = [strat.to_json() for strat in strategies]
+ cfg_json = _prepared_config_json(config, None, store)
raw_results = _run_batch_native(
- strategy_jsons,
- config.to_json(),
+ [strat.to_json() for strat in strategies],
+ cfg_json,
store,
max_parallelism,
)
@@ -828,6 +833,11 @@ def run_batch_lite(
position traces, and Arrow output construction. Ideal for parameter sweeps
where you only need metrics to select the best variant.
+ Per-strategy ``stop_loss``/``take_profit``/``trailing_stop`` are honored:
+ each strategy's orders travel inside its JSON and the engine applies them
+ per-strategy, so a batch of strategies with DIFFERENT brackets still runs
+ over a single data load.
+
Args:
strategies: List of Strategy definitions.
config: Shared backtest configuration (same universe/time range).
@@ -839,13 +849,12 @@ def run_batch_lite(
"""
_require_pro_over_combos(len(strategies), "Batch backtesting")
try:
- config = _prepare_config(config, None, store)
config = _cap_output_resolution(config)
store = _resolve_store(config, store)
- strategy_jsons = [strat.to_json() for strat in strategies]
+ cfg_json = _prepared_config_json(config, None, store)
return _run_batch_lite_native(
- strategy_jsons,
- config.to_json(),
+ [strat.to_json() for strat in strategies],
+ cfg_json,
store,
max_parallelism,
)
@@ -1372,7 +1381,7 @@ __all__ = [
"__version__",
# Indicators (submodule)
"indicators",
- # Plotting (lazy, requires matplotlib)
+ # Plotting (lazy, requires plotly)
"plot",
# Diagnostics (lazy)
"diagnostics",
diff --git a/python/manifoldbt/plot/__init__.py b/python/manifoldbt/plot/__init__.py
index 598a1ba..86c68ce 100644
--- a/python/manifoldbt/plot/__init__.py
+++ b/python/manifoldbt/plot/__init__.py
@@ -1,4 +1,4 @@
-"""Plotting module for manifoldbt (requires matplotlib).
+"""Plotting module for manifoldbt (requires plotly).
Install with::
@@ -11,12 +11,18 @@ Quick start::
result = bt.run(strategy, config, store)
bt.plot.tearsheet(result) # full-page dashboard
bt.plot.equity(result, show=True) # single chart
+
+Every chart is interactive (crosshair, hover, zoom). ``show=True`` opens it
+in a native window (``pip install manifoldbt[window]``; falls back to a
+browser tab, which you can also force with ``show="browser"``).
+``save=".html"`` writes a responsive interactive page. Static ``save=".png"``
+is optional and needs ``pip install manifoldbt[png]`` (pulls a headless Chromium).
"""
try:
- import matplotlib # noqa: F401
+ import plotly # noqa: F401
except ImportError:
raise ImportError(
- "matplotlib is required for the plotting module. "
+ "plotly is required for the plotting module. "
"Install it with: pip install manifoldbt[plot]"
) from None
@@ -51,6 +57,9 @@ from manifoldbt.plot.research import (
# Composite layouts
from manifoldbt.plot.tearsheet import research_report, tearsheet
+# Window display (multi-window, matplotlib-style)
+from manifoldbt.plot._window import show
+
# Theme
from manifoldbt.plot._theme import THEME, apply_theme
@@ -78,6 +87,8 @@ __all__ = [
# Composites
"tearsheet",
"research_report",
+ # Window display
+ "show",
# Theme
"THEME",
"apply_theme",
diff --git a/python/manifoldbt/plot/_assets/manifoldbt.ico b/python/manifoldbt/plot/_assets/manifoldbt.ico
new file mode 100644
index 0000000..fb851d8
Binary files /dev/null and b/python/manifoldbt/plot/_assets/manifoldbt.ico differ
diff --git a/python/manifoldbt/plot/_decimate.py b/python/manifoldbt/plot/_decimate.py
new file mode 100644
index 0000000..be09163
--- /dev/null
+++ b/python/manifoldbt/plot/_decimate.py
@@ -0,0 +1,42 @@
+"""Min/max time-series decimation for plotting — pure numpy.
+
+A chart is ~1000-2500 px wide, so plotting 10^5-10^6 samples draws hundreds of
+sub-pixel points per column and bloats saved HTML. Per pixel column we keep the
+bucket's min and max in time order, which preserves peaks and troughs exactly
+(max drawdown survives untouched) at O(n) cost (~1 ms for 1M points).
+"""
+from __future__ import annotations
+
+import numpy as np
+
+#: Series shorter than this are plotted as-is.
+DECIMATE_THRESHOLD = 20_000
+
+
+def decimate_minmax(x: np.ndarray, y: np.ndarray, n_cols: int = 2500):
+ """Per-column min/max envelope. Returns (x, y) unchanged when small."""
+ n = len(y)
+ if n <= 2 * n_cols:
+ return x, y
+ bucket = n // n_cols
+ m = bucket * n_cols
+ yb = y[:m].reshape(n_cols, bucket)
+ cols = np.arange(n_cols)
+ idx_min = yb.argmin(axis=1) + cols * bucket
+ idx_max = yb.argmax(axis=1) + cols * bucket
+ lo = np.minimum(idx_min, idx_max)
+ hi = np.maximum(idx_min, idx_max)
+ idx = np.empty(n_cols * 2, dtype=np.int64)
+ idx[0::2] = lo
+ idx[1::2] = hi
+ if m < n:
+ idx = np.append(idx, n - 1) # keep the true last sample
+ idx = np.unique(idx) # dedupe flat buckets (lo == hi)
+ return x[idx], y[idx]
+
+
+def maybe_decimate(x: np.ndarray, y: np.ndarray, n_cols: int = 2500):
+ """Decimate only when the series is longer than DECIMATE_THRESHOLD."""
+ if len(y) <= DECIMATE_THRESHOLD:
+ return x, y
+ return decimate_minmax(x, y, n_cols)
diff --git a/python/manifoldbt/plot/_theme.py b/python/manifoldbt/plot/_theme.py
index 30675c9..b7fd7c4 100644
--- a/python/manifoldbt/plot/_theme.py
+++ b/python/manifoldbt/plot/_theme.py
@@ -1,8 +1,7 @@
-"""Clean dark theme — modern, readable, quant-oriented."""
+"""Clean dark theme — modern, readable, quant-oriented (plotly template)."""
from __future__ import annotations
from contextlib import contextmanager
-from typing import Any, Dict
# ---------------------------------------------------------------------------
# Color palette — neutral dark, no decorative colors
@@ -20,94 +19,106 @@ BG_FIGURE = "#0c0c0f"
BG_AXES = "#111116"
BORDER = "#1e1e24"
GRID_RGBA = (1.0, 1.0, 1.0, 0.04)
+GRID_COLOR = "rgba(255,255,255,0.045)"
SERIES_COLORS = [ACCENT, ACCENT_ALT, "#2dd4bf", ORANGE, RED, GREEN, "#f472b6", WHITE]
+FONT_FAMILY = "Inter, system-ui, Segoe UI, Arial, sans-serif"
+MONO_FAMILY = "SF Mono, Fira Code, Cascadia Code, Consolas, monospace"
+
# ---------------------------------------------------------------------------
-# rcParams
+# Colorscales (plotly format) — same stops as the old matplotlib colormaps
# ---------------------------------------------------------------------------
-THEME: Dict[str, Any] = {
- "figure.facecolor": BG_FIGURE,
- "figure.edgecolor": BG_FIGURE,
- "figure.dpi": 120,
- "axes.facecolor": BG_AXES,
- "axes.edgecolor": BORDER,
- "axes.labelcolor": GRAY,
- "axes.titlecolor": WHITE,
- "axes.titlesize": 11,
- "axes.titleweight": "medium",
- "axes.titlepad": 12,
- "axes.labelsize": 9,
- "axes.labelpad": 8,
- "axes.grid": True,
- "grid.color": GRID_RGBA,
- "grid.linewidth": 0.5,
- "grid.linestyle": "-",
- "xtick.color": DARK_GRAY,
- "ytick.color": DARK_GRAY,
- "xtick.labelsize": 8,
- "ytick.labelsize": 8,
- "text.color": WHITE,
- "font.family": "monospace",
- "font.size": 9,
- "legend.facecolor": BG_AXES,
- "legend.edgecolor": BORDER,
- "legend.fontsize": 8,
- "legend.labelcolor": GRAY,
- "lines.linewidth": 1.3,
- "lines.antialiased": True,
- "savefig.facecolor": BG_FIGURE,
- "savefig.edgecolor": BG_FIGURE,
- "savefig.bbox": "tight",
- "savefig.dpi": 150,
+CS_DIVERGING = [[0.0, "#b91c1c"], [0.5, "#262626"], [1.0, "#15803d"]]
+CS_SEQUENTIAL = [[0.0, "#b91c1c"], [0.5, "#d97706"], [1.0, "#15803d"]]
+CS_CORRELATION = [[0.0, "#b91c1c"], [0.5, "#262626"], [1.0, "#1d4ed8"]]
+
+# ---------------------------------------------------------------------------
+# Layout defaults (also exported as THEME for backward compatibility)
+# ---------------------------------------------------------------------------
+THEME: dict = {
+ "paper_bgcolor": BG_FIGURE,
+ "plot_bgcolor": BG_AXES,
+ "font": {"family": FONT_FAMILY, "color": GRAY, "size": 12},
+ "title": {"font": {"color": WHITE, "size": 15}, "x": 0.01, "xanchor": "left"},
+ "margin": {"l": 64, "r": 24, "t": 48, "b": 36},
+ "hovermode": "x",
+ "colorway": SERIES_COLORS,
+ "hoverlabel": {
+ "bgcolor": "#1a1a20",
+ "bordercolor": BORDER,
+ "font": {"family": MONO_FAMILY, "color": WHITE, "size": 12},
+ },
+ "legend": {
+ "bgcolor": "rgba(17,17,22,0.6)",
+ "bordercolor": BORDER,
+ "borderwidth": 1,
+ "font": {"color": GRAY, "size": 11},
+ },
+}
+
+_AXIS = {
+ "color": GRAY,
+ "gridcolor": GRID_COLOR,
+ "linecolor": BORDER,
+ "zerolinecolor": GRID_COLOR,
+ "ticks": "",
+ "showspikes": True,
+ "spikemode": "across",
+ "spikethickness": 1,
+ "spikedash": "dot",
+ "spikecolor": GRAY,
+}
+
+_SCENE_AXIS = {
+ "backgroundcolor": BG_AXES,
+ "gridcolor": "rgba(255,255,255,0.08)",
+ "color": GRAY,
+ "showbackground": True,
+ "zerolinecolor": "rgba(255,255,255,0.08)",
}
-def _build_theme() -> Dict[str, Any]:
- """Finalize THEME dict with cycler."""
- import matplotlib.pyplot as plt
- theme = dict(THEME)
- theme["axes.prop_cycle"] = plt.cycler(color=SERIES_COLORS)
- return theme
+def _build_template():
+ """Build the manifoldbt plotly template."""
+ import plotly.graph_objects as go
-
-# ---------------------------------------------------------------------------
-# Colormaps
-# ---------------------------------------------------------------------------
-def _register_colormaps() -> None:
- """Register custom colormaps (idempotent)."""
- from matplotlib.colors import LinearSegmentedColormap
- import matplotlib as mpl
-
- _cmaps = {
- "bt_diverging": [(0.0, "#b91c1c"), (0.5, "#262626"), (1.0, "#15803d")],
- "bt_sequential": [(0.0, "#b91c1c"), (0.5, "#d97706"), (1.0, "#15803d")],
- "bt_correlation": [(0.0, "#b91c1c"), (0.5, "#262626"), (1.0, "#1d4ed8")],
+ layout = dict(THEME)
+ layout["xaxis"] = dict(_AXIS)
+ layout["yaxis"] = dict(_AXIS)
+ layout["scene"] = {
+ "xaxis": dict(_SCENE_AXIS),
+ "yaxis": dict(_SCENE_AXIS),
+ "zaxis": dict(_SCENE_AXIS),
+ "bgcolor": BG_FIGURE,
}
- for name, stops in _cmaps.items():
- try:
- mpl.colormaps.get_cmap(name)
- except ValueError:
- positions = [s[0] for s in stops]
- colors = [s[1] for s in stops]
- cmap = LinearSegmentedColormap.from_list(name, list(zip(positions, colors)), N=256)
- mpl.colormaps.register(cmap, name=name)
+ return go.layout.Template(layout=layout)
+
+
+_REGISTERED = False
-# ---------------------------------------------------------------------------
-# Public
-# ---------------------------------------------------------------------------
def apply_theme() -> None:
- """Apply the dark theme globally."""
- import matplotlib.pyplot as plt
- _register_colormaps()
- plt.rcParams.update(_build_theme())
+ """Register the manifoldbt template and set it as plotly's default."""
+ global _REGISTERED
+ import plotly.io as pio
+
+ pio.templates["manifoldbt"] = _build_template()
+ pio.templates.default = "manifoldbt"
+ _REGISTERED = True
+
+
+def _ensure_theme() -> None:
+ if not _REGISTERED:
+ apply_theme()
@contextmanager
def theme_context():
- """Context manager: apply theme temporarily."""
- import matplotlib.pyplot as plt
- _register_colormaps()
- with plt.rc_context(_build_theme()):
- yield
+ """Backward-compatible context manager: ensures the theme is registered.
+
+ With plotly the theme is a global template rather than a temporary
+ rc-context, so this simply guarantees registration.
+ """
+ _ensure_theme()
+ yield
diff --git a/python/manifoldbt/plot/_utils.py b/python/manifoldbt/plot/_utils.py
index bbdf7d9..6cd098f 100644
--- a/python/manifoldbt/plot/_utils.py
+++ b/python/manifoldbt/plot/_utils.py
@@ -1,25 +1,32 @@
-"""Shared plotting utilities."""
+"""Shared plotting utilities (plotly)."""
from __future__ import annotations
from pathlib import Path
from typing import Optional, Tuple, Union
-import matplotlib.pyplot as plt
-from matplotlib.axes import Axes
-from matplotlib.figure import Figure
+from manifoldbt.plot._theme import WHITE, _ensure_theme
-from manifoldbt.plot._theme import theme_context
+_RESPONSIVE_CSS = (
+ ""
+)
+
+_IMAGE_EXTS = {".png", ".svg", ".pdf", ".jpg", ".jpeg", ".webp"}
-def get_or_create_ax(
- ax: Optional[Axes] = None,
+def new_figure(
figsize: Tuple[float, float] = (12, 4),
-) -> Tuple[Figure, Axes]:
- """Return (fig, ax). Creates a new themed figure if *ax* is None."""
- if ax is not None:
- return ax.figure, ax
- fig, new_ax = plt.subplots(figsize=figsize)
- return fig, new_ax
+ title: Optional[str] = None,
+):
+ """Return a themed plotly Figure sized from a matplotlib-style figsize."""
+ import plotly.graph_objects as go
+
+ _ensure_theme()
+ fig = go.Figure()
+ fig.update_layout(width=int(figsize[0] * 80), height=int(figsize[1] * 80))
+ if title:
+ fig.update_layout(title_text=title)
+ return fig
def format_pct(value: float, decimals: int = 1) -> str:
@@ -29,32 +36,98 @@ def format_pct(value: float, decimals: int = 1) -> str:
def format_currency(value: float, currency: str = "USD") -> str:
"""Format a number as currency."""
- symbol = {"USD": "$", "EUR": "\u20ac", "GBP": "\u00a3"}.get(currency, "")
+ symbol = {"USD": "$", "EUR": "€", "GBP": "£"}.get(currency, "")
return f"{symbol}{value:,.2f}"
def finalize(
- fig: Figure,
+ fig,
*,
- show: bool = False,
+ show: "bool | str" = False,
save: Optional[Union[str, Path]] = None,
dpi: int = 150,
-) -> Figure:
- """Optionally save and/or display the figure, then return it."""
- import warnings
- with warnings.catch_warnings():
- warnings.simplefilter("ignore", UserWarning)
- try:
- fig.tight_layout()
- except Exception:
- pass # Skip when axes are incompatible (e.g. inside GridSpec)
+ window_size: Optional[Tuple[int, int]] = None,
+) -> "object":
+ """Optionally save and/or display the figure, then return it.
+
+ ``save`` routes on extension: ``.html`` writes a responsive interactive
+ page; image extensions (.png/.svg/.pdf/...) go through kaleido.
+ ``show``: ``True`` (or ``"window"``) opens a native window (needs pywebview,
+ else falls back to a browser tab); ``"browser"`` forces a browser tab.
+ ``dpi`` is kept for backward compatibility and maps to an export scale.
+ """
if save is not None:
- fig.savefig(str(save), dpi=dpi, bbox_inches="tight")
- if show:
- plt.show()
+ path = Path(save)
+ ext = path.suffix.lower()
+ if ext in _IMAGE_EXTS:
+ try:
+ scale = max(1.0, dpi / 96.0)
+ fig.write_image(str(path), scale=scale)
+ except Exception as exc: # kaleido missing or export failure
+ raise RuntimeError(
+ f"Static image export to {ext} is optional and needs kaleido. "
+ "Install it with: pip install manifoldbt[png] "
+ "(the default is the interactive chart — save to .html)"
+ ) from exc
+ else:
+ write_responsive_html(fig, path)
+ if show == "browser":
+ fig.show()
+ elif show: # True or "window" -> native window (browser tab fallback)
+ from manifoldbt.plot._window import open_in_window
+ title = "Chart"
+ try:
+ t = fig.layout.title.text
+ if t:
+ title = t.split("
")[0].strip() or title
+ except Exception:
+ pass
+ open_in_window(fig, title=title, size=window_size or (1280, 720))
return fig
+def write_responsive_html(fig, path: Union[str, Path]) -> None:
+ """Write a self-adjusting full-window HTML page for *fig*.
+
+ Strips any fixed width/height so the plot fills (and resizes with) the
+ window; a clean
replaces the browser's filename fallback.
+ """
+ # A fixed layout size would override plotly's responsive resizing.
+ fig.update_layout(width=None, height=None, autosize=True)
+ title = "Chart"
+ try:
+ t = fig.layout.title.text
+ if t:
+ title = t.split("
")[0].strip() or title
+ except Exception:
+ pass
+
+ html = fig.to_html(
+ include_plotlyjs="cdn",
+ full_html=True,
+ default_width="100%",
+ default_height="100%",
+ config={"displayModeBar": False, "responsive": True},
+ )
+ head = "" + _RESPONSIVE_CSS + f"{title}"
+ html = html.replace("", head, 1)
+ Path(path).write_text(html, encoding="utf-8")
+
+
+def chart_div(fig, *, height: Optional[int] = None) -> str:
+ """Return an embeddable div (no plotly.js) for report composition."""
+ if height is not None:
+ fig.update_layout(height=height)
+ fig.update_layout(width=None, autosize=True)
+ return fig.to_html(
+ full_html=False,
+ include_plotlyjs=False,
+ default_width="100%",
+ default_height=f"{height}px" if height else "100%",
+ config={"displayModeBar": False, "responsive": True},
+ )
+
+
def auto_title(result, fallback: str) -> str:
"""Build a title from result manifest strategy_name, or use fallback."""
try:
diff --git a/python/manifoldbt/plot/_window.py b/python/manifoldbt/plot/_window.py
new file mode 100644
index 0000000..f70dcbd
--- /dev/null
+++ b/python/manifoldbt/plot/_window.py
@@ -0,0 +1,173 @@
+"""Native frameless windows for charts (pywebview), matplotlib-style.
+
+``show=True`` queues a chart as a borderless native window; a single
+``manifoldbt.plot.show()`` (or the automatic one at interpreter exit) opens
+ALL queued windows together, so you can have the equity in one window and
+the return distribution in another, side by side.
+
+Each window runs in its own child process with a dedicated WebView2 profile:
+WebView2 windows sharing one process share one UI thread (several heavy
+charts freeze it), and separate processes sharing the default user-data
+folder collide on startup. One process + one profile per window avoids both,
+keeps every window frameless and responsive, and lets show() be called again
+later. show() blocks until all windows are closed (like ``plt.show()``).
+
+Without pywebview each queued chart opens in a browser tab instead. Install
+the window backend with ``pip install manifoldbt[window]``.
+"""
+from __future__ import annotations
+
+import os
+import subprocess
+import sys
+import tempfile
+import webbrowser
+from html import escape
+from pathlib import Path
+from typing import List, Tuple
+
+_SHELL = """{title}
+
+
+ ×
+ {div}
+
+"""
+
+# One frameless window; runs inside a dedicated child process.
+_CHILD = """
+import ctypes, os, sys
+try:
+ ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("manifoldbt.plot")
+except Exception:
+ pass
+import webview
+title, url, x, y, w, h, icon = sys.argv[1:8]
+holder = {}
+class Api:
+ def close(self):
+ win = holder.get("w")
+ if win is not None:
+ try:
+ win.destroy()
+ except Exception:
+ pass
+holder["w"] = webview.create_window(
+ title, url, frameless=True, easy_drag=False, js_api=Api(),
+ width=int(w), height=int(h), x=int(x), y=int(y),
+ background_color="#0c0c0f",
+)
+kwargs = dict(storage_path=os.environ.get("MANIFOLDBT_WV_STORAGE"), private_mode=False)
+try:
+ webview.start(icon=icon, **kwargs) if icon and os.path.exists(icon) else webview.start(**kwargs)
+except TypeError:
+ webview.start(**kwargs) # some backends reject the icon kwarg
+"""
+
+# Charts registered by show=True, waiting for the next show() / atexit call.
+_pending: "List[Tuple[str, str, Tuple[int, int]]]" = []
+
+
+def _fig_div(fig) -> str:
+ """Responsive chart div with plotly.js inlined (instant + offline)."""
+ fig.update_layout(width=None, height=None, autosize=True)
+ return fig.to_html(
+ full_html=False, include_plotlyjs=True,
+ default_width="100%", default_height="100%",
+ config={"displayModeBar": False, "responsive": True},
+ )
+
+
+def _write_tmp(html: str) -> Path:
+ tmp = tempfile.NamedTemporaryFile(suffix=".html", delete=False,
+ mode="w", encoding="utf-8")
+ tmp.write(html)
+ tmp.close()
+ return Path(tmp.name).resolve()
+
+
+def queue_window(fig, *, title: str = "Chart",
+ size: Tuple[int, int] = (1280, 720)) -> None:
+ """Register *fig* to be shown as a native window on the next show()."""
+ _pending.append((_fig_div(fig), title, size))
+
+
+# Backward-compatible alias (was the immediate opener).
+open_in_window = queue_window
+
+
+def show() -> None:
+ """Open every chart queued by ``show=True``, each in its own frameless window.
+
+ Blocks until all windows are closed (like ``matplotlib.pyplot.show``).
+ A no-op if nothing is queued; can be called again after more charts are
+ queued. Falls back to browser tabs when pywebview is not installed.
+ """
+ if not _pending:
+ return
+ pending = list(_pending)
+ _pending.clear()
+
+ try:
+ import webview # noqa: F401 — only to detect the backend
+ except ImportError:
+ for div, title, _ in pending:
+ _open_browser(div, title)
+ return
+
+ icon = Path(__file__).parent / "_assets" / "manifoldbt.ico"
+ procs = []
+ for i, (div, title, size) in enumerate(pending):
+ html = _SHELL.format(title=escape(title), div=div)
+ path = _write_tmp(html)
+ env = dict(os.environ)
+ # Dedicated WebView2 profile: concurrent windows sharing the default
+ # user-data folder fail to start (window class/profile collision).
+ storage = tempfile.mkdtemp(prefix="manifoldbt_win_")
+ env["MANIFOLDBT_WV_STORAGE"] = storage
+ env["WEBVIEW2_USER_DATA_FOLDER"] = storage
+ procs.append(subprocess.Popen(
+ [sys.executable, "-c", _CHILD, title, path.as_uri(),
+ str(80 + i * 60), str(80 + i * 60),
+ str(size[0]), str(size[1]), str(icon)],
+ env=env,
+ ))
+ for p in procs:
+ try:
+ p.wait()
+ except KeyboardInterrupt:
+ for q in procs:
+ if q.poll() is None:
+ q.terminate()
+ break
+
+
+def _open_browser(div: str, title: str) -> None:
+ # No pywebview: plain browser tab. The OS/browser chrome provides closing.
+ html = _SHELL.format(title=escape(title),
+ div=div).replace('pywebview.api.close()', 'window.close()')
+ webbrowser.open(_write_tmp(html).as_uri())
+
+
+def _atexit_show() -> None:
+ # Scripts "just work": show whatever is still queued when the process exits.
+ show()
+
+
+import atexit # noqa: E402
+atexit.register(_atexit_show)
diff --git a/python/manifoldbt/plot/backtest.py b/python/manifoldbt/plot/backtest.py
index 864dfb7..b5be3e6 100644
--- a/python/manifoldbt/plot/backtest.py
+++ b/python/manifoldbt/plot/backtest.py
@@ -1,15 +1,12 @@
-"""Charts for BacktestResult visualization."""
+"""Charts for BacktestResult visualization (plotly)."""
from __future__ import annotations
from pathlib import Path
from typing import List, Optional, Tuple, Union
import numpy as np
-import matplotlib.pyplot as plt
-import matplotlib.dates as mdates
-import matplotlib.ticker as mticker
-from matplotlib.axes import Axes
-from matplotlib.figure import Figure
+import plotly.graph_objects as go
+from plotly.subplots import make_subplots
from manifoldbt.plot._theme import (
ACCENT,
@@ -29,7 +26,42 @@ from manifoldbt.plot._convert import (
trades_arrays,
_ts_to_int64,
)
-from manifoldbt.plot._utils import finalize, format_pct, get_or_create_ax
+from manifoldbt.plot._decimate import maybe_decimate
+from manifoldbt.plot._utils import finalize, format_pct, new_figure
+
+
+def _rgba(hex_color: str, alpha: float) -> str:
+ """'#rrggbb' -> 'rgba(r,g,b,a)'."""
+ h = hex_color.lstrip("#")
+ r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
+ return f"rgba({r},{g},{b},{alpha})"
+
+
+def _area_traces(x, y, baseline: float, color: str, *, width: float = 1.5,
+ name: Optional[str] = None, hovertemplate: Optional[str] = None):
+ """Line + fill-to-baseline traces, with a vertical gradient when supported."""
+ base = go.Scatter(
+ x=x, y=np.full(len(x), baseline), mode="lines",
+ line=dict(width=0), hoverinfo="skip", showlegend=False,
+ )
+ kwargs = dict(
+ x=x, y=y, mode="lines",
+ line=dict(color=color, width=width),
+ fill="tonexty",
+ name=name, showlegend=name is not None,
+ hovertemplate=hovertemplate,
+ )
+ try:
+ line_trace = go.Scatter(
+ fillgradient=dict(
+ type="vertical",
+ colorscale=[[0.0, _rgba(color, 0.0)], [1.0, _rgba(color, 0.22)]],
+ ),
+ **kwargs,
+ )
+ except (ValueError, TypeError): # plotly too old for fillgradient
+ line_trace = go.Scatter(fillcolor=_rgba(color, 0.07), **kwargs)
+ return [base, line_trace]
# ── Summary (the essential chart) ────────────────────────────────────────────
@@ -41,29 +73,44 @@ def summary(
figsize: Tuple[float, float] = (14, 8),
show: bool = False,
save: Optional[Union[str, Path]] = None,
-) -> Figure:
+) -> go.Figure:
"""The essential chart: TWR equity + buy-and-hold benchmark, trade activity.
Top panel: TWR-normalized equity curve vs buy-and-hold (close price).
- Bottom panel: daily trade count as a bar chart.
- Metrics displayed in a clean header line.
+ Middle panel: daily trade count as a bar chart.
+ Bottom panel: used margin percentage.
+ Metrics displayed in the title line.
"""
with theme_context():
- fig, (ax_eq, ax_trades, ax_margin) = plt.subplots(
- 3, 1, figsize=figsize, height_ratios=[3, 1, 1],
- sharex=True, gridspec_kw={"hspace": 0.25},
+ fig = make_subplots(
+ rows=3, cols=1, shared_xaxes=True, vertical_spacing=0.06,
+ row_heights=[0.6, 0.2, 0.2],
)
dates, eq_vals = equity_with_dates(result)
metrics = result.metrics if hasattr(result, "metrics") else {}
# ── TWR equity (normalized to 100) ────────────────────────
- twr = eq_vals / eq_vals[0] * 100
- ax_eq.plot(dates, twr, color=ACCENT, linewidth=0.8, label="Strategy")
- ax_eq.fill_between(dates, twr, 100, where=(twr >= 100),
- color=GREEN, alpha=0.04, interpolate=True)
- ax_eq.fill_between(dates, twr, 100, where=(twr < 100),
- color=RED, alpha=0.04, interpolate=True)
+ twr_full = eq_vals / eq_vals[0] * 100
+ d_dates, twr = maybe_decimate(dates, twr_full)
+ fig.add_trace(go.Scatter(
+ x=d_dates, y=twr, mode="lines", name="Strategy",
+ line=dict(color=ACCENT, width=1.0),
+ hovertemplate="%{x|%d %b %Y} %{y:.1f}Strategy",
+ ), row=1, col=1)
+
+ # Faint green/red fill vs the 100 baseline
+ for clip_lo, clip_hi, color in ((100.0, None, GREEN), (None, 100.0, RED)):
+ clipped = np.clip(twr, clip_lo, clip_hi)
+ fig.add_trace(go.Scatter(
+ x=d_dates, y=np.full(len(d_dates), 100.0), mode="lines",
+ line=dict(width=0), hoverinfo="skip", showlegend=False,
+ ), row=1, col=1)
+ fig.add_trace(go.Scatter(
+ x=d_dates, y=clipped, mode="lines", line=dict(width=0),
+ fill="tonexty", fillcolor=_rgba(color, 0.04),
+ hoverinfo="skip", showlegend=False,
+ ), row=1, col=1)
# ── Benchmark: buy-and-hold from close prices ─────────────
positions = result.positions
@@ -78,7 +125,7 @@ def summary(
benchmark_raw = close_vals / close_vals[0] * 100
# Vol-adjusted benchmark: scale to same volatility as strategy
- strat_rets = np.diff(twr) / twr[:-1]
+ strat_rets = np.diff(twr_full) / twr_full[:-1]
bench_rets = np.diff(benchmark_raw) / benchmark_raw[:-1]
strat_vol = np.nanstd(strat_rets)
bench_vol = np.nanstd(bench_rets)
@@ -90,16 +137,19 @@ def summary(
else:
benchmark = benchmark_raw
- ax_eq.plot(dates, benchmark, color=GRAY, linewidth=1.0,
- label="Buy & Hold (vol-adj)", alpha=0.7)
+ b_dates, b_vals = maybe_decimate(dates[: len(benchmark)], benchmark)
+ fig.add_trace(go.Scatter(
+ x=b_dates, y=b_vals, mode="lines", name="Buy & Hold (vol-adj)",
+ line=dict(color=GRAY, width=1.0), opacity=0.7,
+ hovertemplate="%{x|%d %b %Y} %{y:.1f}Buy & Hold",
+ ), row=1, col=1)
- ax_eq.axhline(100, color=DARK_GRAY, linewidth=0.4)
- # Ensure y-axis zooms to strategy range with some padding
- twr_min, twr_max = float(np.nanmin(twr)), float(np.nanmax(twr))
+ fig.add_hline(y=100, line_color=DARK_GRAY, line_width=0.4, row=1, col=1)
+ twr_min, twr_max = float(np.nanmin(twr_full)), float(np.nanmax(twr_full))
twr_range = max(twr_max - twr_min, 0.1)
- ax_eq.set_ylim(twr_min - twr_range * 0.15, twr_max + twr_range * 0.15)
- ax_eq.set_ylabel("TWR (base 100)", fontsize=9)
- ax_eq.legend(loc="upper left", framealpha=0.3, fontsize=8)
+ fig.update_yaxes(title_text="TWR (base 100)",
+ range=[twr_min - twr_range * 0.15, twr_max + twr_range * 0.15],
+ row=1, col=1)
# Header metrics
ret = metrics.get("total_return", 0)
@@ -112,10 +162,9 @@ def summary(
f" Max DD {mdd * 100:.1f}%"
f" Trades {n_trades:,}"
)
- ax_eq.set_title(title, fontsize=10, loc="left", pad=10)
+ fig.update_layout(title_text=title)
# ── Adaptive smoothing window ──────────────────────────────
- # Scale window: min(7d, max(1d, 5% of total period))
smooth_label = ""
if len(dates) >= 2:
bar_ns = int(dates[1]) - int(dates[0])
@@ -126,22 +175,22 @@ def summary(
smooth_window = min(smooth_window, len(dates))
smooth_days = round(target_ns / day_ns)
smooth_label = f" ({smooth_days}d)" if smooth_days >= 1 else ""
- else:
- smooth_window = 1
# ── Trade activity (daily trade count) ─────────────────────
try:
ta = trades_arrays(result)
trade_ts = ta.get("execution_timestamp", np.array([], dtype="datetime64[ns]"))
if len(trade_ts) > 0 and len(dates) >= 2:
- # Bucket trades into calendar days
trade_days = trade_ts.astype("datetime64[D]")
unique_days, day_counts = np.unique(trade_days, return_counts=True)
day_dates = unique_days.astype("datetime64[ns]")
- ax_trades.bar(day_dates, day_counts,
- width=np.timedelta64(1, "D"),
- color=ACCENT_ALT, alpha=0.4, edgecolor="none")
+ fig.add_trace(go.Bar(
+ x=day_dates, y=day_counts, name="Trades/day",
+ marker_color=_rgba(ACCENT_ALT, 0.4), marker_line_width=0,
+ showlegend=False,
+ hovertemplate="%{x|%d %b %Y} %{y} trades",
+ ), row=2, col=1)
# Rolling 7-day average overlay
eq_days = dates.astype("datetime64[D]")
@@ -154,16 +203,14 @@ def summary(
if win > 1:
kernel = np.ones(win) / win
smoothed = np.convolve(daily_on_grid, kernel, mode="same")
- ax_trades.plot(unique_eq_days.astype("datetime64[ns]"), smoothed,
- color=ACCENT_ALT, linewidth=1.0, alpha=0.8)
- else:
- ax_trades.text(0.5, 0.5, "No trade data", transform=ax_trades.transAxes,
- ha="center", va="center", color=DARK_GRAY, fontsize=9)
+ fig.add_trace(go.Scatter(
+ x=unique_eq_days.astype("datetime64[ns]"), y=smoothed,
+ mode="lines", line=dict(color=ACCENT_ALT, width=1.0),
+ opacity=0.8, showlegend=False, hoverinfo="skip",
+ ), row=2, col=1)
except Exception:
- ax_trades.text(0.5, 0.5, "No trade data", transform=ax_trades.transAxes,
- ha="center", va="center", color=DARK_GRAY, fontsize=9)
-
- ax_trades.set_ylabel("Trades/day", fontsize=8)
+ pass
+ fig.update_yaxes(title_text="Trades/day", row=2, col=1)
# ── Used margin % (daily) ──────────────────────────────
try:
@@ -183,29 +230,27 @@ def summary(
# Resample to daily (end-of-day snapshot)
days = used_dates.astype("datetime64[D]")
unique_days, _ = np.unique(days, return_index=True)
- # Use last value per day (not first) for end-of-day margin
day_last = np.searchsorted(days, unique_days, side="right") - 1
daily_used = used[day_last]
daily_dates = unique_days.astype("datetime64[ns]")
- ax_margin.fill_between(daily_dates, 0, daily_used,
- color=GREEN, alpha=0.10, edgecolor="none")
- ax_margin.plot(daily_dates, daily_used,
- color=GREEN, linewidth=0.7, alpha=0.8)
- ax_margin.axhline(0, color=DARK_GRAY, linewidth=0.4)
+ fig.add_trace(go.Scatter(
+ x=daily_dates, y=daily_used, mode="lines",
+ line=dict(color=GREEN, width=0.7), opacity=0.8,
+ fill="tozeroy", fillcolor=_rgba(GREEN, 0.10),
+ showlegend=False,
+ hovertemplate="%{x|%d %b %Y} %{y:.1f}%Margin",
+ ), row=3, col=1)
except Exception:
- ax_margin.text(
- 0.5, 0.5, "No position data",
- transform=ax_margin.transAxes,
- ha="center", va="center", color=DARK_GRAY, fontsize=9,
- )
-
- ax_margin.set_ylabel(f"Margin %{smooth_label}", fontsize=8)
- ax_margin.xaxis.set_major_formatter(mdates.DateFormatter("%b %Y"))
- ax_margin.xaxis.set_major_locator(mdates.AutoDateLocator())
- fig.align_ylabels([ax_eq, ax_trades, ax_margin])
- fig.autofmt_xdate(rotation=0, ha="center")
+ pass
+ fig.update_yaxes(title_text=f"Margin %{smooth_label}", row=3, col=1)
+ fig.update_layout(
+ width=int(figsize[0] * 80), height=int(figsize[1] * 80),
+ legend=dict(orientation="h", yanchor="bottom", y=1.02,
+ xanchor="right", x=1),
+ bargap=0.0,
+ )
return finalize(fig, show=show, save=save)
@@ -215,24 +260,27 @@ def summary(
def equity(
result,
*,
- ax: Optional[Axes] = None,
+ ax=None,
color: str = ACCENT,
title: str = "Equity Curve",
figsize: Tuple[float, float] = (14, 5),
show: bool = False,
save: Optional[Union[str, Path]] = None,
-) -> Figure:
- """Plot the portfolio equity curve over time."""
+) -> go.Figure:
+ """Plot the portfolio equity curve over time.
+
+ ``ax`` is accepted for backward compatibility and ignored (plotly backend).
+ """
with theme_context():
- fig, ax_ = get_or_create_ax(ax, figsize)
+ fig = new_figure(figsize, title)
dates, values = equity_with_dates(result)
- ax_.plot(dates, values, color=color, linewidth=1.3)
- ax_.fill_between(dates, values, values.min(), color=color, alpha=0.05)
- ax_.set_title(title)
- ax_.set_ylabel("Equity", fontsize=9)
- ax_.xaxis.set_major_formatter(mdates.DateFormatter("%b %Y"))
- ax_.xaxis.set_major_locator(mdates.AutoDateLocator())
- fig.autofmt_xdate(rotation=0, ha="center")
+ dates, values = maybe_decimate(dates, values)
+ fig.add_traces(_area_traces(
+ dates, values, float(values.min()), color, width=1.5,
+ hovertemplate="%{x|%d %b %Y} $%{y:,.0f}",
+ ))
+ fig.update_yaxes(title_text="Equity")
+ fig.update_xaxes(tickformat="%b %Y")
return finalize(fig, show=show, save=save)
@@ -243,7 +291,7 @@ def benchmark_equity(
result,
benchmark: np.ndarray,
*,
- ax: Optional[Axes] = None,
+ ax=None,
strategy_color: str = ACCENT,
benchmark_color: str = DARK_GRAY,
normalize: bool = True,
@@ -252,10 +300,10 @@ def benchmark_equity(
figsize: Tuple[float, float] = (14, 5),
show: bool = False,
save: Optional[Union[str, Path]] = None,
-) -> Figure:
+) -> go.Figure:
"""Overlay strategy equity and a benchmark, both normalized to 100."""
with theme_context():
- fig, ax_ = get_or_create_ax(ax, figsize)
+ fig = new_figure(figsize, title)
dates, strat_eq = equity_with_dates(result)
bench = np.asarray(benchmark, dtype=np.float64)
n = min(len(strat_eq), len(bench))
@@ -265,13 +313,19 @@ def benchmark_equity(
strat_eq = strat_eq / strat_eq[0] * 100
bench = bench / bench[0] * 100
- ax_.plot(dates, strat_eq, color=strategy_color, linewidth=1.3, label=labels[0])
- ax_.plot(dates, bench, color=benchmark_color, linewidth=1.0, label=labels[1])
- ax_.set_title(title)
- ax_.set_ylabel("Normalized" if normalize else "Equity")
- ax_.legend(loc="upper left", framealpha=0.5)
- ax_.xaxis.set_major_formatter(mdates.DateFormatter("%b %Y"))
- fig.autofmt_xdate(rotation=0, ha="center")
+ d1, s1 = maybe_decimate(dates, strat_eq)
+ d2, b1 = maybe_decimate(dates, bench)
+ fig.add_trace(go.Scatter(
+ x=d1, y=s1, mode="lines", name=labels[0],
+ line=dict(color=strategy_color, width=1.5),
+ ))
+ fig.add_trace(go.Scatter(
+ x=d2, y=b1, mode="lines", name=labels[1],
+ line=dict(color=benchmark_color, width=1.0),
+ ))
+ fig.update_yaxes(title_text="Normalized" if normalize else "Equity")
+ fig.update_xaxes(tickformat="%b %Y")
+ fig.update_layout(legend=dict(x=0.01, y=0.99))
return finalize(fig, show=show, save=save)
@@ -281,28 +335,31 @@ def benchmark_equity(
def drawdown(
result,
*,
- ax: Optional[Axes] = None,
+ ax=None,
color: str = RED,
title: str = "Drawdown",
figsize: Tuple[float, float] = (14, 3),
show: bool = False,
save: Optional[Union[str, Path]] = None,
-) -> Figure:
+) -> go.Figure:
"""Plot the drawdown as a filled area chart."""
with theme_context():
- fig, ax_ = get_or_create_ax(ax, figsize)
+ fig = new_figure(figsize, title)
dates, values = equity_with_dates(result)
running_max = np.maximum.accumulate(values)
dd = (values - running_max) / running_max
+ dates, dd = maybe_decimate(dates, dd)
- ax_.fill_between(dates, dd, 0, color=color, alpha=0.25)
- ax_.plot(dates, dd, color=color, linewidth=0.8)
- ax_.set_title(title)
- ax_.set_ylabel("Drawdown")
- ax_.yaxis.set_major_formatter(mticker.PercentFormatter(xmax=1.0, decimals=0))
- ax_.set_ylim(top=0)
- ax_.xaxis.set_major_formatter(mdates.DateFormatter("%b %Y"))
- fig.autofmt_xdate(rotation=0, ha="center")
+ fig.add_trace(go.Scatter(
+ x=dates, y=dd, mode="lines",
+ line=dict(color=color, width=0.9),
+ fill="tozeroy", fillcolor=_rgba(color, 0.25),
+ hovertemplate="%{x|%d %b %Y} %{y:.1%}",
+ ))
+ dd_min = float(dd.min()) if len(dd) else -0.01
+ fig.update_yaxes(title_text="Drawdown", tickformat=".0%",
+ range=[dd_min * 1.08, 0])
+ fig.update_xaxes(tickformat="%b %Y")
return finalize(fig, show=show, save=save)
@@ -312,14 +369,16 @@ def drawdown(
def monthly_returns(
result,
*,
- ax: Optional[Axes] = None,
+ ax=None,
annotate: bool = True,
title: str = "Monthly Returns (%)",
figsize: Tuple[float, float] = (12, 5),
show: bool = False,
save: Optional[Union[str, Path]] = None,
-) -> Figure:
+) -> go.Figure:
"""Monthly returns heatmap (year rows x month columns + annual)."""
+ from manifoldbt.plot._theme import CS_DIVERGING
+
with theme_context():
dates, values = equity_with_dates(result)
ts = dates.astype("datetime64[M]")
@@ -344,31 +403,27 @@ def monthly_returns(
if len(valid) > 0:
grid[yi, 12] = np.prod(1.0 + valid) - 1.0
- fig, ax_ = get_or_create_ax(ax, figsize)
abs_max = max(np.nanmax(np.abs(grid)), 0.01)
- cmap = plt.get_cmap("bt_diverging")
- im = ax_.imshow(grid, cmap=cmap, aspect="auto", vmin=-abs_max, vmax=abs_max)
-
month_labels = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec", "YTD"]
- ax_.set_xticks(range(13))
- ax_.set_xticklabels(month_labels, fontsize=8)
- ax_.set_yticks(range(len(years)))
- ax_.set_yticklabels([str(y) for y in years], fontsize=9)
- if annotate:
- for yi in range(len(years)):
- for mi in range(13):
- val = grid[yi, mi]
- if np.isnan(val):
- continue
- txt = f"{val * 100:+.1f}"
- brightness = abs(val) / abs_max
- txt_color = WHITE if brightness > 0.4 else GRAY
- ax_.text(mi, yi, txt, ha="center", va="center",
- fontsize=7, color=txt_color, fontweight="medium")
+ text = np.where(np.isnan(grid), "", np.vectorize(lambda v: f"{v * 100:+.1f}" if not np.isnan(v) else "")(grid))
- ax_.set_title(title)
+ fig = new_figure(figsize, title)
+ fig.add_trace(go.Heatmap(
+ z=grid * 100, x=month_labels, y=[str(y) for y in years],
+ colorscale=CS_DIVERGING, zmin=-abs_max * 100, zmax=abs_max * 100,
+ text=text if annotate else None,
+ texttemplate="%{text}" if annotate else None,
+ textfont=dict(size=10),
+ hovertemplate="%{y} %{x}: %{z:+.2f}%",
+ colorbar=dict(ticksuffix="%", outlinewidth=0, thickness=12),
+ hoverongaps=False,
+ ))
+ fig.update_yaxes(autorange="reversed")
+ fig.update_xaxes(side="bottom", showspikes=False)
+ fig.update_yaxes(showspikes=False)
+ fig.update_layout(hovermode="closest")
return finalize(fig, show=show, save=save)
@@ -378,12 +433,12 @@ def monthly_returns(
def annual_returns(
result,
*,
- ax: Optional[Axes] = None,
+ ax=None,
title: str = "Annual Returns",
figsize: Tuple[float, float] = (10, 4),
show: bool = False,
save: Optional[Union[str, Path]] = None,
-) -> Figure:
+) -> go.Figure:
"""Annual returns bar chart with green/red conditional coloring."""
with theme_context():
dates, values = equity_with_dates(result)
@@ -394,19 +449,20 @@ def annual_returns(
idx = np.nonzero(years_arr == y)[0]
ann_rets.append(values[idx[-1]] / values[idx[0]] - 1.0 if len(idx) >= 2 else 0.0)
- fig, ax_ = get_or_create_ax(ax, figsize)
+ fig = new_figure(figsize, title)
colors = [GREEN if r >= 0 else RED for r in ann_rets]
- bars = ax_.bar([str(y) for y in unique_years], ann_rets, color=colors,
- width=0.5, alpha=0.85, edgecolor="none")
- ax_.axhline(0, color=DARK_GRAY, linewidth=0.5)
- ax_.set_title(title)
- ax_.yaxis.set_major_formatter(mticker.PercentFormatter(xmax=1.0, decimals=0))
-
- for bar, ret in zip(bars, ann_rets):
- ax_.text(bar.get_x() + bar.get_width() / 2, bar.get_height(),
- format_pct(ret), ha="center",
- va="bottom" if ret >= 0 else "top",
- fontsize=8, color=GRAY)
+ fig.add_trace(go.Bar(
+ x=[str(y) for y in unique_years], y=ann_rets,
+ marker_color=colors, opacity=0.85, marker_line_width=0,
+ width=0.5,
+ text=[format_pct(r) for r in ann_rets],
+ textposition="outside", textfont=dict(color=GRAY, size=11),
+ hovertemplate="%{x}: %{y:.1%}",
+ ))
+ fig.add_hline(y=0, line_color=DARK_GRAY, line_width=0.5)
+ fig.update_yaxes(tickformat=".0%")
+ fig.update_xaxes(showspikes=False, type="category")
+ fig.update_layout(hovermode="closest")
return finalize(fig, show=show, save=save)
@@ -416,19 +472,19 @@ def annual_returns(
def returns_histogram(
result,
*,
- ax: Optional[Axes] = None,
+ ax=None,
bins: int = 100,
title: str = "Returns Distribution",
figsize: Tuple[float, float] = (12, 5),
show: bool = False,
save: Optional[Union[str, Path]] = None,
-) -> Figure:
+) -> go.Figure:
"""Histogram of daily returns with green/red coloring by sign."""
with theme_context():
- fig, ax_ = get_or_create_ax(ax, figsize)
+ fig = new_figure(figsize, title)
rets = daily_returns_array(result)
if len(rets) == 0:
- ax_.set_title(title + " (no data)")
+ fig.update_layout(title_text=title + " (no data)")
return finalize(fig, show=show, save=save)
# Clip x-axis to P1-P99 range to avoid empty space from outliers
@@ -436,28 +492,34 @@ def returns_histogram(
margin = (p99 - p1) * 0.3
xlim = (p1 - margin, p99 + margin)
- _, bin_edges, patches = ax_.hist(rets, bins=bins, edgecolor="none", alpha=0.7,
- range=xlim)
- for patch, left in zip(patches, bin_edges[:-1]):
- patch.set_facecolor(GREEN if left >= 0 else RED)
+ counts, bin_edges = np.histogram(rets, bins=bins, range=xlim)
+ centers = (bin_edges[:-1] + bin_edges[1:]) / 2
+ bw = bin_edges[1] - bin_edges[0]
+ colors = [GREEN if left >= 0 else RED for left in bin_edges[:-1]]
- ax_.axvline(0, color=DARK_GRAY, linewidth=0.8, linestyle="--")
- ax_.set_xlim(xlim)
+ fig.add_trace(go.Bar(
+ x=centers, y=counts, width=bw,
+ marker_color=colors, opacity=0.7, marker_line_width=0,
+ hovertemplate="%{x:.2%}: %{y}",
+ ))
+ fig.add_vline(x=0, line_color=DARK_GRAY, line_width=0.8, line_dash="dash")
# Normal fit (pure numpy)
mu, sigma = rets.mean(), rets.std()
if sigma > 0:
x = np.linspace(xlim[0], xlim[1], 200)
- bw = bin_edges[1] - bin_edges[0]
pdf = (1 / (sigma * np.sqrt(2 * np.pi))) * np.exp(-0.5 * ((x - mu) / sigma) ** 2)
- ax_.plot(x, pdf * len(rets) * bw, color=ACCENT, linewidth=1.0,
- alpha=0.7, label="Normal")
- ax_.legend(loc="upper right", framealpha=0.3)
+ fig.add_trace(go.Scatter(
+ x=x, y=pdf * len(rets) * bw, mode="lines", name="Normal",
+ line=dict(color=ACCENT, width=1.0), opacity=0.7,
+ hoverinfo="skip",
+ ))
+ fig.update_layout(legend=dict(x=0.99, y=0.99, xanchor="right"))
- ax_.set_title(title)
- ax_.set_xlabel("Daily Return")
- ax_.set_ylabel("Frequency")
- ax_.xaxis.set_major_formatter(mticker.PercentFormatter(xmax=1.0, decimals=1))
+ fig.update_xaxes(title_text="Daily Return", tickformat=".1%",
+ range=list(xlim))
+ fig.update_yaxes(title_text="Frequency")
+ fig.update_layout(hovermode="closest", bargap=0.05)
return finalize(fig, show=show, save=save)
@@ -467,60 +529,66 @@ def returns_histogram(
def var_chart(
result,
*,
- ax: Optional[Axes] = None,
+ ax=None,
confidence: float = 0.05,
bins: int = 120,
title: str = "Value at Risk",
figsize: Tuple[float, float] = (12, 5),
show: bool = False,
save: Optional[Union[str, Path]] = None,
-) -> Figure:
+) -> go.Figure:
"""Returns histogram with VaR and CVaR lines at 5% and 1% levels."""
with theme_context():
- fig, ax_ = get_or_create_ax(ax, figsize)
+ fig = new_figure(figsize, title)
rets = daily_returns_array(result)
if len(rets) == 0:
- ax_.set_title(title + " (no data)")
+ fig.update_layout(title_text=title + " (no data)")
return finalize(fig, show=show, save=save)
rets_pct = rets * 100
- # Histogram
- n, bin_edges, patches = ax_.hist(
- rets_pct, bins=bins, color=ACCENT, alpha=0.5, edgecolor="none",
- )
-
- # VaR/CVaR at 5%
+ # VaR/CVaR at 5% and 1%
var_5 = float(np.percentile(rets, 5))
cvar_5 = float(rets[rets <= var_5].mean()) if np.any(rets <= var_5) else var_5
-
- # VaR/CVaR at 1%
var_1 = float(np.percentile(rets, 1))
cvar_1 = float(rets[rets <= var_1].mean()) if np.any(rets <= var_1) else var_1
- # Color tail bins
- for b, p in zip(bin_edges, patches):
- if b < var_1 * 100:
- p.set_facecolor(RED)
- p.set_alpha(0.5)
- elif b < var_5 * 100:
- p.set_facecolor(ORANGE)
- p.set_alpha(0.4)
+ counts, bin_edges = np.histogram(rets_pct, bins=bins)
+ centers = (bin_edges[:-1] + bin_edges[1:]) / 2
+ bw = bin_edges[1] - bin_edges[0]
+ colors = []
+ for left in bin_edges[:-1]:
+ if left < var_1 * 100:
+ colors.append(_rgba(RED, 0.5))
+ elif left < var_5 * 100:
+ colors.append(_rgba(ORANGE, 0.4))
+ else:
+ colors.append(_rgba(ACCENT, 0.5))
- # VaR lines
- ax_.axvline(var_5 * 100, color=ORANGE, linewidth=0.8,
- label=f"VaR 5%: {format_pct(var_5)}")
- ax_.axvline(cvar_5 * 100, color=ORANGE, linewidth=0.6, linestyle="--", alpha=0.5,
- label=f"CVaR 5%: {format_pct(cvar_5)}")
- ax_.axvline(var_1 * 100, color=RED, linewidth=0.8,
- label=f"VaR 1%: {format_pct(var_1)}")
- ax_.axvline(cvar_1 * 100, color=RED, linewidth=0.6, linestyle="--", alpha=0.5,
- label=f"CVaR 1%: {format_pct(cvar_1)}")
+ fig.add_trace(go.Bar(
+ x=centers, y=counts, width=bw, marker_color=colors,
+ marker_line_width=0, showlegend=False,
+ hovertemplate="%{x:.2f}%: %{y}",
+ ))
- ax_.set_title(title)
- ax_.set_xlabel("Daily Return (%)")
- ax_.set_ylabel("Frequency")
- ax_.legend(loc="upper right", fontsize=8, framealpha=0.3)
+ # VaR/CVaR lines with legend proxies
+ for val, color, dash, label in (
+ (var_5, ORANGE, None, f"VaR 5%: {format_pct(var_5)}"),
+ (cvar_5, ORANGE, "dash", f"CVaR 5%: {format_pct(cvar_5)}"),
+ (var_1, RED, None, f"VaR 1%: {format_pct(var_1)}"),
+ (cvar_1, RED, "dash", f"CVaR 1%: {format_pct(cvar_1)}"),
+ ):
+ fig.add_vline(x=val * 100, line_color=color, line_width=0.8,
+ line_dash=dash, opacity=0.8 if dash is None else 0.5)
+ fig.add_trace(go.Scatter(
+ x=[None], y=[None], mode="lines", name=label,
+ line=dict(color=color, width=1.2, dash=dash),
+ ))
+
+ fig.update_xaxes(title_text="Daily Return (%)")
+ fig.update_yaxes(title_text="Frequency")
+ fig.update_layout(hovermode="closest", bargap=0.05,
+ legend=dict(x=0.99, y=0.99, xanchor="right"))
return finalize(fig, show=show, save=save)
@@ -531,20 +599,20 @@ def rolling_sharpe(
result,
*,
windows: Optional[List[int]] = None,
- ax: Optional[Axes] = None,
+ ax=None,
title: str = "Rolling Sharpe",
trading_days_per_year: float = 365.25,
figsize: Tuple[float, float] = (14, 4),
show: bool = False,
save: Optional[Union[str, Path]] = None,
-) -> Figure:
+) -> go.Figure:
"""Rolling annualized Sharpe ratio."""
if windows is None:
windows = [126, 252]
colors = [ACCENT, ACCENT_ALT, GREEN, RED]
with theme_context():
- fig, ax_ = get_or_create_ax(ax, figsize)
+ fig = new_figure(figsize, title)
rets = daily_returns_array(result)
for i, w in enumerate(windows):
@@ -554,13 +622,15 @@ def rolling_sharpe(
rs = _rolling(rets, w, np.std)
with np.errstate(divide="ignore", invalid="ignore"):
sharpe = np.where(rs > 0, rm / rs * np.sqrt(trading_days_per_year), 0.0)
- label = f"{w}d"
- ax_.plot(sharpe, color=colors[i % len(colors)], linewidth=1.0, label=label)
+ fig.add_trace(go.Scatter(
+ y=sharpe, mode="lines", name=f"{w}d",
+ line=dict(color=colors[i % len(colors)], width=1.0),
+ hovertemplate="day %{x}: %{y:.2f}" + f"{w}d" + "",
+ ))
- ax_.axhline(0, color=DARK_GRAY, linewidth=0.5, linestyle="--")
- ax_.set_title(title)
- ax_.set_ylabel("Sharpe")
- ax_.legend(loc="upper left", framealpha=0.3)
+ fig.add_hline(y=0, line_color=DARK_GRAY, line_width=0.5, line_dash="dash")
+ fig.update_yaxes(title_text="Sharpe")
+ fig.update_layout(legend=dict(x=0.01, y=0.99))
return finalize(fig, show=show, save=save)
@@ -571,20 +641,20 @@ def rolling_volatility(
result,
*,
windows: Optional[List[int]] = None,
- ax: Optional[Axes] = None,
+ ax=None,
title: str = "Rolling Volatility",
trading_days_per_year: float = 365.25,
figsize: Tuple[float, float] = (14, 4),
show: bool = False,
save: Optional[Union[str, Path]] = None,
-) -> Figure:
+) -> go.Figure:
"""Rolling annualized volatility."""
if windows is None:
windows = [126, 252]
colors = [ACCENT, ACCENT_ALT, GREEN, RED]
with theme_context():
- fig, ax_ = get_or_create_ax(ax, figsize)
+ fig = new_figure(figsize, title)
rets = daily_returns_array(result)
for i, w in enumerate(windows):
@@ -592,12 +662,14 @@ def rolling_volatility(
continue
rs = _rolling(rets, w, np.std)
vol = rs * np.sqrt(trading_days_per_year)
- ax_.plot(vol, color=colors[i % len(colors)], linewidth=1.0, label=f"{w}d")
+ fig.add_trace(go.Scatter(
+ y=vol, mode="lines", name=f"{w}d",
+ line=dict(color=colors[i % len(colors)], width=1.0),
+ hovertemplate="day %{x}: %{y:.1%}" + f"{w}d" + "",
+ ))
- ax_.set_title(title)
- ax_.set_ylabel("Volatility")
- ax_.yaxis.set_major_formatter(mticker.PercentFormatter(xmax=1.0, decimals=0))
- ax_.legend(loc="upper left", framealpha=0.3)
+ fig.update_yaxes(title_text="Volatility", tickformat=".0%")
+ fig.update_layout(legend=dict(x=0.01, y=0.99))
return finalize(fig, show=show, save=save)
diff --git a/python/manifoldbt/plot/chart.py b/python/manifoldbt/plot/chart.py
index 8e13c4e..290eec3 100644
--- a/python/manifoldbt/plot/chart.py
+++ b/python/manifoldbt/plot/chart.py
@@ -1,4 +1,4 @@
-"""Candlestick chart with indicators and trade markers."""
+"""Candlestick chart with indicators and trade markers (plotly)."""
from __future__ import annotations
from pathlib import Path
@@ -12,13 +12,9 @@ from manifoldbt.plot._theme import (
ACCENT_ALT,
BG_AXES,
BG_FIGURE,
- BORDER,
- DARK_GRAY,
GREEN,
- GRID_RGBA,
- GRAY,
RED,
- WHITE,
+ theme_context,
)
from manifoldbt.plot._utils import finalize
@@ -143,49 +139,7 @@ def _load_bars(
# ---------------------------------------------------------------------------
-# Candlestick drawing
-# ---------------------------------------------------------------------------
-
-def _draw_candles(ax, dates, o, h, l, c, width_ratio=0.6):
- """Draw candlestick bodies and wicks on an axes."""
- n = len(dates)
- if n < 2:
- return
-
- # Width in date units
- delta = np.median(np.diff(dates)).astype("timedelta64[s]").astype(float)
- w = np.timedelta64(int(delta * width_ratio), "s")
-
- bull = c >= o
- bear = ~bull
-
- # Wicks (high-low lines)
- for i in range(n):
- color = GREEN if bull[i] else RED
- ax.plot([dates[i], dates[i]], [l[i], h[i]], color=color, linewidth=0.5, alpha=0.7)
-
- # Bodies
- for mask, color in [(bull, GREEN), (bear, RED)]:
- idx = np.where(mask)[0]
- for i in idx:
- bottom = min(o[i], c[i])
- height = abs(c[i] - o[i])
- if height < 1e-10:
- height = (h[i] - l[i]) * 0.01
- rect = __import__("matplotlib.patches", fromlist=["Rectangle"]).Rectangle(
- (dates[i] - w / 2, bottom),
- w,
- height,
- facecolor=color,
- edgecolor=color,
- alpha=0.85,
- linewidth=0.5,
- )
- ax.add_patch(rect)
-
-
-# ---------------------------------------------------------------------------
-# Public API
+# Shared helpers
# ---------------------------------------------------------------------------
INDICATOR_COLORS = [ACCENT, ACCENT_ALT, "#2dd4bf", "#f59e0b", "#f472b6"]
@@ -206,7 +160,7 @@ def _resolve_sym_name(store, symbol_id: int) -> str:
def _prepare_chart_data(result, store, symbol_id, n_bars):
- """Load bars, compute trim offset, extract trades — shared by both renderers."""
+ """Load bars, compute trim offset, extract trades."""
manifest = result.manifest
cfg = manifest.get("config", {})
tr = cfg.get("time_range", {})
@@ -244,243 +198,6 @@ def _prepare_chart_data(result, store, symbol_id, n_bars):
}
-# ---------------------------------------------------------------------------
-# Interactive chart (plotly)
-# ---------------------------------------------------------------------------
-
-def _chart_interactive(result, store, symbol_id, *, emas, smas, n_bars, save):
- """Plotly-based interactive candlestick chart."""
- import plotly.graph_objects as go
- from plotly.subplots import make_subplots
-
- bars, offset, bar_interval_s, filtered_trades = _prepare_chart_data(
- result, store, symbol_id, n_bars,
- )
-
- close_full = bars["close"]
- ts = bars["timestamp"][offset:]
- o = bars["open"][offset:]
- h = bars["high"][offset:]
- l = bars["low"][offset:]
- c = bars["close"][offset:]
- vol = bars["volume"][offset:]
- dates = ts.view("datetime64[ns]")
-
- sym_name = _resolve_sym_name(store, symbol_id)
- interval_label = _interval_label(bar_interval_s)
-
- fig = make_subplots(
- rows=2, cols=1,
- shared_xaxes=True,
- vertical_spacing=0.03,
- row_heights=[0.8, 0.2],
- )
-
- # Candlesticks
- fig.add_trace(
- go.Candlestick(
- x=dates, open=o, high=h, low=l, close=c,
- increasing_line_color=GREEN, decreasing_line_color=RED,
- increasing_fillcolor=GREEN, decreasing_fillcolor=RED,
- name="OHLC",
- ),
- row=1, col=1,
- )
-
- # Indicators
- color_idx = 0
- if emas:
- for period in emas:
- vals = _ema(close_full, period)[offset:]
- color = INDICATOR_COLORS[color_idx % len(INDICATOR_COLORS)]
- fig.add_trace(
- go.Scatter(
- x=dates, y=vals, mode="lines",
- name=f"EMA({period})",
- line=dict(color=color, width=1.5),
- ),
- row=1, col=1,
- )
- color_idx += 1
-
- if smas:
- for period in smas:
- vals = _sma(close_full, period)[offset:]
- color = INDICATOR_COLORS[color_idx % len(INDICATOR_COLORS)]
- fig.add_trace(
- go.Scatter(
- x=dates, y=vals, mode="lines",
- name=f"SMA({period})",
- line=dict(color=color, width=1.5, dash="dash"),
- ),
- row=1, col=1,
- )
- color_idx += 1
-
- # Trade markers
- t_ts = filtered_trades["ts"]
- t_side = filtered_trades["side"]
- t_price = filtered_trades["price"]
- t_qty = filtered_trades["qty"]
-
- buy_mask = t_side == 1
- sell_mask = t_side == 2
-
- if buy_mask.any():
- fig.add_trace(
- go.Scatter(
- x=t_ts[buy_mask], y=t_price[buy_mask],
- mode="markers",
- name="BUY",
- marker=dict(
- symbol="triangle-up", size=12,
- color=GREEN, line=dict(color="white", width=1),
- ),
- text=[f"BUY {q:.6f} @ {p:.2f}" for q, p in
- zip(t_qty[buy_mask], t_price[buy_mask])],
- hoverinfo="text+x",
- ),
- row=1, col=1,
- )
-
- if sell_mask.any():
- fig.add_trace(
- go.Scatter(
- x=t_ts[sell_mask], y=t_price[sell_mask],
- mode="markers",
- name="SELL",
- marker=dict(
- symbol="triangle-down", size=12,
- color=RED, line=dict(color="white", width=1),
- ),
- text=[f"SELL {q:.6f} @ {p:.2f}" for q, p in
- zip(t_qty[sell_mask], t_price[sell_mask])],
- hoverinfo="text+x",
- ),
- row=1, col=1,
- )
-
- # Volume bars
- vol_colors = [GREEN if c[i] >= o[i] else RED for i in range(len(c))]
- fig.add_trace(
- go.Bar(
- x=dates, y=vol, name="Volume",
- marker_color=vol_colors, opacity=0.5,
- showlegend=False,
- ),
- row=2, col=1,
- )
-
- # Layout — dark theme
- fig.update_layout(
- title=f"{sym_name} {interval_label}",
- template="plotly_dark",
- paper_bgcolor=BG_FIGURE,
- plot_bgcolor=BG_AXES,
- xaxis_rangeslider_visible=False,
- hovermode="x unified",
- legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0),
- height=700,
- margin=dict(l=60, r=20, t=60, b=40),
- )
-
- fig.update_yaxes(title_text="Price", row=1, col=1)
- fig.update_yaxes(title_text="Vol", row=2, col=1)
-
- if save:
- fig.write_html(str(save))
-
- fig.show()
- return fig
-
-
-# ---------------------------------------------------------------------------
-# Matplotlib (static) chart
-# ---------------------------------------------------------------------------
-
-def _chart_matplotlib(result, store, symbol_id, *, emas, smas, n_bars, figsize, show, save):
- """Matplotlib-based static candlestick chart."""
- import matplotlib.pyplot as plt
- from manifoldbt.plot._theme import theme_context
-
- bars, offset, bar_interval_s, filtered_trades = _prepare_chart_data(
- result, store, symbol_id, n_bars,
- )
-
- close_full = bars["close"]
- ts = bars["timestamp"][offset:]
- o = bars["open"][offset:]
- h = bars["high"][offset:]
- l = bars["low"][offset:]
- c = bars["close"][offset:]
- dates = ts.view("datetime64[ns]")
-
- sym_name = _resolve_sym_name(store, symbol_id)
- interval_label = _interval_label(bar_interval_s)
-
- with theme_context():
- fig, (ax_price, ax_vol) = plt.subplots(
- 2, 1, figsize=figsize, height_ratios=[4, 1],
- sharex=True, gridspec_kw={"hspace": 0.05},
- )
-
- _draw_candles(ax_price, dates, o, h, l, c)
-
- color_idx = 0
- if emas:
- for period in emas:
- vals = _ema(close_full, period)[offset:]
- color = INDICATOR_COLORS[color_idx % len(INDICATOR_COLORS)]
- ax_price.plot(dates, vals, color=color, linewidth=1.2,
- label=f"EMA({period})", alpha=0.9)
- color_idx += 1
- if smas:
- for period in smas:
- vals = _sma(close_full, period)[offset:]
- color = INDICATOR_COLORS[color_idx % len(INDICATOR_COLORS)]
- ax_price.plot(dates, vals, color=color, linewidth=1.2,
- label=f"SMA({period})", linestyle="--", alpha=0.9)
- color_idx += 1
-
- # Trade markers
- t_ts = filtered_trades["ts"]
- t_side = filtered_trades["side"]
- t_price = filtered_trades["price"]
- buy_mask = t_side == 1
- sell_mask = t_side == 2
-
- if buy_mask.any():
- ax_price.scatter(
- t_ts[buy_mask], t_price[buy_mask],
- marker="^", color=GREEN, s=80, zorder=5,
- edgecolors=WHITE, linewidths=0.5, label="BUY",
- )
- if sell_mask.any():
- ax_price.scatter(
- t_ts[sell_mask], t_price[sell_mask],
- marker="v", color=RED, s=80, zorder=5,
- edgecolors=WHITE, linewidths=0.5, label="SELL",
- )
-
- ax_price.legend(loc="upper left", fontsize=8)
- ax_price.set_title(f"{sym_name} {interval_label}", fontsize=11, loc="left")
- ax_price.set_ylabel("Price", fontsize=9)
-
- vol = bars["volume"][offset:]
- vol_colors = np.where(c >= o, GREEN, RED)
- ax_vol.bar(dates, vol, width=np.timedelta64(int(bar_interval_s * 0.6), "s"),
- color=vol_colors, alpha=0.5)
- ax_vol.set_ylabel("Volume", fontsize=9)
-
- import matplotlib.dates as mdates
- if bar_interval_s < 86400:
- ax_vol.xaxis.set_major_locator(mdates.AutoDateLocator())
- ax_vol.xaxis.set_major_formatter(mdates.DateFormatter("%m-%d %H:%M"))
- fig.autofmt_xdate(rotation=30, ha="right")
-
- return finalize(fig, show=show, save=save)
-
-
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
@@ -507,22 +224,152 @@ def chart(
emas: List of EMA periods to overlay (e.g. [10, 25]).
smas: List of SMA periods to overlay.
n_bars: Number of bars to display (last N).
- interactive: Use plotly (True) or matplotlib (False).
- figsize: Figure size (matplotlib only).
- show: Display the chart (matplotlib only; plotly always shows).
- save: Save path (.html for plotly, .png for matplotlib).
+ interactive: Kept for backward compatibility (plotly renders both
+ paths; ``save=".png"`` produces a static image via kaleido).
+ figsize: Figure size in inches, mapped to pixels.
+ show: Display the chart in the browser.
+ save: Save path (.html interactive, or .png/.svg via kaleido).
"""
- if interactive:
- return _chart_interactive(
- result, store, symbol_id,
- emas=emas, smas=smas, n_bars=n_bars, save=save,
- )
- return _chart_matplotlib(
- result, store, symbol_id,
- emas=emas, smas=smas, n_bars=n_bars,
- figsize=figsize, show=show, save=save,
+ _ = interactive # single plotly path
+ import plotly.graph_objects as go
+ from plotly.subplots import make_subplots
+
+ bars, offset, bar_interval_s, filtered_trades = _prepare_chart_data(
+ result, store, symbol_id, n_bars,
)
+ close_full = bars["close"]
+ ts = bars["timestamp"][offset:]
+ o = bars["open"][offset:]
+ h = bars["high"][offset:]
+ l = bars["low"][offset:]
+ c = bars["close"][offset:]
+ vol = bars["volume"][offset:]
+ dates = ts.view("datetime64[ns]")
+
+ sym_name = _resolve_sym_name(store, symbol_id)
+ interval_label = _interval_label(bar_interval_s)
+
+ with theme_context():
+ fig = make_subplots(
+ rows=2, cols=1,
+ shared_xaxes=True,
+ vertical_spacing=0.03,
+ row_heights=[0.8, 0.2],
+ )
+
+ # Candlesticks
+ fig.add_trace(
+ go.Candlestick(
+ x=dates, open=o, high=h, low=l, close=c,
+ increasing_line_color=GREEN, decreasing_line_color=RED,
+ increasing_fillcolor=GREEN, decreasing_fillcolor=RED,
+ name="OHLC",
+ ),
+ row=1, col=1,
+ )
+
+ # Indicators
+ color_idx = 0
+ if emas:
+ for period in emas:
+ vals = _ema(close_full, period)[offset:]
+ color = INDICATOR_COLORS[color_idx % len(INDICATOR_COLORS)]
+ fig.add_trace(
+ go.Scatter(
+ x=dates, y=vals, mode="lines",
+ name=f"EMA({period})",
+ line=dict(color=color, width=1.5),
+ ),
+ row=1, col=1,
+ )
+ color_idx += 1
+
+ if smas:
+ for period in smas:
+ vals = _sma(close_full, period)[offset:]
+ color = INDICATOR_COLORS[color_idx % len(INDICATOR_COLORS)]
+ fig.add_trace(
+ go.Scatter(
+ x=dates, y=vals, mode="lines",
+ name=f"SMA({period})",
+ line=dict(color=color, width=1.5, dash="dash"),
+ ),
+ row=1, col=1,
+ )
+ color_idx += 1
+
+ # Trade markers
+ t_ts = filtered_trades["ts"]
+ t_side = filtered_trades["side"]
+ t_price = filtered_trades["price"]
+ t_qty = filtered_trades["qty"]
+
+ buy_mask = t_side == 1
+ sell_mask = t_side == 2
+
+ if buy_mask.any():
+ fig.add_trace(
+ go.Scatter(
+ x=t_ts[buy_mask], y=t_price[buy_mask],
+ mode="markers",
+ name="BUY",
+ marker=dict(
+ symbol="triangle-up", size=12,
+ color=GREEN, line=dict(color="white", width=1),
+ ),
+ text=[f"BUY {q:.6f} @ {p:.2f}" for q, p in
+ zip(t_qty[buy_mask], t_price[buy_mask])],
+ hoverinfo="text+x",
+ ),
+ row=1, col=1,
+ )
+
+ if sell_mask.any():
+ fig.add_trace(
+ go.Scatter(
+ x=t_ts[sell_mask], y=t_price[sell_mask],
+ mode="markers",
+ name="SELL",
+ marker=dict(
+ symbol="triangle-down", size=12,
+ color=RED, line=dict(color="white", width=1),
+ ),
+ text=[f"SELL {q:.6f} @ {p:.2f}" for q, p in
+ zip(t_qty[sell_mask], t_price[sell_mask])],
+ hoverinfo="text+x",
+ ),
+ row=1, col=1,
+ )
+
+ # Volume bars
+ vol_colors = [GREEN if c[i] >= o[i] else RED for i in range(len(c))]
+ fig.add_trace(
+ go.Bar(
+ x=dates, y=vol, name="Volume",
+ marker_color=vol_colors, opacity=0.5,
+ showlegend=False,
+ ),
+ row=2, col=1,
+ )
+
+ fig.update_layout(
+ title=f"{sym_name} {interval_label}",
+ paper_bgcolor=BG_FIGURE,
+ plot_bgcolor=BG_AXES,
+ xaxis_rangeslider_visible=False,
+ hovermode="x unified",
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0),
+ width=int(figsize[0] * 80),
+ height=int(figsize[1] * 80),
+ margin=dict(l=60, r=20, t=60, b=40),
+ )
+
+ fig.update_yaxes(title_text="Price", row=1, col=1)
+ fig.update_yaxes(title_text="Vol", row=2, col=1)
+
+ return finalize(fig, show=show, save=save)
+
def _bar_interval_to_seconds(bi: dict) -> int:
"""Convert manifest bar_interval dict to seconds."""
diff --git a/python/manifoldbt/plot/research.py b/python/manifoldbt/plot/research.py
index d6f730b..4d4dda6 100644
--- a/python/manifoldbt/plot/research.py
+++ b/python/manifoldbt/plot/research.py
@@ -1,28 +1,76 @@
-"""Charts for research analysis results (sweep, walk-forward, stability)."""
+"""Charts for research analysis results (sweep, walk-forward, stability) — plotly."""
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union
import numpy as np
-import matplotlib.pyplot as plt
-import matplotlib.ticker as mticker
-from matplotlib.axes import Axes
-from matplotlib.figure import Figure
+import plotly.graph_objects as go
+from plotly.subplots import make_subplots
from manifoldbt.plot._theme import (
ACCENT,
- ACCENT_ALT,
+ BORDER,
+ CS_CORRELATION,
+ CS_SEQUENTIAL,
DARK_GRAY,
GRAY,
- GREEN,
+ MONO_FAMILY,
ORANGE,
- RED,
WHITE,
theme_context,
)
from manifoldbt.plot._convert import daily_returns_array, equity_with_dates
-from manifoldbt.plot._utils import finalize, format_pct, get_or_create_ax
+from manifoldbt.plot._utils import finalize, new_figure
+
+
+def _rgba(hex_color: str, alpha: float) -> str:
+ h = hex_color.lstrip("#")
+ return f"rgba({int(h[0:2], 16)},{int(h[2:4], 16)},{int(h[4:6], 16)},{alpha})"
+
+
+def _extract_val(v):
+ """Extract numeric values from ScalarValue dicts like {'Float64': 1.23}."""
+ if isinstance(v, dict):
+ for val in v.values():
+ return val
+ return v
+
+
+def _grid_window_size(nx: int, ny: int, plot: int = 720, cbar: int = 160,
+ top: int = 70) -> tuple:
+ """Window size matching the grid aspect (square grid -> square-ish window)."""
+ if nx >= ny:
+ pw, ph = plot, plot * ny / max(nx, 1)
+ else:
+ pw, ph = plot * nx / max(ny, 1), plot
+ return (int(pw + cbar), int(ph + top))
+
+
+def _plateau_best(grid: np.ndarray):
+ """Plateau-optimal cell: Gaussian blur finds the center of the best stable
+ region, not a lucky spike (overfit-resistant). sigma = ~5% of each axis."""
+ from scipy.ndimage import gaussian_filter
+
+ sigma_y = max(1.0, grid.shape[0] * 0.05)
+ sigma_x = max(1.0, grid.shape[1] * 0.05)
+ smoothed = gaussian_filter(
+ np.nan_to_num(grid, nan=np.nanmin(grid)),
+ sigma=(sigma_y, sigma_x),
+ )
+ return np.unravel_index(np.argmax(smoothed), smoothed.shape)
+
+
+def _stats_annotation(fig, text: str) -> None:
+ """Monospace stats box in the top-right corner."""
+ fig.add_annotation(
+ x=0.98, y=0.95, xref="paper", yref="paper",
+ xanchor="right", yanchor="top", align="left",
+ text=text.replace("\n", "
"), showarrow=False,
+ font=dict(family=MONO_FAMILY, size=11, color=GRAY),
+ bgcolor="rgba(17,17,22,0.9)", bordercolor=BORDER, borderwidth=1,
+ borderpad=6,
+ )
# ── 2D Parameter Sweep Heatmap ──────────────────────────────────────────────
@@ -31,7 +79,7 @@ from manifoldbt.plot._utils import finalize, format_pct, get_or_create_ax
def heatmap_2d(
sweep_result: Dict[str, Any],
*,
- ax: Optional[Axes] = None,
+ ax=None,
annotate: bool = True,
fmt: str = ".3f",
highlight_best: bool = True,
@@ -39,105 +87,73 @@ def heatmap_2d(
figsize: Tuple[float, float] = (10, 8),
show: bool = False,
save: Optional[Union[str, Path]] = None,
-) -> Figure:
+) -> go.Figure:
"""2D parameter sweep heatmap from ``run_sweep_2d()`` result.
Expected keys: metric_grid, x_values, y_values, x_param, y_param, metric.
"""
with theme_context():
- fig, ax_ = get_or_create_ax(ax, figsize)
-
grid = np.array(sweep_result["metric_grid"], dtype=np.float64)
- x_vals_raw = sweep_result["x_values"]
- y_vals_raw = sweep_result["y_values"]
+ x_vals = [_extract_val(v) for v in sweep_result["x_values"]]
+ y_vals = [_extract_val(v) for v in sweep_result["y_values"]]
x_param = sweep_result.get("x_param", "x")
y_param = sweep_result.get("y_param", "y")
metric = sweep_result.get("metric", "metric")
-
- # Extract numeric values from ScalarValue dicts like {'Float64': 1.23}
- def _extract_val(v):
- if isinstance(v, dict):
- for val in v.values():
- return val
- return v
-
- x_vals = [_extract_val(v) for v in x_vals_raw]
- y_vals = [_extract_val(v) for v in y_vals_raw]
-
- cmap = plt.get_cmap("bt_sequential")
- im = ax_.imshow(
- grid, cmap=cmap, aspect="auto", interpolation="nearest",
- origin="lower",
- )
-
- # Adaptive tick labels: show max ~10 ticks per axis
- max_ticks = 10
nx, ny = len(x_vals), len(y_vals)
- x_step = max(1, nx // max_ticks)
- x_tick_idx = list(range(0, nx, x_step))
- ax_.set_xticks(x_tick_idx)
- ax_.set_xticklabels([f"{x_vals[i]:.2f}" for i in x_tick_idx], rotation=45, ha="right", fontsize=9)
-
- y_step = max(1, ny // max_ticks)
- y_tick_idx = list(range(0, ny, y_step))
- ax_.set_yticks(y_tick_idx)
- ax_.set_yticklabels([f"{y_vals[i]:.2f}" for i in y_tick_idx], fontsize=9)
-
- ax_.set_xlabel(x_param, fontsize=10, labelpad=8)
- ax_.set_ylabel(y_param, fontsize=10, labelpad=8)
-
- # Only annotate if grid is small enough to be readable
+ text = None
if annotate and nx * ny <= 100:
- for yi in range(grid.shape[0]):
- for xi in range(grid.shape[1]):
- val = grid[yi, xi]
- if np.isnan(val):
- continue
- norm = (val - np.nanmin(grid)) / (np.nanmax(grid) - np.nanmin(grid) + 1e-12)
- txt_color = "white" if norm > 0.6 or norm < 0.4 else "#1a1a1a"
- ax_.text(
- xi, yi, f"{val:{fmt}}",
- ha="center", va="center", fontsize=8, color=txt_color,
- )
+ text = np.vectorize(lambda v: "" if np.isnan(v) else f"{v:{fmt}}")(grid)
+ fig = new_figure(figsize)
+ fig.add_trace(go.Heatmap(
+ z=grid, x=x_vals, y=y_vals,
+ colorscale=CS_SEQUENTIAL,
+ text=text, texttemplate="%{text}" if text is not None else None,
+ textfont=dict(size=9),
+ hovertemplate=(
+ f"{x_param} %{{x}}
{y_param} %{{y}}
"
+ f"{metric} %{{z:{fmt}}}"
+ ),
+ colorbar=dict(outlinewidth=0, thickness=12),
+ hoverongaps=False,
+ ))
+
+ best_label = None
if highlight_best:
- from scipy.ndimage import gaussian_filter
-
- # Plateau-optimal: Gaussian blur finds the center of the best
- # stable region, not a lucky spike (overfit-resistant).
- # sigma = ~5% of each axis → favors broad plateaus.
- sigma_y = max(1.0, grid.shape[0] * 0.05)
- sigma_x = max(1.0, grid.shape[1] * 0.05)
- smoothed = gaussian_filter(
- np.nan_to_num(grid, nan=np.nanmin(grid)),
- sigma=(sigma_y, sigma_x),
- )
- best_idx = np.unravel_index(np.argmax(smoothed), smoothed.shape)
+ best_idx = _plateau_best(grid)
best_val = grid[best_idx]
best_x = x_vals[best_idx[1]]
best_y = y_vals[best_idx[0]]
- rect = plt.Rectangle(
- (best_idx[1] - 0.5, best_idx[0] - 0.5), 1, 1,
- linewidth=2.5, edgecolor="white", facecolor="none",
+ # Cell outline around the plateau-best combo
+ dx = (x_vals[1] - x_vals[0]) / 2 if nx > 1 else 0.5
+ dy = (y_vals[1] - y_vals[0]) / 2 if ny > 1 else 0.5
+ fig.add_shape(
+ type="rect",
+ x0=best_x - dx, x1=best_x + dx, y0=best_y - dy, y1=best_y + dy,
+ line=dict(color="white", width=2.5),
)
- ax_.add_patch(rect)
best_label = f"best: {best_val:{fmt}} ({x_param}={best_x:.0f}, {y_param}={best_y:.0f})"
- ax_.text(
- best_idx[1], best_idx[0], f"{best_val:{fmt}}",
- ha="center", va="center", fontsize=9, color="white", fontweight="bold",
- bbox={"boxstyle": "round,pad=0.2", "facecolor": "black", "alpha": 0.7, "edgecolor": "white"},
- )
combos = nx * ny
- main_title = title or f"{metric} -- Parameter Sweep ({combos:,} combos)"
- if highlight_best:
- ax_.set_title(f"{main_title}\n{best_label}", fontsize=11)
- else:
- ax_.set_title(main_title)
- fig.colorbar(im, ax=ax_, shrink=0.7)
- return finalize(fig, show=show, save=save)
+ main_title = title or f"{metric} — Parameter Sweep ({combos:,} combos)"
+ if best_label:
+ main_title = f"{main_title}
{best_label}"
+ fig.update_layout(title_text=main_title, hovermode="closest")
+ fig.update_xaxes(title_text=x_param, showspikes=False, constrain="domain")
+ fig.update_yaxes(title_text=y_param, showspikes=False)
+
+ # Square cells: lock the y/x pixel ratio to the data spacing so the grid
+ # keeps its true aspect (a 100x100 sweep is a square), even on resize.
+ if nx > 1 and ny > 1:
+ dx = (float(x_vals[-1]) - float(x_vals[0])) / (nx - 1)
+ dy = (float(y_vals[-1]) - float(y_vals[0])) / (ny - 1)
+ if dx > 0 and dy > 0:
+ fig.update_yaxes(scaleanchor="x", scaleratio=dx / dy,
+ constrain="domain")
+ return finalize(fig, show=show, save=save,
+ window_size=_grid_window_size(nx, ny))
# ── 3D Surface Plot ─────────────────────────────────────────────────────────
@@ -153,82 +169,75 @@ def surface_3d(
azim: float = -45,
show: bool = False,
save: Optional[Union[str, Path]] = None,
-) -> Figure:
+) -> go.Figure:
"""3D surface plot from a 2D parameter sweep result.
- Same input format as ``heatmap_2d``:
- Expected keys: metric_grid, x_values, y_values, x_param, y_param, metric.
+ Same input format as ``heatmap_2d``. ``elev``/``azim`` are kept for
+ backward compatibility and mapped to the plotly camera.
"""
- from mpl_toolkits.mplot3d import Axes3D # noqa: F401
-
with theme_context():
- fig = plt.figure(figsize=figsize)
- ax = fig.add_subplot(111, projection="3d")
-
grid = np.array(sweep_result["metric_grid"], dtype=np.float64)
- x_vals_raw = sweep_result["x_values"]
- y_vals_raw = sweep_result["y_values"]
+ x_vals = np.array([_extract_val(v) for v in sweep_result["x_values"]], dtype=np.float64)
+ y_vals = np.array([_extract_val(v) for v in sweep_result["y_values"]], dtype=np.float64)
x_param = sweep_result.get("x_param", "x")
y_param = sweep_result.get("y_param", "y")
metric = sweep_result.get("metric", "metric")
- def _extract_val(v):
- if isinstance(v, dict):
- for val in v.values():
- return val
- return v
-
- x_vals = np.array([_extract_val(v) for v in x_vals_raw], dtype=np.float64)
- y_vals = np.array([_extract_val(v) for v in y_vals_raw], dtype=np.float64)
-
- X, Y = np.meshgrid(x_vals, y_vals)
-
- cmap = plt.get_cmap("bt_sequential")
- surf = ax.plot_surface(
- X, Y, grid,
- cmap=cmap, alpha=0.9, linewidth=0, antialiased=True,
- rstride=max(1, grid.shape[0] // 80),
- cstride=max(1, grid.shape[1] // 80),
- )
+ fig = new_figure(figsize)
+ fig.add_trace(go.Surface(
+ x=x_vals, y=y_vals, z=grid,
+ colorscale=CS_SEQUENTIAL, opacity=0.98,
+ colorbar=dict(title=dict(text=metric, side="right"),
+ outlinewidth=0, thickness=13, len=0.6),
+ lighting=dict(ambient=0.75, diffuse=0.5, roughness=0.9, specular=0.1),
+ contours=dict(z=dict(show=True, usecolormap=True, project_z=True,
+ width=1)),
+ hovertemplate=(
+ f"{x_param} %{{x:.2f}}
{y_param} %{{y:.2f}}
"
+ f"{metric} %{{z:.3f}}"
+ ),
+ ))
+ best_label = None
if highlight_best:
- from scipy.ndimage import gaussian_filter
-
- sigma_y = max(1.0, grid.shape[0] * 0.05)
- sigma_x = max(1.0, grid.shape[1] * 0.05)
- smoothed = gaussian_filter(
- np.nan_to_num(grid, nan=np.nanmin(grid)),
- sigma=(sigma_y, sigma_x),
- )
- best_idx = np.unravel_index(np.argmax(smoothed), smoothed.shape)
+ best_idx = _plateau_best(grid)
best_val = grid[best_idx]
bx = x_vals[best_idx[1]]
by = y_vals[best_idx[0]]
- ax.scatter([bx], [by], [best_val], color="white", s=80, zorder=5,
- edgecolors="black", linewidths=1.5)
+ fig.add_trace(go.Scatter3d(
+ x=[bx], y=[by], z=[best_val], mode="markers",
+ marker=dict(color="white", size=6,
+ line=dict(color="black", width=2)),
+ name="best", showlegend=False,
+ hovertemplate=f"best {metric} %{{z:.3f}}",
+ ))
best_label = f"best: {best_val:.3f} ({x_param}={bx:.0f}, {y_param}={by:.0f})"
- # Force dark panes (matplotlib 3D ignores rc theme)
- pane_color = (0.1, 0.1, 0.1, 0.9)
- ax.xaxis.set_pane_color(pane_color)
- ax.yaxis.set_pane_color(pane_color)
- ax.zaxis.set_pane_color(pane_color)
- for axis in (ax.xaxis, ax.yaxis, ax.zaxis):
- axis.label.set_color("white")
- axis.set_tick_params(colors="white")
- ax.set_xlabel(x_param, fontsize=10, labelpad=10)
- ax.set_ylabel(y_param, fontsize=10, labelpad=10)
- ax.set_zlabel(metric, fontsize=10, labelpad=10)
- ax.view_init(elev=elev, azim=azim)
+ # Map matplotlib elev/azim to a plotly camera eye position
+ r = 1.9
+ elev_rad = np.deg2rad(elev)
+ azim_rad = np.deg2rad(azim)
+ eye = dict(
+ x=r * np.cos(elev_rad) * np.cos(azim_rad),
+ y=r * np.cos(elev_rad) * np.sin(azim_rad),
+ z=r * np.sin(elev_rad),
+ )
combos = len(x_vals) * len(y_vals)
- main_title = title or f"{metric} -- Surface ({combos:,} combos)"
- if highlight_best:
- ax.set_title(f"{main_title}\n{best_label}", fontsize=11)
- else:
- ax.set_title(main_title)
- fig.colorbar(surf, ax=ax, shrink=0.5, pad=0.1)
+ main_title = title or f"{metric} — Surface ({combos:,} combos)"
+ if best_label:
+ main_title = f"{main_title}
{best_label}"
+ fig.update_layout(
+ title_text=main_title,
+ scene=dict(
+ xaxis_title=x_param, yaxis_title=y_param, zaxis_title=metric,
+ aspectmode="manual",
+ aspectratio=dict(x=1.25, y=1.25, z=0.85),
+ camera=dict(eye=eye),
+ ),
+ margin=dict(l=0, r=0, t=60, b=0),
+ )
return finalize(fig, show=show, save=save)
@@ -240,14 +249,14 @@ def walk_forward(
*,
mode: str = "auto",
full_result=None,
- ax: Optional[Axes] = None,
+ ax=None,
is_color: str = ACCENT,
oos_color: str = ORANGE,
title: Optional[str] = None,
figsize: Tuple[float, float] = (10, 5),
show: bool = False,
save: Optional[Union[str, Path]] = None,
-) -> Figure:
+) -> go.Figure:
"""Walk-forward analysis chart.
Args:
@@ -265,129 +274,136 @@ def walk_forward(
mode = "equity" if has_equity else "bars"
if mode == "equity":
- return _walk_forward_equity(wf_result, folds, ax=ax, is_color=is_color,
- oos_color=oos_color, title=title, figsize=figsize,
- show=show, save=save)
+ return _walk_forward_equity(wf_result, folds, is_color=is_color,
+ oos_color=oos_color, title=title, figsize=figsize,
+ show=show, save=save)
elif mode == "stitched":
return _walk_forward_stitched(wf_result, folds, full_result=full_result,
- ax=ax, is_color=is_color,
- oos_color=oos_color, title=title, figsize=figsize,
- show=show, save=save)
+ is_color=is_color,
+ oos_color=oos_color, title=title, figsize=figsize,
+ show=show, save=save)
else:
- return _walk_forward_bars(wf_result, folds, ax=ax, is_color=is_color,
- oos_color=oos_color, title=title, figsize=figsize,
- show=show, save=save)
+ return _walk_forward_bars(wf_result, folds, is_color=is_color,
+ oos_color=oos_color, title=title, figsize=figsize,
+ show=show, save=save)
-def _walk_forward_equity(wf_result, folds, *, ax, is_color, oos_color, title, figsize, show, save):
+def _fold_metric(fold, key, optimize_metric):
+ val = fold.get(key)
+ if isinstance(val, dict):
+ return val.get(optimize_metric, val.get("sharpe", 0))
+ return val if val is not None else 0
+
+
+def _walk_forward_equity(wf_result, folds, *, is_color, oos_color, title, figsize, show, save):
"""Equity curve per fold: IS (blue) + OOS (orange) side by side."""
- from matplotlib.gridspec import GridSpec
-
optimize_metric = wf_result.get("optimize_metric", "sharpe")
n = len(folds)
with theme_context():
- fig = plt.figure(figsize=figsize)
- gs = GridSpec(1, n, figure=fig, wspace=0.08)
- fig.suptitle(title or f"Walk-Forward Analysis ({optimize_metric})", fontsize=10)
+ fig = make_subplots(
+ rows=1, cols=n, horizontal_spacing=0.02,
+ subplot_titles=[
+ f"Fold {f.get('fold_index', f.get('fold', i)) + 1}"
+ for i, f in enumerate(folds)
+ ],
+ )
for i, fold in enumerate(folds):
- ax_ = fig.add_subplot(gs[0, i])
+ col = i + 1
is_eq = fold.get("is_equity", [])
oos_eq = fold.get("oos_equity", [])
if is_eq:
- is_x = np.arange(len(is_eq))
- ax_.plot(is_x, is_eq, color=is_color, linewidth=1.2, alpha=0.8)
+ fig.add_trace(go.Scatter(
+ y=is_eq, mode="lines",
+ line=dict(color=is_color, width=1.2), opacity=0.8,
+ showlegend=False, hoverinfo="skip",
+ ), row=1, col=col)
if oos_eq:
- oos_x = np.arange(len(is_eq), len(is_eq) + len(oos_eq))
- ax_.plot(oos_x, oos_eq, color=oos_color, linewidth=1.2, alpha=0.8)
+ fig.add_trace(go.Scatter(
+ x=list(range(len(is_eq), len(is_eq) + len(oos_eq))), y=oos_eq,
+ mode="lines", line=dict(color=oos_color, width=1.2), opacity=0.8,
+ showlegend=False, hoverinfo="skip",
+ ), row=1, col=col)
if is_eq and oos_eq:
- ax_.axvline(x=len(is_eq), color=DARK_GRAY, linewidth=0.8, linestyle="--")
+ fig.add_vline(x=len(is_eq), line_color=DARK_GRAY, line_width=0.8,
+ line_dash="dash", row=1, col=col)
- # Extract metric values for labels
- def _get_metric(key):
- val = fold.get(key)
- if isinstance(val, dict):
- return val.get(optimize_metric, val.get("sharpe", 0))
- return val if val is not None else 0
-
- is_m = _get_metric("is_metrics") or _get_metric("is_metric")
- oos_m = _get_metric("oos_metrics") or _get_metric("oos_metric")
-
- ax_.text(0.05, 0.92, f"IS: {is_m:.2f}", transform=ax_.transAxes,
- fontsize=7, color=is_color, fontfamily="monospace")
- ax_.text(0.05, 0.82, f"OOS: {oos_m:.2f}", transform=ax_.transAxes,
- fontsize=7, color=oos_color, fontfamily="monospace")
-
- fold_idx = fold.get("fold_index", fold.get("fold", i))
- ax_.set_title(f"Fold {fold_idx + 1}", fontsize=8)
- ax_.tick_params(labelsize=6)
- ax_.grid(True, alpha=0.08)
+ is_m = (_fold_metric(fold, "is_metrics", optimize_metric)
+ or _fold_metric(fold, "is_metric", optimize_metric))
+ oos_m = (_fold_metric(fold, "oos_metrics", optimize_metric)
+ or _fold_metric(fold, "oos_metric", optimize_metric))
+ fig.add_annotation(
+ x=0.04, y=0.96, xref=f"x{col if col > 1 else ''} domain",
+ yref=f"y{col if col > 1 else ''} domain",
+ xanchor="left", yanchor="top", showarrow=False, align="left",
+ text=(f"IS: {is_m:.2f}
"
+ f"OOS: {oos_m:.2f}"),
+ font=dict(family=MONO_FAMILY, size=10),
+ )
if i > 0:
- ax_.set_yticklabels([])
+ fig.update_yaxes(showticklabels=False, row=1, col=col)
+ fig.update_layout(
+ title_text=title or f"Walk-Forward Analysis ({optimize_metric})",
+ width=int(figsize[0] * 80), height=int(figsize[1] * 80),
+ )
return finalize(fig, show=show, save=save)
-def _walk_forward_bars(wf_result, folds, *, ax, is_color, oos_color, title, figsize, show, save):
+def _walk_forward_bars(wf_result, folds, *, is_color, oos_color, title, figsize, show, save):
"""Grouped bar chart: IS vs OOS metric per fold."""
optimize_metric = wf_result.get("optimize_metric", "sharpe")
- n = len(folds)
- x = np.arange(n)
- width = 0.35
-
- def _extract(fold, key):
- val = fold.get(key)
- if isinstance(val, dict):
- return val.get(optimize_metric, val.get("sharpe", 0))
- return val if val is not None else 0
with theme_context():
- fig, ax_ = get_or_create_ax(ax, figsize)
+ fig = new_figure(figsize)
- is_vals = [_extract(f, "is_metrics") or _extract(f, "is_metric") for f in folds]
- oos_vals = [_extract(f, "oos_metrics") or _extract(f, "oos_metric") for f in folds]
+ is_vals = [(_fold_metric(f, "is_metrics", optimize_metric)
+ or _fold_metric(f, "is_metric", optimize_metric)) for f in folds]
+ oos_vals = [(_fold_metric(f, "oos_metrics", optimize_metric)
+ or _fold_metric(f, "oos_metric", optimize_metric)) for f in folds]
+ labels = [f"Fold {f.get('fold_index', f.get('fold', i)) + 1}"
+ for i, f in enumerate(folds)]
- ax_.bar(x - width / 2, is_vals, width, label="In-Sample", color=is_color, alpha=0.65)
- ax_.bar(x + width / 2, oos_vals, width, label="Out-of-Sample", color=oos_color, alpha=0.65)
-
- for i, (is_v, oos_v) in enumerate(zip(is_vals, oos_vals)):
- if is_v != 0:
- ax_.text(i - width / 2, is_v, f"{is_v:.2f}", ha="center",
- va="bottom" if is_v > 0 else "top", fontsize=7, color=is_color)
- if oos_v != 0:
- ax_.text(i + width / 2, oos_v, f"{oos_v:.2f}", ha="center",
- va="bottom" if oos_v > 0 else "top", fontsize=7, color=oos_color)
-
- ax_.set_xticks(x)
- ax_.set_xticklabels([f"Fold {f.get('fold_index', f.get('fold', i)) + 1}" for i, f in enumerate(folds)])
- ax_.axhline(0, color=DARK_GRAY, linewidth=0.5, linestyle="--")
- ax_.set_title(title or f"Walk-Forward Analysis ({optimize_metric})")
- ax_.set_ylabel(optimize_metric.capitalize())
- ax_.legend(loc="upper right")
+ fig.add_trace(go.Bar(
+ x=labels, y=is_vals, name="In-Sample",
+ marker_color=is_color, opacity=0.65, marker_line_width=0,
+ text=[f"{v:.2f}" if v != 0 else "" for v in is_vals],
+ textposition="outside", textfont=dict(size=10, color=is_color),
+ ))
+ fig.add_trace(go.Bar(
+ x=labels, y=oos_vals, name="Out-of-Sample",
+ marker_color=oos_color, opacity=0.65, marker_line_width=0,
+ text=[f"{v:.2f}" if v != 0 else "" for v in oos_vals],
+ textposition="outside", textfont=dict(size=10, color=oos_color),
+ ))
+ fig.add_hline(y=0, line_color=DARK_GRAY, line_width=0.5, line_dash="dash")
+ fig.update_layout(
+ title_text=title or f"Walk-Forward Analysis ({optimize_metric})",
+ barmode="group", hovermode="closest",
+ legend=dict(x=0.99, y=0.99, xanchor="right"),
+ )
+ fig.update_yaxes(title_text=optimize_metric.capitalize())
+ fig.update_xaxes(showspikes=False, type="category")
return finalize(fig, show=show, save=save)
-def _walk_forward_stitched(wf_result, folds, *, full_result=None, ax, is_color, oos_color, title, figsize, show, save):
+def _walk_forward_stitched(wf_result, folds, *, full_result=None, is_color, oos_color, title, figsize, show, save):
"""Stitched OOS equity vs full backtest.
- Orange: OOS segments from each fold, chained end-to-end.
This is the TRUE out-of-sample performance of the WFO strategy.
- Blue: full backtest with default params over the same period (no WFO).
- This is what you'd get without walk-forward optimization.
- If orange ~ blue → no overfitting, WFO adds little.
- If blue >> orange → full backtest is overfitted.
- If orange >> blue → WFO optimization adds real value.
-
- Args:
- full_result: BacktestResult from bt.run() on the full period.
+ If orange ~ blue: no overfitting, WFO adds little.
+ If blue >> orange: full backtest is overfitted.
+ If orange >> blue: WFO optimization adds real value.
"""
with theme_context():
- fig, ax_ = get_or_create_ax(ax, figsize)
+ fig = new_figure(figsize)
# 1. Stitch OOS segments: chain so each starts where previous ended
stitched = []
@@ -409,7 +425,7 @@ def _walk_forward_stitched(wf_result, folds, *, full_result=None, ax, is_color,
fold_boundaries.append(len(stitched))
if not stitched:
- ax_.set_title("No OOS equity data available")
+ fig.update_layout(title_text="No OOS equity data available")
return finalize(fig, show=show, save=save)
stitched = np.array(stitched)
@@ -417,36 +433,36 @@ def _walk_forward_stitched(wf_result, folds, *, full_result=None, ax, is_color,
# 2. Full backtest equity (if provided)
if full_result is not None:
- full_eq_raw = full_result.equity_curve
- full_eq = np.array(full_eq_raw)
+ full_eq = np.array(full_result.equity_curve)
if len(full_eq) > 0:
- # Resample to match stitched length
indices = np.linspace(0, len(full_eq) - 1, len(stitched), dtype=int)
full_resampled = full_eq[indices].astype(float)
- # Normalize to start at same value as stitched
if full_resampled[0] != 0:
full_resampled = full_resampled * (stitched[0] / full_resampled[0])
- ax_.plot(x, full_resampled, color=is_color, linewidth=0.8, alpha=0.4,
- label="Full backtest (default params)")
-
- full_ret = (full_resampled[-1] / full_resampled[0] - 1) * 100
+ fig.add_trace(go.Scatter(
+ x=x, y=full_resampled, mode="lines",
+ name="Full backtest (default params)",
+ line=dict(color=is_color, width=0.8), opacity=0.4,
+ ))
# 3. Plot stitched OOS on top
- ax_.plot(x, stitched, color=oos_color, linewidth=0.9, alpha=0.85,
- label="Walk-forward (stitched OOS)", zorder=3)
+ fig.add_trace(go.Scatter(
+ x=x, y=stitched, mode="lines",
+ name="Walk-forward (stitched OOS)",
+ line=dict(color=oos_color, width=1.0), opacity=0.85,
+ ))
# Fold boundaries
for b in fold_boundaries[:-1]:
- ax_.axvline(x=b, color=DARK_GRAY, linewidth=0.5,
- linestyle="--", alpha=0.3)
+ fig.add_vline(x=b, line_color=DARK_GRAY, line_width=0.5,
+ line_dash="dash", opacity=0.3)
- # No floating text - returns are visible from the curves
-
- ax_.set_title(title or "Walk-Forward: Stitched OOS vs Full Backtest")
- ax_.set_xlabel("Bars")
- ax_.set_ylabel("Equity")
- ax_.legend(loc="upper left", fontsize=8)
- ax_.grid(True, alpha=0.08)
+ fig.update_layout(
+ title_text=title or "Walk-Forward: Stitched OOS vs Full Backtest",
+ legend=dict(x=0.01, y=0.99),
+ )
+ fig.update_xaxes(title_text="Bars")
+ fig.update_yaxes(title_text="Equity")
return finalize(fig, show=show, save=save)
@@ -456,7 +472,7 @@ def _walk_forward_stitched(wf_result, folds, *, full_result=None, ax, is_color,
def stability(
stability_result: Dict[str, Any],
*,
- ax: Optional[Axes] = None,
+ ax=None,
line_color: str = ACCENT,
band_color: str = ACCENT,
band_alpha: float = 0.15,
@@ -464,14 +480,14 @@ def stability(
figsize: Tuple[float, float] = (10, 5),
show: bool = False,
save: Optional[Union[str, Path]] = None,
-) -> Figure:
+) -> go.Figure:
"""Parameter stability chart with mean +/- std shaded bands.
Expected keys: values, metric_values, mean_metric, std_metric,
param_name, metric, stability_score.
"""
with theme_context():
- fig, ax_ = get_or_create_ax(ax, figsize)
+ fig = new_figure(figsize)
param_vals = np.array(stability_result["values"], dtype=np.float64)
metric_vals = np.array(stability_result["metric_values"], dtype=np.float64)
@@ -481,20 +497,38 @@ def stability(
metric_name = stability_result.get("metric", "metric")
score = stability_result.get("stability_score", None)
- ax_.plot(param_vals, metric_vals, color=line_color, linewidth=1.8, marker="o", markersize=4)
- ax_.axhline(mean, color=band_color, linewidth=1.0, linestyle="--", label=f"Mean: {mean:.3f}")
- ax_.fill_between(
- param_vals, mean - std, mean + std,
- color=band_color, alpha=band_alpha, label=f"\u00b11\u03c3: {std:.3f}",
- )
+ # ±1σ band
+ fig.add_trace(go.Scatter(
+ x=param_vals, y=np.full(len(param_vals), mean - std), mode="lines",
+ line=dict(width=0), hoverinfo="skip", showlegend=False,
+ ))
+ fig.add_trace(go.Scatter(
+ x=param_vals, y=np.full(len(param_vals), mean + std), mode="lines",
+ line=dict(width=0), fill="tonexty",
+ fillcolor=_rgba(band_color, band_alpha),
+ name=f"±1σ: {std:.3f}", hoverinfo="skip",
+ ))
+ fig.add_hline(y=mean, line_color=band_color, line_width=1.0,
+ line_dash="dash")
+ fig.add_trace(go.Scatter(
+ x=[None], y=[None], mode="lines", name=f"Mean: {mean:.3f}",
+ line=dict(color=band_color, width=1.0, dash="dash"),
+ ))
+ fig.add_trace(go.Scatter(
+ x=param_vals, y=metric_vals, mode="lines+markers",
+ line=dict(color=line_color, width=1.8),
+ marker=dict(size=6, color=line_color),
+ name=metric_name, showlegend=False,
+ hovertemplate=f"{param_name} %{{x}}: %{{y:.3f}}",
+ ))
- ax_.set_xlabel(param_name)
- ax_.set_ylabel(metric_name)
t = title or f"{metric_name} Stability"
if score is not None:
t += f" (score: {score:.2f})"
- ax_.set_title(t)
- ax_.legend(loc="upper right")
+ fig.update_layout(title_text=t,
+ legend=dict(x=0.99, y=0.99, xanchor="right"))
+ fig.update_xaxes(title_text=param_name)
+ fig.update_yaxes(title_text=metric_name)
return finalize(fig, show=show, save=save)
@@ -505,40 +539,72 @@ def correlation_matrix(
symbols: List[str],
matrix: List[List[float]],
*,
- ax: Optional[Axes] = None,
+ ax=None,
annotate: bool = True,
title: str = "Correlation Matrix",
figsize: Tuple[float, float] = (8, 7),
show: bool = False,
save: Optional[Union[str, Path]] = None,
-) -> Figure:
+) -> go.Figure:
"""Symbol correlation matrix heatmap."""
with theme_context():
- fig, ax_ = get_or_create_ax(ax, figsize)
-
mat = np.array(matrix, dtype=np.float64)
+
+ fig = new_figure(figsize, title)
+ fig.add_trace(go.Heatmap(
+ z=mat, x=symbols, y=symbols,
+ colorscale=CS_CORRELATION, zmin=-1, zmax=1,
+ text=np.round(mat, 2) if annotate else None,
+ texttemplate="%{text:.2f}" if annotate else None,
+ textfont=dict(size=10),
+ hovertemplate="%{y} / %{x}: %{z:.2f}",
+ colorbar=dict(outlinewidth=0, thickness=12),
+ ))
+ fig.update_yaxes(autorange="reversed", showspikes=False,
+ scaleanchor="x", scaleratio=1, constrain="domain")
+ fig.update_xaxes(tickangle=45, showspikes=False, constrain="domain")
+ fig.update_layout(hovermode="closest")
n = len(symbols)
- cmap = plt.get_cmap("bt_correlation")
- im = ax_.imshow(mat, cmap=cmap, vmin=-1, vmax=1, aspect="equal", interpolation="nearest")
+ return finalize(fig, show=show, save=save,
+ window_size=_grid_window_size(n, n))
- ax_.set_xticks(range(n))
- ax_.set_xticklabels(symbols, rotation=45, ha="right")
- ax_.set_yticks(range(n))
- ax_.set_yticklabels(symbols)
- if annotate:
- for yi in range(n):
- for xi in range(n):
- val = mat[yi, xi]
- txt_color = DARK_GRAY if yi == xi else ("white" if abs(val) > 0.5 else DARK_GRAY)
- ax_.text(
- xi, yi, f"{val:.2f}",
- ha="center", va="center", fontsize=9, color=txt_color,
- )
+# ── Fan chart internals (Monte Carlo + stochastic) ──────────────────────────
- ax_.set_title(title)
- fig.colorbar(im, ax=ax_, shrink=0.7)
- return finalize(fig, show=show, save=save)
+
+def _batched_paths_trace(x, paths: np.ndarray, n_sample_paths: int, color: str):
+ """All faded sample paths as ONE trace (None-separated) for performance."""
+ k = min(n_sample_paths, paths.shape[0])
+ if k <= 0:
+ return None
+ n = paths.shape[1]
+ xs = np.empty((k, n + 1), dtype=np.float64)
+ ys = np.empty((k, n + 1), dtype=np.float64)
+ xs[:, :n] = np.asarray(x, dtype=np.float64)
+ ys[:, :n] = paths[:k]
+ xs[:, n] = np.nan
+ ys[:, n] = np.nan
+ return go.Scatter(
+ x=xs.ravel(), y=ys.ravel(), mode="lines",
+ line=dict(color=color, width=0.3), opacity=0.06,
+ hoverinfo="skip", showlegend=False, connectgaps=False,
+ )
+
+
+def _fan_bands(fig, x, pct_lines, percentiles, band_color):
+ """Fill between symmetric percentile bands."""
+ for lo, hi in [(0, -1), (1, -2)]:
+ if lo < len(percentiles) and abs(hi) <= len(percentiles):
+ fig.add_trace(go.Scatter(
+ x=x, y=pct_lines[percentiles[lo]], mode="lines",
+ line=dict(width=0), hoverinfo="skip", showlegend=False,
+ ))
+ fig.add_trace(go.Scatter(
+ x=x, y=pct_lines[percentiles[hi]], mode="lines",
+ line=dict(width=0), fill="tonexty",
+ fillcolor=_rgba(band_color, 0.08),
+ hoverinfo="skip", showlegend=False,
+ ))
# ── Monte Carlo Fan ──────────────────────────────────────────────────────────
@@ -551,7 +617,7 @@ def monte_carlo(
method: str = "bootstrap",
percentiles: Optional[List[int]] = None,
n_sample_paths: int = 50,
- ax: Optional[Axes] = None,
+ ax=None,
median_color: str = ACCENT,
band_color: str = ACCENT,
title: Optional[str] = None,
@@ -559,7 +625,7 @@ def monte_carlo(
seed: Optional[int] = None,
show: bool = False,
save: Optional[Union[str, Path]] = None,
-) -> Figure:
+) -> go.Figure:
"""Monte Carlo fan chart with percentile bands, sample paths, and risk stats.
Args:
@@ -591,13 +657,13 @@ def monte_carlo(
title = f"Monte Carlo - {n_simulations:,} paths ({method_label})"
with theme_context():
- fig, ax_ = get_or_create_ax(ax, figsize)
+ fig = new_figure(figsize, title)
rets = daily_returns_array(result)
_, orig_equity = equity_with_dates(result)
if len(rets) < 2:
- ax_.set_title(title + " (insufficient data)")
+ fig.update_layout(title_text=title + " (insufficient data)")
return finalize(fig, show=show, save=save)
rng = np.random.default_rng(seed)
@@ -614,108 +680,86 @@ def monte_carlo(
sampled = rng.choice(rets, size=n_days, replace=True)
paths[i, 1:] = initial * np.cumprod(1.0 + sampled)
- # Compute percentile bands
x = np.arange(n_days + 1)
pct_lines = {pct: np.percentile(paths, pct, axis=0) for pct in percentiles}
- # Draw sample paths (faded)
- if n_sample_paths > 0:
- for i in range(min(n_sample_paths, n_simulations)):
- ax_.plot(x, paths[i], color=band_color, linewidth=0.3, alpha=0.06)
+ sample_trace = _batched_paths_trace(x, paths, n_sample_paths, band_color)
+ if sample_trace is not None:
+ fig.add_trace(sample_trace)
+ _fan_bands(fig, x, pct_lines, percentiles, band_color)
- # Fill between symmetric bands
- for lo, hi in [(0, -1), (1, -2)]:
- ax_.fill_between(
- x, pct_lines[percentiles[lo]], pct_lines[percentiles[hi]],
- color=band_color, alpha=0.08,
- )
-
- # Original equity (dashed) — resample to match MC daily resolution
+ # Original equity (dashed), resampled to MC daily resolution
if len(orig_equity) > n_days * 2:
indices = np.linspace(0, len(orig_equity) - 1, n_days + 1, dtype=int)
orig_resampled = np.array(orig_equity)[indices]
else:
orig_resampled = np.array(orig_equity[:n_days + 1])
- orig_x = np.arange(len(orig_resampled))
- ax_.plot(orig_x, orig_resampled, color="#e8e9ed", linewidth=0.8,
- alpha=0.4, linestyle="--", label="Original")
+ fig.add_trace(go.Scatter(
+ x=np.arange(len(orig_resampled)), y=orig_resampled, mode="lines",
+ name="Original", line=dict(color="#e8e9ed", width=0.8, dash="dash"),
+ opacity=0.4,
+ ))
+
+ running_peak = np.maximum.accumulate(paths, axis=1)
+ drawdowns = (paths - running_peak) / running_peak
+ max_dd_per_path = drawdowns.min(axis=1) * 100
if method == "bootstrap":
- # Bootstrap: percentile lines with final return %
for pct in percentiles:
ret_pct = (pct_lines[pct][-1] / initial - 1) * 100
if pct == 50:
- ax_.plot(x, pct_lines[pct], color=median_color, linewidth=2,
- label=f"P{pct} (median): {ret_pct:+.1f}%", zorder=3)
+ fig.add_trace(go.Scatter(
+ x=x, y=pct_lines[pct], mode="lines",
+ name=f"P{pct} (median): {ret_pct:+.1f}%",
+ line=dict(color=median_color, width=2),
+ ))
else:
- ax_.plot(x, pct_lines[pct], color=band_color, linewidth=0.5,
- alpha=0.4, label=f"P{pct}: {ret_pct:+.1f}%")
-
- # Drawdown stats
- running_peak = np.maximum.accumulate(paths, axis=1)
- drawdowns = (paths - running_peak) / running_peak
- max_dd_per_path = drawdowns.min(axis=1) * 100
+ fig.add_trace(go.Scatter(
+ x=x, y=pct_lines[pct], mode="lines",
+ name=f"P{pct}: {ret_pct:+.1f}%",
+ line=dict(color=band_color, width=0.5), opacity=0.4,
+ ))
dd_p5 = np.percentile(max_dd_per_path, 5)
dd_p50 = np.percentile(max_dd_per_path, 50)
-
- # P(ruin)
p_ruin = np.mean((paths[:, -1] / initial - 1) < -0.5) * 100
-
- stats_text = f"P(ruin) = {p_ruin:.2f}%\nMax DD (P5): {dd_p5:.1f}%\nMax DD (median): {dd_p50:.1f}%"
- ax_.text(
- 0.98, 0.95, stats_text,
- transform=ax_.transAxes, ha="right", va="top",
- color="#8a8a8a", fontsize=8, fontfamily="monospace",
- bbox={"boxstyle": "round,pad=0.4", "facecolor": "#111116",
- "edgecolor": "#1e1e24", "alpha": 0.9},
- )
-
+ _stats_annotation(fig, (
+ f"P(ruin) = {p_ruin:.2f}%\n"
+ f"Max DD (P5): {dd_p5:.1f}%\n"
+ f"Max DD (median): {dd_p50:.1f}%"
+ ))
else:
- # Permutation: all paths end at the same point.
- # Skill vs luck analysis: compare original drawdown to permuted distribution.
- ax_.plot(x, pct_lines[50], color=median_color, linewidth=2,
- label="Median path", zorder=3)
+ # Permutation: skill vs luck via drawdown rank
+ fig.add_trace(go.Scatter(
+ x=x, y=pct_lines[50], mode="lines", name="Median path",
+ line=dict(color=median_color, width=2),
+ ))
for pct in percentiles:
if pct != 50:
- ax_.plot(x, pct_lines[pct], color=band_color, linewidth=0.5, alpha=0.4)
+ fig.add_trace(go.Scatter(
+ x=x, y=pct_lines[pct], mode="lines", showlegend=False,
+ line=dict(color=band_color, width=0.5), opacity=0.4,
+ ))
- # Max drawdown per path
- running_peak = np.maximum.accumulate(paths, axis=1)
- drawdowns = (paths - running_peak) / running_peak
- max_dd_per_path = drawdowns.min(axis=1) * 100
-
- # Original strategy drawdown
orig_eq = np.array(orig_resampled)
orig_peak = np.maximum.accumulate(orig_eq)
orig_max_dd = ((orig_eq - orig_peak) / orig_peak).min() * 100
- dd_p50 = np.percentile(max_dd_per_path, 50)
-
dd_p5 = np.percentile(max_dd_per_path, 5)
+ dd_p50 = np.percentile(max_dd_per_path, 50)
dd_p95 = np.percentile(max_dd_per_path, 95)
dd_rank = np.mean(max_dd_per_path <= orig_max_dd) * 100
-
- stats_text = (
+ _stats_annotation(fig, (
f"Realized max DD: {orig_max_dd:.1f}%\n"
f"Permuted DD P5: {dd_p5:.1f}%\n"
f"Permuted DD P50: {dd_p50:.1f}%\n"
f"Permuted DD P95: {dd_p95:.1f}%\n"
f"DD rank: {dd_rank:.0f}th percentile"
- )
- ax_.text(
- 0.98, 0.95, stats_text,
- transform=ax_.transAxes, ha="right", va="top",
- color="#8a8a8a", fontsize=8, fontfamily="monospace",
- bbox={"boxstyle": "round,pad=0.4", "facecolor": "#111116",
- "edgecolor": "#1e1e24", "alpha": 0.9},
- )
+ ))
- ax_.margins(x=0.02)
- ax_.set_title(title)
- ax_.set_xlabel("Days")
- ax_.set_ylabel("Equity")
- ax_.legend(loc="upper left", fontsize=7, framealpha=0.3)
+ fig.update_xaxes(title_text="Days")
+ fig.update_yaxes(title_text="Equity")
+ fig.update_layout(legend=dict(x=0.01, y=0.99, font=dict(size=10)))
return finalize(fig, show=show, save=save)
@@ -727,14 +771,14 @@ def stochastic_paths(
*,
percentiles: Optional[List[int]] = None,
n_sample_paths: int = 50,
- ax: Optional[Axes] = None,
+ ax=None,
median_color: str = ACCENT,
band_color: str = ACCENT,
title: Optional[str] = None,
figsize: Tuple[float, float] = (12, 5),
show: bool = False,
save: Optional[Union[str, Path]] = None,
-) -> Figure:
+) -> go.Figure:
"""Fan chart for stochastic simulation paths with percentile bands.
Args:
@@ -756,7 +800,7 @@ def stochastic_paths(
"result has no paths data. Run with store_paths=True."
)
- # Reshape flat Arrow/numpy array → (n_paths, n_steps+1)
+ # Reshape flat Arrow/numpy array -> (n_paths, n_steps+1)
flat = np.asarray(paths_raw, dtype=np.float64)
paths = flat.reshape((n_paths, n_steps))
@@ -764,44 +808,32 @@ def stochastic_paths(
title = f"Stochastic simulation - {model_name} ({n_paths:,} paths)"
with theme_context():
- fig, ax_ = get_or_create_ax(ax, figsize)
+ fig = new_figure(figsize, title)
x = np.arange(paths.shape[1])
-
- # Draw sample paths (faded)
- if n_sample_paths > 0:
- for i in range(min(n_sample_paths, n_paths)):
- ax_.plot(x, paths[i], color=band_color, linewidth=0.3, alpha=0.06)
-
- # Compute percentile bands
pct_lines = {pct: np.percentile(paths, pct, axis=0) for pct in percentiles}
- # Fill between symmetric bands
- for lo, hi in [(0, -1), (1, -2)]:
- if lo < len(percentiles) and hi < 0 and abs(hi) <= len(percentiles):
- ax_.fill_between(
- x,
- pct_lines[percentiles[lo]],
- pct_lines[percentiles[hi]],
- color=band_color,
- alpha=0.08,
- )
+ sample_trace = _batched_paths_trace(x, paths, n_sample_paths, band_color)
+ if sample_trace is not None:
+ fig.add_trace(sample_trace)
+ _fan_bands(fig, x, pct_lines, percentiles, band_color)
- # Percentile lines
s0 = paths[0, 0] if paths.shape[1] > 0 else 100.0
for pct in percentiles:
final = pct_lines[pct][-1]
ret_pct = (final / s0 - 1) * 100
if pct == 50:
- ax_.plot(
- x, pct_lines[pct], color=median_color, linewidth=2,
- label=f"P{pct} (median): {ret_pct:+.1f}%", zorder=3,
- )
+ fig.add_trace(go.Scatter(
+ x=x, y=pct_lines[pct], mode="lines",
+ name=f"P{pct} (median): {ret_pct:+.1f}%",
+ line=dict(color=median_color, width=2),
+ ))
else:
- ax_.plot(
- x, pct_lines[pct], color=band_color, linewidth=0.5,
- alpha=0.4, label=f"P{pct}: {ret_pct:+.1f}%",
- )
+ fig.add_trace(go.Scatter(
+ x=x, y=pct_lines[pct], mode="lines",
+ name=f"P{pct}: {ret_pct:+.1f}%",
+ line=dict(color=band_color, width=0.5), opacity=0.4,
+ ))
# Stats box
final_prices = paths[:, -1]
@@ -812,27 +844,13 @@ def stochastic_paths(
dd_p5 = np.percentile(max_dd_per_path, 5)
dd_p50 = np.percentile(max_dd_per_path, 50)
mean_ret = (np.mean(final_prices) / s0 - 1) * 100
-
- stats_text = (
+ _stats_annotation(fig, (
f"Mean return: {mean_ret:+.1f}%\n"
f"Max DD (P5): {dd_p5:.1f}%\n"
f"Max DD (P50): {dd_p50:.1f}%"
- )
- ax_.text(
- 0.98, 0.95, stats_text,
- transform=ax_.transAxes, ha="right", va="top",
- color="#8a8a8a", fontsize=8, fontfamily="monospace",
- bbox={
- "boxstyle": "round,pad=0.4",
- "facecolor": "#111116",
- "edgecolor": "#1e1e24",
- "alpha": 0.9,
- },
- )
+ ))
- ax_.margins(x=0.02)
- ax_.set_title(title)
- ax_.set_xlabel("Time steps")
- ax_.set_ylabel("Price")
- ax_.legend(loc="upper left", fontsize=7, framealpha=0.3)
+ fig.update_xaxes(title_text="Time steps")
+ fig.update_yaxes(title_text="Price")
+ fig.update_layout(legend=dict(x=0.01, y=0.99, font=dict(size=10)))
return finalize(fig, show=show, save=save)
diff --git a/python/manifoldbt/plot/tearsheet.py b/python/manifoldbt/plot/tearsheet.py
index d79e69b..9dc4799 100644
--- a/python/manifoldbt/plot/tearsheet.py
+++ b/python/manifoldbt/plot/tearsheet.py
@@ -1,35 +1,25 @@
-"""Composite tearsheet — HTML strategy report."""
+"""Composite tearsheet — HTML strategy report with interactive plotly charts."""
from __future__ import annotations
-import base64
-import io
import tempfile
import webbrowser
from html import escape
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
-import numpy as np
-import matplotlib.pyplot as plt
-import matplotlib.dates as mdates
-from matplotlib.figure import Figure
-
from manifoldbt.plot._theme import (
BG_AXES,
BG_FIGURE,
DARK_GRAY,
GRAY,
- GREEN,
- RED,
WHITE,
theme_context,
)
-from manifoldbt.plot._convert import equity_with_dates, positions_arrays
-from manifoldbt.plot._utils import auto_title, format_pct
+from manifoldbt.plot._convert import equity_with_dates
+from manifoldbt.plot._utils import auto_title, chart_div, format_pct
from manifoldbt.plot.backtest import (
annual_returns,
drawdown,
- equity,
monthly_returns,
returns_histogram,
rolling_sharpe,
@@ -38,44 +28,6 @@ from manifoldbt.plot.backtest import (
var_chart,
)
-
-def _fig_to_base64(fig: Figure, dpi: int = 150) -> str:
- """Render a matplotlib figure to a base64-encoded PNG string."""
- buf = io.BytesIO()
- fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight",
- facecolor=fig.get_facecolor(), edgecolor="none")
- plt.close(fig)
- buf.seek(0)
- return base64.b64encode(buf.read()).decode("ascii")
-
-
-def _render_chart(chart_fn, result, figsize=(12, 4), dpi=150, **kwargs) -> str:
- """Call a chart function on a fresh figure/axes and return base64 PNG."""
- with theme_context():
- fig, ax = plt.subplots(figsize=figsize)
- chart_fn(result, ax=ax, **kwargs)
- fig.tight_layout()
- return _fig_to_base64(fig, dpi=dpi)
-
-
-def _render_summary_b64(result, figsize=(12, 6), dpi=150) -> str:
- """Render the summary chart (equity+benchmark+trades+margin) to base64."""
- with theme_context():
- fig = summary(result, figsize=figsize)
- return _fig_to_base64(fig, dpi=dpi)
-
-
-def _render_exposure_b64(result, figsize=(12, 4), dpi=150) -> str:
- """Render the exposure chart to base64 PNG."""
- with theme_context():
- fig, ax = plt.subplots(figsize=figsize)
- _render_exposure(ax, result)
- _set_title(ax, "Capital Exposure")
- _format_dates(ax)
- fig.tight_layout()
- return _fig_to_base64(fig, dpi=dpi)
-
-
_CSS = f"""
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
@@ -127,12 +79,6 @@ body {{
flex-direction: column;
gap: 12px;
}}
-.charts-stack img {{
- width: 100%;
- display: block;
- border-radius: 4px;
- border: 1px solid #1e1e24;
-}}
.section-label {{
font-size: 10px;
font-weight: 700;
@@ -167,14 +113,11 @@ body {{
font-weight: 500;
white-space: nowrap;
}}
-.chart-row {{
- margin-bottom: 12px;
-}}
-.chart-row img {{
- width: 100%;
- display: block;
- border-radius: 4px;
+.chart-cell {{
+ background: {BG_FIGURE};
border: 1px solid #1e1e24;
+ border-radius: 4px;
+ overflow: hidden;
}}
.chart-grid {{
display: grid;
@@ -182,27 +125,15 @@ body {{
gap: 12px;
margin-bottom: 12px;
}}
-.chart-grid img {{
- width: 100%;
- display: block;
- border-radius: 4px;
- border: 1px solid #1e1e24;
-}}
-.chart-grid-3 {{
- display: grid;
- grid-template-columns: 1fr 1fr 1fr;
- gap: 12px;
- margin-bottom: 12px;
-}}
-.chart-grid-3 img {{
- width: 100%;
- display: block;
- border-radius: 4px;
- border: 1px solid #1e1e24;
-}}
+.plotly-graph-div {{ width: 100% !important; }}
"""
+def _div(fig, height: int) -> str:
+ """Wrap a plotly figure div in a bordered cell."""
+ return f'{chart_div(fig, height=height)}
'
+
+
def tearsheet(
result,
*,
@@ -211,37 +142,40 @@ def tearsheet(
show: bool = False,
save: Optional[Union[str, Path]] = None,
dpi: int = 150,
+ plotlyjs: str = "cdn",
) -> str:
- """Strategy report — self-contained HTML page.
+ """Strategy report — self-contained HTML page with interactive charts.
Returns the HTML string. Opens in browser when ``show=True``,
writes to disk when ``save`` is given.
+
+ Args:
+ plotlyjs: ``"cdn"`` (small file, needs network on open) or
+ ``"inline"`` (fully offline report, ~4.4 MB heavier).
"""
_ = benchmark # reserved for future benchmark overlay support
+ _ = dpi # kept for backward compatibility (was the PNG export dpi)
strategy_name = title or auto_title(result, "Backtest")
metrics = result.metrics if hasattr(result, "metrics") else {}
ts = metrics.get("trade_stats", {})
- dates, _ = equity_with_dates(result)
+ dates, _vals = equity_with_dates(result)
date_start = str(dates[0])[:10] if len(dates) > 0 else "?"
date_end = str(dates[-1])[:10] if len(dates) > 0 else "?"
- # ── Generate charts as base64 PNGs ────────────────────────────
- # Right column: summary chart (equity + benchmark + trades + margin)
- chart_summary = _render_summary_b64(result, figsize=(12, 6), dpi=dpi)
- chart_dd = _render_chart(drawdown, result, figsize=(12, 2.5), dpi=dpi)
- # Left column chart
- chart_annual = _render_chart(annual_returns, result, figsize=(5, 4), dpi=dpi)
- # Full width grids (2 per row)
- chart_monthly = _render_chart(monthly_returns, result, figsize=(8, 4), dpi=dpi)
- chart_hist = _render_chart(returns_histogram, result, figsize=(8, 4), dpi=dpi)
- chart_sharpe = _render_chart(rolling_sharpe, result, figsize=(8, 3.5), dpi=dpi)
- chart_vol = _render_chart(rolling_volatility, result, figsize=(8, 3.5), dpi=dpi)
- chart_var = _render_chart(var_chart, result, figsize=(8, 4), dpi=dpi)
+ # ── Generate interactive chart divs ────────────────────────────
+ with theme_context():
+ div_summary = _div(summary(result), height=520)
+ div_dd = _div(drawdown(result), height=210)
+ div_annual = _div(annual_returns(result), height=330)
+ div_monthly = _div(monthly_returns(result), height=340)
+ div_hist = _div(returns_histogram(result), height=340)
+ div_sharpe = _div(rolling_sharpe(result), height=300)
+ div_vol = _div(rolling_volatility(result), height=300)
+ div_var = _div(var_chart(result), height=340)
# ── Metrics ───────────────────────────────────────────────────
ret = metrics.get("total_return", 0)
- _ = ret # used below in metrics_html
def _m(label, value, cls=""):
esc_v = escape(str(value))
@@ -278,6 +212,13 @@ def tearsheet(
+ _m("Fees", f"{ts.get('total_fees', 0):.2f}")
)
+ # ── plotly.js include ─────────────────────────────────────────
+ if plotlyjs == "inline":
+ import plotly.io as pio
+ plotly_js_tag = f""
+ else:
+ plotly_js_tag = ''
+
# ── Assemble HTML ─────────────────────────────────────────────
html = f"""
@@ -286,6 +227,7 @@ def tearsheet(
{escape(strategy_name)} — Tearsheet
+{plotly_js_tag}
@@ -298,26 +240,26 @@ def tearsheet(
{metrics_html}
-

+ {div_annual}
-

-

+ {div_summary}
+ {div_dd}
-

-

+ {div_monthly}
+ {div_hist}
-

-

+ {div_sharpe}
+ {div_vol}
-

+ {div_var}
@@ -329,7 +271,6 @@ def tearsheet(
Path(save).write_text(html, encoding="utf-8")
if show:
- # Write the report HTML
if save is not None:
report_path = Path(save).resolve()
else:
@@ -339,22 +280,7 @@ def tearsheet(
tmp.write(html)
tmp.close()
report_path = Path(tmp.name).resolve()
-
- # Create a launcher HTML that opens the report in a 1600x850 window
- report_uri = report_path.as_uri()
- launcher_html = f""""""
-
- launcher = tempfile.NamedTemporaryFile(
- suffix=".html", delete=False, mode="w", encoding="utf-8"
- )
- launcher.write(launcher_html)
- launcher.close()
- webbrowser.open(Path(launcher.name).resolve().as_uri())
+ webbrowser.open(report_path.as_uri())
return html
@@ -369,40 +295,41 @@ def research_report(
show: bool = False,
save: Optional[Union[str, Path]] = None,
dpi: int = 150,
-) -> List[Figure]:
- """Research report — one figure per analysis."""
+) -> List[Any]:
+ """Research report — one figure per analysis (plotly Figures)."""
from manifoldbt.plot.research import (
heatmap_2d,
stability,
walk_forward,
)
+ _ = title
figs = []
with theme_context():
if sweep_result is not None:
- fig, ax = plt.subplots(figsize=figsize)
- heatmap_2d(sweep_result, ax=ax)
- figs.append(fig)
+ figs.append(heatmap_2d(sweep_result, figsize=figsize))
if wf_result is not None:
- fig, ax = plt.subplots(figsize=figsize)
- walk_forward(wf_result, ax=ax)
- figs.append(fig)
+ figs.append(walk_forward(wf_result, figsize=figsize))
if stability_result is not None:
- fig, ax = plt.subplots(figsize=figsize)
- stability(stability_result, ax=ax)
- figs.append(fig)
+ figs.append(stability(stability_result, figsize=figsize))
if not figs:
raise ValueError("At least one result (sweep, wf, or stability) required.")
if save is not None:
path = Path(save)
- stem, suffix = path.stem, path.suffix or ".png"
+ stem, suffix = path.stem, path.suffix or ".html"
for i, f in enumerate(figs):
out = path.parent / f"{stem}_{i + 1}{suffix}"
- f.savefig(str(out), dpi=dpi, bbox_inches="tight")
+ if suffix.lower() == ".html":
+ from manifoldbt.plot._utils import write_responsive_html
+ write_responsive_html(f, out)
+ else:
+ scale = max(1.0, dpi / 96.0)
+ f.write_image(str(out), scale=scale)
if show:
- plt.show()
+ for f in figs:
+ f.show()
return figs
@@ -410,113 +337,6 @@ def research_report(
# ── Internal ─────────────────────────────────────────────────────────────────
-def _set_title(ax, text):
- """Set title left-aligned, clearing any existing title from sub-functions."""
- ax.set_title("", loc="center") # clear default
- ax.set_title(text, fontsize=9, loc="left", color=GRAY)
-
-
-def _format_dates(ax):
- try:
- ax.xaxis.set_major_formatter(mdates.DateFormatter("%b %Y"))
- ax.xaxis.set_major_locator(mdates.AutoDateLocator())
- for lbl in ax.get_xticklabels():
- lbl.set_rotation(0)
- lbl.set_ha("center")
- except Exception:
- pass
-
-
-def _fix_rolling_xaxis(ax, result):
- try:
- dates, _ = equity_with_dates(result)
- from manifoldbt.plot._convert import daily_returns_array
- rets = daily_returns_array(result)
- n_rets = len(rets)
- aligned = dates[len(dates) - n_rets:] if len(dates) > n_rets else dates
-
- for line in ax.get_lines():
- xdata = line.get_xdata()
- n = len(xdata)
- if n <= 1:
- continue
- if isinstance(xdata[0], (int, float, np.integer, np.floating)):
- x0, x1 = float(xdata[0]), float(xdata[1])
- if abs(x1 - x0 - 1.0) < 0.01 and n <= len(aligned):
- line.set_xdata(aligned[:n])
-
- _format_dates(ax)
- ax.relim()
- ax.autoscale_view()
- except Exception:
- pass
-
-
-def _render_metrics_table(ax, metrics, ts):
- """Render metrics as single-column dotted-leader list with sections."""
- ax.set_xticks([])
- ax.set_yticks([])
- ax.set_facecolor(BG_AXES)
- for spine in ax.spines.values():
- spine.set_visible(True)
- spine.set_color(DARK_GRAY)
- spine.set_linewidth(0.5)
-
- ret = metrics.get("total_return", 0)
- ret_color = GREEN if ret > 0 else RED if ret < 0 else GRAY
- W = 30 # total width for dotted leader alignment
-
- def _line(label, value):
- dots = "·" * max(1, W - len(label) - len(str(value)))
- return f"{label} {dots} {value}"
-
- # Build sections
- sections = [
- ("RETURNS", GRAY, [
- (_line("Total Return", format_pct(ret)), ret_color),
- (_line("CAGR", format_pct(metrics.get("cagr", 0))), GRAY),
- (_line("Max Drawdown", format_pct(metrics.get("max_drawdown", 0))), RED),
- (_line("Volatility", format_pct(metrics.get("volatility", 0))), GRAY),
- (_line("Best Day", format_pct(metrics.get("best_day", 0))), GRAY),
- (_line("Worst Day", format_pct(metrics.get("worst_day", 0))), GRAY),
- ]),
- ("RATIOS", GRAY, [
- (_line("Sharpe", f"{metrics.get('sharpe', 0):.2f}"), GRAY),
- (_line("Sortino", f"{metrics.get('sortino', 0):.2f}"), GRAY),
- (_line("Calmar", f"{metrics.get('calmar', 0):.2f}"), GRAY),
- ]),
- ("TRADING", GRAY, [
- (_line("Trades", f"{ts.get('total_trades', metrics.get('total_trades', 0))}"), GRAY),
- (_line("Win Rate", f"{ts.get('win_rate', metrics.get('win_rate', 0)):.1%}"), GRAY),
- (_line("Profit Factor", f"{ts.get('profit_factor', metrics.get('profit_factor', 0)):.2f}"), GRAY),
- (_line("Round Trips", f"{ts.get('round_trips', 0)}"), GRAY),
- (_line("Avg Hold", _fmt_hold_time(ts.get("avg_holding_seconds", 0))), GRAY),
- (_line("Fees", f"{ts.get('total_fees', 0):.2f}"), GRAY),
- ]),
- ]
-
- # Count total lines for spacing
- total = sum(1 + len(items) + 1 for _, _, items in sections) # header + items + gap
- y = 0.97
- dy = 0.92 / total
-
- for section_name, section_color, items in sections:
- # Section header
- ax.text(0.06, y, section_name, fontsize=7, fontweight="bold",
- color=DARK_GRAY, transform=ax.transAxes, va="top",
- family="monospace")
- y -= dy * 1.2
-
- # Items
- for text, color in items:
- ax.text(0.06, y, text, fontsize=9, color=color,
- transform=ax.transAxes, va="top", family="monospace")
- y -= dy
-
- # Gap between sections
- y -= dy * 0.5
-
-
def _fmt_hold_time(seconds):
"""Format holding time in human-readable units."""
if seconds <= 0:
@@ -530,29 +350,3 @@ def _fmt_hold_time(seconds):
return f"{days:.0f}d"
hours = seconds / 3600
return f"{hours:.0f}h"
-
-
-def _render_exposure(ax, result):
- try:
- pa = positions_arrays(result)
- pos_ts = pa["timestamp"]
- pos_cap = pa["capital"]
- pos_eq = pa["equity"]
-
- unique_ts, first_idx = np.unique(pos_ts, return_index=True)
- first_idx.sort()
- cap = pos_cap[first_idx]
- eq_arr = pos_eq[first_idx]
- used = np.where(eq_arr > 0, (1.0 - cap / eq_arr) * 100, 0.0)
- used = np.clip(used, 0, None)
- used_dates = unique_ts.astype("datetime64[ns]")
-
- ax.fill_between(used_dates, 0, used,
- color=GREEN, alpha=0.10, edgecolor="none")
- ax.plot(used_dates, used, color=GREEN, linewidth=0.7, alpha=0.8)
- ax.axhline(0, color=DARK_GRAY, linewidth=0.4)
- ax.set_ylabel("Exposure %", fontsize=8)
- except Exception:
- ax.text(0.5, 0.5, "No position data",
- transform=ax.transAxes, ha="center", va="center",
- color=DARK_GRAY, fontsize=9)
diff --git a/python/manifoldbt/portfolio.py b/python/manifoldbt/portfolio.py
index 00c62e2..8c4062b 100644
--- a/python/manifoldbt/portfolio.py
+++ b/python/manifoldbt/portfolio.py
@@ -37,6 +37,15 @@ class Portfolio:
strategy: A Strategy instance.
weight: Fraction of total capital (0.0 to 1.0).
"""
+ if getattr(strategy, "_orders", None):
+ import warnings
+ warnings.warn(
+ f"Strategy '{strategy.name}' defines stop_loss/take_profit/"
+ "trailing_stop orders, but portfolio mode does not support "
+ "per-strategy orders yet: they are IGNORED in run_portfolio().",
+ UserWarning,
+ stacklevel=2,
+ )
self._strategies.append({
"name": strategy.name,
"strategy_json": strategy.to_json(),
diff --git a/python/manifoldbt/strategy.py b/python/manifoldbt/strategy.py
index cda9d83..90b4796 100644
--- a/python/manifoldbt/strategy.py
+++ b/python/manifoldbt/strategy.py
@@ -194,7 +194,7 @@ class Strategy:
spec["range"] = None
params[param_name] = spec
- return {
+ out = {
"name": self.name,
"signals": {
name: expr.to_json() for name, expr in self.signals.items()
@@ -206,6 +206,12 @@ class Strategy:
"description": self._description,
},
}
+ # Per-strategy SL/TP/trailing orders travel with the strategy so the
+ # engine applies them per-strategy in a single batch/sweep call (the
+ # Rust StrategyDef.orders field; omitted when unset for a clean JSON).
+ if self._orders:
+ out["orders"] = self._orders
+ return out
def to_json(self) -> str:
"""Serialize to a JSON string matching Rust ``StrategyDef``.
diff --git a/python/manifoldbt/sweep.py b/python/manifoldbt/sweep.py
index 3ca37ec..e4a51f1 100644
--- a/python/manifoldbt/sweep.py
+++ b/python/manifoldbt/sweep.py
@@ -93,62 +93,62 @@ class SweepResult:
return best_result
def plot_metric(self, metric: str = "sharpe", **kwargs: Any) -> Any:
- """Plot a metric across sweep results.
+ """Plot a metric across sweep results (plotly).
For 2-parameter sweeps, delegates to ``bt.plot.heatmap_2d``.
For 1-parameter sweeps, produces a bar chart.
Args:
metric: Metric to visualize.
- **kwargs: Forwarded to the plot function.
+ **kwargs: ``figsize``, ``show``, ``save`` forwarded to the plot.
"""
- import matplotlib.pyplot as plt
- import numpy as np
+ from manifoldbt.plot._theme import ACCENT, theme_context
+ from manifoldbt.plot._utils import finalize, new_figure
df = self.to_df(backend="pandas")
param_cols = [c for c in df.columns if c.startswith("param_")]
+ show = kwargs.pop("show", True)
+ save = kwargs.pop("save", None)
if len(param_cols) == 2:
- # 2D heatmap
+ from manifoldbt.plot.research import heatmap_2d
+
x_col, y_col = param_cols[0], param_cols[1]
pivot = df.pivot_table(index=y_col, columns=x_col, values=metric)
- fig, ax = plt.subplots(figsize=kwargs.pop("figsize", (10, 6)))
- im = ax.imshow(pivot.values, aspect="auto", cmap=kwargs.pop("cmap", "RdYlGn"))
- ax.set_xticks(range(len(pivot.columns)))
- ax.set_xticklabels(pivot.columns, rotation=45)
- ax.set_yticks(range(len(pivot.index)))
- ax.set_yticklabels(pivot.index)
- ax.set_xlabel(x_col.replace("param_", ""))
- ax.set_ylabel(y_col.replace("param_", ""))
- ax.set_title(f"{metric} heatmap")
- plt.colorbar(im, ax=ax, label=metric)
- plt.tight_layout()
- if kwargs.get("show", True):
- plt.show()
- return fig
- elif len(param_cols) == 1:
- # 1D bar chart
- p_col = param_cols[0]
- fig, ax = plt.subplots(figsize=kwargs.pop("figsize", (10, 5)))
- ax.bar(range(len(df)), df[metric].values, tick_label=[str(v) for v in df[p_col].values])
- ax.set_xlabel(p_col.replace("param_", ""))
- ax.set_ylabel(metric)
- ax.set_title(f"{metric} by {p_col.replace('param_', '')}")
- plt.tight_layout()
- if kwargs.get("show", True):
- plt.show()
- return fig
- else:
- # Fallback: simple bar
- fig, ax = plt.subplots(figsize=kwargs.pop("figsize", (10, 5)))
- ax.bar(range(len(df)), df[metric].values)
- ax.set_xlabel("run")
- ax.set_ylabel(metric)
- ax.set_title(f"{metric} across sweep")
- plt.tight_layout()
- if kwargs.get("show", True):
- plt.show()
- return fig
+ sweep_result = {
+ "metric_grid": pivot.values.tolist(),
+ "x_values": list(pivot.columns),
+ "y_values": list(pivot.index),
+ "x_param": x_col.replace("param_", ""),
+ "y_param": y_col.replace("param_", ""),
+ "metric": metric,
+ }
+ return heatmap_2d(sweep_result, show=show, save=save, **kwargs)
+
+ import plotly.graph_objects as go
+
+ with theme_context():
+ if len(param_cols) == 1:
+ p_col = param_cols[0]
+ fig = new_figure(kwargs.pop("figsize", (10, 5)),
+ f"{metric} by {p_col.replace('param_', '')}")
+ fig.add_trace(go.Bar(
+ x=[str(v) for v in df[p_col].values], y=df[metric].values,
+ marker_color=ACCENT, marker_line_width=0,
+ ))
+ fig.update_xaxes(title_text=p_col.replace("param_", ""),
+ type="category", showspikes=False)
+ else:
+ fig = new_figure(kwargs.pop("figsize", (10, 5)),
+ f"{metric} across sweep")
+ fig.add_trace(go.Bar(
+ x=list(range(len(df))), y=df[metric].values,
+ marker_color=ACCENT, marker_line_width=0,
+ ))
+ fig.update_xaxes(title_text="run", showspikes=False)
+ fig.update_yaxes(title_text=metric)
+ fig.update_layout(hovermode="closest")
+ return finalize(fig, show=show, save=save)
def __repr__(self) -> str:
params = ", ".join(f"{k}={len(v)} vals" for k, v in self._param_grid.items())
diff --git a/python/tests/test_batch_orders.py b/python/tests/test_batch_orders.py
new file mode 100644
index 0000000..c8263a5
--- /dev/null
+++ b/python/tests/test_batch_orders.py
@@ -0,0 +1,137 @@
+"""Regression tests for per-strategy orders in batch runs.
+
+History: run_batch/run_batch_lite once dropped SL/TP entirely (they called
+_prepare_config(config, None)). They were then fixed by merging orders into a
+grouped config. Now orders travel INSIDE the strategy JSON (StrategyDef.orders)
+and the engine applies them per-strategy, so a single native call handles a
+batch of strategies with DIFFERENT brackets over one data load — the config
+carries no orders and there is no per-profile grouping.
+
+Native calls are monkeypatched, so no market data is needed.
+"""
+import json
+
+import pytest
+
+import manifoldbt as bt
+
+
+class _DummyStore:
+ """Minimal store: no metadata DB, default dataset (all lookups fall back)."""
+
+ def dataset(self):
+ raise RuntimeError("no dataset")
+
+ def metadata_db(self):
+ raise RuntimeError("no metadata db")
+
+
+def _strategy(name, sl=None, tp=None):
+ s = bt.Strategy.create(name).signal("sig", bt.lit(1.0)).size(bt.lit(0.1))
+ if sl is not None:
+ s = s.stop_loss(pct=sl)
+ if tp is not None:
+ s = s.take_profit(pct=tp)
+ return s
+
+
+def _config():
+ return bt.BacktestConfig(
+ universe=[1],
+ time_range_start=0,
+ time_range_end=10_000_000_000,
+ initial_capital=10_000,
+ )
+
+
+@pytest.fixture()
+def captured(monkeypatch):
+ """Patch both native batch entry points; record (config_dict, [strategy_dict])."""
+ calls = []
+
+ def fake_batch_lite(strategy_jsons, config_json, store, max_parallelism=0):
+ strats = [json.loads(s) for s in strategy_jsons]
+ calls.append((json.loads(config_json), strats))
+ return [f"lite:{s['name']}" for s in strats]
+
+ def fake_batch(strategy_jsons, config_json, store, max_parallelism=0):
+ strats = [json.loads(s) for s in strategy_jsons]
+ calls.append((json.loads(config_json), strats))
+ return [object() for _ in strats]
+
+ monkeypatch.setattr(bt, "_run_batch_lite_native", fake_batch_lite)
+ monkeypatch.setattr(bt, "_run_batch_native", fake_batch)
+ return calls
+
+
+def _config_orders(cfg_json):
+ return (cfg_json.get("execution") or {}).get("orders")
+
+
+def _names(strats):
+ return [s["name"] for s in strats]
+
+
+def _sl_of(strat):
+ orders = strat.get("orders")
+ return orders["stop_loss"]["stop_pct"] if orders and "stop_loss" in orders else None
+
+
+def test_batch_lite_carries_sl_tp_in_strategy_json(captured):
+ strats = [_strategy(f"s{i}", sl=2.0, tp=4.0) for i in range(3)]
+ out = bt.run_batch_lite(strats, _config(), _DummyStore())
+
+ assert len(captured) == 1, "one native call handles the whole batch"
+ cfg, sent = captured[0]
+ assert _config_orders(cfg) is None, "orders travel in the strategy JSON, not the config"
+ for s in sent:
+ assert s["orders"]["stop_loss"]["stop_pct"] == 2.0
+ assert s["orders"]["take_profit"]["profit_pct"] == 4.0
+ assert _names(sent) == ["s0", "s1", "s2"]
+ assert out == ["lite:s0", "lite:s1", "lite:s2"]
+
+
+def test_batch_lite_no_orders_absent_from_json(captured):
+ strats = [_strategy(f"s{i}") for i in range(2)]
+ bt.run_batch_lite(strats, _config(), _DummyStore())
+
+ assert len(captured) == 1
+ cfg, sent = captured[0]
+ assert _config_orders(cfg) is None
+ for s in sent:
+ assert s.get("orders") is None
+
+
+def test_batch_lite_mixed_orders_single_call_in_order(captured):
+ strats = [
+ _strategy("a", sl=2.0),
+ _strategy("b"), # no orders
+ _strategy("c", sl=2.0),
+ _strategy("d", sl=5.0),
+ ]
+ out = bt.run_batch_lite(strats, _config(), _DummyStore())
+
+ # Heterogeneous brackets now run in ONE native call over one data load,
+ # each strategy carrying its own orders — no grouping, no reordering.
+ assert len(captured) == 1
+ cfg, sent = captured[0]
+ assert _config_orders(cfg) is None
+ assert _names(sent) == ["a", "b", "c", "d"]
+ assert [_sl_of(s) for s in sent] == [2.0, None, 2.0, 5.0]
+ assert out == ["lite:a", "lite:b", "lite:c", "lite:d"]
+
+
+def test_run_batch_carries_sl_tp(captured):
+ strats = [_strategy("x", sl=1.5), _strategy("y", sl=1.5)]
+ bt.run_batch(strats, _config(), _DummyStore())
+
+ assert len(captured) == 1
+ cfg, sent = captured[0]
+ assert _config_orders(cfg) is None
+ assert all(_sl_of(s) == 1.5 for s in sent)
+ assert _names(sent) == ["x", "y"]
+
+
+def test_portfolio_warns_on_ignored_orders():
+ with pytest.warns(UserWarning, match="IGNORED"):
+ bt.Portfolio().strategy(_strategy("p", sl=2.0), weight=1.0)