Engine: add stop loss integration (fixed & trailing) with tests
This commit is contained in:
93
tests/backtest/test_engine_sizing.py
Normal file
93
tests/backtest/test_engine_sizing.py
Normal 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%
|
||||
100
tests/backtest/test_engine_stop.py
Normal file
100
tests/backtest/test_engine_stop.py
Normal 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"
|
||||
82
tests/backtest/test_engine_trailing_stop.py
Normal file
82
tests/backtest/test_engine_trailing_stop.py
Normal 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]
|
||||
39
tests/risk/sizing/test_fixed.py
Normal file
39
tests/risk/sizing/test_fixed.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
# Añadir raíz del proyecto al path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
|
||||
|
||||
from src.risk.sizing.fixed import FixedPositionSizer
|
||||
|
||||
|
||||
def test_fixed_position_size_basic():
|
||||
sizer = FixedPositionSizer(capital_fraction=0.5)
|
||||
|
||||
capital = 10_000
|
||||
entry_price = 100
|
||||
|
||||
units = sizer.calculate_size(
|
||||
capital=capital,
|
||||
entry_price=entry_price
|
||||
)
|
||||
|
||||
# 50% de 10k = 5k / 100 = 50 unidades
|
||||
assert units == 50
|
||||
|
||||
|
||||
def test_fixed_position_size_full_capital():
|
||||
sizer = FixedPositionSizer(capital_fraction=1.0)
|
||||
|
||||
units = sizer.calculate_size(
|
||||
capital=10_000,
|
||||
entry_price=200
|
||||
)
|
||||
|
||||
assert units == 50
|
||||
|
||||
|
||||
def test_fixed_invalid_fraction():
|
||||
with pytest.raises(ValueError):
|
||||
FixedPositionSizer(capital_fraction=1.5)
|
||||
50
tests/risk/sizing/test_percent_risk.py
Normal file
50
tests/risk/sizing/test_percent_risk.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
# Añadir raíz del proyecto al path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
|
||||
|
||||
|
||||
from src.risk.sizing.percent_risk import PercentRiskSizer
|
||||
|
||||
|
||||
def test_percent_risk_basic():
|
||||
sizer = PercentRiskSizer(risk_fraction=0.01) # 1%
|
||||
|
||||
capital = 10_000
|
||||
entry_price = 100
|
||||
stop_price = 95
|
||||
|
||||
units = sizer.calculate_size(
|
||||
capital=capital,
|
||||
entry_price=entry_price,
|
||||
stop_price=stop_price
|
||||
)
|
||||
|
||||
# riesgo = 100€
|
||||
# riesgo por unidad = 5
|
||||
# unidades = 100 / 5 = 20
|
||||
assert units == 20
|
||||
|
||||
|
||||
def test_percent_risk_zero_distance():
|
||||
sizer = PercentRiskSizer(0.01)
|
||||
|
||||
units = sizer.calculate_size(
|
||||
capital=10_000,
|
||||
entry_price=100,
|
||||
stop_price=100
|
||||
)
|
||||
|
||||
assert units == 0.0
|
||||
|
||||
|
||||
def test_percent_risk_requires_stop():
|
||||
sizer = PercentRiskSizer(0.01)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
sizer.calculate_size(
|
||||
capital=10_000,
|
||||
entry_price=100
|
||||
)
|
||||
42
tests/risk/sizing/test_volatility.py
Normal file
42
tests/risk/sizing/test_volatility.py
Normal file
@@ -0,0 +1,42 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
# Añadir raíz del proyecto al path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
|
||||
|
||||
|
||||
from src.risk.sizing.volatility import VolatilitySizer
|
||||
|
||||
|
||||
def test_volatility_sizer_basic():
|
||||
sizer = VolatilitySizer(volatility_multiplier=2.0)
|
||||
|
||||
capital = 10_000
|
||||
entry_price = 100
|
||||
volatility = 0.02 # 2%
|
||||
|
||||
units = sizer.calculate_size(
|
||||
capital=capital,
|
||||
entry_price=entry_price,
|
||||
volatility=volatility
|
||||
)
|
||||
|
||||
# riesgo por unidad = 0.02 * 100 * 2 = 4
|
||||
# unidades = 10_000 / 4 = 2500
|
||||
assert units == 2500
|
||||
|
||||
|
||||
def test_volatility_requires_volatility():
|
||||
sizer = VolatilitySizer()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
sizer.calculate_size(
|
||||
capital=10_000,
|
||||
entry_price=100
|
||||
)
|
||||
|
||||
|
||||
def test_volatility_invalid_multiplier():
|
||||
with pytest.raises(ValueError):
|
||||
VolatilitySizer(volatility_multiplier=0)
|
||||
73
tests/risk/stops/test_atr_stop.py
Normal file
73
tests/risk/stops/test_atr_stop.py
Normal file
@@ -0,0 +1,73 @@
|
||||
# src/risk/stops/test_atr_stop.py
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
# Añadir raíz del proyecto al path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
|
||||
|
||||
from src.risk.stops.base import StopLoss
|
||||
from src.backtest.trade import TradeType
|
||||
from src.risk.stops.atr_stop import ATRStop
|
||||
from src.backtest.trade import TradeType
|
||||
|
||||
|
||||
def atr_data():
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"high": [10, 11, 12, 13, 14],
|
||||
"low": [9, 10, 11, 12, 13],
|
||||
"close": [9.5, 10.5, 11.5, 12.5, 13.5],
|
||||
},
|
||||
index=pd.date_range("2024-01-01", periods=5, freq="D"),
|
||||
)
|
||||
|
||||
|
||||
def test_atr_stop_long():
|
||||
stop = ATRStop(atr_period=3, multiplier=2.0)
|
||||
|
||||
price = stop.get_stop_price(
|
||||
data=atr_data(),
|
||||
idx=4,
|
||||
entry_price=14,
|
||||
trade_type=TradeType.LONG,
|
||||
)
|
||||
|
||||
assert price < 14 # stop por debajo
|
||||
|
||||
|
||||
def test_atr_stop_short():
|
||||
stop = ATRStop(atr_period=3, multiplier=2.0)
|
||||
|
||||
price = stop.get_stop_price(
|
||||
data=atr_data(),
|
||||
idx=4,
|
||||
entry_price=14,
|
||||
trade_type=TradeType.SHORT,
|
||||
)
|
||||
|
||||
assert price > 14 # stop por encima
|
||||
|
||||
|
||||
def test_atr_stop_requires_columns():
|
||||
stop = ATRStop()
|
||||
|
||||
bad_data = pd.DataFrame({"close": [1, 2, 3]})
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
stop.get_stop_price(
|
||||
data=bad_data,
|
||||
idx=2,
|
||||
entry_price=3,
|
||||
trade_type=TradeType.LONG,
|
||||
)
|
||||
|
||||
|
||||
def test_atr_stop_invalid_params():
|
||||
with pytest.raises(ValueError):
|
||||
ATRStop(atr_period=0)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
ATRStop(multiplier=0)
|
||||
51
tests/risk/stops/test_fixed_stop.py
Normal file
51
tests/risk/stops/test_fixed_stop.py
Normal file
@@ -0,0 +1,51 @@
|
||||
# tests/risk/stops/test_fixed_stop.py
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
# Añadir raíz del proyecto al path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
|
||||
|
||||
from src.risk.stops.fixed_stop import FixedStop
|
||||
from src.backtest.trade import TradeType
|
||||
|
||||
|
||||
def dummy_data():
|
||||
return pd.DataFrame(
|
||||
{"close": [100, 101, 102]},
|
||||
index=pd.date_range("2024-01-01", periods=3, freq="D")
|
||||
)
|
||||
|
||||
|
||||
def test_fixed_stop_long():
|
||||
stop = FixedStop(0.02)
|
||||
price = stop.get_stop_price(
|
||||
data=dummy_data(),
|
||||
idx=1,
|
||||
entry_price=100,
|
||||
trade_type=TradeType.LONG
|
||||
)
|
||||
assert price == 98
|
||||
|
||||
|
||||
def test_fixed_stop_short():
|
||||
stop = FixedStop(0.02)
|
||||
price = stop.get_stop_price(
|
||||
data=dummy_data(),
|
||||
idx=1,
|
||||
entry_price=100,
|
||||
trade_type=TradeType.SHORT
|
||||
)
|
||||
assert price == 102
|
||||
|
||||
|
||||
def test_fixed_stop_invalid_fraction():
|
||||
with pytest.raises(ValueError):
|
||||
FixedStop(0)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
FixedStop(1)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
FixedStop(-0.1)
|
||||
Reference in New Issue
Block a user