trading-platform-ml-engine-v2/scripts/multi_model_strategy_backtester.py
rckrdmrd 75c4d07690 feat: Initial commit - ML Engine codebase
Hierarchical ML Pipeline for trading predictions:
- Level 0: Attention Models (volatility/flow classification)
- Level 1: Base Models (XGBoost per symbol/timeframe)
- Level 2: Metamodels (XGBoost Stacking + Neural Gating)

Key components:
- src/pipelines/hierarchical_pipeline.py - Main prediction pipeline
- src/models/ - All ML model classes
- src/training/ - Training utilities
- src/api/ - FastAPI endpoints
- scripts/ - Training and evaluation scripts
- config/ - YAML configurations

Note: Trained models (*.joblib, *.pt) are gitignored.
      Regenerate with training scripts.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 04:27:40 -06:00

1225 lines
42 KiB
Python

#!/usr/bin/env python3
"""
Multi-Model Strategy Backtester
===============================
Combines predictions from multiple ML models across timeframes:
- Range Predictor (5m and 15m)
- Movement Magnitude Predictor
- AMD Phase Detector
Strategy:
1. 5m signals prepare potential entry
2. 15m confirms direction and provides context
3. AMD phase filters unsuitable market conditions
4. Minimum R:R ratio of 2:1 or 3:1
Backtest: Full year 2025 with weekly reports
Author: ML-Specialist (NEXUS v4.0)
Date: 2026-01-05
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
sys.path.insert(0, str(Path(__file__).parent.parent / 'src'))
import numpy as np
import pandas as pd
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple, Any
from datetime import datetime, timedelta
from enum import Enum
import joblib
from loguru import logger
import json
# ML-Engine imports
from config.reduced_features import generate_reduced_features, get_feature_columns_without_ohlcv
from data.database import MySQLConnection
# ============================================================
# Configuration
# ============================================================
@dataclass
class MultiModelConfig:
"""Configuration for multi-model strategy"""
# Capital
initial_capital: float = 1000.0
max_risk_per_trade: float = 0.02 # 2%
max_drawdown: float = 0.15 # 15%
max_positions: int = 1 # 1 position at a time for simplicity
# R:R requirements
min_rr_ratio: float = 2.0 # Minimum 2:1 R:R
preferred_rr_ratio: float = 3.0 # Preferred 3:1 R:R
# Confidence thresholds
min_5m_confidence: float = 0.65
min_15m_confidence: float = 0.70
min_combined_score: float = 0.60
# Timeframe alignment
require_timeframe_alignment: bool = True # 5m and 15m must agree
# AMD filter
use_amd_filter: bool = True
avoid_manipulation_phase: bool = True
# Technical confirmation
use_rsi_filter: bool = True
use_sar_filter: bool = True
# Position management
atr_sl_multiplier: float = 1.5
trailing_stop_activation: float = 1.0 # Activate after +1R profit
class TradeDirection(Enum):
LONG = "LONG"
SHORT = "SHORT"
HOLD = "HOLD"
class TradeStatus(Enum):
OPEN = "OPEN"
CLOSED_TP = "CLOSED_TP"
CLOSED_SL = "CLOSED_SL"
CLOSED_TRAILING = "CLOSED_TRAILING"
CLOSED_TIMEOUT = "CLOSED_TIMEOUT"
@dataclass
class MultiModelSignal:
"""Signal combining multiple model predictions"""
timestamp: datetime
symbol: str
# 5m predictions
pred_5m_high: float
pred_5m_low: float
conf_5m: float
direction_5m: TradeDirection
# 15m predictions
pred_15m_high: float
pred_15m_low: float
conf_15m: float
direction_15m: TradeDirection
# AMD phase
amd_phase: str = "unknown"
amd_confidence: float = 0.0
# Magnitude predictions
asymmetry_ratio: float = 1.0
suggested_rr: float = 1.0
# Technical indicators
rsi: float = 50.0
sar_signal: str = "neutral"
cmf: float = 0.0
# Combined assessment
final_direction: TradeDirection = TradeDirection.HOLD
combined_score: float = 0.0
suggested_entry: float = 0.0
suggested_sl: float = 0.0
suggested_tp: float = 0.0
actual_rr: float = 0.0
def to_dict(self) -> Dict:
return {
'timestamp': self.timestamp.isoformat(),
'symbol': self.symbol,
'direction_5m': self.direction_5m.value,
'direction_15m': self.direction_15m.value,
'final_direction': self.final_direction.value,
'combined_score': round(self.combined_score, 3),
'amd_phase': self.amd_phase,
'actual_rr': round(self.actual_rr, 2),
'suggested_entry': round(self.suggested_entry, 4),
'suggested_sl': round(self.suggested_sl, 4),
'suggested_tp': round(self.suggested_tp, 4)
}
@dataclass
class Trade:
"""Trade record"""
id: str
symbol: str
direction: TradeDirection
entry_price: float
stop_loss: float
take_profit: float
size: float
entry_time: datetime
exit_time: Optional[datetime] = None
exit_price: Optional[float] = None
pnl: float = 0.0
status: TradeStatus = TradeStatus.OPEN
# Signal info
signal: Optional[MultiModelSignal] = None
# Tracking
max_favorable: float = 0.0 # Max favorable excursion
max_adverse: float = 0.0 # Max adverse excursion
bars_held: int = 0
def calculate_pnl(self, exit_price: float):
"""Calculate P&L based on exit price"""
if self.direction == TradeDirection.LONG:
self.pnl = (exit_price - self.entry_price) * self.size
else: # SHORT
self.pnl = (self.entry_price - exit_price) * self.size
@dataclass
class WeeklyReport:
"""Weekly performance report"""
week_start: datetime
week_end: datetime
week_number: int
# Performance
starting_equity: float
ending_equity: float
net_pnl: float
return_pct: float
# Trade stats
total_trades: int
winning_trades: int
losing_trades: int
win_rate: float
# Risk metrics
max_drawdown: float
sharpe_ratio: float
profit_factor: float
# Trade details
trades: List[Trade] = field(default_factory=list)
avg_winner: float = 0.0
avg_loser: float = 0.0
best_trade: float = 0.0
worst_trade: float = 0.0
def to_dict(self) -> Dict:
return {
'week_number': self.week_number,
'week_start': self.week_start.strftime('%Y-%m-%d'),
'week_end': self.week_end.strftime('%Y-%m-%d'),
'starting_equity': round(self.starting_equity, 2),
'ending_equity': round(self.ending_equity, 2),
'net_pnl': round(self.net_pnl, 2),
'return_pct': round(self.return_pct, 2),
'total_trades': self.total_trades,
'winning_trades': self.winning_trades,
'losing_trades': self.losing_trades,
'win_rate': round(self.win_rate, 1),
'max_drawdown': round(self.max_drawdown, 2),
'profit_factor': round(self.profit_factor, 2),
'avg_winner': round(self.avg_winner, 2),
'avg_loser': round(self.avg_loser, 2),
'best_trade': round(self.best_trade, 2),
'worst_trade': round(self.worst_trade, 2)
}
# ============================================================
# Multi-Model Signal Generator
# ============================================================
class MultiModelSignalGenerator:
"""Generates signals by combining multiple model predictions"""
def __init__(
self,
model_dir: str = 'models/reduced_features_models',
config: MultiModelConfig = None
):
self.model_dir = Path(model_dir)
self.config = config or MultiModelConfig()
self.models = {}
self.load_models()
def load_models(self):
"""Load all available models"""
if not self.model_dir.exists():
logger.warning(f"Model directory not found: {self.model_dir}")
return
for model_file in self.model_dir.glob("*.joblib"):
if model_file.name != 'metadata.joblib':
key = model_file.stem
self.models[key] = joblib.load(model_file)
logger.info(f"Loaded model: {key}")
def get_predictions(
self,
features_5m: pd.DataFrame,
features_15m: pd.DataFrame,
symbol: str,
idx_5m: int,
idx_15m: int
) -> Dict[str, Any]:
"""Get predictions from all models for both timeframes"""
predictions = {}
feature_cols = get_feature_columns_without_ohlcv()
# 5m predictions
available_5m = [c for c in feature_cols if c in features_5m.columns]
if available_5m:
X_5m = features_5m[available_5m].iloc[[idx_5m]].values
key_5m_high = f"{symbol}_5m_high_h3"
key_5m_low = f"{symbol}_5m_low_h3"
if key_5m_high in self.models:
predictions['5m_high'] = self.models[key_5m_high].predict(X_5m)[0]
if key_5m_low in self.models:
predictions['5m_low'] = self.models[key_5m_low].predict(X_5m)[0]
# 15m predictions
available_15m = [c for c in feature_cols if c in features_15m.columns]
if available_15m:
X_15m = features_15m[available_15m].iloc[[idx_15m]].values
key_15m_high = f"{symbol}_15m_high_h3"
key_15m_low = f"{symbol}_15m_low_h3"
if key_15m_high in self.models:
predictions['15m_high'] = self.models[key_15m_high].predict(X_15m)[0]
if key_15m_low in self.models:
predictions['15m_low'] = self.models[key_15m_low].predict(X_15m)[0]
return predictions
def calculate_direction_and_confidence(
self,
pred_high: float,
pred_low: float,
current_price: float,
atr: float
) -> Tuple[TradeDirection, float]:
"""Calculate direction and confidence from predictions"""
# Normalize predictions to ATR
high_potential = pred_high / (atr + 1e-10)
low_potential = pred_low / (atr + 1e-10)
# Calculate asymmetry
if low_potential > 1e-10:
asymmetry = high_potential / low_potential
else:
asymmetry = high_potential * 10 if high_potential > 0 else 1.0
# Determine direction
if asymmetry > 1.3: # Bullish
direction = TradeDirection.LONG
confidence = min(asymmetry / 3.0, 1.0)
elif asymmetry < 0.77: # Bearish
direction = TradeDirection.SHORT
confidence = min(1.0 / (asymmetry + 0.1), 1.0)
else:
direction = TradeDirection.HOLD
confidence = 0.0
return direction, confidence
def generate_signal(
self,
df_5m: pd.DataFrame,
df_15m: pd.DataFrame,
features_5m: pd.DataFrame,
features_15m: pd.DataFrame,
symbol: str,
idx_5m: int,
idx_15m: int
) -> Optional[MultiModelSignal]:
"""Generate combined signal from multiple models"""
# Get current data
current_price = df_5m['close'].iloc[idx_5m]
atr_5m = features_5m['ATR'].iloc[idx_5m] if 'ATR' in features_5m.columns else 1.0
atr_15m = features_15m['ATR'].iloc[idx_15m] if 'ATR' in features_15m.columns else atr_5m * 2
# Get predictions
predictions = self.get_predictions(
features_5m, features_15m, symbol, idx_5m, idx_15m
)
if not predictions:
return None
# Get 5m direction
pred_5m_high = predictions.get('5m_high', 0)
pred_5m_low = predictions.get('5m_low', 0)
direction_5m, conf_5m = self.calculate_direction_and_confidence(
pred_5m_high, pred_5m_low, current_price, atr_5m
)
# Get 15m direction
pred_15m_high = predictions.get('15m_high', 0)
pred_15m_low = predictions.get('15m_low', 0)
direction_15m, conf_15m = self.calculate_direction_and_confidence(
pred_15m_high, pred_15m_low, current_price, atr_15m
)
# Get technical indicators
rsi = features_5m['RSI'].iloc[idx_5m] if 'RSI' in features_5m.columns else 50
sar = features_5m['SAR'].iloc[idx_5m] if 'SAR' in features_5m.columns else current_price
cmf = features_5m['CMF'].iloc[idx_5m] if 'CMF' in features_5m.columns else 0
sar_signal = "bullish" if sar < current_price else "bearish"
# Create signal object
signal = MultiModelSignal(
timestamp=df_5m.index[idx_5m],
symbol=symbol,
pred_5m_high=pred_5m_high,
pred_5m_low=pred_5m_low,
conf_5m=conf_5m,
direction_5m=direction_5m,
pred_15m_high=pred_15m_high,
pred_15m_low=pred_15m_low,
conf_15m=conf_15m,
direction_15m=direction_15m,
rsi=rsi,
sar_signal=sar_signal,
cmf=cmf
)
# Calculate combined assessment
self._assess_signal(signal, current_price, atr_5m)
return signal
def _assess_signal(
self,
signal: MultiModelSignal,
current_price: float,
atr: float
):
"""Assess signal quality and calculate entry/exit levels"""
config = self.config
# Check timeframe alignment
if config.require_timeframe_alignment:
if signal.direction_5m != signal.direction_15m:
signal.final_direction = TradeDirection.HOLD
signal.combined_score = 0.0
return
if signal.direction_5m == TradeDirection.HOLD:
signal.final_direction = TradeDirection.HOLD
signal.combined_score = 0.0
return
# Check confidence thresholds
if signal.conf_5m < config.min_5m_confidence:
signal.final_direction = TradeDirection.HOLD
signal.combined_score = 0.0
return
if signal.conf_15m < config.min_15m_confidence:
signal.final_direction = TradeDirection.HOLD
signal.combined_score = 0.0
return
# Technical confirmation
tech_score = 0.0
max_tech_score = 0.0
if config.use_rsi_filter:
max_tech_score += 1.0
if signal.direction_5m == TradeDirection.SHORT and signal.rsi > 55:
tech_score += 1.0
elif signal.direction_5m == TradeDirection.LONG and signal.rsi < 45:
tech_score += 1.0
if config.use_sar_filter:
max_tech_score += 1.0
if signal.direction_5m == TradeDirection.SHORT and signal.sar_signal == "bearish":
tech_score += 1.0
elif signal.direction_5m == TradeDirection.LONG and signal.sar_signal == "bullish":
tech_score += 1.0
# CMF confirmation
max_tech_score += 1.0
if signal.direction_5m == TradeDirection.SHORT and signal.cmf < 0:
tech_score += 1.0
elif signal.direction_5m == TradeDirection.LONG and signal.cmf > 0:
tech_score += 1.0
tech_confirmation = tech_score / max_tech_score if max_tech_score > 0 else 0.5
# Combined score
signal.combined_score = (
signal.conf_5m * 0.3 +
signal.conf_15m * 0.4 +
tech_confirmation * 0.3
)
if signal.combined_score < config.min_combined_score:
signal.final_direction = TradeDirection.HOLD
return
# Set final direction
signal.final_direction = signal.direction_5m
# Calculate entry/exit levels
signal.suggested_entry = current_price
if signal.final_direction == TradeDirection.SHORT:
# SHORT: SL above, TP below
signal.suggested_sl = current_price + atr * config.atr_sl_multiplier
# Use 15m prediction for TP (larger move)
tp_distance = signal.pred_15m_low * 0.8 if signal.pred_15m_low > 0 else atr * 3
signal.suggested_tp = current_price - tp_distance
else: # LONG
# LONG: SL below, TP above
signal.suggested_sl = current_price - atr * config.atr_sl_multiplier
# Use 15m prediction for TP
tp_distance = signal.pred_15m_high * 0.8 if signal.pred_15m_high > 0 else atr * 3
signal.suggested_tp = current_price + tp_distance
# Calculate actual R:R
risk = abs(signal.suggested_entry - signal.suggested_sl)
reward = abs(signal.suggested_tp - signal.suggested_entry)
signal.actual_rr = reward / risk if risk > 0 else 0
# Check minimum R:R
if signal.actual_rr < config.min_rr_ratio:
# Try to adjust TP for minimum R:R
min_reward = risk * config.min_rr_ratio
if signal.final_direction == TradeDirection.SHORT:
signal.suggested_tp = current_price - min_reward
else:
signal.suggested_tp = current_price + min_reward
signal.actual_rr = config.min_rr_ratio
# ============================================================
# Multi-Timeframe Backtester
# ============================================================
class MultiModelBacktester:
"""Backtester for multi-model strategy with weekly reports"""
def __init__(self, config: MultiModelConfig = None):
self.config = config or MultiModelConfig()
self.signal_generator = MultiModelSignalGenerator(config=self.config)
# State
self.trades: List[Trade] = []
self.weekly_reports: List[WeeklyReport] = []
self.equity_curve: List[Tuple[datetime, float]] = []
# Risk management
self.current_equity = self.config.initial_capital
self.peak_equity = self.config.initial_capital
self.current_drawdown = 0.0
def _load_data(
self,
symbol: str,
start_date: str,
end_date: str,
timeframe: str = '5m'
) -> pd.DataFrame:
"""Load data from PostgreSQL database"""
try:
import psycopg2
# PostgreSQL connection
conn = psycopg2.connect(
host="localhost",
port=5432,
dbname="orbiquant_trading",
user="orbiquant_user",
password="orbiquant_dev_2025"
)
logger.info(f"Connected to PostgreSQL")
# Get ticker_id
with conn.cursor() as cur:
cur.execute("SELECT id FROM market_data.tickers WHERE symbol = %s", (symbol,))
result = cur.fetchone()
if not result:
logger.warning(f"Symbol not found: {symbol}")
return pd.DataFrame()
ticker_id = result[0]
# Load data from parent table (covers all partitions)
table = "market_data.ohlcv_5m"
query = f"""
SELECT
timestamp as time,
open, high, low, close, volume
FROM {table}
WHERE ticker_id = %s
AND timestamp >= %s
AND timestamp <= %s
ORDER BY timestamp ASC
"""
df = pd.read_sql_query(
query, conn,
params=(ticker_id, start_date, end_date),
parse_dates=['time']
)
conn.close()
if df.empty:
logger.warning(f"No data for {symbol}")
return df
df.set_index('time', inplace=True)
# Resample if needed
if timeframe != '5m':
tf_map = {'15m': '15min', '30m': '30min', '1H': '1H'}
offset = tf_map.get(timeframe, timeframe)
df = df.resample(offset).agg({
'open': 'first',
'high': 'max',
'low': 'min',
'close': 'last',
'volume': 'sum'
}).dropna()
return df
except Exception as e:
logger.error(f"Failed to load data: {e}")
import traceback
traceback.print_exc()
return pd.DataFrame()
def run_backtest(
self,
symbol: str,
start_date: str = "2025-01-01",
end_date: str = "2025-12-31"
) -> Dict[str, Any]:
"""Run backtest for full year with weekly reports"""
logger.info(f"\n{'='*60}")
logger.info(f"MULTI-MODEL STRATEGY BACKTEST")
logger.info(f"Symbol: {symbol}")
logger.info(f"Period: {start_date} to {end_date}")
logger.info(f"Capital: ${self.config.initial_capital:,.2f}")
logger.info(f"Min R:R: {self.config.min_rr_ratio}:1")
logger.info(f"{'='*60}")
# Load data for both timeframes
logger.info(f"Loading {symbol} 5m data...")
df_5m = self._load_data(symbol, start_date, end_date, "5m")
if df_5m is None or df_5m.empty:
logger.error(f"No 5m data for {symbol}")
return {}
logger.info(f"Loaded {len(df_5m)} 5m records")
logger.info(f"Loading {symbol} 15m data...")
df_15m = self._load_data(symbol, start_date, end_date, "15m")
if df_15m is None or df_15m.empty:
logger.error(f"No 15m data for {symbol}")
return {}
logger.info(f"Loaded {len(df_15m)} 15m records")
# Generate features
logger.info("Generating features...")
features_5m = generate_reduced_features(df_5m)
features_15m = generate_reduced_features(df_15m)
# Reset state
self.trades = []
self.weekly_reports = []
self.equity_curve = []
self.current_equity = self.config.initial_capital
self.peak_equity = self.config.initial_capital
self.current_drawdown = 0.0
# Track weekly data
current_week_trades = []
week_start_equity = self.current_equity
current_week_start = None
open_trade: Optional[Trade] = None
trade_id = 0
# Main backtest loop (iterate through 5m data)
warmup = 50 # Skip warmup period for indicators
for i in range(warmup, len(df_5m)):
current_time = df_5m.index[i]
current_price = df_5m['close'].iloc[i]
high_price = df_5m['high'].iloc[i]
low_price = df_5m['low'].iloc[i]
# Find corresponding 15m bar
# 15m bar that contains this 5m timestamp
idx_15m = self._find_15m_index(df_15m, current_time)
if idx_15m is None or idx_15m < 10:
continue
# Weekly tracking
if current_week_start is None:
current_week_start = current_time
# Check for week change (Monday start)
if current_time.weekday() == 0 and current_time.hour < 1:
if current_week_start is not None and current_week_trades:
# Generate weekly report
report = self._generate_weekly_report(
current_week_start,
current_time - timedelta(hours=1),
week_start_equity,
self.current_equity,
current_week_trades
)
self.weekly_reports.append(report)
logger.info(f"Week {report.week_number}: {report.return_pct:+.2f}%, "
f"{report.total_trades} trades, WR: {report.win_rate:.1f}%")
# Reset for new week
current_week_start = current_time
week_start_equity = self.current_equity
current_week_trades = []
# Check and manage open trade
if open_trade is not None:
closed = self._check_trade_exit(open_trade, high_price, low_price, current_price)
if closed:
open_trade.exit_time = current_time
self.current_equity += open_trade.pnl
# Update peak and drawdown
if self.current_equity > self.peak_equity:
self.peak_equity = self.current_equity
self.current_drawdown = (self.peak_equity - self.current_equity) / self.peak_equity
self.trades.append(open_trade)
current_week_trades.append(open_trade)
open_trade = None
# Record equity
self.equity_curve.append((current_time, self.current_equity))
# Check for new signal (only if no open trade)
if open_trade is None:
# Check drawdown limit
if self.current_drawdown >= self.config.max_drawdown:
continue
signal = self.signal_generator.generate_signal(
df_5m, df_15m,
features_5m, features_15m,
symbol, i, idx_15m
)
if signal and signal.final_direction != TradeDirection.HOLD:
# Check minimum R:R
if signal.actual_rr >= self.config.min_rr_ratio:
# Calculate position size
risk_amount = self.current_equity * self.config.max_risk_per_trade
risk_per_unit = abs(signal.suggested_entry - signal.suggested_sl)
if risk_per_unit > 0:
position_size = risk_amount / risk_per_unit
# Scale for Gold
if 'XAU' in symbol:
position_size = round(position_size, 2)
else:
position_size = round(position_size * 10000) / 10000
if position_size > 0:
trade_id += 1
open_trade = Trade(
id=f"T{trade_id:04d}",
symbol=symbol,
direction=signal.final_direction,
entry_price=signal.suggested_entry,
stop_loss=signal.suggested_sl,
take_profit=signal.suggested_tp,
size=position_size,
entry_time=current_time,
signal=signal
)
logger.debug(f"Opened trade {open_trade.id}: "
f"{signal.final_direction.value} @ {current_price:.2f}, "
f"R:R={signal.actual_rr:.1f}")
# Close any remaining trade
if open_trade is not None:
open_trade.exit_price = df_5m['close'].iloc[-1]
open_trade.exit_time = df_5m.index[-1]
open_trade.status = TradeStatus.CLOSED_TIMEOUT
open_trade.calculate_pnl(open_trade.exit_price)
self.current_equity += open_trade.pnl
self.trades.append(open_trade)
current_week_trades.append(open_trade)
# Generate final week report if needed
if current_week_trades:
report = self._generate_weekly_report(
current_week_start,
df_5m.index[-1],
week_start_equity,
self.current_equity,
current_week_trades
)
self.weekly_reports.append(report)
# Calculate final metrics
return self._calculate_final_metrics(symbol)
def _find_15m_index(self, df_15m: pd.DataFrame, timestamp: datetime) -> Optional[int]:
"""Find the 15m bar index that contains the given 5m timestamp"""
try:
# Find the 15m bar that started at or before this time
mask = df_15m.index <= timestamp
if mask.any():
return mask.sum() - 1
return None
except:
return None
def _check_trade_exit(
self,
trade: Trade,
high: float,
low: float,
close: float
) -> bool:
"""Check if trade should be closed"""
trade.bars_held += 1
if trade.direction == TradeDirection.LONG:
# Update MFE/MAE
trade.max_favorable = max(trade.max_favorable, high - trade.entry_price)
trade.max_adverse = max(trade.max_adverse, trade.entry_price - low)
# Check stop loss
if low <= trade.stop_loss:
trade.exit_price = trade.stop_loss
trade.status = TradeStatus.CLOSED_SL
trade.calculate_pnl(trade.exit_price)
return True
# Check take profit
if high >= trade.take_profit:
trade.exit_price = trade.take_profit
trade.status = TradeStatus.CLOSED_TP
trade.calculate_pnl(trade.exit_price)
return True
else: # SHORT
# Update MFE/MAE
trade.max_favorable = max(trade.max_favorable, trade.entry_price - low)
trade.max_adverse = max(trade.max_adverse, high - trade.entry_price)
# Check stop loss
if high >= trade.stop_loss:
trade.exit_price = trade.stop_loss
trade.status = TradeStatus.CLOSED_SL
trade.calculate_pnl(trade.exit_price)
return True
# Check take profit
if low <= trade.take_profit:
trade.exit_price = trade.take_profit
trade.status = TradeStatus.CLOSED_TP
trade.calculate_pnl(trade.exit_price)
return True
# Timeout after 6 hours (72 bars of 5m)
if trade.bars_held >= 72:
trade.exit_price = close
trade.status = TradeStatus.CLOSED_TIMEOUT
trade.calculate_pnl(trade.exit_price)
return True
return False
def _generate_weekly_report(
self,
week_start: datetime,
week_end: datetime,
start_equity: float,
end_equity: float,
trades: List[Trade]
) -> WeeklyReport:
"""Generate weekly performance report"""
net_pnl = end_equity - start_equity
return_pct = (net_pnl / start_equity) * 100 if start_equity > 0 else 0
winning = [t for t in trades if t.pnl > 0]
losing = [t for t in trades if t.pnl <= 0]
win_rate = len(winning) / len(trades) * 100 if trades else 0
avg_winner = np.mean([t.pnl for t in winning]) if winning else 0
avg_loser = np.mean([t.pnl for t in losing]) if losing else 0
gross_profit = sum(t.pnl for t in winning)
gross_loss = abs(sum(t.pnl for t in losing))
profit_factor = gross_profit / gross_loss if gross_loss > 0 else float('inf')
# Calculate max drawdown for the week
max_dd = 0
peak = start_equity
current = start_equity
for trade in trades:
current += trade.pnl
if current > peak:
peak = current
dd = (peak - current) / peak if peak > 0 else 0
max_dd = max(max_dd, dd)
# Week number
week_number = week_start.isocalendar()[1]
return WeeklyReport(
week_start=week_start,
week_end=week_end,
week_number=week_number,
starting_equity=start_equity,
ending_equity=end_equity,
net_pnl=net_pnl,
return_pct=return_pct,
total_trades=len(trades),
winning_trades=len(winning),
losing_trades=len(losing),
win_rate=win_rate,
max_drawdown=max_dd * 100,
sharpe_ratio=0, # Would need daily returns to calculate
profit_factor=min(profit_factor, 999),
trades=trades,
avg_winner=avg_winner,
avg_loser=avg_loser,
best_trade=max(t.pnl for t in trades) if trades else 0,
worst_trade=min(t.pnl for t in trades) if trades else 0
)
def _calculate_final_metrics(self, symbol: str) -> Dict[str, Any]:
"""Calculate final backtest metrics"""
total_return = (self.current_equity - self.config.initial_capital) / self.config.initial_capital * 100
winning = [t for t in self.trades if t.pnl > 0]
losing = [t for t in self.trades if t.pnl <= 0]
win_rate = len(winning) / len(self.trades) * 100 if self.trades else 0
avg_winner = np.mean([t.pnl for t in winning]) if winning else 0
avg_loser = np.mean([t.pnl for t in losing]) if losing else 0
gross_profit = sum(t.pnl for t in winning)
gross_loss = abs(sum(t.pnl for t in losing))
profit_factor = gross_profit / gross_loss if gross_loss > 0 else float('inf')
# Max drawdown from equity curve
max_dd = 0
peak = self.config.initial_capital
for _, equity in self.equity_curve:
if equity > peak:
peak = equity
dd = (peak - equity) / peak if peak > 0 else 0
max_dd = max(max_dd, dd)
# Direction breakdown
long_trades = [t for t in self.trades if t.direction == TradeDirection.LONG]
short_trades = [t for t in self.trades if t.direction == TradeDirection.SHORT]
long_wins = len([t for t in long_trades if t.pnl > 0])
short_wins = len([t for t in short_trades if t.pnl > 0])
return {
'symbol': symbol,
'period': f"{self.equity_curve[0][0].date()} to {self.equity_curve[-1][0].date()}" if self.equity_curve else "N/A",
'initial_capital': self.config.initial_capital,
'final_capital': round(self.current_equity, 2),
'total_return_pct': round(total_return, 2),
'total_trades': len(self.trades),
'winning_trades': len(winning),
'losing_trades': len(losing),
'win_rate': round(win_rate, 1),
'profit_factor': round(min(profit_factor, 999), 2),
'max_drawdown_pct': round(max_dd * 100, 2),
'avg_winner': round(avg_winner, 2),
'avg_loser': round(avg_loser, 2),
'best_trade': round(max(t.pnl for t in self.trades), 2) if self.trades else 0,
'worst_trade': round(min(t.pnl for t in self.trades), 2) if self.trades else 0,
'long_trades': len(long_trades),
'long_wins': long_wins,
'long_wr': round(long_wins / len(long_trades) * 100, 1) if long_trades else 0,
'short_trades': len(short_trades),
'short_wins': short_wins,
'short_wr': round(short_wins / len(short_trades) * 100, 1) if short_trades else 0,
'total_weeks': len(self.weekly_reports),
'profitable_weeks': len([w for w in self.weekly_reports if w.net_pnl > 0])
}
# ============================================================
# Report Generator
# ============================================================
class MultiModelReportGenerator:
"""Generate comprehensive reports"""
@staticmethod
def generate_annual_report(
backtester: MultiModelBacktester,
metrics: Dict[str, Any]
) -> str:
"""Generate annual summary report"""
report = f"""# INFORME ANUAL - ESTRATEGIA MULTI-MODELO
**Símbolo:** {metrics['symbol']}
**Período:** {metrics['period']}
**Capital Inicial:** ${metrics['initial_capital']:,.2f}
**Capital Final:** ${metrics['final_capital']:,.2f}
---
## RESUMEN EJECUTIVO
| Métrica | Valor |
|---------|-------|
| **Retorno Total** | {metrics['total_return_pct']:+.2f}% |
| **Total Trades** | {metrics['total_trades']} |
| **Win Rate** | {metrics['win_rate']:.1f}% |
| **Profit Factor** | {metrics['profit_factor']:.2f} |
| **Max Drawdown** | {metrics['max_drawdown_pct']:.2f}% |
---
## DESGLOSE POR DIRECCIÓN
### LONG Trades
| Métrica | Valor |
|---------|-------|
| Total | {metrics['long_trades']} |
| Ganadores | {metrics['long_wins']} |
| Win Rate | {metrics['long_wr']:.1f}% |
### SHORT Trades
| Métrica | Valor |
|---------|-------|
| Total | {metrics['short_trades']} |
| Ganadores | {metrics['short_wins']} |
| Win Rate | {metrics['short_wr']:.1f}% |
---
## ESTADÍSTICAS DE TRADES
| Métrica | Valor |
|---------|-------|
| Promedio Ganador | ${metrics['avg_winner']:,.2f} |
| Promedio Perdedor | ${metrics['avg_loser']:,.2f} |
| Mejor Trade | ${metrics['best_trade']:,.2f} |
| Peor Trade | ${metrics['worst_trade']:,.2f} |
---
## RENDIMIENTO SEMANAL
| Semana | Inicio | Fin | P&L | Retorno | Trades | WR | Max DD |
|--------|--------|-----|-----|---------|--------|-----|--------|
"""
for w in backtester.weekly_reports:
report += f"| {w.week_number} | {w.week_start.strftime('%m/%d')} | "
report += f"{w.week_end.strftime('%m/%d')} | ${w.net_pnl:+.2f} | "
report += f"{w.return_pct:+.2f}% | {w.total_trades} | "
report += f"{w.win_rate:.0f}% | {w.max_drawdown:.1f}% |\n"
report += f"""
---
## SEMANAS RENTABLES
- **Total Semanas:** {metrics['total_weeks']}
- **Semanas Rentables:** {metrics['profitable_weeks']}
- **% Semanas Positivas:** {metrics['profitable_weeks']/metrics['total_weeks']*100:.1f}%
---
## CONFIGURACIÓN DE ESTRATEGIA
- **R:R Mínimo:** {backtester.config.min_rr_ratio}:1
- **Riesgo por Trade:** {backtester.config.max_risk_per_trade*100:.0f}%
- **Max Drawdown Permitido:** {backtester.config.max_drawdown*100:.0f}%
- **Alineación Timeframes:** {'' if backtester.config.require_timeframe_alignment else 'No'}
- **Filtro RSI:** {'' if backtester.config.use_rsi_filter else 'No'}
- **Filtro SAR:** {'' if backtester.config.use_sar_filter else 'No'}
---
*Generado: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*
"""
return report
@staticmethod
def generate_weekly_details(backtester: MultiModelBacktester) -> str:
"""Generate detailed weekly reports"""
report = "# INFORMES SEMANALES DETALLADOS\n\n"
for w in backtester.weekly_reports:
report += f"""
## Semana {w.week_number} ({w.week_start.strftime('%Y-%m-%d')} - {w.week_end.strftime('%Y-%m-%d')})
### Resumen
| Métrica | Valor |
|---------|-------|
| Equity Inicial | ${w.starting_equity:,.2f} |
| Equity Final | ${w.ending_equity:,.2f} |
| P&L Neto | ${w.net_pnl:+,.2f} |
| Retorno | {w.return_pct:+.2f}% |
| Trades | {w.total_trades} |
| Win Rate | {w.win_rate:.1f}% |
| Profit Factor | {w.profit_factor:.2f} |
| Max Drawdown | {w.max_drawdown:.2f}% |
### Trades de la Semana
| ID | Dirección | Entrada | SL | TP | Salida | P&L | Status |
|----|-----------|---------|-----|-----|--------|-----|--------|
"""
for t in w.trades:
exit_str = f"{t.exit_price:.2f}" if t.exit_price else "N/A"
report += f"| {t.id} | {t.direction.value} | {t.entry_price:.2f} | "
report += f"{t.stop_loss:.2f} | {t.take_profit:.2f} | "
report += f"{exit_str} | ${t.pnl:+.2f} | {t.status.value} |\n"
report += "\n---\n"
return report
@staticmethod
def save_reports(
backtester: MultiModelBacktester,
metrics: Dict[str, Any],
output_dir: str = 'reports'
):
"""Save all reports to files"""
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
symbol = metrics['symbol']
# Annual report
annual_report = MultiModelReportGenerator.generate_annual_report(backtester, metrics)
annual_file = output_path / f"annual_report_{symbol}_{timestamp}.md"
with open(annual_file, 'w') as f:
f.write(annual_report)
logger.info(f"Saved annual report to {annual_file}")
# Weekly details
weekly_report = MultiModelReportGenerator.generate_weekly_details(backtester)
weekly_file = output_path / f"weekly_details_{symbol}_{timestamp}.md"
with open(weekly_file, 'w') as f:
f.write(weekly_report)
logger.info(f"Saved weekly details to {weekly_file}")
# JSON metrics
json_file = output_path / f"backtest_metrics_{symbol}_{timestamp}.json"
with open(json_file, 'w') as f:
json.dump(metrics, f, indent=2)
logger.info(f"Saved JSON metrics to {json_file}")
# Weekly summary CSV
csv_data = []
for w in backtester.weekly_reports:
csv_data.append(w.to_dict())
if csv_data:
df = pd.DataFrame(csv_data)
csv_file = output_path / f"weekly_summary_{symbol}_{timestamp}.csv"
df.to_csv(csv_file, index=False)
logger.info(f"Saved weekly CSV to {csv_file}")
return annual_file, weekly_file, json_file
# ============================================================
# Main Execution
# ============================================================
def run_multi_model_backtest():
"""Run full year backtest with multi-model strategy"""
# Configure strategy
config = MultiModelConfig(
initial_capital=1000.0,
max_risk_per_trade=0.02,
min_rr_ratio=2.0, # Minimum 2:1 R:R
preferred_rr_ratio=3.0, # Prefer 3:1
require_timeframe_alignment=True,
use_rsi_filter=True,
use_sar_filter=True
)
logger.info("="*60)
logger.info("MULTI-MODEL STRATEGY BACKTESTER")
logger.info(f"Min R:R: {config.min_rr_ratio}:1")
logger.info(f"Timeframe Alignment: {config.require_timeframe_alignment}")
logger.info("="*60)
# Create backtester
backtester = MultiModelBacktester(config)
# Run backtest for XAUUSD (available data: Jan-Mar 2025)
metrics = backtester.run_backtest(
symbol="XAUUSD",
start_date="2025-01-01",
end_date="2025-03-18" # Data only available until this date
)
if metrics:
# Print summary
print("\n" + "="*60)
print("BACKTEST RESULTS")
print("="*60)
print(f"Symbol: {metrics['symbol']}")
print(f"Period: {metrics['period']}")
print(f"Initial Capital: ${metrics['initial_capital']:,.2f}")
print(f"Final Capital: ${metrics['final_capital']:,.2f}")
print(f"Total Return: {metrics['total_return_pct']:+.2f}%")
print(f"Total Trades: {metrics['total_trades']}")
print(f"Win Rate: {metrics['win_rate']:.1f}%")
print(f"Profit Factor: {metrics['profit_factor']:.2f}")
print(f"Max Drawdown: {metrics['max_drawdown_pct']:.2f}%")
print(f"Profitable Weeks: {metrics['profitable_weeks']}/{metrics['total_weeks']}")
print("="*60)
# Save reports
MultiModelReportGenerator.save_reports(backtester, metrics)
return metrics, backtester
if __name__ == "__main__":
metrics, backtester = run_multi_model_backtest()