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

@@ -33,12 +33,10 @@ def run_backtest_demo():
# Configuración del backtest
symbol = 'BTC/USDT'
timeframe = '1h'
days_back = 60 # 2 meses de datos
log.info(f"\n📊 Configuración:")
log.info(f" Símbolo: {symbol}")
log.info(f" Timeframe: {timeframe}")
log.info(f" Periodo: {days_back} días")
# Conectar a base de datos
storage = StorageManager(
@@ -50,15 +48,13 @@ def run_backtest_demo():
)
# Cargar datos
log.info("\n📥 Cargando datos desde PostgreSQL...")
end_date = datetime.now()
start_date = end_date - timedelta(days=days_back)
log.info("\n📥 Cargando TODOS los datos desde PostgreSQL...")
data = storage.load_ohlcv(
symbol=symbol,
timeframe=timeframe,
start_date=start_date,
end_date=end_date,
start_date=None,
end_date=None,
use_cache=False
)
@@ -68,8 +64,9 @@ def run_backtest_demo():
return
log.success(f"✓ Datos cargados: {len(data)} velas")
log.info(f" Desde: {data.index[0]}")
log.info(f" Hasta: {data.index[-1]}")
log.info(f" Desde: {data.index.min()}")
log.info(f" Hasta: {data.index.max()}")
log.info(f" Total días: {(data.index.max() - data.index.min()).days}")
# Crear estrategia
strategy = MovingAverageCrossover(
@@ -137,7 +134,6 @@ def compare_strategies_demo():
# Configuración
symbol = 'BTC/USDT'
timeframe = '1h'
days_back = 60
# Conectar a base de datos y cargar datos
storage = StorageManager(
@@ -148,10 +144,13 @@ def compare_strategies_demo():
db_password=os.getenv('DB_PASSWORD'),
)
end_date = datetime.now()
start_date = end_date - timedelta(days=days_back)
data = storage.load_ohlcv(symbol, timeframe, start_date, end_date, use_cache=False)
data = storage.load_ohlcv(
symbol=symbol,
timeframe=timeframe,
start_date=None,
end_date=None,
use_cache=False
)
if data.empty:
log.error(f"❌ No hay datos disponibles")

View File

@@ -10,6 +10,7 @@ from src.utils.logger import log
from src.data.fetcher import DataFetcher
from src.data.processor import DataProcessor
from src.data.storage import StorageManager
from src.data import indicators
def setup_environment():
"""Carga variables de entorno"""
@@ -133,7 +134,7 @@ def download_multiple_symbols():
log.info(f"🧹 Procesando datos...")
df_clean = processor.clean_data(df)
df_clean = processor.calculate_returns(df_clean)
df_clean = processor.calculate_indicators(df_clean)
df_clean = indicators.add_adx(df_clean)
# Guardar
log.info(f"💾 Guardando en base de datos...")

View File

@@ -9,6 +9,8 @@ from datetime import datetime
from ..utils.logger import log
from .strategy import Strategy, Signal
from .trade import Trade, TradeType, TradeStatus, Position
from ..risk.sizing.base import PositionSizer
from ..risk.stops.base import StopLoss
class BacktestEngine:
"""
@@ -21,7 +23,9 @@ class BacktestEngine:
initial_capital: float = 10000,
commission: float = 0.001, # 0.1% por trade
slippage: float = 0.0005, # 0.05% de slippage
position_size: float = 1.0 # Fracción del capital por trade (1.0 = 100%)
position_size: float = 1.0, # Fracción del capital por trade (1.0 = 100%)
position_sizer: Optional[PositionSizer] = None,
stop_loss: Optional[StopLoss] = None,
):
"""
Inicializa el motor de backtesting
@@ -32,12 +36,15 @@ class BacktestEngine:
commission: Comisión por trade (como fracción, ej: 0.001 = 0.1%)
slippage: Slippage simulado (como fracción)
position_size: Fracción del capital a usar por trade
position_sizer: Objeto PositionSizer para calcular units dinámicamente
"""
self.strategy = strategy
self.initial_capital = initial_capital
self.commission = commission
self.slippage = slippage
self.position_size_fraction = position_size
self.position_sizer = position_sizer
self.stop_loss = stop_loss
# Estado del backtest
self.cash = initial_capital
@@ -57,6 +64,11 @@ class BacktestEngine:
log.info(f"Capital inicial: ${initial_capital:,.2f}")
log.info(f"Comisión: {commission*100:.2%}, Slippage: {slippage*100:.2%}")
if self.position_sizer is not None:
log.info(f"PositionSizer activo: {self.position_sizer.__class__.__name__}")
else:
log.info(f"PositionSizer: None (fallback position_size={self.position_size_fraction:.2f})")
def run(self, data: pd.DataFrame) -> dict:
"""
Ejecuta el backtest sobre los datos históricos
@@ -120,17 +132,48 @@ class BacktestEngine:
current_price = current_bar['close']
current_time = current_bar.name # El índice es el timestamp
# Actualizar equity con posición actual
# 🟠 ACTUALIZAR TRAILING STOP (si aplica)
if self.current_position is not None and self.stop_loss is not None:
try:
new_stop = self.stop_loss.update_stop(
data=self.data,
idx=idx,
position=self.current_position,
)
if new_stop is not None:
self.current_position.move_stop(new_stop)
except Exception as e:
log.warning(f"[{current_time}] Error actualizando stop: {e}")
# 🔴 STOP LOSS CHECK
if self.current_position is not None:
if self.current_position.is_stop_hit(current_price):
unrealized = self.current_position.unrealized_pnl(current_price)
log.info(
f"[{current_time}] 🔴 STOP HIT | "
f"{self.current_position.trade_type.value} {self.current_position.symbol} | "
f"Price: {current_price:.2f} | "
f"Stop: {self.current_position.stop_price:.2f} | "
f"Unrealized PnL: {unrealized:.2f}"
)
self._close_position(idx, reason="Stop Loss")
self._update_equity(idx)
return
# 📈 Actualizar equity
self._update_equity(idx)
# Guardar historial
# 📊 Guardar historial
self.equity_curve.append(self.equity)
self.timestamps.append(current_time)
# Generar señal de la estrategia
# 📡 Generar señal
signal = self.strategy.generate_signal(idx)
# Ejecutar acciones según señal
if signal == Signal.BUY and self.current_position is None:
self._open_position(idx, TradeType.LONG)
@@ -139,7 +182,7 @@ class BacktestEngine:
def _open_position(self, idx: int, trade_type: TradeType):
"""
Abre una nueva posición
Abre una nueva posición, delegando size al PositionSizer si existe
"""
current_bar = self.data.iloc[idx]
current_price = current_bar['close']
@@ -148,25 +191,56 @@ class BacktestEngine:
# Aplicar slippage (en compra, pagamos más)
execution_price = current_price * (1 + self.slippage)
# Calcular tamaño de la posición
position_value = self.cash * self.position_size_fraction
size = position_value / execution_price
# --------------------------------------------------
# ✅ 1) Calcular units (size) vía sizer o fallback legacy
# --------------------------------------------------
if self.position_sizer is not None:
try:
units = float(
self.position_sizer.calculate_size(
capital=self.cash,
entry_price=float(execution_price),
stop_price=None, # stops aún no integrados
volatility=None # vol/ATR aún no integrado aquí
)
)
except Exception as e:
log.warning(f"[{current_time}] PositionSizer rechazó la entrada: {e}")
return
# Calcular comisión
if not np.isfinite(units) or units <= 0:
log.warning(f"[{current_time}] PositionSizer devolvió units inválidos: {units}")
return
position_value = units * execution_price
else:
# Fallback actual: usar fracción del cash
position_value = self.cash * self.position_size_fraction
units = position_value / execution_price
# --------------------------------------------------
# ✅ 2) Comisión basada en el nominal invertido
# --------------------------------------------------
commission_cost = position_value * self.commission
# Verificar que tenemos suficiente cash
if self.cash < position_value + commission_cost:
log.warning(f"Cash insuficiente para abrir posición: ${self.cash:.2f}")
log.warning(
f"[{current_time}] Cash insuficiente para abrir posición "
f"(cash=${self.cash:.2f}, needed=${position_value + commission_cost:.2f})"
)
return
# Crear trade
# --------------------------------------------------
# ✅ 3) Crear trade + posición
# --------------------------------------------------
trade = Trade(
symbol=current_bar.get('symbol', 'UNKNOWN'),
trade_type=trade_type,
entry_price=execution_price,
entry_time=current_time,
size=size,
size=units,
entry_commission=commission_cost,
entry_reason="Strategy signal"
)
@@ -179,15 +253,25 @@ class BacktestEngine:
symbol=trade.symbol,
trade_type=trade_type,
average_price=execution_price,
total_size=size,
total_size=units,
trades=[trade]
)
# 🔴 FIJAR STOP INICIAL
if self.stop_loss is not None:
stop_price = self.stop_loss.get_stop_price(
data=self.data,
idx=idx,
entry_price=execution_price,
trade_type=trade_type,
)
self.current_position.set_stop(stop_price)
self.trades.append(trade)
log.debug(f"[{current_time}] OPEN {trade_type.value}: "
f"Price: ${execution_price:.2f}, Size: {size:.4f}, "
f"Value: ${position_value:.2f}")
f"Price: ${execution_price:.2f}, Units: {units:.6f}, "
f"Value: ${position_value:.2f}, Fee: ${commission_cost:.2f}")
def _close_position(self, idx: int, reason: str):
"""
@@ -197,7 +281,7 @@ class BacktestEngine:
return
current_bar = self.data.iloc[idx]
current_price = current_bar['close']
current_price = current_bar["close"]
current_time = current_bar.name
# Aplicar slippage (en venta, recibimos menos)
@@ -220,9 +304,11 @@ class BacktestEngine:
# Calcular PnL de la posición
total_pnl = sum(t.pnl for t in self.current_position.trades)
log.debug(f"[{current_time}] CLOSE {self.current_position.trade_type.value}: "
f"Price: ${execution_price:.2f}, PnL: ${total_pnl:.2f} "
f"({total_pnl/self.initial_capital*100:.2f}%)")
log.debug(
f"[{current_time}] CLOSE {self.current_position.trade_type.value}: "
f"Price: ${execution_price:.2f}, PnL: ${total_pnl:.2f} "
f"({total_pnl/self.initial_capital*100:.2f}%)"
)
# Limpiar posición actual
self.current_position = None
@@ -234,7 +320,7 @@ class BacktestEngine:
self.equity = self.cash
if self.current_position is not None:
current_price = self.data.iloc[idx]['close']
current_price = self.data.iloc[idx]["close"]
position_value = self.current_position.total_size * current_price
self.equity += position_value
@@ -261,7 +347,7 @@ class BacktestEngine:
# Profit factor
gross_profit = sum(t.pnl for t in winning_trades)
gross_loss = abs(sum(t.pnl for t in losing_trades))
profit_factor = gross_profit / gross_loss if gross_loss > 0 else float('inf')
profit_factor = gross_profit / gross_loss if gross_loss > 0 else float("inf")
# Drawdown
equity_curve = np.array(self.equity_curve)
@@ -271,42 +357,45 @@ class BacktestEngine:
# Sharpe Ratio (aproximado con returns diarios)
returns = pd.Series(equity_curve).pct_change().dropna()
sharpe_ratio = (returns.mean() / returns.std()) * np.sqrt(252) if len(returns) > 1 else 0
if returns.std() == 0 or np.isnan(returns.std()):
sharpe_ratio = 0.0
else:
sharpe_ratio = (returns.mean() / returns.std()) * np.sqrt(252) if len(returns) > 1 else 0
results = {
# Generales
'initial_capital': self.initial_capital,
'final_equity': self.equity,
'total_return': total_return,
'total_return_pct': total_return * 100,
"initial_capital": self.initial_capital,
"final_equity": self.equity,
"total_return": total_return,
"total_return_pct": total_return * 100,
# Trades
'total_trades': total_trades,
'winning_trades': len(winning_trades),
'losing_trades': len(losing_trades),
'win_rate': win_rate,
'win_rate_pct': win_rate * 100,
"total_trades": total_trades,
"winning_trades": len(winning_trades),
"losing_trades": len(losing_trades),
"win_rate": win_rate,
"win_rate_pct": win_rate * 100,
# PnL
'total_pnl': total_pnl,
'gross_profit': gross_profit,
'gross_loss': gross_loss,
'profit_factor': profit_factor,
"total_pnl": total_pnl,
"gross_profit": gross_profit,
"gross_loss": gross_loss,
"profit_factor": profit_factor,
# Average trades
'avg_win': np.mean([t.pnl for t in winning_trades]) if winning_trades else 0,
'avg_loss': np.mean([t.pnl for t in losing_trades]) if losing_trades else 0,
'avg_trade': total_pnl / total_trades if total_trades > 0 else 0,
"avg_win": np.mean([t.pnl for t in winning_trades]) if winning_trades else 0,
"avg_loss": np.mean([t.pnl for t in losing_trades]) if losing_trades else 0,
"avg_trade": total_pnl / total_trades if total_trades > 0 else 0,
# Risk metrics
'max_drawdown': max_drawdown,
'max_drawdown_pct': max_drawdown * 100,
'sharpe_ratio': sharpe_ratio,
"max_drawdown": max_drawdown,
"max_drawdown_pct": max_drawdown * 100,
"sharpe_ratio": sharpe_ratio,
# Datos para gráficos
'equity_curve': self.equity_curve,
'timestamps': self.timestamps,
'trades': closed_trades,
"equity_curve": self.equity_curve,
"timestamps": self.timestamps,
"trades": closed_trades,
}
return results
@@ -314,26 +403,26 @@ class BacktestEngine:
def _empty_results(self) -> dict:
"""Resultados vacíos cuando no hay trades"""
return {
'initial_capital': self.initial_capital,
'final_equity': self.equity,
'total_return': 0,
'total_return_pct': 0,
'total_trades': 0,
'winning_trades': 0,
'losing_trades': 0,
'win_rate': 0,
'win_rate_pct': 0,
'total_pnl': 0,
'gross_profit': 0,
'gross_loss': 0,
'profit_factor': 0,
'avg_win': 0,
'avg_loss': 0,
'avg_trade': 0,
'max_drawdown': 0,
'max_drawdown_pct': 0,
'sharpe_ratio': 0,
'equity_curve': self.equity_curve,
'timestamps': self.timestamps,
'trades': [],
"initial_capital": self.initial_capital,
"final_equity": self.equity,
"total_return": 0,
"total_return_pct": 0,
"total_trades": 0,
"winning_trades": 0,
"losing_trades": 0,
"win_rate": 0,
"win_rate_pct": 0,
"total_pnl": 0,
"gross_profit": 0,
"gross_loss": 0,
"profit_factor": 0,
"avg_win": 0,
"avg_loss": 0,
"avg_trade": 0,
"max_drawdown": 0,
"max_drawdown_pct": 0,
"sharpe_ratio": 0,
"equity_curve": self.equity_curve,
"timestamps": self.timestamps,
"trades": [],
}

View File

@@ -102,6 +102,8 @@ class Position:
average_price: float
total_size: float
trades: list = field(default_factory=list)
stop_price: Optional[float] = None
initial_stop_price: Optional[float] = None
def add_trade(self, trade: Trade):
"""
@@ -130,3 +132,50 @@ class Position:
return (current_price - self.average_price) * self.total_size
else:
return (self.average_price - current_price) * self.total_size
def set_stop(self, price: float):
"""
Establece el stop de la posición.
Guarda el stop inicial si aún no existe.
"""
if price is None or price <= 0:
raise ValueError("stop_price inválido")
if self.initial_stop_price is None:
self.initial_stop_price = price
self.stop_price = price
def is_stop_hit(self, current_price: float):
"""
Devuelve True si el stop ha sido alcanzado.
"""
if self.stop_price is None:
return False
if self.trade_type == TradeType.LONG:
return current_price <= self.stop_price
elif self.trade_type == TradeType.SHORT:
return current_price >= self.stop_price
return False
def move_stop(self, new_price: float):
"""
Mueve el stop solo si reduce el riesgo.
"""
if new_price is None or new_price <= 0:
return
if self.stop_price is None:
self.set_stop(new_price)
return
# LONG → solo subir stop
if self.trade_type == TradeType.LONG and new_price > self.stop_price:
self.stop_price = new_price
# Short → solo bajar stop
elif self.trade_type == TradeType.SHORT and new_price < self.stop_price:
self.stop_price = new_price

