Add walk-forward validation with optimizer, OOS evaluation and visualizer
This commit is contained in:
0
src/backtest/visualizers/__init__.py
Normal file
0
src/backtest/visualizers/__init__.py
Normal file
311
src/backtest/visualizers/visualizer.py
Normal file
311
src/backtest/visualizers/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/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}/")
|
||||
325
src/backtest/visualizers/walk_forward_visualizer.py
Normal file
325
src/backtest/visualizers/walk_forward_visualizer.py
Normal 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
|
||||
Reference in New Issue
Block a user