""" EURUSD H1 — same stack as ai/yt (24 features, 5-class softmax, optional KMeans weights). MinMaxScaler is fit on **all** feature rows returned by MT5 (full downloaded history). A chronological **tail** slice (default 12%% of sequences) is used only for val_loss / EarlyStopping; all earlier sequences are used for training. """ from __future__ import annotations import json import os import pickle import sys from datetime import datetime, timedelta from pathlib import Path import MetaTrader5 as mt5 import numpy as np import pandas as pd import tensorflow as tf import tf2onnx import onnx from sklearn.cluster import KMeans from sklearn.preprocessing import MinMaxScaler from tensorflow import keras from tensorflow.keras import layers from tqdm import tqdm _XH1 = Path(__file__).resolve().parent.parent / "xauusd_h1" sys.path.insert(0, str(_XH1)) from features import NUM_FEATURES, prepare_features_full # noqa: E402 from labeling import atr_series, class_weights, compute_action_labels # noqa: E402 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 EURUSD 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 forward_return_fingerprints( df: pd.DataFrame, feat_index: pd.DatetimeIndex, horizons: tuple[int, ...] = (1, 2, 4, 8, 16), ) -> tuple[np.ndarray, np.ndarray]: close = df["close"].to_numpy(dtype=np.float64) atr = atr_series(df, 14).to_numpy(dtype=np.float64) pos = df.index.get_indexer(feat_index) n = len(feat_index) d = len(horizons) M = np.zeros((n, d), dtype=np.float64) valid = np.ones(n, dtype=bool) max_h = max(horizons) for j, i in enumerate(pos): if i < 0 or i + max_h >= len(close): valid[j] = False continue a = float(atr[i]) if np.isfinite(atr[i]) and atr[i] > 0 else close[i] * 1e-4 for k, h in enumerate(horizons): if i + h >= len(close): valid[j] = False break M[j, k] = (close[i + h] - close[i]) / a return M, valid def create_sequences( X: np.ndarray, y: np.ndarray, times: np.ndarray, lookback: int, ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: xs, ys, t_end = [], [], [] 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]) t_end.append(times[i]) return ( np.asarray(xs, dtype=np.float32), np.asarray(ys, dtype=np.int64), np.asarray(t_end), ) 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("EUR_SYMBOL", "EURUSD") lookback = int(os.environ.get("EUR_LOOKBACK", "48")) epochs = int(os.environ.get("EUR_EPOCHS", "40")) batch_size = int(os.environ.get("EUR_BATCH", "64")) n_clusters = int(os.environ.get("EUR_CLUSTERS", "12")) val_frac = float(os.environ.get("EUR_VAL_FRAC", "0.12")) val_frac = min(max(val_frac, 0.05), 0.35) fetch_start = datetime(1990, 1, 1) fetch_end = datetime(2030, 12, 31) out_dir = Path(__file__).resolve().parent / "models" out_dir.mkdir(parents=True, exist_ok=True) onnx_path = out_dir / f"{symbol}_H1_action.onnx" meta_path = out_dir / f"{symbol}_H1_action_meta.json" print(f"Symbol={symbol} H1 | fetch [{fetch_start.date()} .. {fetch_end.date()}]") print("Scaler: ALL bars | Val: chronological tail for early stopping only") print("Fetching MT5 …") try: raw = fetch_mt5_range(symbol, mt5.TIMEFRAME_H1, fetch_start, fetch_end) finally: mt5.shutdown() if len(raw) < 500: print("ERROR: Not enough H1 bars — check EURUSD history in MT5.") return 1 print(f"Bars: {len(raw)} range: {raw.index[0]} → {raw.index[-1]}") feat = prepare_features_full(raw) labels = compute_action_labels(raw).loc[feat.index] y = labels.values.astype(np.int64) X_raw = feat.values.astype(np.float32) times = feat.index.to_numpy() valid = np.isfinite(X_raw).all(axis=1) & (y >= 0) & (y < NUM_CLASSES) X_raw = X_raw[valid] y = y[valid] times = times[valid] scaler = MinMaxScaler() scaler.fit(X_raw) Xn = scaler.transform(X_raw).astype(np.float32) X_seq, y_seq, t_end = create_sequences(Xn, y, times, lookback) if len(X_seq) < 500: print("ERROR: Too few sequences.") return 1 n_seq = len(X_seq) split_i = int(n_seq * (1.0 - val_frac)) split_i = max(split_i, lookback + 100) split_i = min(split_i, n_seq - 200) train_m = np.zeros(n_seq, dtype=bool) train_m[:split_i] = True val_m = ~train_m X_train, y_train = X_seq[train_m], y_seq[train_m] X_val, y_val = X_seq[val_m], y_seq[val_m] print( f"Sequences train={len(X_train)} val_tail={len(X_val)} ({100*val_frac:.1f}%%) " f"lookback={lookback}" ) cw = class_weights(y_train, NUM_CLASSES) tr_idx = np.flatnonzero(train_m) base_w = np.array([cw[int(y_seq[i])] for i in tr_idx], dtype=np.float32) sample_w = base_w.copy() if n_clusters > 1: fp, fp_ok = forward_return_fingerprints(raw, pd.DatetimeIndex(times)) fp_seq = fp[lookback - 1 :] ok_seq = fp_ok[lookback - 1 :] fp_tr = fp_seq[tr_idx] ok_tr = ok_seq[tr_idx] fit_mask = ok_tr & np.isfinite(fp_tr).all(axis=1) if int(fit_mask.sum()) >= n_clusters * 5: km = KMeans(n_clusters=n_clusters, random_state=42, n_init=10) km.fit(fp_tr[fit_mask]) labels_tr = np.full(len(tr_idx), -1, dtype=np.int32) labels_tr[fit_mask] = km.predict(fp_tr[fit_mask]) counts = np.zeros(n_clusters, dtype=np.float64) for c in labels_tr: if 0 <= c < n_clusters: counts[c] += 1.0 counts = np.maximum(counts, 1.0) total_assigned = max(int((labels_tr >= 0).sum()), 1) w_cl = np.ones(len(tr_idx), dtype=np.float32) for j in range(len(tr_idx)): c = int(labels_tr[j]) if c >= 0: w_cl[j] = float(total_assigned / (n_clusters * counts[c])) sample_w = base_w * w_cl sample_w *= len(sample_w) / float(np.sum(sample_w)) print(f"KMeans clusters={n_clusters} (train subset only)") else: print("Skipping KMeans: not enough valid fingerprints.") 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=10, restore_best_weights=True ), keras.callbacks.ReduceLROnPlateau( monitor="val_loss", factor=0.5, patience=4, min_lr=1e-6 ), ], ) loss, acc = model.evaluate(X_val, y_val, verbose=0) print(f"Tail val_loss={loss:.4f} val_accuracy={acc:.4f}") 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, str(onnx_path)) with open(str(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, "mt5_bar_range": [str(raw.index[0]), str(raw.index[-1])], "scaler_fit_on": "all_valid_feature_rows_full_mt5_range", "validation_split": { "mode": "chronological_tail_fraction", "val_fraction": val_frac, "train_sequences": int(train_m.sum()), "val_sequences": int(val_m.sum()), }, "clustering": ( f"KMeans n={n_clusters} on forward returns (1,2,4,8,16); train-only fit" if n_clusters > 1 else "disabled" ), "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, "tail_val_accuracy": float(acc), "tail_val_loss": float(loss), "ea_note": "Copy ai/yt/US500_H1_ArticleEA.mq5 pattern: #resource ONNX + paste scaler from meta.", } 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 (%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}") return 0 if __name__ == "__main__": sys.exit(main())