From 9301ab905103f73f643505be6d2b48775d7168d8 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sat, 9 May 2026 19:15:05 +0200 Subject: [PATCH] feat(backtest): event-driven engine with 1-bar exec delay Engine sincrono bar-per-bar con delay 1: segnale a t-1 esegue a open di t per evitare lookahead. Position sizing 1 unit, fees su entry+exit, mark-to-market su close, chiusura forzata posizione open a fine serie. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/multi_swarm/backtest/engine.py | 101 +++++++++++++++++++++++++++++ tests/unit/test_backtest_engine.py | 56 ++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 src/multi_swarm/backtest/engine.py create mode 100644 tests/unit/test_backtest_engine.py diff --git a/src/multi_swarm/backtest/engine.py b/src/multi_swarm/backtest/engine.py new file mode 100644 index 0000000..df2e14a --- /dev/null +++ b/src/multi_swarm/backtest/engine.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import pandas as pd # type: ignore[import-untyped] + +from .orders import Position, Side, Trade + +Signal = Side # alias semantico + + +@dataclass(frozen=True) +class BacktestResult: + equity_curve: pd.Series + returns: pd.Series + trades: list[Trade] + + +class BacktestEngine: + """Engine event-driven sincrono: itera bar per bar, applica segnali con + delay di 1 bar (segnale a t -> eseguito a t+1 open) per evitare lookahead. + + Position sizing: 1 unit per posizione. Fees applicati su entry+exit. + Niente leva, niente liquidation, niente funding (semplificazione Phase 1). + """ + + def __init__(self, fees_bp: float = 5.0) -> None: + self.fees_bp = fees_bp + + def run(self, ohlcv: pd.DataFrame, signals: pd.Series) -> BacktestResult: + signals = signals.reindex(ohlcv.index).ffill().fillna(Side.FLAT) + + # Esecuzione con delay 1: segnale a t-1 esegue a open di t. + shifted = [Side.FLAT, *list(signals.iloc[:-1])] + executed_side = pd.Series(shifted, index=ohlcv.index, dtype=object) + + position: Position | None = None + position_entry_ts: pd.Timestamp | None = None + trades: list[Trade] = [] + equity = 0.0 + equity_history: list[float] = [] + returns_history: list[float] = [] + prev_equity = 0.0 + + for ts, row in ohlcv.iterrows(): + target_side = executed_side.loc[ts] + current_side = position.side if position else Side.FLAT + + if target_side != current_side: + if position is not None: + assert position_entry_ts is not None + trade = Trade( + entry_ts=position_entry_ts, + exit_ts=ts, + side=position.side, + size=position.size, + entry_price=position.entry_price, + exit_price=float(row["open"]), + fees_bp=self.fees_bp, + ) + trades.append(trade) + equity += trade.net_pnl + position = None + position_entry_ts = None + if target_side in (Side.LONG, Side.SHORT): + position = Position( + side=target_side, entry_price=float(row["open"]), size=1.0 + ) + position_entry_ts = ts + + mark = float(row["close"]) + mtm = position.unrealized_pnl(mark) if position else 0.0 + current_equity = equity + mtm + equity_history.append(current_equity) + returns_history.append(current_equity - prev_equity) + prev_equity = current_equity + + if position is not None: + assert position_entry_ts is not None + last_ts = ohlcv.index[-1] + last_close = float(ohlcv["close"].iloc[-1]) + trade = Trade( + entry_ts=position_entry_ts, + exit_ts=last_ts, + side=position.side, + size=position.size, + entry_price=position.entry_price, + exit_price=last_close, + fees_bp=self.fees_bp, + ) + trades.append(trade) + equity += trade.net_pnl + equity_history[-1] = equity + if len(returns_history) >= 2: + returns_history[-1] = equity - equity_history[-2] + + return BacktestResult( + equity_curve=pd.Series(equity_history, index=ohlcv.index, name="equity"), + returns=pd.Series(returns_history, index=ohlcv.index, name="returns"), + trades=trades, + ) diff --git a/tests/unit/test_backtest_engine.py b/tests/unit/test_backtest_engine.py new file mode 100644 index 0000000..a3a8f9d --- /dev/null +++ b/tests/unit/test_backtest_engine.py @@ -0,0 +1,56 @@ +import numpy as np +import pandas as pd +import pytest + +from multi_swarm.backtest.engine import BacktestEngine +from multi_swarm.backtest.orders import Side + + +@pytest.fixture +def trending_ohlcv() -> pd.DataFrame: + idx = pd.date_range("2024-01-01", periods=100, freq="1h", tz="UTC") + close = np.linspace(100, 120, 100) + df = pd.DataFrame( + {"open": close, "high": close + 0.5, "low": close - 0.5, "close": close, "volume": 1.0}, + index=idx, + ) + return df + + +def test_engine_no_signals_zero_pnl(trending_ohlcv: pd.DataFrame) -> None: + signals = pd.Series([Side.FLAT] * len(trending_ohlcv), index=trending_ohlcv.index) + engine = BacktestEngine(fees_bp=5.0) + result = engine.run(trending_ohlcv, signals) + assert result.equity_curve.iloc[-1] == pytest.approx(0.0) + assert len(result.trades) == 0 + + +def test_engine_long_in_uptrend_makes_profit(trending_ohlcv: pd.DataFrame) -> None: + signals = pd.Series([Side.LONG] * len(trending_ohlcv), index=trending_ohlcv.index) + engine = BacktestEngine(fees_bp=5.0) + result = engine.run(trending_ohlcv, signals) + assert result.equity_curve.iloc[-1] > 0 + assert len(result.trades) == 1 + assert result.trades[0].side == Side.LONG + + +def test_engine_position_flips_on_side_change(trending_ohlcv: pd.DataFrame) -> None: + half = len(trending_ohlcv) // 2 + signals = pd.Series( + [Side.LONG] * half + [Side.SHORT] * (len(trending_ohlcv) - half), + index=trending_ohlcv.index, + ) + engine = BacktestEngine(fees_bp=5.0) + result = engine.run(trending_ohlcv, signals) + assert len(result.trades) == 2 + assert result.trades[0].side == Side.LONG + assert result.trades[1].side == Side.SHORT + + +def test_engine_fees_are_subtracted(trending_ohlcv: pd.DataFrame) -> None: + signals = pd.Series([Side.LONG] * len(trending_ohlcv), index=trending_ohlcv.index) + engine_no_fees = BacktestEngine(fees_bp=0.0) + engine_fees = BacktestEngine(fees_bp=10.0) + r1 = engine_no_fees.run(trending_ohlcv, signals) + r2 = engine_fees.run(trending_ohlcv, signals) + assert r1.equity_curve.iloc[-1] > r2.equity_curve.iloc[-1]