316 lines
11 KiB
Python
316 lines
11 KiB
Python
|
|
"""
|
|||
|
|
Article-style training: chronological train (2010–2019) vs OOS (2020–2024),
|
|||
|
|
optional KMeans on forward-return fingerprints (trade-shape clustering),
|
|||
|
|
ONNX export compatible with the repo's MT5 ONNX EAs.
|
|||
|
|
|
|||
|
|
Reuses feature + label definitions from ai/xauusd_h1 (24 features, 5 classes).
|
|||
|
|
ENKS conversion is out of scope — use the article's tooling after ONNX if needed.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
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
|
|||
|
|
|
|||
|
|
# Reuse XAUUSD H1 stack (same 24 dims / 5 classes as published EAs).
|
|||
|
|
_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"]
|
|||
|
|
|
|||
|
|
TRAIN_START = pd.Timestamp("2010-01-01")
|
|||
|
|
TRAIN_END = pd.Timestamp("2020-01-01") # exclusive: train < this
|
|||
|
|
OOS_END = pd.Timestamp("2025-01-01") # exclusive: val < this (covers through 2024)
|
|||
|
|
|
|||
|
|
|
|||
|
|
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 symbol history in MT5")
|
|||
|
|
|
|||
|
|
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]:
|
|||
|
|
"""Normalized forward returns at listed horizons; aligned to feat rows."""
|
|||
|
|
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("YT_SYMBOL", "US500")
|
|||
|
|
lookback = int(os.environ.get("YT_LOOKBACK", "48"))
|
|||
|
|
epochs = int(os.environ.get("YT_EPOCHS", "40"))
|
|||
|
|
batch_size = int(os.environ.get("YT_BATCH", "64"))
|
|||
|
|
n_clusters = int(os.environ.get("YT_CLUSTERS", "12"))
|
|||
|
|
|
|||
|
|
fetch_start = datetime(2009, 6, 1)
|
|||
|
|
fetch_end = datetime(2025, 1, 1)
|
|||
|
|
|
|||
|
|
out_dir = Path(__file__).resolve().parent / "models"
|
|||
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|||
|
|
onnx_path = out_dir / f"{symbol}_H1_article_split.onnx"
|
|||
|
|
meta_path = out_dir / f"{symbol}_H1_article_split_meta.json"
|
|||
|
|
|
|||
|
|
print(f"Symbol={symbol} H1 | train [{TRAIN_START.date()} , {TRAIN_END.date()}) | OOS [{TRAIN_END.date()} , {OOS_END.date()})")
|
|||
|
|
print("Fetching MT5 …")
|
|||
|
|
try:
|
|||
|
|
raw = fetch_mt5_range(symbol, mt5.TIMEFRAME_H1, fetch_start, fetch_end)
|
|||
|
|
finally:
|
|||
|
|
mt5.shutdown()
|
|||
|
|
|
|||
|
|
raw = raw.loc[raw.index >= TRAIN_START]
|
|||
|
|
if len(raw) < 500:
|
|||
|
|
print("ERROR: Not enough H1 bars after 2010 — check symbol and History Center.")
|
|||
|
|
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]
|
|||
|
|
|
|||
|
|
train_row = times < np.datetime64(TRAIN_END)
|
|||
|
|
if int(train_row.sum()) < 800:
|
|||
|
|
print("ERROR: Too few train rows before 2020 — need deeper history.")
|
|||
|
|
return 1
|
|||
|
|
|
|||
|
|
scaler = MinMaxScaler()
|
|||
|
|
scaler.fit(X_raw[train_row])
|
|||
|
|
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
|
|||
|
|
|
|||
|
|
train_m = t_end < np.datetime64(TRAIN_END)
|
|||
|
|
val_m = (t_end >= np.datetime64(TRAIN_END)) & (t_end < np.datetime64(OOS_END))
|
|||
|
|
X_train, y_train = X_seq[train_m], y_seq[train_m]
|
|||
|
|
X_val, y_val = X_seq[val_m], y_seq[val_m]
|
|||
|
|
if len(X_val) < 200:
|
|||
|
|
print("ERROR: Too few OOS sequences in 2020–2024.")
|
|||
|
|
return 1
|
|||
|
|
|
|||
|
|
print(f"Sequences train={len(X_train)} val(OOS)={len(X_val)} 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 trade-fingerprint clusters={n_clusters} "
|
|||
|
|
"(fit on in-sample train only; sample_weight × class balance)"
|
|||
|
|
)
|
|||
|
|
else:
|
|||
|
|
print("Skipping KMeans: not enough valid fingerprint rows in train.")
|
|||
|
|
|
|||
|
|
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"OOS 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,
|
|||
|
|
"train_window": [str(TRAIN_START.date()), str(TRAIN_END.date())],
|
|||
|
|
"oos_window": [str(TRAIN_END.date()), str(OOS_END.date())],
|
|||
|
|
"scaler_fit_on": "train_only_rows_before_2020",
|
|||
|
|
"clustering": f"KMeans n={n_clusters} on forward returns (1,2,4,8,16) ATR-norm; sample reweight train" 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,
|
|||
|
|
"oos_val_accuracy": float(acc),
|
|||
|
|
"oos_val_loss": float(loss),
|
|||
|
|
"notes": "ONNX for repo EAs; convert to ENKS externally if required.",
|
|||
|
|
}
|
|||
|
|
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())
|