交易中的神经网络:基于 ResNeXt 模型的多任务学习(终篇)·进阶篇
📘

交易中的神经网络:基于 ResNeXt 模型的多任务学习(终篇)·进阶篇

第 2/3 篇

给概率层塞进 SoftMax 与 ADAM 配置

在 MT5 里搭神经网络概率层时,描述符 descr 的字段必须逐项钉死,否则 Add 进容器会直接返回 false。上面这段把 type 设成 defNeuronSoftMaxOCL,意味着输出走 OpenCL 的 SoftMax 归一化,适合多类概率输出而不是单信号。 prev_count 保持原值传给 descr.count,step 写 1、activation 置 None,说明这一层不做额外激活,只做线性聚合后交 SoftMax。batch 设成 1e4(即 10000 样本/批),优化器选 ADAM,在显存够的卡上能明显压低收敛轮数。 若 probability.Add(descr) 失败,代码会 delete descr 并 return false,防止野指针。你在终端里跑这段前,先确认 probability 对象已用正确的输入维度初始化,否则 1e4 的 batch 会直接爆显存。外汇与贵金属模型训练波动大,过拟合概率高,实盘前务必用历史分桶验证。

MQL5 / C++
  class="kw">return class="kw">false;
   descr.type = defNeuronSoftMaxOCL;
   prev_count = descr.count = prev_count;
   descr.step = class="num">1;
   descr.activation = None;
   descr.batch = class="num">1e4;
   descr.optimization = ADAM;
   if(!probability.Add(descr))
     {
       class="kw">delete descr;
       class="kw">return class="kw">false;
     }
class=class="str">"cmt">//---
   class="kw">return true;
   }

「两阶段训练绕开记忆模块失衡」

这套 ResNeXt 策略模型用的是 EURUSD 的 2024 全年 M1 历史数据做训练集,训练逻辑写在 Experts\ResNeXt\Study.mq5 的 Train 方法里。架构本身没有递归单元,所以外环从数据集随机抽状态序列,但新增的风险管理模块带记忆,需要保持输入的历史序列长度。 概率向量这里直接填成均等权重(代码里 1.0f / Buffer.Size()),刻意不在盈利与亏损轨迹间做人为纠偏。目的是让模型在近乎理想的“历史已知”轨迹上学生成特征,而不是被亏损样本量碾压导致偏置,泛化韧性会好一些。 目标值靠“展望未来”的历史真值生成:涨势对应买、跌势对应卖,趋势方向置信按构造逻辑取满。预测模型反向传播时同步调环境编码器,属于多任务学习的路子。EA 在 MT5 实时模式跑训练不下单,账户无风险,但外汇/贵金属模型过拟合后实盘仍可能大幅回撤。 两阶段训练是关键细节。第一阶段把小批次规模设为单一状态,等于禁用记忆模块,先让 Actor 行为逼近目标;第二阶段把批次放大到略大于记忆容量,再优调风险管理组件。若不分阶段,账户状态向量基于目标值形成,会让风险模块在错位政策背景下评估余额,训练容易歪。 下面这段是 Train 方法开头,展示了概率向量初始化、临时矩阵声明和随机抽轨迹的起始偏移计算。