View File

@@ -268,22 +268,3 @@ class DataProcessor:
log.debug(f"Datos normalizados usando {method}")
return df_norm
@staticmethod
def calculate_indicators(df: pd.DataFrame) -> pd.DataFrame:
"""
Calcula indicadores técnicos (ADX, etc.)
"""
adx = ta.adx(
high=df['high'],
low=df['low'],
close=df['close'],
length=14
)
df = df.copy()
df['adx'] = adx['ADX_14']
log.debug("Indicadores técnicos calculados (ADX)")
return df

0
src/risk/__init__.py Normal file
View File

View File

23
src/risk/sizing/base.py Normal file
View File

@@ -0,0 +1,23 @@
# src/risk/sizing/base.py
from abc import ABC, abstractmethod
from typing import Optional
class PositionSizer(ABC):
"""
Clase base para position sizing.
"""
@abstractmethod
def calculate_size(
self,
capital: float,
entry_price: float,
stop_price: Optional[float] = None,
volatility: Optional[float] = None,
) -> float:
"""
Devuelve el número de unidades a comprar.
"""
pass

26
src/risk/sizing/fixed.py Normal file
View File

@@ -0,0 +1,26 @@
# src/risk/sizing/fixed.py
from .base import PositionSizer
class FixedPositionSizer(PositionSizer):
"""
Usa siempre un porcentaje fijo del capital disponible.
"""
def __init__(self, capital_fraction: float = 1.0):
if not 0 < capital_fraction <= 1:
raise ValueError("capital_fraction debe estar entre 0 y 1")
self.capital_fraction = capital_fraction
def calculate_size(
self,
capital: float,
entry_price: float,
stop_price=None,
volatility=None,
) -> float:
notional = capital * self.capital_fraction
units = notional / entry_price
return units

