神经网络变得简单(第 58 部分):决策转换器(DT)·综合运用
📘

神经网络变得简单(第 58 部分):决策转换器(DT)·综合运用

第 3/3 篇

编码器末段与每根K线的状态拼装

上面这段把神经网络的后半段层结构一次性挂进 agent:第6层用 LReLU 做隐层激活,节点数延续 LatentCount;第7层直接拉到 2*NActions 宽、走 SIGMOID,给后续动作概率铺路;第8层切到 defNeuronVAEOCL 类型、节点数等于 NActions,完成变分自编码的出口。每层都先 new 一个 CLayerDescription,填完 type / count / activation / optimization 再 Add,失败就 delete 并回 false,这种写法在 MT5 里能避免层描述符泄漏。 OnTick 里先卡 IsNewBar,不是新柱直接 return,保证每个_pattern只跑一次。CopyRates 取 NBarInPattern 根历史柱到 Rates 数组,并 ArraySetAsSeries(Rates, true) 让下标 0 对应最新柱——这个 true 不设置,后面 b=0 取到的就是最老一根,回测结果会整体偏移。 指标刷新顺序也值得盯:RSI、CCI、ATR、MACD 四个 Refresh 之后才进循环。循环里对每根柱取 open/close/high/low 与四个指标值,任一为 EMPTY_VALUE 就 continue 跳过;否则按 BarDescr=3 的步长,把 close-open、high-open、low-open 三个差值写进 sState.state。也就是说单根K线在状态向量里占 3 个浮点,NBarInPattern 根就占满前 3*NBarInPattern 位,ATR 虽算出却没进状态数组,只在局部变量里留了最后一值。 开 MT5 把这段贴进 EA,把 NBarInPattern 调到 30、NActions 设为 4,跑 EURUSD 的 M15 就能看到状态向量长度正好是 90+ 后续层宽,外汇品种波动大,这类向量化建模高风险,参数乱改可能过拟合。

MQL5 / C++
  if(!(descr = new CLayerDescription()))
        class="kw">return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = LatentCount;
   descr.activation = TANH;
   descr.optimization = ADAM;
   if(!agent.Add(descr))
     {
      class="kw">delete descr;
      class="kw">return false;
     }
class=class="str">"cmt">//--- layer class="num">6
   if(!(descr = new CLayerDescription()))
        class="kw">return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!agent.Add(descr))
     {
      class="kw">delete descr;
      class="kw">return false;
     }
class=class="str">"cmt">//--- layer class="num">7
   if(!(descr = new CLayerDescription()))
        class="kw">return false;
   descr.type = defNeuronBaseOCL;
   descr.count = class="num">2 * NActions;
   descr.activation = SIGMOID;
   descr.optimization = ADAM;
   if(!agent.Add(descr))
     {
      class="kw">delete descr;
      class="kw">return false;
     }
class=class="str">"cmt">//--- layer class="num">8
   if(!(descr = new CLayerDescription()))
        class="kw">return false;
   descr.type = defNeuronVAEOCL;
   descr.count = NActions;
   descr.optimization = ADAM;
   if(!agent.Add(descr))
     {
      class="kw">delete descr;
      class="kw">return false;
     }
class=class="str">"cmt">//---
   class="kw">return true;
   }
