数据科学和机器学习(第 30 部分):预测股票市场的幂对、卷积神经网络(CNN)、和递归神经网络(RNN)·进阶篇
📘

数据科学和机器学习(第 30 部分):预测股票市场的幂对、卷积神经网络(CNN)、和递归神经网络(RNN)·进阶篇

第 2/3 篇

「把 CNN 特征流改造成 RNN 时态输入」

上一步抽出的特征不能直接喂给普通全连接层,要先过 RNN 这一关。RNN 在扫这些特征时会顺着时间轴走,把前后步的依赖关系带进权重里,这和本系列之前用 CNN 那套只吃空间局部性的逻辑不一样。 关键改动在 Flatten 层:原 CNN 架构里它把 3D 张量压成 2D,而 RNN、LSTM、GRU 全都要(批次大小、时间步长、特征)这种 3D 输入。所以动手搭网络时,Flatten 必须删掉,否则维度对不上直接报 shape 错。 下面这段 Keras 式堆叠就是替换后的主干:先 1D 池化降采样,接 50 单元的 SimpleRNN,两次 Dropout 0.5 防过拟合,末端 softmax 出信号分类。外汇与贵金属行情高波动、高杠杆,此类模型仅给出概率倾向,实盘前务必在 MT5 历史数据上回测。

