From 0f6b2fc7308dc4b51f306d60437069d3801e3b5d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 15 Jul 2026 00:01:41 +0000 Subject: [PATCH] release: v0.12.0 --- docs/plotly_migration_plan.md | 100 +++ pyproject.toml | 2 +- python/manifoldbt/__init__.py | 49 +- python/manifoldbt/plot/__init__.py | 17 +- python/manifoldbt/plot/_assets/manifoldbt.ico | Bin 0 -> 20669 bytes python/manifoldbt/plot/_decimate.py | 42 + python/manifoldbt/plot/_theme.py | 163 ++-- python/manifoldbt/plot/_utils.py | 129 ++- python/manifoldbt/plot/_window.py | 173 ++++ python/manifoldbt/plot/backtest.py | 468 +++++----- python/manifoldbt/plot/chart.py | 447 ++++------ python/manifoldbt/plot/research.py | 818 +++++++++--------- python/manifoldbt/plot/tearsheet.py | 332 ++----- python/manifoldbt/portfolio.py | 9 + python/manifoldbt/strategy.py | 8 +- python/manifoldbt/sweep.py | 84 +- python/tests/test_batch_orders.py | 137 +++ 17 files changed, 1640 insertions(+), 1338 deletions(-) create mode 100644 docs/plotly_migration_plan.md create mode 100644 python/manifoldbt/plot/_assets/manifoldbt.ico create mode 100644 python/manifoldbt/plot/_decimate.py create mode 100644 python/manifoldbt/plot/_window.py create mode 100644 python/tests/test_batch_orders.py 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 0000000000000000000000000000000000000000..fb851d8b92a6e0e2a7b09061f05fbff1195a9854 GIT binary patch literal 20669 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�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 literal 0 HcmV?d00001 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("<br>")[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 = "<head>" + _RESPONSIVE_CSS + f"<title>{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}
- Annual Returns + {div_annual}
- Equity + Benchmark + Trades + Margin - Drawdown + {div_summary} + {div_dd}
- Monthly Returns - Returns Distribution + {div_monthly} + {div_hist}
- Rolling Sharpe - Rolling Volatility + {div_sharpe} + {div_vol}
- Value at Risk + {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)