275 lines
12 KiB
Python
275 lines
12 KiB
Python
"""
|
|
Enhanced ONNX Strategy for Backtesting with Historical Data Buffer
|
|
|
|
This version maintains a buffer of historical bars for proper ONNX predictions.
|
|
"""
|
|
|
|
from datetime import datetime
|
|
from typing import Dict, Any, Optional, List
|
|
import numpy as np
|
|
import MetaTrader5 as mt5
|
|
import onnxruntime as ort
|
|
import pickle
|
|
import os
|
|
from base_strategy import BaseStrategy
|
|
|
|
|
|
class ONNXBacktestStrategy(BaseStrategy):
|
|
"""
|
|
ONNX strategy with historical data buffer for backtesting.
|
|
"""
|
|
|
|
def __init__(self, symbol: str, timeframe: int, model_path: str,
|
|
scaler_path: Optional[str] = None, initial_balance: float = 10000.0,
|
|
prediction_threshold: float = 0.0001, min_confidence: float = 0.0,
|
|
lot_size: float = 0.1, stop_loss_pips: int = 50, take_profit_pips: int = 100):
|
|
"""
|
|
Initialize the ONNX backtest strategy.
|
|
"""
|
|
super().__init__(symbol, timeframe, initial_balance)
|
|
|
|
self.model_path = model_path
|
|
self.scaler_path = scaler_path
|
|
self.prediction_threshold = prediction_threshold
|
|
self.min_confidence = min_confidence
|
|
self.lot_size = lot_size
|
|
self.stop_loss_pips = stop_loss_pips
|
|
self.take_profit_pips = take_profit_pips
|
|
|
|
# Load ONNX model
|
|
if not os.path.exists(model_path):
|
|
raise FileNotFoundError(f"ONNX model not found: {model_path}")
|
|
|
|
self.session = ort.InferenceSession(model_path)
|
|
self.input_name = self.session.get_inputs()[0].name
|
|
self.output_name = self.session.get_outputs()[0].name
|
|
self.input_shape = self.session.get_inputs()[0].shape
|
|
|
|
# Determine lookback
|
|
if self.input_shape and len(self.input_shape) >= 2:
|
|
self.lookback = int(self.input_shape[1]) if self.input_shape[1] else 60
|
|
else:
|
|
self.lookback = 60
|
|
|
|
# Load scaler
|
|
if scaler_path and os.path.exists(scaler_path):
|
|
with open(scaler_path, 'rb') as f:
|
|
self.scaler = pickle.load(f)
|
|
else:
|
|
self.scaler = None
|
|
|
|
# Historical data buffer
|
|
self.historical_bars: List[Dict[str, Any]] = []
|
|
|
|
def get_required_indicators(self) -> Dict[str, Dict[str, Any]]:
|
|
"""Required indicators for feature preparation."""
|
|
# MT5 uses PRICE_CLOSE constant, but if not available, use 0 (close price)
|
|
price_close = getattr(mt5, 'PRICE_CLOSE', 0)
|
|
return {
|
|
'rsi': {'period': 14, 'applied_price': price_close},
|
|
'ema': {'period': 50, 'applied_price': price_close},
|
|
'atr': {'period': 14}
|
|
}
|
|
|
|
def prepare_features(self) -> np.ndarray:
|
|
"""Prepare features from historical buffer - must match training features (13 total)."""
|
|
if len(self.historical_bars) < self.lookback:
|
|
return None
|
|
|
|
features = []
|
|
bars_to_use = self.historical_bars[-self.lookback:]
|
|
|
|
# Calculate EMA20 and volume MA for all bars first
|
|
closes = [bar['close'] for bar in bars_to_use]
|
|
volumes = [bar.get('tick_volume', 0) for bar in bars_to_use]
|
|
|
|
# Calculate EMA20 (using pandas-like ewm)
|
|
import pandas as pd
|
|
closes_series = pd.Series(closes)
|
|
ema20_values = closes_series.ewm(span=20, adjust=False).mean().tolist()
|
|
|
|
# Calculate volume MA
|
|
volumes_series = pd.Series(volumes)
|
|
volume_ma_values = volumes_series.rolling(window=20, min_periods=1).mean().tolist()
|
|
|
|
for i, bar in enumerate(bars_to_use):
|
|
feature_row = []
|
|
|
|
# OHLC (4 features)
|
|
feature_row.append(bar['open'])
|
|
feature_row.append(bar['high'])
|
|
feature_row.append(bar['low'])
|
|
feature_row.append(bar['close'])
|
|
|
|
# Volume (1 feature)
|
|
volume = bar.get('tick_volume', 0)
|
|
feature_row.append(volume / 1000000.0)
|
|
|
|
# RSI (1 feature)
|
|
rsi = bar.get('rsi', 50.0)
|
|
feature_row.append(rsi / 100.0)
|
|
|
|
# EMA20 (1 feature) - normalized difference
|
|
ema20 = ema20_values[i] if i < len(ema20_values) else bar['close']
|
|
feature_row.append((ema20 - bar['close']) / bar['close'] if bar['close'] > 0 else 0.0)
|
|
|
|
# EMA50 (1 feature) - normalized difference
|
|
ema50 = bar.get('ema', bar['close'])
|
|
feature_row.append((ema50 - bar['close']) / bar['close'] if bar['close'] > 0 else 0.0)
|
|
|
|
# ATR (1 feature)
|
|
atr = bar.get('atr', 0.0)
|
|
feature_row.append(atr / bar['close'] if bar['close'] > 0 else 0.0)
|
|
|
|
# Price change (1 feature)
|
|
if i > 0:
|
|
prev_close = bars_to_use[i-1]['close']
|
|
price_change = (bar['close'] - prev_close) / prev_close if prev_close > 0 else 0.0
|
|
else:
|
|
price_change = 0.0
|
|
feature_row.append(price_change)
|
|
|
|
# High/Low ratio (1 feature)
|
|
feature_row.append(bar['high'] / bar['low'] if bar['low'] > 0 else 1.0)
|
|
|
|
# Volume MA and ratio (2 features)
|
|
volume_ma = volume_ma_values[i] if i < len(volume_ma_values) else max(volume, 1)
|
|
volume_ratio = volume / max(volume_ma, 1) if volume_ma > 0 else 1.0
|
|
feature_row.append(volume_ma / 1000000.0) # Normalized volume MA
|
|
feature_row.append(volume_ratio)
|
|
|
|
features.append(feature_row)
|
|
|
|
features = np.array(features, dtype=np.float32)
|
|
|
|
# Normalize
|
|
if self.scaler is not None:
|
|
original_shape = features.shape
|
|
features_flat = features.reshape(-1, features.shape[-1])
|
|
features_scaled = self.scaler.transform(features_flat)
|
|
features = features_scaled.reshape(original_shape)
|
|
else:
|
|
# Simple normalization
|
|
mean = features.mean(axis=0)
|
|
std = features.std(axis=0) + 1e-8
|
|
features = (features - mean) / std
|
|
|
|
# Reshape for model: (1, lookback, features)
|
|
features = features.reshape(1, self.lookback, -1)
|
|
|
|
return features
|
|
|
|
def predict_price(self) -> Optional[float]:
|
|
"""Make prediction using ONNX model."""
|
|
if len(self.historical_bars) < self.lookback:
|
|
return None
|
|
|
|
input_data = self.prepare_features()
|
|
if input_data is None:
|
|
return None
|
|
|
|
try:
|
|
outputs = self.session.run([self.output_name], {self.input_name: input_data})
|
|
prediction = outputs[0][0][0]
|
|
|
|
# Model now predicts price change percentage (e.g., -0.003 = -0.3%)
|
|
# These values should be between -1 and 1 (or slightly outside for extreme cases)
|
|
# Don't filter based on absolute price range anymore
|
|
|
|
return float(prediction)
|
|
except Exception as e:
|
|
print(f"Prediction error: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
return None
|
|
|
|
def on_bar(self, bar_data: Dict[str, Any]) -> None:
|
|
"""Trading logic based on ONNX predictions."""
|
|
# Add current bar to historical buffer
|
|
self.historical_bars.append(bar_data.copy())
|
|
|
|
# Keep only necessary history
|
|
if len(self.historical_bars) > self.lookback + 50:
|
|
self.historical_bars = self.historical_bars[-(self.lookback + 50):]
|
|
|
|
# Check if we have enough data
|
|
if len(self.historical_bars) < self.lookback:
|
|
return
|
|
|
|
current_price = bar_data['close']
|
|
|
|
# Check existing position
|
|
if self.position is not None:
|
|
self.check_stop_loss_take_profit(current_price)
|
|
return
|
|
|
|
# Make prediction
|
|
# Model now predicts price change percentage directly (e.g., 0.001 = 0.1%)
|
|
predicted_change_pct = self.predict_price()
|
|
if predicted_change_pct is None:
|
|
return
|
|
|
|
# Model predicts price change percentage directly
|
|
# Check if it's a percentage (between -1 and 1) or absolute price
|
|
if abs(predicted_change_pct) < 1.0:
|
|
# It's already a percentage (e.g., 0.001 = 0.1%)
|
|
price_change_pct = predicted_change_pct
|
|
else:
|
|
# It's an absolute price (old model format), convert to percentage
|
|
predicted_price = predicted_change_pct
|
|
if predicted_price <= 0 or predicted_price > 10000:
|
|
return # Invalid prediction
|
|
price_change = predicted_price - current_price
|
|
price_change_pct = (price_change / current_price) if current_price > 0 else 0.0
|
|
|
|
# Calculate confidence (simple heuristic)
|
|
# For percentage predictions (0.001 = 0.1%), normalize to 0-1
|
|
# If price_change_pct is already a percentage (e.g., 0.001), use it directly
|
|
# If it's a large number, it's already in percentage form
|
|
if abs(price_change_pct) < 1.0:
|
|
# It's a decimal percentage (e.g., 0.001 = 0.1%)
|
|
confidence = min(abs(price_change_pct) / 0.01, 1.0) # Normalize: 0.01 = 1% = 100% confidence
|
|
else:
|
|
# It's already in percentage form (e.g., 0.1 = 0.1%)
|
|
confidence = min(abs(price_change_pct) / 1.0, 1.0) # Normalize: 1% = 100% confidence
|
|
|
|
# Debug: Print first few predictions (only for debugging)
|
|
if len(self.historical_bars) % 100 == 0:
|
|
predicted_price_val = current_price * (1 + price_change_pct) if abs(price_change_pct) < 1.0 else current_price * (1 + price_change_pct / 100)
|
|
print(f" Debug - Bar {len(self.historical_bars)}, Price: {current_price:.2f}, "
|
|
f"Predicted Change: {price_change_pct*100:.4f}%, Abs: {abs(price_change_pct):.6f}, "
|
|
f"Confidence: {confidence:.3f}, Threshold: {self.prediction_threshold:.6f}, "
|
|
f"MinConf: {self.min_confidence:.2f}, WillTrade: {abs(price_change_pct) >= self.prediction_threshold and confidence >= self.min_confidence}")
|
|
|
|
# Check if we should trade
|
|
if confidence < self.min_confidence:
|
|
return
|
|
|
|
if abs(price_change_pct) < self.prediction_threshold:
|
|
return
|
|
|
|
# Open position based on prediction
|
|
if price_change_pct > self.prediction_threshold:
|
|
# Bullish prediction
|
|
sl = current_price - (self.stop_loss_pips / 10000) if self.stop_loss_pips > 0 else None
|
|
tp = current_price + (self.take_profit_pips / 10000) if self.take_profit_pips > 0 else None
|
|
self.open_position('BUY', self.lot_size, current_price, sl, tp, 'ONNX Buy')
|
|
|
|
elif price_change_pct < -self.prediction_threshold:
|
|
# Bearish prediction
|
|
sl = current_price + (self.stop_loss_pips / 10000) if self.stop_loss_pips > 0 else None
|
|
tp = current_price - (self.take_profit_pips / 10000) if self.take_profit_pips > 0 else None
|
|
self.open_position('SELL', self.lot_size, current_price, sl, tp, 'ONNX Sell')
|
|
|
|
def get_parameters(self) -> Dict[str, Any]:
|
|
"""Return strategy parameters."""
|
|
return {
|
|
'model_path': self.model_path,
|
|
'lookback': self.lookback,
|
|
'prediction_threshold': self.prediction_threshold,
|
|
'min_confidence': self.min_confidence,
|
|
'lot_size': self.lot_size,
|
|
'stop_loss_pips': self.stop_loss_pips,
|
|
'take_profit_pips': self.take_profit_pips
|
|
}
|