View File

@@ -0,0 +1,36 @@
# src/risk/sizing/percent_risk.py
from .base import PositionSizer
class PercentRiskSizer(PositionSizer):
"""
Position sizing basado en % de riesgo por trade.
"""
def __init__(self, risk_fraction: float):
if not 0 < risk_fraction <= 1:
raise ValueError("risk_fraction debe estar entre 0 y 1")
self.risk_fraction = risk_fraction
def calculate_size(
self,
capital: float,
entry_price: float,
stop_price: float | None = None
) -> float:
if stop_price is None:
raise ValueError("PercentRiskSizer requiere stop_price")
risk_amount = capital * self.risk_fraction
distance = abs(entry_price - stop_price)
if distance < 0:
raise ValueError("Distancia entry-stop inválida")
if distance == 0:
return 0.0
position_size = risk_amount / distance
return position_size

View File

@@ -0,0 +1,34 @@
# src/risk/sizing/volatility.py
from .base import PositionSizer
class VolatilitySizer(PositionSizer):
"""
Ajusta el tamaño según la volatilidad (ATR o std).
"""
def __init__(self, volatility_multiplier: float = 2.0):
if volatility_multiplier <= 0:
raise ValueError("volatility_multiplier debe ser > 0")
self.volatility_multiplier = volatility_multiplier
def calculate_size(
self,
capital: float,
entry_price: float,
stop_price=None,
volatility: float = None,
) -> float:
if volatility is None:
raise ValueError("VolatilitySizer requiere volatilidad (ATR o std)")
risk_per_unit = volatility * entry_price * self.volatility_multiplier
if risk_per_unit <= 0:
return 0.0
units = capital / risk_per_unit
return units