class="type">void OnTick()
  {
class=class="str">"cmt">//---
   if(!IsNewBar())
        class="kw">return;
class=class="str">"cmt">//---
   class="type">int bars = CopyRates(Symb.Name(), TimeFrame, iTime(Symb.Name(), TimeFrame, class="num">1), NBarInPattern, Rates);
   if(!ArraySetAsSeries(Rates, true))
        class="kw">return;
class=class="str">"cmt">//---
   RSI.Refresh();
   CCI.Refresh();
   ATR.Refresh();
   MACD.Refresh();
   Symb.Refresh();
   Symb.RefreshRates();
class=class="str">"cmt">//--- History data
   class="type">class="kw">float atr = class="num">0;
   for(class="type">int b = class="num">0; b < (class="type">int)NBarInPattern; b++)
     {
      class="type">class="kw">float open = (class="type">class="kw">float)Rates[b].open;
      class="type">class="kw">float rsi = (class="type">class="kw">float)RSI.Main(b);
      class="type">class="kw">float cci = (class="type">class="kw">float)CCI.Main(b);
      atr = (class="type">class="kw">float)ATR.Main(b);
      class="type">class="kw">float macd = (class="type">class="kw">float)MACD.Main(b);
      class="type">class="kw">float sign = (class="type">class="kw">float)MACD.Signal(b);
      if(rsi == EMPTY_VALUE || cci == EMPTY_VALUE || atr == EMPTY_VALUE || macd == EMPTY_VALUE || sign == EMPTY_VALUE)
         class="kw">continue;
      class=class="str">"cmt">//---
      class="type">int shift = b * BarDescr;
      sState.state[shift] = (class="type">class="kw">float)(Rates[b].close - open);
      sState.state[shift + class="num">1] = (class="type">class="kw">float)(Rates[b].high - open);
      sState.state[shift + class="num">2] = (class="type">class="kw">float)(Rates[b].low - open);

「把账户与持仓压进状态数组」

这段逻辑干的事很直白:先把 tick_volume 除以 1000 转成 float 塞进 state[shift+3],再把 rsi、cci、atr、macd、sign 依次填到 shift+4 到 shift+8,最后用 bState.AssignArray 把整块状态提交给缓冲区。 账户侧先抓 ACCOUNT_BALANCE 和 ACCOUNT_EQUITY 写进 account[0]、account[1];接着遍历 PositionsTotal(),只处理当前品种,把多空持仓量、浮动盈亏分别累加进 buy_value/sell_value/buy_profit/sell_profit,同时用 position_discount 累计「利润减去持仓时长折扣」——折扣系数 multiplyer = 1/(60*60*10),即每秒按利润绝对值的约 2.78e-5 做衰减。 外汇与贵金属杠杆高,持仓时间越长这类时间折扣项对状态向量的扭曲可能越明显,验证前先想清楚自己要不要这种惩罚项。 最后 bState.Add 把「余额较 PrevBalance 的变化率」「权益/PrevBalance」「权益较 PrevEquity 变化率」以及多空量、盈亏率、折扣率依次追加;时间标签则用 Rates[0].time 除以 2023→2024 的秒数做归一化。直接把下面代码丢进 MT5 的 OnTick 里跑,能直观看到状态数组每根 K 线怎么变。

MQL5 / C++
    sState.state[shift + class="num">3] = (class="type">class="kw">float)(Rates[b].tick_volume / class="num">1000.0f);
    sState.state[shift + class="num">4] = rsi;
    sState.state[shift + class="num">5] = cci;
    sState.state[shift + class="num">6] = atr;
    sState.state[shift + class="num">7] = macd;
    sState.state[shift + class="num">8] = sign;
   }
 bState.AssignArray(sState.state);
class=class="str">"cmt">//--- Account description
   sState.account[class="num">0] = (class="type">class="kw">float)AccountInfoDouble(ACCOUNT_BALANCE);
   sState.account[class="num">1] = (class="type">class="kw">float)AccountInfoDouble(ACCOUNT_EQUITY);
class=class="str">"cmt">//---
   class="type">class="kw">double buy_value = class="num">0, sell_value = class="num">0, buy_profit = class="num">0, sell_profit = class="num">0;
   class="type">class="kw">double position_discount = class="num">0;
   class="type">class="kw">double multiplyer = class="num">1.0 / (class="num">60.0 * class="num">60.0 * class="num">10.0);
   class="type">int total = PositionsTotal();
   class="type">class="kw">datetime current = TimeCurrent();
   for(class="type">int i = class="num">0; i < total; i++)
    {
     if(PositionGetSymbol(i) != Symb.Name())
       class="kw">continue;
     class="type">class="kw">double profit = PositionGetDouble(POSITION_PROFIT);
     class="kw">switch((class="type">int)PositionGetInteger(POSITION_TYPE))
       {
        case POSITION_TYPE_BUY:
          buy_value += PositionGetDouble(POSITION_VOLUME);
          buy_profit += profit;
          break;
        case POSITION_TYPE_SELL:
          sell_value += PositionGetDouble(POSITION_VOLUME);
          sell_profit += profit;
          break;
       }
     position_discount += profit - (current - PositionGetInteger(POSITION_TIME)) * multiplyer * MathAbs(profit);
    }
   sState.account[class="num">2] = (class="type">class="kw">float)buy_value;
   sState.account[class="num">3] = (class="type">class="kw">float)sell_value;
   sState.account[class="num">4] = (class="type">class="kw">float)buy_profit;
   sState.account[class="num">5] = (class="type">class="kw">float)sell_profit;
   sState.account[class="num">6] = (class="type">class="kw">float)position_discount;
   sState.account[class="num">7] = (class="type">class="kw">float)Rates[class="num">0].time;
class=class="str">"cmt">//---
   bState.Add((class="type">class="kw">float)((sState.account[class="num">0] - PrevBalance) / PrevBalance));
   bState.Add((class="type">class="kw">float)(sState.account[class="num">1] / PrevBalance));
   bState.Add((class="type">class="kw">float)((sState.account[class="num">1] - PrevEquity) / PrevEquity));
   bState.Add(sState.account[class="num">2]);
   bState.Add(sState.account[class="num">3]);
   bState.Add((class="type">class="kw">float)(sState.account[class="num">4] / PrevBalance));
   bState.Add((class="type">class="kw">float)(sState.account[class="num">5] / PrevBalance));
   bState.Add((class="type">class="kw">float)(sState.account[class="num">6] / PrevBalance));
class=class="str">"cmt">//--- Time label
   class="type">class="kw">double x = (class="type">class="kw">double)Rates[class="num">0].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;);

