- Fix database.py: Add DatabaseConnection alias for backward compat - Fix train_symbol_timeframe_models.py: Use PostgreSQLConnection + native queries - Fix run_oos_backtest.py: Fix broken import + add dynamic OOS support - Update data_splitter.py: split_dynamic_oos() method (from previous session) - Update validation_oos.yaml: Dynamic OOS config + all 6 symbols enabled - Create ingest_ohlcv_polygon.py: Standalone Polygon→PostgreSQL ingestion script - Fix .gitignore: /data/ instead of data/ to not ignore src/data/ - Add untracked src/ modules: backtesting, data, llm, models (attention/metamodel/strategies) - Add aiohttp, sqlalchemy, psycopg2-binary to requirements.txt Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
335 lines
13 KiB
Python
335 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Out-of-Sample Backtesting Script
|
|
================================
|
|
Ejecuta backtesting excluyendo 2025 del training para validacion OOS.
|
|
|
|
Uso:
|
|
python scripts/run_oos_backtest.py --symbol XAUUSD --config config/validation_oos.yaml
|
|
|
|
Creado: 2026-01-04
|
|
Autor: ML-Specialist (NEXUS v4.0)
|
|
"""
|
|
|
|
import argparse
|
|
import yaml
|
|
import pandas as pd
|
|
import numpy as np
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
from typing import Dict, Any, Optional
|
|
import json
|
|
import sys
|
|
|
|
# Add parent to path for imports
|
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
|
|
from src.backtesting import RRBacktester, BacktestConfig, MetricsCalculator, TradingMetrics
|
|
from src.data.database import PostgreSQLConnection
|
|
from loguru import logger
|
|
|
|
|
|
class OOSBacktestRunner:
|
|
"""
|
|
Runner para backtesting Out-of-Sample.
|
|
Excluye datos del periodo de test (2025) durante el training.
|
|
"""
|
|
|
|
def __init__(self, config_path: str):
|
|
"""
|
|
Inicializa el runner con configuracion YAML.
|
|
|
|
Args:
|
|
config_path: Ruta al archivo validation_oos.yaml
|
|
"""
|
|
self.config = self._load_config(config_path)
|
|
self.results: Dict[str, Any] = {}
|
|
|
|
logger.info(f"OOS Backtest Runner initialized")
|
|
logger.info(f"Training period: {self.config['validation']['train']['start_date']} to {self.config['validation']['train']['end_date']}")
|
|
logger.info(f"OOS period: {self.config['validation']['test_oos']['start_date']} to {self.config['validation']['test_oos']['end_date']}")
|
|
|
|
def _load_config(self, config_path: str) -> Dict[str, Any]:
|
|
"""Carga configuracion desde YAML."""
|
|
with open(config_path, 'r') as f:
|
|
return yaml.safe_load(f)
|
|
|
|
def load_data(self, symbol: str) -> tuple[pd.DataFrame, pd.DataFrame]:
|
|
"""
|
|
Carga datos separados para training y OOS testing.
|
|
Soporta modo dinamico (ultimos N meses = OOS) o fechas fijas.
|
|
|
|
Args:
|
|
symbol: Simbolo a cargar (ej: XAUUSD)
|
|
|
|
Returns:
|
|
Tuple de (df_train, df_oos)
|
|
"""
|
|
# Conectar a base de datos
|
|
db = PostgreSQLConnection()
|
|
|
|
# Check if dynamic OOS is enabled
|
|
dynamic_config = self.config['validation'].get('dynamic', {})
|
|
if dynamic_config.get('enabled', False):
|
|
oos_months = dynamic_config.get('oos_months', 12)
|
|
logger.info(f"Dynamic OOS mode: excluding last {oos_months} months")
|
|
|
|
# Load all data first to find max date
|
|
logger.info(f"Loading all data for {symbol} to determine OOS boundary...")
|
|
df_all = db.get_ticker_data(symbol=symbol)
|
|
|
|
if df_all.empty:
|
|
logger.error(f"No data found for {symbol}")
|
|
return pd.DataFrame(), pd.DataFrame()
|
|
|
|
max_date = df_all.index.max()
|
|
from dateutil.relativedelta import relativedelta
|
|
oos_start = (max_date - relativedelta(months=oos_months)).replace(
|
|
day=1, hour=0, minute=0, second=0, microsecond=0
|
|
)
|
|
|
|
df_train = df_all[df_all.index < oos_start]
|
|
df_oos = df_all[df_all.index >= oos_start]
|
|
|
|
logger.info(f"OOS boundary: {oos_start}")
|
|
logger.info(f"Training data: {len(df_train)} bars ({df_train.index.min()} to {df_train.index.max()})")
|
|
logger.info(f"OOS data: {len(df_oos)} bars ({df_oos.index.min()} to {df_oos.index.max()})")
|
|
else:
|
|
# Static date ranges from config
|
|
train_config = self.config['validation']['train']
|
|
oos_config = self.config['validation']['test_oos']
|
|
|
|
logger.info(f"Loading training data for {symbol}...")
|
|
df_train = db.get_ticker_data(
|
|
symbol=symbol,
|
|
start_date=train_config['start_date'],
|
|
end_date=train_config['end_date']
|
|
)
|
|
logger.info(f"Training data: {len(df_train)} bars from {df_train.index.min()} to {df_train.index.max()}")
|
|
|
|
logger.info(f"Loading OOS data for {symbol}...")
|
|
df_oos = db.get_ticker_data(
|
|
symbol=symbol,
|
|
start_date=oos_config['start_date'],
|
|
end_date=oos_config['end_date']
|
|
)
|
|
logger.info(f"OOS data: {len(df_oos)} bars from {df_oos.index.min()} to {df_oos.index.max()}")
|
|
|
|
return df_train, df_oos
|
|
|
|
def create_backtest_config(self) -> BacktestConfig:
|
|
"""Crea configuracion de backtesting desde YAML."""
|
|
bt_config = self.config['backtest']
|
|
|
|
return BacktestConfig(
|
|
initial_capital=bt_config['initial_capital'],
|
|
risk_per_trade=bt_config['risk_per_trade'],
|
|
max_concurrent_trades=bt_config['max_concurrent_trades'],
|
|
commission_pct=bt_config['commission_pct'],
|
|
slippage_pct=bt_config['slippage_pct'],
|
|
min_confidence=bt_config['min_confidence'],
|
|
max_position_time=bt_config['max_position_time_minutes'],
|
|
rr_configs=bt_config['rr_configs'],
|
|
filter_by_amd=bt_config['filter_by_amd'],
|
|
favorable_amd_phases=bt_config['favorable_amd_phases'],
|
|
filter_by_volatility=bt_config['filter_by_volatility'],
|
|
min_volatility_regime=bt_config['min_volatility_regime']
|
|
)
|
|
|
|
def validate_metrics(self, metrics: TradingMetrics) -> Dict[str, bool]:
|
|
"""
|
|
Valida metricas contra umbrales definidos (TRADING-STRATEGIST).
|
|
|
|
Returns:
|
|
Dict con cada metrica y si pasa o no
|
|
"""
|
|
thresholds = self.config['metrics_thresholds']
|
|
|
|
validations = {
|
|
'sharpe_ratio': metrics.sharpe_ratio >= thresholds['sharpe_ratio_min'],
|
|
'sortino_ratio': metrics.sortino_ratio >= thresholds['sortino_ratio_min'],
|
|
'max_drawdown': abs(metrics.max_drawdown_pct) <= thresholds['max_drawdown_max'],
|
|
'win_rate': metrics.winrate >= thresholds['win_rate_min'],
|
|
'profit_factor': metrics.profit_factor >= thresholds['profit_factor_min'],
|
|
}
|
|
|
|
return validations
|
|
|
|
def run_backtest(self, symbol: str, signals: pd.DataFrame, df_oos: pd.DataFrame) -> Dict[str, Any]:
|
|
"""
|
|
Ejecuta backtest en datos OOS.
|
|
|
|
Args:
|
|
symbol: Simbolo
|
|
signals: DataFrame con senales generadas
|
|
df_oos: DataFrame con datos de precio OOS
|
|
|
|
Returns:
|
|
Resultados del backtest
|
|
"""
|
|
config = self.create_backtest_config()
|
|
backtester = RRBacktester(config)
|
|
|
|
logger.info(f"Running backtest on {symbol} with {len(signals)} signals...")
|
|
|
|
result = backtester.run_backtest(df_oos, signals)
|
|
|
|
# Validar metricas
|
|
validations = self.validate_metrics(result.metrics)
|
|
all_passed = all(validations.values())
|
|
|
|
return {
|
|
'symbol': symbol,
|
|
'metrics': result.metrics.__dict__,
|
|
'validations': validations,
|
|
'gate_passed': all_passed,
|
|
'total_trades': len(result.trades),
|
|
'equity_curve': result.equity_curve.tolist() if hasattr(result, 'equity_curve') else [],
|
|
'metrics_by_rr': {k: v.__dict__ for k, v in result.metrics_by_rr.items()} if hasattr(result, 'metrics_by_rr') else {},
|
|
'metrics_by_amd': {k: v.__dict__ for k, v in result.metrics_by_amd.items()} if hasattr(result, 'metrics_by_amd') else {},
|
|
}
|
|
|
|
def generate_report(self, results: Dict[str, Any], output_dir: str) -> str:
|
|
"""
|
|
Genera reporte de backtesting.
|
|
|
|
Args:
|
|
results: Resultados del backtest
|
|
output_dir: Directorio de salida
|
|
|
|
Returns:
|
|
Ruta al archivo de reporte
|
|
"""
|
|
output_path = Path(output_dir)
|
|
output_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
report_file = output_path / f"oos_backtest_{results['symbol']}_{timestamp}.json"
|
|
|
|
# Agregar metadata
|
|
results['metadata'] = {
|
|
'generated_at': datetime.now().isoformat(),
|
|
'config': self.config['validation'],
|
|
'thresholds': self.config['metrics_thresholds']
|
|
}
|
|
|
|
with open(report_file, 'w') as f:
|
|
json.dump(results, f, indent=2, default=str)
|
|
|
|
logger.info(f"Report saved to {report_file}")
|
|
return str(report_file)
|
|
|
|
def print_summary(self, results: Dict[str, Any]):
|
|
"""Imprime resumen de resultados."""
|
|
m = results['metrics']
|
|
v = results['validations']
|
|
|
|
print("\n" + "="*60)
|
|
print(f"OOS BACKTEST RESULTS - {results['symbol']}")
|
|
print("="*60)
|
|
|
|
print(f"\nTotal Trades: {results['total_trades']}")
|
|
print(f"\n{'Metric':<20} {'Value':<15} {'Threshold':<15} {'Status':<10}")
|
|
print("-"*60)
|
|
|
|
thresholds = self.config['metrics_thresholds']
|
|
|
|
metrics_display = [
|
|
('Sharpe Ratio', m.get('sharpe_ratio', 0), f">= {thresholds['sharpe_ratio_min']}", v.get('sharpe_ratio', False)),
|
|
('Sortino Ratio', m.get('sortino_ratio', 0), f">= {thresholds['sortino_ratio_min']}", v.get('sortino_ratio', False)),
|
|
('Max Drawdown', f"{abs(m.get('max_drawdown_pct', 0))*100:.1f}%", f"<= {thresholds['max_drawdown_max']*100:.0f}%", v.get('max_drawdown', False)),
|
|
('Win Rate', f"{m.get('winrate', 0)*100:.1f}%", f">= {thresholds['win_rate_min']*100:.0f}%", v.get('win_rate', False)),
|
|
('Profit Factor', m.get('profit_factor', 0), f">= {thresholds['profit_factor_min']}", v.get('profit_factor', False)),
|
|
]
|
|
|
|
for name, value, threshold, passed in metrics_display:
|
|
status = "PASS" if passed else "FAIL"
|
|
status_color = status
|
|
print(f"{name:<20} {str(value):<15} {threshold:<15} {status_color:<10}")
|
|
|
|
print("-"*60)
|
|
gate_status = "APPROVED" if results['gate_passed'] else "REJECTED"
|
|
print(f"\nGATE TRADING: {gate_status}")
|
|
print("="*60)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description='Run Out-of-Sample Backtesting')
|
|
parser.add_argument('--symbol', type=str, default='XAUUSD', help='Symbol to backtest')
|
|
parser.add_argument('--config', type=str, default='config/validation_oos.yaml', help='Config file path')
|
|
parser.add_argument('--output', type=str, default='reports/validation', help='Output directory')
|
|
parser.add_argument('--mock', action='store_true', help='Use mock data for testing')
|
|
|
|
args = parser.parse_args()
|
|
|
|
logger.info(f"Starting OOS Backtest for {args.symbol}")
|
|
|
|
runner = OOSBacktestRunner(args.config)
|
|
|
|
if args.mock:
|
|
# Generar datos mock para testing del script
|
|
logger.warning("Using MOCK data - not real backtest results")
|
|
|
|
# Mock signals
|
|
dates = pd.date_range('2025-01-01', '2025-12-31', freq='1H')
|
|
mock_signals = pd.DataFrame({
|
|
'timestamp': dates,
|
|
'direction': np.random.choice(['long', 'short'], len(dates)),
|
|
'confidence': np.random.uniform(0.5, 0.9, len(dates)),
|
|
'amd_phase': np.random.choice(['accumulation', 'distribution'], len(dates)),
|
|
}).set_index('timestamp')
|
|
|
|
# Mock price data
|
|
mock_prices = pd.DataFrame({
|
|
'open': np.random.uniform(1800, 2000, len(dates)),
|
|
'high': np.random.uniform(1810, 2010, len(dates)),
|
|
'low': np.random.uniform(1790, 1990, len(dates)),
|
|
'close': np.random.uniform(1800, 2000, len(dates)),
|
|
'volume': np.random.uniform(1000, 10000, len(dates)),
|
|
}, index=dates)
|
|
|
|
# Mock results
|
|
results = {
|
|
'symbol': args.symbol,
|
|
'metrics': {
|
|
'sharpe_ratio': 1.23,
|
|
'sortino_ratio': 1.67,
|
|
'max_drawdown_pct': -0.085,
|
|
'winrate': 0.525,
|
|
'profit_factor': 1.85,
|
|
'total_trades': 142,
|
|
'net_profit': 2350.00,
|
|
},
|
|
'validations': {
|
|
'sharpe_ratio': True,
|
|
'sortino_ratio': True,
|
|
'max_drawdown': True,
|
|
'win_rate': True,
|
|
'profit_factor': True,
|
|
},
|
|
'gate_passed': True,
|
|
'total_trades': 142,
|
|
}
|
|
else:
|
|
# Cargar datos reales
|
|
df_train, df_oos = runner.load_data(args.symbol)
|
|
|
|
# TODO: Aqui iria el codigo para:
|
|
# 1. Entrenar modelos con df_train
|
|
# 2. Generar senales en df_oos
|
|
# 3. Ejecutar backtest
|
|
|
|
logger.error("Real data loading requires database connection. Use --mock for testing.")
|
|
return
|
|
|
|
# Imprimir resumen
|
|
runner.print_summary(results)
|
|
|
|
# Guardar reporte
|
|
report_path = runner.generate_report(results, args.output)
|
|
print(f"\nReport saved: {report_path}")
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|