release: v0.12.0

This commit is contained in:
github-actions[bot]
2026-07-15 00:01:41 +00:00
parent 285e858649
commit aece0242af
16 changed files with 1540 additions and 1338 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "manifoldbt"
version = "0.11.0"
version = "0.12.0"
description = "Rust-powered backtesting engine for quantitative research"
requires-python = ">=3.9"
license = { file = "LICENSE" }
+29 -20
View File
@@ -310,7 +310,12 @@ _PREPARED_CFG_CACHE_MAX = 256
def _prepared_config_json(config: BacktestConfig, strategy, store: DataStore) -> str:
"""Content-memoised equivalent of ``_prepare_config(...).to_json()``."""
"""Content-memoised equivalent of ``_prepare_config(...).to_json()``.
The prepared config no longer depends on the strategy (orders travel in the
strategy JSON now), so the memo key is just the config content plus the
metadata DB; the ``strategy`` argument is accepted for call-site symmetry.
"""
try:
meta_db = store.metadata_db()
except Exception:
@@ -318,10 +323,8 @@ def _prepared_config_json(config: BacktestConfig, strategy, store: DataStore) ->
if meta_db is None:
return _prepare_config(config, strategy, store).to_json()
orders = getattr(strategy, "_orders", None) if strategy is not None else None
try:
orders_key = json.dumps(orders, sort_keys=True, default=str) if orders else ""
key = (config.to_json(), orders_key, meta_db)
key = (config.to_json(), meta_db)
except (TypeError, ValueError):
# Unserialisable config content — skip memoisation, never fail.
return _prepare_config(config, strategy, store).to_json()
@@ -397,13 +400,11 @@ def _prepare_config(config: BacktestConfig, strategy, store: DataStore) -> Backt
resolved_sv[int(store.resolve_symbol(key))] = venue
fees.symbol_venue = resolved_sv
# Merge orders from strategy into execution config
if strategy and hasattr(strategy, '_orders') and strategy._orders:
if cfg.execution.orders is None:
cfg.execution.orders = OrderConfig()
for key, val in strategy._orders.items():
setattr(cfg.execution.orders, key, val)
# Per-strategy SL/TP/trailing orders are NOT merged into the config anymore:
# they travel inside the strategy JSON (Strategy.to_json -> StrategyDef.orders)
# so the engine applies them per-strategy. This lets one batch/sweep call run
# strategies carrying different brackets over a single data load. A bracket
# set directly on config.execution.orders still applies as the fallback.
return cfg
@@ -789,6 +790,11 @@ def run_batch(
Loads bars once, aligns timestamps once, then evaluates each strategy
on a separate rayon thread. Much faster than calling ``run()`` in a loop.
Per-strategy ``stop_loss``/``take_profit``/``trailing_stop`` are honored:
each strategy's orders travel inside its JSON and the engine applies them
per-strategy, so a batch of strategies with DIFFERENT brackets still runs
over a single data load.
Args:
strategies: List of Strategy definitions.
config: Shared backtest configuration (same universe/time range).
@@ -800,13 +806,12 @@ def run_batch(
"""
_require_pro_over_combos(len(strategies), "Batch backtesting")
try:
config = _prepare_config(config, None, store)
config = _cap_output_resolution(config)
store = _resolve_store(config, store)
strategy_jsons = [strat.to_json() for strat in strategies]
cfg_json = _prepared_config_json(config, None, store)
raw_results = _run_batch_native(
strategy_jsons,
config.to_json(),
[strat.to_json() for strat in strategies],
cfg_json,
store,
max_parallelism,
)
@@ -828,6 +833,11 @@ def run_batch_lite(
position traces, and Arrow output construction. Ideal for parameter sweeps
where you only need metrics to select the best variant.
Per-strategy ``stop_loss``/``take_profit``/``trailing_stop`` are honored:
each strategy's orders travel inside its JSON and the engine applies them
per-strategy, so a batch of strategies with DIFFERENT brackets still runs
over a single data load.
Args:
strategies: List of Strategy definitions.
config: Shared backtest configuration (same universe/time range).
@@ -839,13 +849,12 @@ def run_batch_lite(
"""
_require_pro_over_combos(len(strategies), "Batch backtesting")
try:
config = _prepare_config(config, None, store)
config = _cap_output_resolution(config)
store = _resolve_store(config, store)
strategy_jsons = [strat.to_json() for strat in strategies]
cfg_json = _prepared_config_json(config, None, store)
return _run_batch_lite_native(
strategy_jsons,
config.to_json(),
[strat.to_json() for strat in strategies],
cfg_json,
store,
max_parallelism,
)
@@ -1372,7 +1381,7 @@ __all__ = [
"__version__",
# Indicators (submodule)
"indicators",
# Plotting (lazy, requires matplotlib)
# Plotting (lazy, requires plotly)
"plot",
# Diagnostics (lazy)
"diagnostics",
+14 -3
View File
@@ -1,4 +1,4 @@
"""Plotting module for manifoldbt (requires matplotlib).
"""Plotting module for manifoldbt (requires plotly).
Install with::
@@ -11,12 +11,18 @@ Quick start::
result = bt.run(strategy, config, store)
bt.plot.tearsheet(result) # full-page dashboard
bt.plot.equity(result, show=True) # single chart
Every chart is interactive (crosshair, hover, zoom). ``show=True`` opens it
in a native window (``pip install manifoldbt[window]``; falls back to a
browser tab, which you can also force with ``show="browser"``).
``save=".html"`` writes a responsive interactive page. Static ``save=".png"``
is optional and needs ``pip install manifoldbt[png]`` (pulls a headless Chromium).
"""
try:
import matplotlib # noqa: F401
import plotly # noqa: F401
except ImportError:
raise ImportError(
"matplotlib is required for the plotting module. "
"plotly is required for the plotting module. "
"Install it with: pip install manifoldbt[plot]"
) from None
@@ -51,6 +57,9 @@ from manifoldbt.plot.research import (
# Composite layouts
from manifoldbt.plot.tearsheet import research_report, tearsheet
# Window display (multi-window, matplotlib-style)
from manifoldbt.plot._window import show
# Theme
from manifoldbt.plot._theme import THEME, apply_theme
@@ -78,6 +87,8 @@ __all__ = [
# Composites
"tearsheet",
"research_report",
# Window display
"show",
# Theme
"THEME",
"apply_theme",
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 contextlib import contextmanager
from typing import Any, Dict
# ---------------------------------------------------------------------------
# Color palette — neutral dark, no decorative colors
@@ -20,94 +19,106 @@ BG_FIGURE = "#0c0c0f"
BG_AXES = "#111116"
BORDER = "#1e1e24"
GRID_RGBA = (1.0, 1.0, 1.0, 0.04)
GRID_COLOR = "rgba(255,255,255,0.045)"
SERIES_COLORS = [ACCENT, ACCENT_ALT, "#2dd4bf", ORANGE, RED, GREEN, "#f472b6", WHITE]
FONT_FAMILY = "Inter, system-ui, Segoe UI, Arial, sans-serif"
MONO_FAMILY = "SF Mono, Fira Code, Cascadia Code, Consolas, monospace"
# ---------------------------------------------------------------------------
# rcParams
# Colorscales (plotly format) — same stops as the old matplotlib colormaps
# ---------------------------------------------------------------------------
THEME: Dict[str, Any] = {
"figure.facecolor": BG_FIGURE,
"figure.edgecolor": BG_FIGURE,
"figure.dpi": 120,
"axes.facecolor": BG_AXES,
"axes.edgecolor": BORDER,
"axes.labelcolor": GRAY,
"axes.titlecolor": WHITE,
"axes.titlesize": 11,
"axes.titleweight": "medium",
"axes.titlepad": 12,
"axes.labelsize": 9,
"axes.labelpad": 8,
"axes.grid": True,
"grid.color": GRID_RGBA,
"grid.linewidth": 0.5,
"grid.linestyle": "-",
"xtick.color": DARK_GRAY,
"ytick.color": DARK_GRAY,
"xtick.labelsize": 8,
"ytick.labelsize": 8,
"text.color": WHITE,
"font.family": "monospace",
"font.size": 9,
"legend.facecolor": BG_AXES,
"legend.edgecolor": BORDER,
"legend.fontsize": 8,
"legend.labelcolor": GRAY,
"lines.linewidth": 1.3,
"lines.antialiased": True,
"savefig.facecolor": BG_FIGURE,
"savefig.edgecolor": BG_FIGURE,
"savefig.bbox": "tight",
"savefig.dpi": 150,
CS_DIVERGING = [[0.0, "#b91c1c"], [0.5, "#262626"], [1.0, "#15803d"]]
CS_SEQUENTIAL = [[0.0, "#b91c1c"], [0.5, "#d97706"], [1.0, "#15803d"]]
CS_CORRELATION = [[0.0, "#b91c1c"], [0.5, "#262626"], [1.0, "#1d4ed8"]]
# ---------------------------------------------------------------------------
# Layout defaults (also exported as THEME for backward compatibility)
# ---------------------------------------------------------------------------
THEME: dict = {
"paper_bgcolor": BG_FIGURE,
"plot_bgcolor": BG_AXES,
"font": {"family": FONT_FAMILY, "color": GRAY, "size": 12},
"title": {"font": {"color": WHITE, "size": 15}, "x": 0.01, "xanchor": "left"},
"margin": {"l": 64, "r": 24, "t": 48, "b": 36},
"hovermode": "x",
"colorway": SERIES_COLORS,
"hoverlabel": {
"bgcolor": "#1a1a20",
"bordercolor": BORDER,
"font": {"family": MONO_FAMILY, "color": WHITE, "size": 12},
},
"legend": {
"bgcolor": "rgba(17,17,22,0.6)",
"bordercolor": BORDER,
"borderwidth": 1,
"font": {"color": GRAY, "size": 11},
},
}
_AXIS = {
"color": GRAY,
"gridcolor": GRID_COLOR,
"linecolor": BORDER,
"zerolinecolor": GRID_COLOR,
"ticks": "",
"showspikes": True,
"spikemode": "across",
"spikethickness": 1,
"spikedash": "dot",
"spikecolor": GRAY,
}
_SCENE_AXIS = {
"backgroundcolor": BG_AXES,
"gridcolor": "rgba(255,255,255,0.08)",
"color": GRAY,
"showbackground": True,
"zerolinecolor": "rgba(255,255,255,0.08)",
}
def _build_theme() -> Dict[str, Any]:
"""Finalize THEME dict with cycler."""
import matplotlib.pyplot as plt
theme = dict(THEME)
theme["axes.prop_cycle"] = plt.cycler(color=SERIES_COLORS)
return theme
def _build_template():
"""Build the manifoldbt plotly template."""
import plotly.graph_objects as go
# ---------------------------------------------------------------------------
# Colormaps
# ---------------------------------------------------------------------------
def _register_colormaps() -> None:
"""Register custom colormaps (idempotent)."""
from matplotlib.colors import LinearSegmentedColormap
import matplotlib as mpl
_cmaps = {
"bt_diverging": [(0.0, "#b91c1c"), (0.5, "#262626"), (1.0, "#15803d")],
"bt_sequential": [(0.0, "#b91c1c"), (0.5, "#d97706"), (1.0, "#15803d")],
"bt_correlation": [(0.0, "#b91c1c"), (0.5, "#262626"), (1.0, "#1d4ed8")],
layout = dict(THEME)
layout["xaxis"] = dict(_AXIS)
layout["yaxis"] = dict(_AXIS)
layout["scene"] = {
"xaxis": dict(_SCENE_AXIS),
"yaxis": dict(_SCENE_AXIS),
"zaxis": dict(_SCENE_AXIS),
"bgcolor": BG_FIGURE,
}
for name, stops in _cmaps.items():
try:
mpl.colormaps.get_cmap(name)
except ValueError:
positions = [s[0] for s in stops]
colors = [s[1] for s in stops]
cmap = LinearSegmentedColormap.from_list(name, list(zip(positions, colors)), N=256)
mpl.colormaps.register(cmap, name=name)
return go.layout.Template(layout=layout)
_REGISTERED = False
# ---------------------------------------------------------------------------
# Public
# ---------------------------------------------------------------------------
def apply_theme() -> None:
"""Apply the dark theme globally."""
import matplotlib.pyplot as plt
_register_colormaps()
plt.rcParams.update(_build_theme())
"""Register the manifoldbt template and set it as plotly's default."""
global _REGISTERED
import plotly.io as pio
pio.templates["manifoldbt"] = _build_template()
pio.templates.default = "manifoldbt"
_REGISTERED = True
def _ensure_theme() -> None:
if not _REGISTERED:
apply_theme()
@contextmanager
def theme_context():
"""Context manager: apply theme temporarily."""
import matplotlib.pyplot as plt
_register_colormaps()
with plt.rc_context(_build_theme()):
yield
"""Backward-compatible context manager: ensures the theme is registered.
With plotly the theme is a global template rather than a temporary
rc-context, so this simply guarantees registration.
"""
_ensure_theme()
yield
+101 -28
View File
@@ -1,25 +1,32 @@
"""Shared plotting utilities."""
"""Shared plotting utilities (plotly)."""
from __future__ import annotations
from pathlib import Path
from typing import Optional, Tuple, Union
import matplotlib.pyplot as plt
from matplotlib.axes import Axes
from matplotlib.figure import Figure
from manifoldbt.plot._theme import WHITE, _ensure_theme
from manifoldbt.plot._theme import theme_context
_RESPONSIVE_CSS = (
"<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(
ax: Optional[Axes] = None,
def new_figure(
figsize: Tuple[float, float] = (12, 4),
) -> Tuple[Figure, Axes]:
"""Return (fig, ax). Creates a new themed figure if *ax* is None."""
if ax is not None:
return ax.figure, ax
fig, new_ax = plt.subplots(figsize=figsize)
return fig, new_ax
title: Optional[str] = None,
):
"""Return a themed plotly Figure sized from a matplotlib-style figsize."""
import plotly.graph_objects as go
_ensure_theme()
fig = go.Figure()
fig.update_layout(width=int(figsize[0] * 80), height=int(figsize[1] * 80))
if title:
fig.update_layout(title_text=title)
return fig
def format_pct(value: float, decimals: int = 1) -> str:
@@ -29,32 +36,98 @@ def format_pct(value: float, decimals: int = 1) -> str:
def format_currency(value: float, currency: str = "USD") -> str:
"""Format a number as currency."""
symbol = {"USD": "$", "EUR": "\u20ac", "GBP": "\u00a3"}.get(currency, "")
symbol = {"USD": "$", "EUR": "", "GBP": "£"}.get(currency, "")
return f"{symbol}{value:,.2f}"
def finalize(
fig: Figure,
fig,
*,
show: bool = False,
show: "bool | str" = False,
save: Optional[Union[str, Path]] = None,
dpi: int = 150,
) -> Figure:
"""Optionally save and/or display the figure, then return it."""
import warnings
with warnings.catch_warnings():
warnings.simplefilter("ignore", UserWarning)
try:
fig.tight_layout()
except Exception:
pass # Skip when axes are incompatible (e.g. inside GridSpec)
window_size: Optional[Tuple[int, int]] = None,
) -> "object":
"""Optionally save and/or display the figure, then return it.
``save`` routes on extension: ``.html`` writes a responsive interactive
page; image extensions (.png/.svg/.pdf/...) go through kaleido.
``show``: ``True`` (or ``"window"``) opens a native window (needs pywebview,
else falls back to a browser tab); ``"browser"`` forces a browser tab.
``dpi`` is kept for backward compatibility and maps to an export scale.
"""
if save is not None:
fig.savefig(str(save), dpi=dpi, bbox_inches="tight")
if show:
plt.show()
path = Path(save)
ext = path.suffix.lower()
if ext in _IMAGE_EXTS:
try:
scale = max(1.0, dpi / 96.0)
fig.write_image(str(path), scale=scale)
except Exception as exc: # kaleido missing or export failure
raise RuntimeError(
f"Static image export to {ext} is optional and needs kaleido. "
"Install it with: pip install manifoldbt[png] "
"(the default is the interactive chart — save to .html)"
) from exc
else:
write_responsive_html(fig, path)
if show == "browser":
fig.show()
elif show: # True or "window" -> native window (browser tab fallback)
from manifoldbt.plot._window import open_in_window
title = "Chart"
try:
t = fig.layout.title.text
if t:
title = t.split("<br>")[0].strip() or title
except Exception:
pass
open_in_window(fig, title=title, size=window_size or (1280, 720))
return fig
def write_responsive_html(fig, path: Union[str, Path]) -> None:
"""Write a self-adjusting full-window HTML page for *fig*.
Strips any fixed width/height so the plot fills (and resizes with) the
window; a clean <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:
"""Build a title from result manifest strategy_name, or use fallback."""
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 pathlib import Path
from typing import List, Optional, Tuple, Union
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import matplotlib.ticker as mticker
from matplotlib.axes import Axes
from matplotlib.figure import Figure
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from manifoldbt.plot._theme import (
ACCENT,
@@ -29,7 +26,42 @@ from manifoldbt.plot._convert import (
trades_arrays,
_ts_to_int64,
)
from manifoldbt.plot._utils import finalize, format_pct, get_or_create_ax
from manifoldbt.plot._decimate import maybe_decimate
from manifoldbt.plot._utils import finalize, format_pct, new_figure
def _rgba(hex_color: str, alpha: float) -> str:
"""'#rrggbb' -> 'rgba(r,g,b,a)'."""
h = hex_color.lstrip("#")
r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
return f"rgba({r},{g},{b},{alpha})"
def _area_traces(x, y, baseline: float, color: str, *, width: float = 1.5,
name: Optional[str] = None, hovertemplate: Optional[str] = None):
"""Line + fill-to-baseline traces, with a vertical gradient when supported."""
base = go.Scatter(
x=x, y=np.full(len(x), baseline), mode="lines",
line=dict(width=0), hoverinfo="skip", showlegend=False,
)
kwargs = dict(
x=x, y=y, mode="lines",
line=dict(color=color, width=width),
fill="tonexty",
name=name, showlegend=name is not None,
hovertemplate=hovertemplate,
)
try:
line_trace = go.Scatter(
fillgradient=dict(
type="vertical",
colorscale=[[0.0, _rgba(color, 0.0)], [1.0, _rgba(color, 0.22)]],
),
**kwargs,
)
except (ValueError, TypeError): # plotly too old for fillgradient
line_trace = go.Scatter(fillcolor=_rgba(color, 0.07), **kwargs)
return [base, line_trace]
# ── Summary (the essential chart) ────────────────────────────────────────────
@@ -41,29 +73,44 @@ def summary(
figsize: Tuple[float, float] = (14, 8),
show: bool = False,
save: Optional[Union[str, Path]] = None,
) -> Figure:
) -> go.Figure:
"""The essential chart: TWR equity + buy-and-hold benchmark, trade activity.
Top panel: TWR-normalized equity curve vs buy-and-hold (close price).
Bottom panel: daily trade count as a bar chart.
Metrics displayed in a clean header line.
Middle panel: daily trade count as a bar chart.
Bottom panel: used margin percentage.
Metrics displayed in the title line.
"""
with theme_context():
fig, (ax_eq, ax_trades, ax_margin) = plt.subplots(
3, 1, figsize=figsize, height_ratios=[3, 1, 1],
sharex=True, gridspec_kw={"hspace": 0.25},
fig = make_subplots(
rows=3, cols=1, shared_xaxes=True, vertical_spacing=0.06,
row_heights=[0.6, 0.2, 0.2],
)
dates, eq_vals = equity_with_dates(result)
metrics = result.metrics if hasattr(result, "metrics") else {}
# ── TWR equity (normalized to 100) ────────────────────────
twr = eq_vals / eq_vals[0] * 100
ax_eq.plot(dates, twr, color=ACCENT, linewidth=0.8, label="Strategy")
ax_eq.fill_between(dates, twr, 100, where=(twr >= 100),
color=GREEN, alpha=0.04, interpolate=True)
ax_eq.fill_between(dates, twr, 100, where=(twr < 100),
color=RED, alpha=0.04, interpolate=True)
twr_full = eq_vals / eq_vals[0] * 100
d_dates, twr = maybe_decimate(dates, twr_full)
fig.add_trace(go.Scatter(
x=d_dates, y=twr, mode="lines", name="Strategy",
line=dict(color=ACCENT, width=1.0),
hovertemplate="%{x|%d %b %Y} %{y:.1f}<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 ─────────────
positions = result.positions
@@ -78,7 +125,7 @@ def summary(
benchmark_raw = close_vals / close_vals[0] * 100
# Vol-adjusted benchmark: scale to same volatility as strategy
strat_rets = np.diff(twr) / twr[:-1]
strat_rets = np.diff(twr_full) / twr_full[:-1]
bench_rets = np.diff(benchmark_raw) / benchmark_raw[:-1]
strat_vol = np.nanstd(strat_rets)
bench_vol = np.nanstd(bench_rets)
@@ -90,16 +137,19 @@ def summary(
else:
benchmark = benchmark_raw
ax_eq.plot(dates, benchmark, color=GRAY, linewidth=1.0,
label="Buy & Hold (vol-adj)", alpha=0.7)
b_dates, b_vals = maybe_decimate(dates[: len(benchmark)], benchmark)
fig.add_trace(go.Scatter(
x=b_dates, y=b_vals, mode="lines", name="Buy & Hold (vol-adj)",
line=dict(color=GRAY, width=1.0), opacity=0.7,
hovertemplate="%{x|%d %b %Y} %{y:.1f}<extra>Buy & Hold</extra>",
), row=1, col=1)
ax_eq.axhline(100, color=DARK_GRAY, linewidth=0.4)
# Ensure y-axis zooms to strategy range with some padding
twr_min, twr_max = float(np.nanmin(twr)), float(np.nanmax(twr))
fig.add_hline(y=100, line_color=DARK_GRAY, line_width=0.4, row=1, col=1)
twr_min, twr_max = float(np.nanmin(twr_full)), float(np.nanmax(twr_full))
twr_range = max(twr_max - twr_min, 0.1)
ax_eq.set_ylim(twr_min - twr_range * 0.15, twr_max + twr_range * 0.15)
ax_eq.set_ylabel("TWR (base 100)", fontsize=9)
ax_eq.legend(loc="upper left", framealpha=0.3, fontsize=8)
fig.update_yaxes(title_text="TWR (base 100)",
range=[twr_min - twr_range * 0.15, twr_max + twr_range * 0.15],
row=1, col=1)
# Header metrics
ret = metrics.get("total_return", 0)
@@ -112,10 +162,9 @@ def summary(
f" Max DD {mdd * 100:.1f}%"
f" Trades {n_trades:,}"
)
ax_eq.set_title(title, fontsize=10, loc="left", pad=10)
fig.update_layout(title_text=title)
# ── Adaptive smoothing window ──────────────────────────────
# Scale window: min(7d, max(1d, 5% of total period))
smooth_label = ""
if len(dates) >= 2:
bar_ns = int(dates[1]) - int(dates[0])
@@ -126,22 +175,22 @@ def summary(
smooth_window = min(smooth_window, len(dates))
smooth_days = round(target_ns / day_ns)
smooth_label = f" ({smooth_days}d)" if smooth_days >= 1 else ""
else:
smooth_window = 1
# ── Trade activity (daily trade count) ─────────────────────
try:
ta = trades_arrays(result)
trade_ts = ta.get("execution_timestamp", np.array([], dtype="datetime64[ns]"))
if len(trade_ts) > 0 and len(dates) >= 2:
# Bucket trades into calendar days
trade_days = trade_ts.astype("datetime64[D]")
unique_days, day_counts = np.unique(trade_days, return_counts=True)
day_dates = unique_days.astype("datetime64[ns]")
ax_trades.bar(day_dates, day_counts,
width=np.timedelta64(1, "D"),
color=ACCENT_ALT, alpha=0.4, edgecolor="none")
fig.add_trace(go.Bar(
x=day_dates, y=day_counts, name="Trades/day",
marker_color=_rgba(ACCENT_ALT, 0.4), marker_line_width=0,
showlegend=False,
hovertemplate="%{x|%d %b %Y} %{y} trades<extra></extra>",
), row=2, col=1)
# Rolling 7-day average overlay
eq_days = dates.astype("datetime64[D]")
@@ -154,16 +203,14 @@ def summary(
if win > 1:
kernel = np.ones(win) / win
smoothed = np.convolve(daily_on_grid, kernel, mode="same")
ax_trades.plot(unique_eq_days.astype("datetime64[ns]"), smoothed,
color=ACCENT_ALT, linewidth=1.0, alpha=0.8)
else:
ax_trades.text(0.5, 0.5, "No trade data", transform=ax_trades.transAxes,
ha="center", va="center", color=DARK_GRAY, fontsize=9)
fig.add_trace(go.Scatter(
x=unique_eq_days.astype("datetime64[ns]"), y=smoothed,
mode="lines", line=dict(color=ACCENT_ALT, width=1.0),
opacity=0.8, showlegend=False, hoverinfo="skip",
), row=2, col=1)
except Exception:
ax_trades.text(0.5, 0.5, "No trade data", transform=ax_trades.transAxes,
ha="center", va="center", color=DARK_GRAY, fontsize=9)
ax_trades.set_ylabel("Trades/day", fontsize=8)
pass
fig.update_yaxes(title_text="Trades/day", row=2, col=1)
# ── Used margin % (daily) ──────────────────────────────
try:
@@ -183,29 +230,27 @@ def summary(
# Resample to daily (end-of-day snapshot)
days = used_dates.astype("datetime64[D]")
unique_days, _ = np.unique(days, return_index=True)
# Use last value per day (not first) for end-of-day margin
day_last = np.searchsorted(days, unique_days, side="right") - 1
daily_used = used[day_last]
daily_dates = unique_days.astype("datetime64[ns]")
ax_margin.fill_between(daily_dates, 0, daily_used,
color=GREEN, alpha=0.10, edgecolor="none")
ax_margin.plot(daily_dates, daily_used,
color=GREEN, linewidth=0.7, alpha=0.8)
ax_margin.axhline(0, color=DARK_GRAY, linewidth=0.4)
fig.add_trace(go.Scatter(
x=daily_dates, y=daily_used, mode="lines",
line=dict(color=GREEN, width=0.7), opacity=0.8,
fill="tozeroy", fillcolor=_rgba(GREEN, 0.10),
showlegend=False,
hovertemplate="%{x|%d %b %Y} %{y:.1f}%<extra>Margin</extra>",
), row=3, col=1)
except Exception:
ax_margin.text(
0.5, 0.5, "No position data",
transform=ax_margin.transAxes,
ha="center", va="center", color=DARK_GRAY, fontsize=9,
)
ax_margin.set_ylabel(f"Margin %{smooth_label}", fontsize=8)
ax_margin.xaxis.set_major_formatter(mdates.DateFormatter("%b %Y"))
ax_margin.xaxis.set_major_locator(mdates.AutoDateLocator())
fig.align_ylabels([ax_eq, ax_trades, ax_margin])
fig.autofmt_xdate(rotation=0, ha="center")
pass
fig.update_yaxes(title_text=f"Margin %{smooth_label}", row=3, col=1)
fig.update_layout(
width=int(figsize[0] * 80), height=int(figsize[1] * 80),
legend=dict(orientation="h", yanchor="bottom", y=1.02,
xanchor="right", x=1),
bargap=0.0,
)
return finalize(fig, show=show, save=save)
@@ -215,24 +260,27 @@ def summary(
def equity(
result,
*,
ax: Optional[Axes] = None,
ax=None,
color: str = ACCENT,
title: str = "Equity Curve",
figsize: Tuple[float, float] = (14, 5),
show: bool = False,
save: Optional[Union[str, Path]] = None,
) -> Figure:
"""Plot the portfolio equity curve over time."""
) -> go.Figure:
"""Plot the portfolio equity curve over time.
``ax`` is accepted for backward compatibility and ignored (plotly backend).
"""
with theme_context():
fig, ax_ = get_or_create_ax(ax, figsize)
fig = new_figure(figsize, title)
dates, values = equity_with_dates(result)
ax_.plot(dates, values, color=color, linewidth=1.3)
ax_.fill_between(dates, values, values.min(), color=color, alpha=0.05)
ax_.set_title(title)
ax_.set_ylabel("Equity", fontsize=9)
ax_.xaxis.set_major_formatter(mdates.DateFormatter("%b %Y"))
ax_.xaxis.set_major_locator(mdates.AutoDateLocator())
fig.autofmt_xdate(rotation=0, ha="center")
dates, values = maybe_decimate(dates, values)
fig.add_traces(_area_traces(
dates, values, float(values.min()), color, width=1.5,
hovertemplate="%{x|%d %b %Y} $%{y:,.0f}<extra></extra>",
))
fig.update_yaxes(title_text="Equity")
fig.update_xaxes(tickformat="%b %Y")
return finalize(fig, show=show, save=save)
@@ -243,7 +291,7 @@ def benchmark_equity(
result,
benchmark: np.ndarray,
*,
ax: Optional[Axes] = None,
ax=None,
strategy_color: str = ACCENT,
benchmark_color: str = DARK_GRAY,
normalize: bool = True,
@@ -252,10 +300,10 @@ def benchmark_equity(
figsize: Tuple[float, float] = (14, 5),
show: bool = False,
save: Optional[Union[str, Path]] = None,
) -> Figure:
) -> go.Figure:
"""Overlay strategy equity and a benchmark, both normalized to 100."""
with theme_context():
fig, ax_ = get_or_create_ax(ax, figsize)
fig = new_figure(figsize, title)
dates, strat_eq = equity_with_dates(result)
bench = np.asarray(benchmark, dtype=np.float64)
n = min(len(strat_eq), len(bench))
@@ -265,13 +313,19 @@ def benchmark_equity(
strat_eq = strat_eq / strat_eq[0] * 100
bench = bench / bench[0] * 100
ax_.plot(dates, strat_eq, color=strategy_color, linewidth=1.3, label=labels[0])
ax_.plot(dates, bench, color=benchmark_color, linewidth=1.0, label=labels[1])
ax_.set_title(title)
ax_.set_ylabel("Normalized" if normalize else "Equity")
ax_.legend(loc="upper left", framealpha=0.5)
ax_.xaxis.set_major_formatter(mdates.DateFormatter("%b %Y"))
fig.autofmt_xdate(rotation=0, ha="center")
d1, s1 = maybe_decimate(dates, strat_eq)
d2, b1 = maybe_decimate(dates, bench)
fig.add_trace(go.Scatter(
x=d1, y=s1, mode="lines", name=labels[0],
line=dict(color=strategy_color, width=1.5),
))
fig.add_trace(go.Scatter(
x=d2, y=b1, mode="lines", name=labels[1],
line=dict(color=benchmark_color, width=1.0),
))
fig.update_yaxes(title_text="Normalized" if normalize else "Equity")
fig.update_xaxes(tickformat="%b %Y")
fig.update_layout(legend=dict(x=0.01, y=0.99))
return finalize(fig, show=show, save=save)
@@ -281,28 +335,31 @@ def benchmark_equity(
def drawdown(
result,
*,
ax: Optional[Axes] = None,
ax=None,
color: str = RED,
title: str = "Drawdown",
figsize: Tuple[float, float] = (14, 3),
show: bool = False,
save: Optional[Union[str, Path]] = None,
) -> Figure:
) -> go.Figure:
"""Plot the drawdown as a filled area chart."""
with theme_context():
fig, ax_ = get_or_create_ax(ax, figsize)
fig = new_figure(figsize, title)
dates, values = equity_with_dates(result)
running_max = np.maximum.accumulate(values)
dd = (values - running_max) / running_max
dates, dd = maybe_decimate(dates, dd)
ax_.fill_between(dates, dd, 0, color=color, alpha=0.25)
ax_.plot(dates, dd, color=color, linewidth=0.8)
ax_.set_title(title)
ax_.set_ylabel("Drawdown")
ax_.yaxis.set_major_formatter(mticker.PercentFormatter(xmax=1.0, decimals=0))
ax_.set_ylim(top=0)
ax_.xaxis.set_major_formatter(mdates.DateFormatter("%b %Y"))
fig.autofmt_xdate(rotation=0, ha="center")
fig.add_trace(go.Scatter(
x=dates, y=dd, mode="lines",
line=dict(color=color, width=0.9),
fill="tozeroy", fillcolor=_rgba(color, 0.25),
hovertemplate="%{x|%d %b %Y} %{y:.1%}<extra></extra>",
))
dd_min = float(dd.min()) if len(dd) else -0.01
fig.update_yaxes(title_text="Drawdown", tickformat=".0%",
range=[dd_min * 1.08, 0])
fig.update_xaxes(tickformat="%b %Y")
return finalize(fig, show=show, save=save)
@@ -312,14 +369,16 @@ def drawdown(
def monthly_returns(
result,
*,
ax: Optional[Axes] = None,
ax=None,
annotate: bool = True,
title: str = "Monthly Returns (%)",
figsize: Tuple[float, float] = (12, 5),
show: bool = False,
save: Optional[Union[str, Path]] = None,
) -> Figure:
) -> go.Figure:
"""Monthly returns heatmap (year rows x month columns + annual)."""
from manifoldbt.plot._theme import CS_DIVERGING
with theme_context():
dates, values = equity_with_dates(result)
ts = dates.astype("datetime64[M]")
@@ -344,31 +403,27 @@ def monthly_returns(
if len(valid) > 0:
grid[yi, 12] = np.prod(1.0 + valid) - 1.0
fig, ax_ = get_or_create_ax(ax, figsize)
abs_max = max(np.nanmax(np.abs(grid)), 0.01)
cmap = plt.get_cmap("bt_diverging")
im = ax_.imshow(grid, cmap=cmap, aspect="auto", vmin=-abs_max, vmax=abs_max)
month_labels = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec", "YTD"]
ax_.set_xticks(range(13))
ax_.set_xticklabels(month_labels, fontsize=8)
ax_.set_yticks(range(len(years)))
ax_.set_yticklabels([str(y) for y in years], fontsize=9)
if annotate:
for yi in range(len(years)):
for mi in range(13):
val = grid[yi, mi]
if np.isnan(val):
continue
txt = f"{val * 100:+.1f}"
brightness = abs(val) / abs_max
txt_color = WHITE if brightness > 0.4 else GRAY
ax_.text(mi, yi, txt, ha="center", va="center",
fontsize=7, color=txt_color, fontweight="medium")
text = np.where(np.isnan(grid), "", np.vectorize(lambda v: f"{v * 100:+.1f}" if not np.isnan(v) else "")(grid))
ax_.set_title(title)
fig = new_figure(figsize, title)
fig.add_trace(go.Heatmap(
z=grid * 100, x=month_labels, y=[str(y) for y in years],
colorscale=CS_DIVERGING, zmin=-abs_max * 100, zmax=abs_max * 100,
text=text if annotate else None,
texttemplate="%{text}" if annotate else None,
textfont=dict(size=10),
hovertemplate="%{y} %{x}: %{z:+.2f}%<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)
@@ -378,12 +433,12 @@ def monthly_returns(
def annual_returns(
result,
*,
ax: Optional[Axes] = None,
ax=None,
title: str = "Annual Returns",
figsize: Tuple[float, float] = (10, 4),
show: bool = False,
save: Optional[Union[str, Path]] = None,
) -> Figure:
) -> go.Figure:
"""Annual returns bar chart with green/red conditional coloring."""
with theme_context():
dates, values = equity_with_dates(result)
@@ -394,19 +449,20 @@ def annual_returns(
idx = np.nonzero(years_arr == y)[0]
ann_rets.append(values[idx[-1]] / values[idx[0]] - 1.0 if len(idx) >= 2 else 0.0)
fig, ax_ = get_or_create_ax(ax, figsize)
fig = new_figure(figsize, title)
colors = [GREEN if r >= 0 else RED for r in ann_rets]
bars = ax_.bar([str(y) for y in unique_years], ann_rets, color=colors,
width=0.5, alpha=0.85, edgecolor="none")
ax_.axhline(0, color=DARK_GRAY, linewidth=0.5)
ax_.set_title(title)
ax_.yaxis.set_major_formatter(mticker.PercentFormatter(xmax=1.0, decimals=0))
for bar, ret in zip(bars, ann_rets):
ax_.text(bar.get_x() + bar.get_width() / 2, bar.get_height(),
format_pct(ret), ha="center",
va="bottom" if ret >= 0 else "top",
fontsize=8, color=GRAY)
fig.add_trace(go.Bar(
x=[str(y) for y in unique_years], y=ann_rets,
marker_color=colors, opacity=0.85, marker_line_width=0,
width=0.5,
text=[format_pct(r) for r in ann_rets],
textposition="outside", textfont=dict(color=GRAY, size=11),
hovertemplate="%{x}: %{y:.1%}<extra></extra>",
))
fig.add_hline(y=0, line_color=DARK_GRAY, line_width=0.5)
fig.update_yaxes(tickformat=".0%")
fig.update_xaxes(showspikes=False, type="category")
fig.update_layout(hovermode="closest")
return finalize(fig, show=show, save=save)
@@ -416,19 +472,19 @@ def annual_returns(
def returns_histogram(
result,
*,
ax: Optional[Axes] = None,
ax=None,
bins: int = 100,
title: str = "Returns Distribution",
figsize: Tuple[float, float] = (12, 5),
show: bool = False,
save: Optional[Union[str, Path]] = None,
) -> Figure:
) -> go.Figure:
"""Histogram of daily returns with green/red coloring by sign."""
with theme_context():
fig, ax_ = get_or_create_ax(ax, figsize)
fig = new_figure(figsize, title)
rets = daily_returns_array(result)
if len(rets) == 0:
ax_.set_title(title + " (no data)")
fig.update_layout(title_text=title + " (no data)")
return finalize(fig, show=show, save=save)
# Clip x-axis to P1-P99 range to avoid empty space from outliers
@@ -436,28 +492,34 @@ def returns_histogram(
margin = (p99 - p1) * 0.3
xlim = (p1 - margin, p99 + margin)
_, bin_edges, patches = ax_.hist(rets, bins=bins, edgecolor="none", alpha=0.7,
range=xlim)
for patch, left in zip(patches, bin_edges[:-1]):
patch.set_facecolor(GREEN if left >= 0 else RED)
counts, bin_edges = np.histogram(rets, bins=bins, range=xlim)
centers = (bin_edges[:-1] + bin_edges[1:]) / 2
bw = bin_edges[1] - bin_edges[0]
colors = [GREEN if left >= 0 else RED for left in bin_edges[:-1]]
ax_.axvline(0, color=DARK_GRAY, linewidth=0.8, linestyle="--")
ax_.set_xlim(xlim)
fig.add_trace(go.Bar(
x=centers, y=counts, width=bw,
marker_color=colors, opacity=0.7, marker_line_width=0,
hovertemplate="%{x:.2%}: %{y}<extra></extra>",
))
fig.add_vline(x=0, line_color=DARK_GRAY, line_width=0.8, line_dash="dash")
# Normal fit (pure numpy)
mu, sigma = rets.mean(), rets.std()
if sigma > 0:
x = np.linspace(xlim[0], xlim[1], 200)
bw = bin_edges[1] - bin_edges[0]
pdf = (1 / (sigma * np.sqrt(2 * np.pi))) * np.exp(-0.5 * ((x - mu) / sigma) ** 2)
ax_.plot(x, pdf * len(rets) * bw, color=ACCENT, linewidth=1.0,
alpha=0.7, label="Normal")
ax_.legend(loc="upper right", framealpha=0.3)
fig.add_trace(go.Scatter(
x=x, y=pdf * len(rets) * bw, mode="lines", name="Normal",
line=dict(color=ACCENT, width=1.0), opacity=0.7,
hoverinfo="skip",
))
fig.update_layout(legend=dict(x=0.99, y=0.99, xanchor="right"))
ax_.set_title(title)
ax_.set_xlabel("Daily Return")
ax_.set_ylabel("Frequency")
ax_.xaxis.set_major_formatter(mticker.PercentFormatter(xmax=1.0, decimals=1))
fig.update_xaxes(title_text="Daily Return", tickformat=".1%",
range=list(xlim))
fig.update_yaxes(title_text="Frequency")
fig.update_layout(hovermode="closest", bargap=0.05)
return finalize(fig, show=show, save=save)
@@ -467,60 +529,66 @@ def returns_histogram(
def var_chart(
result,
*,
ax: Optional[Axes] = None,
ax=None,
confidence: float = 0.05,
bins: int = 120,
title: str = "Value at Risk",
figsize: Tuple[float, float] = (12, 5),
show: bool = False,
save: Optional[Union[str, Path]] = None,
) -> Figure:
) -> go.Figure:
"""Returns histogram with VaR and CVaR lines at 5% and 1% levels."""
with theme_context():
fig, ax_ = get_or_create_ax(ax, figsize)
fig = new_figure(figsize, title)
rets = daily_returns_array(result)
if len(rets) == 0:
ax_.set_title(title + " (no data)")
fig.update_layout(title_text=title + " (no data)")
return finalize(fig, show=show, save=save)
rets_pct = rets * 100
# Histogram
n, bin_edges, patches = ax_.hist(
rets_pct, bins=bins, color=ACCENT, alpha=0.5, edgecolor="none",
)
# VaR/CVaR at 5%
# VaR/CVaR at 5% and 1%
var_5 = float(np.percentile(rets, 5))
cvar_5 = float(rets[rets <= var_5].mean()) if np.any(rets <= var_5) else var_5
# VaR/CVaR at 1%
var_1 = float(np.percentile(rets, 1))
cvar_1 = float(rets[rets <= var_1].mean()) if np.any(rets <= var_1) else var_1
# Color tail bins
for b, p in zip(bin_edges, patches):
if b < var_1 * 100:
p.set_facecolor(RED)
p.set_alpha(0.5)
elif b < var_5 * 100:
p.set_facecolor(ORANGE)
p.set_alpha(0.4)
counts, bin_edges = np.histogram(rets_pct, bins=bins)
centers = (bin_edges[:-1] + bin_edges[1:]) / 2
bw = bin_edges[1] - bin_edges[0]
colors = []
for left in bin_edges[:-1]:
if left < var_1 * 100:
colors.append(_rgba(RED, 0.5))
elif left < var_5 * 100:
colors.append(_rgba(ORANGE, 0.4))
else:
colors.append(_rgba(ACCENT, 0.5))
# VaR lines
ax_.axvline(var_5 * 100, color=ORANGE, linewidth=0.8,
label=f"VaR 5%: {format_pct(var_5)}")
ax_.axvline(cvar_5 * 100, color=ORANGE, linewidth=0.6, linestyle="--", alpha=0.5,
label=f"CVaR 5%: {format_pct(cvar_5)}")
ax_.axvline(var_1 * 100, color=RED, linewidth=0.8,
label=f"VaR 1%: {format_pct(var_1)}")
ax_.axvline(cvar_1 * 100, color=RED, linewidth=0.6, linestyle="--", alpha=0.5,
label=f"CVaR 1%: {format_pct(cvar_1)}")
fig.add_trace(go.Bar(
x=centers, y=counts, width=bw, marker_color=colors,
marker_line_width=0, showlegend=False,
hovertemplate="%{x:.2f}%: %{y}<extra></extra>",
))
ax_.set_title(title)
ax_.set_xlabel("Daily Return (%)")
ax_.set_ylabel("Frequency")
ax_.legend(loc="upper right", fontsize=8, framealpha=0.3)
# VaR/CVaR lines with legend proxies
for val, color, dash, label in (
(var_5, ORANGE, None, f"VaR 5%: {format_pct(var_5)}"),
(cvar_5, ORANGE, "dash", f"CVaR 5%: {format_pct(cvar_5)}"),
(var_1, RED, None, f"VaR 1%: {format_pct(var_1)}"),
(cvar_1, RED, "dash", f"CVaR 1%: {format_pct(cvar_1)}"),
):
fig.add_vline(x=val * 100, line_color=color, line_width=0.8,
line_dash=dash, opacity=0.8 if dash is None else 0.5)
fig.add_trace(go.Scatter(
x=[None], y=[None], mode="lines", name=label,
line=dict(color=color, width=1.2, dash=dash),
))
fig.update_xaxes(title_text="Daily Return (%)")
fig.update_yaxes(title_text="Frequency")
fig.update_layout(hovermode="closest", bargap=0.05,
legend=dict(x=0.99, y=0.99, xanchor="right"))
return finalize(fig, show=show, save=save)
@@ -531,20 +599,20 @@ def rolling_sharpe(
result,
*,
windows: Optional[List[int]] = None,
ax: Optional[Axes] = None,
ax=None,
title: str = "Rolling Sharpe",
trading_days_per_year: float = 365.25,
figsize: Tuple[float, float] = (14, 4),
show: bool = False,
save: Optional[Union[str, Path]] = None,
) -> Figure:
) -> go.Figure:
"""Rolling annualized Sharpe ratio."""
if windows is None:
windows = [126, 252]
colors = [ACCENT, ACCENT_ALT, GREEN, RED]
with theme_context():
fig, ax_ = get_or_create_ax(ax, figsize)
fig = new_figure(figsize, title)
rets = daily_returns_array(result)
for i, w in enumerate(windows):
@@ -554,13 +622,15 @@ def rolling_sharpe(
rs = _rolling(rets, w, np.std)
with np.errstate(divide="ignore", invalid="ignore"):
sharpe = np.where(rs > 0, rm / rs * np.sqrt(trading_days_per_year), 0.0)
label = f"{w}d"
ax_.plot(sharpe, color=colors[i % len(colors)], linewidth=1.0, label=label)
fig.add_trace(go.Scatter(
y=sharpe, mode="lines", name=f"{w}d",
line=dict(color=colors[i % len(colors)], width=1.0),
hovertemplate="day %{x}: %{y:.2f}<extra>" + f"{w}d" + "</extra>",
))
ax_.axhline(0, color=DARK_GRAY, linewidth=0.5, linestyle="--")
ax_.set_title(title)
ax_.set_ylabel("Sharpe")
ax_.legend(loc="upper left", framealpha=0.3)
fig.add_hline(y=0, line_color=DARK_GRAY, line_width=0.5, line_dash="dash")
fig.update_yaxes(title_text="Sharpe")
fig.update_layout(legend=dict(x=0.01, y=0.99))
return finalize(fig, show=show, save=save)
@@ -571,20 +641,20 @@ def rolling_volatility(
result,
*,
windows: Optional[List[int]] = None,
ax: Optional[Axes] = None,
ax=None,
title: str = "Rolling Volatility",
trading_days_per_year: float = 365.25,
figsize: Tuple[float, float] = (14, 4),
show: bool = False,
save: Optional[Union[str, Path]] = None,
) -> Figure:
) -> go.Figure:
"""Rolling annualized volatility."""
if windows is None:
windows = [126, 252]
colors = [ACCENT, ACCENT_ALT, GREEN, RED]
with theme_context():
fig, ax_ = get_or_create_ax(ax, figsize)
fig = new_figure(figsize, title)
rets = daily_returns_array(result)
for i, w in enumerate(windows):
@@ -592,12 +662,14 @@ def rolling_volatility(
continue
rs = _rolling(rets, w, np.std)
vol = rs * np.sqrt(trading_days_per_year)
ax_.plot(vol, color=colors[i % len(colors)], linewidth=1.0, label=f"{w}d")
fig.add_trace(go.Scatter(
y=vol, mode="lines", name=f"{w}d",
line=dict(color=colors[i % len(colors)], width=1.0),
hovertemplate="day %{x}: %{y:.1%}<extra>" + f"{w}d" + "</extra>",
))
ax_.set_title(title)
ax_.set_ylabel("Volatility")
ax_.yaxis.set_major_formatter(mticker.PercentFormatter(xmax=1.0, decimals=0))
ax_.legend(loc="upper left", framealpha=0.3)
fig.update_yaxes(title_text="Volatility", tickformat=".0%")
fig.update_layout(legend=dict(x=0.01, y=0.99))
return finalize(fig, show=show, save=save)
+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 pathlib import Path
@@ -12,13 +12,9 @@ from manifoldbt.plot._theme import (
ACCENT_ALT,
BG_AXES,
BG_FIGURE,
BORDER,
DARK_GRAY,
GREEN,
GRID_RGBA,
GRAY,
RED,
WHITE,
theme_context,
)
from manifoldbt.plot._utils import finalize
@@ -143,49 +139,7 @@ def _load_bars(
# ---------------------------------------------------------------------------
# Candlestick drawing
# ---------------------------------------------------------------------------
def _draw_candles(ax, dates, o, h, l, c, width_ratio=0.6):
"""Draw candlestick bodies and wicks on an axes."""
n = len(dates)
if n < 2:
return
# Width in date units
delta = np.median(np.diff(dates)).astype("timedelta64[s]").astype(float)
w = np.timedelta64(int(delta * width_ratio), "s")
bull = c >= o
bear = ~bull
# Wicks (high-low lines)
for i in range(n):
color = GREEN if bull[i] else RED
ax.plot([dates[i], dates[i]], [l[i], h[i]], color=color, linewidth=0.5, alpha=0.7)
# Bodies
for mask, color in [(bull, GREEN), (bear, RED)]:
idx = np.where(mask)[0]
for i in idx:
bottom = min(o[i], c[i])
height = abs(c[i] - o[i])
if height < 1e-10:
height = (h[i] - l[i]) * 0.01
rect = __import__("matplotlib.patches", fromlist=["Rectangle"]).Rectangle(
(dates[i] - w / 2, bottom),
w,
height,
facecolor=color,
edgecolor=color,
alpha=0.85,
linewidth=0.5,
)
ax.add_patch(rect)
# ---------------------------------------------------------------------------
# Public API
# Shared helpers
# ---------------------------------------------------------------------------
INDICATOR_COLORS = [ACCENT, ACCENT_ALT, "#2dd4bf", "#f59e0b", "#f472b6"]
@@ -206,7 +160,7 @@ def _resolve_sym_name(store, symbol_id: int) -> str:
def _prepare_chart_data(result, store, symbol_id, n_bars):
"""Load bars, compute trim offset, extract trades — shared by both renderers."""
"""Load bars, compute trim offset, extract trades."""
manifest = result.manifest
cfg = manifest.get("config", {})
tr = cfg.get("time_range", {})
@@ -244,243 +198,6 @@ def _prepare_chart_data(result, store, symbol_id, n_bars):
}
# ---------------------------------------------------------------------------
# Interactive chart (plotly)
# ---------------------------------------------------------------------------
def _chart_interactive(result, store, symbol_id, *, emas, smas, n_bars, save):
"""Plotly-based interactive candlestick chart."""
import plotly.graph_objects as go
from plotly.subplots import make_subplots
bars, offset, bar_interval_s, filtered_trades = _prepare_chart_data(
result, store, symbol_id, n_bars,
)
close_full = bars["close"]
ts = bars["timestamp"][offset:]
o = bars["open"][offset:]
h = bars["high"][offset:]
l = bars["low"][offset:]
c = bars["close"][offset:]
vol = bars["volume"][offset:]
dates = ts.view("datetime64[ns]")
sym_name = _resolve_sym_name(store, symbol_id)
interval_label = _interval_label(bar_interval_s)
fig = make_subplots(
rows=2, cols=1,
shared_xaxes=True,
vertical_spacing=0.03,
row_heights=[0.8, 0.2],
)
# Candlesticks
fig.add_trace(
go.Candlestick(
x=dates, open=o, high=h, low=l, close=c,
increasing_line_color=GREEN, decreasing_line_color=RED,
increasing_fillcolor=GREEN, decreasing_fillcolor=RED,
name="OHLC",
),
row=1, col=1,
)
# Indicators
color_idx = 0
if emas:
for period in emas:
vals = _ema(close_full, period)[offset:]
color = INDICATOR_COLORS[color_idx % len(INDICATOR_COLORS)]
fig.add_trace(
go.Scatter(
x=dates, y=vals, mode="lines",
name=f"EMA({period})",
line=dict(color=color, width=1.5),
),
row=1, col=1,
)
color_idx += 1
if smas:
for period in smas:
vals = _sma(close_full, period)[offset:]
color = INDICATOR_COLORS[color_idx % len(INDICATOR_COLORS)]
fig.add_trace(
go.Scatter(
x=dates, y=vals, mode="lines",
name=f"SMA({period})",
line=dict(color=color, width=1.5, dash="dash"),
),
row=1, col=1,
)
color_idx += 1
# Trade markers
t_ts = filtered_trades["ts"]
t_side = filtered_trades["side"]
t_price = filtered_trades["price"]
t_qty = filtered_trades["qty"]
buy_mask = t_side == 1
sell_mask = t_side == 2
if buy_mask.any():
fig.add_trace(
go.Scatter(
x=t_ts[buy_mask], y=t_price[buy_mask],
mode="markers",
name="BUY",
marker=dict(
symbol="triangle-up", size=12,
color=GREEN, line=dict(color="white", width=1),
),
text=[f"BUY {q:.6f} @ {p:.2f}" for q, p in
zip(t_qty[buy_mask], t_price[buy_mask])],
hoverinfo="text+x",
),
row=1, col=1,
)
if sell_mask.any():
fig.add_trace(
go.Scatter(
x=t_ts[sell_mask], y=t_price[sell_mask],
mode="markers",
name="SELL",
marker=dict(
symbol="triangle-down", size=12,
color=RED, line=dict(color="white", width=1),
),
text=[f"SELL {q:.6f} @ {p:.2f}" for q, p in
zip(t_qty[sell_mask], t_price[sell_mask])],
hoverinfo="text+x",
),
row=1, col=1,
)
# Volume bars
vol_colors = [GREEN if c[i] >= o[i] else RED for i in range(len(c))]
fig.add_trace(
go.Bar(
x=dates, y=vol, name="Volume",
marker_color=vol_colors, opacity=0.5,
showlegend=False,
),
row=2, col=1,
)
# Layout — dark theme
fig.update_layout(
title=f"{sym_name} {interval_label}",
template="plotly_dark",
paper_bgcolor=BG_FIGURE,
plot_bgcolor=BG_AXES,
xaxis_rangeslider_visible=False,
hovermode="x unified",
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0),
height=700,
margin=dict(l=60, r=20, t=60, b=40),
)
fig.update_yaxes(title_text="Price", row=1, col=1)
fig.update_yaxes(title_text="Vol", row=2, col=1)
if save:
fig.write_html(str(save))
fig.show()
return fig
# ---------------------------------------------------------------------------
# Matplotlib (static) chart
# ---------------------------------------------------------------------------
def _chart_matplotlib(result, store, symbol_id, *, emas, smas, n_bars, figsize, show, save):
"""Matplotlib-based static candlestick chart."""
import matplotlib.pyplot as plt
from manifoldbt.plot._theme import theme_context
bars, offset, bar_interval_s, filtered_trades = _prepare_chart_data(
result, store, symbol_id, n_bars,
)
close_full = bars["close"]
ts = bars["timestamp"][offset:]
o = bars["open"][offset:]
h = bars["high"][offset:]
l = bars["low"][offset:]
c = bars["close"][offset:]
dates = ts.view("datetime64[ns]")
sym_name = _resolve_sym_name(store, symbol_id)
interval_label = _interval_label(bar_interval_s)
with theme_context():
fig, (ax_price, ax_vol) = plt.subplots(
2, 1, figsize=figsize, height_ratios=[4, 1],
sharex=True, gridspec_kw={"hspace": 0.05},
)
_draw_candles(ax_price, dates, o, h, l, c)
color_idx = 0
if emas:
for period in emas:
vals = _ema(close_full, period)[offset:]
color = INDICATOR_COLORS[color_idx % len(INDICATOR_COLORS)]
ax_price.plot(dates, vals, color=color, linewidth=1.2,
label=f"EMA({period})", alpha=0.9)
color_idx += 1
if smas:
for period in smas:
vals = _sma(close_full, period)[offset:]
color = INDICATOR_COLORS[color_idx % len(INDICATOR_COLORS)]
ax_price.plot(dates, vals, color=color, linewidth=1.2,
label=f"SMA({period})", linestyle="--", alpha=0.9)
color_idx += 1
# Trade markers
t_ts = filtered_trades["ts"]
t_side = filtered_trades["side"]
t_price = filtered_trades["price"]
buy_mask = t_side == 1
sell_mask = t_side == 2
if buy_mask.any():
ax_price.scatter(
t_ts[buy_mask], t_price[buy_mask],
marker="^", color=GREEN, s=80, zorder=5,
edgecolors=WHITE, linewidths=0.5, label="BUY",
)
if sell_mask.any():
ax_price.scatter(
t_ts[sell_mask], t_price[sell_mask],
marker="v", color=RED, s=80, zorder=5,
edgecolors=WHITE, linewidths=0.5, label="SELL",
)
ax_price.legend(loc="upper left", fontsize=8)
ax_price.set_title(f"{sym_name} {interval_label}", fontsize=11, loc="left")
ax_price.set_ylabel("Price", fontsize=9)
vol = bars["volume"][offset:]
vol_colors = np.where(c >= o, GREEN, RED)
ax_vol.bar(dates, vol, width=np.timedelta64(int(bar_interval_s * 0.6), "s"),
color=vol_colors, alpha=0.5)
ax_vol.set_ylabel("Volume", fontsize=9)
import matplotlib.dates as mdates
if bar_interval_s < 86400:
ax_vol.xaxis.set_major_locator(mdates.AutoDateLocator())
ax_vol.xaxis.set_major_formatter(mdates.DateFormatter("%m-%d %H:%M"))
fig.autofmt_xdate(rotation=30, ha="right")
return finalize(fig, show=show, save=save)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
@@ -507,22 +224,152 @@ def chart(
emas: List of EMA periods to overlay (e.g. [10, 25]).
smas: List of SMA periods to overlay.
n_bars: Number of bars to display (last N).
interactive: Use plotly (True) or matplotlib (False).
figsize: Figure size (matplotlib only).
show: Display the chart (matplotlib only; plotly always shows).
save: Save path (.html for plotly, .png for matplotlib).
interactive: Kept for backward compatibility (plotly renders both
paths; ``save=".png"`` produces a static image via kaleido).
figsize: Figure size in inches, mapped to pixels.
show: Display the chart in the browser.
save: Save path (.html interactive, or .png/.svg via kaleido).
"""
if interactive:
return _chart_interactive(
result, store, symbol_id,
emas=emas, smas=smas, n_bars=n_bars, save=save,
)
return _chart_matplotlib(
result, store, symbol_id,
emas=emas, smas=smas, n_bars=n_bars,
figsize=figsize, show=show, save=save,
_ = interactive # single plotly path
import plotly.graph_objects as go
from plotly.subplots import make_subplots
bars, offset, bar_interval_s, filtered_trades = _prepare_chart_data(
result, store, symbol_id, n_bars,
)
close_full = bars["close"]
ts = bars["timestamp"][offset:]
o = bars["open"][offset:]
h = bars["high"][offset:]
l = bars["low"][offset:]
c = bars["close"][offset:]
vol = bars["volume"][offset:]
dates = ts.view("datetime64[ns]")
sym_name = _resolve_sym_name(store, symbol_id)
interval_label = _interval_label(bar_interval_s)
with theme_context():
fig = make_subplots(
rows=2, cols=1,
shared_xaxes=True,
vertical_spacing=0.03,
row_heights=[0.8, 0.2],
)
# Candlesticks
fig.add_trace(
go.Candlestick(
x=dates, open=o, high=h, low=l, close=c,
increasing_line_color=GREEN, decreasing_line_color=RED,
increasing_fillcolor=GREEN, decreasing_fillcolor=RED,
name="OHLC",
),
row=1, col=1,
)
# Indicators
color_idx = 0
if emas:
for period in emas:
vals = _ema(close_full, period)[offset:]
color = INDICATOR_COLORS[color_idx % len(INDICATOR_COLORS)]
fig.add_trace(
go.Scatter(
x=dates, y=vals, mode="lines",
name=f"EMA({period})",
line=dict(color=color, width=1.5),
),
row=1, col=1,
)
color_idx += 1
if smas:
for period in smas:
vals = _sma(close_full, period)[offset:]
color = INDICATOR_COLORS[color_idx % len(INDICATOR_COLORS)]
fig.add_trace(
go.Scatter(
x=dates, y=vals, mode="lines",
name=f"SMA({period})",
line=dict(color=color, width=1.5, dash="dash"),
),
row=1, col=1,
)
color_idx += 1
# Trade markers
t_ts = filtered_trades["ts"]
t_side = filtered_trades["side"]
t_price = filtered_trades["price"]
t_qty = filtered_trades["qty"]
buy_mask = t_side == 1
sell_mask = t_side == 2
if buy_mask.any():
fig.add_trace(
go.Scatter(
x=t_ts[buy_mask], y=t_price[buy_mask],
mode="markers",
name="BUY",
marker=dict(
symbol="triangle-up", size=12,
color=GREEN, line=dict(color="white", width=1),
),
text=[f"BUY {q:.6f} @ {p:.2f}" for q, p in
zip(t_qty[buy_mask], t_price[buy_mask])],
hoverinfo="text+x",
),
row=1, col=1,
)
if sell_mask.any():
fig.add_trace(
go.Scatter(
x=t_ts[sell_mask], y=t_price[sell_mask],
mode="markers",
name="SELL",
marker=dict(
symbol="triangle-down", size=12,
color=RED, line=dict(color="white", width=1),
),
text=[f"SELL {q:.6f} @ {p:.2f}" for q, p in
zip(t_qty[sell_mask], t_price[sell_mask])],
hoverinfo="text+x",
),
row=1, col=1,
)
# Volume bars
vol_colors = [GREEN if c[i] >= o[i] else RED for i in range(len(c))]
fig.add_trace(
go.Bar(
x=dates, y=vol, name="Volume",
marker_color=vol_colors, opacity=0.5,
showlegend=False,
),
row=2, col=1,
)
fig.update_layout(
title=f"{sym_name} {interval_label}",
paper_bgcolor=BG_FIGURE,
plot_bgcolor=BG_AXES,
xaxis_rangeslider_visible=False,
hovermode="x unified",
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0),
width=int(figsize[0] * 80),
height=int(figsize[1] * 80),
margin=dict(l=60, r=20, t=60, b=40),
)
fig.update_yaxes(title_text="Price", row=1, col=1)
fig.update_yaxes(title_text="Vol", row=2, col=1)
return finalize(fig, show=show, save=save)
def _bar_interval_to_seconds(bi: dict) -> int:
"""Convert manifest bar_interval dict to seconds."""
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
import base64
import io
import tempfile
import webbrowser
from html import escape
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
from matplotlib.figure import Figure
from manifoldbt.plot._theme import (
BG_AXES,
BG_FIGURE,
DARK_GRAY,
GRAY,
GREEN,
RED,
WHITE,
theme_context,
)
from manifoldbt.plot._convert import equity_with_dates, positions_arrays
from manifoldbt.plot._utils import auto_title, format_pct
from manifoldbt.plot._convert import equity_with_dates
from manifoldbt.plot._utils import auto_title, chart_div, format_pct
from manifoldbt.plot.backtest import (
annual_returns,
drawdown,
equity,
monthly_returns,
returns_histogram,
rolling_sharpe,
@@ -38,44 +28,6 @@ from manifoldbt.plot.backtest import (
var_chart,
)
def _fig_to_base64(fig: Figure, dpi: int = 150) -> str:
"""Render a matplotlib figure to a base64-encoded PNG string."""
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight",
facecolor=fig.get_facecolor(), edgecolor="none")
plt.close(fig)
buf.seek(0)
return base64.b64encode(buf.read()).decode("ascii")
def _render_chart(chart_fn, result, figsize=(12, 4), dpi=150, **kwargs) -> str:
"""Call a chart function on a fresh figure/axes and return base64 PNG."""
with theme_context():
fig, ax = plt.subplots(figsize=figsize)
chart_fn(result, ax=ax, **kwargs)
fig.tight_layout()
return _fig_to_base64(fig, dpi=dpi)
def _render_summary_b64(result, figsize=(12, 6), dpi=150) -> str:
"""Render the summary chart (equity+benchmark+trades+margin) to base64."""
with theme_context():
fig = summary(result, figsize=figsize)
return _fig_to_base64(fig, dpi=dpi)
def _render_exposure_b64(result, figsize=(12, 4), dpi=150) -> str:
"""Render the exposure chart to base64 PNG."""
with theme_context():
fig, ax = plt.subplots(figsize=figsize)
_render_exposure(ax, result)
_set_title(ax, "Capital Exposure")
_format_dates(ax)
fig.tight_layout()
return _fig_to_base64(fig, dpi=dpi)
_CSS = f"""
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
@@ -127,12 +79,6 @@ body {{
flex-direction: column;
gap: 12px;
}}
.charts-stack img {{
width: 100%;
display: block;
border-radius: 4px;
border: 1px solid #1e1e24;
}}
.section-label {{
font-size: 10px;
font-weight: 700;
@@ -167,14 +113,11 @@ body {{
font-weight: 500;
white-space: nowrap;
}}
.chart-row {{
margin-bottom: 12px;
}}
.chart-row img {{
width: 100%;
display: block;
border-radius: 4px;
.chart-cell {{
background: {BG_FIGURE};
border: 1px solid #1e1e24;
border-radius: 4px;
overflow: hidden;
}}
.chart-grid {{
display: grid;
@@ -182,27 +125,15 @@ body {{
gap: 12px;
margin-bottom: 12px;
}}
.chart-grid img {{
width: 100%;
display: block;
border-radius: 4px;
border: 1px solid #1e1e24;
}}
.chart-grid-3 {{
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px;
margin-bottom: 12px;
}}
.chart-grid-3 img {{
width: 100%;
display: block;
border-radius: 4px;
border: 1px solid #1e1e24;
}}
.plotly-graph-div {{ width: 100% !important; }}
"""
def _div(fig, height: int) -> str:
"""Wrap a plotly figure div in a bordered cell."""
return f'<div class="chart-cell">{chart_div(fig, height=height)}</div>'
def tearsheet(
result,
*,
@@ -211,37 +142,40 @@ def tearsheet(
show: bool = False,
save: Optional[Union[str, Path]] = None,
dpi: int = 150,
plotlyjs: str = "cdn",
) -> str:
"""Strategy report — self-contained HTML page.
"""Strategy report — self-contained HTML page with interactive charts.
Returns the HTML string. Opens in browser when ``show=True``,
writes to disk when ``save`` is given.
Args:
plotlyjs: ``"cdn"`` (small file, needs network on open) or
``"inline"`` (fully offline report, ~4.4 MB heavier).
"""
_ = benchmark # reserved for future benchmark overlay support
_ = dpi # kept for backward compatibility (was the PNG export dpi)
strategy_name = title or auto_title(result, "Backtest")
metrics = result.metrics if hasattr(result, "metrics") else {}
ts = metrics.get("trade_stats", {})
dates, _ = equity_with_dates(result)
dates, _vals = equity_with_dates(result)
date_start = str(dates[0])[:10] if len(dates) > 0 else "?"
date_end = str(dates[-1])[:10] if len(dates) > 0 else "?"
# ── Generate charts as base64 PNGs ────────────────────────────
# Right column: summary chart (equity + benchmark + trades + margin)
chart_summary = _render_summary_b64(result, figsize=(12, 6), dpi=dpi)
chart_dd = _render_chart(drawdown, result, figsize=(12, 2.5), dpi=dpi)
# Left column chart
chart_annual = _render_chart(annual_returns, result, figsize=(5, 4), dpi=dpi)
# Full width grids (2 per row)
chart_monthly = _render_chart(monthly_returns, result, figsize=(8, 4), dpi=dpi)
chart_hist = _render_chart(returns_histogram, result, figsize=(8, 4), dpi=dpi)
chart_sharpe = _render_chart(rolling_sharpe, result, figsize=(8, 3.5), dpi=dpi)
chart_vol = _render_chart(rolling_volatility, result, figsize=(8, 3.5), dpi=dpi)
chart_var = _render_chart(var_chart, result, figsize=(8, 4), dpi=dpi)
# ── Generate interactive chart divs ────────────────────────────
with theme_context():
div_summary = _div(summary(result), height=520)
div_dd = _div(drawdown(result), height=210)
div_annual = _div(annual_returns(result), height=330)
div_monthly = _div(monthly_returns(result), height=340)
div_hist = _div(returns_histogram(result), height=340)
div_sharpe = _div(rolling_sharpe(result), height=300)
div_vol = _div(rolling_volatility(result), height=300)
div_var = _div(var_chart(result), height=340)
# ── Metrics ───────────────────────────────────────────────────
ret = metrics.get("total_return", 0)
_ = ret # used below in metrics_html
def _m(label, value, cls=""):
esc_v = escape(str(value))
@@ -278,6 +212,13 @@ def tearsheet(
+ _m("Fees", f"{ts.get('total_fees', 0):.2f}")
)
# ── plotly.js include ─────────────────────────────────────────
if plotlyjs == "inline":
import plotly.io as pio
plotly_js_tag = f"<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 ─────────────────────────────────────────────
html = f"""<!DOCTYPE html>
<html lang="en">
@@ -286,6 +227,7 @@ def tearsheet(
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{escape(strategy_name)} Tearsheet</title>
<style>{_CSS}</style>
{plotly_js_tag}
</head>
<body>
<div class="container">
@@ -298,26 +240,26 @@ def tearsheet(
<div class="main-grid">
<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 class="charts-stack">
<img src="data:image/png;base64,{chart_summary}" alt="Equity + Benchmark + Trades + Margin">
<img src="data:image/png;base64,{chart_dd}" alt="Drawdown">
{div_summary}
{div_dd}
</div>
</div>
<div class="chart-grid">
<img src="data:image/png;base64,{chart_monthly}" alt="Monthly Returns">
<img src="data:image/png;base64,{chart_hist}" alt="Returns Distribution">
{div_monthly}
{div_hist}
</div>
<div class="chart-grid">
<img src="data:image/png;base64,{chart_sharpe}" alt="Rolling Sharpe">
<img src="data:image/png;base64,{chart_vol}" alt="Rolling Volatility">
{div_sharpe}
{div_vol}
</div>
<div class="chart-grid">
<img src="data:image/png;base64,{chart_var}" alt="Value at Risk">
{div_var}
</div>
</div>
@@ -329,7 +271,6 @@ def tearsheet(
Path(save).write_text(html, encoding="utf-8")
if show:
# Write the report HTML
if save is not None:
report_path = Path(save).resolve()
else:
@@ -339,22 +280,7 @@ def tearsheet(
tmp.write(html)
tmp.close()
report_path = Path(tmp.name).resolve()
# Create a launcher HTML that opens the report in a 1600x850 window
report_uri = report_path.as_uri()
launcher_html = f"""<!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())
webbrowser.open(report_path.as_uri())
return html
@@ -369,40 +295,41 @@ def research_report(
show: bool = False,
save: Optional[Union[str, Path]] = None,
dpi: int = 150,
) -> List[Figure]:
"""Research report — one figure per analysis."""
) -> List[Any]:
"""Research report — one figure per analysis (plotly Figures)."""
from manifoldbt.plot.research import (
heatmap_2d,
stability,
walk_forward,
)
_ = title
figs = []
with theme_context():
if sweep_result is not None:
fig, ax = plt.subplots(figsize=figsize)
heatmap_2d(sweep_result, ax=ax)
figs.append(fig)
figs.append(heatmap_2d(sweep_result, figsize=figsize))
if wf_result is not None:
fig, ax = plt.subplots(figsize=figsize)
walk_forward(wf_result, ax=ax)
figs.append(fig)
figs.append(walk_forward(wf_result, figsize=figsize))
if stability_result is not None:
fig, ax = plt.subplots(figsize=figsize)
stability(stability_result, ax=ax)
figs.append(fig)
figs.append(stability(stability_result, figsize=figsize))
if not figs:
raise ValueError("At least one result (sweep, wf, or stability) required.")
if save is not None:
path = Path(save)
stem, suffix = path.stem, path.suffix or ".png"
stem, suffix = path.stem, path.suffix or ".html"
for i, f in enumerate(figs):
out = path.parent / f"{stem}_{i + 1}{suffix}"
f.savefig(str(out), dpi=dpi, bbox_inches="tight")
if suffix.lower() == ".html":
from manifoldbt.plot._utils import write_responsive_html
write_responsive_html(f, out)
else:
scale = max(1.0, dpi / 96.0)
f.write_image(str(out), scale=scale)
if show:
plt.show()
for f in figs:
f.show()
return figs
@@ -410,113 +337,6 @@ def research_report(
# ── Internal ─────────────────────────────────────────────────────────────────
def _set_title(ax, text):
"""Set title left-aligned, clearing any existing title from sub-functions."""
ax.set_title("", loc="center") # clear default
ax.set_title(text, fontsize=9, loc="left", color=GRAY)
def _format_dates(ax):
try:
ax.xaxis.set_major_formatter(mdates.DateFormatter("%b %Y"))
ax.xaxis.set_major_locator(mdates.AutoDateLocator())
for lbl in ax.get_xticklabels():
lbl.set_rotation(0)
lbl.set_ha("center")
except Exception:
pass
def _fix_rolling_xaxis(ax, result):
try:
dates, _ = equity_with_dates(result)
from manifoldbt.plot._convert import daily_returns_array
rets = daily_returns_array(result)
n_rets = len(rets)
aligned = dates[len(dates) - n_rets:] if len(dates) > n_rets else dates
for line in ax.get_lines():
xdata = line.get_xdata()
n = len(xdata)
if n <= 1:
continue
if isinstance(xdata[0], (int, float, np.integer, np.floating)):
x0, x1 = float(xdata[0]), float(xdata[1])
if abs(x1 - x0 - 1.0) < 0.01 and n <= len(aligned):
line.set_xdata(aligned[:n])
_format_dates(ax)
ax.relim()
ax.autoscale_view()
except Exception:
pass
def _render_metrics_table(ax, metrics, ts):
"""Render metrics as single-column dotted-leader list with sections."""
ax.set_xticks([])
ax.set_yticks([])
ax.set_facecolor(BG_AXES)
for spine in ax.spines.values():
spine.set_visible(True)
spine.set_color(DARK_GRAY)
spine.set_linewidth(0.5)
ret = metrics.get("total_return", 0)
ret_color = GREEN if ret > 0 else RED if ret < 0 else GRAY
W = 30 # total width for dotted leader alignment
def _line(label, value):
dots = "·" * max(1, W - len(label) - len(str(value)))
return f"{label} {dots} {value}"
# Build sections
sections = [
("RETURNS", GRAY, [
(_line("Total Return", format_pct(ret)), ret_color),
(_line("CAGR", format_pct(metrics.get("cagr", 0))), GRAY),
(_line("Max Drawdown", format_pct(metrics.get("max_drawdown", 0))), RED),
(_line("Volatility", format_pct(metrics.get("volatility", 0))), GRAY),
(_line("Best Day", format_pct(metrics.get("best_day", 0))), GRAY),
(_line("Worst Day", format_pct(metrics.get("worst_day", 0))), GRAY),
]),
("RATIOS", GRAY, [
(_line("Sharpe", f"{metrics.get('sharpe', 0):.2f}"), GRAY),
(_line("Sortino", f"{metrics.get('sortino', 0):.2f}"), GRAY),
(_line("Calmar", f"{metrics.get('calmar', 0):.2f}"), GRAY),
]),
("TRADING", GRAY, [
(_line("Trades", f"{ts.get('total_trades', metrics.get('total_trades', 0))}"), GRAY),
(_line("Win Rate", f"{ts.get('win_rate', metrics.get('win_rate', 0)):.1%}"), GRAY),
(_line("Profit Factor", f"{ts.get('profit_factor', metrics.get('profit_factor', 0)):.2f}"), GRAY),
(_line("Round Trips", f"{ts.get('round_trips', 0)}"), GRAY),
(_line("Avg Hold", _fmt_hold_time(ts.get("avg_holding_seconds", 0))), GRAY),
(_line("Fees", f"{ts.get('total_fees', 0):.2f}"), GRAY),
]),
]
# Count total lines for spacing
total = sum(1 + len(items) + 1 for _, _, items in sections) # header + items + gap
y = 0.97
dy = 0.92 / total
for section_name, section_color, items in sections:
# Section header
ax.text(0.06, y, section_name, fontsize=7, fontweight="bold",
color=DARK_GRAY, transform=ax.transAxes, va="top",
family="monospace")
y -= dy * 1.2
# Items
for text, color in items:
ax.text(0.06, y, text, fontsize=9, color=color,
transform=ax.transAxes, va="top", family="monospace")
y -= dy
# Gap between sections
y -= dy * 0.5
def _fmt_hold_time(seconds):
"""Format holding time in human-readable units."""
if seconds <= 0:
@@ -530,29 +350,3 @@ def _fmt_hold_time(seconds):
return f"{days:.0f}d"
hours = seconds / 3600
return f"{hours:.0f}h"
def _render_exposure(ax, result):
try:
pa = positions_arrays(result)
pos_ts = pa["timestamp"]
pos_cap = pa["capital"]
pos_eq = pa["equity"]
unique_ts, first_idx = np.unique(pos_ts, return_index=True)
first_idx.sort()
cap = pos_cap[first_idx]
eq_arr = pos_eq[first_idx]
used = np.where(eq_arr > 0, (1.0 - cap / eq_arr) * 100, 0.0)
used = np.clip(used, 0, None)
used_dates = unique_ts.astype("datetime64[ns]")
ax.fill_between(used_dates, 0, used,
color=GREEN, alpha=0.10, edgecolor="none")
ax.plot(used_dates, used, color=GREEN, linewidth=0.7, alpha=0.8)
ax.axhline(0, color=DARK_GRAY, linewidth=0.4)
ax.set_ylabel("Exposure %", fontsize=8)
except Exception:
ax.text(0.5, 0.5, "No position data",
transform=ax.transAxes, ha="center", va="center",
color=DARK_GRAY, fontsize=9)
+9
View File
@@ -37,6 +37,15 @@ class Portfolio:
strategy: A Strategy instance.
weight: Fraction of total capital (0.0 to 1.0).
"""
if getattr(strategy, "_orders", None):
import warnings
warnings.warn(
f"Strategy '{strategy.name}' defines stop_loss/take_profit/"
"trailing_stop orders, but portfolio mode does not support "
"per-strategy orders yet: they are IGNORED in run_portfolio().",
UserWarning,
stacklevel=2,
)
self._strategies.append({
"name": strategy.name,
"strategy_json": strategy.to_json(),
+7 -1
View File
@@ -194,7 +194,7 @@ class Strategy:
spec["range"] = None
params[param_name] = spec
return {
out = {
"name": self.name,
"signals": {
name: expr.to_json() for name, expr in self.signals.items()
@@ -206,6 +206,12 @@ class Strategy:
"description": self._description,
},
}
# Per-strategy SL/TP/trailing orders travel with the strategy so the
# engine applies them per-strategy in a single batch/sweep call (the
# Rust StrategyDef.orders field; omitted when unset for a clean JSON).
if self._orders:
out["orders"] = self._orders
return out
def to_json(self) -> str:
"""Serialize to a JSON string matching Rust ``StrategyDef``.
+42 -42
View File
@@ -93,62 +93,62 @@ class SweepResult:
return best_result
def plot_metric(self, metric: str = "sharpe", **kwargs: Any) -> Any:
"""Plot a metric across sweep results.
"""Plot a metric across sweep results (plotly).
For 2-parameter sweeps, delegates to ``bt.plot.heatmap_2d``.
For 1-parameter sweeps, produces a bar chart.
Args:
metric: Metric to visualize.
**kwargs: Forwarded to the plot function.
**kwargs: ``figsize``, ``show``, ``save`` forwarded to the plot.
"""
import matplotlib.pyplot as plt
import numpy as np
from manifoldbt.plot._theme import ACCENT, theme_context
from manifoldbt.plot._utils import finalize, new_figure
df = self.to_df(backend="pandas")
param_cols = [c for c in df.columns if c.startswith("param_")]
show = kwargs.pop("show", True)
save = kwargs.pop("save", None)
if len(param_cols) == 2:
# 2D heatmap
from manifoldbt.plot.research import heatmap_2d
x_col, y_col = param_cols[0], param_cols[1]
pivot = df.pivot_table(index=y_col, columns=x_col, values=metric)
fig, ax = plt.subplots(figsize=kwargs.pop("figsize", (10, 6)))
im = ax.imshow(pivot.values, aspect="auto", cmap=kwargs.pop("cmap", "RdYlGn"))
ax.set_xticks(range(len(pivot.columns)))
ax.set_xticklabels(pivot.columns, rotation=45)
ax.set_yticks(range(len(pivot.index)))
ax.set_yticklabels(pivot.index)
ax.set_xlabel(x_col.replace("param_", ""))
ax.set_ylabel(y_col.replace("param_", ""))
ax.set_title(f"{metric} heatmap")
plt.colorbar(im, ax=ax, label=metric)
plt.tight_layout()
if kwargs.get("show", True):
plt.show()
return fig
elif len(param_cols) == 1:
# 1D bar chart
p_col = param_cols[0]
fig, ax = plt.subplots(figsize=kwargs.pop("figsize", (10, 5)))
ax.bar(range(len(df)), df[metric].values, tick_label=[str(v) for v in df[p_col].values])
ax.set_xlabel(p_col.replace("param_", ""))
ax.set_ylabel(metric)
ax.set_title(f"{metric} by {p_col.replace('param_', '')}")
plt.tight_layout()
if kwargs.get("show", True):
plt.show()
return fig
else:
# Fallback: simple bar
fig, ax = plt.subplots(figsize=kwargs.pop("figsize", (10, 5)))
ax.bar(range(len(df)), df[metric].values)
ax.set_xlabel("run")
ax.set_ylabel(metric)
ax.set_title(f"{metric} across sweep")
plt.tight_layout()
if kwargs.get("show", True):
plt.show()
return fig
sweep_result = {
"metric_grid": pivot.values.tolist(),
"x_values": list(pivot.columns),
"y_values": list(pivot.index),
"x_param": x_col.replace("param_", ""),
"y_param": y_col.replace("param_", ""),
"metric": metric,
}
return heatmap_2d(sweep_result, show=show, save=save, **kwargs)
import plotly.graph_objects as go
with theme_context():
if len(param_cols) == 1:
p_col = param_cols[0]
fig = new_figure(kwargs.pop("figsize", (10, 5)),
f"{metric} by {p_col.replace('param_', '')}")
fig.add_trace(go.Bar(
x=[str(v) for v in df[p_col].values], y=df[metric].values,
marker_color=ACCENT, marker_line_width=0,
))
fig.update_xaxes(title_text=p_col.replace("param_", ""),
type="category", showspikes=False)
else:
fig = new_figure(kwargs.pop("figsize", (10, 5)),
f"{metric} across sweep")
fig.add_trace(go.Bar(
x=list(range(len(df))), y=df[metric].values,
marker_color=ACCENT, marker_line_width=0,
))
fig.update_xaxes(title_text="run", showspikes=False)
fig.update_yaxes(title_text=metric)
fig.update_layout(hovermode="closest")
return finalize(fig, show=show, save=save)
def __repr__(self) -> str:
params = ", ".join(f"{k}={len(v)} vals" for k, v in self._param_grid.items())
+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)