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) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||||
|
)
|
||||||
@@ -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]
|
||||||
Reference in New Issue
Block a user