trading-platform-ml-engine-v2/scripts/run_oos_backtest.py
Adrian Flores Cortes d015e2b0f3 feat(ml-engine): Phase 4 - PostgreSQL migration, dynamic OOS, data pipeline
- 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>
2026-01-27 04:39:05 -06:00

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()