feat: Backtesting completo + Optimizer + Visualizaciones (Semanas 3-4)

 Motor de backtesting:
- BacktestEngine con simulación de trades
- Sistema de Trade y Position
- Gestión de capital, comisiones y slippage
- Soporte para LONG (por ahora)

 Estrategias implementadas (3):
- MovingAverageCrossover (SMA/EMA configurable)
- RSIStrategy (umbrales personalizables)
- BuyAndHold (baseline para comparación)

 Métricas de performance:
- Sharpe, Sortino, Calmar Ratio
- Max Drawdown, Win Rate, Profit Factor
- Expectancy, Risk/Reward, Recovery Factor

 Optimizador de parámetros:
- Grid search automático
- Prueba todas las combinaciones
- Encuentra mejores parámetros por métrica
- Resultados en DataFrame ordenado

 Visualizaciones:
- Equity curve con benchmark
- Trades sobre gráfico de precios
- Drawdown chart
- Distribución de retornos
- Métricas en dashboard
- Exportar gráficos a PNG

 Scripts:
- backtest.py: Demo simple
- backtest.py compare: Comparar estrategias

 Documentación:
- README actualizado (Semanas 1-4)
- Ejemplos de uso
- Roadmap actualizado
This commit is contained in:
DaM
2026-01-28 08:42:23 +01:00
parent 9b34de3127
commit 9ab1f7fadc
3 changed files with 323 additions and 0 deletions

View File

@@ -7,21 +7,28 @@ ccxt==4.2.25
certifi==2026.1.4
cffi==2.0.0
charset-normalizer==3.4.4
contourpy==1.3.3
cryptography==46.0.3
cycler==0.12.1
fonttools==4.61.1
frozenlist==1.8.0
greenlet==3.3.0
idna==3.11
iniconfig==2.3.0
kiwisolver==1.4.9
loguru==0.7.2
matplotlib==3.10.8
multidict==6.7.0
numpy==1.26.4
packaging==26.0
pandas==2.1.4
pillow==12.1.0
pluggy==1.6.0
propcache==0.4.1
psycopg2-binary==2.9.9
pycares==5.0.1
pycparser==3.0
pyparsing==3.3.2
pytest==7.4.3
python-dateutil==2.9.0.post0
python-dotenv==1.0.0
@@ -29,6 +36,7 @@ pytz==2025.2
PyYAML==6.0.1
redis==5.0.1
requests==2.32.5
seaborn==0.13.2
setuptools==80.10.1
six==1.17.0
SQLAlchemy==2.0.23

View File

