mirror of
https://github.com/manifoldbt/manifoldbt.git
synced 2026-08-24 14:38:04 +00:00
release: v0.14.0
This commit is contained in:
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user