release: v0.14.0

This commit is contained in:
github-actions[bot]
2026-07-19 02:07:07 +00:00
parent d36bc7ee4c
commit 8bb39852d8
32 changed files with 1410 additions and 120 deletions
+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