Add walk-forward validation with optimizer, OOS evaluation and visualizer

This commit is contained in:
DaM
2026-01-28 23:40:12 +01:00
parent e15074c0a7
commit af7b862f60
11 changed files with 910 additions and 10 deletions

View 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/visualizer'):
"""
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}/")

View File

@@ -0,0 +1,325 @@
# src/backtest/visualizers/walk_forward_visualizer.py
from pathlib import Path
import ast
import json
import re
import pandas as pd
import matplotlib.pyplot as plt
from src.utils.logger import log
class WalkForwardVisualizer:
"""
Visualizador para resultados de Walk-Forward Validation.
Input esperado (desde CSVs):
- summary_df: walkforward_summary.csv
- windows_df: walkforward_windows.csv (incluye columna 'params')
"""
def __init__(
self,
summary_df: pd.DataFrame,
windows_df: pd.DataFrame,
name: str = "WalkForward",
output_dir: Path | str = "backtest_results/walkforward/plots",
):
self.summary = summary_df.copy()
self.windows = windows_df.copy()
self.name = name
self.output_dir = Path(output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True)
self._parse_params_column()
log.info(f"📊 WalkForwardVisualizer inicializado → {self.output_dir}")
# ------------------------------------------------------------------
# 🔧 PARSE ROBUSTO DE PARAMS
# ------------------------------------------------------------------
@staticmethod
def _coerce_params_string_to_dict(s: str):
"""
Convierte string de params (desde CSV) a dict.
Soporta:
- "{'fast_period': 15, ...}"
- "{'fast_period': np.int64(15), 'use_adx': np.True_, ...}"
- JSON con comillas dobles
"""
if s is None:
return None
if not isinstance(s, str):
return None
raw = s.strip()
if raw == "" or raw.lower() in {"none", "nan", "null"}:
return None
# Limpieza de serializaciones típicas numpy
cleaned = raw
# np.int64(15) -> 15
cleaned = re.sub(r"np\.int64\((\-?\d+)\)", r"\1", cleaned)
# np.float64(1.23) -> 1.23
cleaned = re.sub(r"np\.float64\(([-+0-9eE\.]+)\)", r"\1", cleaned)
# np.True_ / np.False_ -> True/False
cleaned = cleaned.replace("np.True_", "True").replace("np.False_", "False")
# np.bool_(True) -> True
cleaned = re.sub(r"np\.bool_\((True|False)\)", r"\1", cleaned)
# 1) intentar literal_eval (dict python)
try:
d = ast.literal_eval(cleaned)
if isinstance(d, dict):
return d
except Exception:
pass
# 2) intentar json (si viniera con comillas dobles)
try:
d = json.loads(cleaned)
if isinstance(d, dict):
return d
except Exception:
pass
# 3) intento extra: convertir comillas simples a dobles para json
try:
maybe_json = cleaned.replace("'", '"')
d = json.loads(maybe_json)
if isinstance(d, dict):
return d
except Exception:
return None
return None
def _parse_params_column(self):
"""
Convierte la columna 'params' a dict.
"""
if "params" not in self.windows.columns:
log.warning("⚠️ windows_df no tiene columna 'params'. Se omite parseo.")
return
self.windows["params"] = self.windows["params"].apply(
lambda x: x if isinstance(x, dict) else self._coerce_params_string_to_dict(x)
)
# ------------------------------------------------------------------
# 📊 PLOT 1: MÉTRICAS MEDIAS POR CONFIGURACIÓN
# ------------------------------------------------------------------
def plot_avg_metrics(self) -> Path:
df = self.summary.copy()
fig, ax = plt.subplots(figsize=(10, 6))
x = range(len(df))
ax.bar(x, df["avg_return_pct"], label="Avg Return (%)")
ax.bar(
x,
df["avg_sharpe"],
bottom=df["avg_return_pct"],
label="Avg Sharpe",
)
ax.set_xticks(x)
ax.set_xticklabels(df["wf_name"])
ax.set_title(f"Walk-Forward Performance Summary\n{self.name}")
ax.legend()
ax.grid(alpha=0.3)
path = self.output_dir / "wf_avg_metrics.png"
fig.tight_layout()
fig.savefig(path, dpi=150)
plt.close(fig)
log.success(f"💾 Plot guardado: {path}")
return path
# ------------------------------------------------------------------
# 📈 PLOT 2: RETURNS POR VENTANA (OOS)
# ------------------------------------------------------------------
def plot_returns_by_window(self) -> Path:
df = self.windows.copy()
if "wf_name" not in df.columns:
raise ValueError("windows_df debe contener la columna 'wf_name'")
df["test_start"] = pd.to_datetime(df["test_start"])
df = df.sort_values(["wf_name", "test_start"])
fig, ax = plt.subplots(figsize=(12, 6))
for wf_name, g in df.groupby("wf_name"):
ax.plot(
g["test_start"],
g["return_pct"],
marker="o",
label=wf_name,
)
ax.axhline(0, color="black", linestyle="--", linewidth=1)
ax.set_title(f"Walk-Forward OOS Returns by Window\n{self.name}")
ax.set_xlabel("Test period start")
ax.set_ylabel("Return (%)")
ax.legend()
ax.grid(alpha=0.3)
path = self.output_dir / "wf_returns_by_window.png"
fig.tight_layout()
fig.savefig(path, dpi=150)
plt.close(fig)
log.success(f"💾 Plot guardado: {path}")
return path
# ------------------------------------------------------------------
# 📉 PLOT 3: DRAWDOWN POR VENTANA (OOS)
# ------------------------------------------------------------------
def plot_drawdown_by_window(self) -> Path:
df = self.windows.copy()
required = {"wf_name", "test_start", "max_dd_pct"}
missing = required - set(df.columns)
if missing:
raise ValueError(f"windows_df no contiene columnas requeridas: {missing}")
df["test_start"] = pd.to_datetime(df["test_start"])
df = df.sort_values(["wf_name", "test_start"])
fig, ax = plt.subplots(figsize=(12, 6))
for wf_name, g in df.groupby("wf_name"):
ax.plot(
g["test_start"],
g["max_dd_pct"],
marker="o",
label=wf_name,
)
ax.axhline(0, linestyle="--", linewidth=1)
ax.set_title(f"Walk-Forward OOS Max Drawdown by Window\n{self.name}")
ax.set_xlabel("Test period start")
ax.set_ylabel("Max Drawdown (%)")
ax.legend()
ax.grid(alpha=0.3)
path = self.output_dir / "wf_drawdown_by_window.png"
fig.tight_layout()
fig.savefig(path, dpi=150)
plt.close(fig)
log.success(f"💾 Plot guardado: {path}")
return path
# ------------------------------------------------------------------
# 📊 PLOT 4: DISTRIBUCIÓN DE RETURNS
# ------------------------------------------------------------------
def plot_return_distribution(self, bins: int = 20, overlay: bool = True) -> Path:
df = self.windows.copy().dropna(subset=["return_pct"])
path = self.output_dir / "wf_return_distribution.png"
if overlay:
fig, ax = plt.subplots(figsize=(12, 6))
for wf_name, g in df.groupby("wf_name"):
ax.hist(g["return_pct"], bins=bins, alpha=0.5, label=wf_name)
ax.axvline(0, linestyle="--", linewidth=1)
ax.set_title(f"Walk-Forward OOS Return Distribution\n{self.name}")
ax.set_xlabel("Return (%)")
ax.set_ylabel("Frequency")
ax.legend()
fig.tight_layout()
fig.savefig(path, dpi=150)
plt.close(fig)
log.success(f"💾 Plot guardado: {path}")
return path
wf_names = sorted(df["wf_name"].unique())
n = len(wf_names)
fig, axes = plt.subplots(n, 1, figsize=(12, 4 * n), sharex=True)
if n == 1:
axes = [axes]
for ax, wf_name in zip(axes, wf_names):
g = df[df["wf_name"] == wf_name]
ax.hist(g["return_pct"], bins=bins)
ax.axvline(0, linestyle="--", linewidth=1)
ax.set_title(wf_name)
axes[-1].set_xlabel("Return (%)")
fig.suptitle(f"Walk-Forward OOS Return Distribution\n{self.name}", y=0.98)
fig.tight_layout()
fig.savefig(path, dpi=150)
plt.close(fig)
log.success(f"💾 Plot guardado: {path}")
return path
# ------------------------------------------------------------------
# 📈 PLOT 5: ESTABILIDAD DE PARÁMETROS
# ------------------------------------------------------------------
def plot_parameter_stability(self, param_name: str) -> Path:
df = self.windows.copy()
if "params" not in df.columns:
raise ValueError("windows_df no tiene columna 'params'")
# Asegurar que params está parseado
df["params"] = df["params"].apply(
lambda x: x if isinstance(x, dict) else self._coerce_params_string_to_dict(x)
)
df[param_name] = df["params"].apply(
lambda p: p.get(param_name) if isinstance(p, dict) else None
)
df = df.dropna(subset=[param_name])
if df.empty:
# Debug útil para ver qué pasa en tu CSV
sample = self.windows["params"].dropna().head(5).tolist()
raise ValueError(
f"No se pudo extraer '{param_name}' desde params.\n"
f"Ejemplo de params (primeros 5 no-null): {sample}"
)
df["test_start"] = pd.to_datetime(df["test_start"])
df = df.sort_values(["wf_name", "test_start"])
fig, ax = plt.subplots(figsize=(12, 6))
for wf_name, g in df.groupby("wf_name"):
ax.plot(g["test_start"], g[param_name], marker="o", label=wf_name)
ax.set_title(f"WF Parameter Stability: {param_name}\n{self.name}")
ax.set_xlabel("Test period start")
ax.set_ylabel(param_name)
ax.legend()
ax.grid(alpha=0.3)
path = self.output_dir / f"wf_param_stability_{param_name}.png"
fig.tight_layout()
fig.savefig(path, dpi=150)
plt.close(fig)
log.success(f"💾 Plot guardado: {path}")
return path