Engine: add stop loss integration (fixed & trailing) with tests

This commit is contained in:
DaM
2026-01-30 17:05:47 +01:00
parent af7b862f60
commit c569170fcc
24 changed files with 1121 additions and 137 deletions

View File

@@ -0,0 +1,93 @@
# tests/backtest/test_engine_sizing.py
import pytest
import sys
from pathlib import Path
import pandas as pd
from datetime import datetime, timedelta
# Añadir raíz del proyecto al path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from src.backtest.engine import BacktestEngine
from src.backtest.strategy import Strategy, Signal
from src.risk.sizing.fixed import FixedPositionSizer
class BuyOnceStrategy(Strategy):
"""
Estrategia dummy:
- BUY en la primera vela
- SELL en la segunda
"""
def __init__(self):
super().__init__(name="BuyOnce", params={})
def init_indicators(self, data: pd.DataFrame) -> pd.DataFrame:
return data
def generate_signal(self, idx: int) -> Signal:
if idx == 0:
return Signal.BUY
if idx == 1:
return Signal.SELL
return Signal.HOLD
def test_engine_uses_fixed_position_sizer():
"""
El engine debe usar el PositionSizer
y NO el position_size_fraction por defecto.
"""
# -------------------------
# Datos dummy
# -------------------------
dates = [
datetime(2024, 1, 1),
datetime(2024, 1, 2),
datetime(2024, 1, 3),
]
data = pd.DataFrame(
{
"open": [100, 100, 100],
"high": [100, 100, 100],
"low": [100, 100, 100],
"close": [100, 100, 100],
"volume": [1, 1, 1],
},
index=dates,
)
# -------------------------
# Engine + Sizer
# -------------------------
strategy = BuyOnceStrategy()
sizer = FixedPositionSizer(capital_fraction=0.5)
engine = BacktestEngine(
strategy=strategy,
initial_capital=10000,
commission=0.0,
slippage=0.0,
position_size=0.95,
position_sizer=sizer
)
results = engine.run(data)
# -------------------------
# Validaciones
# -------------------------
trades = results["trades"]
assert len(trades) == 1
trade = trades[0]
invested_value = trade.entry_price * trade.size
# Esperamos ~50% del capital
assert invested_value == pytest.approx(5000, rel=1e-3)
# Sanity check
assert invested_value < 9500 # NO debe usar 95%

View File

@@ -0,0 +1,100 @@
import sys
from pathlib import Path
import pandas as pd
from datetime import datetime
# Añadir raíz del proyecto al path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from src.backtest.engine import BacktestEngine
from src.backtest.strategy import Strategy, Signal
from src.backtest.trade import TradeStatus
from src.risk.stops.fixed_stop import FixedStop
class AlwaysBuyStrategy(Strategy):
"""
Estrategia dummy para testing:
- Compra en la primera vela
- Nunca vende
"""
def __init__(self):
super().__init__(name="AlwaysBuy", params={})
def init_indicators(self, data):
return data
def generate_signal(self, idx: int):
if idx == 0:
return Signal.BUY
return Signal.HOLD
def _build_test_data():
timestamps = pd.date_range(
start=datetime(2024, 1, 1),
periods=5,
freq="1h"
)
return pd.DataFrame(
{
"open": [100, 100, 100, 100, 100],
"high": [101, 101, 101, 101, 101],
"low": [99, 98, 95, 90, 85],
"close": [100, 99, 96, 91, 86],
"volume": [1, 1, 1, 1, 1],
},
index=timestamps
)
def test_engine_closes_position_on_stop_hit():
"""
Con stop activo → debe cerrarse por Stop Loss
"""
data = _build_test_data()
strategy = AlwaysBuyStrategy()
engine = BacktestEngine(
strategy=strategy,
initial_capital=10_000,
commission=0.0,
slippage=0.0,
position_size=1.0,
stop_loss=FixedStop(0.03)
)
engine.run(data)
assert len(engine.trades) == 1
trade = engine.trades[0]
assert trade.status == TradeStatus.CLOSED
assert trade.exit_reason == "Stop Loss"
def test_engine_closes_position_at_end_without_stop():
"""
Sin stop → la posición debe cerrarse al final del backtest
"""
data = _build_test_data()
strategy = AlwaysBuyStrategy()
engine = BacktestEngine(
strategy=strategy,
initial_capital=10_000,
commission=0.0,
slippage=0.0,
position_size=1.0,
stop_loss=None
)
engine.run(data)
assert len(engine.trades) == 1
trade = engine.trades[0]
assert trade.status == TradeStatus.CLOSED
assert trade.exit_reason == "End of backtest"

View File

@@ -0,0 +1,82 @@
# tests/backtest/test_engine_trailing_stop.py
import sys
from pathlib import Path
import pandas as pd
from datetime import datetime
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from src.backtest.engine import BacktestEngine
from src.backtest.strategy import Strategy, Signal
from src.backtest.trade import TradeStatus
from src.risk.stops.trailing_stop import TrailingStop
class AlwaysBuyStrategy(Strategy):
"""
Estrategia dummy:
- Compra en la primera vela
- Nunca vende
"""
def __init__(self):
super().__init__(name="AlwaysBuy", params={})
def init_indicators(self, data):
return data
def generate_signal(self, idx: int):
if idx == 0:
return Signal.BUY
return Signal.HOLD
def test_trailing_stop_moves_and_closes_position():
"""
El trailing stop:
- se mueve cuando el precio sube
- cierra la posición cuando el precio cae
"""
timestamps = pd.date_range(
start=datetime(2024, 1, 1),
periods=7,
freq="1h"
)
data = pd.DataFrame(
{
"open": [100, 102, 105, 108, 110, 107, 103],
"high": [101, 103, 106, 109, 111, 108, 104],
"low": [99, 101, 104, 107, 109, 106, 102],
"close": [100, 102, 105, 108, 110, 107, 103],
"volume": [1, 1, 1, 1, 1, 1, 1],
},
index=timestamps
)
strategy = AlwaysBuyStrategy()
engine = BacktestEngine(
strategy=strategy,
initial_capital=10000,
commission=0.0,
slippage=0.0,
position_size=1.0,
stop_loss=TrailingStop(0.05), # 5% trailing
)
engine.run(data)
# Solo debe haber un trade
assert len(engine.trades) == 1
trade = engine.trades[0]
# El trade debe cerrarse por stop
assert trade.status == TradeStatus.CLOSED
assert trade.exit_reason == "Stop Loss"
# El cierre no debe ser al final del backtest
assert trade.exit_time <= data.index[-1]