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
|
certifi==2026.1.4
|
||||||
cffi==2.0.0
|
cffi==2.0.0
|
||||||
charset-normalizer==3.4.4
|
charset-normalizer==3.4.4
|
||||||
|
contourpy==1.3.3
|
||||||
cryptography==46.0.3
|
cryptography==46.0.3
|
||||||
|
cycler==0.12.1
|
||||||
|
fonttools==4.61.1
|
||||||
frozenlist==1.8.0
|
frozenlist==1.8.0
|
||||||
greenlet==3.3.0
|
greenlet==3.3.0
|
||||||
idna==3.11
|
idna==3.11
|
||||||
iniconfig==2.3.0
|
iniconfig==2.3.0
|
||||||
|
kiwisolver==1.4.9
|
||||||
loguru==0.7.2
|
loguru==0.7.2
|
||||||
|
matplotlib==3.10.8
|
||||||
multidict==6.7.0
|
multidict==6.7.0
|
||||||
numpy==1.26.4
|
numpy==1.26.4
|
||||||
packaging==26.0
|
packaging==26.0
|
||||||
pandas==2.1.4
|
pandas==2.1.4
|
||||||
|
pillow==12.1.0
|
||||||
pluggy==1.6.0
|
pluggy==1.6.0
|
||||||
propcache==0.4.1
|
propcache==0.4.1
|
||||||
psycopg2-binary==2.9.9
|
psycopg2-binary==2.9.9
|
||||||
pycares==5.0.1
|
pycares==5.0.1
|
||||||
pycparser==3.0
|
pycparser==3.0
|
||||||
|
pyparsing==3.3.2
|
||||||
pytest==7.4.3
|
pytest==7.4.3
|
||||||
python-dateutil==2.9.0.post0
|
python-dateutil==2.9.0.post0
|
||||||
python-dotenv==1.0.0
|
python-dotenv==1.0.0
|
||||||
@@ -29,6 +36,7 @@ pytz==2025.2
|
|||||||
PyYAML==6.0.1
|
PyYAML==6.0.1
|
||||||
redis==5.0.1
|
redis==5.0.1
|
||||||
requests==2.32.5
|
requests==2.32.5
|
||||||
|
seaborn==0.13.2
|
||||||
setuptools==80.10.1
|
setuptools==80.10.1
|
||||||
six==1.17.0
|
six==1.17.0
|
||||||
SQLAlchemy==2.0.23
|
SQLAlchemy==2.0.23
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ Módulo de backtesting
|
|||||||
from .engine import BacktestEngine
|
from .engine import BacktestEngine
|
||||||
from .strategy import Strategy, Signal
|
from .strategy import Strategy, Signal
|
||||||
from .trade import Trade, TradeType, TradeStatus, Position
|
from .trade import Trade, TradeType, TradeStatus, Position
|
||||||
|
from .optimizer import ParameterOptimizer
|
||||||
|
from .visualizer import BacktestVisualizer
|
||||||
from .metrics import (
|
from .metrics import (
|
||||||
calculate_sharpe_ratio,
|
calculate_sharpe_ratio,
|
||||||
calculate_sortino_ratio,
|
calculate_sortino_ratio,
|
||||||
@@ -21,6 +23,8 @@ __all__ = [
|
|||||||
'TradeType',
|
'TradeType',
|
||||||
'TradeStatus',
|
'TradeStatus',
|
||||||
'Position',
|
'Position',
|
||||||
|
'ParameterOptimizer',
|
||||||
|
'BacktestVisualizer',
|
||||||
'calculate_sharpe_ratio',
|
'calculate_sharpe_ratio',
|
||||||
'calculate_sortino_ratio',
|
'calculate_sortino_ratio',
|
||||||
'calculate_max_drawdown',
|
'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