From c569170fcc981cd41e4db51ab9d80794775d77a5 Mon Sep 17 00:00:00 2001 From: DaM Date: Fri, 30 Jan 2026 17:05:47 +0100 Subject: [PATCH] Engine: add stop loss integration (fixed & trailing) with tests --- backtest.py | 27 +- download_data.py | 3 +- src/backtest/engine.py | 293 +++++++++++++------- src/backtest/trade.py | 51 +++- src/data/processor.py | 19 -- src/risk/__init__.py | 0 src/risk/sizing/__init__.py | 0 src/risk/sizing/base.py | 23 ++ src/risk/sizing/fixed.py | 26 ++ src/risk/sizing/percent_risk.py | 36 +++ src/risk/sizing/volatility.py | 34 +++ src/risk/stops/__init__.py | 0 src/risk/stops/atr_stop.py | 70 +++++ src/risk/stops/base.py | 39 +++ src/risk/stops/fixed_stop.py | 37 +++ src/risk/stops/trailing_stop.py | 70 +++++ tests/backtest/test_engine_sizing.py | 93 +++++++ tests/backtest/test_engine_stop.py | 100 +++++++ tests/backtest/test_engine_trailing_stop.py | 82 ++++++ tests/risk/sizing/test_fixed.py | 39 +++ tests/risk/sizing/test_percent_risk.py | 50 ++++ tests/risk/sizing/test_volatility.py | 42 +++ tests/risk/stops/test_atr_stop.py | 73 +++++ tests/risk/stops/test_fixed_stop.py | 51 ++++ 24 files changed, 1121 insertions(+), 137 deletions(-) create mode 100644 src/risk/__init__.py create mode 100644 src/risk/sizing/__init__.py create mode 100644 src/risk/sizing/base.py create mode 100644 src/risk/sizing/fixed.py create mode 100644 src/risk/sizing/percent_risk.py create mode 100644 src/risk/sizing/volatility.py create mode 100644 src/risk/stops/__init__.py create mode 100644 src/risk/stops/atr_stop.py create mode 100644 src/risk/stops/base.py create mode 100644 src/risk/stops/fixed_stop.py create mode 100644 src/risk/stops/trailing_stop.py create mode 100644 tests/backtest/test_engine_sizing.py create mode 100644 tests/backtest/test_engine_stop.py create mode 100644 tests/backtest/test_engine_trailing_stop.py create mode 100644 tests/risk/sizing/test_fixed.py create mode 100644 tests/risk/sizing/test_percent_risk.py create mode 100644 tests/risk/sizing/test_volatility.py create mode 100644 tests/risk/stops/test_atr_stop.py create mode 100644 tests/risk/stops/test_fixed_stop.py diff --git a/backtest.py b/backtest.py index 5c51f49..182d25d 100644 --- a/backtest.py +++ b/backtest.py @@ -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") diff --git a/download_data.py b/download_data.py index f4af21f..24c80a3 100644 --- a/download_data.py +++ b/download_data.py @@ -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...") diff --git a/src/backtest/engine.py b/src/backtest/engine.py index 89fe40c..6d1dbeb 100644 --- a/src/backtest/engine.py +++ b/src/backtest/engine.py @@ -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 @@ -56,6 +63,11 @@ class BacktestEngine: log.info(f"Backtesting Engine inicializado: {strategy}") 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: """ @@ -120,26 +132,57 @@ 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) - + elif signal == Signal.SELL and self.current_position is not None: self._close_position(idx, "Strategy signal") 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'] @@ -147,26 +190,57 @@ 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 - - # Calcular comisión + + # -------------------------------------------------- + # ✅ 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 + + 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): """ @@ -195,145 +279,150 @@ class BacktestEngine: """ if self.current_position is None: 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) execution_price = current_price * (1 - self.slippage) - + # Valor de la posición position_value = self.current_position.total_size * execution_price - + # Calcular comisión commission_cost = position_value * self.commission - + # Cerrar todos los trades de la posición for trade in self.current_position.trades: trade.exit_commission = commission_cost / len(self.current_position.trades) trade.close(execution_price, current_time, reason) - + # Actualizar cash self.cash += (position_value - commission_cost) - + # 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 - + def _update_equity(self, idx: int): """ Actualiza el equity total (cash + valor de posiciones abiertas) """ 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 - + def _calculate_results(self) -> dict: """ Calcula métricas y resultados del backtest """ closed_trades = [t for t in self.trades if t.status == TradeStatus.CLOSED] - + if not closed_trades: log.warning("No hay trades cerrados para analizar") return self._empty_results() - + # Métricas básicas total_trades = len(closed_trades) winning_trades = [t for t in closed_trades if t.pnl > 0] losing_trades = [t for t in closed_trades if t.pnl < 0] - + win_rate = len(winning_trades) / total_trades if total_trades > 0 else 0 - + total_pnl = sum(t.pnl for t in closed_trades) total_return = (self.equity - self.initial_capital) / self.initial_capital - + # 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) running_max = np.maximum.accumulate(equity_curve) drawdown = (equity_curve - running_max) / running_max max_drawdown = drawdown.min() - + # 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 - + 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': [], - } \ No newline at end of file + "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": [], + } diff --git a/src/backtest/trade.py b/src/backtest/trade.py index 532052a..230302d 100644 --- a/src/backtest/trade.py +++ b/src/backtest/trade.py @@ -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): """ @@ -129,4 +131,51 @@ class Position: if self.trade_type == TradeType.LONG: return (current_price - self.average_price) * self.total_size else: - return (self.average_price - current_price) * self.total_size \ No newline at end of file + 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 \ No newline at end of file diff --git a/src/data/processor.py b/src/data/processor.py index 986fcb9..35c6803 100644 --- a/src/data/processor.py +++ b/src/data/processor.py @@ -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 \ No newline at end of file diff --git a/src/risk/__init__.py b/src/risk/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/risk/sizing/__init__.py b/src/risk/sizing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/risk/sizing/base.py b/src/risk/sizing/base.py new file mode 100644 index 0000000..4fcfe44 --- /dev/null +++ b/src/risk/sizing/base.py @@ -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 diff --git a/src/risk/sizing/fixed.py b/src/risk/sizing/fixed.py new file mode 100644 index 0000000..e0c912b --- /dev/null +++ b/src/risk/sizing/fixed.py @@ -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 diff --git a/src/risk/sizing/percent_risk.py b/src/risk/sizing/percent_risk.py new file mode 100644 index 0000000..8c8a8a6 --- /dev/null +++ b/src/risk/sizing/percent_risk.py @@ -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 diff --git a/src/risk/sizing/volatility.py b/src/risk/sizing/volatility.py new file mode 100644 index 0000000..52f28e2 --- /dev/null +++ b/src/risk/sizing/volatility.py @@ -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 diff --git a/src/risk/stops/__init__.py b/src/risk/stops/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/risk/stops/atr_stop.py b/src/risk/stops/atr_stop.py new file mode 100644 index 0000000..b37d441 --- /dev/null +++ b/src/risk/stops/atr_stop.py @@ -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}") diff --git a/src/risk/stops/base.py b/src/risk/stops/base.py new file mode 100644 index 0000000..1ca6704 --- /dev/null +++ b/src/risk/stops/base.py @@ -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 diff --git a/src/risk/stops/fixed_stop.py b/src/risk/stops/fixed_stop.py new file mode 100644 index 0000000..f383193 --- /dev/null +++ b/src/risk/stops/fixed_stop.py @@ -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}") diff --git a/src/risk/stops/trailing_stop.py b/src/risk/stops/trailing_stop.py new file mode 100644 index 0000000..b0beaca --- /dev/null +++ b/src/risk/stops/trailing_stop.py @@ -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 diff --git a/tests/backtest/test_engine_sizing.py b/tests/backtest/test_engine_sizing.py new file mode 100644 index 0000000..7aa0687 --- /dev/null +++ b/tests/backtest/test_engine_sizing.py @@ -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% \ No newline at end of file diff --git a/tests/backtest/test_engine_stop.py b/tests/backtest/test_engine_stop.py new file mode 100644 index 0000000..a24e068 --- /dev/null +++ b/tests/backtest/test_engine_stop.py @@ -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" diff --git a/tests/backtest/test_engine_trailing_stop.py b/tests/backtest/test_engine_trailing_stop.py new file mode 100644 index 0000000..d1cb009 --- /dev/null +++ b/tests/backtest/test_engine_trailing_stop.py @@ -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] diff --git a/tests/risk/sizing/test_fixed.py b/tests/risk/sizing/test_fixed.py new file mode 100644 index 0000000..974e909 --- /dev/null +++ b/tests/risk/sizing/test_fixed.py @@ -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) diff --git a/tests/risk/sizing/test_percent_risk.py b/tests/risk/sizing/test_percent_risk.py new file mode 100644 index 0000000..a58153a --- /dev/null +++ b/tests/risk/sizing/test_percent_risk.py @@ -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 + ) diff --git a/tests/risk/sizing/test_volatility.py b/tests/risk/sizing/test_volatility.py new file mode 100644 index 0000000..4cebfbe --- /dev/null +++ b/tests/risk/sizing/test_volatility.py @@ -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) diff --git a/tests/risk/stops/test_atr_stop.py b/tests/risk/stops/test_atr_stop.py new file mode 100644 index 0000000..f95f7de --- /dev/null +++ b/tests/risk/stops/test_atr_stop.py @@ -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) diff --git a/tests/risk/stops/test_fixed_stop.py b/tests/risk/stops/test_fixed_stop.py new file mode 100644 index 0000000..a8adbc1 --- /dev/null +++ b/tests/risk/stops/test_fixed_stop.py @@ -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)