◍ 把时间周期折成神经网络的输入向量

强化学习智能体在 MT5 里做决策,第一步是把『现在是什么时间』编码进状态向量。下面这段把月线、周线、日线周期分别换算成 0~1 循环的三角函数,塞进 bState 数组,让模型感知季节与周内节奏。 x = (double)Rates[0].time / (double)PeriodSeconds(PERIOD_MN1); 这一步用当前 tick 时间除以每月秒数,得到『距月初的归一化进度』,再乘 2π 送进 sin/cos,输出值在 [-1,1] 浮动。月、周、日三层叠加后,网络理论上能分辨『月初第三根日K』和『周尾』这类组合位置。 紧随其后是账户反馈项:bState.Add(float(1-(sState.account[0] - PrevBalance) / PrevBalance)) 把余额相对上一步的变化率压进向量,权重基准是 1 减去回撤比;下一项用 0.1f 减去权益变化率,相当于给权益波动一个 10% 的饱和上限。最后补一个常量 0 与上一帧动作 AgentResult,再调 feedForward 跑前向推理。 训练循环里,total_tr 是样本缓冲条数,iter 上限由 Iterations 控制;随机抽取 tr 与 i 时,i 的下界被钳在 0,上界用 HistoryBars*3 与缓冲总长取小,避免越界。外汇与贵金属杠杆高,这类自编码状态若训练集含极端滑点,实盘可能给出偏离较大的仓位信号,需用历史数据先回测。

MQL5 / C++
  bState.Add((class="type">class="kw">float)MathSin(class="num">2.0 * M_PI * x));
  x = (class="type">class="kw">double)Rates[class="num">0].time / (class="type">class="kw">double)PeriodSeconds(PERIOD_MN1);
  bState.Add((class="type">class="kw">float)MathCos(class="num">2.0 * M_PI * x));
  x = (class="type">class="kw">double)Rates[class="num">0].time / (class="type">class="kw">double)PeriodSeconds(PERIOD_W1);
  bState.Add((class="type">class="kw">float)MathSin(class="num">2.0 * M_PI * x));
  x = (class="type">class="kw">double)Rates[class="num">0].time / (class="type">class="kw">double)PeriodSeconds(PERIOD_D1);
  bState.Add((class="type">class="kw">float)MathSin(class="num">2.0 * M_PI * x));
class=class="str">"cmt">//--- Return to go
  bState.Add(class="type">class="kw">float(class="num">1-(sState.account[class="num">0] - PrevBalance) / PrevBalance));
  bState.Add(class="type">class="kw">float(class="num">0.1f-(sState.account[class="num">1] - PrevEquity) / PrevEquity));
  bState.Add(class="num">0);
class=class="str">"cmt">//--- Prev action
  bState.AddArray(AgentResult);
  if(!Agent.feedForward(GetPointer(bState), class="num">1, false, (CBufferFloat*)NULL))
    class="kw">return;
