feat: Backtesting engine completo + documentación (Semanas 3-4)
✅ 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
This commit is contained in:
295
BACKTESTING.md
295
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.**
|
||||
152
README.md
152
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)
|
||||
**Datos:** 5 símbolos, 3 timeframes, 120 días (~54k registros)
|
||||
**Estrategias:** 3 implementadas (MA Cross, RSI, Buy&Hold)
|
||||
210
backtest.py
210
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")
|
||||
@@ -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',
|
||||
]
|
||||
@@ -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': [],
|
||||
}
|
||||
@@ -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
|
||||
195
src/backtest/optimizer.py
Normal file
195
src/backtest/optimizer.py
Normal file
@@ -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
|
||||
...
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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',
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
# src/strategies/base.py
|
||||
"""
|
||||
Estrategias base para herencia compleja
|
||||
TODO: Implementar en fases futuras
|
||||
"""
|
||||
40
src/strategies/buy_and_hold.py
Normal file
40
src/strategies/buy_and_hold.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
65
src/strategies/rsi_strategy.py
Normal file
65
src/strategies/rsi_strategy.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user