Team Ai
Modelpublic

ParallelLLC/algorithmic_trading

sourceHugging Faceapache-2.0updated 2mo agoView on Hugging Face
27likes32downloads
test_v2_engine.py159 linesDownload Raw Back to tests
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