ParallelLLC/algorithmic_trading
2732
1"""Engine correctness: alignment, costs, and the no-look-ahead guarantee."""2 3from __future__ import annotations4 5import numpy as np6import pandas as pd7import pytest8 9from algotrader.engine import bars_to_returns, run_backtest10from algotrader.metrics import compute_metrics, infer_periods_per_year, max_drawdown, sharpe_ratio11from algotrader.types import CostModel12 13 14def make_bars(n: int = 400, seed: int = 0) -> pd.DataFrame:15 rng = np.random.default_rng(seed)16 close = 100 * np.exp(np.cumsum(rng.normal(0.0003, 0.01, n)))17 index = pd.date_range("2020-01-01", periods=n, freq="B")18 return pd.DataFrame(19 {20 "open": close,21 "high": close * 1.005,22 "low": close * 0.995,23 "close": close,24 "volume": 1e6,25 },26 index=index,27 )28 29 30class TestNoLookAhead:31 """The one property the whole project rests on."""32 33 def test_signal_earns_the_following_bar_not_its_own(self):34 df = make_bars()35 asset_ret = bars_to_returns(df)36 37 # A target that knows the *next* bar's direction must be perfect...38 clairvoyant = np.sign(asset_ret.shift(-1)).fillna(0.0)39 good = run_backtest(df, clairvoyant, costs=CostModel(0, 0, 0))40 assert (good.returns.iloc[1:-1] >= -1e-12).all(), "perfect foresight should never lose"41 42 # ...and a target that only knows the *current* bar must not be.43 hindsight = np.sign(asset_ret).fillna(0.0)44 meh = run_backtest(df, hindsight, costs=CostModel(0, 0, 0))45 assert (meh.returns < 0).any(), "same-bar signal must not be risk-free"46 assert good.sharpe > meh.sharpe47 48 def test_position_is_target_shifted_by_lag(self):49 df = make_bars(200)50 target = pd.Series(np.linspace(-1, 1, len(df)), index=df.index)51 for lag in (1, 2, 5):52 result = run_backtest(df, target, lag=lag)53 expected = target.shift(lag).fillna(0.0)54 pd.testing.assert_series_equal(result.position, expected, check_names=False)55 56 def test_lag_zero_is_rejected(self):57 df = make_bars(120)58 with pytest.raises(ValueError, match="lag"):59 run_backtest(df, pd.Series(1.0, index=df.index), lag=0)60 61 def test_future_bars_cannot_change_past_equity(self):62 """Truncating the data must not alter the equity curve before the cut."""63 df = make_bars(400)64 target = pd.Series(np.tile([1.0, -1.0], len(df) // 2), index=df.index)65 66 full = run_backtest(df, target)67 cut = run_backtest(df.iloc[:250], target.iloc[:250])68 np.testing.assert_allclose(69 full.equity.iloc[:250].to_numpy(), cut.equity.to_numpy(), rtol=1e-1270 )71 72 73class TestCosts:74 def test_buy_and_hold_pays_once(self):75 df = make_bars(300)76 target = pd.Series(1.0, index=df.index)77 result = run_backtest(df, target, costs=CostModel(commission_bps=5, slippage_bps=5, short_borrow_bps=0))78 # One entry at 10bps one-way, and no further turnover.79 assert result.costs.sum() == pytest.approx(10 / 1e4, rel=1e-9)80 assert int(result.metrics["n_trades"]) == 181 82 def test_flipping_every_bar_costs_more_than_holding(self):83 df = make_bars(300)84 costs = CostModel(commission_bps=5, slippage_bps=5, short_borrow_bps=0)85 hold = run_backtest(df, pd.Series(1.0, index=df.index), costs=costs)86 flip = run_backtest(df, pd.Series(np.tile([1.0, -1.0], 150), index=df.index), costs=costs)87 assert flip.costs.sum() > 100 * hold.costs.sum()88 89 def test_zero_costs_means_gross_equals_net(self):90 df = make_bars(200)91 target = pd.Series(np.tile([1.0, 0.0], 100), index=df.index)92 result = run_backtest(df, target, costs=CostModel(0, 0, 0))93 pd.testing.assert_series_equal(result.returns, result.gross_returns, check_names=False)94 95 def test_short_borrow_is_charged_only_on_shorts(self):96 df = make_bars(260)97 costs = CostModel(commission_bps=0, slippage_bps=0, short_borrow_bps=365)98 long_only = run_backtest(df, pd.Series(1.0, index=df.index), costs=costs)99 short_only = run_backtest(df, pd.Series(-1.0, index=df.index), costs=costs)100 assert long_only.costs.sum() == pytest.approx(0.0, abs=1e-12)101 assert short_only.costs.sum() > 0102 103 def test_higher_costs_never_improve_returns(self):104 df = make_bars(300)105 target = pd.Series(np.tile([1.0, -1.0], 150), index=df.index)106 cheap = run_backtest(df, target, costs=CostModel(1, 1, 0))107 dear = run_backtest(df, target, costs=CostModel(20, 20, 0))108 assert dear.equity.iloc[-1] < cheap.equity.iloc[-1]109 110 111class TestConstraints:112 def test_shorts_are_clipped_when_disallowed(self):113 df = make_bars(150)114 target = pd.Series(-1.0, index=df.index)115 result = run_backtest(df, target, allow_short=False)116 assert (result.target >= 0).all()117 assert (result.position >= 0).all()118 119 def test_leverage_is_clipped(self):120 df = make_bars(150)121 result = run_backtest(df, pd.Series(5.0, index=df.index), max_leverage=1.5)122 assert result.target.max() == pytest.approx(1.5)123 124 def test_empty_frame_is_rejected(self):125 with pytest.raises(ValueError):126 run_backtest(pd.DataFrame(columns=["open", "high", "low", "close", "volume"]), pd.Series(dtype=float))127 128 129class TestMetrics:130 def test_sharpe_of_constant_returns_is_zero_not_infinite(self):131 flat = pd.Series([0.001] * 100)132 assert sharpe_ratio(flat, 252) == 0.0133 134 def test_sharpe_scales_with_annualisation(self):135 rng = np.random.default_rng(1)136 returns = pd.Series(rng.normal(0.001, 0.01, 5000))137 assert sharpe_ratio(returns, 252) == pytest.approx(sharpe_ratio(returns, 1) * np.sqrt(252))138 139 def test_max_drawdown_matches_a_hand_worked_example(self):140 equity = pd.Series([100.0, 120.0, 60.0, 90.0])141 assert max_drawdown(equity) == pytest.approx(-0.5)142 143 def test_buy_and_hold_metrics_match_the_price_series(self):144 df = make_bars(500)145 result = run_backtest(df, pd.Series(1.0, index=df.index), costs=CostModel(0, 0, 0))146 expected = df["close"].iloc[-1] / df["close"].iloc[0] - 1.0147 assert result.metrics["total_return"] == pytest.approx(expected, rel=1e-9)148 149 def test_periodicity_inference(self):150 daily = pd.date_range("2020-01-01", periods=300, freq="B")151 assert 200 <= infer_periods_per_year(daily) <= 300152 hourly = pd.date_range("2020-01-01", periods=300, freq="h")153 assert infer_periods_per_year(hourly) > 1000154 155 def test_metrics_survive_a_degenerate_series(self):156 empty = pd.Series(dtype=float)157 out = compute_metrics(empty, pd.Series(dtype=float))158 assert "periods_per_year" in out159 