《数据科学与机器学习(第25部分):使用循环神经网络(RNN)进行外汇时间序列预测》·进阶篇
🔁

《数据科学与机器学习(第25部分):使用循环神经网络(RNN)进行外汇时间序列预测》·进阶篇

(2/3)· 跳过基础概念,直接拆解简单RNN在MT5中的训练、特征筛选与模型导出细节

偏理论进阶 第 2/3 篇

不少交易者把RNN当成黑箱直接套用现成脚本,却不知道序列数据怎么切、特征权重怎么看,导致回测漂亮实盘崩盘。本篇接上篇概念铺垫,继续深挖简单RNN在外汇预测里的具体工程实现。

「把滞后列清掉再切时序序列」

原始数据集有 28 列,其中 16 个带 'lag' 字样的滞后列(如 OPEN_LAG1~CLOSE_LAG3、DIFF_LAG1_OPEN 等)是为非时序模型准备的。RNN 本身能捕捉时间步内的依赖,这些滞后值反而冗余,必须整列 drop 掉,清完剩 12 列。 切分训练集和测试集时,比例取 70% / 30%。用 train_test_split 必须 shuffle=False,否则时序顺序被打乱,RNN 学到的就不是真实的价格演化路径。外汇与贵金属属高风险品种,顺序错乱会直接放大过拟合概率。 删掉 TARGET_CLOSE 和 TARGET_OPEN 两个目标列后,自变量剩 10 个特征。下面这段代码先抓 lag 列名再删除,然后不洗牌切分,最后用 create_sequences 按时间步长 7 转序列: [CODE] lagged_columns = [col for col in data.columns if "lag" in col.lower()] #let us obtain all the columns with the name lag print("lagged columns: ",lagged_columns) data = data.drop(columns=lagged_columns) #drop them lagged columns: ['OPEN_LAG1', 'HIGH_LAG1', 'LOW_LAG1', 'CLOSE_LAG1', 'OPEN_LAG2', 'HIGH_LAG2', 'LOW_LAG2', 'CLOSE_LAG2', 'OPEN_LAG3', 'HIGH_LAG3', 'LOW_LAG3', 'CLOSE_LAG3', 'DIFF_LAG1_OPEN', 'DIFF_LAG1_HIGH', 'DIFF_LAG1_LOW', 'DIFF_LAG1_CL # Split the data X = data.drop(columns=["TARGET_CLOSE","TARGET_OPEN"]) #dropping the target variables Y = data["TARGET_CLOSE"] test_size = 0.3 #70% of the data should be used for training purpose while the rest 30% should be used for testing x_train, x_test, y_train, y_test = train_test_split(X, Y, shuffle=False, test_size = test_size) # this is timeseries data so we don't shuffle print(f"x_train {x_train.shape}\nx_test {x_test.shape}\ny_train{y_train.shape}\ny_test{y_test.shape}") def create_sequences(X, Y, time_step): if len(X) != len(Y): raise ValueError("X and y must have the same length") X = np.array(X) Y = np.array(Y) Xs, Ys = [], [] for i in range(X.shape[0] - time_step): Xs.append(X[i:(i + time_step), :]) # Include all features with slicing Ys.append(Y[i + time_step]) return np.array(Xs), np.array(Ys) time_step = 7 #we consider the past 7 days from sklearn.preprocessing import StandardScaler scaler = StandardScaler() x_train = scaler.fit_transform(x_train) x_test = scaler.transform(x_test) x_train_seq, y_train_seq = create_sequences(x_train, y_train, time_step) x_test_seq, y_test_seq = create_sequences(x_test, y_test, time_step) print(f"Sequential data\n\nx_train {x_train_seq.shape}\nx_test {x_test_seq.shape}\ny_train{y_train_seq.shape}\ny_test{y_test_seq.shape}") Sequential data x_train (693, 7, 10) x_test (293, 7, 10) y_train(693,) y_test(293,) [/CODE] 逐行看:第1行用列表推导抓出所有含 'lag' 的列名;第3行 drop 掉它们。X 去掉两个目标列,Y 只留 TARGET_CLOSE。train_test_split 带 shuffle=False 是关键,不洗牌才能保住日线顺序。 create_sequences 逻辑:X、Y 先转 numpy 数组,for 循环从 0 跑到 len-时间步,每次取 X[i:i+7, :] 作输入窗口,对应的标签是 Y[i+7]。时间步设 7,等价于用过去 7 天日线信息预测第 8 天。 跑完得到 x_train 形状 (693, 7, 10)、x_test (293, 7, 10)——693 和 293 是序列样本数,7 是回溯天数,10 是特征数。开 MT5 导出日线 CSV 后,照这个 shape 核对自己的数据就能验证管道通不通。

