数据科学和机器学习(第 38 部分):外汇市场中的 AI 迁移学习·综合运用
◍ 一维卷积网络在行情二分类上的实测表现
用 Keras 搭了个极简一维卷积结构处理序列行情特征:首层 Conv1D 32 个滤波器、核宽 3、same 填充加 relu,随后全局平均池化压成一维,再接 128 维全连接与二分类 softmax 头,优化器用 Adam、损失函数 binary_crossentropy。 训练跑满 12 个 epoch 后,训练集 binary_accuracy 在 0.5298 附近晃,验证集始终卡在 0.50~0.51 之间,val_loss 不降反微升到 0.6941,典型欠拟合兼微弱过拟合倾向。 最终全量评估给出 Train acc 0.5114、Test acc 0.5050;分类报告里类别 0 的 precision 0.58 / recall 0.50,类别 1 的 precision 0.43 / recall 0.51,f1 分别 0.54 与 0.47。外汇与贵金属波动受噪声主导,这种贴近随机的命中率说明直接拿原始序列喂浅层 CNN 大概率无效,得先做过信号预处理或换特征构造思路。
layers.Conv1D(class="num">32, kernel_size=class="num">3, activation=&class="macro">#x27;relu&class="macro">#x27;, padding=&class="macro">#x27;same&class="macro">#x27;), layers.GlobalAveragePooling1D(), layers.Dense(class="num">128, activation=&class="macro">#x27;relu&class="macro">#x27;), layers.Dense(num_classes, activation=&class="macro">#x27;softmax&class="macro">#x27;) ]) # Compile with Adam optimizer model.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=learning_rate), loss=&class="macro">#x27;binary_crossentropy&class="macro">#x27;, metrics=[&class="macro">#x27;binary_accuracy&class="macro">#x27;] )
「分类模型的混淆报告里藏着什么」
上面这段输出是某价格方向分类模型在 8477 个样本上的汇总评分。accuracy 落在 0.51,macro avg 的 precision、recall、f1 分别是 0.51、0.51、0.50,weighted avg 略高一点到 0.52、0.51、0.51。 样本量 8477 说明回测窗口足够长,但 0.51 的准确率意味着模型仅比随机猜略好一点。外汇与贵金属市场高风险,这种边缘准确率直接拿去下单大概率被手续费吃掉。 别把 0.51 当无效就丢掉。把它和单一品种、单一时段拆开看,可能在欧美盘交叉段能爬到 0.58 以上,这时候才值得接进小布盯盘做二次过滤。
accuracy class="num">0.51 class="num">8477 macro avg class="num">0.51 class="num">0.51 class="num">0.50 class="num">8477 weighted avg class="num">0.52 class="num">0.51 class="num">0.51 class="num">8477
把训练好的模型搬进 EA 实盘
想在 MT5 里跑迁移学习模型,第一步是把 Python 端的 Keras 网络和 Scikit-learn 缩放器各自导成独立 ONNX 文件——因为 Keras 原生不支持把预处理管道一并打包,拆开存最省事。存完丢进 Common Folder,EA 里按相同命名规则加载即可。 EA 侧已有与 Python 版对等的 MQL5 技术指标模块,每根柱线回采最近 50 根已收盘柱(索引从 1 算起)。取 50 根是为了给指标算值留缓冲,避开 NaN;以 Awesome 振荡器周期 34 为例,50-34=16,这 16 根才是喂给模型的干净特征量。 OnTick 里先抓平稳变量再做滑窗切片,拼出 10 行×16 列的矩阵,过一遍 RobustScaler 再送 CNN。模型吐出信号 1 就开买、0 就开卖,单笔在当前 H4 周期经 lookahead 根柱后平掉。 实测用 EURUSD 训的基准模型对 17 个品种做迁移,仅 XAUUSD 与 XAUEUR 在 2023-01-01 至 2025-01-01 的 H4 上给出像样结果,其余基本是噪声。一种可能是 EUR/USD 双币种结构让黄金交叉盘与之形态强相关;另一种可能是 CNN 本身没调参,架构和基准品种都还没穷举。外汇与贵金属杠杆高,这类迁移信号仅作概率参考,不能直接当仓位依据。
class="kw">import onnxmltools class="kw">import tf2onnx from skl2onnx class="kw">import convert_sklearn from skl2onnx.common.data_types class="kw">import FloatTensorType def saveCNN(model, window: class="type">int, features: class="type">int, filename: str): model.output_names = ["output"] # Specifying the input signature for the model spec = (tf.TensorSpec((None, window, features), tf.float16, name="input"),) # Convert the Keras model to ONNX format onnx_model, _ = tf2onnx.convert.from_keras(model, input_signature=spec, opset=class="num">14) # Save the ONNX model to a file with open(filename, "wb") as f: f.write(onnx_model.SerializeToString()) def saveScaler(scaler, features: class="type">int, filename: str): # Convert to ONNX format initial_type = [("input", FloatTensorType([None, features]))] onnx_model = convert_sklearn(scaler, initial_types=initial_type, target_opset=class="num">14) with open(filename, "wb") as f: f.write(onnx_model.SerializeToString()) # .... # .... base_model = trainCNN(train_set=(X_train_seq, y_train_encoded), val_set=(X_test_seq, y_test_encoded), learning_rate = class="num">0.01, epochs = class="num">1000, batch_size =class="num">32) saveCNN(model=base_model, window=window, features=X_train_seq.shape[class="num">2], filename=f"{trained_symbol}.basemodel.{timeframe}.onnx") saveScaler(scaler=scaler, features=X_train.shape[class="num">1], filename=f"{trained_symbol}.{timeframe}.scaler.onnx") for symbol in symbols: # ... # ... history = model.fit(X_train_seq, y_train_encoded, validation_data=(X_test_seq, y_test_encoded), epochs=class="num">1000, # More epochs for fine-tuning batch_size=class="num">32, callbacks=[early_stop],
◍ 把跨品种 CNN 模型接进 MT5 实盘框架
训练侧把 EURUSD 作 base、USDJPY 作 target 的卷积模型存成了 ONNX,文件名形如 basesymbol=EURUSD.symbol=USDJPY.model.PERIOD_H4.onnx,配套的 scaler 单独存为 USDJPY.PERIOD_H4.scaler.onnx,二者都丢进终端公共目录等待加载。 EA 头文件直接引了 ta.mqh、pandas.mqh、CNN.mqh 与 preprocessing.mqh,等于把 Python 里的指标库、DataFrame 操作和 ONNX 推理在 MQL5 侧复刻了一遍;交易动作交给 CTrade,持仓查询用 CPositionInfo。 输入参数里 window_=10、lookahead=1、magic_number=28042025、slippage=100,跨品种推断的窗口长度就是 10 根 H4 K 线。OnInit 里先在非回测环境用 ChartSetSymbolPeriod 切到 USDJPY+H4,失败就 INIT_FAILED;随后按 StringFormat 拼出模型与 scaler 文件名调 cnn.Init / scaler.Init,任一加载失败同样终止初始化。 开 MT5 把这几个头文件与 ONNX 文件就位后,改 base_symbol 与 symbol_ 两个 input 就能换训练基元和推理目标,不用动框架代码。外汇与贵金属杠杆高,跨品种模型推断只给概率倾向,实盘前务必在策略测试器跑通加载流程。
class="macro">#include <ta.mqh> class=class="str">"cmt">//similar to ta in Python class="macro">#include <pandas.mqh> class=class="str">"cmt">//similar to Pandas in Python class="macro">#include <CNN.mqh> class=class="str">"cmt">//For loading Convolutional Neural networks in ONNX format class="macro">#include <preprocessing.mqh> class=class="str">"cmt">//For loading the scaler transformer class="macro">#include <Trade\Trade.mqh> class=class="str">"cmt">//The trading module class="macro">#include <Trade\PositionInfo.mqh> class=class="str">"cmt">//Position handling module CCNNClassifier cnn; RobustScaler scaler; CTrade m_trade; CPositionInfo m_position; input class="type">class="kw">string base_symbol = "EURUSD"; input class="type">class="kw">string symbol_ = "USDJPY"; input ENUM_TIMEFRAMES timeframe = PERIOD_H4; input class="type">uint window_ = class="num">10; input class="type">uint lookahead = class="num">1; input class="type">uint magic_number = class="num">28042025; input class="type">uint slippage = class="num">100; class="type">long classes_in_y_[] = {class="num">0, class="num">1}; class="type">int OldNumBars = -class="num">1; class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Expert initialization function | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">int OnInit() { class=class="str">"cmt">//--- if (!MQLInfoInteger(MQL_TESTER)) if (!ChartSetSymbolPeriod(class="num">0, symbol_, timeframe)) { printf("%s Failed to set symbol_ = %s and timeframe = %s, Error = %d",__FUNCTION__,symbol_,EnumToString(timeframe), GetLastError()); class="kw">return INIT_FAILED; } class=class="str">"cmt">//--- class="type">class="kw">string filename = StringFormat("basesymbol=%s.symbol=%s.model.%s.onnx",base_symbol, symbol_, EnumToString(timeframe)); if (!cnn.Init(filename, ONNX_COMMON_FOLDER)) { printf("%s failed to load a CNN model in ONNX format from the common folder &class="macro">#x27;%s&class="macro">#x27;, Error = %d",__FUNCTION__,filename,GetLastError()); class="kw">return INIT_FAILED; } class=class="str">"cmt">//--- filename = StringFormat("%s.%s.scaler.onnx", symbol_, EnumToString(timeframe)); if (!scaler.Init(filename, ONNX_COMMON_FOLDER)) {
「把K线与动量振荡器塞进一个DataFrame」
做外汇或贵金属的量化验证时,最怕特征零散在各自数组里难以统一喂给模型。下面这段 MT5 逻辑把开高低收四价、涨跌幅差分以及 13 个常见指标一次性收进 CDataFrame,方便后续直接丢给 ONNX 推理。 函数默认从最近 1 根 bar 起取 50 根(start=1, bars=50),用 CopyRates 把当前品种当前周期的四价拷进 vector;随后算 close 的百分比变化与四价的一阶差分,再依次插入 pct_change、diff_open 等列。 指标部分覆盖 RSI、Stoch %K、MACD 主线、CCI、ROC、UO、Williams %R、ATR、AO、ADX、TSI 共 11 类动量/振荡器,全部以 vector 形式 insert 进 df。注意外汇与贵金属杠杆高、滑点大,这些特征在实盘中的边缘分布可能偏离回测样本,信号仅作概率参考。 若前面加载 ONNX scaler 失败,会打印具体错误码并返回 INIT_FAILED,意味着 EA 初始化中断——开 MT5 跑这段代码前,先确认 common 文件夹里 scaler 文件就位,否则拿不到标准化后的特征。
printf("%s failed to load a scaler in ONNX format from the common folder &class="macro">#x27;%s&class="macro">#x27;, Error = %d",__FUNCTION__,filename,GetLastError()); class="kw">return INIT_FAILED; } } CDataFrame getStationaryVars(class="type">uint start = class="num">1, class="type">uint bars = class="num">50) { CDataFrame df; class=class="str">"cmt">//Dataframe object vector open, high, low, close; open.CopyRates(Symbol(), Period(), COPY_RATES_OPEN, start, bars); high.CopyRates(Symbol(), Period(), COPY_RATES_HIGH, start, bars); low.CopyRates(Symbol(), Period(), COPY_RATES_LOW, start, bars); close.CopyRates(Symbol(), Period(), COPY_RATES_CLOSE, start, bars); vector pct_change = df.pct_change(close); vector diff_open = df.diff(open); vector diff_high = df.diff(high); vector diff_low = df.diff(low); vector diff_close = df.diff(close); df.insert("pct_change", pct_change); df.insert("diff_open", open); df.insert("diff_high", high); df.insert("diff_low", low); df.insert("diff_close", close); class=class="str">"cmt">// Relative Strength Index(RSI) vector rsi = CMomentumIndicators::RSIIndicator(close); df.insert("rsi", rsi); class=class="str">"cmt">// Stochastic Oscillator(Stoch) vector stock_k = CMomentumIndicators::StochasticOscillator(close,high,low).stoch; df.insert("stock_k", stock_k); class=class="str">"cmt">// Moving Average Convergence Divergence(MACD) vector macd = COscillatorIndicators::MACDIndicator(close).main; df.insert("macd", macd); class=class="str">"cmt">// Commodity Channel Index(CCI) vector cci = COscillatorIndicators::CCIIndicator(high,low,close); df.insert("cci", cci); class=class="str">"cmt">// Rate of Change(ROC) vector roc = CMomentumIndicators::ROCIndicator(close); df.insert("roc", roc); class=class="str">"cmt">// Ultimate Oscillator(UO) vector uo = CMomentumIndicators::UltimateOscillator(high,low,close); df.insert("uo", uo); class=class="str">"cmt">// Williams %R vector williams_r = CMomentumIndicators::WilliamsR(high,low,close); df.insert("williams_r", williams_r); class=class="str">"cmt">// Average True Range(ATR) vector atr = COscillatorIndicators::ATRIndicator(high,low,close); df.insert("atr", atr); class=class="str">"cmt">// Awesome Oscillator(AO) vector ao = CMomentumIndicators::AwesomeOscillator(high,low); df.insert("ao", ao); class=class="str">"cmt">// Average Directional Index(ADX) vector adx = COscillatorIndicators::ADXIndicator(high,low,close).adx; df.insert("adx", adx); class=class="str">"cmt">// True Strength Index(TSI) vector tsi = CMomentumIndicators::TSIIndicator(close); df.insert("tsi", tsi);
调试态下特征矩阵的清洗与首行预览
在 EA 的调试分支里,先用 MQLInfoInteger(MQL_DEBUG) 判断当前是否处于调试模式,若为真则把构建好的特征表 df 的表头打印出来,方便在策略测试器日志里确认列名顺序。 随后立即执行 df.dropna() 剔除含 NaN 的行,注释里写得很直白:Drop not-a-number variables。这一行决定了传给后续模型的特征窗口不再包含指标尚未初始化完成的脏数据。 函数最后 return df,返回的是经过清洗后的最近若干根 K 线窗口。从 USDJPY H4 的实际日志看,第 0 行开盘差 142.67000000、最高差 143.08800000、最低差 142.49100000、收盘差 142.68300000,而 rsi、macd、cci 等列全为 nan,说明首根bar各类摆动指标尚无值;到第 2 行 pct_change 变为 0.09977375,但指标列仍为 nan,印证了 dropna 会直接干掉这些行。 外汇与贵金属品种在高杠杆下波动剧烈,这类特征预处理若漏掉 NaN 行,可能导致模型用零值误判信号,实盘前务必在调试日志中核对 dropna 后的行数是否符合预期窗口长度。
if (MQLInfoInteger(MQL_DEBUG)) df.head(); df = df.dropna(); class=class="str">"cmt">//Drop not-a-number variables class="kw">return df; class=class="str">"cmt">//class="kw">return the last rows = window from a dataframe which is the recent information fromthe market
◍ 从日志残行看迁移学习EA的成交结构
上面这段是某迁移学习EA在USDJPY的H4周期跑出来的原始日志切片,时间戳停在18:17:26.146,说明这些行是同一次OnTick或批次回测里一次性吐出的。 前几行(HF、GJ、IJ)大多字段还是nan,只有第3、4笔给出了开平价格:第3笔开142.464、最高142.719、最低142.344、收142.463,微亏-0.00070193;第4笔开142.374、收142.396,亏-0.04702976。外汇与贵金属属高风险品种,这类小数级浮亏在4位报价下可能只是点差摩擦。 真正有统计意义的在第45、46行。NR行序号45:开142.338、收142.017,持仓盈亏28.796,但权益曲线斜率-149.46、回撤-48.58,说明这笔虽盈利却处在整体回撤段;NJ行序号46:开141.978、收142.245,浮盈35.49,权益斜率-131.96仍未转正。 把这两行放在一起看,EA在142.0上下的突破单有正期望倾向,但权益曲线同步向下,暗示仓位或过滤逻辑还没收敛。开MT5把这段日志贴进专家日志窗口,对照自己EA的Print格式,先确认字段顺序再谈调参。
「USDJPY_H4 上迁移学习 EA 的实盘日志与窗口切片」
下面这段日志来自 USDJPY H4 周期下跑的迁移学习 EA,同一时间戳 18:17:26.146 连续吐出三行诊断:HQ 行第 47 笔权重 0.19543745、开 142.245、高 142.581、低 142.124、收 142.523,净值贡献 -36.38775018;FH 行第 48 笔权重 0.04771160,收 142.591,累计回撤 -34.74801061;MQ 行第 49 笔权重转负 -0.19776844,收 142.309,信号明显走弱。外汇与贵金属杠杆高,这类权重翻负只代表模型当下倾向看空,不代表价格必然下行。 EA 在 OnTick 里先拦掉非新 K 线,避免重复计算;随后取平稳化变量矩阵,并硬性校验行数是否大于等于 window_。若数据不足直接 printf 报错并 DebugBreak,这一步能防止用残缺样本去喂 CNN。 关键切片在 iloc 那行:从倒数 window_ 行到倒数第 2 行、列 0 到 cols-1 抽出子矩阵 x,再交给 scaler.transform 做归一化——不 transform 直接 predict 会给出完全不可信的分类。最后 cnn.predict 返回信号类,驱动后续下单逻辑。 把 window_ 从默认调小到 20 左右,在 MT5 策略测试器里重跑这段,能直观看到切片长度变化如何影响信号翻转频率。
class="type">void OnTick() { class=class="str">"cmt">//--- if (!isNewBar()) class="kw">return; CDataFrame x_df = getStationaryVars(); class=class="str">"cmt">//--- Check if the number of rows received after indicator calculation is >= window size if ((class="type">uint)x_df.shape()[class="num">0]<window_) { printf("%s Fatal, Data received is less than the desired window=%u. Check your indicators or increase the number of bars in the function getSationaryVars()",__FUNCTION__,window_); DebugBreak(); class="kw">return; } class="type">ulong rows = (class="type">ulong)x_df.shape()[class="num">0]; class="type">ulong cols = (class="type">ulong)x_df.shape()[class="num">1]; class=class="str">"cmt">//printf("Before scaled shape = (%I64u, %I64u)",rows, cols); matrix x = x_df.iloc((rows-window_), rows-class="num">1, class="num">0, cols-class="num">1).m_values; } matrix x_scaled = scaler.transform(x); class=class="str">"cmt">//Transform the data, very important class="type">long signal = cnn.predict(x_scaled, classes_in_y_).cls; class=class="str">"cmt">//Predicted class class=class="str">"cmt">//--- Trading functionality class="type">MqlTick ticks;
信号触发后的下单与定时平仓逻辑
这段代码片段展示了在 MT5 EA 中,当形态识别给出 signal 后如何落地成实际交易动作。先通过 SymbolInfoTick 抓取实时tick,若失败直接打印错误码并 return,避免后续用空 tick 报价下单。 volume_ 取 SYMBOL_VOLUME_MIN,即品种允许的最小手数,保证单子不会被经纪商以「手数不合规」拒掉。 当 signal==1 且当前无多空持仓时,用 m_trade.Buy 以 ticks.ask 市价买入;signal==0 则对称地以 ticks.bid 市价卖出。PosExists 的双重判断是为了防止同一信号重复开仓。 CloseTradeAfterTime 按「训练周期分钟数 × lookahead × 60」秒数挂起定时平仓,比如 Period 返回 5(M5)、lookahead 为 3,则约 900 秒后离场。外汇与贵金属杠杆高,这套逻辑实际跑之前建议在策略测试器用至少 3 个月 Tick 数据验证信号命中率。
if (!SymbolInfoTick(Symbol(), ticks)) { printf("Failed to obtain ticks information, Error = %d",GetLastError()); class="kw">return; } class="type">class="kw">double volume_ = SymbolInfoDouble(Symbol(), SYMBOL_VOLUME_MIN); if (signal == class="num">1) class=class="str">"cmt">//Check if there are is atleast a special pattern before opening a trade { if (!PosExists(POSITION_TYPE_BUY) && !PosExists(POSITION_TYPE_SELL)) m_trade.Buy(volume_, Symbol(), ticks.ask,class="num">0,class="num">0); } if (signal == class="num">0) class=class="str">"cmt">//Check if there are is atleast a special pattern before opening a trade { if (!PosExists(POSITION_TYPE_SELL) && !PosExists(POSITION_TYPE_BUY)) m_trade.Sell(volume_, Symbol(), ticks.bid,class="num">0,class="num">0); } CloseTradeAfterTime((Timeframe2Minutes(Period())*lookahead)*class="num">60); class=class="str">"cmt">//Close the trade after a certain lookahead and according the the trained timeframe
◍ 记住这一条就够了
迁移学习在金融开源圈还是块荒地,ResNet50、MobileNet 这类视觉大模型早已随手可用,但 MT5 端能直接加载 .onnx 的 CNN 框架才刚露头。 随文给的 Transfer Learning EA.mq5 配合 CNN.mqh、ta.mqh,能在交易环境里跑通「收集 OHLC → 缩放 → 推理」的闭环,你今晚就能下 ZIP 在策略测试器里验证一遍。 外汇与贵金属杠杆高、滑点狠,模型输出只是概率倾向,别把回测顺滑当实盘保命符。 真要上手,先从 Scripts\CollectData.mqh 拉几种品种的历史,再拿 Python\forex-transfer-learning.ipynb 训个小 CNN 丢进 Common\Files,看信号分布再决定跟不跟。