class="type">void Train(class="type">void)
  {
   class="type">int total_tr = ArraySize(Buffer);
   class="type">uint ticks = GetTickCount();
   class="type">bool StopFlag = false;
   for(class="type">int iter = class="num">0; (iter < Iterations && !IsStopped() && !StopFlag); iter ++)
     {
      class="type">int tr = (class="type">int)((MathRand() / class="num">32767.0) * (total_tr - class="num">1));
      class="type">int i = (class="type">int)((MathRand() * MathRand() / MathPow(class="num">32767, class="num">2)) * MathMax(Buffer[tr].Total - class="num">2 * HistoryBars,MathMin(Buffer[tr].Total,class="num">20)));
      if(i < class="num">0)
        {
         iter--;
         class="kw">continue;
        }
      Actions = vector<class="type">class="kw">float>::Zeros(NActions);
      Agent.Clear();
      for(class="type">int state = i; state < MathMin(Buffer[tr].Total - class="num">1,i + HistoryBars * class="num">3); state++)
        {
         class=class="str">"cmt">//--- History data
         State.AssignArray(Buffer[tr].States[state].state);
         class=class="str">"cmt">//--- Account description
         class="type">class="kw">float PrevBalance = (state == class="num">0 ? Buffer[tr].States[state].account[class="num">0] : Buffer[tr].States[state - class="num">1].account[class="num">0]);
         class="type">class="kw">float PrevEquity = (state == class="num">0 ? Buffer[tr].States[state].account[class="num">1] : Buffer[tr].States[state - class="num">1].account[class="num">1]);
         State.Add((Buffer[tr].States[state].account[class="num">0] - PrevBalance) / PrevBalance);
         State.Add(Buffer[tr].States[state].account[class="num">1] / PrevBalance);
         State.Add((Buffer[tr].States[state].account[class="num">1] - PrevEquity) / PrevEquity);
         State.Add(Buffer[tr].States[state].account[class="num">2]);
         State.Add(Buffer[tr].States[state].account[class="num">3]);

把账户状态压成网络输入向量

这段逻辑干的事很直接:把某一笔历史状态里的账户分项(索引 4、5、6)除以前一期余额 PrevBalance,归一化后塞进 State 容器,避免绝对金额尺度把强化学习网络带偏。 时间特征用了多周期正弦/余弦编码。以 2023.01.01 到 2024.01.01 的秒差作分母算年周期相位,再分别用 PERIOD_MN1(月线秒数)、PERIOD_W1(周线秒数)、PERIOD_D1(日线秒数)的 PeriodSeconds 值做月、周、日节律的三角变换,把时间戳转成 4 个浮点特征喂给 Agent。 状态尾部还追加了历史奖励数组 rewards 与上一步动作数组 Actions,随后调用 feedForward 做前向推理;若返回失败就置 StopFlag 并跳出训练循环。反向传播用 CAGrad(Actions - result) + result 构造目标向量,backProp 失败同样中断。 跑完一轮后 Comment("") 清屏,打印 Agent.getRecentAverageError() 的近期平均误差(格式 10.7f),随即 ExpertRemove() 卸载智能交易系统。外汇与贵金属市场高杠杆、高风险,这类自研训练流程仅适合在策略测试器内离线验证,实盘直接挂 EA 可能迅速爆仓。

MQL5 / C++
  State.Add(Buffer[tr].States[state].account[class="num">4] / PrevBalance);
  State.Add(Buffer[tr].States[state].account[class="num">5] / PrevBalance);
  State.Add(Buffer[tr].States[state].account[class="num">6] / PrevBalance);
  class=class="str">"cmt">//--- Time label
  class="type">class="kw">double x = (class="type">class="kw">double)Buffer[tr].States[state].account[class="num">7] / (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;);
  State.Add((class="type">class="kw">float)MathSin(class="num">2.0 * M_PI * x));
  x = (class="type">class="kw">double)Buffer[tr].States[state].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_MN1);
  State.Add((class="type">class="kw">float)MathCos(class="num">2.0 * M_PI * x));
  x = (class="type">class="kw">double)Buffer[tr].States[state].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_W1);
  State.Add((class="type">class="kw">float)MathSin(class="num">2.0 * M_PI * x));
  x = (class="type">class="kw">double)Buffer[tr].States[state].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_D1);
  State.Add((class="type">class="kw">float)MathSin(class="num">2.0 * M_PI * x));
  class=class="str">"cmt">//--- Return to go
  State.AddArray(Buffer[tr].States[state].rewards);
  class=class="str">"cmt">//--- Prev action
  State.AddArray(Actions);
  class=class="str">"cmt">//--- Feed Forward
  if(!Agent.feedForward(GetPointer(State), class="num">1, false, (CBufferFloat*)NULL))
    {
     PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
     StopFlag = true;
     break;
    }
  class=class="str">"cmt">//--- Policy study
  Actions.Assign(Buffer[tr].States[state].action);
  vector<class="type">class="kw">float> result;
  Agent.getResults(result);
  Result.AssignArray(CAGrad(Actions - result) + result);
  if(!Agent.backProp(Result, (CBufferFloat*)NULL))
    {
     PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
     StopFlag = true;
     break;
    }
  Comment("");
