bit-backtest-lab / tests /test_engine.py
Bit-Trading-Company's picture
Backtest Lab v1.0.0
46f1a78 verified
Raw
History Blame Contribute Delete
20.3 kB
"""Phase 1 acceptance: the six known-answer tests, plus engine invariants.
The numbered tests map one-to-one onto the build spec's list. They are the gate
for the whole engine; if any of them fails, nothing downstream is trustworthy.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
from src import strategies
from src.engine import (
BacktestConfig,
Costs,
EngineError,
LookaheadError,
Sizing,
Stops,
StrategyOutput,
Validation,
assert_causal,
build_validation_plan,
run_backtest,
)
BARS_PER_YEAR = 365.0
# --------------------------------------------------------------------------
# Deterministic synthetic price series
# --------------------------------------------------------------------------
def prices(n=400, seed=7, start="2022-01-01", freq="D", trend=0.0004, vol=0.02):
rng = np.random.default_rng(seed)
steps = rng.normal(trend, vol, n)
close = 100.0 * np.exp(np.cumsum(steps))
idx = pd.date_range(start, periods=n, freq=freq, tz="UTC")
close = pd.Series(close, index=idx)
open_ = close.shift(1).fillna(close.iloc[0] * 0.999)
high = pd.concat([open_, close], axis=1).max(axis=1) * 1.004
low = pd.concat([open_, close], axis=1).min(axis=1) * 0.996
return pd.DataFrame({
"open": open_, "high": high, "low": low, "close": close,
"volume": pd.Series(rng.uniform(800, 1200, n), index=idx),
"source": "synthetic",
})
def linear_prices(n=100, start="2022-01-01"):
"""Monotone series -- makes hand-computed expectations trivial."""
idx = pd.date_range(start, periods=n, freq="D", tz="UTC")
close = pd.Series(np.linspace(100.0, 200.0, n), index=idx)
open_ = close * 0.995
return pd.DataFrame({
"open": open_, "high": close * 1.01, "low": open_ * 0.99,
"close": close, "volume": 1000.0, "source": "synthetic",
})
def no_costs() -> Costs:
return Costs(enabled=False)
def cfg(**kw) -> BacktestConfig:
base = dict(
asset="BTC-USD", timeframe="1d", strategy="Buy & Hold (benchmark)",
costs=no_costs(), validation=Validation(mode="none"), init_cash=100_000.0,
)
base.update(kw)
return BacktestConfig(**base)
# ==========================================================================
# KNOWN-ANSWER TEST 1
# Buy & Hold with zero costs reproduces the asset's return over the period.
# ==========================================================================
@pytest.mark.parametrize("px", [linear_prices(), prices(n=300, seed=3)])
def test_ka1_buy_and_hold_zero_costs_matches_asset_return(px):
out = strategies.buy_and_hold(px)
res = run_backtest(px, out, cfg(), bars_per_year=BARS_PER_YEAR)
# Execution is next-bar-open, so the position is opened at the second bar's
# open. That fill price is the honest basis for "the asset's return".
expected = float(px["close"].iloc[-1] / px["open"].iloc[1] - 1.0)
assert res.metrics_all.total_return == pytest.approx(expected, abs=1e-9)
assert len(res.trades) == 1
assert res.trades["costs"].sum() == pytest.approx(0.0, abs=1e-12)
def test_ka1_entry_fill_is_the_second_bars_open_not_the_first():
px = linear_prices()
res = run_backtest(px, strategies.buy_and_hold(px), cfg(), bars_per_year=BARS_PER_YEAR)
entry_px = float(res.trades["entry_px"].iloc[0])
assert entry_px == pytest.approx(float(px["open"].iloc[1]), abs=1e-9)
assert entry_px != pytest.approx(float(px["open"].iloc[0]), abs=1e-9)
assert res.trades["entry_ts"].iloc[0] == px.index[1]
# ==========================================================================
# KNOWN-ANSWER TEST 2
# A deliberately lookahead-biased strategy is caught structurally.
# ==========================================================================
def peeking_strategy(px: pd.DataFrame) -> StrategyOutput:
"""Cheats: decides at bar t using bar t+1's close."""
future = px["close"].shift(-1)
entries = (future > px["close"]).fillna(False)
exits = (future <= px["close"]).fillna(False)
return StrategyOutput(entries=entries, exits=exits)
def subtle_peeking_strategy(px: pd.DataFrame) -> StrategyOutput:
"""Cheats less obviously: a centred rolling mean leaks the future."""
centred = px["close"].rolling(11, center=True, min_periods=1).mean()
entries = (px["close"] > centred).fillna(False)
exits = (px["close"] <= centred).fillna(False)
return StrategyOutput(entries=entries, exits=exits)
def test_ka2_obvious_lookahead_is_caught():
px = prices(n=300, seed=11)
with pytest.raises(LookaheadError, match="after the bar it acts on"):
assert_causal(peeking_strategy, px)
def test_ka2_subtle_lookahead_is_caught():
px = prices(n=300, seed=12)
with pytest.raises(LookaheadError):
assert_causal(subtle_peeking_strategy, px)
def test_ka2_global_normalisation_lookahead_is_caught():
"""Scaling by the full-sample max leaks the future into every early bar."""
def global_scaled(px):
z = px["close"] / px["close"].max()
entries = (z > 0.8).fillna(False)
return StrategyOutput(entries=entries, exits=(~entries).fillna(False))
with pytest.raises(LookaheadError):
assert_causal(global_scaled, prices(n=300, seed=13))
@pytest.mark.parametrize("name", [
"Buy & Hold (benchmark)", "SMA Crossover", "RSI Mean Reversion",
"Bollinger Breakout", "MACD Momentum", "Sentiment-Gated Momentum",
])
def test_ka2_every_shipped_preset_is_causal(name):
px = prices(n=400, seed=5)
assert_causal(lambda p: strategies.build(name, p, strategies.defaults_for(name)), px)
def test_ka2_forecast_follower_is_causal_against_stored_signals():
px = prices(n=300, seed=17)
sig = pd.DataFrame({
"q10": px["close"] * 0.97, "q50": px["close"] * 1.01, "q90": px["close"] * 1.05,
}, index=px.index)
# Signals are pinned, so only the price path is perturbed -- exactly the
# situation the store creates when a cached forecast slice is replayed.
assert_causal(lambda p: strategies.forecast_follower(p, {"threshold": 0.005}, sig), px)
def test_ka2_engine_refuses_same_bar_fills():
px = linear_prices()
with pytest.raises(EngineError, match="non-negotiable"):
run_backtest(px, strategies.buy_and_hold(px), cfg(fill="same_bar_close"),
bars_per_year=BARS_PER_YEAR)
def test_ka2_decisions_cannot_be_shifted_twice():
from src.engine import _shift_decisions
px = linear_prices()
once = _shift_decisions(strategies.buy_and_hold(px))
with pytest.raises(EngineError, match="already shifted"):
_shift_decisions(once)
# ==========================================================================
# KNOWN-ANSWER TEST 3
# Zero-cost vs costed runs differ by exactly the modeled costs on the trades.
# ==========================================================================
def alternating(px: pd.DataFrame, hold=7, gap=5) -> StrategyOutput:
"""Deterministic in-and-out signal, independent of price."""
entries = pd.Series(False, index=px.index)
exits = pd.Series(False, index=px.index)
i = 10
while i + hold < len(px) - 2:
entries.iloc[i] = True
exits.iloc[i + hold] = True
i += hold + gap
return StrategyOutput(entries=entries, exits=exits)
def test_ka3_cost_difference_equals_sum_of_trade_costs():
px = prices(n=300, seed=21)
out = alternating(px)
# Fixed unit sizing keeps costs additive: with percent sizing, fees change
# position size and the difference compounds instead of summing.
sizing = Sizing(mode="fixed_units", units=1.0)
free = run_backtest(px, alternating(px),
cfg(costs=no_costs(), sizing=sizing), bars_per_year=BARS_PER_YEAR)
paid = run_backtest(px, out,
cfg(costs=Costs(enabled=True, commission_bps=10.0,
slippage_bps=5.0, slippage_model="fixed"),
sizing=sizing), bars_per_year=BARS_PER_YEAR)
assert len(free.trades) == len(paid.trades) > 3
pnl_gap = float(free.trades["net_pnl"].sum() - paid.trades["net_pnl"].sum())
modeled = float(paid.trades["costs"].sum())
assert pnl_gap == pytest.approx(modeled, rel=1e-9, abs=1e-6)
def test_ka3_gross_minus_costs_equals_net_on_every_trade():
px = prices(n=300, seed=22)
res = run_backtest(px, alternating(px),
cfg(costs=Costs(enabled=True, commission_bps=8.0, slippage_bps=4.0),
sizing=Sizing(mode="fixed_units", units=1.0)),
bars_per_year=BARS_PER_YEAR)
residual = res.trades["gross_pnl"] - res.trades["costs"] - res.trades["net_pnl"]
assert np.allclose(residual.to_numpy(), 0.0, atol=1e-9)
def test_ka3_costs_are_on_by_default():
c = Costs()
assert c.enabled
assert c.commission_rate > 0 and c.slippage_rate > 0
assert BacktestConfig().costs.enabled
def test_ka3_more_slippage_never_helps():
px = prices(n=300, seed=23)
sizing = Sizing(mode="fixed_units", units=1.0)
returns = []
for bps in (0.0, 5.0, 20.0):
r = run_backtest(px, alternating(px),
cfg(costs=Costs(enabled=True, commission_bps=0.0, slippage_bps=bps),
sizing=sizing), bars_per_year=BARS_PER_YEAR)
returns.append(float(r.trades["net_pnl"].sum()))
assert returns[0] > returns[1] > returns[2]
# ==========================================================================
# KNOWN-ANSWER TEST 4
# A strategy with no signals produces flat equity and zero trades.
# ==========================================================================
def test_ka4_no_signals_gives_flat_equity_and_no_trades():
px = prices(n=200, seed=31)
silent = StrategyOutput(
entries=pd.Series(False, index=px.index),
exits=pd.Series(False, index=px.index),
)
res = run_backtest(px, silent, cfg(), bars_per_year=BARS_PER_YEAR)
assert len(res.trades) == 0
assert res.metrics_all.trade_count == 0
assert res.equity.nunique() == 1
assert float(res.equity.iloc[-1]) == pytest.approx(100_000.0, abs=1e-9)
assert res.metrics_all.total_return == pytest.approx(0.0, abs=1e-12)
assert res.metrics_all.max_drawdown == pytest.approx(0.0, abs=1e-12)
assert res.metrics_all.sharpe == 0.0
assert res.metrics_all.win_rate == 0.0
assert res.metrics_all.profit_factor == 0.0
assert res.costs_paid == 0.0
def test_ka4_flat_run_with_costs_still_pays_nothing():
px = prices(n=200, seed=32)
silent = StrategyOutput(entries=pd.Series(False, index=px.index),
exits=pd.Series(False, index=px.index))
res = run_backtest(px, silent, cfg(costs=Costs(enabled=True)), bars_per_year=BARS_PER_YEAR)
assert res.costs_paid == 0.0
assert float(res.equity.iloc[-1]) == pytest.approx(100_000.0, abs=1e-9)
# ==========================================================================
# KNOWN-ANSWER TEST 5
# Walk-forward boundaries never overlap; the holdout is never selectable.
# ==========================================================================
def test_ka5_walk_forward_windows_never_overlap_train_and_test():
idx = pd.date_range("2021-01-01", "2026-01-01", freq="D", tz="UTC")
plan = build_validation_plan(idx, Validation(
mode="walk_forward", train_months=12, test_months=3, roll_months=3,
))
assert len(plan.windows) >= 4
for w in plan.windows:
assert w.train_start < w.train_end
assert w.test_start > w.train_end, f"window {w.idx} overlaps"
assert w.test_start <= w.test_end
def test_ka5_consecutive_test_windows_do_not_overlap_each_other():
idx = pd.date_range("2021-01-01", "2026-01-01", freq="D", tz="UTC")
plan = build_validation_plan(idx, Validation(
mode="walk_forward", train_months=12, test_months=3, roll_months=3,
))
for a, b in zip(plan.windows, plan.windows[1:]):
assert b.test_start > a.test_end
def test_ka5_holdout_is_excluded_from_the_parameter_selection_index():
idx = pd.date_range("2021-01-01", "2026-01-01", freq="D", tz="UTC")
plan = build_validation_plan(idx, Validation(mode="holdout", holdout_months=6))
assert plan.holdout_start is not None
selectable = plan.selectable_index()
holdout = idx[idx >= plan.holdout_start]
assert len(holdout) > 0
assert len(set(selectable) & set(holdout)) == 0
assert selectable.max() < holdout.min()
def test_ka5_walk_forward_windows_never_reach_into_the_holdout():
idx = pd.date_range("2021-01-01", "2026-01-01", freq="D", tz="UTC")
v = Validation(mode="walk_forward", train_months=12, test_months=3,
roll_months=3, holdout_months=6)
plan = build_validation_plan(idx, v)
assert plan.holdout_start is not None
assert len(plan.windows) > 0
for w in plan.windows:
assert w.test_end < plan.holdout_start
assert w.train_end < plan.holdout_start
def test_ka5_holdout_bars_are_labelled_holdout_not_oos():
px = prices(n=900, seed=41, start="2022-01-01")
res = run_backtest(px, strategies.buy_and_hold(px),
cfg(validation=Validation(mode="holdout", holdout_months=6)),
bars_per_year=BARS_PER_YEAR)
assert res.metrics_holdout is not None
assert res.metrics_holdout.bars > 0
assert res.plan.segment_of(px.index[-1]) == "holdout"
assert res.plan.segment_of(px.index[0]) == "IS"
def test_ka5_split_mode_partitions_every_bar_exactly_once():
"""With no holdout reserved, IS and OOS must tile the whole period."""
px = prices(n=400, seed=42)
res = run_backtest(px, strategies.buy_and_hold(px),
cfg(validation=Validation(mode="split", split_frac=0.7,
holdout_months=0)),
bars_per_year=BARS_PER_YEAR)
assert res.metrics_holdout is None
assert res.metrics_is.bars + res.metrics_oos.bars == len(px)
assert res.metrics_is.bars == pytest.approx(280, abs=1)
def test_ka5_holdout_is_carved_out_of_split_mode_too():
"""A reserved holdout is honoured regardless of how the rest is divided."""
px = prices(n=900, seed=43, start="2022-01-01")
res = run_backtest(px, strategies.buy_and_hold(px),
cfg(validation=Validation(mode="split", split_frac=0.7,
holdout_months=6)),
bars_per_year=BARS_PER_YEAR)
assert res.metrics_holdout is not None and res.metrics_holdout.bars > 0
total = res.metrics_is.bars + res.metrics_oos.bars + res.metrics_holdout.bars
assert total == len(px)
# ==========================================================================
# KNOWN-ANSWER TEST 6
# Same config + same data => bit-identical results.
# ==========================================================================
def _signature(res) -> tuple:
return (
tuple(np.round(res.equity.to_numpy(), 12)),
tuple(np.round(res.trades["net_pnl"].to_numpy(), 12)),
tuple(np.round(res.trades["costs"].to_numpy(), 12)),
res.metrics_all.total_return,
res.metrics_all.sharpe,
res.metrics_oos.sharpe,
)
def test_ka6_repeated_runs_are_bit_identical():
px = prices(n=400, seed=51)
c = cfg(strategy="SMA Crossover", costs=Costs(enabled=True),
validation=Validation(mode="walk_forward"))
params = {"fast_ma": 20, "slow_ma": 50}
runs = [
run_backtest(px, strategies.build("SMA Crossover", px, params), c,
bars_per_year=BARS_PER_YEAR)
for _ in range(3)
]
assert _signature(runs[0]) == _signature(runs[1]) == _signature(runs[2])
def test_ka6_fingerprint_is_stable_and_config_sensitive():
a = cfg(strategy="SMA Crossover", params={"fast_ma": 20})
b = cfg(strategy="SMA Crossover", params={"fast_ma": 20})
c = cfg(strategy="SMA Crossover", params={"fast_ma": 21})
assert a.fingerprint() == b.fingerprint()
assert a.fingerprint() != c.fingerprint()
def test_ka6_cost_change_changes_the_fingerprint():
a = cfg(costs=Costs(enabled=True, commission_bps=10.0))
b = cfg(costs=Costs(enabled=True, commission_bps=11.0))
assert a.fingerprint() != b.fingerprint()
# ==========================================================================
# Engine invariants beyond the six
# ==========================================================================
def test_trade_list_carries_every_documented_column():
px = prices(n=300, seed=61)
res = run_backtest(px, alternating(px), cfg(costs=Costs(enabled=True)),
bars_per_year=BARS_PER_YEAR)
for col in ("id", "entry_ts", "exit_ts", "side", "entry_px", "exit_px", "size",
"gross_pnl", "costs", "net_pnl", "r_multiple", "mae", "mfe",
"duration_bars", "trigger", "segment"):
assert col in res.trades.columns
def test_mae_is_never_positive_and_mfe_never_negative_for_longs():
px = prices(n=300, seed=62)
res = run_backtest(px, alternating(px), cfg(), bars_per_year=BARS_PER_YEAR)
longs = res.trades[res.trades["side"] == "long"]
assert (longs["mae"] <= 1e-12).all()
assert (longs["mfe"] >= -1e-12).all()
def test_trigger_reason_is_populated_from_the_strategy():
px = prices(n=400, seed=63)
out = strategies.build("SMA Crossover", px, {"fast_ma": 10, "slow_ma": 30})
res = run_backtest(px, out, cfg(strategy="SMA Crossover"), bars_per_year=BARS_PER_YEAR)
assert len(res.trades) > 0
assert (res.trades["trigger"].str.len() > 0).all()
assert "crossed above" in res.trades["trigger"].iloc[0]
def test_index_mismatch_is_rejected():
px = prices(n=100, seed=64)
bad = StrategyOutput(
entries=pd.Series(False, index=px.index[:50]),
exits=pd.Series(False, index=px.index[:50]),
)
with pytest.raises(EngineError, match="index does not match"):
run_backtest(px, bad, cfg(), bars_per_year=BARS_PER_YEAR)
def test_empty_prices_are_rejected():
empty = pd.DataFrame(columns=["open", "high", "low", "close", "volume"])
out = StrategyOutput(entries=pd.Series(dtype=bool), exits=pd.Series(dtype=bool))
with pytest.raises(EngineError, match="no price data"):
run_backtest(empty, out, cfg(), bars_per_year=BARS_PER_YEAR)
def test_stops_are_applied_when_configured():
px = prices(n=400, seed=65, trend=-0.002, vol=0.03)
with_stop = run_backtest(px, alternating(px),
cfg(stops=Stops(sl_pct=0.02)), bars_per_year=BARS_PER_YEAR)
without = run_backtest(px, alternating(px), cfg(), bars_per_year=BARS_PER_YEAR)
assert with_stop.trades["net_pnl"].min() > without.trades["net_pnl"].min()
def test_r_multiple_uses_the_configured_stop_distance():
px = prices(n=300, seed=66)
res = run_backtest(px, alternating(px), cfg(stops=Stops(sl_pct=0.05)),
bars_per_year=BARS_PER_YEAR)
t = res.trades.iloc[0]
expected = t["net_pnl"] / (0.05 * t["entry_px"] * t["size"])
assert t["r_multiple"] == pytest.approx(expected, rel=1e-9)
def test_unavailable_presets_refuse_to_run():
px = prices(n=100, seed=67)
with pytest.raises(ValueError, match="never executes untrusted code"):
strategies.build("Custom (code)", px)
with pytest.raises(ValueError, match="second leg"):
strategies.build("Pairs Trading", px)
def test_forecast_follower_requires_signals():
px = prices(n=100, seed=68)
with pytest.raises(ValueError, match="needs stored model signals"):
strategies.build("Chronos Forecast Follower", px, {}, signals=None)
def test_default_run_completes_well_under_two_seconds():
"""Perf budget: a 3-year daily run must leave room for chart building."""
px = prices(n=365 * 3, seed=69)
out = strategies.build("SMA Crossover", px, {"fast_ma": 20, "slow_ma": 50})
res = run_backtest(px, out, cfg(strategy="SMA Crossover", costs=Costs(enabled=True),
validation=Validation(mode="walk_forward")),
bars_per_year=BARS_PER_YEAR)
assert res.elapsed_s < 2.0, f"engine took {res.elapsed_s:.2f}s"