release: v0.14.0

This commit is contained in:
github-actions[bot]
2026-07-19 02:07:07 +00:00
parent c36b5ec4ce
commit 14bf482a9e
30 changed files with 1018 additions and 120 deletions
+1 -1
View File
@@ -72,4 +72,4 @@ if __name__ == "__main__":
print(result.summary())
print(f"\nElapsed: {perf_counter() - t0:.2f}s")
mbt.plot.tearsheet(result, show=True)
mbt.plot.tearsheet(result)
+1 -1
View File
@@ -75,4 +75,4 @@ if __name__ == "__main__":
print(f"\nElapsed: {elapsed:.3f}s")
# Plot
mbt.plot.summary(result, show=True)
mbt.plot.summary(result)
+1 -1
View File
@@ -63,4 +63,4 @@ if __name__ == "__main__":
print(result.summary())
print(f"\nElapsed: {elapsed:.3f}s")
mbt.plot.summary(result, show=True)
mbt.plot.summary(result)
+1 -1
View File
@@ -70,4 +70,4 @@ if __name__ == "__main__":
print(result.summary())
print(f"\nElapsed: {elapsed:.3f}s")
mbt.plot.summary(result, show=True)
mbt.plot.summary(result)
+1 -1
View File
@@ -101,4 +101,4 @@ if __name__ == "__main__":
print(result.summary())
print(f"\nElapsed: {elapsed:.3f}s")
mbt.plot.summary(result, show=True)
mbt.plot.summary(result)
+1 -1
View File
@@ -71,4 +71,4 @@ if __name__ == "__main__":
print(result.summary())
print(f"\nElapsed: {elapsed:.3f}s")
mbt.plot.summary(result, show=True)
mbt.plot.summary(result)
+13 -14
View File
@@ -92,7 +92,7 @@ if __name__ == "__main__":
)
# -- 3. Summary 3-panel ---------------------------------------------------
mbt.plot.summary(result, show=True)
mbt.plot.summary(result)
# -- 4. Candlestick chart (first symbol in universe) --------------------
mbt.plot.chart(
@@ -101,18 +101,17 @@ if __name__ == "__main__":
smas=[50],
n_bars=120,
interactive=False,
show=True,
)
# -- 5. Individual charts -------------------------------------------------
mbt.plot.equity(result, show=True)
mbt.plot.drawdown(result, show=True)
mbt.plot.monthly_returns(result, show=True)
mbt.plot.annual_returns(result, show=True)
mbt.plot.returns_histogram(result, show=True)
mbt.plot.var_chart(result, show=True)
mbt.plot.rolling_sharpe(result, show=True)
mbt.plot.rolling_volatility(result, show=True)
mbt.plot.equity(result)
mbt.plot.drawdown(result)
mbt.plot.monthly_returns(result)
mbt.plot.annual_returns(result)
mbt.plot.returns_histogram(result)
mbt.plot.var_chart(result)
mbt.plot.rolling_sharpe(result)
mbt.plot.rolling_volatility(result)
# -- 6. Sweep heatmap 2D -------------------------------------------------
# Sweep over RSI period and oversold threshold
@@ -160,7 +159,7 @@ if __name__ == "__main__":
"metric_grid": metric_grid,
}
print(f"Sweep done in {time.perf_counter() - t0:.1f}s")
mbt.plot.heatmap_2d(sweep_result, show=True)
mbt.plot.heatmap_2d(sweep_result)
# -- 7. Walk-forward validation -------------------------------------------
print("\nRunning walk-forward (manual folds)...")
@@ -201,11 +200,11 @@ if __name__ == "__main__":
"folds": wf_folds,
}
print(f"Walk-forward done in {time.perf_counter() - t0:.1f}s")
mbt.plot.walk_forward(wf_result, show=True)
mbt.plot.walk_forward(wf_result)
# -- 8. Monte Carlo -------------------------------------------------------
print("\nRunning Monte Carlo (1000 paths)...")
mbt.plot.monte_carlo(result, n_simulations=1000, seed=42, show=True)
mbt.plot.monte_carlo(result, n_simulations=1000, seed=42)
# -- 9. Parameter stability -----------------------------------------------
print("\nRunning stability analysis (RSI period)...")
@@ -241,7 +240,7 @@ if __name__ == "__main__":
"stability_score": 1.0 - (std_m / abs(mean_m)) if mean_m != 0 else 0.0,
}
print(f"Stability done in {time.perf_counter() - t0:.1f}s")
mbt.plot.stability(stab_result, show=True)
mbt.plot.stability(stab_result)
# -- 10. Research report (composite) --------------------------------------
print("\nGenerating research report...")
+1 -1
View File
@@ -93,4 +93,4 @@ if __name__ == "__main__":
print(f"\n{len(folds)} folds in {elapsed:.2f}s")
if folds:
mbt.plot.walk_forward({"optimize_metric": metric, "folds": folds}, show=True)
mbt.plot.walk_forward({"optimize_metric": metric, "folds": folds})
+1 -1
View File
@@ -87,4 +87,4 @@ if __name__ == "__main__":
"y_values": slow_values,
"metric": "t-stat(alpha)",
"metric_grid": metric_grid,
}, show=True)
})
+1 -1
View File
@@ -84,4 +84,4 @@ if __name__ == "__main__":
"y_values": slow_values,
"metric": "t-stat(alpha)",
"metric_grid": metric_grid,
}, show=True)
})
+1 -1
View File
@@ -66,4 +66,4 @@ if __name__ == "__main__":
print(f"Elapsed: {time.perf_counter() - t0:.3f}s\n")
# 2. Monte Carlo fan chart
mbt.plot.monte_carlo(result, n_simulations=10000, seed=42, show=True)
mbt.plot.monte_carlo(result, n_simulations=10000, seed=42)
+1 -1
View File
@@ -72,4 +72,4 @@ if __name__ == "__main__":
print(result.summary())
print(f"\nElapsed: {elapsed:.3f}s")
mbt.plot.tearsheet(result, show=True)
mbt.plot.tearsheet(result)
-1
View File
@@ -141,5 +141,4 @@ if __name__ == "__main__":
mbt.plot.stochastic_paths(
result,
title=f"Mean-reverting model (S0=80, target=100, {N_PLOT:,} paths)",
show=True,
)
+1 -1
View File
@@ -83,4 +83,4 @@ if __name__ == "__main__":
print(result.summary())
print(f"\nElapsed: {elapsed:.3f}s")
mbt.plot.equity(result, show=True)
mbt.plot.equity(result)
+1 -1
View File
@@ -105,4 +105,4 @@ if __name__ == "__main__":
print(result.summary())
print(f"\nElapsed: {elapsed:.3f}s")
result.plot_equity(show=True)
result.plot_equity()
+1 -1
View File
@@ -211,4 +211,4 @@ if __name__ == "__main__":
print(result.summary())
print(f"\nElapsed: {elapsed:.3f}s")
result.plot_equity(show=True)
result.plot_equity()
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "manifoldbt"
version = "0.13.2"
version = "0.14.0"
description = "Rust-powered backtesting engine for quantitative research"
requires-python = ">=3.9"
license = { file = "LICENSE" }
+173 -3
View File
@@ -29,6 +29,7 @@ from manifoldbt._native import (
run_portfolio as _run_portfolio_native,
py_ingest as _ingest_native,
py_import_csv as _import_csv_native,
py_import_dataframe as _import_dataframe_native,
)
from manifoldbt._serde import scalar_value_to_json
from manifoldbt.config import (
@@ -183,6 +184,32 @@ def _require_pro_over_combos(n_combos: int, what: str) -> None:
)
def _validate_swept_params(strategy: "Strategy", names, what: str) -> None:
"""Reject swept parameter names the strategy never declares.
Sweeping a name the strategy does not use is a silent no-op: the value is
merged into a parameter map nothing reads, so every combo runs the same
backtest and the sweep returns N identical results with no warning. That
is worse than an error, because an "optimisation" over thousands of combos
looks like it worked and its best result is meaningless.
A parameter counts as declared whether it came from ``mbt.param()`` inside
an expression or from an explicit ``.param()`` call: ``to_json_dict()``
merges both into ``parameters`` (and is memoised, so this costs nothing).
"""
declared = set(strategy.to_json_dict().get("parameters") or {})
unknown = [n for n in names if n not in declared]
if not unknown:
return
known = ", ".join(sorted(declared)) if declared else "none"
raise StrategyError(
f"{what}: parameter(s) {unknown} are not declared by strategy "
f"'{strategy.name}' (declared: {known}). Sweeping them would run the "
f"same backtest for every combination. Use mbt.param(\"name\") where "
f"the value is consumed, e.g. ema(close, mbt.param(\"fast\"))."
)
def _classify_error(exc: Exception) -> Exception:
"""Wrap a Rust ValueError/RuntimeError in a more specific exception."""
msg = str(exc)
@@ -682,6 +709,128 @@ def import_csv(
)
_BARS_REQUIRED_COLUMNS = ("timestamp", "open", "high", "low", "close", "volume")
def _df_to_bars_batch(data):
"""Normalise a pandas/polars DataFrame (or dict) to a pyarrow RecordBatch.
Output contract (what the native import expects): columns
``timestamp`` (timestamp[ns, UTC]), ``open/high/low/close/volume`` (f64).
Naive timestamps are assumed UTC. A pandas DatetimeIndex is promoted to
the ``timestamp`` column when the column is absent.
"""
import pyarrow as pa
# --- to Arrow Table (same dispatch as register_exo) ---
if hasattr(data, "to_arrow"):
# Polars DataFrame
table = data.to_arrow()
elif hasattr(data, "columns"):
# Pandas DataFrame
import pandas as pd
if "timestamp" not in data.columns and isinstance(data.index, pd.DatetimeIndex):
data = data.reset_index(names="timestamp")
table = pa.Table.from_pandas(data, preserve_index=False)
elif isinstance(data, dict):
table = pa.table(data)
else:
raise TypeError(
f"Unsupported data type: {type(data)}. Use a pandas/polars DataFrame or dict."
)
missing = [c for c in _BARS_REQUIRED_COLUMNS if c not in table.column_names]
if missing:
raise DataError(
f"DataFrame is missing required column(s): {', '.join(missing)}. "
f"Expected: {', '.join(_BARS_REQUIRED_COLUMNS)}"
)
table = table.select(list(_BARS_REQUIRED_COLUMNS))
# --- timestamp → timestamp[ns, UTC] ---
ts_type = table.schema.field("timestamp").type
if not pa.types.is_timestamp(ts_type):
raise DataError(
f"'timestamp' column must be a datetime type, got {ts_type}. "
"For epoch integers, convert first: pd.to_datetime(ts, unit='ms', utc=True)"
)
target_ts = pa.timestamp("ns", tz="UTC")
if ts_type != target_ts:
table = table.set_column(
0, pa.field("timestamp", target_ts), table.column(0).cast(target_ts)
)
# --- value columns → float64 ---
for i, name in enumerate(_BARS_REQUIRED_COLUMNS[1:], start=1):
if table.schema.field(i).type != pa.float64():
table = table.set_column(
i, pa.field(name, pa.float64()), table.column(i).cast(pa.float64())
)
if table.num_rows == 0:
raise DataError("DataFrame contains no data rows")
# Single contiguous batch for the zero-copy FFI crossing.
return table.combine_chunks().to_batches()[0]
def import_dataframe(
data,
symbol: str,
symbol_id: int,
*,
interval: str = "1m",
data_root: str = "data",
metadata_db: str = "metadata/metadata.sqlite",
exchange: str = "DATAFRAME",
asset_class: str = "crypto_spot",
) -> DataStore:
"""Import bars from an in-memory DataFrame into the Arrow IPC store. Free on all tiers.
The in-memory twin of :func:`import_csv`: edit your data as a DataFrame,
then import it directly no intermediate CSV. Returns a :class:`DataStore`
ready for :func:`run` (same store, metadata and versioning as ``bt.ingest``).
Accepts a pandas DataFrame, polars DataFrame, or dict of columns with
``timestamp`` (datetime; naive values are assumed UTC), ``open``, ``high``,
``low``, ``close``, ``volume``. A pandas DatetimeIndex is used as
``timestamp`` if that column is absent. Rows must be sorted by timestamp.
Example::
df = pd.read_parquet("EURUSD_1m.parquet")
df["close"] = df["close"].clip(upper=1.5) # edit in memory
store = bt.import_dataframe(df, symbol="EURUSD", symbol_id=1,
interval="1m", asset_class="forex")
result = bt.run(strategy, config, store)
Args:
data: pandas/polars DataFrame or dict of columns.
symbol: Ticker name (e.g. ``"EURUSD"``, ``"BTCUSDT"``).
symbol_id: Unique integer ID for this symbol in the store.
interval: Bar interval of the rows (``"1m"``, ``"5m"``, ``"1h"``, ``"1d"``, ...).
data_root: Store directory (default ``"data"``).
metadata_db: Metadata SQLite path.
exchange: Exchange label for metadata (default ``"DATAFRAME"``).
asset_class: ``crypto_spot``, ``crypto_perp``, ``equity``, ``future``,
``option``, ``forex``, or ``index``.
"""
batch = _df_to_bars_batch(data)
try:
return _import_dataframe_native(
batch,
symbol=symbol,
symbol_id=symbol_id,
interval=interval,
data_root=data_root,
metadata_db=metadata_db,
exchange=exchange,
asset_class=asset_class,
)
except (ValueError, RuntimeError) as exc:
raise _classify_error(exc) from exc
def _ingest_single(
*, provider, symbol, symbol_id, start, end, interval, dataset,
data_root, metadata_db, exchange, asset_class, progress,
@@ -759,6 +908,7 @@ def run_sweep(
A :class:`SweepResult` with ``.to_df()``, ``.best()``, ``.plot_metric()``.
"""
_require_pro_over_combos(_grid_combos(param_grid), "Parameter sweep")
_validate_swept_params(strategy, param_grid.keys(), "Parameter sweep")
try:
config = _cap_output_resolution(config)
store = _resolve_store(config, store)
@@ -930,6 +1080,7 @@ def run_sweep_lite(
One :class:`BatchResultLite` per combo (Cartesian product order).
"""
_require_pro_over_combos(_grid_combos(param_grid), "Parameter sweep")
_validate_swept_params(strategy, param_grid.keys(), "Parameter sweep")
_require_pro_for_gpu(device, "GPU sweep")
try:
config = _cap_output_resolution(config)
@@ -939,7 +1090,11 @@ def run_sweep_lite(
name: [scalar_value_to_json(v) for v in values]
for name, values in param_grid.items()
})
return _run_sweep_lite_native(
# Wrapped in a list subclass: echoing a sweep in a notebook cell
# printed one BatchResultLite line per combo. Indexing, iteration and
# len() are unchanged.
from manifoldbt._reprs import wrap_sweep_lite
return wrap_sweep_lite(_run_sweep_lite_native(
strategy.to_json(),
grid_json,
cfg_json,
@@ -947,7 +1102,7 @@ def run_sweep_lite(
max_parallelism,
device,
precision,
)
))
except (ValueError, RuntimeError) as exc:
raise _classify_error(exc) from exc
@@ -1029,9 +1184,15 @@ def run_walk_forward(
# in `py_run_walk_forward` (check_feature("walk_forward")), so this cannot be
# bypassed by calling the native function directly.
_require_pro("Walk-forward optimization")
_validate_swept_params(strategy, (wf_config.get("param_grid") or {}).keys(),
"Walk-forward")
config = _prepare_config(config, strategy, store)
wf_json = json.dumps(_convert_param_grid_in_config(wf_config))
return _run_walk_forward_native(strategy.to_json(), wf_json, config.to_json(), store)
raw = _run_walk_forward_native(strategy.to_json(), wf_json, config.to_json(), store)
# Wrapped in a dict subclass: the raw dict holds a full equity curve per
# fold, so echoing it in a cell printed tens of thousands of floats.
from manifoldbt._reprs import wrap_walk_forward
return wrap_walk_forward(raw)
def run_sweep_2d(
@@ -1061,6 +1222,10 @@ def run_sweep_2d(
len(sweep_config.get("x_values", [])) * len(sweep_config.get("y_values", [])),
"2D parameter sweep",
)
_validate_swept_params(
strategy,
[n for n in (sweep_config.get("x_param"), sweep_config.get("y_param")) if n],
"2D parameter sweep")
config = _prepare_config(config, strategy, store)
sweep_json = json.dumps(_convert_scalar_values_in_sweep(sweep_config))
return _run_sweep_2d_native(strategy.to_json(), sweep_json, config.to_json(), store)
@@ -1088,6 +1253,10 @@ def run_stability(
Dict with ``stability_score``, ``metric_values``, ``mean_metric``, ``std_metric``.
"""
_require_pro_over_combos(len(stability_config.get("values", [])), "Parameter stability analysis")
_validate_swept_params(
strategy,
[n for n in (stability_config.get("param_name"),) if n],
"Parameter stability analysis")
config = _prepare_config(config, strategy, store)
stab_json = json.dumps(_convert_scalar_values_in_stability(stability_config))
return _run_stability_native(strategy.to_json(), stab_json, config.to_json(), store)
@@ -1404,6 +1573,7 @@ __all__ = [
# Data ingestion
"ingest",
"import_csv",
"import_dataframe",
# Run functions
"run",
"run_sweep",
+107
View File
@@ -0,0 +1,107 @@
"""Compact reprs for the big containers returned to notebooks.
A sweep returns one object per combo and a walk-forward carries a full
equity curve per fold, so echoing either in a Jupyter cell used to print
thousands of lines. These wrappers subclass ``list``/``dict`` so every
existing access keeps working (indexing, iteration, ``.keys()``, JSON
round-trips); only the repr changes.
"""
from __future__ import annotations
from typing import Any, Dict, List
_MAX_SCAN = 100_000 # cap the repr's own cost on million-combo sweeps
def _fmt(v: float) -> str:
"""Compact number: 3 significant-ish digits, thousands as k."""
if v is None:
return "?"
a = abs(v)
if a >= 1_000_000:
return f"{v / 1_000_000:.2f}M"
if a >= 1_000:
return f"{v / 1_000:.2f}k"
if a >= 1:
return f"{v:.2f}"
return f"{v:.4g}"
def _span(values) -> str:
vals = [v for v in values if v is not None]
if not vals:
return "n/a"
lo, hi = min(vals), max(vals)
return _fmt(lo) if lo == hi else f"{_fmt(lo)}..{_fmt(hi)}"
class SweepLiteResults(list):
"""``run_sweep_lite`` output: a list, with a one-line repr.
Printing 400 combos used to emit 400 lines of ``BatchResultLite(...)``.
"""
def __repr__(self) -> str:
n = len(self)
if n == 0:
return "SweepLiteResults(empty)"
head = self[:_MAX_SCAN]
eq = _span([getattr(r, "final_equity", None) for r in head])
sharpes = []
for r in head:
m = getattr(r, "metrics", None)
if isinstance(m, dict):
sharpes.append(m.get("sharpe"))
name = getattr(self[0], "strategy_name", "?")
parts = [f"{n:,} combos", f"strategy {name!r}", f"final_equity {eq}"]
if any(s is not None for s in sharpes):
parts.append(f"sharpe {_span(sharpes)}")
if n > _MAX_SCAN:
parts.append(f"(range over first {_MAX_SCAN:,})")
return ("<SweepLiteResults: " + " | ".join(parts) +
"\n r[i] for one combo, mbt.sweep_columns(r, 'sharpe') for arrays>")
class WalkForwardResult(dict):
"""``run_walk_forward`` output: a dict, with a one-line repr.
The raw dict carries a full IS and OOS equity curve per fold, so echoing
it in a cell used to print tens of thousands of floats.
"""
def __repr__(self) -> str:
folds = self.get("folds") or []
if not folds:
return "<WalkForwardResult: no folds>"
metric = self.get("optimize_metric", "sharpe")
def _m(fold, key):
v = fold.get(key)
return v.get(metric) if isinstance(v, dict) else v
is_v = [_m(f, "is_metrics") for f in folds]
oos_v = [_m(f, "oos_metrics") for f in folds]
lines = [
f"<WalkForwardResult: {len(folds)} folds | metric {metric!r} | "
f"IS {_span(is_v)} | OOS {_span(oos_v)}"
]
for f in folds:
best = f.get("best_params") or {}
flat = {k: (list(v.values())[0] if isinstance(v, dict) else v)
for k, v in best.items()}
i, o = _m(f, "is_metrics"), _m(f, "oos_metrics")
lines.append(
f" fold {f.get('fold_index', '?')}: "
f"IS {_fmt(i) if i is not None else '?':>8} "
f"OOS {_fmt(o) if o is not None else '?':>8} {flat}"
)
lines.append(" keys: " + ", ".join(sorted(self.keys())) + ">")
return "\n".join(lines)
def wrap_sweep_lite(results: List[Any]) -> "SweepLiteResults":
return SweepLiteResults(results)
def wrap_walk_forward(result: Dict[str, Any]) -> "WalkForwardResult":
return WalkForwardResult(result) if isinstance(result, dict) else result
+16 -3
View File
@@ -10,11 +10,24 @@ Quick start::
result = bt.run(strategy, config, store)
bt.plot.tearsheet(result) # full-page dashboard
bt.plot.equity(result, show=True) # single chart
bt.plot.equity(result) # single chart, opens on its own
Every chart is interactive (crosshair, hover, zoom). ``show=True`` opens it
in a native window (``pip install manifoldbt[window]``; falls back to a
Every chart is interactive (crosshair, hover, zoom) and **shows itself by
default**: plotting is what you asked for, so no ``show=`` is needed. Charts
open in a native window (``pip install manifoldbt[window]``; falls back to a
browser tab, which you can also force with ``show="browser"``).
Three cases opt out of showing automatically, because showing would be
wrong: passing ``save=`` (you asked for a file, not a window), running
under pytest/CI (a window there blocks the run), and running inside a
notebook, where the cell already renders the returned Figure and showing
would print a second copy of the same chart.
Pass an explicit ``show=True`` to override any of them, or ``show=False``
to get the Figure back silently and compose it yourself. To place a chart
in the middle of a notebook cell, where there is no trailing expression for
Jupyter to display, call IPython's ``display(fig)`` on the returned figure.
``save=".html"`` writes a responsive interactive page. Static ``save=".png"``
is optional and needs ``pip install manifoldbt[png]`` (pulls a headless Chromium).
"""
+71 -4
View File
@@ -29,6 +29,57 @@ def new_figure(
return fig
def _in_notebook() -> bool:
"""True inside a Jupyter/IPython kernel (not a plain terminal REPL)."""
try:
from IPython import get_ipython # type: ignore
except ImportError:
return False
try:
ip = get_ipython()
except Exception:
return False
return ip is not None and hasattr(ip, "kernel")
def _in_test_or_ci() -> bool:
"""True under pytest or on a CI runner.
A test that builds a figure must not queue a window: show() runs from
the atexit hook and blocks on the window process, so one bare plot call
in a test suite hangs the whole run until a human closes it.
"""
import os
return bool(os.environ.get("PYTEST_CURRENT_TEST") or os.environ.get("CI"))
def resolve_show(show: "bool | str | None",
save: Optional[Union[str, Path]]) -> "bool | str":
"""Resolve the ``show=None`` auto default.
A chart you asked for is a chart you want to see, so the default shows
it. Three cases opt out, because showing there would be wrong:
- ``save`` was given: you asked for a file, not a window.
- pytest/CI: show() runs from atexit and blocks on the window process.
- a notebook: the cell already renders the returned Figure. Calling
show() here too would emit a SECOND copy of the same chart, so the
notebook path stays silent and lets the cell do the rendering.
Explicit ``True``/``False``/``"browser"`` always wins. There is no
"render it inline" value to pass, because that is what the notebook
already does with the returned Figure; to place a chart mid-cell, call
IPython's ``display(fig)``.
"""
if show is not None:
return show
if save is not None:
return False
if _in_test_or_ci():
return False
return False if _in_notebook() else True
def format_pct(value: float, decimals: int = 1) -> str:
"""Format a decimal fraction as a percentage string."""
return f"{value * 100:+.{decimals}f}%"
@@ -43,7 +94,7 @@ def format_currency(value: float, currency: str = "USD") -> str:
def finalize(
fig,
*,
show: "bool | str" = False,
show: "bool | str | None" = None,
save: Optional[Union[str, Path]] = None,
dpi: int = 150,
window_size: Optional[Tuple[int, int]] = None,
@@ -52,10 +103,20 @@ def finalize(
``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.
``show``: ``None`` (default) shows the chart unless ``save`` was given or
we are in a notebook (see :func:`resolve_show`); ``True`` (or ``"window"``)
opens a native window (needs pywebview, else falls back to a browser tab);
``"browser"`` forces a browser tab; ``False`` returns the figure silently.
``dpi`` is kept for backward compatibility and maps to an export scale.
"""
show = resolve_show(show, save)
if _in_notebook():
# new_figure() sets a pixel width sized for a window (1120px by
# default). A notebook cell is narrower than that, so the chart
# overflowed its output area: the right edge and the modebar were
# pushed out of view. Drop the fixed width and let it track the cell,
# keeping the height so the cell still has a definite size.
fig.update_layout(width=None, autosize=True)
if save is not None:
path = Path(save)
ext = path.suffix.lower()
@@ -71,7 +132,13 @@ def finalize(
) from exc
else:
write_responsive_html(fig, path)
if show == "browser":
if show == "inline" and _in_notebook():
# No-op on purpose. Rendering in the cell IS the notebook default, so
# calling show() here would emit a second copy of the chart the cell
# is already going to render. To place a chart mid-cell, where there
# is no trailing expression, use IPython's display(fig).
pass
elif show in ("browser", "inline"):
fig.show()
elif show: # True or "window" -> native window (browser tab fallback)
from manifoldbt.plot._window import open_in_window
+10 -10
View File
@@ -71,7 +71,7 @@ def summary(
result,
*,
figsize: Tuple[float, float] = (14, 8),
show: bool = False,
show: "bool | str | None" = None,
save: Optional[Union[str, Path]] = None,
) -> go.Figure:
"""The essential chart: TWR equity + buy-and-hold benchmark, trade activity.
@@ -264,7 +264,7 @@ def equity(
color: str = ACCENT,
title: str = "Equity Curve",
figsize: Tuple[float, float] = (14, 5),
show: bool = False,
show: "bool | str | None" = None,
save: Optional[Union[str, Path]] = None,
) -> go.Figure:
"""Plot the portfolio equity curve over time.
@@ -298,7 +298,7 @@ def benchmark_equity(
labels: Tuple[str, str] = ("Strategy", "Buy & Hold"),
title: str = "Strategy vs Benchmark",
figsize: Tuple[float, float] = (14, 5),
show: bool = False,
show: "bool | str | None" = None,
save: Optional[Union[str, Path]] = None,
) -> go.Figure:
"""Overlay strategy equity and a benchmark, both normalized to 100."""
@@ -339,7 +339,7 @@ def drawdown(
color: str = RED,
title: str = "Drawdown",
figsize: Tuple[float, float] = (14, 3),
show: bool = False,
show: "bool | str | None" = None,
save: Optional[Union[str, Path]] = None,
) -> go.Figure:
"""Plot the drawdown as a filled area chart."""
@@ -373,7 +373,7 @@ def monthly_returns(
annotate: bool = True,
title: str = "Monthly Returns (%)",
figsize: Tuple[float, float] = (12, 5),
show: bool = False,
show: "bool | str | None" = None,
save: Optional[Union[str, Path]] = None,
) -> go.Figure:
"""Monthly returns heatmap (year rows x month columns + annual)."""
@@ -439,7 +439,7 @@ def annual_returns(
ax=None,
title: str = "Annual Returns",
figsize: Tuple[float, float] = (10, 4),
show: bool = False,
show: "bool | str | None" = None,
save: Optional[Union[str, Path]] = None,
) -> go.Figure:
"""Annual returns bar chart with green/red conditional coloring."""
@@ -479,7 +479,7 @@ def returns_histogram(
bins: int = 100,
title: str = "Returns Distribution",
figsize: Tuple[float, float] = (12, 5),
show: bool = False,
show: "bool | str | None" = None,
save: Optional[Union[str, Path]] = None,
) -> go.Figure:
"""Histogram of daily returns with green/red coloring by sign."""
@@ -541,7 +541,7 @@ def var_chart(
bins: int = 120,
title: str = "Value at Risk",
figsize: Tuple[float, float] = (12, 5),
show: bool = False,
show: "bool | str | None" = None,
save: Optional[Union[str, Path]] = None,
) -> go.Figure:
"""Returns histogram with VaR and CVaR lines at 5% and 1% levels."""
@@ -610,7 +610,7 @@ def rolling_sharpe(
title: str = "Rolling Sharpe",
trading_days_per_year: float = 365.25,
figsize: Tuple[float, float] = (14, 4),
show: bool = False,
show: "bool | str | None" = None,
save: Optional[Union[str, Path]] = None,
) -> go.Figure:
"""Rolling annualized Sharpe ratio."""
@@ -652,7 +652,7 @@ def rolling_volatility(
title: str = "Rolling Volatility",
trading_days_per_year: float = 365.25,
figsize: Tuple[float, float] = (14, 4),
show: bool = False,
show: "bool | str | None" = None,
save: Optional[Union[str, Path]] = None,
) -> go.Figure:
"""Rolling annualized volatility."""
+1 -1
View File
@@ -212,7 +212,7 @@ def chart(
n_bars: int = 120,
interactive: bool = True,
figsize: Tuple[float, float] = (14, 7),
show: bool = False,
show: "bool | str | None" = None,
save: Optional[Union[str, Path]] = None,
):
"""Plot candlestick chart with indicators and trade markers.
+290 -41
View File
@@ -112,14 +112,29 @@ def heatmap_2d(
annotate: bool = True,
fmt: str = ".3f",
highlight_best: bool = True,
zones: "bool | List[float] | None" = None,
drift: int = 2,
title: Optional[str] = None,
figsize: Tuple[float, float] = (10, 8),
show: bool = False,
show: "bool | str | None" = None,
save: Optional[Union[str, Path]] = None,
) -> go.Figure:
"""2D parameter sweep heatmap from ``run_sweep_2d()`` result.
Expected keys: metric_grid, x_values, y_values, x_param, y_param, metric.
Args:
zones: Colour cells by discrete robustness zone instead of by the
metric. A zone is what the area still guarantees when the
parameters drift by ``drift`` cells, so a lucky spike is shown in
a low zone despite scoring well on its own cell. ``True`` picks
the bands (conventional 0/0.5/1.0/1.5 for risk-adjusted ratios,
an even split of the observed range otherwise); pass a list of
thresholds to set them yourself. The metric value stays on hover
and in the cell labels. Same option as ``surface_3d``.
drift: Neighbourhood radius in grid cells for the worst case. Cells,
not parameter units: with an x step of 1 and a y step of 5,
``drift=2`` means +/-2 on x but +/-10 on y.
"""
with theme_context():
grid = np.array(sweep_result["metric_grid"], dtype=np.float64)
@@ -135,22 +150,50 @@ def heatmap_2d(
text = np.vectorize(lambda v: "" if np.isnan(v) else f"{v:{fmt}}")(grid)
fig = new_figure(figsize)
fig.add_trace(go.Heatmap(
z=grid, x=x_vals, y=y_vals,
colorscale=CS_SEQUENTIAL,
text=text, texttemplate="%{text}" if text is not None else None,
textfont=dict(size=9),
hovertemplate=(
f"{x_param} %{{x}}<br>{y_param} %{{y}}<br>"
f"{metric} %{{z:{fmt}}}<extra></extra>"
),
colorbar=dict(outlinewidth=0, thickness=12),
hoverongaps=False,
))
if zones:
worst = _worst_case(grid, drift)
band, scale, labels, edges, n_bands = _zone_bands(worst, zones, metric)
# z carries the band so colour is discrete; the metric and what it
# holds ride along in customdata so the cell still reports both.
fig.add_trace(go.Heatmap(
z=band, x=x_vals, y=y_vals,
colorscale=scale, zmin=-0.5, zmax=n_bands - 0.5,
customdata=np.dstack((grid, worst)),
text=text, texttemplate="%{text}" if text is not None else None,
textfont=dict(size=9),
hovertemplate=(
f"{x_param} %{{x}}<br>{y_param} %{{y}}<br>"
f"{metric} %{{customdata[0]:{fmt}}}<br>"
f"held %{{customdata[1]:{fmt}}}<extra></extra>"
),
colorbar=dict(
title=dict(text=f"{metric}<br>held under drift", side="right"),
outlinewidth=0, thickness=12,
tickmode="array", tickvals=list(range(n_bands)),
ticktext=labels),
hoverongaps=False,
))
else:
fig.add_trace(go.Heatmap(
z=grid, x=x_vals, y=y_vals,
colorscale=CS_SEQUENTIAL,
text=text, texttemplate="%{text}" if text is not None else None,
textfont=dict(size=9),
hovertemplate=(
f"{x_param} %{{x}}<br>{y_param} %{{y}}<br>"
f"{metric} %{{z:{fmt}}}<extra></extra>"
),
colorbar=dict(outlinewidth=0, thickness=12),
hoverongaps=False,
))
best_label = None
if highlight_best:
best_idx = _plateau_best(grid)
if zones:
# Match the colouring: best = what holds up, not the spike.
best_idx = np.unravel_index(np.argmax(worst), worst.shape)
else:
best_idx = _plateau_best(grid)
best_val = grid[best_idx]
best_x = x_vals[best_idx[1]]
best_y = y_vals[best_idx[0]]
@@ -163,7 +206,12 @@ def heatmap_2d(
x0=best_x - dx, x1=best_x + dx, y0=best_y - dy, y1=best_y + dy,
line=dict(color="white", width=2.5),
)
best_label = f"best: {best_val:{fmt}} ({x_param}={best_x:.0f}, {y_param}={best_y:.0f})"
kind = "most robust" if zones else "plateau centre"
best_label = (f"{kind}: {best_val:{fmt}} "
f"({x_param}={best_x:.0f}, {y_param}={best_y:.0f})")
if zones:
best_label += (f", holds {worst[best_idx]:{fmt}} "
f"under +/-{drift} cells")
combos = nx * ny
main_title = title or f"{metric} · Parameter Sweep ({combos:,} combos)"
@@ -188,21 +236,162 @@ def heatmap_2d(
# ── 3D Surface Plot ─────────────────────────────────────────────────────────
def _worst_case(grid: np.ndarray, radius: int) -> np.ndarray:
"""Lowest value reachable within +/-``radius`` cells of each cell.
This is what a combo still returns if the parameters drift, as opposed
to what its own cell scored. Lucky spikes collapse to their surroundings;
plateaus keep their value. Edges are replicated so the border is not
flattered by having fewer neighbours.
"""
if radius < 1:
return grid
filled = np.nan_to_num(grid, nan=np.nanmin(grid))
padded = np.pad(filled, radius, mode="edge")
n, m = filled.shape
stack = np.stack([padded[i:i + n, j:j + m]
for i in range(2 * radius + 1)
for j in range(2 * radius + 1)])
return stack.min(axis=0)
# Metrics where 0 separates losing from winning, so 0 is worth keeping as a
# band edge even when the data would not have put one there.
_RATIO_METRICS = ("sharpe", "sortino", "calmar", "tstat_alpha", "information")
_ZONE_COLORS = ["#3f1d1d", "#7c3a1d", "#8a7a1e", "#2f6b3a", ACCENT]
def _nice_step(span: float, n_bands: int) -> float:
"""A 1/2/2.5/5 x 10^k step covering ``span`` in about ``n_bands`` steps.
Rounded steps keep the legend readable: "0.8 - 1.2" rather than
"0.7834 - 1.2017".
"""
if not np.isfinite(span) or span <= 0:
return 1.0
raw = span / n_bands
mag = 10.0 ** np.floor(np.log10(raw))
for m in (1.0, 2.0, 2.5, 5.0):
if raw <= m * mag:
return m * mag
return 10.0 * mag
def _auto_edges(worst: np.ndarray, metric: str, n_bands: int = 5):
"""Band edges fitted to the data, snapped to round numbers.
Fixed conventional thresholds (0/0.5/1.0/1.5 for a Sharpe) collapse to a
single flat band whenever the sweep happens to live inside one of them,
which is common: a grid whose guaranteed Sharpe runs 1.5-2.0 came out
entirely one colour.
Edges sit at -1.5 to +1.5 standard deviations around the sweep's mean, so
the zones say how exceptional a region is *within this sweep*. That is a
relative statement, not a quality certificate: a sweep where every combo
loses money still has a top zone, it is just the least bad. Read the
colourbar, which prints the real thresholds, and pass explicit
``zones=[...]`` whenever the bands must mean something absolute.
"""
finite = worst[np.isfinite(worst)]
if finite.size == 0:
return [0.0]
lo, hi = float(finite.min()), float(finite.max())
if hi <= lo: # a flat grid has nothing to band
return [lo]
# Quantiles, not an even split of the range. Taking a minimum over the
# drift window skews the distribution hard toward its low tail, so even
# edges dumped 93% of the cells into one band and the map came out flat.
# Quantiles balance the bands by construction; the snap keeps the numbers
# readable and the colourbar prints them.
# Bands in standard deviations around the mean of the sweep.
#
# Quantiles were the other candidate and they are worse here: they force
# ~20% of cells into every band, so a grid that is genuinely uniform
# after erosion still comes out looking structured. Sigma bands scale
# with the actual dispersion, so a flat sweep reads flat and a sweep with
# a real standout region shows it. They also carry a meaning a reader can
# use: "+1 sigma" is how exceptional the region is for THIS sweep.
mu, sd = float(np.mean(finite)), float(np.std(finite))
if sd <= 0:
return [lo]
sigmas = np.linspace(-1.5, 1.5, n_bands - 1) # 5 bands -> -1.5..+1.5
raw_edges = mu + sigmas * sd
step = _nice_step(float(raw_edges[-1] - raw_edges[0]),
max(len(raw_edges) - 1, 1))
edges = sorted({round(float(np.round(e / step) * step), 10)
for e in raw_edges})
edges = [e for e in edges if lo < e < hi]
if len(edges) < len(raw_edges):
# Rounding merged edges (a very tight spread): keep them unsnapped.
edges = sorted({float(f"{e:.4g}") for e in raw_edges if lo < e < hi})
# 0 is a real boundary for a ratio: above it you make money, below you
# lose it. Keep it even if the rounding would have skipped it.
if any(k in metric.lower() for k in _RATIO_METRICS) and lo < 0.0 < hi:
edges = sorted(set(edges + [0.0]))
if len(edges) > n_bands - 1: # drop the edge nearest 0, not 0 itself
nonzero = [e for e in edges if e != 0.0]
drop = min(nonzero, key=lambda e: abs(e))
edges.remove(drop)
return edges or [(lo + hi) / 2.0]
def _zone_bands(worst: np.ndarray, zones, metric: str):
"""Resolve ``zones`` into thresholds, then bucket ``worst`` into bands."""
if zones is True:
edges = _auto_edges(worst, metric)
else:
edges = sorted(float(z) for z in zones)
band = np.digitize(worst, edges).astype(float)
n_bands = len(edges) + 1
labels = [f"< {edges[0]:g}"]
labels += [f"{edges[i]:g} - {edges[i + 1]:g}" for i in range(len(edges) - 1)]
labels.append(f">= {edges[-1]:g}")
colors = _ZONE_COLORS
if n_bands != len(colors): # stretch or trim the ramp to the band count
idx = np.linspace(0, len(colors) - 1, n_bands).round().astype(int)
colors = [colors[i] for i in idx]
scale = []
for i, c in enumerate(colors): # duplicated stops = hard borders
scale.append([i / n_bands, c])
scale.append([(i + 1) / n_bands, c])
return band, scale, labels, edges, n_bands
def surface_3d(
sweep_result: Dict[str, Any],
*,
highlight_best: bool = True,
zones: "bool | List[float] | None" = None,
drift: int = 2,
title: Optional[str] = None,
figsize: Tuple[float, float] = (12, 8),
elev: float = 30,
azim: float = -45,
show: bool = False,
show: "bool | str | None" = None,
save: Optional[Union[str, Path]] = None,
) -> go.Figure:
"""3D surface plot from a 2D parameter sweep result.
Same input format as ``heatmap_2d``. ``elev``/``azim`` are kept for
backward compatibility and mapped to the plotly camera.
Args:
zones: Colour the surface by discrete robustness zones instead of by
height. Height still shows the metric; colour shows what each
area still guarantees when the parameters drift by ``drift``
cells, so a lucky spike lands in a low zone despite standing
tall. ``True`` picks the bands (conventional 0/0.5/1.0/1.5 for
risk-adjusted ratios, an even split of the observed range
otherwise); pass a list of thresholds to set them yourself,
which is what you want whenever the bands carry meaning.
drift: Neighbourhood radius in grid cells used for the worst case.
Note this is cells, not parameter units: with an x step of 1 and
a y step of 5, ``drift=2`` means +/-2 on x but +/-10 on y.
"""
with theme_context():
grid = np.array(sweep_result["metric_grid"], dtype=np.float64)
@@ -213,34 +402,94 @@ def surface_3d(
metric = sweep_result.get("metric", "metric")
fig = new_figure(figsize)
fig.add_trace(go.Surface(
x=x_vals, y=y_vals, z=grid,
colorscale=CS_SEQUENTIAL, opacity=0.98,
colorbar=dict(title=dict(text=metric, side="right"),
outlinewidth=0, thickness=13, len=0.6),
lighting=dict(ambient=0.75, diffuse=0.5, roughness=0.9, specular=0.1),
contours=dict(z=dict(show=True, usecolormap=True, project_z=True,
width=1)),
hovertemplate=(
f"{x_param} %{{x:.2f}}<br>{y_param} %{{y:.2f}}<br>"
f"{metric} %{{z:.3f}}<extra></extra>"
),
))
lighting = dict(ambient=0.75, diffuse=0.5, roughness=0.9, specular=0.1)
if zones:
worst = _worst_case(grid, drift)
band, scale, labels, edges, n_bands = _zone_bands(worst, zones, metric)
fig.add_trace(go.Surface(
x=x_vals, y=y_vals, z=grid,
surfacecolor=band, colorscale=scale,
cmin=-0.5, cmax=n_bands - 0.5, opacity=0.98,
colorbar=dict(
title=dict(text=f"{metric}<br>held under drift", side="right"),
outlinewidth=0, thickness=13, len=0.62,
tickmode="array", tickvals=list(range(n_bands)),
ticktext=labels),
lighting=lighting,
contours=dict(z=dict(show=True, color="rgba(255,255,255,0.13)",
width=1)),
customdata=worst,
hovertemplate=(
f"{x_param} %{{x:.2f}}<br>{y_param} %{{y:.2f}}<br>"
f"{metric} %{{z:.3f}}<br>held %{{customdata:.3f}}"
f"<extra></extra>"
),
))
else:
fig.add_trace(go.Surface(
x=x_vals, y=y_vals, z=grid,
colorscale=CS_SEQUENTIAL, opacity=0.98,
colorbar=dict(title=dict(text=metric, side="right"),
outlinewidth=0, thickness=13, len=0.6),
lighting=lighting,
contours=dict(z=dict(show=True, usecolormap=True, project_z=True,
width=1)),
hovertemplate=(
f"{x_param} %{{x:.2f}}<br>{y_param} %{{y:.2f}}<br>"
f"{metric} %{{z:.3f}}<extra></extra>"
),
))
best_label = None
if highlight_best:
best_idx = _plateau_best(grid)
if zones:
# With zones on, "best" means the combo that holds up best
# under drift, not the tallest cell. Reporting the spike here
# would contradict the colouring right next to it.
best_idx = np.unravel_index(np.argmax(worst), worst.shape)
held = worst[best_idx]
else:
best_idx = _plateau_best(grid)
held = None
best_val = grid[best_idx]
bx = x_vals[best_idx[1]]
by = y_vals[best_idx[0]]
# A dot sitting exactly at best_val is half-buried in the surface
# it marks, and a stem dropped to the floor runs underneath that
# surface, hidden by it. So the marker is a pin standing ABOVE
# the peak: the stalk clears the geometry and stays readable from
# any camera angle and over any colour.
span = float(np.nanmax(grid) - np.nanmin(grid)) or 1.0
tip = best_val + span * 0.10
fig.add_trace(go.Scatter3d(
x=[bx], y=[by], z=[best_val], mode="markers",
marker=dict(color="white", size=6,
line=dict(color="black", width=2)),
name="best", showlegend=False,
hovertemplate=f"best {metric} %{{z:.3f}}<extra></extra>",
x=[bx, bx], y=[by, by], z=[best_val, tip], mode="lines",
line=dict(color=WHITE, width=5),
name="best", showlegend=False, hoverinfo="skip",
))
best_label = f"best: {best_val:.3f} ({x_param}={bx:.0f}, {y_param}={by:.0f})"
# Name the criterion. Calling this "best <metric>" was a lie
# whenever zones were on: it is not the highest cell, it is the
# one that survives drift, and the highest cell is elsewhere and
# visibly taller.
if zones:
pin_text = (f"most robust<br>{metric} {best_val:.3f}"
f"<br>holds {worst[best_idx]:.3f} "
f"under +/-{drift} cells")
else:
pin_text = f"plateau centre<br>{metric} {best_val:.3f}"
fig.add_trace(go.Scatter3d(
x=[bx], y=[by], z=[tip], mode="markers",
marker=dict(color=WHITE, size=9, symbol="diamond",
line=dict(color="black", width=3)),
name="best", showlegend=False,
hovertemplate=pin_text + "<extra></extra>",
))
kind = "most robust" if zones else "plateau centre"
best_label = (f"{kind}: {best_val:.3f} "
f"({x_param}={bx:.0f}, {y_param}={by:.0f})")
if held is not None:
best_label += f", holds {held:.3f} under +/-{drift} cells"
# Map matplotlib elev/azim to a plotly camera eye position
r = 1.9
@@ -283,7 +532,7 @@ def walk_forward(
oos_color: str = ORANGE,
title: Optional[str] = None,
figsize: Tuple[float, float] = (10, 5),
show: bool = False,
show: "bool | str | None" = None,
save: Optional[Union[str, Path]] = None,
) -> go.Figure:
"""Walk-forward analysis chart.
@@ -507,7 +756,7 @@ def stability(
band_alpha: float = 0.15,
title: Optional[str] = None,
figsize: Tuple[float, float] = (10, 5),
show: bool = False,
show: "bool | str | None" = None,
save: Optional[Union[str, Path]] = None,
) -> go.Figure:
"""Parameter stability chart with mean +/- std shaded bands.
@@ -572,7 +821,7 @@ def correlation_matrix(
annotate: bool = True,
title: str = "Correlation Matrix",
figsize: Tuple[float, float] = (8, 7),
show: bool = False,
show: "bool | str | None" = None,
save: Optional[Union[str, Path]] = None,
) -> go.Figure:
"""Symbol correlation matrix heatmap."""
@@ -652,7 +901,7 @@ def monte_carlo(
title: Optional[str] = None,
figsize: Tuple[float, float] = (12, 5),
seed: Optional[int] = None,
show: bool = False,
show: "bool | str | None" = None,
save: Optional[Union[str, Path]] = None,
) -> go.Figure:
"""Monte Carlo fan chart with percentile bands, sample paths, and risk stats.
@@ -805,7 +1054,7 @@ def stochastic_paths(
band_color: str = ACCENT,
title: Optional[str] = None,
figsize: Tuple[float, float] = (12, 5),
show: bool = False,
show: "bool | str | None" = None,
save: Optional[Union[str, Path]] = None,
) -> go.Figure:
"""Fan chart for stochastic simulation paths with percentile bands.
+21 -16
View File
@@ -16,7 +16,7 @@ from manifoldbt.plot._theme import (
theme_context,
)
from manifoldbt.plot._convert import equity_with_dates
from manifoldbt.plot._utils import auto_title, chart_div, format_pct
from manifoldbt.plot._utils import auto_title, chart_div, format_pct, resolve_show
from manifoldbt.plot.backtest import (
annual_returns,
drawdown,
@@ -139,7 +139,7 @@ def tearsheet(
*,
benchmark=None,
title: Optional[str] = None,
show: bool = False,
show: "bool | str | None" = None,
save: Optional[Union[str, Path]] = None,
dpi: int = 150,
plotlyjs: str = "cdn",
@@ -165,14 +165,16 @@ def tearsheet(
# ── 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)
# show=False on every panel: these are embedded as divs in the page
# below, so the auto-show default would open 8 stray windows.
div_summary = _div(summary(result, show=False), height=520)
div_dd = _div(drawdown(result, show=False), height=210)
div_annual = _div(annual_returns(result, show=False), height=330)
div_monthly = _div(monthly_returns(result, show=False), height=340)
div_hist = _div(returns_histogram(result, show=False), height=340)
div_sharpe = _div(rolling_sharpe(result, show=False), height=300)
div_vol = _div(rolling_volatility(result, show=False), height=300)
div_var = _div(var_chart(result, show=False), height=340)
# ── Metrics ───────────────────────────────────────────────────
ret = metrics.get("total_return", 0)
@@ -273,7 +275,9 @@ def tearsheet(
if save is not None:
Path(save).write_text(html, encoding="utf-8")
if show:
# A report is an HTML page, not a Figure: it always opens in a browser
# tab, so "inline" resolves to the same thing here.
if resolve_show(show, save):
if save is not None:
report_path = Path(save).resolve()
else:
@@ -295,7 +299,7 @@ def research_report(
*,
title: str = "Research Report",
figsize: tuple = (14, 6),
show: bool = False,
show: "bool | str | None" = None,
save: Optional[Union[str, Path]] = None,
dpi: int = 150,
) -> List[Any]:
@@ -309,12 +313,13 @@ def research_report(
_ = title
figs = []
with theme_context():
# show=False: this function does its own showing at the end.
if sweep_result is not None:
figs.append(heatmap_2d(sweep_result, figsize=figsize))
figs.append(heatmap_2d(sweep_result, figsize=figsize, show=False))
if wf_result is not None:
figs.append(walk_forward(wf_result, figsize=figsize))
figs.append(walk_forward(wf_result, figsize=figsize, show=False))
if stability_result is not None:
figs.append(stability(stability_result, figsize=figsize))
figs.append(stability(stability_result, figsize=figsize, show=False))
if not figs:
raise ValueError("At least one result (sweep, wf, or stability) required.")
@@ -330,7 +335,7 @@ def research_report(
else:
scale = max(1.0, dpi / 96.0)
f.write_image(str(out), scale=scale)
if show:
if resolve_show(show, save):
for f in figs:
f.show()
+3 -1
View File
@@ -107,7 +107,9 @@ class SweepResult:
df = self.to_df(backend="pandas")
param_cols = [c for c in df.columns if c.startswith("param_")]
show = kwargs.pop("show", True)
# None = the auto default (show, unless save= or a notebook); both
# branches below hand it to finalize(), which resolves it.
show = kwargs.pop("show", None)
save = kwargs.pop("save", None)
if len(param_cols) == 2:
+21 -9
View File
@@ -11,13 +11,13 @@ import pytest
import manifoldbt as bt
from manifoldbt import run_with_parquet
# The golden fixtures were generated at full (Pro) resolution; the Community
# resolution cap changes the equity-point count and the comparison is
# meaningless. CI unlocks via BT_UNLOCKED=1 (debug builds); locally this needs
# an activated Pro license.
# The golden fixtures assert on 1-second output resolution, below even the Pro
# floor (60s) — exactly like the Rust golden test, which sets BT_UNLOCKED=1.
# The override is only honored by debug builds (cargo test / maturin develop),
# so this needs BOTH: a dev build and BT_UNLOCKED=1 in the environment.
pytestmark = pytest.mark.skipif(
bt.license_info()[0] != "Pro",
reason="requires Pro (fixtures generated at sub-daily resolution); activate a license or use a BT_UNLOCKED dev build",
os.environ.get("BT_UNLOCKED") != "1",
reason="requires BT_UNLOCKED=1 on a dev (debug) build: fixtures assert 1s output, below the Pro 60s floor",
)
@@ -38,9 +38,14 @@ def test_golden_buy_and_hold_matches_fixtures(golden_buy_hold_dir):
universe=[1],
time_range_start=0,
time_range_end=4_000_000_000,
bar_interval={"Days": 1},
# The fixture is 4 bars at 1-second spacing; the Rust golden test runs
# them at Seconds(1) with per-bar output. Days(1) would resample the
# whole range into a single bar and the comparison would be meaningless.
bar_interval={"Seconds": 1},
output_resolution={"Seconds": 1},
initial_capital=1000.0,
currency="USD",
risk_free_rate=0.025,
execution=bt.ExecutionConfig(
signal_delay=1,
execution_price="AtClose",
@@ -92,8 +97,11 @@ def test_golden_buy_and_hold_matches_fixtures(golden_buy_hold_dir):
with open(os.path.join(golden_buy_hold_dir, "expected_metrics.json")) as f:
expected_metrics = json.load(f)
# Mirror the Rust golden test: annualized metrics (CAGR, volatility,
# sharpe, sortino, calmar) are not compared because the fixture uses 4
# synthetic 1-second bars, making annualization numerically extreme.
metrics = result.metrics
for key in expected_metrics:
for key in ("total_return", "max_drawdown"):
assert abs(metrics[key] - expected_metrics[key]) <= 1e-12, (
f"Metric {key}: {metrics[key]} != {expected_metrics[key]}"
)
@@ -102,7 +110,11 @@ def test_golden_buy_and_hold_matches_fixtures(golden_buy_hold_dir):
with open(os.path.join(golden_buy_hold_dir, "expected_manifest_snapshot.json")) as f:
expected_manifest = json.load(f)
# Mirror the Rust golden test: engine_version is excluded from the snapshot
# (it tracks the crate version and would break on every release bump);
# assert only that it is populated.
manifest = result.manifest
assert manifest["strategy_name"] == expected_manifest["strategy_name"]
assert manifest["engine_version"] == expected_manifest["engine_version"]
assert manifest["engine_version"], "engine_version should be populated"
assert manifest["data_versions"].get("bars_1m", "") == expected_manifest["data_version"]
assert manifest["config"] == expected_manifest["config"]
+164
View File
@@ -0,0 +1,164 @@
"""Tests for bt.import_dataframe — in-memory DataFrame → Arrow IPC store.
The contract under test: import_dataframe is the in-memory twin of
import_csv. Same data through either path must produce an identical store
(same backtest results), and the normalisation layer must give clear errors
for bad inputs instead of a Rust panic.
"""
import os
import pytest
import manifoldbt as bt
pd = pytest.importorskip("pandas")
N_BARS = 120
START_MS = 1_577_836_800_000 # 2020-01-01T00:00:00Z
def _bars_df(n=N_BARS, tz="UTC"):
"""Synthetic 1m bars as a pandas DataFrame."""
ts = pd.date_range("2020-01-01", periods=n, freq="1min", tz=tz)
close = [100.0 + i * 0.5 for i in range(n)]
return pd.DataFrame(
{
"timestamp": ts,
"open": close,
"high": [c + 1.0 for c in close],
"low": [c - 1.0 for c in close],
"close": close,
"volume": [10.0] * n,
}
)
def _store_paths(tmp_path, name):
root = tmp_path / name
return str(root / "data"), str(root / "metadata.sqlite")
def _import_df(df, tmp_path, name="df", **kw):
data_root, metadata_db = _store_paths(tmp_path, name)
os.makedirs(os.path.dirname(metadata_db), exist_ok=True)
return bt.import_dataframe(
df, symbol="BTCUSDT", symbol_id=1,
data_root=data_root, metadata_db=metadata_db, **kw
)
def _run_buy_and_hold(store):
strategy = bt.Strategy(
name="bh",
signals={"signal": bt.lit(1.0)},
position_sizing=bt.col("signal"),
)
config = bt.BacktestConfig(
universe=[1],
time_range_start=0,
time_range_end=START_MS * 1_000_000 + N_BARS * 60_000_000_000,
bar_interval={"Minutes": 1},
initial_capital=1000.0,
currency="USD",
execution=bt.ExecutionConfig(
signal_delay=1,
execution_price="AtClose",
max_position_pct=1.0,
allow_short=False,
allow_fractional=True,
skip_gap_bars=False,
position_sizing_mode="Units",
),
fees=bt.FeeConfig(),
slippage={"FixedBps": {"bps": 0.0}},
rng_seed=7,
)
return bt.run(strategy, config, store)
def test_import_dataframe_roundtrip(tmp_path):
"""DataFrame → store → run produces a usable backtest."""
store = _import_df(_bars_df(), tmp_path)
assert store.resolve_symbol("BTCUSDT") == 1
result = _run_buy_and_hold(store)
equity = result.equity_curve.to_pylist()
assert len(equity) > 0
# Price rises monotonically → buy & hold ends above initial capital.
assert equity[-1] > 1000.0
def test_import_dataframe_matches_import_csv(tmp_path):
"""Same bars through import_csv and import_dataframe → identical results."""
df = _bars_df()
# CSV path (standard format: epoch-ms timestamp).
#
# Built from START_MS rather than derived from the datetime column:
# `.astype("int64")` returns the underlying integer in the COLUMN's
# resolution, which pandas picks for itself. Locally that was ns (so
# //1e6 gave ms), on CI it was us (so //1e6 gave seconds) and the import
# rejected the row. The bars are 1 minute apart by construction here, so
# spelling the epoch out keeps the CSV identical on every pandas.
csv_df = df.copy()
csv_df["timestamp"] = [START_MS + i * 60_000 for i in range(len(csv_df))]
csv_path = tmp_path / "bars.csv"
csv_df.to_csv(csv_path, index=False)
csv_root, csv_meta = _store_paths(tmp_path, "csv")
os.makedirs(os.path.dirname(csv_meta), exist_ok=True)
store_csv = bt.import_csv(
str(csv_path), symbol="BTCUSDT", symbol_id=1,
data_root=csv_root, metadata_db=csv_meta,
)
store_df = _import_df(df, tmp_path)
res_csv = _run_buy_and_hold(store_csv)
res_df = _run_buy_and_hold(store_df)
assert res_df.equity_curve.to_pylist() == res_csv.equity_curve.to_pylist()
assert res_df.metrics == res_csv.metrics
def test_import_dataframe_naive_timestamps_assumed_utc(tmp_path):
"""tz-naive datetimes are accepted and treated as UTC."""
naive = _bars_df(tz=None)
aware = _bars_df(tz="UTC")
store_naive = _import_df(naive, tmp_path, name="naive")
store_aware = _import_df(aware, tmp_path, name="aware")
assert _run_buy_and_hold(store_naive).equity_curve.to_pylist() == \
_run_buy_and_hold(store_aware).equity_curve.to_pylist()
def test_import_dataframe_datetime_index_promoted(tmp_path):
"""A pandas DatetimeIndex is used as the timestamp column."""
df = _bars_df().set_index("timestamp")
assert "timestamp" not in df.columns
store = _import_df(df, tmp_path)
assert store.resolve_symbol("BTCUSDT") == 1
def test_import_dataframe_polars(tmp_path):
"""Polars DataFrames go through the zero-copy to_arrow path."""
pl = pytest.importorskip("polars")
df = pl.from_pandas(_bars_df())
store = _import_df(df, tmp_path)
assert store.resolve_symbol("BTCUSDT") == 1
def test_import_dataframe_missing_column_raises(tmp_path):
df = _bars_df().drop(columns=["volume"])
with pytest.raises(bt.DataError, match="volume"):
_import_df(df, tmp_path)
def test_import_dataframe_integer_timestamp_raises(tmp_path):
"""Epoch integers are ambiguous (ms? ns?) — require datetimes."""
df = _bars_df()
df["timestamp"] = df["timestamp"].astype("int64")
with pytest.raises(bt.DataError, match="datetime"):
_import_df(df, tmp_path)
def test_import_dataframe_empty_raises(tmp_path):
with pytest.raises(bt.DataError, match="no data rows"):
_import_df(_bars_df(0), tmp_path)
+4 -2
View File
@@ -31,7 +31,9 @@ def test_sweep_returns_one_result_per_combo(golden_buy_hold_dir):
universe=[1],
time_range_start=0,
time_range_end=4_000_000_000,
bar_interval={"Days": 1},
# Fixture bars are 1-second spaced; Days(1) collapses them into a
# single bar and signal_delay=1 then never fills → zero trades.
bar_interval={"Seconds": 1},
execution=bt.ExecutionConfig(
signal_delay=1,
execution_price="AtClose",
@@ -89,7 +91,7 @@ def test_sweep_golden_grid_deterministic_order(golden_buy_hold_dir):
universe=[1],
time_range_start=0,
time_range_end=4_000_000_000,
bar_interval={"Days": 1},
bar_interval={"Seconds": 1},
execution=bt.ExecutionConfig(
signal_delay=1,
execution_price="AtClose",
+109
View File
@@ -0,0 +1,109 @@
"""Sweeping a parameter the strategy never declares must fail loudly.
It used to be a silent no-op: the unknown name landed in a parameter map
nothing reads, so every combination ran the same backtest and the sweep
returned N identical results with no warning. An "optimisation" over
thousands of combos looked like it had worked.
These tests only exercise the Python-side guard, so they need no data store:
validation happens before any native call.
"""
import pytest
import manifoldbt as bt
from manifoldbt.exceptions import StrategyError
from manifoldbt.indicators import close, ema
def _declared():
"""Strategy whose 'fast' comes from mbt.param() inside an indicator."""
fast = ema(close, bt.param("fast"))
return (
bt.Strategy.create("declared")
.signal("fast", fast)
.size(bt.when(close > fast, 1.0, 0.0))
)
def _hardcoded():
"""The shape that caused the bug: the period is a literal, not a param."""
fast = ema(close, 12)
return (
bt.Strategy.create("hardcoded")
.signal("fast", fast)
.size(bt.when(close > fast, 1.0, 0.0))
)
def _cfg():
# Never reaches the engine: the guard raises before config is used.
return bt.BacktestConfig(universe={"binance": ["BTC-USDT:perp"]})
def test_sweep_lite_rejects_undeclared_param():
with pytest.raises(StrategyError) as exc:
bt.run_sweep_lite(_hardcoded(), {"fast": [10, 20, 30]}, _cfg(), None)
msg = str(exc.value)
assert "fast" in msg
# The message must say what to do, not just that it failed.
assert "mbt.param" in msg
def test_sweep_rejects_undeclared_param():
with pytest.raises(StrategyError):
bt.run_sweep(_hardcoded(), {"fast": [10, 20]}, _cfg(), None)
def test_walk_forward_rejects_undeclared_param():
wf = {
"method": "Rolling", "n_splits": 2, "train_ratio": 0.7,
"optimize_metric": "sharpe", "param_grid": {"fast": [10, 20]},
}
with pytest.raises((StrategyError, bt.LicenseError)) as exc:
bt.run_walk_forward(_hardcoded(), wf, _cfg(), None)
# Walk-forward is Pro-gated first; only assert our message when we got past it.
if isinstance(exc.value, StrategyError):
assert "fast" in str(exc.value)
def test_sweep_2d_rejects_undeclared_params():
sweep = {
"x_param": "fast", "y_param": "slow",
"x_values": [5, 10], "y_values": [20, 40], "metric": "sharpe",
}
with pytest.raises(StrategyError) as exc:
bt.run_sweep_2d(_hardcoded(), sweep, _cfg(), None)
assert "fast" in str(exc.value) and "slow" in str(exc.value)
def test_stability_rejects_undeclared_param():
stab = {"param_name": "fast", "values": [5, 10, 15], "metric": "sharpe"}
with pytest.raises(StrategyError) as exc:
bt.run_stability(_hardcoded(), stab, _cfg(), None)
assert "fast" in str(exc.value)
def test_declared_param_passes_validation():
"""A declared param must get past the guard (it then fails on the store)."""
with pytest.raises(Exception) as exc:
bt.run_sweep_lite(_declared(), {"fast": [10, 20]}, _cfg(), None)
# Whatever stops it next, it must not be our guard.
assert "not declared" not in str(exc.value)
def test_explicit_param_call_counts_as_declared():
""".param() declares a name even when no expression references it."""
strat = _hardcoded().param("fast", default=12)
with pytest.raises(Exception) as exc:
bt.run_sweep_lite(strat, {"fast": [10, 20]}, _cfg(), None)
assert "not declared" not in str(exc.value)
def test_message_lists_only_the_unknown_names():
"""A mixed grid must blame the unknown name, not the good one."""
with pytest.raises(StrategyError) as exc:
bt.run_sweep_lite(_declared(), {"fast": [10], "slow": [50]}, _cfg(), None)
msg = str(exc.value)
assert "slow" in msg
# 'fast' is declared, so it must appear as available, never as unknown.
assert "['slow']" in msg