MQL5 / C++
lagged_columns = [col for col in data.columns if "lag" in col.lower()] class="macro">#let us obtain all the columns with the name lag
print("lagged columns: ",lagged_columns)
data = data.drop(columns=lagged_columns) class="macro">#drop them
lagged columns:  [&class="macro">#x27;OPEN_LAG1&class="macro">#x27;, &class="macro">#x27;HIGH_LAG1&class="macro">#x27;, &class="macro">#x27;LOW_LAG1&class="macro">#x27;, &class="macro">#x27;CLOSE_LAG1&class="macro">#x27;, &class="macro">#x27;OPEN_LAG2&class="macro">#x27;, &class="macro">#x27;HIGH_LAG2&class="macro">#x27;, &class="macro">#x27;LOW_LAG2&class="macro">#x27;, &class="macro">#x27;CLOSE_LAG2&class="macro">#x27;, &class="macro">#x27;OPEN_LAG3&class="macro">#x27;, &class="macro">#x27;HIGH_LAG3&class="macro">#x27;, &class="macro">#x27;LOW_LAG3&class="macro">#x27;, &class="macro">#x27;CLOSE_LAG3&class="macro">#x27;, &class="macro">#x27;DIFF_LAG1_OPEN&class="macro">#x27;, &class="macro">#x27;DIFF_LAG1_HIGH&class="macro">#x27;, &class="macro">#x27;DIFF_LAG1_LOW&class="macro">#x27;, &class="macro">#x27;DIFF_LAG1_CL
# Split the data
X = data.drop(columns=["TARGET_CLOSE","TARGET_OPEN"]) class="macro">#dropping the target variables
Y = data["TARGET_CLOSE"]
test_size = class="num">0.3 #class="num">70% of the data should be used for training purpose while the rest class="num">30% should be used for testing
x_train, x_test, y_train, y_test = train_test_split(X, Y, shuffle=False, test_size = test_size) # this is timeseries data so we don&class="macro">#x27;t shuffle
print(f"x_train {x_train.shape}\nx_test {x_test.shape}\ny_train{y_train.shape}\ny_test{y_test.shape}")
def create_sequences(X, Y, time_step):
    if len(X) != len(Y):
        raise ValueError("X and y must have the same length")
    
    X = np.array(X)
    Y = np.array(Y)
    
    Xs, Ys = [], []
    
    for i in range(X.shape[class="num">0] - time_step):
        Xs.append(X[i:(i + time_step), :])  # Include all features with slicing
        Ys.append(Y[i + time_step])
        
    class="kw">return np.array(Xs), np.array(Ys)
time_step = class="num">7 class="macro">#we consider the past class="num">7 days
from sklearn.preprocessing class="kw">import StandardScaler
scaler = StandardScaler()
x_train = scaler.fit_transform(x_train)
x_test = scaler.transform(x_test)
x_train_seq, y_train_seq = create_sequences(x_train, y_train, time_step)
x_test_seq, y_test_seq = create_sequences(x_test, y_test, time_step)
print(f"Sequential data\n\nx_train {x_train_seq.shape}\nx_test {x_test_seq.shape}\ny_train{y_train_seq.shape}\ny_test{y_test_seq.shape}")
Sequential data
x_train(class="num">693, class="num">7, class="num">10)
x_test(class="num">293, class="num">7, class="num">10)
y_train(class="num">693,)
y_test(class="num">293,)

RNN 回归跑分落后树模型

在测试集上评估完表现后,这套简单循环网络的 R² 准确率落在 78%。回看前一篇的同标的回归任务,LightGBM 这类非时序模型拿过 86.76% 的准确率,说明在纯数值回归上,未堆深度的 RNN 暂时跑不赢梯度提升树。 训练时用了提前停止:监控验证集损失,连续 5 轮不改善就回滚到最优权重。日志里第 95–100 轮 val_loss 从 4.4433e-05 缓降到 4.4329e-05,模型仍在缓慢收敛但边际极小。 别把 RNN 当万能时序锤。若你的 MT5 导出序列用 LightGBM 已到 86%+,先别急着换循环层,先查序列窗口和特征滞后阶数是否喂对了。外汇与贵金属波动受事件驱动,模型回测高准确率不代表实盘概率占优,杠杆品种高风险。

MQL5 / C++
early_stopping = EarlyStopping(monitor=&class="macro">#x27;val_loss&class="macro">#x27;, patience=class="num">5, restore_best_weights=True)
history = reg_model.fit(x_train_seq, y_train_seq, epochs=class="num">100, batch_size=class="num">64, verbose=class="num">1, validation_data=(x_test_seq, y_test_seq), callbacks=[early_stopping])
Epoch class="num">95/class="num">100
class="num">11/class="num">11 ━━━━━━━━━━━━━━━━━━━━ 0s 8ms/step - loss: class="num">6.4504e-05 - val_loss: class="num">4.4433e-05
Epoch class="num">96/class="num">100
class="num">11/class="num">11 ━━━━━━━━━━━━━━━━━━━━ 0s 8ms/step - loss: class="num">6.4380e-05 - val_loss: class="num">4.4408e-05
Epoch class="num">97/class="num">100
class="num">11/class="num">11 ━━━━━━━━━━━━━━━━━━━━ 0s 8ms/step - loss: class="num">6.4259e-05 - val_loss: class="num">4.4386e-05
Epoch class="num">98/class="num">100
class="num">11/class="num">11 ━━━━━━━━━━━━━━━━━━━━ 0s 8ms/step - loss: class="num">6.4140e-05 - val_loss: class="num">4.4365e-05
Epoch class="num">99/class="num">100
class="num">11/class="num">11 ━━━━━━━━━━━━━━━━━━━━ 0s 7ms/step - loss: class="num">6.4024e-05 - val_loss: class="num">4.4346e-05
Epoch class="num">100/class="num">100
class="num">11/class="num">11 ━━━━━━━━━━━━━━━━━━━━ 0s 7ms/step - loss: class="num">6.3910e-05 - val_loss: class="num">4.4329e-05
from sklearn.metrics class="kw">import r2_score
y_pred = reg_model.predict(x_test_seq) # Make predictions on the test set
# Plot the actual vs predicted values
plt.figure(figsize=(class="num">12, class="num">6))
plt.plot(y_test_seq, label=&class="macro">#x27;Actual Values&class="macro">#x27;)
plt.plot(y_pred, label=&class="macro">#x27;Predicted Values&class="macro">#x27;)
plt.xlabel(&class="macro">#x27;Samples&class="macro">#x27;)
plt.ylabel(&class="macro">#x27;TARGET_CLOSE&class="macro">#x27;)
plt.title(&class="macro">#x27;Actual vs Predicted Values&class="macro">#x27;)
plt.legend()
plt.show()
print("RNN accuracy =",r2_score(y_test_seq, y_pred))

◍ SHAP 拆解 RNN 到底看了哪些行情片段

用 SHAP 跑了一遍 RNN 回归模型的决策归因,抽样 100 条训练序列、nsamples=100 做 KernelExplainer 近似计算。输出的 summary_plot 里,带最新时间步标记的特征权重明显压过最旧时间步——模型更像在盯「句子最后几个词」做判断,而不是均匀消化整段历史。 这种偏向近期信息的模式,对人类交易员未必符合直觉,但在机器学习里是常见现象:越靠近预测点的输入,梯度贡献往往越大。外汇与贵金属波动受突发流动性影响,近期价量信号权重高,也意味着模型对隔夜跳空类风险可能反应偏慢,实盘需警惕。 要注意的是,这里用的是 KernelExplainer 而非 DeepExplainer,后者在 RNN 上试过但报错频繁,所以归因结果只能作参考、不能当唯一依据。另外回归模型只预测下一根收盘/开盘价,不如分类器直接给方向信号实用,下一步该转向搭一个 RNN 分类器。

MQL5 / C++
class="kw">import shap
# Wrap the model prediction for KernelExplainer
def rnn_predict(data):
    data = data.reshape((data.shape[class="num">0], time_step, x_train.shape[class="num">1]))
    class="kw">return reg_model.predict(data).flatten()
# Use SHAP to explain the model
sampled_idx = np.random.choice(len(x_train_seq), size=class="num">100, replace=False)
explainer = shap.KernelExplainer(rnn_predict, x_train_seq[sampled_idx].reshape(class="num">100, -class="num">1))
shap_values = explainer.shap_values(x_test_seq[:class="num">100].reshape(class="num">100, -class="num">1), nsamples=class="num">100)
# Update feature names for SHAP
feature_names = [f&class="macro">#x27;{original_feat}_t{t}&class="macro">#x27; for t in range(time_step) for original_feat in X.columns]
# Plot the SHAP values
shap.summary_plot(shap_values, x_test_seq[:class="num">100].reshape(class="num">100, -class="num">1), feature_names=feature_names, max_display=len(feature_names), show=False)
# Adjust layout and set figure size
plt.subplots_adjust(left=class="num">0.12, bottom=class="num">0.1, right=class="num">0.9, top=class="num">0.9)  
plt.gcf().set_size_inches(class="num">7.5, class="num">14) 
plt.tight_layout()
plt.savefig("regressor-rnn feature-importance.png")
plt.show()

「用RNN给K线方向做二分类」

把回归思路搬来分类,第一件事是改目标变量:不再预测数值,而是标出每根K线是收高于开(偏多)还是否则偏空。下面这段就是按收盘价与开盘价比较打 0/1 标签,再转成独热编码喂给序列。 序列切好之后要立刻做 one-hot,否则后面建模会维度对不上。代码里 y_train_encoded 形状是 (693, 2)、y_test_encoded 是 (293, 2),说明训练集 693 个样本、测试集 293 个,二分类两列。 网络尾巴和回归器不同:最后一层用 sigmoid,单元数必须等于类别数,这里写死 2 个。训练时设了 EarlyStopping(patience=5),但实际跑下来 6 个 epoch 左右就收敛了,val_loss 在 0.687 附近横住。 在准确率上,这个 RNN 分类器比 LightGBM 分类器高出约 3 个百分点;不过外汇与贵金属波动随机性强,实盘信号失效概率不低,任何模型输出只作概率参考。

MQL5 / C++
Y = []
target_open = data["TARGET_OPEN"]
target_close = data["TARGET_CLOSE"]
for i in range(len(target_open)):
    if target_close[i] > target_open[i]: # if the candle closed above where it opened thats a buy signal
        Y.append(class="num">1)
    else: class="macro">#otherwise it is a sell signal
        Y.append(class="num">0)
        
Y = np.array(Y) class="macro">#converting this array to NumPy
classes_in_y = np.unique(Y) # obtaining classes present in the target variable for the sake of setting the number of outputs in the RNN
from tensorflow.keras.utils class="kw">import to_categorical
y_train_encoded = to_categorical(y_train_seq)
y_test_encoded = to_categorical(y_test_seq)
print(f"One hot encoded\n\ny_train {y_train_encoded.shape}\ny_test {y_test_encoded.shape}")
One hot encoded
y_train(class="num">693, class="num">2)
y_test(class="num">293, class="num">2)
cls_model = Sequential()
cls_model.add(Input(shape=(time_step, x_train.shape[class="num">1]))) # input layer
cls_model.add(SimpleRNN(class="num">50, activation=&class="macro">#x27;relu&class="macro">#x27;))
cls_model.add(Dense(class="num">50, activation=&class="macro">#x27;relu&class="macro">#x27;))
cls_model.add(Dense(units=len(classes_in_y), activation=&class="macro">#x27;sigmoid&class="macro">#x27;, name=&class="macro">#x27;outputs&class="macro">#x27;))
adam_optimizer = Adam(learning_rate = class="num">0.001)
cls_model.compile(optimizer=adam_optimizer, loss=&class="macro">#x27;binary_crossentropy&class="macro">#x27;) # Compile the model
cls_model.summary()
early_stopping = EarlyStopping(monitor=&class="macro">#x27;val_loss&class="macro">#x27;, patience=class="num">5, restore_best_weights=True)
history = cls_model.fit(x_train_seq, y_train_encoded, epochs=class="num">100, batch_size=class="num">64, verbose=class="num">1, validation_data=(x_test_seq, y_test_encoded), callbacks=[early_stopping])
Model: "sequential_1"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┓
┃ Layer(type)                 ┃ Output Shape           ┃    Param # ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ simple_rnn_1(SimpleRNN)      │ (None, class="num">50)             │      class="num">3,class="num">050 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dense_2(Dense)                │ (None, class="num">50)             │      class="num">2,class="num">550 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ outputs(Dense)                │ (None, class="num">2)              │        class="num">102 │
└─────────────────────────────────┴────────────────────────┴───────────────┘
Epoch class="num">1/class="num">100
class="num">11/class="num">11 ━━━━━━━━━━━━━━━━━━━━ 2s 36ms/step - loss: class="num">0.7242 - val_loss: class="num">0.6872
Epoch class="num">2/class="num">100
class="num">11/class="num">11 ━━━━━━━━━━━━━━━━━━━━ 0s 9ms/step - loss: class="num">0.6883 - val_loss: class="num">0.6891
Epoch class="num">3/class="num">100
class="num">11/class="num">11 ━━━━━━━━━━━━━━━━━━━━ 0s 8ms/step - loss: class="num">0.6817 - val_loss: class="num">0.6909
Epoch class="num">4/class="num">100
class="num">11/class="num">11 ━━━━━━━━━━━━━━━━━━━━ 0s 8ms/step - loss: class="num">0.6780 - val_loss: class="num">0.6940
Epoch class="num">5/class="num">100
class="num">11/class="num">11 ━━━━━━━━━━━━━━━━━━━━ 0s 8ms/step - loss: class="num">0.6743 - val_loss: class="num">0.6974
Epoch class="num">6/class="num">100

分类报告里的真实命中率

上面这段输出是模型跑完 11 轮训练后的终端日志,最后落在一份分类报告上。训练损失停在 0.6707,验证损失 0.6998,两者差距不大,说明没明显过拟合,但绝对水平都不低。 报告按 0、1 两类给出精度:类别 0 的 precision 0.53、recall 0.27,f1 只有 0.36,样本 137 个;类别 1 的 precision 0.55、recall 0.79,f1 0.65,样本 156 个。整体 accuracy 0.55,macro avg f1 0.50,weighted avg f1 0.51。 类别 0 的召回率掉到 0.27,意味着真实为 0 的样本里七成以上被错判成 1。如果你拿这套标签做欧元兑美元或黄金的波段过滤,类别 0 对应的那侧信号大概率会漏掉大半,实战里可能倾向错过起涨段。外汇与贵金属波动受事件驱动强,这种概率级模型只能做辅助,高风险始终在。

MQL5 / C++
class="num">11/class="num">11 ━━━━━━━━━━━━━━━━━━━━ 0s 8ms/step - loss: class="num">0.6707 - val_loss: class="num">0.6998
class="num">10/class="num">10 ━━━━━━━━━━━━━━━━━━━━ 0s 19ms/step
Classification Report
              precision    recall  f1-score   support
           class="num">0       class="num">0.53      class="num">0.27      class="num">0.36       class="num">137
           class="num">1       class="num">0.55      class="num">0.79      class="num">0.65       class="num">156
    accuracy                           class="num">0.55       class="num">293
   macro avg       class="num">0.54      class="num">0.53      class="num">0.50       class="num">293
weighted avg       class="num">0.54      class="num">0.55      class="num">0.51       class="num">293

◍ 把RNN分类器落盘成MT5能跑的ONNX

Keras的循环神经网络不像Scikit-learn模型那样能直接走Pipeline导出,想进MetaTrader 5必须转成ONNX。缩放环节有两种路:要么在MQL5里收完数据现场缩放,要么在Python侧把标准化器的参数存好、用MT5预处理库加载,两条都能避开管道不兼容的坑。 标准化缩放器的核心就是均值和标准差,只要把这两组数组原样写出成二进制,缩放逻辑就完整保留了,后续MT5侧读入即可对齐训练时的分布。外汇与贵金属行情高波动,模型推理仅作概率参考,不代表方向必现。 下面这段把时序输入定死为time_step长度、特征数为x_train列数,用opset 13导出rnn.EURUSD.D1.onnx,顺手把mean_和scale_存成bin。开MT5把这三个文件丢进MQL5/Files,就能在EA里调ONNX推理了。

MQL5 / C++
class="kw">import tf2onnx
# Convert the Keras model to ONNX
spec = (tf.TensorSpec((None, time_step, x_train.shape[class="num">1]), tf.float16, name="input"),)
cls_model.output_names=[&class="macro">#x27;output&class="macro">#x27;]
onnx_model, _ = tf2onnx.convert.from_keras(cls_model, input_signature=spec, opset=class="num">13)
# Save the ONNX model to a file
with open("rnn.EURUSD.D1.onnx", "wb") as f:
    f.write(onnx_model.SerializeToString())
# Save the mean and scale parameters to binary files
scaler.mean_.tofile("standard_scaler_mean.bin")
scaler.scale_.tofile("standard_scaler_scale.bin")
把重复劳动交给小布
这些RNN特征重要性与序列诊断,小布盯盘的AIGC模块已内置常用检查,打开对应品种页即可看到,你专注模型调参和决策。

常见问题

简单RNN只有序列记忆且易遇短期记忆衰减,LSTM通过门控缓解梯度消失,本篇聚焦简单RNN的工程落地。
窗口长度取决于品种波动周期与采样频率,需结合回测稳定性调,本篇提供基础切分方法供进阶实测。
可以,小布盯盘的AIGC已内置特征诊断与异动提示,对应品种页直接显示,省去手动跑脚本的重复劳动。
回归倾向预测具体价格变动幅度,分类倾向方向概率,本篇第4节给分类训练示例。
需确认ONNX算子支持范围与MQL5的ONNX API匹配,本篇第6节覆盖保存与调用要点。