Files
zhutoutoutousan b5acd37754 UpDATE
2026-05-02 15:55:04 +02:00

316 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Article-style training: chronological train (20102019) vs OOS (20202024),
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 20202024.")
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())