View File

View File

@@ -0,0 +1,70 @@
# src/risk/stops/atr_stop.py
import pandas as pd
import numpy as np
from src.risk.stops.base import StopLoss
from src.backtest.trade import TradeType
class ATRStop(StopLoss):
"""
Stop basado en ATR (Average True Range).
"""
def __init__(self, atr_period: int = 14, multiplier: float = 2.0):
if atr_period <= 0:
raise ValueError("atr_period debe ser > 0")
if multiplier <= 0:
raise ValueError("multiplier debe ser > 0")
self.atr_period = atr_period
self.multiplier = multiplier
def _calculate_atr(self, data: pd.DataFrame) -> pd.Series:
"""
Calcula el ATR clásico (Wilder).
"""
high = data["high"]
low = data["low"]
close = data["close"].shift(1)
tr = pd.concat(
[
high - low,
(high - close).abs(),
(low - close).abs(),
],
axis=1,
).max(axis=1)
atr = tr.rolling(self.atr_period).mean()
return atr
def get_stop_price(
self,
*,
data: pd.DataFrame,
idx: int,
entry_price: float,
trade_type: TradeType
) -> float:
if not {"high", "low", "close"}.issubset(data.columns):
raise ValueError("ATRStop requiere columnas high, low, close")
atr_series = self._calculate_atr(data)
atr_value = atr_series.iloc[idx]
if not np.isfinite(atr_value):
raise ValueError("ATR no disponible en este índice")
distance = atr_value * self.multiplier
if trade_type == TradeType.LONG:
return entry_price - distance
elif trade_type == TradeType.SHORT:
return entry_price + distance
else:
raise ValueError(f"TradeType no soportado: {trade_type}")

