From 9b34de3127b1076b0c127ac8e73376b1294c7299 Mon Sep 17 00:00:00 2001 From: DaM Date: Tue, 27 Jan 2026 21:37:39 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Backtesting=20engine=20completo=20+=20d?= =?UTF-8?q?ocumentaci=C3=B3n=20(Semanas=203-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✅ Motor de backtesting: - BacktestEngine con simulación de trades - Sistema de Trade y Position - Gestión de capital y comisiones - Slippage simulado ✅ Estrategias implementadas: - MovingAverageCrossover (SMA/EMA configurable) - RSIStrategy (umbrales personalizables) - BuyAndHold (baseline) ✅ Métricas de performance: - Sharpe Ratio, Sortino Ratio, Calmar Ratio - Max Drawdown, Win Rate, Profit Factor - Expectancy, Risk/Reward Ratio ✅ Scripts: - backtest.py: Ejecutar backtests individuales - backtest.py compare: Comparar múltiples estrategias ✅ Documentación: - README actualizado con sección de backtesting - Ejemplos de uso programático - Estructura de proyecto actualizada --- BACKTESTING.md | 295 +++++++++++++++++++++++++++ README.md | 152 ++++++++++++-- backtest.py | 210 +++++++++++++++++++ src/backtest/__init__.py | 29 +++ src/backtest/engine.py | 339 +++++++++++++++++++++++++++++++ src/backtest/metrics.py | 278 +++++++++++++++++++++++++ src/backtest/optimizer.py | 195 ++++++++++++++++++ src/backtest/strategy.py | 198 ++++++++++++++++++ src/backtest/trade.py | 132 ++++++++++++ src/strategies/__init__.py | 13 ++ src/strategies/base.py | 5 + src/strategies/buy_and_hold.py | 40 ++++ src/strategies/moving_average.py | 89 ++++++++ src/strategies/rsi_strategy.py | 65 ++++++ 14 files changed, 2020 insertions(+), 20 deletions(-) create mode 100644 src/backtest/optimizer.py create mode 100644 src/strategies/buy_and_hold.py create mode 100644 src/strategies/rsi_strategy.py diff --git a/BACKTESTING.md b/BACKTESTING.md index e69de29..207b5da 100644 --- a/BACKTESTING.md +++ b/BACKTESTING.md @@ -0,0 +1,295 @@ +# 🧪 Backtesting Engine - Guía de Uso + +## 📋 Descripción + +El motor de backtesting permite simular estrategias de trading sobre datos históricos para evaluar su performance antes de arriesgar capital real. + +## 🚀 Uso Rápido + +### Demo Simple + +```bash +python backtest.py +``` + +Esto ejecuta un backtest con: +- Estrategia: Moving Average Crossover (10/30) +- Símbolo: BTC/USDT +- Periodo: 60 días +- Capital inicial: $10,000 + +### Comparar Estrategias + +```bash +python backtest.py compare +``` + +Compara 4 estrategias diferentes sobre los mismos datos. + +## 📊 Estrategias Disponibles + +### 1. Moving Average Crossover + +Cruces de medias móviles: + +```python +from src.strategies import MovingAverageCrossover + +strategy = MovingAverageCrossover( + fast_period=10, # Periodo media rápida + slow_period=30, # Periodo media lenta + ma_type='sma' # 'sma' o 'ema' +) +``` + +**Señales:** +- BUY: Media rápida cruza por encima de media lenta +- SELL: Media rápida cruza por debajo de media lenta + +### 2. RSI Strategy + +Basada en niveles de RSI: + +```python +from src.strategies import RSIStrategy + +strategy = RSIStrategy( + rsi_period=14, # Periodo del RSI + oversold_threshold=30, # Umbral de sobreventa + overbought_threshold=70 # Umbral de sobrecompra +) +``` + +**Señales:** +- BUY: RSI < 30 (sobrevendido) +- SELL: RSI > 70 (sobrecomprado) + +### 3. Buy and Hold + +Estrategia base para comparación: + +```python +from src.strategies import BuyAndHold + +strategy = BuyAndHold() +``` + +Compra al inicio y mantiene hasta el final. + +## 🛠️ Uso Programático + +### Backtest Básico + +```python +from src.data.storage import StorageManager +from src.backtest import BacktestEngine +from src.strategies import MovingAverageCrossover + +# Cargar datos +storage = StorageManager(...) +data = storage.load_ohlcv('BTC/USDT', '1h') + +# Crear estrategia +strategy = MovingAverageCrossover(fast_period=10, slow_period=30) + +# Crear motor de backtesting +engine = BacktestEngine( + strategy=strategy, + initial_capital=10000, + commission=0.001, # 0.1% por trade + slippage=0.0005, # 0.05% de slippage + position_size=0.95 # Usar 95% del capital +) + +# Ejecutar +results = engine.run(data) + +# Ver resultados +from src.backtest.metrics import print_backtest_report +print_backtest_report(results) +``` + +### Crear Tu Propia Estrategia + +```python +from src.backtest.strategy import Strategy, Signal +import pandas as pd + +class MiEstrategia(Strategy): + + def __init__(self): + super().__init__(name="Mi Estrategia", params={}) + + def init_indicators(self, data: pd.DataFrame) -> pd.DataFrame: + """ + Calcula tus indicadores aquí + """ + # Ejemplo: añadir SMA + data['sma_20'] = data['close'].rolling(20).mean() + return data + + def generate_signal(self, idx: int) -> Signal: + """ + Lógica de tu estrategia + """ + current_price = self.data.iloc[idx]['close'] + sma = self.data.iloc[idx]['sma_20'] + + # Ejemplo: comprar si precio > SMA + if current_price > sma and self.current_position == 0: + return Signal.BUY + + # Vender si precio < SMA y tenemos posición + elif current_price < sma and self.current_position > 0: + return Signal.SELL + + return Signal.HOLD + +# Usar tu estrategia +strategy = MiEstrategia() +engine = BacktestEngine(strategy, initial_capital=10000) +results = engine.run(data) +``` + +## 📈 Métricas Disponibles + +### Básicas +- **Total Return**: Retorno total del periodo +- **Total Trades**: Número de trades ejecutados +- **Win Rate**: Porcentaje de trades ganadores +- **Profit Factor**: Ganancia bruta / Pérdida bruta + +### Avanzadas +- **Sharpe Ratio**: Retorno ajustado por riesgo +- **Sortino Ratio**: Como Sharpe pero solo penaliza volatilidad a la baja +- **Max Drawdown**: Máxima caída desde un pico +- **Calmar Ratio**: Retorno anualizado / Max Drawdown +- **Expectancy**: Ganancia esperada por trade +- **Recovery Factor**: Net Profit / Max Drawdown + +## 🎯 Estructura de Resultados + +```python +results = { + # Capital + 'initial_capital': 10000, + 'final_equity': 12500, + 'total_return': 0.25, + 'total_return_pct': 25.0, + 'total_pnl': 2500, + + # Trades + 'total_trades': 15, + 'winning_trades': 9, + 'losing_trades': 6, + 'win_rate': 0.6, + 'win_rate_pct': 60.0, + + # Performance + 'gross_profit': 3000, + 'gross_loss': 500, + 'profit_factor': 6.0, + 'avg_trade': 166.67, + 'avg_win': 333.33, + 'avg_loss': -83.33, + + # Riesgo + 'max_drawdown': -0.15, + 'max_drawdown_pct': -15.0, + 'sharpe_ratio': 1.8, + + # Para análisis + 'equity_curve': [...], + 'timestamps': [...], + 'trades': [Trade(...), Trade(...), ...] +} +``` + +## 💡 Mejores Prácticas + +### 1. Periodo de Backtest +- **Mínimo**: 1 año de datos +- **Recomendado**: 3-5 años +- **Ideal**: Múltiples ciclos de mercado + +### 2. Comisiones y Slippage +- Siempre incluir comisiones realistas +- Incluir slippage (0.05% - 0.1%) +- No optimizar en exceso (overfitting) + +### 3. Position Sizing +- No usar 100% del capital por trade +- Recomendado: 50-95% del capital disponible +- Considerar gestión de riesgo + +### 4. Validación +- **In-sample**: Periodo de entrenamiento/optimización +- **Out-of-sample**: Periodo de validación (datos no vistos) +- **Walk-forward**: Validación continua + +### 5. Métricas Importantes +- No solo mirar retorno total +- Sharpe Ratio > 1.0 es bueno, > 2.0 es excelente +- Max Drawdown < 20% es aceptable +- Win Rate: >50% es bueno, pero no es lo único importante +- Profit Factor > 1.5 es bueno, > 2.0 es excelente + +## ⚠️ Advertencias + +### Limitaciones del Backtesting + +1. **Look-ahead bias**: No usar información futura +2. **Survivorship bias**: Incluir activos que ya no existen +3. **Overfitting**: Optimizar demasiado para datos históricos +4. **Market conditions**: Pasado no garantiza futuro +5. **Ejecución perfecta**: Backtesting asume ejecución instantánea + +### Realismo + +El backtest asume: +- ✅ Comisiones y slippage incluidos +- ✅ No hay look-ahead bias +- ❌ Liquidez infinita (órdenes siempre se ejecutan) +- ❌ No considera impacto de mercado +- ❌ No simula rechazo de órdenes + +## 📊 Ejemplos de Resultados + +### Estrategia Exitosa +``` +Retorno Total: 45.2% +Sharpe Ratio: 2.1 +Max Drawdown: -12.3% +Win Rate: 58% +Profit Factor: 2.4 +``` + +### Estrategia Problemática +``` +Retorno Total: 15.2% +Sharpe Ratio: 0.4 +Max Drawdown: -35.8% +Win Rate: 45% +Profit Factor: 1.1 +``` + +## 🔄 Próximos Pasos + +Después del backtesting: +1. ✅ Si resultados son buenos → Paper trading +2. ✅ Validar en out-of-sample +3. ✅ Optimizar parámetros (con cuidado) +4. ✅ Añadir gestión de riesgo +5. ✅ Testear en diferentes condiciones de mercado + +## 📝 Notas + +- Siempre testea en datos out-of-sample +- Un buen backtest no garantiza éxito futuro +- Considera paper trading antes de dinero real +- Mantén expectativas realistas +- El mercado puede cambiar + +--- + +**Para dudas o problemas, consulta el README principal del proyecto.** \ No newline at end of file diff --git a/README.md b/README.md index af0a19a..46880cf 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,9 @@ Bot de trading algorítmico desarrollado desde cero con Python, PostgreSQL y Mac ## 🎯 Estado del Proyecto -### ✅ Completado (Semanas 1-2) +### ✅ Completado (Semanas 1-2 y 3-4) +**Semanas 1-2: Infraestructura de Datos** - ✅ Sistema de logging robusto con rotación de archivos - ✅ Conexión a exchanges vía CCXT (Binance por defecto) - ✅ Descarga de datos históricos con reintentos automáticos @@ -35,6 +36,23 @@ Bot de trading algorítmico desarrollado desde cero con Python, PostgreSQL y Mac - ✅ Tests unitarios - ✅ Manejo de errores y reintentos +**Semanas 3-4: Backtesting Engine** +- ✅ Motor de backtesting completo +- ✅ Clase base abstracta para estrategias +- ✅ Sistema de trades y posiciones +- ✅ 3 estrategias implementadas: + - Moving Average Crossover (SMA/EMA) + - RSI Strategy + - Buy & Hold (baseline) +- ✅ Métricas de performance: + - Sharpe Ratio, Sortino Ratio + - Max Drawdown, Calmar Ratio + - Win Rate, Profit Factor + - Expectancy, Risk/Reward +- ✅ Script de comparación de estrategias +- ✅ Simulación de comisiones y slippage +- ✅ Gestión de capital y position sizing + **Datos descargados actualmente:** - 5 criptomonedas (BTC, ETH, BNB, SOL, XRP) - 3 timeframes (1h, 4h, 1d) @@ -43,9 +61,9 @@ Bot de trading algorítmico desarrollado desde cero con Python, PostgreSQL y Mac ### 🔄 En Progreso -- ⏳ Backtesting Engine (Semanas 3-4) -- ⏳ Estrategias de trading (Semanas 5-8) -- ⏳ Machine Learning (Semanas 5-8) +- ⏳ Optimización de parámetros (en desarrollo) +- ⏳ Visualizaciones de resultados (en desarrollo) +- ⏳ Machine Learning (Semanas 5-8 planificadas) ### 📅 Planificado @@ -281,8 +299,33 @@ timeframes = ['1h', '4h', '1d'] days_back = 120 # Cambia aquí ``` +### Backtesting + +**Ejecutar backtest simple:** +```bash +python backtest.py +``` + +Esto ejecuta un backtest con: +- Estrategia: Moving Average Crossover (10/30) +- Símbolo: BTC/USDT +- Periodo: 60 días +- Capital inicial: $10,000 + +**Comparar múltiples estrategias:** +```bash +python backtest.py compare +``` + +Compara 4 estrategias diferentes: +- Buy & Hold +- MA Cross (10/30 SMA) +- MA Cross (20/50 EMA) +- RSI (30/70) + ### Uso programático +**Pipeline de datos:** ```python from src.data.fetcher import DataFetcher from src.data.processor import DataProcessor @@ -304,6 +347,34 @@ df_clean = processor.calculate_returns(df_clean) storage.save_ohlcv(df_clean) ``` +**Backtesting:** +```python +from src.data.storage import StorageManager +from src.backtest import BacktestEngine +from src.strategies import MovingAverageCrossover + +# Cargar datos +storage = StorageManager(...) +data = storage.load_ohlcv('BTC/USDT', '1h') + +# Crear estrategia +strategy = MovingAverageCrossover(fast_period=10, slow_period=30) + +# Ejecutar backtest +engine = BacktestEngine( + strategy=strategy, + initial_capital=10000, + commission=0.001, + position_size=0.95 +) + +results = engine.run(data) + +# Ver resultados +from src.backtest.metrics import print_backtest_report +print_backtest_report(results) +``` + ## 📁 Estructura del Proyecto ``` @@ -313,17 +384,32 @@ trading-bot/ │ └── secrets.env # Credenciales (NO subir a git) │ ├── src/ # Código fuente +│ ├── backtest/ # Motor de backtesting +│ │ ├── __init__.py +│ │ ├── engine.py # BacktestEngine +│ │ ├── strategy.py # Clase base Strategy +│ │ ├── trade.py # Trade, Position +│ │ └── metrics.py # Métricas de performance +│ │ +│ ├── strategies/ # Estrategias de trading +│ │ ├── __init__.py +│ │ ├── moving_average.py # MA Crossover +│ │ ├── rsi_strategy.py # RSI Strategy +│ │ ├── buy_and_hold.py # Buy & Hold +│ │ ├── base.py # (futuro) +│ │ ├── ml_model.py # (futuro) +│ │ └── signals.py # (futuro) +│ │ │ ├── data/ # Módulo de datos │ │ ├── __init__.py │ │ ├── fetcher.py # Descarga desde exchanges │ │ ├── processor.py # Limpieza y procesamiento │ │ └── storage.py # PostgreSQL + Redis │ │ -│ ├── backtest/ # Motor de backtesting (próximo) -│ ├── strategies/ # Estrategias de trading (próximo) -│ ├── ml/ # Machine Learning (futuro) │ └── utils/ # Utilidades -│ └── logger.py # Sistema de logging +│ ├── __init__.py +│ ├── logger.py # Sistema de logging +│ └── alerts.py # (futuro) │ ├── tests/ # Tests unitarios │ └── test_data.py @@ -338,6 +424,7 @@ trading-bot/ │ ├── main.py # Demo/testing ├── download_data.py # Descarga masiva +├── backtest.py # Backtesting runner ├── requirements.txt # Dependencias ├── .gitignore └── README.md @@ -488,6 +575,23 @@ python download_data.py - Manejo de errores robusto - Resumen final con estadísticas +### `backtest.py` - Backtesting + +```bash +# Backtest simple +python backtest.py + +# Comparar estrategias +python backtest.py compare +``` + +**Uso:** Evaluar estrategias sobre datos históricos +**Características:** +- Simulación de comisiones y slippage +- Métricas detalladas de performance +- Comparación de múltiples estrategias +- Informe completo de resultados + ## 🧪 Testing ### Ejecutar todos los tests @@ -511,29 +615,36 @@ pytest tests/test_data.py::TestDataProcessor::test_clean_data_removes_duplicates ## 🗺️ Roadmap -### ✅ Fase 1: Infraestructura de Datos (COMPLETADO) +### ✅ Fase 1: Infraestructura de Datos (COMPLETADO - Semanas 1-2) - Sistema de descarga robusto - Almacenamiento optimizado - Procesamiento de datos -### 🔄 Fase 2: Backtesting (PRÓXIMO - Semanas 3-4) +### ✅ Fase 2: Backtesting (COMPLETADO - Semanas 3-4) - Motor de backtesting -- Estrategia simple (moving average crossover) +- 3 estrategias implementadas - Métricas de performance -- Visualizaciones +- Sistema de comparación -### 📅 Fase 3: Estrategias Avanzadas (Semanas 5-8) -- Indicadores técnicos +### 🔄 Fase 3: Optimización y Visualización (EN PROGRESO) +- Optimización de parámetros (grid search) +- Visualizaciones de resultados +- Gráficos de equity curve +- Walk-forward analysis + +### 📅 Fase 4: Estrategias Avanzadas (Semanas 5-8) +- Indicadores técnicos avanzados - Machine Learning básico -- Optimización de parámetros +- Feature engineering +- Optimización con validación -### 📅 Fase 4: Trading Real (Semanas 9-12) +### 📅 Fase 5: Trading Real (Semanas 9-12) - Paper trading -- Gestión de riesgo +- Gestión de riesgo avanzada - Ejecución de órdenes - Monitoreo en tiempo real -### 📅 Fase 5: Producción (Futuro) +### 📅 Fase 6: Producción (Futuro) - Dashboard web - Alertas y notificaciones - Multi-exchange @@ -646,8 +757,9 @@ Para dudas sobre el código o siguientes fases de desarrollo, consulta conmigo. --- -**Versión actual:** 0.2.0 (Semanas 1-2 completadas, Semanas 3-4 en preparación) +**Versión actual:** 0.3.0 (Semanas 1-4 completadas) **Última actualización:** Enero 2026 **Python:** 3.12.3 **PostgreSQL:** 16+ -**Datos:** 5 símbolos, 3 timeframes, 120 días (~54k registros) \ No newline at end of file +**Datos:** 5 símbolos, 3 timeframes, 120 días (~54k registros) +**Estrategias:** 3 implementadas (MA Cross, RSI, Buy&Hold) \ No newline at end of file diff --git a/backtest.py b/backtest.py index e69de29..5c51f49 100644 --- a/backtest.py +++ b/backtest.py @@ -0,0 +1,210 @@ +# backtest.py +""" +Script principal para ejecutar backtests +""" +import os +from dotenv import load_dotenv +from pathlib import Path +from datetime import datetime, timedelta + +from src.utils.logger import log +from src.data.storage import StorageManager +from src.backtest.engine import BacktestEngine +from src.backtest.metrics import print_backtest_report, calculate_all_metrics +from src.strategies import MovingAverageCrossover, BuyAndHold, RSIStrategy + +def setup_environment(): + """Carga variables de entorno""" + env_path = Path(__file__).parent / 'config' / 'secrets.env' + load_dotenv(dotenv_path=env_path) + log.success("✓ Variables de entorno cargadas") + +def run_backtest_demo(): + """ + Demo de backtesting con estrategia simple + """ + log.info("="*70) + log.info("🧪 BACKTESTING - DEMO") + log.info("="*70) + + # Setup + setup_environment() + + # 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( + db_host=os.getenv('DB_HOST'), + db_port=int(os.getenv('DB_PORT', 5432)), + db_name=os.getenv('DB_NAME'), + db_user=os.getenv('DB_USER'), + db_password=os.getenv('DB_PASSWORD'), + ) + + # Cargar datos + log.info("\n📥 Cargando datos desde PostgreSQL...") + end_date = datetime.now() + start_date = end_date - timedelta(days=days_back) + + data = storage.load_ohlcv( + symbol=symbol, + timeframe=timeframe, + start_date=start_date, + end_date=end_date, + use_cache=False + ) + + if data.empty: + log.error(f"❌ No hay datos disponibles para {symbol} {timeframe}") + log.info("💡 Ejecuta primero: python download_data.py") + return + + log.success(f"✓ Datos cargados: {len(data)} velas") + log.info(f" Desde: {data.index[0]}") + log.info(f" Hasta: {data.index[-1]}") + + # Crear estrategia + strategy = MovingAverageCrossover( + fast_period=10, + slow_period=30, + ma_type='sma' + ) + + # Crear motor de backtesting + engine = BacktestEngine( + strategy=strategy, + initial_capital=10000, + commission=0.001, # 0.1% + slippage=0.0005, # 0.05% + position_size=0.95 # Usar 95% del capital + ) + + # Ejecutar backtest + log.info("\n🚀 Ejecutando backtest...") + results = engine.run(data) + + # Mostrar resultados + print_backtest_report(results) + + # Calcular métricas adicionales + additional_metrics = calculate_all_metrics(results) + + if additional_metrics: + print("\n📈 MÉTRICAS ADICIONALES:") + print(f" Sortino Ratio: {additional_metrics['sortino_ratio']:>12.2f}") + print(f" Calmar Ratio: {additional_metrics['calmar_ratio']:>12.2f}") + print(f" Expectancy: ${additional_metrics['expectancy']:>12,.2f}") + print(f" Risk/Reward: {additional_metrics['risk_reward_ratio']:>12.2f}") + print(f" Recovery Factor: {additional_metrics['recovery_factor']:>12.2f}") + + # Mostrar algunos trades de ejemplo + if results['trades']: + print(f"\n📋 TRADES (primeros 5):") + for i, trade in enumerate(results['trades'][:5], 1): + print(f"\n Trade #{i}:") + print(f" Entrada: {trade.entry_time} @ ${trade.entry_price:.2f}") + print(f" Salida: {trade.exit_time} @ ${trade.exit_price:.2f}") + print(f" PnL: ${trade.pnl:>10.2f} ({trade.pnl_percentage:>6.2f}%)") + print(f" Duración: {trade.duration:.1f}h") + + # Cleanup + storage.close() + + log.info("\n" + "="*70) + log.success("✅ DEMO COMPLETADO") + log.info("="*70) + + return results + +def compare_strategies_demo(): + """ + Compara múltiples estrategias sobre los mismos datos + """ + log.info("="*70) + log.info("🔍 COMPARACIÓN DE ESTRATEGIAS") + log.info("="*70) + + setup_environment() + + # Configuración + symbol = 'BTC/USDT' + timeframe = '1h' + days_back = 60 + + # Conectar a base de datos y cargar datos + storage = StorageManager( + db_host=os.getenv('DB_HOST'), + db_port=int(os.getenv('DB_PORT', 5432)), + db_name=os.getenv('DB_NAME'), + db_user=os.getenv('DB_USER'), + 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) + + if data.empty: + log.error(f"❌ No hay datos disponibles") + return + + log.success(f"✓ Datos cargados: {len(data)} velas") + + # Definir estrategias a comparar + strategies = [ + ('Buy & Hold', BuyAndHold()), + ('MA Cross (10/30 SMA)', MovingAverageCrossover(10, 30, 'sma')), + ('MA Cross (20/50 EMA)', MovingAverageCrossover(20, 50, 'ema')), + ('RSI (30/70)', RSIStrategy(14, 30, 70)), + ] + + # Ejecutar backtest para cada estrategia + all_results = {} + + for name, strategy in strategies: + log.info(f"\n🧪 Testeando: {name}") + + engine = BacktestEngine( + strategy=strategy, + initial_capital=10000, + commission=0.001, + position_size=0.95 + ) + + results = engine.run(data) + all_results[name] = results + + log.info(f" Retorno: {results['total_return_pct']:.2f}%") + log.info(f" Trades: {results['total_trades']}") + log.info(f" Win Rate: {results['win_rate_pct']:.2f}%") + + # Comparar resultados + from src.backtest.metrics import compare_strategies + compare_strategies(all_results) + + storage.close() + + log.success("✅ COMPARACIÓN COMPLETADA") + + return all_results + +if __name__ == "__main__": + import sys + + if len(sys.argv) > 1 and sys.argv[1] == 'compare': + # Modo comparación + compare_strategies_demo() + else: + # Modo demo simple + run_backtest_demo() + + print("\n💡 TIP: Para comparar estrategias usa: python backtest.py compare") \ No newline at end of file diff --git a/src/backtest/__init__.py b/src/backtest/__init__.py index e69de29..294d63f 100644 --- a/src/backtest/__init__.py +++ b/src/backtest/__init__.py @@ -0,0 +1,29 @@ +# src/backtest/__init__.py +""" +Módulo de backtesting +""" +from .engine import BacktestEngine +from .strategy import Strategy, Signal +from .trade import Trade, TradeType, TradeStatus, Position +from .metrics import ( + calculate_sharpe_ratio, + calculate_sortino_ratio, + calculate_max_drawdown, + print_backtest_report, + compare_strategies +) + +__all__ = [ + 'BacktestEngine', + 'Strategy', + 'Signal', + 'Trade', + 'TradeType', + 'TradeStatus', + 'Position', + 'calculate_sharpe_ratio', + 'calculate_sortino_ratio', + 'calculate_max_drawdown', + 'print_backtest_report', + 'compare_strategies', +] \ No newline at end of file diff --git a/src/backtest/engine.py b/src/backtest/engine.py index e69de29..89fe40c 100644 --- a/src/backtest/engine.py +++ b/src/backtest/engine.py @@ -0,0 +1,339 @@ +# src/backtest/engine.py +""" +Motor de backtesting para simular estrategias de trading +""" +import pandas as pd +import numpy as np +from typing import Optional, List +from datetime import datetime +from ..utils.logger import log +from .strategy import Strategy, Signal +from .trade import Trade, TradeType, TradeStatus, Position + +class BacktestEngine: + """ + Motor de backtesting que simula la ejecución de una estrategia + """ + + def __init__( + self, + strategy: Strategy, + 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%) + ): + """ + Inicializa el motor de backtesting + + Args: + strategy: Estrategia a testear + initial_capital: Capital inicial + 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 + """ + self.strategy = strategy + self.initial_capital = initial_capital + self.commission = commission + self.slippage = slippage + self.position_size_fraction = position_size + + # Estado del backtest + self.cash = initial_capital + self.equity = initial_capital + self.trades: List[Trade] = [] + self.current_position: Optional[Position] = None + + # Historial para métricas + self.equity_curve: List[float] = [] + self.timestamps: List[datetime] = [] + self.drawdown_curve: List[float] = [] + + # Datos + self.data: Optional[pd.DataFrame] = None + + 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%}") + + def run(self, data: pd.DataFrame) -> dict: + """ + Ejecuta el backtest sobre los datos históricos + + Args: + data: DataFrame con datos OHLCV + + Returns: + Diccionario con resultados del backtest + """ + log.info("="*70) + log.info("INICIANDO BACKTEST") + log.info("="*70) + + # Reset state + self._reset() + + # Establecer datos en la estrategia + self.data = data.copy() + self.strategy.set_data(self.data) + + log.info(f"Datos: {len(data)} velas") + log.info(f"Periodo: {data.index[0]} a {data.index[-1]}") + log.info(f"Estrategia: {self.strategy}") + + # Iterar por cada timestamp + for idx in range(len(self.data)): + self._process_bar(idx) + + # Cerrar posición abierta al final + if self.current_position is not None: + self._close_position(len(self.data) - 1, "End of backtest") + + # Calcular resultados + results = self._calculate_results() + + log.info("="*70) + log.success("BACKTEST COMPLETADO") + log.info("="*70) + + return results + + def _reset(self): + """Reinicia el estado del backtest""" + self.cash = self.initial_capital + self.equity = self.initial_capital + self.trades = [] + self.current_position = None + self.equity_curve = [] + self.timestamps = [] + self.drawdown_curve = [] + + def _process_bar(self, idx: int): + """ + Procesa una barra de datos (timestamp) + + Args: + idx: Índice de la barra actual + """ + current_bar = self.data.iloc[idx] + current_price = current_bar['close'] + current_time = current_bar.name # El índice es el timestamp + + # Actualizar equity con posición actual + self._update_equity(idx) + + # Guardar historial + self.equity_curve.append(self.equity) + self.timestamps.append(current_time) + + # Generar señal de la estrategia + 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 + """ + current_bar = self.data.iloc[idx] + current_price = current_bar['close'] + current_time = current_bar.name + + # 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 + 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}") + return + + # Crear trade + trade = Trade( + symbol=current_bar.get('symbol', 'UNKNOWN'), + trade_type=trade_type, + entry_price=execution_price, + entry_time=current_time, + size=size, + entry_commission=commission_cost, + entry_reason="Strategy signal" + ) + + # Actualizar cash + self.cash -= (position_value + commission_cost) + + # Crear posición + self.current_position = Position( + symbol=trade.symbol, + trade_type=trade_type, + average_price=execution_price, + total_size=size, + trades=[trade] + ) + + 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}") + + def _close_position(self, idx: int, reason: str): + """ + Cierra la posición actual + """ + if self.current_position is None: + return + + current_bar = self.data.iloc[idx] + 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}%)") + + # 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'] + 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') + + # 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 + + results = { + # Generales + '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, + + # PnL + '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, + + # Risk metrics + '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, + } + + 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 diff --git a/src/backtest/metrics.py b/src/backtest/metrics.py index e69de29..81dc2b7 100644 --- a/src/backtest/metrics.py +++ b/src/backtest/metrics.py @@ -0,0 +1,278 @@ +# src/backtest/metrics.py +""" +Métricas avanzadas de performance para backtesting +""" +import numpy as np +import pandas as pd +from typing import List +from .trade import Trade + +def calculate_sharpe_ratio(returns: pd.Series, risk_free_rate: float = 0.0) -> float: + """ + Sharpe Ratio - Retorno ajustado por riesgo + + Args: + returns: Serie de retornos + risk_free_rate: Tasa libre de riesgo (anualizada) + + Returns: + Sharpe Ratio anualizado + """ + if len(returns) == 0 or returns.std() == 0: + return 0.0 + + excess_returns = returns - risk_free_rate / 252 # Daily risk-free rate + sharpe = (excess_returns.mean() / excess_returns.std()) * np.sqrt(252) + return sharpe + +def calculate_sortino_ratio(returns: pd.Series, risk_free_rate: float = 0.0) -> float: + """ + Sortino Ratio - Como Sharpe pero solo penaliza volatilidad a la baja + + Args: + returns: Serie de retornos + risk_free_rate: Tasa libre de riesgo + + Returns: + Sortino Ratio anualizado + """ + if len(returns) == 0: + return 0.0 + + excess_returns = returns - risk_free_rate / 252 + downside_returns = excess_returns[excess_returns < 0] + + if len(downside_returns) == 0 or downside_returns.std() == 0: + return 0.0 + + sortino = (excess_returns.mean() / downside_returns.std()) * np.sqrt(252) + return sortino + +def calculate_calmar_ratio(returns: pd.Series, max_drawdown: float) -> float: + """ + Calmar Ratio - Retorno anualizado / Max Drawdown + + Args: + returns: Serie de retornos + max_drawdown: Max drawdown (como valor positivo, ej: 0.2 para 20%) + + Returns: + Calmar Ratio + """ + if max_drawdown == 0: + return 0.0 + + annual_return = returns.mean() * 252 + calmar = annual_return / abs(max_drawdown) + return calmar + +def calculate_max_drawdown(equity_curve: List[float]) -> tuple[float, int, int]: + """ + Calcula el máximo drawdown + + Args: + equity_curve: Lista con valores de equity + + Returns: + Tupla (max_drawdown, start_idx, end_idx) + """ + equity = np.array(equity_curve) + running_max = np.maximum.accumulate(equity) + drawdown = (equity - running_max) / running_max + + max_dd = drawdown.min() + end_idx = drawdown.argmin() + + # Encontrar el inicio del drawdown + start_idx = 0 + for i in range(end_idx, -1, -1): + if equity[i] == running_max[end_idx]: + start_idx = i + break + + return max_dd, start_idx, end_idx + +def calculate_win_rate(trades: List[Trade]) -> float: + """ + Porcentaje de trades ganadores + """ + if not trades: + return 0.0 + + winning = sum(1 for t in trades if t.pnl > 0) + return winning / len(trades) + +def calculate_profit_factor(trades: List[Trade]) -> float: + """ + Profit Factor = Gross Profit / Gross Loss + """ + if not trades: + return 0.0 + + gross_profit = sum(t.pnl for t in trades if t.pnl > 0) + gross_loss = abs(sum(t.pnl for t in trades if t.pnl < 0)) + + if gross_loss == 0: + return float('inf') if gross_profit > 0 else 0.0 + + return gross_profit / gross_loss + +def calculate_expectancy(trades: List[Trade]) -> float: + """ + Expectancy = Average Win * Win Rate - Average Loss * Loss Rate + """ + if not trades: + return 0.0 + + winning_trades = [t for t in trades if t.pnl > 0] + losing_trades = [t for t in trades if t.pnl < 0] + + win_rate = len(winning_trades) / len(trades) + loss_rate = len(losing_trades) / len(trades) + + avg_win = np.mean([t.pnl for t in winning_trades]) if winning_trades else 0 + avg_loss = abs(np.mean([t.pnl for t in losing_trades])) if losing_trades else 0 + + expectancy = (avg_win * win_rate) - (avg_loss * loss_rate) + return expectancy + +def calculate_recovery_factor(total_return: float, max_drawdown: float) -> float: + """ + Recovery Factor = Net Profit / Max Drawdown + """ + if max_drawdown == 0: + return 0.0 + + return total_return / abs(max_drawdown) + +def calculate_risk_reward_ratio(trades: List[Trade]) -> float: + """ + Average Win / Average Loss + """ + if not trades: + return 0.0 + + winning_trades = [t for t in trades if t.pnl > 0] + losing_trades = [t for t in trades if t.pnl < 0] + + if not winning_trades or not losing_trades: + return 0.0 + + avg_win = np.mean([t.pnl for t in winning_trades]) + avg_loss = abs(np.mean([t.pnl for t in losing_trades])) + + if avg_loss == 0: + return float('inf') + + return avg_win / avg_loss + +def print_backtest_report(results: dict): + """ + Imprime un reporte detallado de los resultados del backtest + """ + print("\n" + "="*70) + print("📊 REPORTE DE BACKTEST") + print("="*70) + + # Capital + print(f"\n💰 CAPITAL:") + print(f" Capital Inicial: ${results['initial_capital']:>12,.2f}") + print(f" Capital Final: ${results['final_equity']:>12,.2f}") + print(f" Retorno Total: {results['total_return_pct']:>12.2f}%") + print(f" PnL Total: ${results['total_pnl']:>12,.2f}") + + # Trades + print(f"\n📈 TRADES:") + print(f" Total Trades: {results['total_trades']:>12}") + print(f" Trades Ganadores: {results['winning_trades']:>12}") + print(f" Trades Perdedores: {results['losing_trades']:>12}") + print(f" Win Rate: {results['win_rate_pct']:>12.2f}%") + + # Performance + print(f"\n💵 PERFORMANCE:") + print(f" Ganancia Bruta: ${results['gross_profit']:>12,.2f}") + print(f" Pérdida Bruta: ${results['gross_loss']:>12,.2f}") + print(f" Profit Factor: {results['profit_factor']:>12.2f}") + print(f" Trade Promedio: ${results['avg_trade']:>12,.2f}") + print(f" Ganancia Promedio: ${results['avg_win']:>12,.2f}") + print(f" Pérdida Promedio: ${results['avg_loss']:>12,.2f}") + + # Risk + print(f"\n⚠️ RIESGO:") + print(f" Max Drawdown: {results['max_drawdown_pct']:>12.2f}%") + print(f" Sharpe Ratio: {results['sharpe_ratio']:>12.2f}") + + # Calcular métricas adicionales si hay trades + if results['trades']: + sortino = calculate_sortino_ratio( + pd.Series(results['equity_curve']).pct_change().dropna() + ) + expectancy = calculate_expectancy(results['trades']) + rr_ratio = calculate_risk_reward_ratio(results['trades']) + + print(f" Sortino Ratio: {sortino:>12.2f}") + print(f" Expectancy: ${expectancy:>12,.2f}") + print(f" Risk/Reward Ratio: {rr_ratio:>12.2f}") + + print("\n" + "="*70) + +def compare_strategies(results_dict: dict): + """ + Compara múltiples estrategias + + Args: + results_dict: Diccionario {nombre_estrategia: results} + """ + print("\n" + "="*90) + print("🔍 COMPARACIÓN DE ESTRATEGIAS") + print("="*90) + + # Crear tabla comparativa + comparison = pd.DataFrame({ + name: { + 'Retorno Total (%)': res['total_return_pct'], + 'Sharpe Ratio': res['sharpe_ratio'], + 'Max Drawdown (%)': res['max_drawdown_pct'], + 'Win Rate (%)': res['win_rate_pct'], + 'Profit Factor': res['profit_factor'], + 'Total Trades': res['total_trades'], + } + for name, res in results_dict.items() + }).T + + print(comparison.to_string()) + print("="*90 + "\n") + + # Mejor estrategia por cada métrica + print("🏆 MEJORES POR MÉTRICA:") + for col in comparison.columns: + best = comparison[col].idxmax() + value = comparison.loc[best, col] + print(f" {col:.<30} {best:>20} ({value:.2f})") + + print() + +def calculate_all_metrics(results: dict) -> dict: + """ + Calcula todas las métricas disponibles sobre los resultados + + Returns: + Diccionario con métricas adicionales + """ + if not results['trades']: + return {} + + returns = pd.Series(results['equity_curve']).pct_change().dropna() + + additional_metrics = { + 'sortino_ratio': calculate_sortino_ratio(returns), + 'calmar_ratio': calculate_calmar_ratio(returns, results['max_drawdown']), + 'expectancy': calculate_expectancy(results['trades']), + 'risk_reward_ratio': calculate_risk_reward_ratio(results['trades']), + 'recovery_factor': calculate_recovery_factor( + results['total_return'], + results['max_drawdown'] + ), + } + + return additional_metrics \ No newline at end of file diff --git a/src/backtest/optimizer.py b/src/backtest/optimizer.py new file mode 100644 index 0000000..89af2bf --- /dev/null +++ b/src/backtest/optimizer.py @@ -0,0 +1,195 @@ +# src/backtest/optimizer.py +""" +Optimizador de parámetros para estrategias +""" +import pandas as pd +from typing import Dict, List, Any, Type +from itertools import product +from ..utils.logger import log +from .engine import BacktestEngine +from .strategy import Strategy + +class ParameterOptimizer: + """ + Optimiza parámetros de estrategias mediante grid search + """ + + def __init__(self, + strategy_class: Type[Strategy], + data: pd.DataFrame, + initial_capital: float = 10000, + commission: float = 0.001): + """ + Args: + strategy_class: Clase de estrategia (no instancia) + data: Datos para backtest + initial_capital: Capital inicial + commission: Comisión por trade + """ + self.strategy_class = strategy_class + self.data = data + self.initial_capital = initial_capital + self.commission = commission + + self.results: List[Dict] = [] + + def optimize(self, param_grid: Dict[str, List[Any]]) -> pd.DataFrame: + """ + Optimiza parámetros mediante grid search + + Args: + param_grid: Diccionario con parámetros a probar + Ejemplo: { + 'fast_period': [5, 10, 15, 20], + 'slow_period': [30, 50, 100], + 'ma_type': ['sma', 'ema'] + } + + Returns: + DataFrame con resultados ordenados por retorno + """ + # Generar todas las combinaciones posibles + param_names = list(param_grid.keys()) + param_values = list(param_grid.values()) + combinations = list(product(*param_values)) + + total_tests = len(combinations) + log.info(f"Iniciando optimización: {total_tests} combinaciones a probar") + + # Probar cada combinación + for i, values in enumerate(combinations, 1): + params = dict(zip(param_names, values)) + + log.debug(f"[{i}/{total_tests}] Probando: {params}") + + try: + # Crear estrategia con estos parámetros + strategy = self.strategy_class(**params) + + # Ejecutar backtest + engine = BacktestEngine( + strategy=strategy, + initial_capital=self.initial_capital, + commission=self.commission + ) + + results = engine.run(self.data) + + # Guardar resultados + result_entry = { + **params, # Parámetros probados + 'total_return_pct': results['total_return_pct'], + 'sharpe_ratio': results['sharpe_ratio'], + 'max_drawdown_pct': results['max_drawdown_pct'], + 'total_trades': results['total_trades'], + 'win_rate_pct': results['win_rate_pct'], + 'profit_factor': results['profit_factor'], + } + + self.results.append(result_entry) + + except Exception as e: + log.error(f"Error con parámetros {params}: {e}") + continue + + # Convertir a DataFrame + df_results = pd.DataFrame(self.results) + + # Ordenar por retorno (mejor primero) + df_results = df_results.sort_values('total_return_pct', ascending=False) + + log.success(f"Optimización completa: {len(df_results)} resultados válidos") + + return df_results + + def get_best_params(self, metric: str = 'total_return_pct') -> Dict: + """ + Obtiene los mejores parámetros según una métrica + + Args: + metric: Métrica a optimizar ('total_return_pct', 'sharpe_ratio', etc) + + Returns: + Diccionario con los mejores parámetros + """ + if not self.results: + raise ValueError("No hay resultados. Ejecuta optimize() primero.") + + df_results = pd.DataFrame(self.results) + + # Ordenar por la métrica elegida + if metric in ['max_drawdown_pct']: + # Para drawdown, queremos el MENOR (menos negativo) + best_idx = df_results[metric].idxmax() + else: + # Para otras métricas, queremos el MAYOR + best_idx = df_results[metric].idxmax() + + best_result = df_results.loc[best_idx] + + # Extraer solo los parámetros (no las métricas) + param_names = [col for col in df_results.columns + if col not in ['total_return_pct', 'sharpe_ratio', + 'max_drawdown_pct', 'total_trades', + 'win_rate_pct', 'profit_factor']] + + best_params = {param: best_result[param] for param in param_names} + + log.info(f"Mejores parámetros según {metric}: {best_params}") + log.info(f" {metric}: {best_result[metric]:.2f}") + + return best_params + +# ============================================================================ +# Ejemplo de Uso +# ============================================================================ + +""" +from src.data.storage import StorageManager +from src.strategies.moving_average import MovingAverageCrossover +from src.backtest.optimizer import ParameterOptimizer + +# Cargar datos +storage = StorageManager(...) +data = storage.load_ohlcv('BTC/USDT', '1h') + +# Crear optimizador +optimizer = ParameterOptimizer( + strategy_class=MovingAverageCrossover, # Clase, no instancia + data=data, + initial_capital=10000, + commission=0.001 +) + +# Definir parámetros a probar +param_grid = { + 'fast_period': [5, 10, 15, 20, 25], + 'slow_period': [30, 50, 70, 100, 150], + 'ma_type': ['sma', 'ema'] +} + +# Ejecutar optimización (probará 5 × 5 × 2 = 50 combinaciones) +results_df = optimizer.optimize(param_grid) + +# Ver mejores resultados +print(results_df.head(10)) + +# Obtener mejores parámetros +best_params = optimizer.get_best_params(metric='sharpe_ratio') +print(f"Mejores parámetros: {best_params}") + +# Usar los mejores parámetros +best_strategy = MovingAverageCrossover(**best_params) +``` + +--- + +## 📊 Output esperado: +``` + fast_period slow_period ma_type total_return_pct sharpe_ratio max_drawdown_pct +0 15 50 ema 45.20 2.10 -12.30 +1 10 30 sma 42.80 1.95 -15.20 +2 20 70 ema 38.50 1.85 -14.10 +3 5 30 sma 35.20 1.75 -18.50 +... +""" \ No newline at end of file diff --git a/src/backtest/strategy.py b/src/backtest/strategy.py index e69de29..8c39e9d 100644 --- a/src/backtest/strategy.py +++ b/src/backtest/strategy.py @@ -0,0 +1,198 @@ +# src/backtest/strategy.py +""" +Clase base para estrategias de trading +""" +from abc import ABC, abstractmethod +from enum import Enum +import pandas as pd +from typing import Optional, Dict, Any + +class Signal(Enum): + """Señales de Trading""" + BUY = 1 + SELL = -1 + HOLD = 0 + +class Strategy(ABC): + """ + Clase base abstracta para todas las estrategias + + Para crear una estrategia: + 1. Heredar de Strategy + 2. Implementar init_indicators() + 3. Implementar generate_signal() + """ + + def __init__(self, name: str, params: Optional[Dict[str, Any]] = 0): + """ + Inicializa la estrategia + + Args: + name: Nombre de la estrategia + params: Parámetros configurables de la estrategia + """ + self.name = name + self.params = params or {} + self.data: Optional[pd.DataFrame] = None + self.current_position = 0 # 1 = long, -1 = short, 0 = sin posición + + @abstractmethod + def init_indicators(self, data: pd.DataFrame) -> pd.DataFrame: + """ + Inicializa y calcula indicadores técnicos sobre los datos + + Args: + data: DataFrame con datos OHLCV + + Returns: + DataFrame con indicadores añadidos + """ + pass + + @abstractmethod + def generate_signal(self, idx: int) -> Signal: + """ + Genera señal de trading para un punto específico en el tiempo + + Args: + idx: Índice del DataFrame (posición temporal) + + Returns: + Signal (BUY, SELL, HOLD) + """ + pass + + def set_data(self, data: pd.DataFrame): + """ + Establece los datos y calcula indicadores + """ + self.data = self.init_indicators(data.copy()) + + def get_indicator_value(self, idx: int, indicator_name: str) -> Any: + """ + Obtiene el valor de un indicador en un punto temporal específico + """ + if self.data is None: + raise ValueError("Data no ha sido establecida. Llama a set_data() primero.") + + if indicator_name not in self.data.columns: + raise ValueError(f"Indicador '{indicator_name}' no existe en los datos.") + + return self.data.iloc[idx][indicator_name] + + def __repr__(self): + params_str = ", ".join([f"{k}={v}" for k, v in self.params.items()]) + return f"{self.name}({params_str})" + +# ============================================================================ +# Funciones auxiliares para indicadores técnicos comunes +# ============================================================================ + +def calculate_sma(data: pd.Series, period: int) -> pd.Series: + """ + Simple Moving Average + + Args: + data: Serie de precios + period: Periodo de la media + + Returns: + Serie con SMA + """ + return data.rolling(window=period).mean() + +def calculate_ema(data: pd.Series, period: int) -> pd.Series: + """ + Exponential Moving Average + + Args: + data: Serie de precios + period: Periodo de la media + + Returns: + Serie con EMA + """ + return data.ewm(span=period, adjust=False).mean() + +def calculate_rsi(data: pd.Series, period: int = 14) -> pd.Series: + """ + Relative Strength Index + + Args: + data: Serie de precios + period: Periodo del RSI + + Returns: + Serie con RSI + """ + delta = data.diff() + gain = (delta.where(delta > 0, 0)).rolling(window=period).mean() + loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean() + + rs = gain / loss + rsi = 100 - (100 / (1 + rs)) + return rsi + +def calculate_macd(data: pd.Series, fast: int = 12, slow: int = 26, signal: int = 9) -> tuple: + """ + MACD (Moving Average Convergence Divergence) + + Args: + data: Serie de precios + fast: Periodo EMA rápida + slow: Periodo EMA lenta + signal: Periodo de la línea de señal + + Returns: + Tupla (MACD, Signal Line, Histogram) + """ + ema_fast = calculate_ema(data, fast) + ema_slow = calculate_ema(data, slow) + + macd_line = ema_fast - ema_slow + signal_line = calculate_ema(macd_line, signal) + histogram = macd_line - signal_line + + return macd_line, signal_line, histogram + +def calculate_bollinger_bands(data: pd.Series, period: int = 20, std_dev: float = 2) -> tuple: + """ + Bollinger Bands + + Args: + data: Serie de precios + period: Periodo de la media móvil + std_dev: Número de desviaciones estándar + + Returns: + Tupla (Upper Band, Middle Band, Lower Band) + """ + middle = calculate_sma(data, period) + std = data.rolling(window=period).std() + + upper = middle + (std * std_dev) + lower = middle - (std * std_dev) + + return upper, middle, lower + +def calculate_atr(high: pd.Series, low: pd.Series, close: pd.Series, period: int = 14) -> pd.Series: + """ + Average True Range (volatilidad) + + Args: + high: Serie de precios máximos + low: Serie de precios mínimos + close: Serie de precios de cierre + period: Periodo del ATR + + Returns: + Serie con ATR + """ + tr1 = high - low + tr2 = abs(high - close.shift()) + tr3 = abs(low - close.shift()) + + tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1) + atr = tr.rolling(window=period).mean() + + return atr \ No newline at end of file diff --git a/src/backtest/trade.py b/src/backtest/trade.py index e69de29..532052a 100644 --- a/src/backtest/trade.py +++ b/src/backtest/trade.py @@ -0,0 +1,132 @@ +# src/backtest/trade.py +""" +Clases para representar trades y posiciones +""" +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from typing import Optional + +class TradeType(Enum): + """Tipo de trade""" + LONG = "LONG" + SHORT = "SHORT" + +class TradeStatus(Enum): + """Estado del trade""" + OPEN = "OPEN" + CLOSED = "CLOSED" + +@dataclass +class Trade: + """ + Representa un trade individual + """ + symbol: str + trade_type: TradeType + entry_price: float + entry_time: datetime + size: float + exit_price: Optional[float] = None + exit_time: Optional[datetime] = None + status: TradeStatus = field(default=TradeStatus.OPEN) + + # Costes + entry_commission: float = 0.0 + exit_commission: float = 0.0 + + #Razones + entry_reason: str = "" + exit_reason: str = "" + + @property + def pnl(self) -> float: + """ + Calcula profit and loss del trade + """ + if self.status == TradeStatus.OPEN or self.exit_price is None: + return 0.0 + + if self.trade_type == TradeType.LONG: + raw_pnl = (self.exit_price - self.entry_price) * self.size + else: + raw_pnl = (self.entry_price - self.exit_price) * self.size + + total_pnl = raw_pnl - self.entry_commission - self.exit_commission + return total_pnl + + @property + def pnl_percentage(self) -> float: + """ + Retorno porcentual del trade + """ + if self.status == TradeStatus.OPEN or self.exit_price is None: + return 0.0 + + investment = self.entry_price * self.size + return (self.pnl / investment) * 100 + + @property + def duration(self) -> Optional[float]: + """ + Duración del trade en horas + """ + if self.exit_time is None: + return None + + delta = self.exit_time - self.entry_time + return delta.total_seconds() / 3600 + + def close(self, exit_price: float, exit_time: datetime, reason: str = ""): + """ + Cierra el trade + """ + self.exit_price = exit_price + self.exit_time = exit_time + self.status = TradeStatus.CLOSED + self.exit_reason = reason + + def __repr__(self): + status_str = "OPEN" if self.status == TradeStatus.OPEN else f"CLOSED (PnL: {self.pnl:.2f})" + return (f"Trade({self.trade_type.value} {self.symbol} @ {self.entry_price:.2f}, " + f"Size: {self.size:.4f}, {status_str})") + + +@dataclass +class Position: + """ + Representa una posición activa (puede contener múltiples trades) + """ + symbol: str + trade_type: TradeType + average_price: float + total_size: float + trades: list = field(default_factory=list) + + def add_trade(self, trade: Trade): + """ + Añade un trade a la posición y actualiza el precio promedio + """ + total_cost = self.average_price * self.total_size + new_cost = trade.entry_price * trade.size + + self.total_size += trade.size + self.average_price = (total_cost + new_cost) / self.total_size + self.trades.append(trade) + + def close_all(self, exit_price: float, exit_time: datetime, reason: str = ""): + """ + Cierra todos los trades de la posición + """ + for trade in self.trades: + if trade.status == TradeStatus.OPEN: + trade.close(exit_price, exit_time, reason) + + def unrealized_pnl(self, current_price: float) -> float: + """ + PnL no realizado basado en el precio actual + """ + 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 diff --git a/src/strategies/__init__.py b/src/strategies/__init__.py index e69de29..1d997bb 100644 --- a/src/strategies/__init__.py +++ b/src/strategies/__init__.py @@ -0,0 +1,13 @@ +# src/strategies/__init__.py +""" +Colección de estrategias de trading +""" +from .moving_average import MovingAverageCrossover +from .buy_and_hold import BuyAndHold +from .rsi_strategy import RSIStrategy + +__all__ = [ + 'MovingAverageCrossover', + 'BuyAndHold', + 'RSIStrategy', +] \ No newline at end of file diff --git a/src/strategies/base.py b/src/strategies/base.py index e69de29..d6ebc53 100644 --- a/src/strategies/base.py +++ b/src/strategies/base.py @@ -0,0 +1,5 @@ +# src/strategies/base.py +""" +Estrategias base para herencia compleja +TODO: Implementar en fases futuras +""" \ No newline at end of file diff --git a/src/strategies/buy_and_hold.py b/src/strategies/buy_and_hold.py new file mode 100644 index 0000000..12ecf78 --- /dev/null +++ b/src/strategies/buy_and_hold.py @@ -0,0 +1,40 @@ +# src/strategies/buy_and_hold.py +""" +Estrategia Buy and Hold +""" +import pandas as pd +from ..backtest.strategy import Strategy, Signal + +class BuyAndHold(Strategy): + """ + Estrategia simple Buy and Hold + + Compra al inicio y mantiene hasta el final. + Útil como baseline para comparar otras estrategias. + """ + + def __init__(self): + super().__init__(name="Buy and Hold", params={}) + self.bought = False + + def init_indicators(self, data: pd.DataFrame) -> pd.DataFrame: + """ + No necesita indicadores + """ + return data + + def generate_signal(self, idx: int) -> Signal: + """ + Compra solo la primera vez, luego mantiene + """ + if not self.bought: + self.bought = True + return Signal.BUY + + return Signal.HOLD + + def reset(self): + """ + Reinicia el estado para nuevo backtest + """ + self.bought = False \ No newline at end of file diff --git a/src/strategies/moving_average.py b/src/strategies/moving_average.py index e69de29..27a788c 100644 --- a/src/strategies/moving_average.py +++ b/src/strategies/moving_average.py @@ -0,0 +1,89 @@ +# src/strategies/moving_average.py +""" +Estrategia de cruce de medias móviles +""" +import pandas as pd +from ..backtest.strategy import Strategy, Signal, calculate_sma, calculate_ema + +class MovingAverageCrossover(Strategy): + """ + Estrategia simple de cruce de medias móviles + + Señales: + - BUY: Cuando la media rápida cruza por encima de la lenta + - SELL: Cuando la media rápida cruza por debajo de la lenta + - HOLD: En cualquier otro caso + + Parámetros: + fast_period: Periodo de la media rápida (default: 10) + slow_period: Periodo de la media lenta (default: 30) + ma_type: Tipo de media móvil 'sma' o 'ema' (default: 'sma') + """ + def __init__(self, fast_period: int = 10, slow_period: int = 30, ma_type: str = 'sma'): + params = { + 'fast_period': fast_period, + 'slow_period': slow_period, + 'ma_type': ma_type + } + + super().__init__(name="Moving Average Crossover", params=params) + + self.fast_period = fast_period + self.slow_period = slow_period + self.ma_type = ma_type.lower() + + if self.ma_type not in ['sma', 'ema']: + raise ValueError("ma_type debe ser 'sma' o 'ema'") + + def init_indicators(self, data: pd.DataFrame) -> pd.DataFrame: + """ + Calcula las medias móviles sobre los datos + """ + # Usar precio de cierre + close_prices = data['close'] + + # Calcular medias móviles según el tipo + if self.ma_type == 'sma': + data['ma_fast'] = calculate_sma(close_prices, self.fast_period) + data['ma_slow'] = calculate_sma(close_prices, self.slow_period) + else: # ema + data['ma_fast'] = calculate_ema(close_prices, self.fast_period) + data['ma_slow'] = calculate_ema(close_prices, self.slow_period) + + # Calcular cruce (1 = fast > slow, -1 = fast < slow) + data['ma_cross'] = 0 + data.loc[data['ma_fast'] > data['ma_slow'], 'ma_cross'] = 1 + data.loc[data['ma_fast'] < data['ma_slow'], 'ma_cross'] = -1 + + # Detectar cambios (cruces) + data['ma_cross_change'] = data['ma_cross'].diff() + + return data + + def generate_signal(self, idx: int) -> Signal: + """ + Genera señal basada en el cruce de medias móviles + """ + if self.data is None: + raise ValueError("Data no establecida") + + # Necesitamos al menos 2 puntos para detectar cruce + if idx < 1: + return Signal.HOLD + + # Verificar que las MAs están calculadas (no son NaN) + if pd.isna(self.data.iloc[idx]['ma_fast']) or pd.isna(self.data.iloc[idx]['ma_slow']): + return Signal.HOLD + + cross_change = self.data.iloc[idx]['ma_cross_change'] + + # Cruce alcista: fast cruza por encima de slow + if cross_change == 2: # De -1 a 1 + return Signal.BUY + + # Cruce bajista: fast cruza por debajo de slow + elif cross_change == -2: # De 1 a -1 + return Signal.SELL + + # Sin cruce + return Signal.HOLD \ No newline at end of file diff --git a/src/strategies/rsi_strategy.py b/src/strategies/rsi_strategy.py new file mode 100644 index 0000000..e91bf5b --- /dev/null +++ b/src/strategies/rsi_strategy.py @@ -0,0 +1,65 @@ +# src/strategies/rsi_strategy.py +""" +Estrategia basada en RSI +""" +import pandas as pd +from ..backtest.strategy import Strategy, Signal, calculate_rsi + +class RSIStrategy(Strategy): + """ + Estrategia basada en RSI (Relative Strength Index) + + Señales: + - BUY: Cuando RSI < oversold_threshold (mercado sobrevendido) + - SELL: Cuando RSI > overbought_threshold (mercado sobrecomprado) + - HOLD: RSI en zona neutral + + Parámetros: + rsi_period: Periodo del RSI (default: 14) + oversold_threshold: Umbral de sobreventa (default: 30) + overbought_threshold: Umbral de sobrecompra (default: 70) + """ + + def __init__(self, rsi_period: int = 14, oversold_threshold: float = 30, overbought_threshold: float = 70): + + params = { + 'rsi_period': rsi_period, + 'oversold': oversold_threshold, + 'overbought': overbought_threshold + } + + super().__init__(name="RSI Strategy", params=params) + + self.rsi_period = rsi_period + self.oversold = oversold_threshold + self.overbought = overbought_threshold + + def init_indicators(self, data: pd.DataFrame) -> pd.DataFrame: + """ + Calcula el RSI + """ + data['rsi'] = calculate_rsi(data['close'], self.rsi_period) + return data + + def generate_signal(self, idx: int) -> Signal: + """ + Genera señal basada en niveles de RSI + """ + if self.data is None: + raise ValueError("Data no establecida") + + rsi = self.data.iloc[idx]['rsi'] + + # Verificar que RSI está calculado + if pd.isna(rsi): + return Signal.HOLD + + # Sobreventa: señal de compra + if rsi < self.oversold and self.current_position <= 0: + return Signal.BUY + + # Sobrecompra: señal de venta + elif rsi > self.overbought and self.current_position >= 0: + return Signal.SELL + + return Signal.HOLD \ No newline at end of file