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 # Configuración del backtest
symbol = 'BTC/USDT' symbol = 'BTC/USDT'
timeframe = '1h' timeframe = '1h'
days_back = 60 # 2 meses de datos
log.info(f"\n📊 Configuración:") log.info(f"\n📊 Configuración:")
log.info(f" Símbolo: {symbol}") log.info(f" Símbolo: {symbol}")
log.info(f" Timeframe: {timeframe}") log.info(f" Timeframe: {timeframe}")
log.info(f" Periodo: {days_back} días")
# Conectar a base de datos # Conectar a base de datos
storage = StorageManager( storage = StorageManager(
@@ -50,15 +48,13 @@ def run_backtest_demo():
) )
# Cargar datos # Cargar datos
log.info("\n📥 Cargando datos desde PostgreSQL...") log.info("\n📥 Cargando TODOS los datos desde PostgreSQL...")
end_date = datetime.now()
start_date = end_date - timedelta(days=days_back)
data = storage.load_ohlcv( data = storage.load_ohlcv(
symbol=symbol, symbol=symbol,
timeframe=timeframe, timeframe=timeframe,
start_date=start_date, start_date=None,
end_date=end_date, end_date=None,
use_cache=False use_cache=False
) )
@@ -68,8 +64,9 @@ def run_backtest_demo():
return return
log.success(f"✓ Datos cargados: {len(data)} velas") log.success(f"✓ Datos cargados: {len(data)} velas")
log.info(f" Desde: {data.index[0]}") log.info(f" Desde: {data.index.min()}")
log.info(f" Hasta: {data.index[-1]}") log.info(f" Hasta: {data.index.max()}")
log.info(f" Total días: {(data.index.max() - data.index.min()).days}")
# Crear estrategia # Crear estrategia
strategy = MovingAverageCrossover( strategy = MovingAverageCrossover(
@@ -137,7 +134,6 @@ def compare_strategies_demo():
# Configuración # Configuración
symbol = 'BTC/USDT' symbol = 'BTC/USDT'
timeframe = '1h' timeframe = '1h'
days_back = 60
# Conectar a base de datos y cargar datos # Conectar a base de datos y cargar datos
storage = StorageManager( storage = StorageManager(
@@ -148,10 +144,13 @@ def compare_strategies_demo():
db_password=os.getenv('DB_PASSWORD'), db_password=os.getenv('DB_PASSWORD'),
) )
end_date = datetime.now() data = storage.load_ohlcv(
start_date = end_date - timedelta(days=days_back) symbol=symbol,
timeframe=timeframe,
data = storage.load_ohlcv(symbol, timeframe, start_date, end_date, use_cache=False) start_date=None,
end_date=None,
use_cache=False
)
if data.empty: if data.empty:
log.error(f"❌ No hay datos disponibles") 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.fetcher import DataFetcher
from src.data.processor import DataProcessor from src.data.processor import DataProcessor
from src.data.storage import StorageManager from src.data.storage import StorageManager
from src.data import indicators
def setup_environment(): def setup_environment():
"""Carga variables de entorno""" """Carga variables de entorno"""
@@ -133,7 +134,7 @@ def download_multiple_symbols():
log.info(f"🧹 Procesando datos...") log.info(f"🧹 Procesando datos...")
df_clean = processor.clean_data(df) df_clean = processor.clean_data(df)
df_clean = processor.calculate_returns(df_clean) df_clean = processor.calculate_returns(df_clean)
df_clean = processor.calculate_indicators(df_clean) df_clean = indicators.add_adx(df_clean)
# Guardar # Guardar
log.info(f"💾 Guardando en base de datos...") log.info(f"💾 Guardando en base de datos...")

View File

@@ -9,6 +9,8 @@ from datetime import datetime
from ..utils.logger import log from ..utils.logger import log
from .strategy import Strategy, Signal from .strategy import Strategy, Signal
from .trade import Trade, TradeType, TradeStatus, Position from .trade import Trade, TradeType, TradeStatus, Position
from ..risk.sizing.base import PositionSizer
from ..risk.stops.base import StopLoss
class BacktestEngine: class BacktestEngine:
""" """
@@ -21,7 +23,9 @@ class BacktestEngine:
initial_capital: float = 10000, initial_capital: float = 10000,
commission: float = 0.001, # 0.1% por trade commission: float = 0.001, # 0.1% por trade
slippage: float = 0.0005, # 0.05% de slippage 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 Inicializa el motor de backtesting
@@ -32,12 +36,15 @@ class BacktestEngine:
commission: Comisión por trade (como fracción, ej: 0.001 = 0.1%) commission: Comisión por trade (como fracción, ej: 0.001 = 0.1%)
slippage: Slippage simulado (como fracción) slippage: Slippage simulado (como fracción)
position_size: Fracción del capital a usar por trade position_size: Fracción del capital a usar por trade
position_sizer: Objeto PositionSizer para calcular units dinámicamente
""" """
self.strategy = strategy self.strategy = strategy
self.initial_capital = initial_capital self.initial_capital = initial_capital
self.commission = commission self.commission = commission
self.slippage = slippage self.slippage = slippage
self.position_size_fraction = position_size self.position_size_fraction = position_size
self.position_sizer = position_sizer
self.stop_loss = stop_loss
# Estado del backtest # Estado del backtest
self.cash = initial_capital self.cash = initial_capital
@@ -57,6 +64,11 @@ class BacktestEngine:
log.info(f"Capital inicial: ${initial_capital:,.2f}") log.info(f"Capital inicial: ${initial_capital:,.2f}")
log.info(f"Comisión: {commission*100:.2%}, Slippage: {slippage*100:.2%}") 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: def run(self, data: pd.DataFrame) -> dict:
""" """
Ejecuta el backtest sobre los datos históricos Ejecuta el backtest sobre los datos históricos
@@ -120,17 +132,48 @@ class BacktestEngine:
current_price = current_bar['close'] current_price = current_bar['close']
current_time = current_bar.name # El índice es el timestamp 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) self._update_equity(idx)
# Guardar historial # 📊 Guardar historial
self.equity_curve.append(self.equity) self.equity_curve.append(self.equity)
self.timestamps.append(current_time) self.timestamps.append(current_time)
# Generar señal de la estrategia # 📡 Generar señal
signal = self.strategy.generate_signal(idx) signal = self.strategy.generate_signal(idx)
# Ejecutar acciones según señal
if signal == Signal.BUY and self.current_position is None: if signal == Signal.BUY and self.current_position is None:
self._open_position(idx, TradeType.LONG) self._open_position(idx, TradeType.LONG)
@@ -139,7 +182,7 @@ class BacktestEngine:
def _open_position(self, idx: int, trade_type: TradeType): 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_bar = self.data.iloc[idx]
current_price = current_bar['close'] current_price = current_bar['close']
@@ -148,25 +191,56 @@ class BacktestEngine:
# Aplicar slippage (en compra, pagamos más) # Aplicar slippage (en compra, pagamos más)
execution_price = current_price * (1 + self.slippage) execution_price = current_price * (1 + self.slippage)
# Calcular tamaño de la posición # --------------------------------------------------
position_value = self.cash * self.position_size_fraction # ✅ 1) Calcular units (size) vía sizer o fallback legacy
size = position_value / execution_price # --------------------------------------------------
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 commission_cost = position_value * self.commission
# Verificar que tenemos suficiente cash # Verificar que tenemos suficiente cash
if self.cash < position_value + commission_cost: 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 return
# Crear trade # --------------------------------------------------
# ✅ 3) Crear trade + posición
# --------------------------------------------------
trade = Trade( trade = Trade(
symbol=current_bar.get('symbol', 'UNKNOWN'), symbol=current_bar.get('symbol', 'UNKNOWN'),
trade_type=trade_type, trade_type=trade_type,
entry_price=execution_price, entry_price=execution_price,
entry_time=current_time, entry_time=current_time,
size=size, size=units,
entry_commission=commission_cost, entry_commission=commission_cost,
entry_reason="Strategy signal" entry_reason="Strategy signal"
) )
@@ -179,15 +253,25 @@ class BacktestEngine:
symbol=trade.symbol, symbol=trade.symbol,
trade_type=trade_type, trade_type=trade_type,
average_price=execution_price, average_price=execution_price,
total_size=size, total_size=units,
trades=[trade] 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) self.trades.append(trade)
log.debug(f"[{current_time}] OPEN {trade_type.value}: " log.debug(f"[{current_time}] OPEN {trade_type.value}: "
f"Price: ${execution_price:.2f}, Size: {size:.4f}, " f"Price: ${execution_price:.2f}, Units: {units:.6f}, "
f"Value: ${position_value:.2f}") f"Value: ${position_value:.2f}, Fee: ${commission_cost:.2f}")
def _close_position(self, idx: int, reason: str): def _close_position(self, idx: int, reason: str):
""" """
@@ -197,7 +281,7 @@ class BacktestEngine:
return return
current_bar = self.data.iloc[idx] current_bar = self.data.iloc[idx]
current_price = current_bar['close'] current_price = current_bar["close"]
current_time = current_bar.name current_time = current_bar.name
# Aplicar slippage (en venta, recibimos menos) # Aplicar slippage (en venta, recibimos menos)
@@ -220,9 +304,11 @@ class BacktestEngine:
# Calcular PnL de la posición # Calcular PnL de la posición
total_pnl = sum(t.pnl for t in self.current_position.trades) total_pnl = sum(t.pnl for t in self.current_position.trades)
log.debug(f"[{current_time}] CLOSE {self.current_position.trade_type.value}: " log.debug(
f"Price: ${execution_price:.2f}, PnL: ${total_pnl:.2f} " f"[{current_time}] CLOSE {self.current_position.trade_type.value}: "
f"({total_pnl/self.initial_capital*100:.2f}%)") f"Price: ${execution_price:.2f}, PnL: ${total_pnl:.2f} "
f"({total_pnl/self.initial_capital*100:.2f}%)"
)
# Limpiar posición actual # Limpiar posición actual
self.current_position = None self.current_position = None
@@ -234,7 +320,7 @@ class BacktestEngine:
self.equity = self.cash self.equity = self.cash
if self.current_position is not None: 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 position_value = self.current_position.total_size * current_price
self.equity += position_value self.equity += position_value
@@ -261,7 +347,7 @@ class BacktestEngine:
# Profit factor # Profit factor
gross_profit = sum(t.pnl for t in winning_trades) gross_profit = sum(t.pnl for t in winning_trades)
gross_loss = abs(sum(t.pnl for t in losing_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 # Drawdown
equity_curve = np.array(self.equity_curve) equity_curve = np.array(self.equity_curve)
@@ -271,42 +357,45 @@ class BacktestEngine:
# Sharpe Ratio (aproximado con returns diarios) # Sharpe Ratio (aproximado con returns diarios)
returns = pd.Series(equity_curve).pct_change().dropna() 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 = { results = {
# Generales # Generales
'initial_capital': self.initial_capital, "initial_capital": self.initial_capital,
'final_equity': self.equity, "final_equity": self.equity,
'total_return': total_return, "total_return": total_return,
'total_return_pct': total_return * 100, "total_return_pct": total_return * 100,
# Trades # Trades
'total_trades': total_trades, "total_trades": total_trades,
'winning_trades': len(winning_trades), "winning_trades": len(winning_trades),
'losing_trades': len(losing_trades), "losing_trades": len(losing_trades),
'win_rate': win_rate, "win_rate": win_rate,
'win_rate_pct': win_rate * 100, "win_rate_pct": win_rate * 100,
# PnL # PnL
'total_pnl': total_pnl, "total_pnl": total_pnl,
'gross_profit': gross_profit, "gross_profit": gross_profit,
'gross_loss': gross_loss, "gross_loss": gross_loss,
'profit_factor': profit_factor, "profit_factor": profit_factor,
# Average trades # Average trades
'avg_win': np.mean([t.pnl for t in winning_trades]) if winning_trades 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_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_trade": total_pnl / total_trades if total_trades > 0 else 0,
# Risk metrics # Risk metrics
'max_drawdown': max_drawdown, "max_drawdown": max_drawdown,
'max_drawdown_pct': max_drawdown * 100, "max_drawdown_pct": max_drawdown * 100,
'sharpe_ratio': sharpe_ratio, "sharpe_ratio": sharpe_ratio,
# Datos para gráficos # Datos para gráficos
'equity_curve': self.equity_curve, "equity_curve": self.equity_curve,
'timestamps': self.timestamps, "timestamps": self.timestamps,
'trades': closed_trades, "trades": closed_trades,
} }
return results return results
@@ -314,26 +403,26 @@ class BacktestEngine:
def _empty_results(self) -> dict: def _empty_results(self) -> dict:
"""Resultados vacíos cuando no hay trades""" """Resultados vacíos cuando no hay trades"""
return { return {
'initial_capital': self.initial_capital, "initial_capital": self.initial_capital,
'final_equity': self.equity, "final_equity": self.equity,
'total_return': 0, "total_return": 0,
'total_return_pct': 0, "total_return_pct": 0,
'total_trades': 0, "total_trades": 0,
'winning_trades': 0, "winning_trades": 0,
'losing_trades': 0, "losing_trades": 0,
'win_rate': 0, "win_rate": 0,
'win_rate_pct': 0, "win_rate_pct": 0,
'total_pnl': 0, "total_pnl": 0,
'gross_profit': 0, "gross_profit": 0,
'gross_loss': 0, "gross_loss": 0,
'profit_factor': 0, "profit_factor": 0,
'avg_win': 0, "avg_win": 0,
'avg_loss': 0, "avg_loss": 0,
'avg_trade': 0, "avg_trade": 0,
'max_drawdown': 0, "max_drawdown": 0,
'max_drawdown_pct': 0, "max_drawdown_pct": 0,
'sharpe_ratio': 0, "sharpe_ratio": 0,
'equity_curve': self.equity_curve, "equity_curve": self.equity_curve,
'timestamps': self.timestamps, "timestamps": self.timestamps,
'trades': [], "trades": [],
} }

View File

@@ -102,6 +102,8 @@ class Position:
average_price: float average_price: float
total_size: float total_size: float
trades: list = field(default_factory=list) trades: list = field(default_factory=list)
stop_price: Optional[float] = None
initial_stop_price: Optional[float] = None
def add_trade(self, trade: Trade): def add_trade(self, trade: Trade):
""" """
@@ -130,3 +132,50 @@ class Position:
return (current_price - self.average_price) * self.total_size return (current_price - self.average_price) * self.total_size
else: else:
return (self.average_price - current_price) * self.total_size 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}") log.debug(f"Datos normalizados usando {method}")
return df_norm 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)