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:
@@ -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
|
||||
|
||||
@@ -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
311
src/backtest/visualizer.py
Normal 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}/")
|
||||
Reference in New Issue
Block a user