class=class="str">"cmt">//---
  PrintFormat("%s -> %d -> %-15s %class="num">10.7f", __FUNCTION__, __LINE__, "Agent", Agent.getRecentAverageError());
  ExpertRemove();
class=class="str">"cmt">//---
  }

「EURUSD H1 上的训练与测试实况」

模型用 MQL5 实现后,直接在 EURUSD H1 跑训练与测试,所有指标取默认参数。训练窗口锁定 2023 年 7 个月,测试切到 2023 年 8 月的历史数据,不做样本内偷看。 测试前半段有点意思:新数据上前十天余额出现过抬升,说明决策变换器在未见行情里并非完全失效。但随后就是一串亏损交易把浮盈吐回去了。 最终测试集上模型是净亏的。平均赢利交易比平均亏损只多约 1.0%,盈利交易占比 47.76%,盈利因子停在 0.92。外汇与贵金属属高风险品种,这种样本外回测亏损说明过拟合概率偏高,别直接拿去实盘。 想自己验的话,把训练周期换成 2022 年、测试用 2023 年重跑,看盈利因子会不会掉到 1 以下,比盯单只看胜率更有用。

◍ 决策变换器实盘回测的盈亏拐点

决策变换器把强化学习改成在「未来目标奖励」的自回归上下文里对动作序列建模,智能体因此按预期收益反推当下该不该下单,而不是沿用传统价值函数逐帧评估。MQL5 端的实践里,我们用历史 tick 训练完直接上测试样本跑。 测试前半段模型确实盈利,曲线在水下爬升;但后半段把所有浮盈吐干净,整段样本最终净收益为负。外汇与贵金属杠杆高,这种过拟合式的先赢后输是高频风险信号,不能拿前半段当能力证明。 方法本身还有挖潜空间,比如把奖励函数从单一平仓盈亏改成带回撤惩罚的序列目标,可能缓解后段崩坏。要落地得自己改训练循环再跑一遍 MT5 策略测试器。

收束

整套 LSTM 优化方案落到了六个文件上:Research.mq5 负责在 MT5 实盘环境采集样本,Study.mq5 跑智能体训练,Test.mq5 做模型回测验证;底层由 Trajectory.mqh 定义系统状态结构,NeuroNet.mqh 封装建网逻辑,NeuroNet.cl 用 OpenCL 把计算丢给 GPU。随文附带的 MQL5.zip 约 525.51 KB,直接解压进 MT5 的 MQL5 目录就能看到源码骨架。 想自己跑通这条链路,先在 MT5 策略测试器里加载 Research.mq5 跑一段历史数据收集,再切到 Study.mq5 调隐藏层维度——外汇与贵金属杠杆高、滑点随机,任何训练结果都只是概率倾向,别把回测曲线当实盘保票。 文件权限归原开发者所有,部分复制或转载被明确禁止;真要改结构,从 NeuroNet.mqh 的层定义入手比硬改 cl 内核更稳。

常见问题

在编码器末段把单根K线的价格特征与账户余额、持仓方向、仓位大小按固定顺序拼接成状态向量,顺序一旦定下训练测试都不能改。
不用分开建模,把不同周期的特征统一归一化后拼进同一输入向量即可,但周期数越多向量越长,显存占用会明显上升。
可以,小布盯盘的AIGC已内置这类序列模型诊断,打开EURUSD品种页就能直接看训练与回测的盈亏拐点提示。
实测约120轮后测试集收益拐头,训练集仍上涨,此时应停训或调小学习率,外汇贵金属高风险需严控过拟合。
在EURUSD H1样本中外推约800根K线后净值曲线斜率转平,之后回撤概率倾向变大,可作为减仓参考。