210 lines
6.9 KiB
Python
210 lines
6.9 KiB
Python
"""
|
||
XAUUSD H1 — ONNX action model (buy / sell short / close long / close short / hold).
|
||
|
||
Same 24 features as M15 stack; labels use H1-scaled horizons (~wall-clock parity with M15).
|
||
Row order matches XAUUSD_H1_ActionEA.mq5 (row 0 = newest bar).
|
||
Data: MT5, 2008–2026 (limited by downloaded history).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import pickle
|
||
import sys
|
||
from datetime import datetime, timedelta
|
||
|
||
import MetaTrader5 as mt5
|
||
import numpy as np
|
||
import pandas as pd
|
||
import tensorflow as tf
|
||
from sklearn.model_selection import train_test_split
|
||
from sklearn.preprocessing import MinMaxScaler
|
||
from tensorflow import keras
|
||
from tensorflow.keras import layers
|
||
from tqdm import tqdm
|
||
import tf2onnx
|
||
import onnx
|
||
|
||
from labeling import class_weights, compute_action_labels
|
||
from features import NUM_FEATURES, prepare_features_full
|
||
|
||
NUM_CLASSES = 5
|
||
CLASS_NAMES = ["HOLD", "BUY", "SELL_SHORT", "CLOSE_LONG", "CLOSE_SHORT"]
|
||
|
||
|
||
def fetch_mt5_range(
|
||
symbol: str,
|
||
timeframe: int,
|
||
start_date: datetime,
|
||
end_date: datetime,
|
||
) -> pd.DataFrame:
|
||
if not mt5.initialize():
|
||
raise RuntimeError(f"MT5 init failed: {mt5.last_error()}")
|
||
|
||
info = mt5.symbol_info(symbol)
|
||
if info is None:
|
||
mt5.shutdown()
|
||
raise ValueError(f"Symbol {symbol} not found")
|
||
if not info.visible and not mt5.symbol_select(symbol, True):
|
||
mt5.shutdown()
|
||
raise ValueError(f"Cannot select {symbol}")
|
||
|
||
all_rows: list[dict] = []
|
||
chunk_days = 120
|
||
cur = start_date
|
||
while cur < end_date:
|
||
chunk_end = min(cur + timedelta(days=chunk_days), end_date)
|
||
rates = mt5.copy_rates_range(symbol, timeframe, cur, chunk_end)
|
||
if rates is not None and len(rates) > 1:
|
||
for row in rates:
|
||
all_rows.append({n: row[n] for n in rates.dtype.names})
|
||
cur = chunk_end
|
||
|
||
if not all_rows:
|
||
mt5.shutdown()
|
||
raise ValueError("No rates returned — download XAUUSD H1 in MT5 History Center")
|
||
|
||
df = pd.DataFrame(all_rows)
|
||
df["time"] = pd.to_datetime(df["time"], unit="s")
|
||
df = df.set_index("time").sort_index()
|
||
df = df[~df.index.duplicated(keep="first")]
|
||
return df
|
||
|
||
|
||
def create_sequences(
|
||
X: np.ndarray, y: np.ndarray, lookback: int
|
||
) -> tuple[np.ndarray, np.ndarray]:
|
||
xs, ys = [], []
|
||
for i in tqdm(range(lookback - 1, len(X)), desc="sequences"):
|
||
window = X[i - lookback + 1 : i + 1].copy()
|
||
window = window[::-1]
|
||
xs.append(window)
|
||
ys.append(y[i])
|
||
return np.asarray(xs, dtype=np.float32), np.asarray(ys, dtype=np.int64)
|
||
|
||
|
||
def build_model(lookback: int, n_feat: int) -> keras.Model:
|
||
inp = layers.Input(shape=(lookback, n_feat))
|
||
x = layers.LSTM(96, return_sequences=True)(inp)
|
||
x = layers.Dropout(0.25)(x)
|
||
x = layers.LSTM(48)(x)
|
||
x = layers.Dropout(0.25)(x)
|
||
x = layers.Dense(32, activation="relu")(x)
|
||
out = layers.Dense(NUM_CLASSES, activation="softmax", name="action_probs")(x)
|
||
model = keras.Model(inp, out)
|
||
model.compile(
|
||
optimizer=keras.optimizers.Adam(1e-3),
|
||
loss="sparse_categorical_crossentropy",
|
||
metrics=["accuracy"],
|
||
)
|
||
return model
|
||
|
||
|
||
def main() -> int:
|
||
symbol = os.environ.get("XAU_SYMBOL", "XAUUSD")
|
||
lookback = int(os.environ.get("XAU_H1_LOOKBACK", os.environ.get("XAU_LOOKBACK", "48")))
|
||
epochs = int(os.environ.get("XAU_EPOCHS", "40"))
|
||
batch_size = int(os.environ.get("XAU_BATCH", "64"))
|
||
|
||
start_date = datetime(2008, 1, 1)
|
||
end_date = datetime(2026, 12, 31)
|
||
|
||
out_dir = os.path.join(os.path.dirname(__file__), "models")
|
||
os.makedirs(out_dir, exist_ok=True)
|
||
onnx_path = os.path.join(out_dir, f"{symbol}_H1_action.onnx")
|
||
meta_path = os.path.join(out_dir, f"{symbol}_H1_action_meta.json")
|
||
|
||
print("Fetching MT5 H1 data …")
|
||
try:
|
||
raw = fetch_mt5_range(symbol, mt5.TIMEFRAME_H1, start_date, end_date)
|
||
finally:
|
||
mt5.shutdown()
|
||
print(f"Bars: {len(raw)} range: {raw.index[0]} → {raw.index[-1]}")
|
||
|
||
feat = prepare_features_full(raw)
|
||
labels_full = compute_action_labels(raw)
|
||
labels = labels_full.loc[feat.index]
|
||
|
||
y = labels.loc[feat.index].values.astype(np.int64)
|
||
X_raw = feat.values.astype(np.float32)
|
||
|
||
valid = np.isfinite(X_raw).all(axis=1) & (y >= 0) & (y < NUM_CLASSES)
|
||
X_raw = X_raw[valid]
|
||
y = y[valid]
|
||
|
||
print("Label counts:", {CLASS_NAMES[i]: int((y == i).sum()) for i in range(NUM_CLASSES)})
|
||
|
||
scaler = MinMaxScaler()
|
||
Xn = scaler.fit_transform(X_raw).astype(np.float32)
|
||
|
||
X_seq, y_seq = create_sequences(Xn, y, lookback)
|
||
if len(X_seq) < 500:
|
||
print("ERROR: Too few sequences — need more H1 history in MT5.")
|
||
return 1
|
||
|
||
X_train, X_val, y_train, y_val = train_test_split(
|
||
X_seq, y_seq, test_size=0.15, shuffle=False
|
||
)
|
||
|
||
cw = class_weights(y_train, NUM_CLASSES)
|
||
sample_w = np.array([cw[int(c)] for c in y_train], dtype=np.float32)
|
||
|
||
model = build_model(lookback, NUM_FEATURES)
|
||
model.summary()
|
||
|
||
model.fit(
|
||
X_train,
|
||
y_train,
|
||
sample_weight=sample_w,
|
||
validation_data=(X_val, y_val),
|
||
epochs=epochs,
|
||
batch_size=batch_size,
|
||
verbose=1,
|
||
callbacks=[
|
||
keras.callbacks.EarlyStopping(
|
||
monitor="val_loss", patience=8, restore_best_weights=True
|
||
),
|
||
keras.callbacks.ReduceLROnPlateau(
|
||
monitor="val_loss", factor=0.5, patience=4, min_lr=1e-6
|
||
),
|
||
],
|
||
)
|
||
|
||
spec = (tf.TensorSpec((None, lookback, NUM_FEATURES), tf.float32, name="input"),)
|
||
onnx_m, _ = tf2onnx.convert.from_keras(model, input_signature=spec, opset=13)
|
||
onnx.save_model(onnx_m, onnx_path)
|
||
|
||
with open(onnx_path.replace(".onnx", "_scaler.pkl"), "wb") as f:
|
||
pickle.dump(scaler, f)
|
||
|
||
meta = {
|
||
"symbol": symbol,
|
||
"timeframe": "H1",
|
||
"lookback": lookback,
|
||
"num_features": int(NUM_FEATURES),
|
||
"feature_columns": feat.columns.tolist(),
|
||
"num_classes": NUM_CLASSES,
|
||
"class_names": CLASS_NAMES,
|
||
"label_horizon_bars": 8,
|
||
"label_note": "H1 labeling defaults: horizon=8, local=6, pullback=5 (~M15 wall-clock parity)",
|
||
"scaler_feature_min": scaler.data_min_.tolist(),
|
||
"scaler_feature_max": scaler.data_max_.tolist(),
|
||
"scaler_scale": scaler.scale_.tolist() if hasattr(scaler, "scale_") else None,
|
||
"notes": "MinMax in EA; row0=newest. Match EA InpLookback to lookback here.",
|
||
}
|
||
with open(meta_path, "w", encoding="utf-8") as f:
|
||
json.dump(meta, f, indent=2)
|
||
|
||
print(f"Saved: {onnx_path}")
|
||
print(f"Meta: {meta_path}")
|
||
print("\n--- Paste into EA InpFeatMinStr / InpFeatMaxStr (comma-separated, %d floats each) ---" % NUM_FEATURES)
|
||
print(",".join(f"{x:.8g}" for x in scaler.data_min_))
|
||
print(",".join(f"{x:.8g}" for x in scaler.data_max_))
|
||
print(f"\nSet EA InpLookback = {lookback} (must match ONNX input dim).")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|