This commit is contained in:
zhutoutoutousan
2026-05-02 15:55:04 +02:00
parent 0b4a70b843
commit b5acd37754
198 changed files with 18739 additions and 11364 deletions
+386
View File
@@ -0,0 +1,386 @@
//+------------------------------------------------------------------+
//| EURUSD_H1_ActionEA.mq5 |
//| ai/eurusd1h/main.py — 24 features, 5-class softmax |
//| Classes: 0=HOLD 1=BUY 2=SELL_SHORT 3=CLOSE_LONG 4=CLOSE_SHORT |
//| Entry: strict trio winner among p0,p1,p2 only. |
//| Exit: unique 5-class argmax != held side (1 long, 2 short). |
//| No SL / TP / ATR stops. Attach EURUSD H1. |
//+------------------------------------------------------------------+
#property copyright "Profitable EA Project"
#property version "1.00"
#property description "EURUSD H1 action ONNX; ordinal entry/exit; no fixed SL/TP"
#include <Trade\Trade.mqh>
#resource "models\\EURUSD_H1_action.onnx" as uchar ExtModel[]
#define FEAT_COUNT 24
#define REL_EPS 1e-9
input group "Model"
input int InpLookback = 48;
input int InpSessionHourOffset = 0;
input string InpFeatMinStr = "";
input string InpFeatMaxStr = "";
input group "Timing"
input int InpMinBarsInTrade = 1; // model exit only after this many bars in position (0=off)
input group "Trade"
input double InpLotSize = 0.01;
input int InpMagic = 902601;
input int InpSlippage = 30;
double g_feat_min[FEAT_COUNT];
double g_feat_max[FEAT_COUNT];
CTrade trade;
long g_onnx = INVALID_HANDLE;
datetime g_last_bar = 0;
void InitDefaultScalerFromMeta()
{
// EURUSD_H1_action_meta.json scaler_feature_min / max (train fit)
double def_min[FEAT_COUNT] = {
0.9539399743080139,
0.9559400081634521,
0.9536200165748596,
0.9538999795913696,
9.999999974752427e-07,
0.07019035518169403,
-0.02143237181007862,
-0.028110405430197716,
0.0002704667276702821,
-0.02017582766711712,
1.0,
0.0004555500054266304,
0.0002461568801663816,
0.022001149132847786,
0.11231997609138489,
-0.5339273810386658,
-1.623793125152588,
-1.837566614151001,
6.83732741890708e-06,
0.0,
0.0,
0.0,
0.0,
0.0
};
double def_max[FEAT_COUNT] = {
1.493149995803833,
1.4938499927520752,
1.4904999732971191,
1.493190050125122,
0.06699500232934952,
0.9350273013114929,
0.028008731082081795,
0.03147505968809128,
0.009249407798051834,
0.01742853783071041,
1.0232577323913574,
0.02111775055527687,
7.801275253295898,
0.9864169955253601,
0.8837512731552124,
0.49708572030067444,
1.8055412769317627,
1.927569031715393,
0.8700546026229858,
1.0,
1.0,
1.0,
1.0,
1.0
};
for(int i = 0; i < FEAT_COUNT; i++)
{
g_feat_min[i] = def_min[i];
g_feat_max[i] = def_max[i];
}
}
bool ParseFeatCsv(const string s, double &arr[])
{
if(StringLen(s) < 3) return false;
string parts[];
if(StringSplit(s, ',', parts) != FEAT_COUNT) return false;
for(int i = 0; i < FEAT_COUNT; i++)
arr[i] = StringToDouble(parts[i]);
return true;
}
void ScaleFeatures(const float &raw[], float &out[])
{
for(int f = 0; f < FEAT_COUNT; f++)
{
double den = g_feat_max[f] - g_feat_min[f];
if(den < 1e-12) den = 1e-12;
double x = (double)raw[f] - g_feat_min[f];
out[f] = (float)MathMax(0.0, MathMin(1.0, x / den));
}
}
bool PrepareMatrix(matrixf &M)
{
int L = InpLookback;
double open[], high[], low[], close[];
long vol[];
datetime bt[];
ArraySetAsSeries(open, true);
ArraySetAsSeries(high, true);
ArraySetAsSeries(low, true);
ArraySetAsSeries(close, true);
ArraySetAsSeries(vol, true);
ArraySetAsSeries(bt, true);
int need = L + 55;
if(CopyOpen(_Symbol, PERIOD_CURRENT, 0, need, open) < L) return false;
if(CopyHigh(_Symbol, PERIOD_CURRENT, 0, need, high) < L) return false;
if(CopyLow(_Symbol, PERIOD_CURRENT, 0, need, low) < L) return false;
if(CopyClose(_Symbol, PERIOD_CURRENT, 0, need, close) < L) return false;
if(CopyTickVolume(_Symbol, PERIOD_CURRENT, 0, need, vol) < L) return false;
if(CopyTime(_Symbol, PERIOD_CURRENT, 0, need, bt) < L) return false;
double rsi7[], rsi14[], rsi21[], ema20[], ema50[], atr[];
ArraySetAsSeries(rsi7, true);
ArraySetAsSeries(rsi14, true);
ArraySetAsSeries(rsi21, true);
ArraySetAsSeries(ema20, true);
ArraySetAsSeries(ema50, true);
ArraySetAsSeries(atr, true);
int h7 = iRSI(_Symbol, PERIOD_CURRENT, 7, PRICE_CLOSE);
int h14 = iRSI(_Symbol, PERIOD_CURRENT, 14, PRICE_CLOSE);
int h21 = iRSI(_Symbol, PERIOD_CURRENT, 21, PRICE_CLOSE);
int hE20 = iMA(_Symbol, PERIOD_CURRENT, 20, 0, MODE_EMA, PRICE_CLOSE);
int hE50 = iMA(_Symbol, PERIOD_CURRENT, 50, 0, MODE_EMA, PRICE_CLOSE);
int hA = iATR(_Symbol, PERIOD_CURRENT, 14);
if(h7 == INVALID_HANDLE || h14 == INVALID_HANDLE || h21 == INVALID_HANDLE ||
hE20 == INVALID_HANDLE || hE50 == INVALID_HANDLE || hA == INVALID_HANDLE)
return false;
if(CopyBuffer(h7, 0, 0, need, rsi7) < L ||
CopyBuffer(h14, 0, 0, need, rsi14) < L ||
CopyBuffer(h21, 0, 0, need, rsi21) < L ||
CopyBuffer(hE20, 0, 0, need, ema20) < L ||
CopyBuffer(hE50, 0, 0, need, ema50) < L ||
CopyBuffer(hA, 0, 0, need, atr) < L)
{
IndicatorRelease(h7); IndicatorRelease(h14); IndicatorRelease(h21);
IndicatorRelease(hE20); IndicatorRelease(hE50); IndicatorRelease(hA);
return false;
}
IndicatorRelease(h7); IndicatorRelease(h14); IndicatorRelease(h21);
IndicatorRelease(hE20); IndicatorRelease(hE50); IndicatorRelease(hA);
M.Resize(L, FEAT_COUNT);
const double RSI_OB = 70.0;
const double RSI_OS = 30.0;
for(int i = 0; i < L; i++)
{
double vma = 0;
int cnt = 0;
for(int k = i; k < i + 20 && k < ArraySize(vol); k++) { vma += (double)vol[k]; cnt++; }
if(cnt < 1) cnt = 1;
vma /= cnt;
double r0 = rsi14[i];
double r1 = (i + 1 < ArraySize(rsi14)) ? rsi14[i + 1] : r0;
double r2 = (i + 2 < ArraySize(rsi14)) ? rsi14[i + 2] : r1;
double rv7 = rsi7[i];
double rv21 = rsi21[i];
double spr = (r0 - rv7) / 50.0;
if(spr > 1.0) spr = 1.0;
if(spr < -1.0) spr = -1.0;
double vel = (r0 - r1) / 25.0;
double acc = ((r0 - r1) - (r1 - r2)) / 25.0;
double dist_mid = MathAbs(r0 - 50.0) / 50.0;
double c_ob = (r1 < RSI_OB && r0 >= RSI_OB) ? 1.0 : 0.0;
double c_os = (r1 > RSI_OS && r0 <= RSI_OS) ? 1.0 : 0.0;
double c50u = (r1 < 50.0 && r0 >= 50.0) ? 1.0 : 0.0;
double c50d = (r1 > 50.0 && r0 <= 50.0) ? 1.0 : 0.0;
MqlDateTime st;
TimeToStruct(bt[i], st);
int hr = (st.hour + InpSessionHourOffset) % 24;
if(hr < 0) hr += 24;
double asian = (hr >= 0 && hr < 8) ? 1.0 : 0.0;
float raw[FEAT_COUNT];
raw[0] = (float)open[i];
raw[1] = (float)high[i];
raw[2] = (float)low[i];
raw[3] = (float)close[i];
raw[4] = (float)((double)vol[i] / 1000000.0);
raw[5] = (float)(r0 / 100.0);
raw[6] = (float)((ema20[i] - close[i]) / close[i]);
raw[7] = (float)((ema50[i] - close[i]) / close[i]);
raw[8] = (float)(atr[i] / close[i]);
double pc = (i < L - 1) ? (close[i] - close[i + 1]) / close[i + 1] : 0.0;
raw[9] = (float)pc;
raw[10] = (float)(high[i] / low[i]);
raw[11] = (float)(vma / 1000000.0);
raw[12] = (float)(vma > 0 ? (double)vol[i] / vma : 1.0);
raw[13] = (float)(rv7 / 100.0);
raw[14] = (float)(rv21 / 100.0);
raw[15] = (float)spr;
raw[16] = (float)vel;
raw[17] = (float)acc;
raw[18] = (float)dist_mid;
raw[19] = (float)c_ob;
raw[20] = (float)c_os;
raw[21] = (float)c50u;
raw[22] = (float)c50d;
raw[23] = (float)asian;
float sc[FEAT_COUNT];
ScaleFeatures(raw, sc);
for(int j = 0; j < FEAT_COUNT; j++)
M[i][j] = sc[j];
}
return true;
}
int TrioStrictWinner012(const double p0, const double p1, const double p2)
{
if(p0 > p1 + REL_EPS && p0 > p2 + REL_EPS) return 0;
if(p1 > p0 + REL_EPS && p1 > p2 + REL_EPS) return 1;
if(p2 > p0 + REL_EPS && p2 > p1 + REL_EPS) return 2;
return -1;
}
int FiveStrictWinner01234(const double p0, const double p1, const double p2, const double p3, const double p4)
{
const double p[5] = {p0, p1, p2, p3, p4};
int best = 0;
for(int k = 1; k < 5; k++)
if(p[k] > p[best])
best = k;
const double m = p[best];
int cnt = 0;
for(int k = 0; k < 5; k++)
if(p[k] + REL_EPS >= m)
cnt++;
if(cnt != 1)
return -1;
return best;
}
bool SelectOurPosition()
{
if(!PositionSelect(_Symbol))
return false;
if((long)PositionGetInteger(POSITION_MAGIC) != InpMagic)
return false;
return true;
}
int PositionBarsInTrade()
{
if(!SelectOurPosition())
return 0;
const datetime tOpen = (datetime)PositionGetInteger(POSITION_TIME);
const int sh = iBarShift(_Symbol, PERIOD_CURRENT, tOpen, false);
if(sh < 0)
return 9999;
return sh + 1;
}
int OnInit()
{
InitDefaultScalerFromMeta();
trade.SetExpertMagicNumber(InpMagic);
trade.SetDeviationInPoints(InpSlippage);
trade.SetTypeFilling(ORDER_FILLING_IOC);
if(StringLen(InpFeatMinStr) > 0 && ParseFeatCsv(InpFeatMinStr, g_feat_min))
Print("EURUSD Action EA: loaded InpFeatMinStr");
if(StringLen(InpFeatMaxStr) > 0 && ParseFeatCsv(InpFeatMaxStr, g_feat_max))
Print("EURUSD Action EA: loaded InpFeatMaxStr");
g_onnx = OnnxCreateFromBuffer(ExtModel, ONNX_DEBUG_LOGS);
if(g_onnx == INVALID_HANDLE)
{
Print("OnnxCreateFromBuffer failed ", GetLastError());
return INIT_FAILED;
}
const long inShape[] = {1, InpLookback, FEAT_COUNT};
if(!OnnxSetInputShape(g_onnx, 0, inShape))
{
Print("OnnxSetInputShape failed ", GetLastError());
OnnxRelease(g_onnx);
return INIT_FAILED;
}
const long outShape[] = {1, 5};
if(!OnnxSetOutputShape(g_onnx, 0, outShape))
{
Print("OnnxSetOutputShape failed ", GetLastError());
OnnxRelease(g_onnx);
return INIT_FAILED;
}
if(_Period != PERIOD_H1)
Print("EURUSD_H1_ActionEA: chart period is ", EnumToString((ENUM_TIMEFRAMES)_Period),
" — training is H1; mismatch may hurt.");
Print("EURUSD_H1_ActionEA: ONNX OK. Ordinal entry/exit, no SL/TP. Lookback=", InpLookback);
return INIT_SUCCEEDED;
}
void OnDeinit(const int r)
{
if(g_onnx != INVALID_HANDLE)
OnnxRelease(g_onnx);
}
void OnTick()
{
datetime t = iTime(_Symbol, PERIOD_CURRENT, 0);
if(t == g_last_bar)
return;
g_last_bar = t;
matrixf Min;
if(!PrepareMatrix(Min))
{
Print("EURUSD Action EA: PrepareMatrix failed");
return;
}
vectorf out;
out.Resize(5);
if(!OnnxRun(g_onnx, ONNX_NO_CONVERSION, Min, out))
{
Print("OnnxRun failed ", GetLastError());
return;
}
const double p0 = out[0], p1 = out[1], p2 = out[2], p3 = out[3], p4 = out[4];
if(!SelectOurPosition())
{
const int w3 = TrioStrictWinner012(p0, p1, p2);
if(w3 == 1)
trade.Buy(InpLotSize, _Symbol, 0, 0, 0, "EURUSD act BUY");
else if(w3 == 2)
trade.Sell(InpLotSize, _Symbol, 0, 0, 0, "EURUSD act SELL");
return;
}
const bool allow = (InpMinBarsInTrade <= 0) || (PositionBarsInTrade() >= InpMinBarsInTrade);
if(!allow)
return;
const int w5 = FiveStrictWinner01234(p0, p1, p2, p3, p4);
const long typ = (long)PositionGetInteger(POSITION_TYPE);
bool close_it = false;
if(typ == POSITION_TYPE_BUY)
close_it = (w5 != -1 && w5 != 1);
else if(typ == POSITION_TYPE_SELL)
close_it = (w5 != -1 && w5 != 2);
if(close_it)
trade.PositionClose(_Symbol);
}
Binary file not shown.
+37
View File
@@ -0,0 +1,37 @@
# EURUSD H1 — ONNX action model (full MT5 history)
Same methodology as `ai/yt/train_article_split.py`:
- **24 features** + **5 softmax classes** (`ai/xauusd_h1/features.py`, `labeling.py`).
- **MinMaxScaler** is fit on **every** valid feature row MT5 returns (no 20102020 cut).
- **Training sequences**: all but a **chronological tail** (default **12%**) used only for `val_loss` / EarlyStopping (does not remove data from the scaler).
- Optional **KMeans** on forward-return fingerprints + class-balanced `sample_weight` on the train split.
## Run
```bash
cd ai/eurusd1h
pip install -r requirements.txt
python main.py
```
Requires MetaTrader 5 with **EURUSD H1** history downloaded (Tools → History Center).
## Environment overrides
| Variable | Default | Meaning |
|-----------------|-----------|----------------------------------------------|
| `EUR_SYMBOL` | `EURUSD` | MT5 symbol |
| `EUR_LOOKBACK` | `48` | Sequence length |
| `EUR_EPOCHS` | `40` | Max epochs |
| `EUR_BATCH` | `64` | Batch size |
| `EUR_CLUSTERS` | `12` | KMeans clusters (`0` = off) |
| `EUR_VAL_FRAC` | `0.12` | Fraction of sequences at **end** for val |
## Outputs
- `models/EURUSD_H1_action.onnx`
- `models/EURUSD_H1_action_meta.json`
- `models/EURUSD_H1_action_scaler.pkl`
Deploy like `ai/yt/US500_H1_ArticleEA.mq5`: embed ONNX, set `InpLookback`, paste `scaler_feature_min` / `max` from the meta JSON into the EA inputs.
+316
View File
@@ -0,0 +1,316 @@
"""
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())
Binary file not shown.
@@ -0,0 +1,133 @@
{
"symbol": "EURUSD",
"timeframe": "H1",
"lookback": 48,
"num_features": 24,
"feature_columns": [
"open",
"high",
"low",
"close",
"tick_volume",
"rsi",
"ema20_n",
"ema50_n",
"atr_n",
"price_change",
"high_low_ratio",
"volume_ma",
"volume_ratio",
"rsi7_n",
"rsi21_n",
"rsi_fast_slow_spread",
"rsi_velocity",
"rsi_accel",
"rsi_dist_mid_50",
"rsi_cross_overbought",
"rsi_cross_oversold",
"rsi_cross_50_up",
"rsi_cross_50_down",
"session_asian_utc"
],
"num_classes": 5,
"class_names": [
"HOLD",
"BUY",
"SELL_SHORT",
"CLOSE_LONG",
"CLOSE_SHORT"
],
"mt5_bar_range": [
"2010-03-17 23:00:00",
"2026-04-24 23:00:00"
],
"scaler_fit_on": "all_valid_feature_rows_full_mt5_range",
"validation_split": {
"mode": "chronological_tail_fraction",
"val_fraction": 0.12,
"train_sequences": 87914,
"val_sequences": 11989
},
"clustering": "KMeans n=12 on forward returns (1,2,4,8,16); train-only fit",
"scaler_feature_min": [
0.9539399743080139,
0.9559400081634521,
0.9536200165748596,
0.9538999795913696,
9.999999974752427e-07,
0.07019035518169403,
-0.02143237181007862,
-0.028110405430197716,
0.0002704667276702821,
-0.02017582766711712,
1.0,
0.0004555500054266304,
0.0002461568801663816,
0.022001149132847786,
0.11231997609138489,
-0.5339273810386658,
-1.623793125152588,
-1.837566614151001,
6.83732741890708e-06,
0.0,
0.0,
0.0,
0.0,
0.0
],
"scaler_feature_max": [
1.493149995803833,
1.4938499927520752,
1.4904999732971191,
1.493190050125122,
0.06699500232934952,
0.9350273013114929,
0.028008731082081795,
0.03147505968809128,
0.009249407798051834,
0.01742853783071041,
1.0232577323913574,
0.02111775055527687,
7.801275253295898,
0.9864169955253601,
0.8837512731552124,
0.49708572030067444,
1.8055412769317627,
1.927569031715393,
0.8700546026229858,
1.0,
1.0,
1.0,
1.0,
1.0
],
"scaler_scale": [
1.854564905166626,
1.8590470552444458,
1.8626137971878052,
1.8542896509170532,
14.926709175109863,
1.1562873125076294,
20.226085662841797,
16.782615661621094,
111.3717041015625,
26.5926570892334,
42.99645233154297,
48.39755630493164,
0.12818820774555206,
1.03689706325531,
1.296291708946228,
0.969919741153717,
0.2916017770767212,
0.2655946612358093,
1.1493622064590454,
1.0,
1.0,
1.0,
1.0,
1.0
],
"tail_val_accuracy": 0.2594878673553467,
"tail_val_loss": 1.5548540353775024,
"ea_note": "Copy ai/yt/US500_H1_ArticleEA.mq5 pattern: #resource ONNX + paste scaler from meta."
}
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
-r ../xauusd_h1/requirements.txt
+663
View File
@@ -0,0 +1,663 @@
//+------------------------------------------------------------------+
//| US500_H1_ArticleEA.mq5 |
//| ai/yt: article-split ONNX (train 20102019 / OOS 20202024) |
//| Train: python train_article_split.py → models/*.onnx |
//| Attach to US500 (or broker equivalent) H1 chart. |
//+------------------------------------------------------------------+
#property copyright "Profitable EA Project"
#property version "1.05"
#property description "Embedded US500 H1 article-split ONNX; scaler from US500_H1_article_split_meta.json"
#include <Trade\Trade.mqh>
#resource "models\\US500_H1_article_split.onnx" as uchar ExtModel[]
#define FEAT_COUNT 24
#define PRED_HIST_CAP 32
#define REL_EPS 1e-9
input group "Model"
input int InpLookback = 48;
input int InpEntryMode = 1;
input double InpProbBuy = 0.18;
input double InpProbSell = 0.18;
input double InpMinBeatHold = 0.0;
input int InpExitMode = 1; // 0=fixed prob; 1/2=close must beat HOLD and stay-in-trade (2 legacy; old 2 vs-HOLD-only removed)
input double InpProbCloseL = 0.18;
input double InpProbCloseS = 0.18;
input double InpMinCloseBeatHold = 0.0;
input int InpMinBarsInTradeModelExit = 1; // min bars before model exit (0=off); pure mode uses 5-class winner
input bool InpPureRelative = true; // true: no prob cutoffs/edges — entry=trio strict winner, exit=5-class strict winner != side
input bool InpUseCloseHeadExit = true; // legacy only when InpPureRelative=false (CL/CS vs HOLD/stay; see InpExitMode)
input bool InpUseDirFlipExit = true; // legacy only when InpPureRelative=false (gap edges InpFlipExitEdge)
input double InpFlipExitEdge = 0.03; // legacy dir-flip min gap (ignored when InpPureRelative)
input int InpMinBarsAfterExit = 6; // after any close, wait this many flat bars before a new entry (0=off)
input int InpCooldownBarsAfterAdverse = 12; // extra flat-bar pause after adverse (ATR) stop; 0 = use only MinBarsAfterExit
input group "Decision (aggregate + sample, lowers trade churn)"
input int InpSampleEveryNBars = 2; // run ONNX / refresh history every N new bars (>=1)
input int InpAggWindow = 4; // rolling mean over last K samples (>=1)
input int InpMinAggSamples = 2; // need this many samples in window before new entries
input int InpMinBarsBetweenEntries = 0; // after an open, wait this many flat bars before next entry (0=off)
input double InpMinDirEdge = 0.03; // legacy entry mode 1 only (ignored when InpPureRelative)
input bool InpRequireStayOverClose = true; // legacy entry (ignored when InpPureRelative)
input group "Session (match Python SESSION_HOUR_OFFSET)"
input int InpSessionHourOffset = 0;
input group "Scaler override (empty = use built-in US500 train split)"
input string InpFeatMinStr = "";
input string InpFeatMaxStr = "";
input group "Risk"
input double InpLotSize = 0.01;
input int InpMagic = 902503;
input int InpSlippage = 30;
input group "Hard exits (fixed ATR in price — optional)"
input bool InpUseAdverseAtrExit = false; // stop by adverse move in ATR multiples (off = model-only risk)
input bool InpUseProfitAtrExit = false; // take-profit in ATR multiples (needs InpTakeProfitATR > 0)
input double InpMaxAdverseATR = 3.5;
input double InpTakeProfitATR = 0.0;
double g_feat_min[FEAT_COUNT];
double g_feat_max[FEAT_COUNT];
CTrade trade;
long g_onnx = INVALID_HANDLE;
datetime g_last_bar = 0;
double g_pred_hist[PRED_HIST_CAP][5];
int g_pred_hist_len = 0;
double g_smooth[5] = {0.2, 0.2, 0.2, 0.2, 0.2};
ulong g_bar_index = 0;
int g_entry_cooldown_bars = 0;
int g_agg_w = 4;
int g_sample_n = 2;
int g_min_agg_samples = 2;
void InitDefaultScalerBounds()
{
// MinMax bounds from ai/yt/models/US500_H1_article_split_meta.json (train-only scaler)
double def_min[FEAT_COUNT] = {
1352.5,
1352.5999755859375,
1347.9000244140625,
1352.0999755859375,
0.0,
0.04497450217604637,
-0.030356179922819138,
-0.04839427396655083,
0.00028562467196024954,
-0.047754231840372086,
1.0,
0.00017100000695791095,
0.0,
0.01168255414813757,
0.0760856345295906,
-0.49618232250213623,
-1.6348180770874023,
-1.731970191001892,
0.000006116794793342706,
0.0,
0.0,
0.0,
0.0,
0.0
};
double def_max[FEAT_COUNT] = {
3250.199951171875,
3251.5,
3249.5,
3250.199951171875,
26050000896.0,
0.887104868888855,
0.09538312256336212,
0.10739167034626007,
0.02898731827735901,
0.036042287945747375,
1.0754634141921997,
6759499776.0,
20.0,
0.9637425541877747,
0.8267387747764587,
0.5573697686195374,
1.2618913650512695,
1.8784747123718262,
0.9100509881973267,
1.0,
1.0,
1.0,
1.0,
1.0
};
for(int i = 0; i < FEAT_COUNT; i++)
{
g_feat_min[i] = def_min[i];
g_feat_max[i] = def_max[i];
}
}
bool ParseFeatCsv(const string s, double &arr[])
{
if(StringLen(s) < 3) return false;
string parts[];
int n = StringSplit(s, ',', parts);
if(n != FEAT_COUNT) return false;
for(int i = 0; i < FEAT_COUNT; i++)
arr[i] = StringToDouble(parts[i]);
return true;
}
int OnInit()
{
InitDefaultScalerBounds();
trade.SetExpertMagicNumber(InpMagic);
trade.SetDeviationInPoints(InpSlippage);
trade.SetTypeFilling(ORDER_FILLING_IOC);
if(StringLen(InpFeatMinStr) > 0 && ParseFeatCsv(InpFeatMinStr, g_feat_min))
Print("US500 Article EA: loaded InpFeatMinStr (24)");
if(StringLen(InpFeatMaxStr) > 0 && ParseFeatCsv(InpFeatMaxStr, g_feat_max))
Print("US500 Article EA: loaded InpFeatMaxStr (24)");
g_onnx = OnnxCreateFromBuffer(ExtModel, ONNX_DEBUG_LOGS);
if(g_onnx == INVALID_HANDLE)
{
Print("OnnxCreateFromBuffer failed ", GetLastError());
return INIT_FAILED;
}
const long inShape[] = {1, InpLookback, FEAT_COUNT};
if(!OnnxSetInputShape(g_onnx, 0, inShape))
{
Print("OnnxSetInputShape failed ", GetLastError());
OnnxRelease(g_onnx);
return INIT_FAILED;
}
const long outShape[] = {1, 5};
if(!OnnxSetOutputShape(g_onnx, 0, outShape))
{
Print("OnnxSetOutputShape failed ", GetLastError());
OnnxRelease(g_onnx);
return INIT_FAILED;
}
g_agg_w = MathMax(1, MathMin(InpAggWindow, PRED_HIST_CAP));
g_sample_n = MathMax(1, InpSampleEveryNBars);
g_min_agg_samples = MathMax(1, MathMin(InpMinAggSamples, g_agg_w));
g_pred_hist_len = 0;
g_bar_index = 0;
g_entry_cooldown_bars = 0;
for(int k = 0; k < 5; k++)
g_smooth[k] = 0.2;
const bool has_atr = InpUseAdverseAtrExit || (InpUseProfitAtrExit && InpTakeProfitATR > 0.0);
const bool has_model_exit = InpPureRelative || InpUseCloseHeadExit || InpUseDirFlipExit;
if(!has_atr && !has_model_exit)
Print("US500_H1_ArticleEA: WARNING — no exit path enabled (enable InpPureRelative and/or legacy exits / ATR)");
Print("US500_H1_ArticleEA: ONNX OK. Chart TF=", EnumToString(PERIOD_CURRENT), "; Lookback=", InpLookback,
" sampleEvery=", g_sample_n, " aggWindow=", g_agg_w, " minAggSamples=", g_min_agg_samples,
" pureRelative=", InpPureRelative,
" entryCooldownBars=", InpMinBarsBetweenEntries, " minDirEdge=", InpMinDirEdge,
" stayOverClose=", InpRequireStayOverClose,
" exitMode=", InpExitMode, " minBarsInTradeModelExit=", InpMinBarsInTradeModelExit,
" closeHeadExit=", InpUseCloseHeadExit, " dirFlipExit=", InpUseDirFlipExit, " flipExitEdge=", InpFlipExitEdge,
" minBarsAfterExit=", InpMinBarsAfterExit, " cooldownAfterAdverse=", InpCooldownBarsAfterAdverse,
" useAdverseATR=", InpUseAdverseAtrExit, " useProfitATR=", InpUseProfitAtrExit,
" maxAdverseATR=", InpMaxAdverseATR, " takeProfitATR=", InpTakeProfitATR);
return INIT_SUCCEEDED;
}
void OnDeinit(const int r)
{
if(g_onnx != INVALID_HANDLE) OnnxRelease(g_onnx);
}
double AtrNow()
{
double b[];
ArraySetAsSeries(b, true);
int h = iATR(_Symbol, PERIOD_CURRENT, 14);
if(h == INVALID_HANDLE) return 0;
if(CopyBuffer(h, 0, 0, 2, b) < 1) { IndicatorRelease(h); return 0; }
double v = b[0];
IndicatorRelease(h);
return v;
}
bool AdverseExit(const long type, const double open_price)
{
if(!InpUseAdverseAtrExit || InpMaxAdverseATR <= 0.0)
return false;
double atr = AtrNow();
if(atr <= 0) return false;
double bid = SymbolInfoDouble(_Symbol, SYMBOL_BID);
double ask = SymbolInfoDouble(_Symbol, SYMBOL_ASK);
if(type == POSITION_TYPE_BUY)
{
double adv = (open_price - bid) / atr;
return adv >= InpMaxAdverseATR;
}
double adv = (ask - open_price) / atr;
return adv >= InpMaxAdverseATR;
}
bool ProfitExit(const long type, const double open_price)
{
if(!InpUseProfitAtrExit || InpTakeProfitATR <= 0.0)
return false;
double atr = AtrNow();
if(atr <= 0.0) return false;
double bid = SymbolInfoDouble(_Symbol, SYMBOL_BID);
double ask = SymbolInfoDouble(_Symbol, SYMBOL_ASK);
if(type == POSITION_TYPE_BUY)
return (bid - open_price) >= InpTakeProfitATR * atr;
return (open_price - ask) >= InpTakeProfitATR * atr;
}
bool ModelCloseLong(const double p0, const double p1, const double p3)
{
if(InpExitMode == 0)
return (p3 >= InpProbCloseL);
// Modes 1/2 (and default): close-long must beat HOLD and stay-long (BUY). Old mode-2 "vs HOLD only" fired almost every bar on softmax.
return (p3 > p0 + InpMinCloseBeatHold && p3 > p1);
}
bool ModelCloseShort(const double p0, const double p2, const double p4)
{
if(InpExitMode == 0)
return (p4 >= InpProbCloseS);
return (p4 > p0 + InpMinCloseBeatHold && p4 > p2);
}
bool ModelDirFlipExitLong(const double p0, const double p1, const double p2)
{
if(!InpUseDirFlipExit)
return false;
const double e = MathMax(0.0, InpFlipExitEdge);
return (p2 > p1 + e && p2 > p0 + InpMinBeatHold);
}
bool ModelDirFlipExitShort(const double p0, const double p1, const double p2)
{
if(!InpUseDirFlipExit)
return false;
const double e = MathMax(0.0, InpFlipExitEdge);
return (p1 > p2 + e && p1 > p0 + InpMinBeatHold);
}
int TrioStrictWinner012(const double p0, const double p1, const double p2)
{
if(p0 > p1 + REL_EPS && p0 > p2 + REL_EPS)
return 0;
if(p1 > p0 + REL_EPS && p1 > p2 + REL_EPS)
return 1;
if(p2 > p0 + REL_EPS && p2 > p1 + REL_EPS)
return 2;
return -1;
}
int FiveStrictWinner01234(const double p0, const double p1, const double p2, const double p3, const double p4)
{
const double p[5] = {p0, p1, p2, p3, p4};
int best = 0;
for(int k = 1; k < 5; k++)
if(p[k] > p[best])
best = k;
const double m = p[best];
int cnt = 0;
for(int k = 0; k < 5; k++)
if(p[k] + REL_EPS >= m)
cnt++;
if(cnt != 1)
return -1;
return best;
}
int PositionBarsInTrade()
{
if(!PositionSelect(_Symbol))
return 0;
const datetime tOpen = (datetime)PositionGetInteger(POSITION_TIME);
const int sh = iBarShift(_Symbol, PERIOD_CURRENT, tOpen, false);
if(sh < 0)
return 9999;
return sh + 1;
}
void ApplyExitCooldown(const bool adverse_stop)
{
int b = MathMax(0, InpMinBarsAfterExit);
if(adverse_stop)
b = MathMax(b, MathMax(0, InpCooldownBarsAfterAdverse));
if(b > 0)
g_entry_cooldown_bars = MathMax(g_entry_cooldown_bars, b);
}
void PushPrediction(const double p0, const double p1, const double p2, const double p3, const double p4, const int maxKeep)
{
for(int i = PRED_HIST_CAP - 1; i > 0; i--)
for(int k = 0; k < 5; k++)
g_pred_hist[i][k] = g_pred_hist[i - 1][k];
g_pred_hist[0][0] = p0;
g_pred_hist[0][1] = p1;
g_pred_hist[0][2] = p2;
g_pred_hist[0][3] = p3;
g_pred_hist[0][4] = p4;
int cap = MathMax(1, MathMin(maxKeep, PRED_HIST_CAP));
g_pred_hist_len = MathMin(g_pred_hist_len + 1, cap);
}
void RecomputeSmooth(const int aggWindow)
{
int w = MathMax(1, MathMin(aggWindow, PRED_HIST_CAP));
int n = MathMin(w, g_pred_hist_len);
if(n < 1)
return;
for(int k = 0; k < 5; k++)
{
double s = 0.0;
for(int i = 0; i < n; i++)
s += g_pred_hist[i][k];
g_smooth[k] = s / (double)n;
}
}
void ScaleFeatures(const float &raw[], float &out[])
{
for(int f = 0; f < FEAT_COUNT; f++)
{
double den = g_feat_max[f] - g_feat_min[f];
if(den < 1e-12) den = 1e-12;
double x = (double)raw[f] - g_feat_min[f];
out[f] = (float)MathMax(0.0, MathMin(1.0, x / den));
}
}
bool PrepareMatrix(matrixf &M)
{
int L = InpLookback;
double open[], high[], low[], close[];
long vol[];
datetime bt[];
ArraySetAsSeries(open, true);
ArraySetAsSeries(high, true);
ArraySetAsSeries(low, true);
ArraySetAsSeries(close, true);
ArraySetAsSeries(vol, true);
ArraySetAsSeries(bt, true);
int need = L + 55;
if(CopyOpen(_Symbol, PERIOD_CURRENT, 0, need, open) < L) return false;
if(CopyHigh(_Symbol, PERIOD_CURRENT, 0, need, high) < L) return false;
if(CopyLow(_Symbol, PERIOD_CURRENT, 0, need, low) < L) return false;
if(CopyClose(_Symbol, PERIOD_CURRENT, 0, need, close) < L) return false;
if(CopyTickVolume(_Symbol, PERIOD_CURRENT, 0, need, vol) < L) return false;
if(CopyTime(_Symbol, PERIOD_CURRENT, 0, need, bt) < L) return false;
double rsi7[], rsi14[], rsi21[], ema20[], ema50[], atr[];
ArraySetAsSeries(rsi7, true);
ArraySetAsSeries(rsi14, true);
ArraySetAsSeries(rsi21, true);
ArraySetAsSeries(ema20, true);
ArraySetAsSeries(ema50, true);
ArraySetAsSeries(atr, true);
int h7 = iRSI(_Symbol, PERIOD_CURRENT, 7, PRICE_CLOSE);
int h14 = iRSI(_Symbol, PERIOD_CURRENT, 14, PRICE_CLOSE);
int h21 = iRSI(_Symbol, PERIOD_CURRENT, 21, PRICE_CLOSE);
int hE20 = iMA(_Symbol, PERIOD_CURRENT, 20, 0, MODE_EMA, PRICE_CLOSE);
int hE50 = iMA(_Symbol, PERIOD_CURRENT, 50, 0, MODE_EMA, PRICE_CLOSE);
int hA = iATR(_Symbol, PERIOD_CURRENT, 14);
if(h7 == INVALID_HANDLE || h14 == INVALID_HANDLE || h21 == INVALID_HANDLE ||
hE20 == INVALID_HANDLE || hE50 == INVALID_HANDLE || hA == INVALID_HANDLE)
return false;
if(CopyBuffer(h7, 0, 0, need, rsi7) < L ||
CopyBuffer(h14, 0, 0, need, rsi14) < L ||
CopyBuffer(h21, 0, 0, need, rsi21) < L ||
CopyBuffer(hE20, 0, 0, need, ema20) < L ||
CopyBuffer(hE50, 0, 0, need, ema50) < L ||
CopyBuffer(hA, 0, 0, need, atr) < L)
{
IndicatorRelease(h7); IndicatorRelease(h14); IndicatorRelease(h21);
IndicatorRelease(hE20); IndicatorRelease(hE50); IndicatorRelease(hA);
return false;
}
IndicatorRelease(h7); IndicatorRelease(h14); IndicatorRelease(h21);
IndicatorRelease(hE20); IndicatorRelease(hE50); IndicatorRelease(hA);
M.Resize(L, FEAT_COUNT);
const double RSI_OB = 70.0;
const double RSI_OS = 30.0;
for(int i = 0; i < L; i++)
{
double vma = 0;
int cnt = 0;
for(int k = i; k < i + 20 && k < ArraySize(vol); k++) { vma += (double)vol[k]; cnt++; }
if(cnt < 1) cnt = 1;
vma /= cnt;
double r0 = rsi14[i];
double r1 = (i + 1 < ArraySize(rsi14)) ? rsi14[i + 1] : r0;
double r2 = (i + 2 < ArraySize(rsi14)) ? rsi14[i + 2] : r1;
double rv7 = rsi7[i];
double rv21 = rsi21[i];
double spread = (r0 - rv7) / 50.0;
if(spread > 1.0) spread = 1.0;
if(spread < -1.0) spread = -1.0;
double vel = (r0 - r1) / 25.0;
double acc = ((r0 - r1) - (r1 - r2)) / 25.0;
double dist_mid = MathAbs(r0 - 50.0) / 50.0;
double c_ob = (r1 < RSI_OB && r0 >= RSI_OB) ? 1.0 : 0.0;
double c_os = (r1 > RSI_OS && r0 <= RSI_OS) ? 1.0 : 0.0;
double c50u = (r1 < 50.0 && r0 >= 50.0) ? 1.0 : 0.0;
double c50d = (r1 > 50.0 && r0 <= 50.0) ? 1.0 : 0.0;
MqlDateTime st;
TimeToStruct(bt[i], st);
int hr = (st.hour + InpSessionHourOffset) % 24;
if(hr < 0) hr += 24;
double asian = (hr >= 0 && hr < 8) ? 1.0 : 0.0;
float raw[FEAT_COUNT];
raw[0] = (float)open[i];
raw[1] = (float)high[i];
raw[2] = (float)low[i];
raw[3] = (float)close[i];
raw[4] = (float)((double)vol[i] / 1000000.0);
raw[5] = (float)(r0 / 100.0);
raw[6] = (float)((ema20[i] - close[i]) / close[i]);
raw[7] = (float)((ema50[i] - close[i]) / close[i]);
raw[8] = (float)(atr[i] / close[i]);
double pc = (i < L - 1) ? (close[i] - close[i + 1]) / close[i + 1] : 0.0;
raw[9] = (float)pc;
raw[10] = (float)(high[i] / low[i]);
raw[11] = (float)(vma / 1000000.0);
raw[12] = (float)(vma > 0 ? (double)vol[i] / vma : 1.0);
raw[13] = (float)(rv7 / 100.0);
raw[14] = (float)(rv21 / 100.0);
raw[15] = (float)spread;
raw[16] = (float)vel;
raw[17] = (float)acc;
raw[18] = (float)dist_mid;
raw[19] = (float)c_ob;
raw[20] = (float)c_os;
raw[21] = (float)c50u;
raw[22] = (float)c50d;
raw[23] = (float)asian;
float sc[FEAT_COUNT];
ScaleFeatures(raw, sc);
for(int j = 0; j < FEAT_COUNT; j++)
M[i][j] = sc[j];
}
return true;
}
void OnTick()
{
datetime t = iTime(_Symbol, PERIOD_CURRENT, 0);
if(t == g_last_bar) return;
g_last_bar = t;
const bool had_pos = PositionSelect(_Symbol);
const bool flat = !had_pos;
if(flat && g_entry_cooldown_bars > 0)
g_entry_cooldown_bars--;
g_bar_index++;
const bool do_sample = (g_sample_n < 2) || ((g_bar_index % (ulong)g_sample_n) == 0);
bool fresh_predict = false;
if(do_sample)
{
matrixf Min;
if(!PrepareMatrix(Min))
{
Print("US500 Article EA: PrepareMatrix failed");
if(!had_pos)
return;
}
else
{
vectorf out;
out.Resize(5);
if(!OnnxRun(g_onnx, ONNX_NO_CONVERSION, Min, out))
{
Print("OnnxRun failed ", GetLastError());
if(!had_pos)
return;
}
else
{
PushPrediction(out[0], out[1], out[2], out[3], out[4], g_agg_w);
RecomputeSmooth(g_agg_w);
fresh_predict = true;
Print("US500 Article H1 raw HOLD=", out[0], " BUY=", out[1], " SELL=", out[2], " CL=", out[3], " CS=", out[4],
" | smooth HOLD=", g_smooth[0], " BUY=", g_smooth[1], " SELL=", g_smooth[2], " CL=", g_smooth[3], " CS=", g_smooth[4]);
}
}
}
const double p0 = g_smooth[0];
const double p1 = g_smooth[1];
const double p2 = g_smooth[2];
const double p3 = g_smooth[3];
const double p4 = g_smooth[4];
if(flat)
{
if(!do_sample || !fresh_predict)
return;
if(g_pred_hist_len < g_min_agg_samples)
return;
if(g_entry_cooldown_bars > 0)
return;
if(InpEntryMode == 1)
{
if(InpPureRelative)
{
const int w3 = TrioStrictWinner012(p0, p1, p2);
if(w3 == 1)
{
if(trade.Buy(InpLotSize, _Symbol, 0, 0, 0, "US500 article BUY"))
g_entry_cooldown_bars = MathMax(0, InpMinBarsBetweenEntries);
}
else if(w3 == 2)
{
if(trade.Sell(InpLotSize, _Symbol, 0, 0, 0, "US500 article SELL"))
g_entry_cooldown_bars = MathMax(0, InpMinBarsBetweenEntries);
}
}
else
{
double dir = MathMax(p1, p2);
if(dir <= p0 + InpMinBeatHold)
return;
const double edge = MathMax(0.0, InpMinDirEdge);
const bool stay_ok_buy = (!InpRequireStayOverClose) || (p1 > p3);
const bool stay_ok_sell = (!InpRequireStayOverClose) || (p2 > p4);
if(p1 >= p2 && p1 > p0 + InpMinBeatHold && (p1 - p2) >= edge && stay_ok_buy)
{
if(trade.Buy(InpLotSize, _Symbol, 0, 0, 0, "US500 article BUY"))
g_entry_cooldown_bars = MathMax(0, InpMinBarsBetweenEntries);
}
else if(p2 > p1 && p2 > p0 + InpMinBeatHold && (p2 - p1) >= edge && stay_ok_sell)
{
if(trade.Sell(InpLotSize, _Symbol, 0, 0, 0, "US500 article SELL"))
g_entry_cooldown_bars = MathMax(0, InpMinBarsBetweenEntries);
}
}
}
else
{
if(p1 >= InpProbBuy && p1 >= p2)
{
if(trade.Buy(InpLotSize, _Symbol, 0, 0, 0, "US500 article BUY"))
g_entry_cooldown_bars = MathMax(0, InpMinBarsBetweenEntries);
}
else if(p2 >= InpProbSell && p2 > p1)
{
if(trade.Sell(InpLotSize, _Symbol, 0, 0, 0, "US500 article SELL"))
g_entry_cooldown_bars = MathMax(0, InpMinBarsBetweenEntries);
}
}
return;
}
long typ = (long)PositionGetInteger(POSITION_TYPE);
double opn = PositionGetDouble(POSITION_PRICE_OPEN);
if(AdverseExit(typ, opn))
{
if(trade.PositionClose(_Symbol))
ApplyExitCooldown(true);
return;
}
if(ProfitExit(typ, opn))
{
if(trade.PositionClose(_Symbol))
ApplyExitCooldown(false);
return;
}
const int bars_in = PositionBarsInTrade();
const bool allow_model_exit = (InpMinBarsInTradeModelExit <= 0) || (bars_in >= InpMinBarsInTradeModelExit);
if(allow_model_exit)
{
bool want_close = false;
if(InpPureRelative)
{
const int w5 = FiveStrictWinner01234(p0, p1, p2, p3, p4);
if(typ == POSITION_TYPE_BUY)
want_close = (w5 != -1 && w5 != 1);
else
want_close = (w5 != -1 && w5 != 2);
}
else
{
if(typ == POSITION_TYPE_BUY)
{
const bool head = InpUseCloseHeadExit && ModelCloseLong(p0, p1, p3);
const bool flip = ModelDirFlipExitLong(p0, p1, p2);
want_close = (head || flip);
}
else
{
const bool head = InpUseCloseHeadExit && ModelCloseShort(p0, p2, p4);
const bool flip = ModelDirFlipExitShort(p0, p1, p2);
want_close = (head || flip);
}
}
if(want_close)
{
if(trade.PositionClose(_Symbol))
ApplyExitCooldown(false);
}
}
}
+30
View File
@@ -0,0 +1,30 @@
; US500_H1_ArticleEA — Strategy Tester preset (fixed inputs, no optimization)
; Copy to: MetaQuotes\Terminal\<instance>\MQL5\Profiles\Tester\
; In Tester: Inputs tab → rightclick → Load → pick this file
;
; Notes:
; - InpLookback must stay 48 unless you retrain/reembed ONNX with another lookback.
; - Use micro lot (0.01) for tests; 1.0 lot caused very large exposure on US500.
; - InpMinBeatHold / InpMinCloseBeatHold > 0 reduce churn when softmax is flat.
;
; Model
InpLookback=48||48||1||48||N
InpEntryMode=1||1||1||1||N
InpProbBuy=0.18||0.18||0.02||0.30||N
InpProbSell=0.18||0.18||0.02||0.30||N
InpMinBeatHold=0.04||0.04||0.01||0.10||N
InpExitMode=2||2||1||2||N
InpProbCloseL=0.18||0.18||0.02||0.30||N
InpProbCloseS=0.18||0.18||0.02||0.30||N
InpMinCloseBeatHold=0.03||0.03||0.01||0.08||N
; Session (match Python SESSION_HOUR_OFFSET)
InpSessionHourOffset=0||0||1||1||N
; Scaler override (empty = builtin scaler from US500_H1_article_split_meta.json)
InpFeatMinStr=
InpFeatMaxStr=
; Risk
InpLotSize=0.01||0.01||0.01||0.10||N
InpMagic=902503||902503||1||902503||N
InpSlippage=30||30||1||300||N
InpMaxAdverseATR=2.0||2.0||0.25||4.0||N
InpTakeProfitATR=0.0||0.0||0.25||3.0||N
+24
View File
@@ -0,0 +1,24 @@
; US500_H1_ArticleEA — genetic / slow optimization preset
; InpLookback fixed at 48 (must match embedded ONNX). Copy to MQL5\Profiles\Tester\
;
; Model
InpLookback=48||48||1||48||N
InpEntryMode=1||0||1||1||Y
InpProbBuy=0.18||0.12||0.02||0.28||Y
InpProbSell=0.18||0.12||0.02||0.28||Y
InpMinBeatHold=0.04||0.0||0.01||0.10||Y
InpExitMode=2||0||1||2||Y
InpProbCloseL=0.18||0.12||0.02||0.28||Y
InpProbCloseS=0.18||0.12||0.02||0.28||Y
InpMinCloseBeatHold=0.03||0.0||0.01||0.08||Y
; Session (match Python SESSION_HOUR_OFFSET)
InpSessionHourOffset=0||-2||1||2||N
; Scaler override (leave empty unless you paste new train bounds)
InpFeatMinStr=
InpFeatMaxStr=
; Risk
InpLotSize=0.01||0.01||0.01||0.10||N
InpMagic=902503||902503||1||902503||N
InpSlippage=30||30||1||300||N
InpMaxAdverseATR=2.0||1.0||0.25||3.5||Y
InpTakeProfitATR=0.0||0.0||0.25||3.0||Y
+30
View File
@@ -0,0 +1,30 @@
# ENKS / clustering article — notes → training in this repo
Summary of the methodology described in the article (MQL5 / ENKS trader clusters):
- Models are trained in **Python**, then converted to **ENKS** for the MetaTrader include/bot stack. This repository does **not** ship an ENKS encoder; training here exports **ONNX + JSON meta + scaler** like `ai/xauusd_h1/`. Convert ENKS with the authors tool or workflow from the article.
- **Clustering** (article: Cayley / trade matching): use **forward-return fingerprints** per bar and **KMeans** on the in-sample window only, then optional **per-cluster balancing** of sample weights during training (see `train_article_split.py`).
- **Windows**: train **2010-01-01 → 2019-12-31**; out-of-sample / forward **2020-01-01 → 2024-12-31**. Scaler is fit **only** on the train window (no leakage).
- **Capital / Capodon-style US H1**: default symbol `US500` on **H1**; override with `YT_SYMBOL`. The article notes models can be attached on other timeframes; EA SL/TP and filters are tuned separately.
- **Includes (`tendq`, etc.)**: not present in this repo; wire your ONNX EA to the exported `*_meta.json` and scaler like the existing XAUUSD H1 action EA.
## Run training
From `ai/yt` (MetaTrader 5 must be installed and history available for the symbol):
```bash
pip install -r requirements.txt
python train_article_split.py
```
Environment overrides:
| Variable | Default | Meaning |
|----------------|----------------|----------------------------------|
| `YT_SYMBOL` | `US500` | MT5 symbol |
| `YT_LOOKBACK` | `48` | Sequence length (bars) |
| `YT_EPOCHS` | `40` | Max epochs |
| `YT_BATCH` | `64` | Batch size |
| `YT_CLUSTERS` | `12` | KMeans clusters (0 = disable) |
Outputs: `ai/yt/models/<SYMBOL>_H1_article_split.onnx`, scaler `.pkl`, `*_meta.json`.
Binary file not shown.
@@ -0,0 +1,131 @@
{
"symbol": "US500",
"timeframe": "H1",
"lookback": 48,
"num_features": 24,
"feature_columns": [
"open",
"high",
"low",
"close",
"tick_volume",
"rsi",
"ema20_n",
"ema50_n",
"atr_n",
"price_change",
"high_low_ratio",
"volume_ma",
"volume_ratio",
"rsi7_n",
"rsi21_n",
"rsi_fast_slow_spread",
"rsi_velocity",
"rsi_accel",
"rsi_dist_mid_50",
"rsi_cross_overbought",
"rsi_cross_oversold",
"rsi_cross_50_up",
"rsi_cross_50_down",
"session_asian_utc"
],
"num_classes": 5,
"class_names": [
"HOLD",
"BUY",
"SELL_SHORT",
"CLOSE_LONG",
"CLOSE_SHORT"
],
"train_window": [
"2010-01-01",
"2020-01-01"
],
"oos_window": [
"2020-01-01",
"2025-01-01"
],
"scaler_fit_on": "train_only_rows_before_2020",
"clustering": "KMeans n=12 on forward returns (1,2,4,8,16) ATR-norm; sample reweight train",
"scaler_feature_min": [
1352.5,
1352.5999755859375,
1347.9000244140625,
1352.0999755859375,
0.0,
0.04497450217604637,
-0.030356179922819138,
-0.04839427396655083,
0.00028562467196024954,
-0.047754231840372086,
1.0,
0.00017100000695791095,
0.0,
0.01168255414813757,
0.0760856345295906,
-0.49618232250213623,
-1.6348180770874023,
-1.731970191001892,
6.116794793342706e-06,
0.0,
0.0,
0.0,
0.0,
0.0
],
"scaler_feature_max": [
3250.199951171875,
3251.5,
3249.5,
3250.199951171875,
26050000896.0,
0.887104868888855,
0.09538312256336212,
0.10739167034626007,
0.02898731827735901,
0.036042287945747375,
1.0754634141921997,
6759499776.0,
20.0,
0.9637425541877747,
0.8267387747764587,
0.5573697686195374,
1.2618913650512695,
1.8784747123718262,
0.9100509881973267,
1.0,
1.0,
1.0,
1.0,
1.0
],
"scaler_scale": [
0.0005269537214189768,
0.0005266206571832299,
0.0005258729797787964,
0.0005268426612019539,
3.8387713147125524e-11,
1.1874645948410034,
7.952962398529053,
6.419064044952393,
34.84115219116211,
11.933670043945312,
13.25145435333252,
1.4793993807771244e-10,
0.05000000074505806,
1.05035400390625,
1.332173228263855,
0.949169933795929,
0.34521928429603577,
0.2769741714000702,
1.0988469123840332,
1.0,
1.0,
1.0,
1.0,
1.0
],
"oos_val_accuracy": 0.293920636177063,
"oos_val_loss": 1.5438036918640137,
"notes": "ONNX for repo EAs; convert to ENKS externally if required."
}
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
-r ../xauusd_h1/requirements.txt
+315
View File
@@ -0,0 +1,315 @@
"""
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())