MQL5 / C++
model.add(MaxPooling1D(pool_size=class="num">2))
model.add(SimpleRNN(class="num">50, activation=&class="macro">#x27;relu&class="macro">#x27;))
model.add(Dropout(class="num">0.5))
model.add(Dense(class="num">50, activation=&class="macro">#x27;relu&class="macro">#x27;))
model.add(Dropout(class="num">0.5))
model.add(Dense(units=len(np.unique(y)), activation=&class="macro">#x27;softmax&class="macro">#x27;))  # Softmax for binary classification(class="num">1 buy, class="num">0 sell signal)

把 RNN+CNN 模型塞进 MT5 实盘前要做的事

模型在 Python 端跑完 14 个 epoch 后,测试集准确率停在 54%,分类报告里类 0(卖出)precision 0.70 / recall 0.40,类 1(买入)precision 0.45 / recall 0.74,样本量 195。这个水平不算高,但作为简单 RNN 叠加 CNN 的基线已经能区分多空倾向。 训练好后必须存成 ONNX,同时把 StandardScaler 的 mean 和 scale 写成 bin 文件。MQL5 里用 #resource 把 onnx 和 scaler 参数挂成资源,在 OnInit 中初始化 CConvNet 与 StandardizationScaler,预测前先拿 scaler 归一化输入,再丢进 CNN 模型取信号和概率。 特征矩阵 x_data 按 3 列(Open、High、Low)和训练时相同的时间步长排布,类标识须升序(0 卖、1 买)。回测区间取 2020.01.01–2024.09.01,EA 挂在 4 小时图而非日线,因为日线新烛在收市才生成,会漏单;数据仍用 CopyRates 从日线取,不影响决策。该设置下 EA 盈利交易占比约 90%,但外汇/贵金属品种切换时此结果不必然复现,属高风险验证。 下面这段是 Python 训练与导出核心,以及 MQL5 资源挂载片段,逐行看便知衔接点。

MQL5 / C++
model.summary()
# Compile the model
optimizer = Adam(learning_rate=class="num">0.0001)
model.compile(optimizer=optimizer, loss=&class="macro">#x27;binary_crossentropy&class="macro">#x27;, metrics=[&class="macro">#x27;accuracy&class="macro">#x27;])
# Train the model
early_stopping = EarlyStopping(monitor=&class="macro">#x27;val_loss&class="macro">#x27;, patience=class="num">10, restore_best_weights=True)
history = model.fit(X_train, y_train_encoded, epochs=class="num">1000, batch_size=class="num">16, validation_split=class="num">0.2, callbacks=[early_stopping])
plt.figure(figsize=(class="num">7.5, class="num">6))
plt.plot(history.history[&class="macro">#x27;loss&class="macro">#x27;], label=&class="macro">#x27;Training Loss&class="macro">#x27;)
plt.plot(history.history[&class="macro">#x27;val_loss&class="macro">#x27;], label=&class="macro">#x27;Validation Loss&class="macro">#x27;)
plt.xlabel(&class="macro">#x27;Epochs&class="macro">#x27;)
plt.ylabel(&class="macro">#x27;Loss&class="macro">#x27;)
plt.title(&class="macro">#x27;Training Loss Curve&class="macro">#x27;)
plt.legend()
plt.savefig("training loss curve-rnn-cnn-clf.png")
plt.show()
# Evaluating the Trained Model
y_pred = model.predict(X_test)
classes_in_y = np.unique(y)
y_pred_binary = classes_in_y[np.argmax(y_pred, axis=class="num">1)]
# Confusion Matrix
cm = confusion_matrix(y_test, y_pred_binary)
sns.heatmap(cm, annot=True, fmt=&class="macro">#x27;d&class="macro">#x27;, cmap=&class="macro">#x27;Blues&class="macro">#x27;)
plt.xlabel("Predicted Label")
plt.ylabel("True Label")
plt.title("Confusion Matrix")
plt.savefig("confusion-matrix RNN + CNN.png")
print("Classification Report\n", classification_report(y_test, y_pred_binary))
onnx_file_name = "rnn+cnn.TSLA.D1.onnx"
spec = (tf.TensorSpec((None, time_step, X_train.shape[class="num">2]), tf.float16, name="class="kw">input"),)
model.output_names = [&class="macro">#x27;outputs&class="macro">#x27;]
onnx_model, _ = tf2onnx.convert.from_keras(model, input_signature=spec, opset=class="num">13)
with open(onnx_file_name, "wb") as f:
    f.write(onnx_model.SerializeToString())
scaler.mean_.tofile(f"{onnx_file_name.replace(&class="macro">#x27;.onnx&class="macro">#x27;,&class="macro">#x27;&class="macro">#x27;)}.standard_scaler_mean.bin")
scaler.scale_.tofile(f"{onnx_file_name.replace(&class="macro">#x27;.onnx&class="macro">#x27;,&class="macro">#x27;&class="macro">#x27;)}.standard_scaler_scale.bin")
class="macro">#include <MALE5\Convolutional Neural Networks(CNNs)\ConvNet.mqh>
class="macro">#include <MALE5\preprocessing.mqh>
CConvNet cnn;
StandardizationScaler *scaler;
class="macro">#resource "\Files\rnn+cnn.TSLA.D1.onnx" as class="type">uchar onnx_model[]
class="macro">#resource "\Files\rnn+cnn.TSLA.D1.standard_scaler_mean.bin" as class="type">class="kw">double standardization_mean[]

◍ 把CNN信号接进D1实盘前要先过标准化

在 MT5 里加载训练好的 CNN 模型,第一道坎不是预测,而是把盘口数据先过一遍与训练时一致的标准化。代码里用 #resourcernn+cnn.TSLA.D1.standard_scaler_scale.bin 读成 standardization_std[],再 new StandardizationScaler(standardization_mean, standardization_std) 把均值和标准差灌进缩放器,这一步漏掉,模型输入分布偏移,输出信号会直接失真。 新 K 线开盘才触发推理:NewBar() 为真时 CopyRates(Symbol(), PERIOD_D1, 1, time_step, rates) 取最近 time_step=5 根日线,把每根 open/high/low 填进 x_data 这个 5×3 矩阵。注意这里只用了三个价格字段,训练时若含 close 或 vol 就得改列数,否则维度对不上会初始化失败。 填完矩阵后 x_data = scaler.transform(x_data) 做归一,随后 cnn.predict_bin() 出 0/1 信号、cnn.predict_proba() 出各类概率,用 Comment 打到图表上方便肉眼核对。实盘里 signal==1 时先 ClosePos(POSITION_TYPE_SELL) 平空,再检查无多单后 m_trade.Buy(min_lot, ...) 开最小手数多单——外汇与贵金属杠杆高,这类基于历史分布的模型信号仅代表概率倾向,实盘前务必用策略测试器跑样本外验证。

MQL5 / C++
class="macro">#resource "\\Files\\rnn+cnn.TSLA.D1.standard_scaler_scale.bin" as class="type">class="kw">double standardization_std[]
class="type">int OnInit()
  {
class=class="str">"cmt">//---

   if (!cnn.Init(onnx_model)) class=class="str">"cmt">//Initialize the Convolutional neural network
     class="kw">return INIT_FAILED;

   scaler = new StandardizationScaler(standardization_mean, standardization_std); class=class="str">"cmt">//Initialize the saved scaler by populating it with values

   ...
   ...

    class="kw">return (INIT_SUCCEEDED);
  }
   if (NewBar()) class=class="str">"cmt">//Trade at the opening of a new candle
    {
      CopyRates(Symbol(), PERIOD_D1, class="num">1, time_step, rates);

      for (class="type">ulong i=class="num">0;  i<x_data.Rows(); i++)
        {
          x_data[i][class="num">0] = rates[i].open;
          x_data[i][class="num">1] = rates[i].high;
          x_data[i][class="num">2] = rates[i].low;
        }

   class=class="str">"cmt">//---

          x_data = scaler.transform(x_data); class=class="str">"cmt">//Normalize the data

      class="type">int signal = cnn.predict_bin(x_data, classes_in_data_); class=class="str">"cmt">//getting a trading signal from the RNN model
      vector probabilities = cnn.predict_proba(x_data);  class=class="str">"cmt">//probability for each class

      Comment("Probability = ",probabilities,"\nSignal = ",signal);
class="kw">input class="type">int time_step = class="num">5;
class="kw">input class="type">int magic_number = class="num">24092024;
class="kw">input class="type">int slippage = class="num">100;
class="type">MqlRates rates[];
matrix x_data(time_step, class="num">3); class=class="str">"cmt">//class="num">3 columns for open, high and low
vector classes_in_data_ = {class="num">0, class="num">1}; class=class="str">"cmt">//unique target variables as they are in the target variable in your training data
class="type">int OldNumBars = class="num">0;
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Expert initialization function                                     |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">int OnInit()
  {
class=class="str">"cmt">//---
   class="type">class="kw">double min_lot = SymbolInfoDouble(Symbol(), SYMBOL_VOLUME_MIN);

      class="type">MqlTick ticks;
      SymbolInfoTick(Symbol(), ticks);

      if (signal==class="num">1) class=class="str">"cmt">//if the signal is bullish
       {
          ClosePos(POSITION_TYPE_SELL); class=class="str">"cmt">//close sell trades when the signal is buy

          if (!PosExists(POSITION_TYPE_BUY)) class=class="str">"cmt">//There are no buy positions
          {
             if (!m_trade.Buy(min_lot, Symbol(), ticks.ask, class="num">0 , class="num">0)) class=class="str">"cmt">//Open a buy trade

「空头信号下的平仓与反向开仓逻辑」

当指标返回 signal==0 时,这段逻辑判定为空头信号,先平掉所有多单再决定是否开空。ClosePos(POSITION_TYPE_BUY) 会扫掉账户里全部买入持仓,避免多空对锁或逆势扛单。 紧接着用 PosExists(POSITION_TYPE_SELL) 检查是否已有卖单;若没有,才以 m_trade.Sell(min_lot, Symbol(), ticks.bid, 0, 0) 按当前 bid 价开最小手数空单。两个止损止盈参数传 0,意味着本段不挂硬性止损,实盘中外汇与贵金属波动剧烈,这种写法可能让单笔回撤失控。 若 Sell 返回失败,printf 会把 GetLastError() 的错误码打进日志,方便在 MT5 终端直接排查 4109(交易被禁)或 4756(报价过期)之类问题。建议把这段粘进 EA 的 OnTick 末尾,用欧美 5 分钟图跑一周模拟盘验证信号翻转时的成交表现。

MQL5 / C++
      printf("Failed to open a buy position err=%d",GetLastError());
     }
   }
   else if (signal==class="num">0) class=class="str">"cmt">//Bearish signal
   {
      ClosePos(POSITION_TYPE_BUY); class=class="str">"cmt">//close all buy trades when the signal is sell
      
      if (!PosExists(POSITION_TYPE_SELL)) class=class="str">"cmt">//There are no Sell positions
      {
         if (!m_trade.Sell(min_lot, Symbol(), ticks.bid, class="num">0 , class="num">0)) class=class="str">"cmt">//open a sell trade
            printf("Failed to open a sell position err=%d",GetLastError());
      }
   }
   else class=class="str">"cmt">//There was an error
      class="kw">return;

常见问题

先把CNN输出的二维特征图展平成一维序列,再按时间步切片喂给RNN;每个时间步特征数必须固定,否则RNN层会拒绝接收。
不标准化会导致不同品种量纲差异放大,模型输出偏移;建议用训练集的均值方差做Z-score,实盘每根新K线同步变换。
可以,小布能按你设定的训练统计量自动对新K线做标准化,并标记CNN信号是否越过置信阈值,省去手动核对。
优先在信号确认根收盘平多,反向开空等下一根开盘确认避免毛刺;外汇贵金属波动大,务必带止损。
核对特征工程与训练时一致、确认推理延迟低于行情刷新间隔、用历史片段做离线回放验证信号方向不漂移。