import numpy as np import pandas as pd from ktrade.backtest.costs import CostModel, round_lot from ktrade.backtest.engine import run_backtest from ktrade.backtest.metrics import compute_metrics from ktrade.strategy.base import ParamSpec, Strategy, StrategyMeta from ktrade.strategy.benchmarks import BuyAndHold from tests.conftest import synthetic_candles class StepAt(Strategy): meta = StrategyMeta(name="step", label="step", family="test", hypothesis="go long at bar k", min_bars=1, params=[ParamSpec(name="k", type="int", default=10)]) def features(self, df): return pd.DataFrame({"ts": df["ts"].values}, index=df.index) def target(self, feats): t = pd.Series(0.0, index=feats.index) t.iloc[self.params["k"]:] = 1.0 return t def test_execution_happens_at_next_open_not_same_bar(): df = synthetic_candles(n=50, seed=2) k = 10 res = run_backtest(StepAt({"k": k}), df, interval_min=60, cost_model=CostModel(spread_bps=0, slippage_bps=0, impact_coeff=0, taker_fee=0, max_participation=10)) assert len(res.trades) == 1 t = res.trades[0] assert t.ts == int(df["ts"].iloc[k + 1]) # filled on bar k+1 assert t.ref_price == df["open"].iloc[k + 1] # at its OPEN assert res.equity["position"].iloc[k] == 0.0 # no position on the decision bar assert res.equity["position"].iloc[k + 1] > 0 def test_latency_adds_bars(): df = synthetic_candles(n=50, seed=2) res = run_backtest(StepAt({"k": 10}), df, interval_min=60, cost_model=CostModel(latency_bars=2, taker_fee=0, spread_bps=0, slippage_bps=0, impact_coeff=0, max_participation=10)) assert res.trades[0].ts == int(df["ts"].iloc[13]) def test_fees_and_slippage_are_charged(): df = synthetic_candles(n=50, seed=2) cm = CostModel(taker_fee=0.004, spread_bps=10, slippage_bps=5, impact_coeff=0, max_participation=10) res = run_backtest(StepAt({"k": 5}), df, interval_min=60, cost_model=cm, initial_cash=10_000) t = res.trades[0] assert t.price > t.ref_price # buy pays half-spread + slippage assert abs(t.fee - t.notional * 0.004) < 1e-9 assert res.fees_paid > 0 and res.slippage_paid > 0 assert abs(t.notional + t.fee - (10_000 - res.equity["cash"].iloc[6])) < 1e-6 def test_partial_fills_respect_participation_cap(): df = synthetic_candles(n=60, seed=2) df["volume"] = 0.01 # tiny bar volume → participation cap binds cm = CostModel(max_participation=0.5, taker_fee=0, spread_bps=0, slippage_bps=0, impact_coeff=0) res = run_backtest(StepAt({"k": 5}), df, interval_min=60, cost_model=cm, initial_cash=10_000) assert all(t.qty <= 0.005 + 1e-12 for t in res.trades) assert any(t.partial for t in res.trades) assert len(res.trades) > 1 # remainder keeps being worked on following bars def test_lot_rounding_and_min_size(): assert round_lot(1.23456789123, 8) == 1.23456789 assert round_lot(0.000000001, 8) == 0.0 df = synthetic_candles(n=40, seed=2) res = run_backtest(StepAt({"k": 5}), df, interval_min=60, initial_cash=1.0, ordermin=0.0001, costmin=0.5, cost_model=CostModel(max_participation=10)) assert res.trades == [] and res.rejected # 1 USD cannot buy ≥ 0.0001 BTC at 30k → rejected by the gateway def test_buy_and_hold_equity_tracks_price(): df = synthetic_candles(n=300, seed=4) cm = CostModel(taker_fee=0, spread_bps=0, slippage_bps=0, impact_coeff=0, max_participation=10) res = run_backtest(BuyAndHold(), df, interval_min=60, cost_model=cm, initial_cash=10_000) entry = df["open"].iloc[1] expected = 10_000 / entry * df["close"].iloc[-1] assert abs(res.equity["equity"].iloc[-1] - expected) / expected < 1e-6 m = compute_metrics(res.equity, res.trades, 60, 10_000, 0, 0, res.turnover) assert m["bars"] == 300 and np.isfinite(m["sharpe"]) and -1 <= m["max_drawdown"] <= 0 def test_never_short_and_never_negative_cash(): from ktrade.strategy.momentum import TimeSeriesMomentum df = synthetic_candles(n=1500, seed=9, vol=0.03) res = run_backtest(TimeSeriesMomentum(), df, interval_min=60) assert (res.equity["position"] >= -1e-12).all() assert (res.equity["cash"] >= -1e-6).all()