39
src/risk/stops/base.py Normal file
View File

@@ -0,0 +1,39 @@
# src/risk/stops/base.py
from abc import ABC, abstractmethod
import pandas as pd
from src.backtest.trade import TradeType
class StopLoss(ABC):
"""
Interfaz base para Stop Loss.
Un stop:
- NO ejecuta órdenes
- NO calcula size
- SOLO devuelve un precio de stop
"""
@abstractmethod
def get_stop_price(
self,
*,
data: pd.DataFrame,
idx: int,
entry_price: float,
trade_type: TradeType
) -> float:
"""
Devuelve el precio del stop.
Args:
data: DataFrame OHLCV
idx: índice temporal actual
entry_price: precio de entrada
trade_type: LONG o SHORT
Returns:
stop_price (float)
"""
pass

View File

@@ -0,0 +1,37 @@
# src/risk/stops/fixed_stop.py
import pandas as pd
from src.risk.stops.base import StopLoss
from src.backtest.trade import TradeType
class FixedStop(StopLoss):
"""
Stop fijo porcentual desde el precio de entrada.
"""
def __init__(self, stop_fraction: float):
"""
Args:
stop_fraction: ej 0.02 = 2%
"""
if stop_fraction <= 0 or stop_fraction >= 1:
raise ValueError("stop_fraction debe estar entre 0 y 1")
self.stop_fraction = stop_fraction
def get_stop_price(
self,
*,
data: pd.DataFrame,
idx: int,
entry_price: float,
trade_type: TradeType
) -> float:
if trade_type == TradeType.LONG:
return entry_price * (1 - self.stop_fraction)
elif trade_type == TradeType.SHORT:
return entry_price * (1 + self.stop_fraction)
else:
raise ValueError(f"TradeType no soportado: {trade_type}")

