release: v0.12.0

This commit is contained in:
github-actions[bot]
2026-07-15 00:01:41 +00:00
parent a8f9d56b9e
commit 0f6b2fc730
17 changed files with 1640 additions and 1338 deletions
+100
View File
@@ -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).
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "manifoldbt" name = "manifoldbt"
version = "0.11.0" version = "0.12.0"
description = "Rust-powered backtesting engine for quantitative research" description = "Rust-powered backtesting engine for quantitative research"
requires-python = ">=3.9" requires-python = ">=3.9"
license = { file = "LICENSE" } license = { file = "LICENSE" }
+29 -20
View File
@@ -310,7 +310,12 @@ _PREPARED_CFG_CACHE_MAX = 256
def _prepared_config_json(config: BacktestConfig, strategy, store: DataStore) -> str: 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: try:
meta_db = store.metadata_db() meta_db = store.metadata_db()
except Exception: except Exception:
@@ -318,10 +323,8 @@ def _prepared_config_json(config: BacktestConfig, strategy, store: DataStore) ->
if meta_db is None: if meta_db is None:
return _prepare_config(config, strategy, store).to_json() return _prepare_config(config, strategy, store).to_json()
orders = getattr(strategy, "_orders", None) if strategy is not None else None
try: try:
orders_key = json.dumps(orders, sort_keys=True, default=str) if orders else "" key = (config.to_json(), meta_db)
key = (config.to_json(), orders_key, meta_db)
except (TypeError, ValueError): except (TypeError, ValueError):
# Unserialisable config content — skip memoisation, never fail. # Unserialisable config content — skip memoisation, never fail.
return _prepare_config(config, strategy, store).to_json() 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 resolved_sv[int(store.resolve_symbol(key))] = venue
fees.symbol_venue = resolved_sv fees.symbol_venue = resolved_sv
# Merge orders from strategy into execution config # Per-strategy SL/TP/trailing orders are NOT merged into the config anymore:
if strategy and hasattr(strategy, '_orders') and strategy._orders: # they travel inside the strategy JSON (Strategy.to_json -> StrategyDef.orders)
if cfg.execution.orders is None: # so the engine applies them per-strategy. This lets one batch/sweep call run
cfg.execution.orders = OrderConfig() # strategies carrying different brackets over a single data load. A bracket
for key, val in strategy._orders.items(): # set directly on config.execution.orders still applies as the fallback.
setattr(cfg.execution.orders, key, val)
return cfg return cfg
@@ -789,6 +790,11 @@ def run_batch(
Loads bars once, aligns timestamps once, then evaluates each strategy Loads bars once, aligns timestamps once, then evaluates each strategy
on a separate rayon thread. Much faster than calling ``run()`` in a loop. 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: Args:
strategies: List of Strategy definitions. strategies: List of Strategy definitions.
config: Shared backtest configuration (same universe/time range). config: Shared backtest configuration (same universe/time range).
@@ -800,13 +806,12 @@ def run_batch(
""" """
_require_pro_over_combos(len(strategies), "Batch backtesting") _require_pro_over_combos(len(strategies), "Batch backtesting")
try: try:
config = _prepare_config(config, None, store)
config = _cap_output_resolution(config) config = _cap_output_resolution(config)
store = _resolve_store(config, store) 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( raw_results = _run_batch_native(
strategy_jsons, [strat.to_json() for strat in strategies],
config.to_json(), cfg_json,
store, store,
max_parallelism, max_parallelism,
) )
@@ -828,6 +833,11 @@ def run_batch_lite(
position traces, and Arrow output construction. Ideal for parameter sweeps position traces, and Arrow output construction. Ideal for parameter sweeps
where you only need metrics to select the best variant. 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: Args:
strategies: List of Strategy definitions. strategies: List of Strategy definitions.
config: Shared backtest configuration (same universe/time range). config: Shared backtest configuration (same universe/time range).
@@ -839,13 +849,12 @@ def run_batch_lite(
""" """
_require_pro_over_combos(len(strategies), "Batch backtesting") _require_pro_over_combos(len(strategies), "Batch backtesting")
try: try:
config = _prepare_config(config, None, store)
config = _cap_output_resolution(config) config = _cap_output_resolution(config)
store = _resolve_store(config, store) 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( return _run_batch_lite_native(
strategy_jsons, [strat.to_json() for strat in strategies],
config.to_json(), cfg_json,
store, store,
max_parallelism, max_parallelism,
) )
@@ -1372,7 +1381,7 @@ __all__ = [
"__version__", "__version__",
# Indicators (submodule) # Indicators (submodule)
"indicators", "indicators",
# Plotting (lazy, requires matplotlib) # Plotting (lazy, requires plotly)
"plot", "plot",
# Diagnostics (lazy) # Diagnostics (lazy)
"diagnostics", "diagnostics",
+14 -3
View File
@@ -1,4 +1,4 @@
"""Plotting module for manifoldbt (requires matplotlib). """Plotting module for manifoldbt (requires plotly).
Install with:: Install with::
@@ -11,12 +11,18 @@ Quick start::
result = bt.run(strategy, config, store) result = bt.run(strategy, config, store)
bt.plot.tearsheet(result) # full-page dashboard bt.plot.tearsheet(result) # full-page dashboard
bt.plot.equity(result, show=True) # single chart 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: try:
import matplotlib # noqa: F401 import plotly # noqa: F401
except ImportError: except ImportError:
raise ImportError( raise ImportError(
"matplotlib is required for the plotting module. " "plotly is required for the plotting module. "
"Install it with: pip install manifoldbt[plot]" "Install it with: pip install manifoldbt[plot]"
) from None ) from None
@@ -51,6 +57,9 @@ from manifoldbt.plot.research import (
# Composite layouts # Composite layouts
from manifoldbt.plot.tearsheet import research_report, tearsheet from manifoldbt.plot.tearsheet import research_report, tearsheet
# Window display (multi-window, matplotlib-style)
from manifoldbt.plot._window import show
# Theme # Theme
from manifoldbt.plot._theme import THEME, apply_theme from manifoldbt.plot._theme import THEME, apply_theme
@@ -78,6 +87,8 @@ __all__ = [
# Composites # Composites
"tearsheet", "tearsheet",
"research_report", "research_report",
# Window display
"show",
# Theme # Theme
"THEME", "THEME",
"apply_theme", "apply_theme",
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+42
View File
@@ -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)
+87 -76
View File
@@ -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 __future__ import annotations
from contextlib import contextmanager from contextlib import contextmanager
from typing import Any, Dict
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Color palette — neutral dark, no decorative colors # Color palette — neutral dark, no decorative colors
@@ -20,94 +19,106 @@ BG_FIGURE = "#0c0c0f"
BG_AXES = "#111116" BG_AXES = "#111116"
BORDER = "#1e1e24" BORDER = "#1e1e24"
GRID_RGBA = (1.0, 1.0, 1.0, 0.04) 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] 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] = { CS_DIVERGING = [[0.0, "#b91c1c"], [0.5, "#262626"], [1.0, "#15803d"]]
"figure.facecolor": BG_FIGURE, CS_SEQUENTIAL = [[0.0, "#b91c1c"], [0.5, "#d97706"], [1.0, "#15803d"]]
"figure.edgecolor": BG_FIGURE, CS_CORRELATION = [[0.0, "#b91c1c"], [0.5, "#262626"], [1.0, "#1d4ed8"]]
"figure.dpi": 120,
"axes.facecolor": BG_AXES, # ---------------------------------------------------------------------------
"axes.edgecolor": BORDER, # Layout defaults (also exported as THEME for backward compatibility)
"axes.labelcolor": GRAY, # ---------------------------------------------------------------------------
"axes.titlecolor": WHITE, THEME: dict = {
"axes.titlesize": 11, "paper_bgcolor": BG_FIGURE,
"axes.titleweight": "medium", "plot_bgcolor": BG_AXES,
"axes.titlepad": 12, "font": {"family": FONT_FAMILY, "color": GRAY, "size": 12},
"axes.labelsize": 9, "title": {"font": {"color": WHITE, "size": 15}, "x": 0.01, "xanchor": "left"},
"axes.labelpad": 8, "margin": {"l": 64, "r": 24, "t": 48, "b": 36},
"axes.grid": True, "hovermode": "x",
"grid.color": GRID_RGBA, "colorway": SERIES_COLORS,
"grid.linewidth": 0.5, "hoverlabel": {
"grid.linestyle": "-", "bgcolor": "#1a1a20",
"xtick.color": DARK_GRAY, "bordercolor": BORDER,
"ytick.color": DARK_GRAY, "font": {"family": MONO_FAMILY, "color": WHITE, "size": 12},
"xtick.labelsize": 8, },
"ytick.labelsize": 8, "legend": {
"text.color": WHITE, "bgcolor": "rgba(17,17,22,0.6)",
"font.family": "monospace", "bordercolor": BORDER,
"font.size": 9, "borderwidth": 1,
"legend.facecolor": BG_AXES, "font": {"color": GRAY, "size": 11},
"legend.edgecolor": BORDER, },
"legend.fontsize": 8, }
"legend.labelcolor": GRAY,
"lines.linewidth": 1.3, _AXIS = {
"lines.antialiased": True, "color": GRAY,
"savefig.facecolor": BG_FIGURE, "gridcolor": GRID_COLOR,
"savefig.edgecolor": BG_FIGURE, "linecolor": BORDER,
"savefig.bbox": "tight", "zerolinecolor": GRID_COLOR,
"savefig.dpi": 150, "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]: def _build_template():
"""Finalize THEME dict with cycler.""" """Build the manifoldbt plotly template."""
import matplotlib.pyplot as plt import plotly.graph_objects as go
theme = dict(THEME)
theme["axes.prop_cycle"] = plt.cycler(color=SERIES_COLORS)
return theme
layout = dict(THEME)
# --------------------------------------------------------------------------- layout["xaxis"] = dict(_AXIS)
# Colormaps layout["yaxis"] = dict(_AXIS)
# --------------------------------------------------------------------------- layout["scene"] = {
def _register_colormaps() -> None: "xaxis": dict(_SCENE_AXIS),
"""Register custom colormaps (idempotent).""" "yaxis": dict(_SCENE_AXIS),
from matplotlib.colors import LinearSegmentedColormap "zaxis": dict(_SCENE_AXIS),
import matplotlib as mpl "bgcolor": BG_FIGURE,
_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")],
} }
for name, stops in _cmaps.items(): return go.layout.Template(layout=layout)
try:
mpl.colormaps.get_cmap(name)
except ValueError: _REGISTERED = False
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)
# ---------------------------------------------------------------------------
# Public
# ---------------------------------------------------------------------------
def apply_theme() -> None: def apply_theme() -> None:
"""Apply the dark theme globally.""" """Register the manifoldbt template and set it as plotly's default."""
import matplotlib.pyplot as plt global _REGISTERED
_register_colormaps() import plotly.io as pio
plt.rcParams.update(_build_theme())
pio.templates["manifoldbt"] = _build_template()
pio.templates.default = "manifoldbt"
_REGISTERED = True
def _ensure_theme() -> None:
if not _REGISTERED:
apply_theme()
@contextmanager @contextmanager
def theme_context(): def theme_context():
"""Context manager: apply theme temporarily.""" """Backward-compatible context manager: ensures the theme is registered.
import matplotlib.pyplot as plt
_register_colormaps() With plotly the theme is a global template rather than a temporary
with plt.rc_context(_build_theme()): rc-context, so this simply guarantees registration.
yield """
_ensure_theme()
yield
+101 -28
View File
@@ -1,25 +1,32 @@
"""Shared plotting utilities.""" """Shared plotting utilities (plotly)."""
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Optional, Tuple, Union from typing import Optional, Tuple, Union
import matplotlib.pyplot as plt from manifoldbt.plot._theme import WHITE, _ensure_theme
from matplotlib.axes import Axes
from matplotlib.figure import Figure
from manifoldbt.plot._theme import theme_context _RESPONSIVE_CSS = (
"<style>html,body{height:100%;margin:0;background:#0c0c0f;overflow:hidden}"
".plotly-graph-div{width:100vw!important;height:100vh!important}</style>"
)
_IMAGE_EXTS = {".png", ".svg", ".pdf", ".jpg", ".jpeg", ".webp"}
def get_or_create_ax( def new_figure(
ax: Optional[Axes] = None,
figsize: Tuple[float, float] = (12, 4), figsize: Tuple[float, float] = (12, 4),
) -> Tuple[Figure, Axes]: title: Optional[str] = None,
"""Return (fig, ax). Creates a new themed figure if *ax* is None.""" ):
if ax is not None: """Return a themed plotly Figure sized from a matplotlib-style figsize."""
return ax.figure, ax import plotly.graph_objects as go
fig, new_ax = plt.subplots(figsize=figsize)
return fig, new_ax _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: 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: def format_currency(value: float, currency: str = "USD") -> str:
"""Format a number as currency.""" """Format a number as currency."""
symbol = {"USD": "$", "EUR": "\u20ac", "GBP": "\u00a3"}.get(currency, "") symbol = {"USD": "$", "EUR": "", "GBP": "£"}.get(currency, "")
return f"{symbol}{value:,.2f}" return f"{symbol}{value:,.2f}"
def finalize( def finalize(
fig: Figure, fig,
*, *,
show: bool = False, show: "bool | str" = False,
save: Optional[Union[str, Path]] = None, save: Optional[Union[str, Path]] = None,
dpi: int = 150, dpi: int = 150,
) -> Figure: window_size: Optional[Tuple[int, int]] = None,
"""Optionally save and/or display the figure, then return it.""" ) -> "object":
import warnings """Optionally save and/or display the figure, then return it.
with warnings.catch_warnings():
warnings.simplefilter("ignore", UserWarning) ``save`` routes on extension: ``.html`` writes a responsive interactive
try: page; image extensions (.png/.svg/.pdf/...) go through kaleido.
fig.tight_layout() ``show``: ``True`` (or ``"window"``) opens a native window (needs pywebview,
except Exception: else falls back to a browser tab); ``"browser"`` forces a browser tab.
pass # Skip when axes are incompatible (e.g. inside GridSpec) ``dpi`` is kept for backward compatibility and maps to an export scale.
"""
if save is not None: if save is not None:
fig.savefig(str(save), dpi=dpi, bbox_inches="tight") path = Path(save)
if show: ext = path.suffix.lower()
plt.show() 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("<br>")[0].strip() or title
except Exception:
pass
open_in_window(fig, title=title, size=window_size or (1280, 720))
return fig 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 <title> 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}</title>"
html = html.replace("<head>", 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: def auto_title(result, fallback: str) -> str:
"""Build a title from result manifest strategy_name, or use fallback.""" """Build a title from result manifest strategy_name, or use fallback."""
try: try:
+173
View File
@@ -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 = """<!doctype html><html><head><meta charset="utf-8"><title>{title}</title>
<style>
html,body{{margin:0;height:100%;background:#0c0c0f;overflow:hidden}}
#chart{{height:100vh}} .plotly-graph-div{{width:100%!important;height:100%!important}}
#drag{{position:fixed;top:0;left:0;right:0;height:26px;z-index:5}}
#close{{position:fixed;top:8px;right:10px;z-index:6;width:26px;height:26px;
display:flex;align-items:center;justify-content:center;font-family:Arial,sans-serif;
font-size:17px;color:#8a8a8a;cursor:pointer;border-radius:5px;
background:rgba(17,17,22,0.45);transition:all .12s}}
#close:hover{{background:#ef4444;color:#fff}}
</style></head><body>
<div id="drag" class="pywebview-drag-region"></div>
<div id="close" onclick="pywebview.api.close()" title="Close (Alt+F4)">&times;</div>
<div id="chart">{div}</div>
<script>
function fit(){{var g=document.querySelector('.plotly-graph-div');
if(g&&window.Plotly)Plotly.relayout(g,{{width:window.innerWidth,height:window.innerHeight}});}}
window.addEventListener('resize',fit);
window.addEventListener('load',function(){{fit();requestAnimationFrame(fit);
setTimeout(fit,120);setTimeout(fit,400);}});
</script>
</body></html>"""
# 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)
+270 -198
View File
@@ -1,15 +1,12 @@
"""Charts for BacktestResult visualization.""" """Charts for BacktestResult visualization (plotly)."""
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import List, Optional, Tuple, Union from typing import List, Optional, Tuple, Union
import numpy as np import numpy as np
import matplotlib.pyplot as plt import plotly.graph_objects as go
import matplotlib.dates as mdates from plotly.subplots import make_subplots
import matplotlib.ticker as mticker
from matplotlib.axes import Axes
from matplotlib.figure import Figure
from manifoldbt.plot._theme import ( from manifoldbt.plot._theme import (
ACCENT, ACCENT,
@@ -29,7 +26,42 @@ from manifoldbt.plot._convert import (
trades_arrays, trades_arrays,
_ts_to_int64, _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) ──────────────────────────────────────────── # ── Summary (the essential chart) ────────────────────────────────────────────
@@ -41,29 +73,44 @@ def summary(
figsize: Tuple[float, float] = (14, 8), figsize: Tuple[float, float] = (14, 8),
show: bool = False, show: bool = False,
save: Optional[Union[str, Path]] = None, save: Optional[Union[str, Path]] = None,
) -> Figure: ) -> go.Figure:
"""The essential chart: TWR equity + buy-and-hold benchmark, trade activity. """The essential chart: TWR equity + buy-and-hold benchmark, trade activity.
Top panel: TWR-normalized equity curve vs buy-and-hold (close price). Top panel: TWR-normalized equity curve vs buy-and-hold (close price).
Bottom panel: daily trade count as a bar chart. Middle panel: daily trade count as a bar chart.
Metrics displayed in a clean header line. Bottom panel: used margin percentage.
Metrics displayed in the title line.
""" """
with theme_context(): with theme_context():
fig, (ax_eq, ax_trades, ax_margin) = plt.subplots( fig = make_subplots(
3, 1, figsize=figsize, height_ratios=[3, 1, 1], rows=3, cols=1, shared_xaxes=True, vertical_spacing=0.06,
sharex=True, gridspec_kw={"hspace": 0.25}, row_heights=[0.6, 0.2, 0.2],
) )
dates, eq_vals = equity_with_dates(result) dates, eq_vals = equity_with_dates(result)
metrics = result.metrics if hasattr(result, "metrics") else {} metrics = result.metrics if hasattr(result, "metrics") else {}
# ── TWR equity (normalized to 100) ──────────────────────── # ── TWR equity (normalized to 100) ────────────────────────
twr = eq_vals / eq_vals[0] * 100 twr_full = eq_vals / eq_vals[0] * 100
ax_eq.plot(dates, twr, color=ACCENT, linewidth=0.8, label="Strategy") d_dates, twr = maybe_decimate(dates, twr_full)
ax_eq.fill_between(dates, twr, 100, where=(twr >= 100), fig.add_trace(go.Scatter(
color=GREEN, alpha=0.04, interpolate=True) x=d_dates, y=twr, mode="lines", name="Strategy",
ax_eq.fill_between(dates, twr, 100, where=(twr < 100), line=dict(color=ACCENT, width=1.0),
color=RED, alpha=0.04, interpolate=True) hovertemplate="%{x|%d %b %Y} %{y:.1f}<extra>Strategy</extra>",
), 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 ───────────── # ── Benchmark: buy-and-hold from close prices ─────────────
positions = result.positions positions = result.positions
@@ -78,7 +125,7 @@ def summary(
benchmark_raw = close_vals / close_vals[0] * 100 benchmark_raw = close_vals / close_vals[0] * 100
# Vol-adjusted benchmark: scale to same volatility as strategy # 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] bench_rets = np.diff(benchmark_raw) / benchmark_raw[:-1]
strat_vol = np.nanstd(strat_rets) strat_vol = np.nanstd(strat_rets)
bench_vol = np.nanstd(bench_rets) bench_vol = np.nanstd(bench_rets)
@@ -90,16 +137,19 @@ def summary(
else: else:
benchmark = benchmark_raw benchmark = benchmark_raw
ax_eq.plot(dates, benchmark, color=GRAY, linewidth=1.0, b_dates, b_vals = maybe_decimate(dates[: len(benchmark)], benchmark)
label="Buy & Hold (vol-adj)", alpha=0.7) 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}<extra>Buy & Hold</extra>",
), row=1, col=1)
ax_eq.axhline(100, color=DARK_GRAY, linewidth=0.4) fig.add_hline(y=100, line_color=DARK_GRAY, line_width=0.4, row=1, col=1)
# Ensure y-axis zooms to strategy range with some padding twr_min, twr_max = float(np.nanmin(twr_full)), float(np.nanmax(twr_full))
twr_min, twr_max = float(np.nanmin(twr)), float(np.nanmax(twr))
twr_range = max(twr_max - twr_min, 0.1) 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) fig.update_yaxes(title_text="TWR (base 100)",
ax_eq.set_ylabel("TWR (base 100)", fontsize=9) range=[twr_min - twr_range * 0.15, twr_max + twr_range * 0.15],
ax_eq.legend(loc="upper left", framealpha=0.3, fontsize=8) row=1, col=1)
# Header metrics # Header metrics
ret = metrics.get("total_return", 0) ret = metrics.get("total_return", 0)
@@ -112,10 +162,9 @@ def summary(
f" Max DD {mdd * 100:.1f}%" f" Max DD {mdd * 100:.1f}%"
f" Trades {n_trades:,}" f" Trades {n_trades:,}"
) )
ax_eq.set_title(title, fontsize=10, loc="left", pad=10) fig.update_layout(title_text=title)
# ── Adaptive smoothing window ────────────────────────────── # ── Adaptive smoothing window ──────────────────────────────
# Scale window: min(7d, max(1d, 5% of total period))
smooth_label = "" smooth_label = ""
if len(dates) >= 2: if len(dates) >= 2:
bar_ns = int(dates[1]) - int(dates[0]) bar_ns = int(dates[1]) - int(dates[0])
@@ -126,22 +175,22 @@ def summary(
smooth_window = min(smooth_window, len(dates)) smooth_window = min(smooth_window, len(dates))
smooth_days = round(target_ns / day_ns) smooth_days = round(target_ns / day_ns)
smooth_label = f" ({smooth_days}d)" if smooth_days >= 1 else "" smooth_label = f" ({smooth_days}d)" if smooth_days >= 1 else ""
else:
smooth_window = 1
# ── Trade activity (daily trade count) ───────────────────── # ── Trade activity (daily trade count) ─────────────────────
try: try:
ta = trades_arrays(result) ta = trades_arrays(result)
trade_ts = ta.get("execution_timestamp", np.array([], dtype="datetime64[ns]")) trade_ts = ta.get("execution_timestamp", np.array([], dtype="datetime64[ns]"))
if len(trade_ts) > 0 and len(dates) >= 2: if len(trade_ts) > 0 and len(dates) >= 2:
# Bucket trades into calendar days
trade_days = trade_ts.astype("datetime64[D]") trade_days = trade_ts.astype("datetime64[D]")
unique_days, day_counts = np.unique(trade_days, return_counts=True) unique_days, day_counts = np.unique(trade_days, return_counts=True)
day_dates = unique_days.astype("datetime64[ns]") day_dates = unique_days.astype("datetime64[ns]")
ax_trades.bar(day_dates, day_counts, fig.add_trace(go.Bar(
width=np.timedelta64(1, "D"), x=day_dates, y=day_counts, name="Trades/day",
color=ACCENT_ALT, alpha=0.4, edgecolor="none") marker_color=_rgba(ACCENT_ALT, 0.4), marker_line_width=0,
showlegend=False,
hovertemplate="%{x|%d %b %Y} %{y} trades<extra></extra>",
), row=2, col=1)
# Rolling 7-day average overlay # Rolling 7-day average overlay
eq_days = dates.astype("datetime64[D]") eq_days = dates.astype("datetime64[D]")
@@ -154,16 +203,14 @@ def summary(
if win > 1: if win > 1:
kernel = np.ones(win) / win kernel = np.ones(win) / win
smoothed = np.convolve(daily_on_grid, kernel, mode="same") smoothed = np.convolve(daily_on_grid, kernel, mode="same")
ax_trades.plot(unique_eq_days.astype("datetime64[ns]"), smoothed, fig.add_trace(go.Scatter(
color=ACCENT_ALT, linewidth=1.0, alpha=0.8) x=unique_eq_days.astype("datetime64[ns]"), y=smoothed,
else: mode="lines", line=dict(color=ACCENT_ALT, width=1.0),
ax_trades.text(0.5, 0.5, "No trade data", transform=ax_trades.transAxes, opacity=0.8, showlegend=False, hoverinfo="skip",
ha="center", va="center", color=DARK_GRAY, fontsize=9) ), row=2, col=1)
except Exception: except Exception:
ax_trades.text(0.5, 0.5, "No trade data", transform=ax_trades.transAxes, pass
ha="center", va="center", color=DARK_GRAY, fontsize=9) fig.update_yaxes(title_text="Trades/day", row=2, col=1)
ax_trades.set_ylabel("Trades/day", fontsize=8)
# ── Used margin % (daily) ────────────────────────────── # ── Used margin % (daily) ──────────────────────────────
try: try:
@@ -183,29 +230,27 @@ def summary(
# Resample to daily (end-of-day snapshot) # Resample to daily (end-of-day snapshot)
days = used_dates.astype("datetime64[D]") days = used_dates.astype("datetime64[D]")
unique_days, _ = np.unique(days, return_index=True) 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 day_last = np.searchsorted(days, unique_days, side="right") - 1
daily_used = used[day_last] daily_used = used[day_last]
daily_dates = unique_days.astype("datetime64[ns]") daily_dates = unique_days.astype("datetime64[ns]")
ax_margin.fill_between(daily_dates, 0, daily_used, fig.add_trace(go.Scatter(
color=GREEN, alpha=0.10, edgecolor="none") x=daily_dates, y=daily_used, mode="lines",
ax_margin.plot(daily_dates, daily_used, line=dict(color=GREEN, width=0.7), opacity=0.8,
color=GREEN, linewidth=0.7, alpha=0.8) fill="tozeroy", fillcolor=_rgba(GREEN, 0.10),
ax_margin.axhline(0, color=DARK_GRAY, linewidth=0.4) showlegend=False,
hovertemplate="%{x|%d %b %Y} %{y:.1f}%<extra>Margin</extra>",
), row=3, col=1)
except Exception: except Exception:
ax_margin.text( pass
0.5, 0.5, "No position data", fig.update_yaxes(title_text=f"Margin %{smooth_label}", row=3, col=1)
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")
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) return finalize(fig, show=show, save=save)
@@ -215,24 +260,27 @@ def summary(
def equity( def equity(
result, result,
*, *,
ax: Optional[Axes] = None, ax=None,
color: str = ACCENT, color: str = ACCENT,
title: str = "Equity Curve", title: str = "Equity Curve",
figsize: Tuple[float, float] = (14, 5), figsize: Tuple[float, float] = (14, 5),
show: bool = False, show: bool = False,
save: Optional[Union[str, Path]] = None, save: Optional[Union[str, Path]] = None,
) -> Figure: ) -> go.Figure:
"""Plot the portfolio equity curve over time.""" """Plot the portfolio equity curve over time.
``ax`` is accepted for backward compatibility and ignored (plotly backend).
"""
with theme_context(): with theme_context():
fig, ax_ = get_or_create_ax(ax, figsize) fig = new_figure(figsize, title)
dates, values = equity_with_dates(result) dates, values = equity_with_dates(result)
ax_.plot(dates, values, color=color, linewidth=1.3) dates, values = maybe_decimate(dates, values)
ax_.fill_between(dates, values, values.min(), color=color, alpha=0.05) fig.add_traces(_area_traces(
ax_.set_title(title) dates, values, float(values.min()), color, width=1.5,
ax_.set_ylabel("Equity", fontsize=9) hovertemplate="%{x|%d %b %Y} $%{y:,.0f}<extra></extra>",
ax_.xaxis.set_major_formatter(mdates.DateFormatter("%b %Y")) ))
ax_.xaxis.set_major_locator(mdates.AutoDateLocator()) fig.update_yaxes(title_text="Equity")
fig.autofmt_xdate(rotation=0, ha="center") fig.update_xaxes(tickformat="%b %Y")
return finalize(fig, show=show, save=save) return finalize(fig, show=show, save=save)
@@ -243,7 +291,7 @@ def benchmark_equity(
result, result,
benchmark: np.ndarray, benchmark: np.ndarray,
*, *,
ax: Optional[Axes] = None, ax=None,
strategy_color: str = ACCENT, strategy_color: str = ACCENT,
benchmark_color: str = DARK_GRAY, benchmark_color: str = DARK_GRAY,
normalize: bool = True, normalize: bool = True,
@@ -252,10 +300,10 @@ def benchmark_equity(
figsize: Tuple[float, float] = (14, 5), figsize: Tuple[float, float] = (14, 5),
show: bool = False, show: bool = False,
save: Optional[Union[str, Path]] = None, save: Optional[Union[str, Path]] = None,
) -> Figure: ) -> go.Figure:
"""Overlay strategy equity and a benchmark, both normalized to 100.""" """Overlay strategy equity and a benchmark, both normalized to 100."""
with theme_context(): with theme_context():
fig, ax_ = get_or_create_ax(ax, figsize) fig = new_figure(figsize, title)
dates, strat_eq = equity_with_dates(result) dates, strat_eq = equity_with_dates(result)
bench = np.asarray(benchmark, dtype=np.float64) bench = np.asarray(benchmark, dtype=np.float64)
n = min(len(strat_eq), len(bench)) n = min(len(strat_eq), len(bench))
@@ -265,13 +313,19 @@ def benchmark_equity(
strat_eq = strat_eq / strat_eq[0] * 100 strat_eq = strat_eq / strat_eq[0] * 100
bench = bench / bench[0] * 100 bench = bench / bench[0] * 100
ax_.plot(dates, strat_eq, color=strategy_color, linewidth=1.3, label=labels[0]) d1, s1 = maybe_decimate(dates, strat_eq)
ax_.plot(dates, bench, color=benchmark_color, linewidth=1.0, label=labels[1]) d2, b1 = maybe_decimate(dates, bench)
ax_.set_title(title) fig.add_trace(go.Scatter(
ax_.set_ylabel("Normalized" if normalize else "Equity") x=d1, y=s1, mode="lines", name=labels[0],
ax_.legend(loc="upper left", framealpha=0.5) line=dict(color=strategy_color, width=1.5),
ax_.xaxis.set_major_formatter(mdates.DateFormatter("%b %Y")) ))
fig.autofmt_xdate(rotation=0, ha="center") 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) return finalize(fig, show=show, save=save)
@@ -281,28 +335,31 @@ def benchmark_equity(
def drawdown( def drawdown(
result, result,
*, *,
ax: Optional[Axes] = None, ax=None,
color: str = RED, color: str = RED,
title: str = "Drawdown", title: str = "Drawdown",
figsize: Tuple[float, float] = (14, 3), figsize: Tuple[float, float] = (14, 3),
show: bool = False, show: bool = False,
save: Optional[Union[str, Path]] = None, save: Optional[Union[str, Path]] = None,
) -> Figure: ) -> go.Figure:
"""Plot the drawdown as a filled area chart.""" """Plot the drawdown as a filled area chart."""
with theme_context(): with theme_context():
fig, ax_ = get_or_create_ax(ax, figsize) fig = new_figure(figsize, title)
dates, values = equity_with_dates(result) dates, values = equity_with_dates(result)
running_max = np.maximum.accumulate(values) running_max = np.maximum.accumulate(values)
dd = (values - running_max) / running_max dd = (values - running_max) / running_max
dates, dd = maybe_decimate(dates, dd)
ax_.fill_between(dates, dd, 0, color=color, alpha=0.25) fig.add_trace(go.Scatter(
ax_.plot(dates, dd, color=color, linewidth=0.8) x=dates, y=dd, mode="lines",
ax_.set_title(title) line=dict(color=color, width=0.9),
ax_.set_ylabel("Drawdown") fill="tozeroy", fillcolor=_rgba(color, 0.25),
ax_.yaxis.set_major_formatter(mticker.PercentFormatter(xmax=1.0, decimals=0)) hovertemplate="%{x|%d %b %Y} %{y:.1%}<extra></extra>",
ax_.set_ylim(top=0) ))
ax_.xaxis.set_major_formatter(mdates.DateFormatter("%b %Y")) dd_min = float(dd.min()) if len(dd) else -0.01
fig.autofmt_xdate(rotation=0, ha="center") 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) return finalize(fig, show=show, save=save)
@@ -312,14 +369,16 @@ def drawdown(
def monthly_returns( def monthly_returns(
result, result,
*, *,
ax: Optional[Axes] = None, ax=None,
annotate: bool = True, annotate: bool = True,
title: str = "Monthly Returns (%)", title: str = "Monthly Returns (%)",
figsize: Tuple[float, float] = (12, 5), figsize: Tuple[float, float] = (12, 5),
show: bool = False, show: bool = False,
save: Optional[Union[str, Path]] = None, save: Optional[Union[str, Path]] = None,
) -> Figure: ) -> go.Figure:
"""Monthly returns heatmap (year rows x month columns + annual).""" """Monthly returns heatmap (year rows x month columns + annual)."""
from manifoldbt.plot._theme import CS_DIVERGING
with theme_context(): with theme_context():
dates, values = equity_with_dates(result) dates, values = equity_with_dates(result)
ts = dates.astype("datetime64[M]") ts = dates.astype("datetime64[M]")
@@ -344,31 +403,27 @@ def monthly_returns(
if len(valid) > 0: if len(valid) > 0:
grid[yi, 12] = np.prod(1.0 + valid) - 1.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) 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", month_labels = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec", "YTD"] "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: text = np.where(np.isnan(grid), "", np.vectorize(lambda v: f"{v * 100:+.1f}" if not np.isnan(v) else "")(grid))
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")
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}%<extra></extra>",
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) return finalize(fig, show=show, save=save)
@@ -378,12 +433,12 @@ def monthly_returns(
def annual_returns( def annual_returns(
result, result,
*, *,
ax: Optional[Axes] = None, ax=None,
title: str = "Annual Returns", title: str = "Annual Returns",
figsize: Tuple[float, float] = (10, 4), figsize: Tuple[float, float] = (10, 4),
show: bool = False, show: bool = False,
save: Optional[Union[str, Path]] = None, save: Optional[Union[str, Path]] = None,
) -> Figure: ) -> go.Figure:
"""Annual returns bar chart with green/red conditional coloring.""" """Annual returns bar chart with green/red conditional coloring."""
with theme_context(): with theme_context():
dates, values = equity_with_dates(result) dates, values = equity_with_dates(result)
@@ -394,19 +449,20 @@ def annual_returns(
idx = np.nonzero(years_arr == y)[0] 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) 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] 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, fig.add_trace(go.Bar(
width=0.5, alpha=0.85, edgecolor="none") x=[str(y) for y in unique_years], y=ann_rets,
ax_.axhline(0, color=DARK_GRAY, linewidth=0.5) marker_color=colors, opacity=0.85, marker_line_width=0,
ax_.set_title(title) width=0.5,
ax_.yaxis.set_major_formatter(mticker.PercentFormatter(xmax=1.0, decimals=0)) text=[format_pct(r) for r in ann_rets],
textposition="outside", textfont=dict(color=GRAY, size=11),
for bar, ret in zip(bars, ann_rets): hovertemplate="%{x}: %{y:.1%}<extra></extra>",
ax_.text(bar.get_x() + bar.get_width() / 2, bar.get_height(), ))
format_pct(ret), ha="center", fig.add_hline(y=0, line_color=DARK_GRAY, line_width=0.5)
va="bottom" if ret >= 0 else "top", fig.update_yaxes(tickformat=".0%")
fontsize=8, color=GRAY) fig.update_xaxes(showspikes=False, type="category")
fig.update_layout(hovermode="closest")
return finalize(fig, show=show, save=save) return finalize(fig, show=show, save=save)
@@ -416,19 +472,19 @@ def annual_returns(
def returns_histogram( def returns_histogram(
result, result,
*, *,
ax: Optional[Axes] = None, ax=None,
bins: int = 100, bins: int = 100,
title: str = "Returns Distribution", title: str = "Returns Distribution",
figsize: Tuple[float, float] = (12, 5), figsize: Tuple[float, float] = (12, 5),
show: bool = False, show: bool = False,
save: Optional[Union[str, Path]] = None, save: Optional[Union[str, Path]] = None,
) -> Figure: ) -> go.Figure:
"""Histogram of daily returns with green/red coloring by sign.""" """Histogram of daily returns with green/red coloring by sign."""
with theme_context(): with theme_context():
fig, ax_ = get_or_create_ax(ax, figsize) fig = new_figure(figsize, title)
rets = daily_returns_array(result) rets = daily_returns_array(result)
if len(rets) == 0: 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) return finalize(fig, show=show, save=save)
# Clip x-axis to P1-P99 range to avoid empty space from outliers # 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 margin = (p99 - p1) * 0.3
xlim = (p1 - margin, p99 + margin) xlim = (p1 - margin, p99 + margin)
_, bin_edges, patches = ax_.hist(rets, bins=bins, edgecolor="none", alpha=0.7, counts, bin_edges = np.histogram(rets, bins=bins, range=xlim)
range=xlim) centers = (bin_edges[:-1] + bin_edges[1:]) / 2
for patch, left in zip(patches, bin_edges[:-1]): bw = bin_edges[1] - bin_edges[0]
patch.set_facecolor(GREEN if left >= 0 else RED) colors = [GREEN if left >= 0 else RED for left in bin_edges[:-1]]
ax_.axvline(0, color=DARK_GRAY, linewidth=0.8, linestyle="--") fig.add_trace(go.Bar(
ax_.set_xlim(xlim) x=centers, y=counts, width=bw,
marker_color=colors, opacity=0.7, marker_line_width=0,
hovertemplate="%{x:.2%}: %{y}<extra></extra>",
))
fig.add_vline(x=0, line_color=DARK_GRAY, line_width=0.8, line_dash="dash")
# Normal fit (pure numpy) # Normal fit (pure numpy)
mu, sigma = rets.mean(), rets.std() mu, sigma = rets.mean(), rets.std()
if sigma > 0: if sigma > 0:
x = np.linspace(xlim[0], xlim[1], 200) 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) 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, fig.add_trace(go.Scatter(
alpha=0.7, label="Normal") x=x, y=pdf * len(rets) * bw, mode="lines", name="Normal",
ax_.legend(loc="upper right", framealpha=0.3) 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) fig.update_xaxes(title_text="Daily Return", tickformat=".1%",
ax_.set_xlabel("Daily Return") range=list(xlim))
ax_.set_ylabel("Frequency") fig.update_yaxes(title_text="Frequency")
ax_.xaxis.set_major_formatter(mticker.PercentFormatter(xmax=1.0, decimals=1)) fig.update_layout(hovermode="closest", bargap=0.05)
return finalize(fig, show=show, save=save) return finalize(fig, show=show, save=save)
@@ -467,60 +529,66 @@ def returns_histogram(
def var_chart( def var_chart(
result, result,
*, *,
ax: Optional[Axes] = None, ax=None,
confidence: float = 0.05, confidence: float = 0.05,
bins: int = 120, bins: int = 120,
title: str = "Value at Risk", title: str = "Value at Risk",
figsize: Tuple[float, float] = (12, 5), figsize: Tuple[float, float] = (12, 5),
show: bool = False, show: bool = False,
save: Optional[Union[str, Path]] = None, save: Optional[Union[str, Path]] = None,
) -> Figure: ) -> go.Figure:
"""Returns histogram with VaR and CVaR lines at 5% and 1% levels.""" """Returns histogram with VaR and CVaR lines at 5% and 1% levels."""
with theme_context(): with theme_context():
fig, ax_ = get_or_create_ax(ax, figsize) fig = new_figure(figsize, title)
rets = daily_returns_array(result) rets = daily_returns_array(result)
if len(rets) == 0: 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) return finalize(fig, show=show, save=save)
rets_pct = rets * 100 rets_pct = rets * 100
# Histogram # VaR/CVaR at 5% and 1%
n, bin_edges, patches = ax_.hist(
rets_pct, bins=bins, color=ACCENT, alpha=0.5, edgecolor="none",
)
# VaR/CVaR at 5%
var_5 = float(np.percentile(rets, 5)) var_5 = float(np.percentile(rets, 5))
cvar_5 = float(rets[rets <= var_5].mean()) if np.any(rets <= var_5) else var_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)) var_1 = float(np.percentile(rets, 1))
cvar_1 = float(rets[rets <= var_1].mean()) if np.any(rets <= var_1) else var_1 cvar_1 = float(rets[rets <= var_1].mean()) if np.any(rets <= var_1) else var_1
# Color tail bins counts, bin_edges = np.histogram(rets_pct, bins=bins)
for b, p in zip(bin_edges, patches): centers = (bin_edges[:-1] + bin_edges[1:]) / 2
if b < var_1 * 100: bw = bin_edges[1] - bin_edges[0]
p.set_facecolor(RED) colors = []
p.set_alpha(0.5) for left in bin_edges[:-1]:
elif b < var_5 * 100: if left < var_1 * 100:
p.set_facecolor(ORANGE) colors.append(_rgba(RED, 0.5))
p.set_alpha(0.4) elif left < var_5 * 100:
colors.append(_rgba(ORANGE, 0.4))
else:
colors.append(_rgba(ACCENT, 0.5))
# VaR lines fig.add_trace(go.Bar(
ax_.axvline(var_5 * 100, color=ORANGE, linewidth=0.8, x=centers, y=counts, width=bw, marker_color=colors,
label=f"VaR 5%: {format_pct(var_5)}") marker_line_width=0, showlegend=False,
ax_.axvline(cvar_5 * 100, color=ORANGE, linewidth=0.6, linestyle="--", alpha=0.5, hovertemplate="%{x:.2f}%: %{y}<extra></extra>",
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)}")
ax_.set_title(title) # VaR/CVaR lines with legend proxies
ax_.set_xlabel("Daily Return (%)") for val, color, dash, label in (
ax_.set_ylabel("Frequency") (var_5, ORANGE, None, f"VaR 5%: {format_pct(var_5)}"),
ax_.legend(loc="upper right", fontsize=8, framealpha=0.3) (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) return finalize(fig, show=show, save=save)
@@ -531,20 +599,20 @@ def rolling_sharpe(
result, result,
*, *,
windows: Optional[List[int]] = None, windows: Optional[List[int]] = None,
ax: Optional[Axes] = None, ax=None,
title: str = "Rolling Sharpe", title: str = "Rolling Sharpe",
trading_days_per_year: float = 365.25, trading_days_per_year: float = 365.25,
figsize: Tuple[float, float] = (14, 4), figsize: Tuple[float, float] = (14, 4),
show: bool = False, show: bool = False,
save: Optional[Union[str, Path]] = None, save: Optional[Union[str, Path]] = None,
) -> Figure: ) -> go.Figure:
"""Rolling annualized Sharpe ratio.""" """Rolling annualized Sharpe ratio."""
if windows is None: if windows is None:
windows = [126, 252] windows = [126, 252]
colors = [ACCENT, ACCENT_ALT, GREEN, RED] colors = [ACCENT, ACCENT_ALT, GREEN, RED]
with theme_context(): with theme_context():
fig, ax_ = get_or_create_ax(ax, figsize) fig = new_figure(figsize, title)
rets = daily_returns_array(result) rets = daily_returns_array(result)
for i, w in enumerate(windows): for i, w in enumerate(windows):
@@ -554,13 +622,15 @@ def rolling_sharpe(
rs = _rolling(rets, w, np.std) rs = _rolling(rets, w, np.std)
with np.errstate(divide="ignore", invalid="ignore"): with np.errstate(divide="ignore", invalid="ignore"):
sharpe = np.where(rs > 0, rm / rs * np.sqrt(trading_days_per_year), 0.0) sharpe = np.where(rs > 0, rm / rs * np.sqrt(trading_days_per_year), 0.0)
label = f"{w}d" fig.add_trace(go.Scatter(
ax_.plot(sharpe, color=colors[i % len(colors)], linewidth=1.0, label=label) y=sharpe, mode="lines", name=f"{w}d",
line=dict(color=colors[i % len(colors)], width=1.0),
hovertemplate="day %{x}: %{y:.2f}<extra>" + f"{w}d" + "</extra>",
))
ax_.axhline(0, color=DARK_GRAY, linewidth=0.5, linestyle="--") fig.add_hline(y=0, line_color=DARK_GRAY, line_width=0.5, line_dash="dash")
ax_.set_title(title) fig.update_yaxes(title_text="Sharpe")
ax_.set_ylabel("Sharpe") fig.update_layout(legend=dict(x=0.01, y=0.99))
ax_.legend(loc="upper left", framealpha=0.3)
return finalize(fig, show=show, save=save) return finalize(fig, show=show, save=save)
@@ -571,20 +641,20 @@ def rolling_volatility(
result, result,
*, *,
windows: Optional[List[int]] = None, windows: Optional[List[int]] = None,
ax: Optional[Axes] = None, ax=None,
title: str = "Rolling Volatility", title: str = "Rolling Volatility",
trading_days_per_year: float = 365.25, trading_days_per_year: float = 365.25,
figsize: Tuple[float, float] = (14, 4), figsize: Tuple[float, float] = (14, 4),
show: bool = False, show: bool = False,
save: Optional[Union[str, Path]] = None, save: Optional[Union[str, Path]] = None,
) -> Figure: ) -> go.Figure:
"""Rolling annualized volatility.""" """Rolling annualized volatility."""
if windows is None: if windows is None:
windows = [126, 252] windows = [126, 252]
colors = [ACCENT, ACCENT_ALT, GREEN, RED] colors = [ACCENT, ACCENT_ALT, GREEN, RED]
with theme_context(): with theme_context():
fig, ax_ = get_or_create_ax(ax, figsize) fig = new_figure(figsize, title)
rets = daily_returns_array(result) rets = daily_returns_array(result)
for i, w in enumerate(windows): for i, w in enumerate(windows):
@@ -592,12 +662,14 @@ def rolling_volatility(
continue continue
rs = _rolling(rets, w, np.std) rs = _rolling(rets, w, np.std)
vol = rs * np.sqrt(trading_days_per_year) 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%}<extra>" + f"{w}d" + "</extra>",
))
ax_.set_title(title) fig.update_yaxes(title_text="Volatility", tickformat=".0%")
ax_.set_ylabel("Volatility") fig.update_layout(legend=dict(x=0.01, y=0.99))
ax_.yaxis.set_major_formatter(mticker.PercentFormatter(xmax=1.0, decimals=0))
ax_.legend(loc="upper left", framealpha=0.3)
return finalize(fig, show=show, save=save) return finalize(fig, show=show, save=save)
+147 -300
View File
@@ -1,4 +1,4 @@
"""Candlestick chart with indicators and trade markers.""" """Candlestick chart with indicators and trade markers (plotly)."""
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
@@ -12,13 +12,9 @@ from manifoldbt.plot._theme import (
ACCENT_ALT, ACCENT_ALT,
BG_AXES, BG_AXES,
BG_FIGURE, BG_FIGURE,
BORDER,
DARK_GRAY,
GREEN, GREEN,
GRID_RGBA,
GRAY,
RED, RED,
WHITE, theme_context,
) )
from manifoldbt.plot._utils import finalize from manifoldbt.plot._utils import finalize
@@ -143,49 +139,7 @@ def _load_bars(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Candlestick drawing # Shared helpers
# ---------------------------------------------------------------------------
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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
INDICATOR_COLORS = [ACCENT, ACCENT_ALT, "#2dd4bf", "#f59e0b", "#f472b6"] 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): 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 manifest = result.manifest
cfg = manifest.get("config", {}) cfg = manifest.get("config", {})
tr = cfg.get("time_range", {}) 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 # Public API
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -507,22 +224,152 @@ def chart(
emas: List of EMA periods to overlay (e.g. [10, 25]). emas: List of EMA periods to overlay (e.g. [10, 25]).
smas: List of SMA periods to overlay. smas: List of SMA periods to overlay.
n_bars: Number of bars to display (last N). n_bars: Number of bars to display (last N).
interactive: Use plotly (True) or matplotlib (False). interactive: Kept for backward compatibility (plotly renders both
figsize: Figure size (matplotlib only). paths; ``save=".png"`` produces a static image via kaleido).
show: Display the chart (matplotlib only; plotly always shows). figsize: Figure size in inches, mapped to pixels.
save: Save path (.html for plotly, .png for matplotlib). show: Display the chart in the browser.
save: Save path (.html interactive, or .png/.svg via kaleido).
""" """
if interactive: _ = interactive # single plotly path
return _chart_interactive( import plotly.graph_objects as go
result, store, symbol_id, from plotly.subplots import make_subplots
emas=emas, smas=smas, n_bars=n_bars, save=save,
) bars, offset, bar_interval_s, filtered_trades = _prepare_chart_data(
return _chart_matplotlib( result, store, symbol_id, n_bars,
result, store, symbol_id,
emas=emas, smas=smas, n_bars=n_bars,
figsize=figsize, show=show, save=save,
) )
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: def _bar_interval_to_seconds(bi: dict) -> int:
"""Convert manifest bar_interval dict to seconds.""" """Convert manifest bar_interval dict to seconds."""
File diff suppressed because it is too large Load Diff
+63 -269
View File
@@ -1,35 +1,25 @@
"""Composite tearsheet — HTML strategy report.""" """Composite tearsheet — HTML strategy report with interactive plotly charts."""
from __future__ import annotations from __future__ import annotations
import base64
import io
import tempfile import tempfile
import webbrowser import webbrowser
from html import escape from html import escape
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Union 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 ( from manifoldbt.plot._theme import (
BG_AXES, BG_AXES,
BG_FIGURE, BG_FIGURE,
DARK_GRAY, DARK_GRAY,
GRAY, GRAY,
GREEN,
RED,
WHITE, WHITE,
theme_context, theme_context,
) )
from manifoldbt.plot._convert import equity_with_dates, positions_arrays from manifoldbt.plot._convert import equity_with_dates
from manifoldbt.plot._utils import auto_title, format_pct from manifoldbt.plot._utils import auto_title, chart_div, format_pct
from manifoldbt.plot.backtest import ( from manifoldbt.plot.backtest import (
annual_returns, annual_returns,
drawdown, drawdown,
equity,
monthly_returns, monthly_returns,
returns_histogram, returns_histogram,
rolling_sharpe, rolling_sharpe,
@@ -38,44 +28,6 @@ from manifoldbt.plot.backtest import (
var_chart, 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""" _CSS = f"""
* {{ margin: 0; padding: 0; box-sizing: border-box; }} * {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ body {{
@@ -127,12 +79,6 @@ body {{
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
}} }}
.charts-stack img {{
width: 100%;
display: block;
border-radius: 4px;
border: 1px solid #1e1e24;
}}
.section-label {{ .section-label {{
font-size: 10px; font-size: 10px;
font-weight: 700; font-weight: 700;
@@ -167,14 +113,11 @@ body {{
font-weight: 500; font-weight: 500;
white-space: nowrap; white-space: nowrap;
}} }}
.chart-row {{ .chart-cell {{
margin-bottom: 12px; background: {BG_FIGURE};
}}
.chart-row img {{
width: 100%;
display: block;
border-radius: 4px;
border: 1px solid #1e1e24; border: 1px solid #1e1e24;
border-radius: 4px;
overflow: hidden;
}} }}
.chart-grid {{ .chart-grid {{
display: grid; display: grid;
@@ -182,27 +125,15 @@ body {{
gap: 12px; gap: 12px;
margin-bottom: 12px; margin-bottom: 12px;
}} }}
.chart-grid img {{ .plotly-graph-div {{ width: 100% !important; }}
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;
}}
""" """
def _div(fig, height: int) -> str:
"""Wrap a plotly figure div in a bordered cell."""
return f'<div class="chart-cell">{chart_div(fig, height=height)}</div>'
def tearsheet( def tearsheet(
result, result,
*, *,
@@ -211,37 +142,40 @@ def tearsheet(
show: bool = False, show: bool = False,
save: Optional[Union[str, Path]] = None, save: Optional[Union[str, Path]] = None,
dpi: int = 150, dpi: int = 150,
plotlyjs: str = "cdn",
) -> str: ) -> 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``, Returns the HTML string. Opens in browser when ``show=True``,
writes to disk when ``save`` is given. 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 _ = 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") strategy_name = title or auto_title(result, "Backtest")
metrics = result.metrics if hasattr(result, "metrics") else {} metrics = result.metrics if hasattr(result, "metrics") else {}
ts = metrics.get("trade_stats", {}) 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_start = str(dates[0])[:10] if len(dates) > 0 else "?"
date_end = str(dates[-1])[:10] if len(dates) > 0 else "?" date_end = str(dates[-1])[:10] if len(dates) > 0 else "?"
# ── Generate charts as base64 PNGs ──────────────────────────── # ── Generate interactive chart divs ────────────────────────────
# Right column: summary chart (equity + benchmark + trades + margin) with theme_context():
chart_summary = _render_summary_b64(result, figsize=(12, 6), dpi=dpi) div_summary = _div(summary(result), height=520)
chart_dd = _render_chart(drawdown, result, figsize=(12, 2.5), dpi=dpi) div_dd = _div(drawdown(result), height=210)
# Left column chart div_annual = _div(annual_returns(result), height=330)
chart_annual = _render_chart(annual_returns, result, figsize=(5, 4), dpi=dpi) div_monthly = _div(monthly_returns(result), height=340)
# Full width grids (2 per row) div_hist = _div(returns_histogram(result), height=340)
chart_monthly = _render_chart(monthly_returns, result, figsize=(8, 4), dpi=dpi) div_sharpe = _div(rolling_sharpe(result), height=300)
chart_hist = _render_chart(returns_histogram, result, figsize=(8, 4), dpi=dpi) div_vol = _div(rolling_volatility(result), height=300)
chart_sharpe = _render_chart(rolling_sharpe, result, figsize=(8, 3.5), dpi=dpi) div_var = _div(var_chart(result), height=340)
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)
# ── Metrics ─────────────────────────────────────────────────── # ── Metrics ───────────────────────────────────────────────────
ret = metrics.get("total_return", 0) ret = metrics.get("total_return", 0)
_ = ret # used below in metrics_html
def _m(label, value, cls=""): def _m(label, value, cls=""):
esc_v = escape(str(value)) esc_v = escape(str(value))
@@ -278,6 +212,13 @@ def tearsheet(
+ _m("Fees", f"{ts.get('total_fees', 0):.2f}") + _m("Fees", f"{ts.get('total_fees', 0):.2f}")
) )
# ── plotly.js include ─────────────────────────────────────────
if plotlyjs == "inline":
import plotly.io as pio
plotly_js_tag = f"<script>{pio.get_plotlyjs()}</script>"
else:
plotly_js_tag = '<script src="https://cdn.plot.ly/plotly-2.35.2.min.js" charset="utf-8"></script>'
# ── Assemble HTML ───────────────────────────────────────────── # ── Assemble HTML ─────────────────────────────────────────────
html = f"""<!DOCTYPE html> html = f"""<!DOCTYPE html>
<html lang="en"> <html lang="en">
@@ -286,6 +227,7 @@ def tearsheet(
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{escape(strategy_name)} Tearsheet</title> <title>{escape(strategy_name)} Tearsheet</title>
<style>{_CSS}</style> <style>{_CSS}</style>
{plotly_js_tag}
</head> </head>
<body> <body>
<div class="container"> <div class="container">
@@ -298,26 +240,26 @@ def tearsheet(
<div class="main-grid"> <div class="main-grid">
<div> <div>
<div class="metrics-panel" style="margin-bottom:12px;">{metrics_html}</div> <div class="metrics-panel" style="margin-bottom:12px;">{metrics_html}</div>
<img src="data:image/png;base64,{chart_annual}" alt="Annual Returns" style="width:100%; border-radius:4px; border:1px solid #1e1e24;"> {div_annual}
</div> </div>
<div class="charts-stack"> <div class="charts-stack">
<img src="data:image/png;base64,{chart_summary}" alt="Equity + Benchmark + Trades + Margin"> {div_summary}
<img src="data:image/png;base64,{chart_dd}" alt="Drawdown"> {div_dd}
</div> </div>
</div> </div>
<div class="chart-grid"> <div class="chart-grid">
<img src="data:image/png;base64,{chart_monthly}" alt="Monthly Returns"> {div_monthly}
<img src="data:image/png;base64,{chart_hist}" alt="Returns Distribution"> {div_hist}
</div> </div>
<div class="chart-grid"> <div class="chart-grid">
<img src="data:image/png;base64,{chart_sharpe}" alt="Rolling Sharpe"> {div_sharpe}
<img src="data:image/png;base64,{chart_vol}" alt="Rolling Volatility"> {div_vol}
</div> </div>
<div class="chart-grid"> <div class="chart-grid">
<img src="data:image/png;base64,{chart_var}" alt="Value at Risk"> {div_var}
</div> </div>
</div> </div>
@@ -329,7 +271,6 @@ def tearsheet(
Path(save).write_text(html, encoding="utf-8") Path(save).write_text(html, encoding="utf-8")
if show: if show:
# Write the report HTML
if save is not None: if save is not None:
report_path = Path(save).resolve() report_path = Path(save).resolve()
else: else:
@@ -339,22 +280,7 @@ def tearsheet(
tmp.write(html) tmp.write(html)
tmp.close() tmp.close()
report_path = Path(tmp.name).resolve() report_path = Path(tmp.name).resolve()
webbrowser.open(report_path.as_uri())
# Create a launcher HTML that opens the report in a 1600x850 window
report_uri = report_path.as_uri()
launcher_html = f"""<!DOCTYPE html><html><head><script>
var w = window.open("{report_uri}", "_blank",
"width=1600,height=850,menubar=no,toolbar=no,location=no,status=no");
if (!w) window.location = "{report_uri}";
else window.close();
</script></head><body></body></html>"""
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())
return html return html
@@ -369,40 +295,41 @@ def research_report(
show: bool = False, show: bool = False,
save: Optional[Union[str, Path]] = None, save: Optional[Union[str, Path]] = None,
dpi: int = 150, dpi: int = 150,
) -> List[Figure]: ) -> List[Any]:
"""Research report — one figure per analysis.""" """Research report — one figure per analysis (plotly Figures)."""
from manifoldbt.plot.research import ( from manifoldbt.plot.research import (
heatmap_2d, heatmap_2d,
stability, stability,
walk_forward, walk_forward,
) )
_ = title
figs = [] figs = []
with theme_context(): with theme_context():
if sweep_result is not None: if sweep_result is not None:
fig, ax = plt.subplots(figsize=figsize) figs.append(heatmap_2d(sweep_result, figsize=figsize))
heatmap_2d(sweep_result, ax=ax)
figs.append(fig)
if wf_result is not None: if wf_result is not None:
fig, ax = plt.subplots(figsize=figsize) figs.append(walk_forward(wf_result, figsize=figsize))
walk_forward(wf_result, ax=ax)
figs.append(fig)
if stability_result is not None: if stability_result is not None:
fig, ax = plt.subplots(figsize=figsize) figs.append(stability(stability_result, figsize=figsize))
stability(stability_result, ax=ax)
figs.append(fig)
if not figs: if not figs:
raise ValueError("At least one result (sweep, wf, or stability) required.") raise ValueError("At least one result (sweep, wf, or stability) required.")
if save is not None: if save is not None:
path = Path(save) 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): for i, f in enumerate(figs):
out = path.parent / f"{stem}_{i + 1}{suffix}" 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: if show:
plt.show() for f in figs:
f.show()
return figs return figs
@@ -410,113 +337,6 @@ def research_report(
# ── Internal ───────────────────────────────────────────────────────────────── # ── 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): def _fmt_hold_time(seconds):
"""Format holding time in human-readable units.""" """Format holding time in human-readable units."""
if seconds <= 0: if seconds <= 0:
@@ -530,29 +350,3 @@ def _fmt_hold_time(seconds):
return f"{days:.0f}d" return f"{days:.0f}d"
hours = seconds / 3600 hours = seconds / 3600
return f"{hours:.0f}h" 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)
+9
View File
@@ -37,6 +37,15 @@ class Portfolio:
strategy: A Strategy instance. strategy: A Strategy instance.
weight: Fraction of total capital (0.0 to 1.0). 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({ self._strategies.append({
"name": strategy.name, "name": strategy.name,
"strategy_json": strategy.to_json(), "strategy_json": strategy.to_json(),
+7 -1
View File
@@ -194,7 +194,7 @@ class Strategy:
spec["range"] = None spec["range"] = None
params[param_name] = spec params[param_name] = spec
return { out = {
"name": self.name, "name": self.name,
"signals": { "signals": {
name: expr.to_json() for name, expr in self.signals.items() name: expr.to_json() for name, expr in self.signals.items()
@@ -206,6 +206,12 @@ class Strategy:
"description": self._description, "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: def to_json(self) -> str:
"""Serialize to a JSON string matching Rust ``StrategyDef``. """Serialize to a JSON string matching Rust ``StrategyDef``.
+42 -42
View File
@@ -93,62 +93,62 @@ class SweepResult:
return best_result return best_result
def plot_metric(self, metric: str = "sharpe", **kwargs: Any) -> Any: 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 2-parameter sweeps, delegates to ``bt.plot.heatmap_2d``.
For 1-parameter sweeps, produces a bar chart. For 1-parameter sweeps, produces a bar chart.
Args: Args:
metric: Metric to visualize. metric: Metric to visualize.
**kwargs: Forwarded to the plot function. **kwargs: ``figsize``, ``show``, ``save`` forwarded to the plot.
""" """
import matplotlib.pyplot as plt from manifoldbt.plot._theme import ACCENT, theme_context
import numpy as np from manifoldbt.plot._utils import finalize, new_figure
df = self.to_df(backend="pandas") df = self.to_df(backend="pandas")
param_cols = [c for c in df.columns if c.startswith("param_")] 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: if len(param_cols) == 2:
# 2D heatmap from manifoldbt.plot.research import heatmap_2d
x_col, y_col = param_cols[0], param_cols[1] x_col, y_col = param_cols[0], param_cols[1]
pivot = df.pivot_table(index=y_col, columns=x_col, values=metric) pivot = df.pivot_table(index=y_col, columns=x_col, values=metric)
fig, ax = plt.subplots(figsize=kwargs.pop("figsize", (10, 6))) sweep_result = {
im = ax.imshow(pivot.values, aspect="auto", cmap=kwargs.pop("cmap", "RdYlGn")) "metric_grid": pivot.values.tolist(),
ax.set_xticks(range(len(pivot.columns))) "x_values": list(pivot.columns),
ax.set_xticklabels(pivot.columns, rotation=45) "y_values": list(pivot.index),
ax.set_yticks(range(len(pivot.index))) "x_param": x_col.replace("param_", ""),
ax.set_yticklabels(pivot.index) "y_param": y_col.replace("param_", ""),
ax.set_xlabel(x_col.replace("param_", "")) "metric": metric,
ax.set_ylabel(y_col.replace("param_", "")) }
ax.set_title(f"{metric} heatmap") return heatmap_2d(sweep_result, show=show, save=save, **kwargs)
plt.colorbar(im, ax=ax, label=metric)
plt.tight_layout() import plotly.graph_objects as go
if kwargs.get("show", True):
plt.show() with theme_context():
return fig if len(param_cols) == 1:
elif len(param_cols) == 1: p_col = param_cols[0]
# 1D bar chart fig = new_figure(kwargs.pop("figsize", (10, 5)),
p_col = param_cols[0] f"{metric} by {p_col.replace('param_', '')}")
fig, ax = plt.subplots(figsize=kwargs.pop("figsize", (10, 5))) fig.add_trace(go.Bar(
ax.bar(range(len(df)), df[metric].values, tick_label=[str(v) for v in df[p_col].values]) x=[str(v) for v in df[p_col].values], y=df[metric].values,
ax.set_xlabel(p_col.replace("param_", "")) marker_color=ACCENT, marker_line_width=0,
ax.set_ylabel(metric) ))
ax.set_title(f"{metric} by {p_col.replace('param_', '')}") fig.update_xaxes(title_text=p_col.replace("param_", ""),
plt.tight_layout() type="category", showspikes=False)
if kwargs.get("show", True): else:
plt.show() fig = new_figure(kwargs.pop("figsize", (10, 5)),
return fig f"{metric} across sweep")
else: fig.add_trace(go.Bar(
# Fallback: simple bar x=list(range(len(df))), y=df[metric].values,
fig, ax = plt.subplots(figsize=kwargs.pop("figsize", (10, 5))) marker_color=ACCENT, marker_line_width=0,
ax.bar(range(len(df)), df[metric].values) ))
ax.set_xlabel("run") fig.update_xaxes(title_text="run", showspikes=False)
ax.set_ylabel(metric) fig.update_yaxes(title_text=metric)
ax.set_title(f"{metric} across sweep") fig.update_layout(hovermode="closest")
plt.tight_layout() return finalize(fig, show=show, save=save)
if kwargs.get("show", True):
plt.show()
return fig
def __repr__(self) -> str: def __repr__(self) -> str:
params = ", ".join(f"{k}={len(v)} vals" for k, v in self._param_grid.items()) params = ", ".join(f"{k}={len(v)} vals" for k, v in self._param_grid.items())
+137
View File
@@ -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)