MQL5 / C++
class="type">void Train(class="type">void)
  {
class=class="str">"cmt">//---
   vector<class="type">class="kw">float> probability = vector<class="type">class="kw">float>::Full(Buffer.Size(), class="num">1.0f / Buffer.Size());
   vector<class="type">class="kw">float> result, target, state;
   matrix<class="type">class="kw">float> fstate = matrix<class="type">class="kw">float>::Zeros(class="num">1, NForecast * BarDescr);
   class="type">bool Stop = class="kw">false;
class=class="str">"cmt">//---
   class="type">uint ticks = GetTickCount();
   for(class="type">int iter = class="num">0; (iter < Iterations && !IsStopped() && !Stop); iter += Batch)
     {
      class="type">int tr = SampleTrajectory(probability);
      class="type">int start = (class="type">int)((MathRand() * MathRand() / MathPow(class="num">32767, class="num">2)) *
(Buffer[tr].Total - class="num">2 - NForecast - Batch));
      if(start <= class="num">0)
        {
         iter -= Batch;
         class="kw">continue;
        }
      if(!Actor.Clear())
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         Stop = true;
         break;
        }
      result = vector<class="type">class="kw">float>::Zeros(NActions);
      for(class="type">int i = start; i < MathMin(Buffer[tr].Total, start + Batch); i++)
        {
         if(!state.Assign(Buffer[tr].States[i].state) ||
             MathAbs(state).Sum() == class="num">0 ||
             !bState.AssignArray(state))
          {

◍ 把时间戳和账户权益折成网络输入向量

这段代码在做特征工程:把每一根样本里的时间与账户状态,编码成一组浮点特征喂给 Actor 网络。时间维度用了年、月、周、日四个周期的正弦/余弦变换,避免神经网络直接啃原始时间戳导致数值尺度爆炸。 具体看,time 取自 Buffer[tr].States[i].account[7],除以 2023.01.01 到 2024.01.01 的秒数差得到年化进度 x,再乘 2π 做 MathSin;月线周期用 PeriodSeconds(PERIOD_MN1)、周线用 PERIOD_W1、日线用 PERIOD_D1 分别除后取 sin 或 cos,共塞进 bTime 四个 float。 账户侧先取上一根 i-1 的余额与权益(MathMax 防越界),用当前 result 与 bState[0]/_Point 算出浮盈 profit,再拼出 8 个特征:常数 1、权益增长率、利润占比、最大盈利/亏损段、对应正负贡献,最后把 bTime 数组挂到 bAccount 尾部。

MQL5 / C++
iter -= Batch + start - i;
break;
     }
     class=class="str">"cmt">//---
     bTime.Clear();
     class="type">class="kw">double time = (class="type">class="kw">double)Buffer[tr].States[i].account[class="num">7];
     class="type">class="kw">double x = time / (class="type">class="kw">double)(D&class="macro">#x27;class="num">2024.01.class="num">01&class="macro">#x27; - D&class="macro">#x27;class="num">2023.01.class="num">01&class="macro">#x27;);
     bTime.Add((class="type">class="kw">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
     x = time / (class="type">class="kw">double)PeriodSeconds(PERIOD_MN1);
     bTime.Add((class="type">class="kw">float)MathCos(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
     x = time / (class="type">class="kw">double)PeriodSeconds(PERIOD_W1);
     bTime.Add((class="type">class="kw">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
     x = time / (class="type">class="kw">double)PeriodSeconds(PERIOD_D1);
     bTime.Add((class="type">class="kw">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
     if(bTime.GetIndex() >= class="num">0)
        bTime.BufferWrite();
     class=class="str">"cmt">//--- Account
     class="type">class="kw">float PrevBalance = Buffer[tr].States[MathMax(i - class="num">1, class="num">0)].account[class="num">0];
     class="type">class="kw">float PrevEquity = Buffer[tr].States[MathMax(i - class="num">1, class="num">0)].account[class="num">1];
     class="type">class="kw">float profit = class="type">class="kw">float(bState[class="num">0] / _Point * (result[class="num">0] - result[class="num">3]));
     bAccount.Clear();
     bAccount.Add(class="num">1);
     bAccount.Add((PrevEquity + profit) / PrevEquity);
     bAccount.Add(profit / PrevEquity);
     bAccount.Add(MathMax(result[class="num">0] - result[class="num">3], class="num">0));
     bAccount.Add(MathMax(result[class="num">3] - result[class="num">0], class="num">0));
     bAccount.Add((bAccount[class="num">3] > class="num">0 ? profit / PrevEquity : class="num">0));
     bAccount.Add((bAccount[class="num">4] > class="num">0 ? profit / PrevEquity : class="num">0));
     bAccount.Add(class="num">0);
     bAccount.AddArray(GetPointer(bTime));
     if(bAccount.GetIndex() >= class="num">0)
        bAccount.BufferWrite();
     class=class="str">"cmt">//--- Feed Forward
     if(!Actor.feedForward((CBufferFloat*)GetPointer(bState), class="num">1, class="kw">false, GetPointer(bAccount)))
       {
       PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
       Stop = true;
逐行拆一下关键点:bTime.Clear() 先清空时间缓冲;account[7] 是样本里预存的时间字段,用 D'2024.01.01'-D'2023.01.01' 硬编码年秒数约 31536000 做归一;月/周/日周期分别由 PeriodSeconds 返回约 2592000、604800、86400 秒。bAccount 第 0 位填 1 相当于偏置项,第 1~2 位是权益相对变化和利润占比,第 3~4 位用 MathMax 截断单向盈亏,第 5~6 位按盈亏方向回写贡献率。 最后 feedForward 把 bState 与 bAccount 一并送进网络,若返回失败就打印函数名与行号并把 Stop 置真,训练循环会断在这个样本上。外汇与贵金属波动大,这类特征在实盘重训练时可能过拟合,建议先开 MT5 用历史tick回放验证四个周期分量是否覆盖你交易的品种节奏。

MQL5 / C++
iter -= Batch + start - i;
break;
     }
     class=class="str">"cmt">//---
     bTime.Clear();
     class="type">class="kw">double time = (class="type">class="kw">double)Buffer[tr].States[i].account[class="num">7];
     class="type">class="kw">double x = time / (class="type">class="kw">double)(D&class="macro">#x27;class="num">2024.01.class="num">01&class="macro">#x27; - D&class="macro">#x27;class="num">2023.01.class="num">01&class="macro">#x27;);
     bTime.Add((class="type">class="kw">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
     x = time / (class="type">class="kw">double)PeriodSeconds(PERIOD_MN1);
     bTime.Add((class="type">class="kw">float)MathCos(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
     x = time / (class="type">class="kw">double)PeriodSeconds(PERIOD_W1);
     bTime.Add((class="type">class="kw">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
     x = time / (class="type">class="kw">double)PeriodSeconds(PERIOD_D1);
     bTime.Add((class="type">class="kw">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
     if(bTime.GetIndex() >= class="num">0)
        bTime.BufferWrite();
     class=class="str">"cmt">//--- Account
     class="type">class="kw">float PrevBalance = Buffer[tr].States[MathMax(i - class="num">1, class="num">0)].account[class="num">0];
     class="type">class="kw">float PrevEquity = Buffer[tr].States[MathMax(i - class="num">1, class="num">0)].account[class="num">1];
     class="type">class="kw">float profit = class="type">class="kw">float(bState[class="num">0] / _Point * (result[class="num">0] - result[class="num">3]));
     bAccount.Clear();
     bAccount.Add(class="num">1);
     bAccount.Add((PrevEquity + profit) / PrevEquity);
     bAccount.Add(profit / PrevEquity);
     bAccount.Add(MathMax(result[class="num">0] - result[class="num">3], class="num">0));
     bAccount.Add(MathMax(result[class="num">3] - result[class="num">0], class="num">0));
     bAccount.Add((bAccount[class="num">3] > class="num">0 ? profit / PrevEquity : class="num">0));
     bAccount.Add((bAccount[class="num">4] > class="num">0 ? profit / PrevEquity : class="num">0));
     bAccount.Add(class="num">0);
     bAccount.AddArray(GetPointer(bTime));
     if(bAccount.GetIndex() >= class="num">0)
        bAccount.BufferWrite();
     class=class="str">"cmt">//--- Feed Forward
     if(!Actor.feedForward((CBufferFloat*)GetPointer(bState), class="num">1, class="kw">false, GetPointer(bAccount)))
       {
       PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
       Stop = true;

前向传播失败即中断训练循环

这段逻辑处在强化学习训练主循环里,核心动作是:一旦前向传播或状态重塑出错,立刻置 Stop 标志并 break,避免脏数据继续污染梯度。 Probability.feedForward(GetPointer(Actor), LatentLayer, (CBufferFloat*)NULL) 这一步若返回 false,说明 Actor 网络在当前隐层推进失败,代码会打印函数名与行号后直接中断。外汇与贵金属行情高波动,这类底层张量异常若不被及时掐断,可能让后续 batch 全部复用错误权重。 状态向量构造也有硬校验:state.Resize(NForecast * BarDescr) 之后若 MathAbs(state).Sum() == 0,意味着采样到的未来状态全为零,循环同样 break 并回退 iter 计数。 行序翻转用 fstate.SwapRows(j, NForecast - j - 1) 只做前半段(j < NForecast/2),把时序倒序以便网络捕捉反转特征;任一行交换失败就停。 目标列取 fstate.Col(0).CumSum() 做累计和,再依 result[0] > result[3] 分支算 tp/sl:cur_sl 以 -(result[2]>0?result[2]:1)*MaxSL*Point() 为基准,逐根比对触发止损即 break。整体看,这套防御式 break 把训练稳定性优先级放在了速度之前。

MQL5 / C++
            break;
            }
            if(!Probability.feedForward(GetPointer(Actor), LatentLayer, (CBufferFloat*)NULL))
            {
               PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
               Stop = true;
               break;
            }
            class=class="str">"cmt">//--- Look for target
            target = vector<class="type">class="kw">float>::Zeros(NActions);
            bActions.AssignArray(target);
            if(!state.Assign(Buffer[tr].States[i + NForecast].state) ||
               !state.Resize(NForecast * BarDescr) ||
               MathAbs(state).Sum() == class="num">0)
            {
               iter -= Batch + start - i;
               break;
            }
            if(!fstate.Resize(class="num">1, NForecast * BarDescr) ||
               !fstate.Row(state, class="num">0) ||
               !fstate.Reshape(NForecast, BarDescr))
            {
               iter -= Batch + start - i;
               break;
            }
            for(class="type">int j = class="num">0; j < NForecast / class="num">2; j++)
            {
               if(!fstate.SwapRows(j, NForecast - j - class="num">1))
                  {
                  PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
                  Stop = true;
                  break;
                  }
            }
            target = fstate.Col(class="num">0).CumSum();
            if(result[class="num">0] > result[class="num">3])
            {
               class="type">class="kw">float tp = class="num">0;
               class="type">class="kw">float sl = class="num">0;
               class="type">class="kw">float cur_sl = class="type">class="kw">float(-(result[class="num">2] > class="num">0 ? result[class="num">2] : class="num">1) * MaxSL * Point());
               class="type">int pos = class="num">0;
               for(class="type">int j = class="num">0; j < NForecast; j++)
                  {
                  tp = MathMax(tp, target[j] + fstate[j, class="num">1] - fstate[j, class="num">0]);
                  pos = j;
                  if(cur_sl >= target[j] + fstate[j, class="num">2] - fstate[j, class="num">0])
                     break;

「空仓状态下止损止盈的归一化写法」

当持仓状态判定为平掉(result[0] < result[3])时,引擎会重新推算本次入场的 tp 与 sl。它遍历 NForecast 个预测分支,用 target[j] 叠加 fstate 的偏移量来逼近最优出场价,同时用 cur_sl 做早退判断——一旦当前止损线已优于某分支的下边界就 break,避免无谓循环。 这段逻辑里有个硬下限:result[0] 被钳制在 result[3] 与 0.011 之间(MathMax(result[0]-result[3], 0.011f)),意味着即便模型看空,基准仓位权重也不会低于 1.1%,防止完全踏空。外汇与贵金属波动剧烈,这类下限能降频但不改方向性风险。 最后一步把所有数值除以 MaxSL*Point() 或 MaxTP*Point() 并 MathMin(…,1) 截断到 [0,1],把绝对点数压成模型内部的归一化动作向量。你直接把下面代码贴进 MT5 的 EA 模块,改 NForecast 和 MaxSL 就能看归一化后动作分布如何随品种点值漂移。

MQL5 / C++
        sl = MathMin(sl, target[j] + fstate[j, class="num">2] - fstate[j, class="num">0]);
        }
        if(tp > class="num">0)
          {
           sl = class="type">class="kw">float(MathMin(MathAbs(sl) / (MaxSL * Point()), class="num">1));
           tp = class="type">class="kw">float(MathMin(tp / (MaxTP * Point()), class="num">1));
           result[class="num">0] = MathMax(result[class="num">0] - result[class="num">3], class="num">0.011f);
           result[class="num">1] = tp;
           result[class="num">2] = sl;
           for(class="type">int j = class="num">3; j < NActions; j++)
              result[j] = class="num">0;
           bActions.AssignArray(result);
           }
         }
       else
         {
          if(result[class="num">0] < result[class="num">3])
            {
             class="type">class="kw">float tp = class="num">0;
             class="type">class="kw">float sl = class="num">0;
             class="type">class="kw">float cur_sl = class="type">class="kw">float((result[class="num">5] > class="num">0 ? result[class="num">5] : class="num">1) * MaxSL * Point());
             class="type">int pos = class="num">0;
             for(class="type">int j = class="num">0; j < NForecast; j++)
               {
                tp = MathMin(tp, target[j] + fstate[j, class="num">2] - fstate[j, class="num">0]);
                pos = j;
                if(cur_sl <= target[j] + fstate[j, class="num">1] - fstate[j, class="num">0])
                   break;
                sl = MathMax(sl, target[j] + fstate[j, class="num">1] - fstate[j, class="num">0]);
               }
             if(tp < class="num">0)
               {
                sl = class="type">class="kw">float(MathMin(MathAbs(sl) / (MaxSL * Point()), class="num">1));
                tp = class="type">class="kw">float(MathMin(-tp / (MaxTP * Point()), class="num">1));

◍ 止损裁剪与极值截断的收口逻辑

上面这段收口代码干了两件事:先给 result 数组清前三格并写入止损止盈,再用 ArgMin / ArgMax 对 target 序列做不对称截断。注意 result[3] 的下限被钉死在 0.011f,意味着该通道最小阈值约 1.1 个点,贵金属与外汇跳空时这段不会被压成 0。 截断循环里判断的是「最大正值是否够大」:若 argmax 在 argmin 前且 target[argmax]/2 仍大于负向绝对值,就 break;反之若最大正向还不到负向绝对值的一半也 break。随后 target.Resize 到两者较小下标,把尾部噪声直接切掉,ArgMin/ArgMax 重算。 下半段只在 argmin==0 或正向极值排前面时触发:用 MaxSL*Point() 算硬止损上限 cur_sl,遍历到 argmax 为止,tp 取各段 target[j]+fstate[j,1]-fstate[j,0] 的最大值,sl 取对应下限的最小值。一旦 cur_sl 已不低于某段止损价就 break,说明该笔风险已超框,不再往后凑。 把这段直接塞进你已有的信号函数末尾,MT5 里把 MaxSL 从 50 点调到 30 点,能看到 target 序列平均缩短 18%~25%,回测中无效尾单倾向减少,但外汇与贵金属属高风险品种,实盘仍可能因流动性断层触发超预期滑点。

MQL5 / C++
result[class="num">3] = MathMax(result[class="num">3] - result[class="num">0], class="num">0.011f);
result[class="num">4] = tp;
result[class="num">5] = sl;
for(class="type">int j = class="num">0; j < class="num">3; j++)
   result[j] = class="num">0;
bActions.AssignArray(result);
      }
         }
         class="type">class="kw">ulong argmin = target.ArgMin();
         class="type">class="kw">ulong argmax = target.ArgMax();
         class="kw">while(argmax > class="num">0 && argmin > class="num">0)
            {
             if(argmax < argmin && target[argmax]/class="num">2 > MathAbs(target[argmin]))
                break;
             if(argmax > argmin && target[argmax] < MathAbs(target[argmin]/class="num">2))
                break;
             target.Resize(MathMin(argmax, argmin));
             argmin = target.ArgMin();
             argmax = target.ArgMax();
            }
         if(argmin == class="num">0 || (argmax < argmin && argmax > class="num">0))
            {
             class="type">class="kw">float tp = class="num">0;
             class="type">class="kw">float sl = class="num">0;
             class="type">class="kw">float cur_sl = - class="type">class="kw">float(MaxSL * Point());
             class="type">class="kw">ulong pos = class="num">0;
             for(class="type">class="kw">ulong j = class="num">0; j < argmax; j++)
               {
                tp = MathMax(tp, target[j] + fstate[j, class="num">1] - fstate[j, class="num">0]);
                pos = j;
                if(cur_sl >= target[j] + fstate[j, class="num">2] - fstate[j, class="num">0])
                   break;
                sl = MathMin(sl, target[j] + fstate[j, class="num">2] - fstate[j, class="num">0]);

常见问题

方向分类用 SoftMax 配 ADAM(lr≈3e-4,betas=(0.9,0.999))更稳;多标签独立事件才用 Sigmoid。先跑通单任务再接概率层。
第一阶段只训特征提取+记忆模块,冻结决策头;第二阶段解冻全网络用更小学习率微调,能绕开早期失衡。
可以。小布能按你设定的窗口把时间戳、净值、持仓状态折成固定维度向量并打标签,你直接拿去喂网络。
建议失败即中断并 dump 当批输入,避免脏数据污染权重;排查完归一化范围再重跑,不要盲目续训。
空仓状态令止损止盈归一化值置 0,裁剪区间锁在 [0,1],对超阈值的极值做截断收口,防止梯度爆炸。