View File

@@ -0,0 +1,70 @@
# src/risk/stops/trailing_stop.py
import pandas as pd
from src.risk.stops.base import StopLoss
from src.backtest.trade import TradeType, Position
class TrailingStop(StopLoss):
"""
Trailing Stop porcentual basado en el precio de cierre.
- LONG: el stop solo sube
- SHORT: el stop solo baja
"""
def __init__(self, trailing_fraction: float):
"""
Args:
trailing_fraction: ej 0.05 = 5%
"""
if trailing_fraction <= 0 or trailing_fraction >= 1:
raise ValueError("trailing_fraction debe estar entre 0 y 1")
self.trailing_fraction = trailing_fraction
def get_stop_price(
self,
*,
data: pd.DataFrame,
idx: int,
entry_price: float,
trade_type: TradeType
) -> float:
"""
Stop inicial al abrir la posición
"""
if trade_type == TradeType.LONG:
return entry_price * (1 - self.trailing_fraction)
elif trade_type == TradeType.SHORT:
return entry_price * (1 + self.trailing_fraction)
else:
raise ValueError(f"TradeType no soportado: {trade_type}")
def update_stop(
self,
*,
data: pd.DataFrame,
idx: int,
position: Position
) -> float | None:
"""
Calcula un nuevo stop si el precio ha mejorado.
Devuelve None si el stop no debe moverse.
"""
current_price = data.iloc[idx]["close"]
if position.trade_type == TradeType.LONG:
candidate = current_price * (1 - self.trailing_fraction)
if position.stop_price is None or candidate > position.stop_price:
return candidate
elif position.trade_type == TradeType.SHORT:
candidate = current_price * (1 + self.trailing_fraction)
if position.stop_price is None or candidate < position.stop_price:
return candidate
return None

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]

View 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)

View 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
)

View 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)

View 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)

View 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)