Update
This commit is contained in:
@@ -10,6 +10,7 @@ import MetaTrader5 as mt5
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from base_strategy import BaseStrategy
|
||||
from indicator_utils import calculate_rsi, calculate_ema, calculate_sma, calculate_atr, calculate_macd
|
||||
|
||||
|
||||
class BacktestEngine:
|
||||
@@ -34,91 +35,84 @@ class BacktestEngine:
|
||||
if not mt5.initialize():
|
||||
raise RuntimeError(f"MT5 initialization failed: {mt5.last_error()}")
|
||||
|
||||
# Indicator handles
|
||||
self.indicator_handles = {}
|
||||
self.setup_indicators()
|
||||
# Store required indicators config (we'll calculate them from data)
|
||||
self.required_indicators = self.strategy.get_required_indicators()
|
||||
|
||||
def setup_indicators(self):
|
||||
"""Setup all required indicators for the strategy."""
|
||||
required_indicators = self.strategy.get_required_indicators()
|
||||
# Pre-calculate indicators from historical data
|
||||
self.indicator_data = {}
|
||||
self._precalculate_indicators()
|
||||
|
||||
for indicator_name, params in required_indicators.items():
|
||||
handle = None
|
||||
|
||||
def _precalculate_indicators(self):
|
||||
"""Pre-calculate all indicators from historical data."""
|
||||
# Fetch all historical data first
|
||||
rates = mt5.copy_rates_range(
|
||||
self.strategy.symbol,
|
||||
self.strategy.timeframe,
|
||||
self.start_date - timedelta(days=100), # Extra data for indicator calculation
|
||||
self.end_date
|
||||
)
|
||||
|
||||
if rates is None or len(rates) == 0:
|
||||
print("Warning: Could not fetch historical data for indicators")
|
||||
return
|
||||
|
||||
# Convert to DataFrame
|
||||
df = pd.DataFrame(rates)
|
||||
df['time'] = pd.to_datetime(df['time'], unit='s')
|
||||
df.set_index('time', inplace=True)
|
||||
|
||||
# Calculate indicators
|
||||
for indicator_name, params in self.required_indicators.items():
|
||||
if indicator_name.lower() == 'rsi':
|
||||
handle = mt5.iRSI(
|
||||
self.strategy.symbol,
|
||||
self.strategy.timeframe,
|
||||
params.get('period', 14),
|
||||
params.get('applied_price', mt5.PRICE_CLOSE)
|
||||
)
|
||||
period = params.get('period', 14)
|
||||
self.indicator_data['rsi'] = calculate_rsi(df['close'], period)
|
||||
elif indicator_name.lower() == 'ema':
|
||||
handle = mt5.iMA(
|
||||
self.strategy.symbol,
|
||||
self.strategy.timeframe,
|
||||
params.get('period', 50),
|
||||
0, # shift
|
||||
mt5.MODE_EMA,
|
||||
params.get('applied_price', mt5.PRICE_CLOSE)
|
||||
)
|
||||
period = params.get('period', 50)
|
||||
self.indicator_data['ema'] = calculate_ema(df['close'], period)
|
||||
elif indicator_name.lower() == 'sma':
|
||||
handle = mt5.iMA(
|
||||
self.strategy.symbol,
|
||||
self.strategy.timeframe,
|
||||
params.get('period', 50),
|
||||
0, # shift
|
||||
mt5.MODE_SMA,
|
||||
params.get('applied_price', mt5.PRICE_CLOSE)
|
||||
)
|
||||
period = params.get('period', 50)
|
||||
self.indicator_data['sma'] = calculate_sma(df['close'], period)
|
||||
elif indicator_name.lower() == 'atr':
|
||||
handle = mt5.iATR(
|
||||
self.strategy.symbol,
|
||||
self.strategy.timeframe,
|
||||
params.get('period', 14)
|
||||
)
|
||||
period = params.get('period', 14)
|
||||
self.indicator_data['atr'] = calculate_atr(df, period)
|
||||
elif indicator_name.lower() == 'macd':
|
||||
handle = mt5.iMACD(
|
||||
self.strategy.symbol,
|
||||
self.strategy.timeframe,
|
||||
params.get('fast', 12),
|
||||
params.get('slow', 26),
|
||||
params.get('signal', 9),
|
||||
params.get('applied_price', mt5.PRICE_CLOSE)
|
||||
)
|
||||
|
||||
if handle is not None and handle != mt5.INVALID_HANDLE:
|
||||
self.indicator_handles[indicator_name] = handle
|
||||
else:
|
||||
print(f"Warning: Failed to create {indicator_name} indicator")
|
||||
fast = params.get('fast', 12)
|
||||
slow = params.get('slow', 26)
|
||||
signal = params.get('signal', 9)
|
||||
macd_df = calculate_macd(df['close'], fast, slow, signal)
|
||||
self.indicator_data['macd'] = macd_df['macd']
|
||||
self.indicator_data['macd_signal'] = macd_df['signal']
|
||||
self.indicator_data['macd_histogram'] = macd_df['histogram']
|
||||
|
||||
def get_indicator_values(self, indicator_name: str, count: int = 1) -> Optional[np.ndarray]:
|
||||
def get_indicator_value(self, indicator_name: str, time: datetime) -> Optional[float]:
|
||||
"""
|
||||
Get indicator values.
|
||||
Get indicator value for a specific time.
|
||||
|
||||
Args:
|
||||
indicator_name: Name of the indicator
|
||||
count: Number of values to retrieve
|
||||
time: Bar time
|
||||
|
||||
Returns:
|
||||
Array of indicator values or None
|
||||
Indicator value or None
|
||||
"""
|
||||
if indicator_name not in self.indicator_handles:
|
||||
if indicator_name.lower() not in self.indicator_data:
|
||||
return None
|
||||
|
||||
handle = self.indicator_handles[indicator_name]
|
||||
buffer = np.zeros(count, dtype=float)
|
||||
series = self.indicator_data[indicator_name.lower()]
|
||||
if time in series.index:
|
||||
value = series.loc[time]
|
||||
return float(value) if not pd.isna(value) else None
|
||||
|
||||
if indicator_name.lower() == 'macd':
|
||||
# MACD returns 3 buffers
|
||||
result = mt5.copy_buffer(handle, 0, 0, count) # Main line
|
||||
if result is None:
|
||||
return None
|
||||
return np.array(result)
|
||||
else:
|
||||
result = mt5.copy_buffer(handle, 0, 0, count)
|
||||
if result is None:
|
||||
return None
|
||||
return np.array(result)
|
||||
# Try to find closest time
|
||||
try:
|
||||
closest_time = series.index[series.index <= time][-1] if len(series.index[series.index <= time]) > 0 else None
|
||||
if closest_time:
|
||||
value = series.loc[closest_time]
|
||||
return float(value) if not pd.isna(value) else None
|
||||
except:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def get_bar_data(self, time: datetime) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
@@ -160,12 +154,12 @@ class BacktestEngine:
|
||||
}
|
||||
|
||||
# Get indicator values
|
||||
for indicator_name in self.indicator_handles.keys():
|
||||
values = self.get_indicator_values(indicator_name, 2)
|
||||
if values is not None and len(values) >= 1:
|
||||
bar_data['indicators'][indicator_name] = values[0]
|
||||
for indicator_name in self.required_indicators.keys():
|
||||
value = self.get_indicator_value(indicator_name, bar_data['time'])
|
||||
if value is not None:
|
||||
bar_data['indicators'][indicator_name] = value
|
||||
# Also add to top level for convenience
|
||||
bar_data[indicator_name.lower()] = values[0]
|
||||
bar_data[indicator_name.lower()] = value
|
||||
|
||||
return bar_data
|
||||
|
||||
@@ -251,7 +245,5 @@ class BacktestEngine:
|
||||
}
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up indicator handles and MT5 connection."""
|
||||
for handle in self.indicator_handles.values():
|
||||
mt5.indicator_release(handle)
|
||||
"""Clean up MT5 connection."""
|
||||
mt5.shutdown()
|
||||
|
||||
@@ -120,9 +120,17 @@ class BaseStrategy(ABC):
|
||||
# Validate volume
|
||||
volume = max(self.min_lot_size, min(volume, self.max_lot_size))
|
||||
|
||||
# Calculate margin requirement (simplified)
|
||||
contract_size = 100000 # Standard lot size
|
||||
margin_required = volume * contract_size * price * 0.01 # 1% margin (adjust as needed)
|
||||
# Calculate margin requirement
|
||||
# For XAUUSD (Gold): 1 lot = 100 oz, typical margin 1-2% of contract value
|
||||
# For Forex pairs: 1 lot = 100,000 units, typical margin 1-2%
|
||||
if 'XAU' in self.symbol or 'GOLD' in self.symbol:
|
||||
contract_size = 100 # 1 lot = 100 oz for gold
|
||||
margin_percent = 0.02 # 2% margin for gold (more volatile)
|
||||
else:
|
||||
contract_size = 100000 # Standard forex lot size
|
||||
margin_percent = 0.01 # 1% margin for forex
|
||||
|
||||
margin_required = volume * contract_size * price * margin_percent
|
||||
|
||||
if margin_required > self.equity * 0.9: # Don't use more than 90% of equity
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
Indicator calculation utilities for backtesting.
|
||||
|
||||
These functions calculate indicators directly from price data,
|
||||
without requiring MT5 indicator handles.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def calculate_rsi(prices: pd.Series, period: int = 14) -> pd.Series:
|
||||
"""Calculate RSI indicator."""
|
||||
delta = prices.diff()
|
||||
gain = (delta.where(delta > 0, 0)).rolling(window=period).mean()
|
||||
loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean()
|
||||
rs = gain / loss
|
||||
rsi = 100 - (100 / (1 + rs))
|
||||
return rsi
|
||||
|
||||
|
||||
def calculate_ema(prices: pd.Series, period: int = 50) -> pd.Series:
|
||||
"""Calculate EMA indicator."""
|
||||
return prices.ewm(span=period, adjust=False).mean()
|
||||
|
||||
|
||||
def calculate_sma(prices: pd.Series, period: int = 50) -> pd.Series:
|
||||
"""Calculate SMA indicator."""
|
||||
return prices.rolling(window=period).mean()
|
||||
|
||||
|
||||
def calculate_atr(df: pd.DataFrame, period: int = 14) -> pd.Series:
|
||||
"""Calculate ATR indicator."""
|
||||
high_low = df['high'] - df['low']
|
||||
high_close = np.abs(df['high'] - df['close'].shift())
|
||||
low_close = np.abs(df['low'] - df['close'].shift())
|
||||
tr = pd.concat([high_low, high_close, low_close], axis=1).max(axis=1)
|
||||
atr = tr.rolling(window=period).mean()
|
||||
return atr
|
||||
|
||||
|
||||
def calculate_macd(prices: pd.Series, fast: int = 12, slow: int = 26, signal: int = 9) -> pd.DataFrame:
|
||||
"""Calculate MACD indicator."""
|
||||
ema_fast = prices.ewm(span=fast, adjust=False).mean()
|
||||
ema_slow = prices.ewm(span=slow, adjust=False).mean()
|
||||
macd = ema_fast - ema_slow
|
||||
signal_line = macd.ewm(span=signal, adjust=False).mean()
|
||||
histogram = macd - signal_line
|
||||
|
||||
return pd.DataFrame({
|
||||
'macd': macd,
|
||||
'signal': signal_line,
|
||||
'histogram': histogram
|
||||
})
|
||||
@@ -0,0 +1,274 @@
|
||||
"""
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
"""
|
||||
ONNX-based Trading Strategy for Backtesting
|
||||
|
||||
This strategy uses a trained ONNX model to make price predictions and trade based on those predictions.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional
|
||||
import numpy as np
|
||||
import MetaTrader5 as mt5
|
||||
import onnxruntime as ort
|
||||
import pickle
|
||||
import os
|
||||
from base_strategy import BaseStrategy
|
||||
|
||||
|
||||
class ONNXStrategy(BaseStrategy):
|
||||
"""
|
||||
Trading strategy that uses ONNX model predictions for trading decisions.
|
||||
"""
|
||||
|
||||
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 strategy.
|
||||
|
||||
Args:
|
||||
symbol: Trading symbol
|
||||
timeframe: MT5 timeframe
|
||||
model_path: Path to ONNX model file
|
||||
scaler_path: Path to saved scaler (optional)
|
||||
initial_balance: Starting balance
|
||||
prediction_threshold: Minimum price change % to trade (0.0001 = 0.01%)
|
||||
min_confidence: Minimum confidence level (0.0-1.0)
|
||||
lot_size: Position size
|
||||
stop_loss_pips: Stop loss in pips
|
||||
take_profit_pips: Take profit in pips
|
||||
"""
|
||||
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 from model shape
|
||||
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
|
||||
print("Warning: No scaler provided. Will use default normalization.")
|
||||
|
||||
# Track previous prediction for comparison
|
||||
self.prev_prediction = None
|
||||
self.prev_price = None
|
||||
|
||||
def get_required_indicators(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""ONNX model doesn't use traditional indicators, but we need RSI, EMA, ATR for features."""
|
||||
return {
|
||||
'rsi': {'period': 14, 'applied_price': mt5.PRICE_CLOSE},
|
||||
'ema': {'period': 50, 'applied_price': mt5.PRICE_CLOSE},
|
||||
'atr': {'period': 14}
|
||||
}
|
||||
|
||||
def prepare_features(self, bar_data: Dict[str, Any], historical_bars: list) -> np.ndarray:
|
||||
"""
|
||||
Prepare features for ONNX model input.
|
||||
|
||||
Args:
|
||||
bar_data: Current bar data
|
||||
historical_bars: List of historical bar data dictionaries
|
||||
|
||||
Returns:
|
||||
Prepared feature array
|
||||
"""
|
||||
features = []
|
||||
|
||||
for bar in historical_bars[-self.lookback:]:
|
||||
feature_row = []
|
||||
|
||||
# OHLC
|
||||
feature_row.append(bar['open'])
|
||||
feature_row.append(bar['high'])
|
||||
feature_row.append(bar['low'])
|
||||
feature_row.append(bar['close'])
|
||||
|
||||
# Volume (normalized)
|
||||
feature_row.append(bar.get('tick_volume', 0) / 1000000.0)
|
||||
|
||||
# RSI (if available)
|
||||
rsi = bar.get('rsi', 50.0)
|
||||
feature_row.append(rsi / 100.0)
|
||||
|
||||
# EMA (if available)
|
||||
ema = bar.get('ema', bar['close'])
|
||||
feature_row.append((ema - bar['close']) / bar['close'])
|
||||
|
||||
# ATR (if available)
|
||||
atr = bar.get('atr', 0.0)
|
||||
feature_row.append(atr / bar['close'])
|
||||
|
||||
# Price change
|
||||
if len(features) > 0:
|
||||
prev_close = historical_bars[historical_bars.index(bar) - 1]['close']
|
||||
price_change = (bar['close'] - prev_close) / prev_close
|
||||
else:
|
||||
price_change = 0.0
|
||||
feature_row.append(price_change)
|
||||
|
||||
# High/Low ratio
|
||||
feature_row.append(bar['high'] / bar['low'])
|
||||
|
||||
# Volume ratio (simplified)
|
||||
if len(features) > 0:
|
||||
prev_volume = historical_bars[historical_bars.index(bar) - 1].get('tick_volume', 1)
|
||||
volume_ratio = bar.get('tick_volume', 1) / max(prev_volume, 1)
|
||||
else:
|
||||
volume_ratio = 1.0
|
||||
feature_row.append(volume_ratio)
|
||||
|
||||
features.append(feature_row)
|
||||
|
||||
# Pad if needed
|
||||
while len(features) < self.lookback:
|
||||
features.insert(0, features[0] if features else [0.0] * 12)
|
||||
|
||||
features = np.array(features[-self.lookback:], dtype=np.float32)
|
||||
|
||||
# Normalize if scaler available
|
||||
if self.scaler is not None:
|
||||
# Reshape for scaler (flatten, scale, reshape)
|
||||
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
|
||||
features = (features - features.mean(axis=0)) / (features.std(axis=0) + 1e-8)
|
||||
|
||||
# Reshape for model: (1, lookback, features)
|
||||
features = features.reshape(1, self.lookback, -1)
|
||||
|
||||
return features
|
||||
|
||||
def predict_price(self, bar_data: Dict[str, Any], historical_bars: list) -> float:
|
||||
"""
|
||||
Make price prediction using ONNX model.
|
||||
|
||||
Args:
|
||||
bar_data: Current bar data
|
||||
historical_bars: Historical bar data
|
||||
|
||||
Returns:
|
||||
Predicted price
|
||||
"""
|
||||
# Prepare input
|
||||
input_data = self.prepare_features(bar_data, historical_bars)
|
||||
|
||||
# Run model
|
||||
outputs = self.session.run([self.output_name], {self.input_name: input_data})
|
||||
prediction = outputs[0][0][0]
|
||||
|
||||
return float(prediction)
|
||||
|
||||
def on_bar(self, bar_data: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Trading logic based on ONNX predictions.
|
||||
"""
|
||||
current_price = bar_data['close']
|
||||
|
||||
# We need historical bars for prediction
|
||||
# For now, we'll use a simplified approach
|
||||
# In a real implementation, you'd maintain a buffer of historical bars
|
||||
|
||||
# Check if we have a position
|
||||
if self.position is not None:
|
||||
# Check stop loss/take profit
|
||||
self.check_stop_loss_take_profit(current_price)
|
||||
return
|
||||
|
||||
# For backtesting, we need to get historical data
|
||||
# This is a simplified version - in practice, you'd maintain a buffer
|
||||
# For now, we'll skip prediction if we don't have enough data
|
||||
# The backtest engine should provide historical context
|
||||
|
||||
# Simple prediction-based logic (simplified for backtesting)
|
||||
# In production, use the full ONNX prediction pipeline
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user