diff --git a/requirements.txt b/requirements.txt index 5e65ca3..ae49b69 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/src/backtest/__init__.py b/src/backtest/__init__.py index 294d63f..03eb33c 100644 --- a/src/backtest/__init__.py +++ b/src/backtest/__init__.py @@ -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', diff --git a/src/backtest/visualizer.py b/src/backtest/visualizer.py new file mode 100644 index 0000000..5a74bde --- /dev/null +++ b/src/backtest/visualizer.py @@ -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}/") \ No newline at end of file