@@ -5,6 +5,8 @@ Módulo de backtesting
from .engine import BacktestEngine
from .strategy import Strategy, Signal
from .trade import Trade, TradeType, TradeStatus, Position
from .optimizer import ParameterOptimizer
from .visualizer import BacktestVisualizer
from .metrics import (
calculate_sharpe_ratio,
calculate_sortino_ratio,
@@ -21,6 +23,8 @@ __all__ = [
'TradeType',
'TradeStatus',
'Position',
'ParameterOptimizer',
'BacktestVisualizer',
'calculate_sharpe_ratio',
'calculate_sortino_ratio',
'calculate_max_drawdown',

311
src/backtest/visualizer.py Normal file
View File

@@ -0,0 +1,311 @@
# src/backtest/visualizer.py
"""
Visualizaciones para resultados de backtesting
"""
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import pandas as pd
import numpy as np
from typing import Dict, Optional
from ..utils.logger import log
# Configurar estilo
plt.style.use('seaborn-v0_8-darkgrid')
class BacktestVisualizer:
"""
Genera visualizaciones de resultados de backtesting
"""
def __init__(self, results: Dict, data: Optional[pd.DataFrame] = None):
"""
Args:
results: Resultados del backtest
data: Datos OHLCV originales (opcional, para gráfico de precios)
"""
self.results = results
self.data = data
def plot_equity_curve(self, benchmark: Optional[pd.Series] = None,
save_path: Optional[str] = None):
"""
Gráfico de equity curve
"""
fig, ax = plt.subplots(figsize=(14, 6))
timestamps = self.results['timestamps']
equity = self.results['equity_curve']
# Equity curve de la estrategia
ax.plot(timestamps, equity, label='Estrategia', linewidth=2, color='#2E86AB')
# Benchmark (Buy & Hold) si se proporciona
if benchmark is not None:
ax.plot(timestamps, benchmark, label='Buy & Hold',
linewidth=2, linestyle='--', color='#A23B72', alpha=0.7)
# Línea del capital inicial
initial_capital = self.results['initial_capital']
ax.axhline(y=initial_capital, color='gray', linestyle=':',
alpha=0.5, label='Capital Inicial')
# Formato
ax.set_title('Equity Curve', fontsize=16, fontweight='bold')
ax.set_xlabel('Fecha', fontsize=12)
ax.set_ylabel('Equity ($)', fontsize=12)
ax.legend(loc='best')
ax.grid(True, alpha=0.3)
# Formato de fechas
ax.xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m-%d'))
plt.xticks(rotation=45)
plt.tight_layout()
if save_path:
plt.savefig(save_path, dpi=300, bbox_inches='tight')
log.success(f"💾 Gráfico guardado: {save_path}")
else:
plt.show()
plt.close()
def plot_trades_on_chart(self, save_path: Optional[str] = None):
"""
Gráfico de trades sobre el precio
"""
if self.data is None:
log.warning("⚠️ No hay datos OHLCV para graficar")
return
fig, ax = plt.subplots(figsize=(14, 7))
# Gráfico de precios
ax.plot(self.data.index, self.data['close'],
label='Precio', linewidth=1.5, color='#333', alpha=0.7)
# Marcar trades
trades = self.results.get('trades', [])
for trade in trades:
# Entry point
ax.scatter(trade.entry_time, trade.entry_price,
marker='^', s=100, color='green',
edgecolors='black', linewidth=1, zorder=5)
# Exit point
if trade.exit_time and trade.exit_price:
color = 'lime' if trade.pnl > 0 else 'red'
ax.scatter(trade.exit_time, trade.exit_price,
marker='v', s=100, color=color,
edgecolors='black', linewidth=1, zorder=5)
# Línea conectando entry y exit
ax.plot([trade.entry_time, trade.exit_time],
[trade.entry_price, trade.exit_price],
linestyle='--', linewidth=1, alpha=0.3,
color=color)
# Formato
ax.set_title('Trades sobre el Precio', fontsize=16, fontweight='bold')
ax.set_xlabel('Fecha', fontsize=12)
ax.set_ylabel('Precio ($)', fontsize=12)
ax.legend(['Precio', 'Entrada', 'Salida Ganadora', 'Salida Perdedora'], loc='best')
ax.grid(True, alpha=0.3)
ax.xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m-%d'))
plt.xticks(rotation=45)
plt.tight_layout()
if save_path:
plt.savefig(save_path, dpi=300, bbox_inches='tight')
log.success(f"💾 Gráfico guardado: {save_path}")
else:
plt.show()
plt.close()
def plot_drawdown(self, save_path: Optional[str] = None):
"""
Gráfico de drawdown
"""
fig, ax = plt.subplots(figsize=(14, 5))
timestamps = self.results['timestamps']
equity = np.array(self.results['equity_curve'])
# Calcular drawdown
running_max = np.maximum.accumulate(equity)
drawdown = (equity - running_max) / running_max * 100
# Gráfico
ax.fill_between(timestamps, drawdown, 0,
color='#C1121F', alpha=0.3)
ax.plot(timestamps, drawdown, color='#C1121F', linewidth=2)
# Marcar max drawdown
max_dd_idx = drawdown.argmin()
ax.scatter(timestamps[max_dd_idx], drawdown[max_dd_idx],
color='red', s=100, zorder=5,
label=f'Max DD: {drawdown[max_dd_idx]:.2f}%')
# Formato
ax.set_title('Drawdown', fontsize=16, fontweight='bold')
ax.set_xlabel('Fecha', fontsize=12)
ax.set_ylabel('Drawdown (%)', fontsize=12)
ax.legend(loc='best')
ax.grid(True, alpha=0.3)
ax.xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m-%d'))
plt.xticks(rotation=45)
plt.tight_layout()
if save_path:
plt.savefig(save_path, dpi=300, bbox_inches='tight')
log.success(f"💾 Gráfico guardado: {save_path}")
else:
plt.show()
plt.close()
def plot_returns_distribution(self, save_path: Optional[str] = None):
"""
Distribución de retornos por trade
"""
trades = self.results.get('trades', [])
if not trades:
log.warning("⚠️ No hay trades para graficar")
return
returns = [t.pnl_percentage for t in trades]
fig, ax = plt.subplots(figsize=(10, 6))
# Histograma
ax.hist(returns, bins=30, color='#4361EE', alpha=0.7, edgecolor='black')
# Línea vertical en 0
ax.axvline(x=0, color='red', linestyle='--', linewidth=2, label='Break Even')
# Media
mean_return = np.mean(returns)
ax.axvline(x=mean_return, color='green', linestyle='--',
linewidth=2, label=f'Media: {mean_return:.2f}%')
# Formato
ax.set_title('Distribución de Retornos por Trade', fontsize=16, fontweight='bold')
ax.set_xlabel('Retorno (%)', fontsize=12)
ax.set_ylabel('Frecuencia', fontsize=12)
ax.legend(loc='best')
ax.grid(True, alpha=0.3, axis='y')
plt.tight_layout()
if save_path:
plt.savefig(save_path, dpi=300, bbox_inches='tight')
log.success(f"💾 Gráfico guardado: {save_path}")
else:
plt.show()
plt.close()
def plot_dashboard(self, save_path: Optional[str] = None):
"""
Dashboard completo con 4 gráficos
"""
fig = plt.figure(figsize=(16, 10))
gs = fig.add_gridspec(3, 2, hspace=0.3, wspace=0.3)
# 1. Equity Curve
ax1 = fig.add_subplot(gs[0, :])
timestamps = self.results['timestamps']
equity = self.results['equity_curve']
ax1.plot(timestamps, equity, linewidth=2, color='#2E86AB')
ax1.set_title('Equity Curve', fontsize=14, fontweight='bold')
ax1.set_ylabel('Equity ($)')
ax1.grid(True, alpha=0.3)
ax1.xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m-%d'))
plt.setp(ax1.xaxis.get_majorticklabels(), rotation=45)
# 2. Drawdown
ax2 = fig.add_subplot(gs[1, :])
equity_arr = np.array(equity)
running_max = np.maximum.accumulate(equity_arr)
drawdown = (equity_arr - running_max) / running_max * 100
ax2.fill_between(timestamps, drawdown, 0, color='#C1121F', alpha=0.3)
ax2.plot(timestamps, drawdown, color='#C1121F', linewidth=2)
ax2.set_title('Drawdown', fontsize=14, fontweight='bold')
ax2.set_ylabel('Drawdown (%)')
ax2.grid(True, alpha=0.3)
ax2.xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m-%d'))
plt.setp(ax2.xaxis.get_majorticklabels(), rotation=45)
# 3. Distribución de retornos
ax3 = fig.add_subplot(gs[2, 0])
trades = self.results.get('trades', [])
if trades:
returns = [t.pnl_percentage for t in trades]
ax3.hist(returns, bins=20, color='#4361EE', alpha=0.7, edgecolor='black')
ax3.axvline(x=0, color='red', linestyle='--', linewidth=1.5)
ax3.set_title('Distribución Retornos', fontsize=14, fontweight='bold')
ax3.set_xlabel('Retorno (%)')
ax3.set_ylabel('Frecuencia')
ax3.grid(True, alpha=0.3, axis='y')
# 4. Métricas clave
ax4 = fig.add_subplot(gs[2, 1])
ax4.axis('off')
metrics_text = f"""
MÉTRICAS DE PERFORMANCE
━━━━━━━━━━━━━━━━━━━━━━━━━
💰 Capital Inicial: ${self.results['initial_capital']:,.0f}
💰 Capital Final: ${self.results['final_equity']:,.0f}
📈 Retorno Total: {self.results['total_return_pct']:.2f}%
📊 Total Trades: {self.results['total_trades']}
✅ Trades Ganadores: {self.results['winning_trades']}
❌ Trades Perdedores: {self.results['losing_trades']}
🎯 Win Rate: {self.results['win_rate_pct']:.2f}%
📉 Max Drawdown: {self.results['max_drawdown_pct']:.2f}%
📈 Sharpe Ratio: {self.results['sharpe_ratio']:.2f}
💵 Profit Factor: {self.results['profit_factor']:.2f}
"""
ax4.text(0.1, 0.5, metrics_text, fontsize=11,
verticalalignment='center', family='monospace',
bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.3))
# Título general
fig.suptitle('Dashboard de Backtesting', fontsize=18, fontweight='bold', y=0.98)
if save_path:
plt.savefig(save_path, dpi=300, bbox_inches='tight')
log.success(f"💾 Dashboard guardado: {save_path}")
else:
plt.show()
plt.close()
def generate_all_plots(self, output_dir: str = 'backtest_results'):
"""
Genera todos los gráficos y los guarda
"""
import os
os.makedirs(output_dir, exist_ok=True)
log.info(f"📊 Generando visualizaciones en: {output_dir}/")
self.plot_equity_curve(save_path=f'{output_dir}/equity_curve.png')
self.plot_drawdown(save_path=f'{output_dir}/drawdown.png')
self.plot_returns_distribution(save_path=f'{output_dir}/returns_distribution.png')
self.plot_dashboard(save_path=f'{output_dir}/dashboard.png')
if self.data is not None:
self.plot_trades_on_chart(save_path=f'{output_dir}/trades_chart.png')
log.success(f"✅ Todas las visualizaciones generadas en: {output_dir}/")