神经网络变得轻松(第三十九部分):Go-Explore,一种不同的探索方式·进阶篇
🧭

神经网络变得轻松(第三十九部分):Go-Explore,一种不同的探索方式·进阶篇

(2/3)·当好奇心与分歧奖励在复杂惩罚环境中失效,回归有前途状态的算法如何另辟蹊径

进阶 第 2/3 篇
很多交易者把 RL 代理直接丢进行情环境,发现奖励稀疏时模型很快躺平。更隐蔽的坑是:代理在逼近盈利状态前吃到回撤惩罚,内在奖励衰减后它再也不愿踏足那片区域。Go-Explore 的思路恰好反过来——先记下来,再回去。

◍ 多指标句柄初始化与历史基库装载

EA 启动时先把品种对象、RSI、CCI、ATR、MACD 全部建一遍句柄,任何一步失败直接返回 INIT_FAILED,避免后续 Tick 里调用空句柄导致崩策略。 品种校验用 Symb.Name(_Symbol) 和 Refresh(),RSI 最少要传周期与价格类型,MACD 则是快/慢/信号三根线加价格源,参数缺一个都不给过。 四个指标缓冲区统一按 HistoryBars resize,若有一个 resize 不成功就打印函数名和行号并退出,方便你直接在 MT5 编译器里跳转到那一行查内存分配问题。 LoadTotalBase() 从公共目录读 .bd 二进制文件,先读总条数 total,再循环把每条记录 Load 进 Total[] 数组;若文件打不开或 total<=0 就返回 false,意味着这次启动不加载任何历史行为基库。 启动后 OnTick 里靠 IsNewBar() 拦掉同根 K 线的重复触发,bar 计数小于 StartCell.total_actions 时按预设动作数组走,比如 case 0 就是 Trade.Buy 用 Symb.LotsMin() 下最小手数——外汇和贵金属杠杆高,最小手也可能在极端波动里放大亏损,实盘前务必在策略测试器跑过。

MQL5 / C++
{
class=class="str">"cmt">//---
  if(!Symb.Name(_Symbol))
     class="kw">return INIT_FAILED;
  Symb.Refresh();
class=class="str">"cmt">//---
  if(!RSI.Create(Symb.Name(), TimeFrame, RSIPeriod, RSIPrice))
     class="kw">return INIT_FAILED;
class=class="str">"cmt">//---
  if(!CCI.Create(Symb.Name(), TimeFrame, CCIPeriod, CCIPrice))
     class="kw">return INIT_FAILED;
class=class="str">"cmt">//---
  if(!ATR.Create(Symb.Name(), TimeFrame, ATRPeriod))
     class="kw">return INIT_FAILED;
class=class="str">"cmt">//---
  if(!MACD.Create(Symb.Name(), TimeFrame, FastPeriod, SlowPeriod, SignalPeriod, MACDPrice))
     class="kw">return INIT_FAILED;
  if(!RSI.BufferResize(HistoryBars) || !CCI.BufferResize(HistoryBars) ||
     !ATR.BufferResize(HistoryBars) || !MACD.BufferResize(HistoryBars))
   {
      PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
      class="kw">return INIT_FAILED;
   }
class=class="str">"cmt">//---
  if(!Trade.SetTypeFillingBySymbol(Symb.Name()))
     class="kw">return INIT_FAILED;
class=class="str">"cmt">//---
  if(LoadTotalBase())
   {
      class="type">int total = ArraySize(Total);
      if(total > Start)
         StartCell = Total[Start];
      else
       {
         total = (class="type">int)(((class="type">class="kw">double)MathRand() / class="num">32768.0) * (total - class="num">1));
         StartCell = Total[total];
       }
   }
class=class="str">"cmt">//---
  class="kw">return(INIT_SUCCEEDED);
  }
class="type">bool LoadTotalBase(class="type">void)
  {
   class="type">int handle = FileOpen(FileName + ".bd", FILE_READ | FILE_BIN | FILE_COMMON);
   if(handle < class="num">0)
      class="kw">return class="kw">false;
   class="type">int total = FileReadInteger(handle);
   if(total <= class="num">0)
    {
      FileClose(handle);
      class="kw">return class="kw">false;
    }
   if(ArrayResize(Total, total) < total)
    {
      FileClose(handle);
      class="kw">return class="kw">false;
    }
   for(class="type">int i = class="num">0; i < total; i++)
      if(!Total[i].Load(handle))
       {
         FileClose(handle);
         class="kw">return class="kw">false;
       }
   FileClose(handle);
class=class="str">"cmt">//---
   class="kw">return true;
  }
class="type">void OnTick()
  {
class=class="str">"cmt">//---
  if(!IsNewBar())
     class="kw">return;
  bar++;
  if(bar < StartCell.total_actions)
   {
      class="kw">switch(StartCell.actions[bar])
       {
         case class="num">0:
            Trade.Buy(Symb.LotsMin(), Symb.Name());

「把多周期行情压成 249 维状态向量」

这段逻辑干的事很直接:先把 RSI、CCI、ATR、MACD 四个指标逐一 Refresh,再按 HistoryBars 根 K 线把开高低收、tick 量、时间分量与指标值铺进一个 float state[249] 数组。每根 bar 占 12 个槽位,所以数组上限 249 意味着大约能装 20 根出头的完整状态(249/12≈20.75),超出的部分在循环里根本写不进去。 循环里先 TimeToStruct 拆出 hour / day_of_week / mon,再抓四个指标主线和 MACD 信号线;只要任意一个返回 EMPTY_VALUE 就 continue 跳过该 bar,避免脏数据进向量。close-open、high-open、low-open 全以开盘价为基准做差分,tick_volume 则除以 1000.0f 压缩量级——黄金和外汇这种成交量跳变大的品种,不归一化很容易让后续模型被量纲带偏。 state 的下标用 b*12 偏移布局,b 是 bar 序号、后面 0~11 是特征通道。你开 MT5 把 HistoryBars 调到 50 会立刻数组越界报错,想扩容量就把 249 改成 HistoryBars*12 再预留余量。外汇贵金属杠杆高、滑点随机,这套状态工程只是特征准备,信号胜率仍随品种和时段波动。

MQL5 / C++
      class="kw">break;
       case class="num">1:
            Trade.Sell(Symb.LotsMin(), Symb.Name());
            class="kw">break;
       case class="num">2:
            for(class="type">int i = PositionsTotal() - class="num">1; i >= class="num">0; i--)
             if(PositionGetSymbol(i) == Symb.Name())
                Trade.PositionClose(PositionGetInteger(POSITION_IDENTIFIER));
            class="kw">break;
       }
       class="kw">return;
    }
 if(bar == StartCell.total_actions)
   ArrayCopy(actions, StartCell.actions, class="num">0, class="num">0, StartCell.total_actions);
 class="type">int bars = CopyRates(Symb.Name(), TimeFrame, iTime(Symb.Name(), TimeFrame, class="num">1), HistoryBars, Rates);
 if(!ArraySetAsSeries(Rates, true))
   class="kw">return;
class=class="str">"cmt">//---
   RSI.Refresh();
   CCI.Refresh();
   ATR.Refresh();
   MACD.Refresh();
   class="type">class="kw">float state[class="num">249];
   class="type">MqlDateTime sTime;
   for(class="type">int b = class="num">0; b < (class="type">int)HistoryBars; b++)
    {
     class="type">class="kw">float open = (class="type">class="kw">float)Rates[b].open;
     TimeToStruct(Rates[b].time, sTime);
     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);
     class="type">class="kw">float 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">//---
     state[b * class="num">12] = (class="type">class="kw">float)Rates[b].close - open;
     state[b * class="num">12 + class="num">1] = (class="type">class="kw">float)Rates[b].high - open;
     state[b * class="num">12 + class="num">2] = (class="type">class="kw">float)Rates[b].low - open;
     state[b * class="num">12 + class="num">3] = (class="type">class="kw">float)Rates[b].tick_volume / class="num">1000.0f;
     state[b * class="num">12 + class="num">4] = (class="type">class="kw">float)sTime.hour;
     state[b * class="num">12 + class="num">5] = (class="type">class="kw">float)sTime.day_of_week;
     state[b * class="num">12 + class="num">6] = (class="type">class="kw">float)sTime.mon;
     state[b * class="num">12 + class="num">7] = rsi;
     state[b * class="num">12 + class="num">8] = cci;
     state[b * class="num">12 + class="num">9] = atr;
     state[b * class="num">12 + class="num">10] = macd;
     state[b * class="num">12 + class="num">11] = sign;
    }
class=class="str">"cmt">//---

把账户与持仓塞进状态数组再让智能体下单

这段逻辑把交易环境的实时快照写进一个偏移量为 240 的浮点数组,给后续的强化学习智能体当观测特征。前 5 个槽位依次存了余额、净值、可用保证金、保证金水平百分比和浮动盈亏,都是 AccountInfoDouble 直接拉取的账户级数据。 接着用 PositionsTotal 遍历当前所有持仓,只筛本品种 Symb.Name() 的仓位,把多空 volume 与 profit 分别累加进 state[245]~state[248]。如果你在 MT5 里同时挂了黄金和欧美,这段不会把跨品种手数混进来,回测时这点能避免特征污染。 action 由 SampleAction(4) 采样得到,0 买、1 卖、2 平掉本品种全部持仓,下单量统一用 Symb.LotsMin() 最小手数——外汇和贵金属杠杆高,最小手数也足以让回测曲线剧烈波动,实盘前务必在策略测试器里跑一遍。 最后把本次 action 追加进 actions 数组,并通过 ArrayCopy 把历史动作链拷给新 cell 的 Base 结构,让决策带一点记忆。下面这段是原文核心代码,逐行拆解见注释。

MQL5 / C++
  state[class="num">240] = (class="type">class="kw">float)AccountInfoDouble(ACCOUNT_BALANCE);
  state[class="num">240 + class="num">1] = (class="type">class="kw">float)AccountInfoDouble(ACCOUNT_EQUITY);
  state[class="num">240 + class="num">2] = (class="type">class="kw">float)AccountInfoDouble(ACCOUNT_MARGIN_FREE);
  state[class="num">240 + class="num">3] = (class="type">class="kw">float)AccountInfoDouble(ACCOUNT_MARGIN_LEVEL);
  state[class="num">240 + class="num">4] = (class="type">class="kw">float)AccountInfoDouble(ACCOUNT_PROFIT);
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">int total = PositionsTotal();
  for(class="type">int i = class="num">0; i < total; i++)
    {
      if(PositionGetSymbol(i) != Symb.Name())
        class="kw">continue;
      class="kw">switch((class="type">int)PositionGetInteger(POSITION_TYPE))
        {
         case POSITION_TYPE_BUY:
            buy_value += PositionGetDouble(POSITION_VOLUME);
            buy_profit += PositionGetDouble(POSITION_PROFIT);
            class="kw">break;
         case POSITION_TYPE_SELL:
            sell_value += PositionGetDouble(POSITION_VOLUME);
            sell_profit += PositionGetDouble(POSITION_PROFIT);
            class="kw">break;
        }
    }
  state[class="num">240 + class="num">5] = (class="type">class="kw">float)buy_value;
  state[class="num">240 + class="num">6] = (class="type">class="kw">float)sell_value;
  state[class="num">240 + class="num">7] = (class="type">class="kw">float)buy_profit;
  state[class="num">240 + class="num">8] = (class="type">class="kw">float)sell_profit;
class=class="str">"cmt">//---
  class="type">int act = SampleAction(class="num">4);
  class="kw">switch(act)
    {
     case class="num">0:
        Trade.Buy(Symb.LotsMin(), Symb.Name());
        class="kw">break;
     case class="num">1:
        Trade.Sell(Symb.LotsMin(), Symb.Name());
        class="kw">break;
     case class="num">2:
        for(class="type">int i = PositionsTotal() - class="num">1; i >= class="num">0; i--)
          if(PositionGetSymbol(i) == Symb.Name())
            Trade.PositionClose(PositionGetInteger(POSITION_IDENTIFIER));
        class="kw">break;
    }
class=class="str">"cmt">//--- copy cell
  actions[action_count] = act;
  Base[action_count].total_actions = action_count+StartCell.total_actions;
  if(action_count > class="num">0)
    {
      ArrayCopy(Base[action_count].actions, actions, class="num">0, class="num">0, Base[action_count].total_actions+class="num">1);

◍ 回测框架里怎么把动作序列捞出来

在 MT5 多品种、多参数优化里,光看最终盈利不够,得把每一组参数跑出来的动作序列存下来。上面这段把 OnTester 到 OnTesterDeinit 的钩子串起来了:盈利大于 0 的 pass 才用 FrameAdd 把 Base 数组(含 241 号状态差值等)推给框架,亏损 pass 直接丢弃。 OnTesterInit 里先 LoadTotalBase() 把历史底库读进内存;OnTesterPass 用 FrameNext 遍历所有 frame,只认程序名匹配且 id>0 的,用 ArrayResize(Total, total+(int)id, 10000) 以 1 万为步长扩容,再 ArrayCopy 拼进 Total。这意味着单次优化最多可累积数万条动作记录而不必频繁重分配。 OnTesterDeinit 打印 total 条数并进入 do-while 冒泡排序,flag 初始 false,每轮若有交换才置 true 继续。外汇与贵金属市场高杠杆、滑点诡异,这套序列排序后只是给你做样本筛选,不表示任何未来收益倾向。开 MT5 把 STAT_PROFIT 阈值改成你关心的回撤比,能立刻看到保留的 pass 数量变化。

MQL5 / C++
Base[action_count - class="num">1].value = Base[action_count - class="num">1].state[class="num">241] - state[class="num">241];
   }
  ArrayCopy(Base[action_count].state, state, class="num">0, class="num">0);
class=class="str">"cmt">//---
  action_count++;
  }
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Tester function                                                  |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">class="kw">double OnTester()
  {
class=class="str">"cmt">//---
   class="type">class="kw">double ret = class="num">0.0;
class=class="str">"cmt">//---
   class="type">class="kw">double profit = TesterStatistics(STAT_PROFIT);
   action_count--;
   if(profit > class="num">0)
      FrameAdd(MQLInfoString(MQL_PROGRAM_NAME), action_count, profit, Base);
class=class="str">"cmt">//---
   class="kw">return(ret);
  }
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| TesterInit function                                              |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void OnTesterInit()
  {
class=class="str">"cmt">//---
   LoadTotalBase();
  }
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| TesterPass function                                              |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void OnTesterPass()
  {
class=class="str">"cmt">//---
   class="type">class="kw">ulong pass;
   class="type">class="kw">string name;
   class="type">long id;
   class="type">class="kw">double value;
   Cell array[];
   class="kw">while(FrameNext(pass, name, id, value, array))
     {
      class="type">int total = ArraySize(Total);
      if(name != MQLInfoString(MQL_PROGRAM_NAME))
         class="kw">continue;
      if(id <= class="num">0)
         class="kw">continue;
      if(ArrayResize(Total, total + (class="type">int)id, class="num">10000) < class="num">0)
         class="kw">return;
      ArrayCopy(Total, array, total, class="num">0, (class="type">int)id);
     }
  }
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| TesterDeinit function                                            |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void OnTesterDeinit()
  {
class=class="str">"cmt">//---
   class="type">bool flag = class="kw">false;
   class="type">int total = ArraySize(Total);
   printf("total %d", total);
   Cell temp;
   Print("Start sorting...");
   do
     {
      flag = class="kw">false;

「排序落盘与神经网络冷启动」

这段逻辑先把 Total 数组按 value 做冒泡降序:外层 do-while 靠 flag 控制是否发生交换,内层 for 从 0 跑到 total-2,只要 Total[i].value 小于后一项就互换并置 flag=true。排序完 Print("Saving...") 后调 SaveTotalBase() 写盘,再 Print("Saved") 收尾,整个动作在 EA 初始化或周期收尾时跑一次即可。 初始化里先 LoadTotalBase() 读底座数据,失败直接 INIT_FAILED;接着 StudyNet.Load 尝试加载 FileName+".nnw",若文件不存在就 new CArrayObj 调 CreateDescriptions 搭网络描述,再 StudyNet.Create 建模型,任何一步出错都删 model 并返回失败。 input int Iterations = 100000 是训练迭代上限,StudyNet.TrainMode(true) 打开训练态,失败同样 INIT_FAILED。外汇与贵金属波动剧烈、杠杆风险高,这套 RL 底座在实盘前务必用 MT5 策略测试器以最小手数验证收敛与过拟合倾向。 让小布替你跑这套:把 Cell.mqh 与 ..\RL\FQF.mqh 路径确认无误后,改 Iterations 到 1000 先跑通 OnInit 不报 INIT_FAILED,再逐步加压看 dError 变化。

MQL5 / C++
for(class="type">int i = class="num">0; i < (total - class="num">1); i++)
      if(Total[i].value < Total[i + class="num">1].value)
        {
         temp = Total[i];
         Total[i] = Total[i + class="num">1];
         Total[i + class="num">1] = temp;
         flag = true;
        }
     }
  class="kw">while(flag);
  Print("Saving...");
  SaveTotalBase();
  Print("Saved");
 }
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Includes                                                            |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="macro">#include "Cell.mqh"
class="macro">#include "..\RL\FQF.mqh"
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Input parameters                                                   |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">input class="type">int                Iterations =  class="num">100000;
CNet                     StudyNet;
class=class="str">"cmt">//---
class="type">class="kw">float                    dError;
class="type">class="kw">datetime                 dtStudied;
class="type">bool                     bEventStudy;
class=class="str">"cmt">//---
CBufferFloat             State1;
CBufferFloat             *Rewards;
Cell                     Base[];
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Expert initialization function                                     |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">int OnInit()
  {
class=class="str">"cmt">//---
   if(!LoadTotalBase())
      class="kw">return(INIT_FAILED);
class=class="str">"cmt">//---
   if(!StudyNet.Load(FileName + ".nnw", dError, dError, dError, dtStudied, true))
     {
      CArrayObj *model = new CArrayObj();
      if(!CreateDescriptions(model))
        {
         class="kw">delete model;
         class="kw">return INIT_FAILED;
        }
      if(!StudyNet.Create(model))
        {
         class="kw">delete model;
         class="kw">return INIT_FAILED;
        }
      class="kw">delete model;
     }
   if(!StudyNet.TrainMode(true))
      class="kw">return INIT_FAILED;
class=class="str">"cmt">//---

训练循环怎样被图表事件触发

EA 初始化末尾用 EventChartCustom 往当前图表推了一条自定义事件,字符串参数填 "Init",返回值赋给 bEventStudy 仅作标记;随后 return(INIT_SUCCEEDED) 让初始化干净退出,不阻塞主线程。 真正的训练入口躲在 OnChartEvent 里:当 id == 1001(自定义事件 ID)时直接调 Train()。也就是说,你拖 EA 上图后,若没手动或代码触发 1001 事件,Train() 永远不会跑,这是很多新手在 MT5 里发现「网络不学习」的首因。 Train() 内部先用 ArraySize(Base) 拿到样本总数,再用 GetTickCount() 记起点。循环条件是 iter < Iterations 且 !IsStopped(),每轮先用两次 MathRand() 平方再除以 32767² 做非线性抽样选样本下标 i——这种写法让小下标被抽中的概率略高,和纯均匀随机不同,调参时可留意偏差。 抽样后把 Base[i].state 灌进 State1,若中途 IsStopped() 为真就打印函数行号并 ExpertRemove() 自退。feedForward 隐藏层节点写死 12,backProp 用 -Base[i].value 当奖励更新信号,说明把原本的「值」取负作为误差方向。 循环里每过 500 毫秒(GetTickCount()-ticks > 500)会切一次节奏,避免单线程训练卡死图表交互;外汇与贵金属杠杆高,这类自学习 EA 过拟合历史样本后实盘失效概率偏大,上 MT5 验证前先缩小 Iterations 到千级看耗时。

MQL5 / C++
  bEventStudy = EventChartCustom(ChartID(), class="num">1, class="num">0, class="num">0, "Init");
class=class="str">"cmt">//---
  class="kw">return(INIT_SUCCEEDED);
  }
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| ChartEvent function                                              |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void OnChartEvent(class="kw">const class="type">int id,
                class="kw">const class="type">long &lparam,
                class="kw">const class="type">class="kw">double &dparam,
                class="kw">const class="type">class="kw">string &sparam)
  {
class=class="str">"cmt">//---
  if(id == class="num">1001)
     Train();
  }
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Train function                                                   |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void Train(class="type">void)
  {
   class="type">int total = ArraySize(Base);
   class="type">uint ticks = GetTickCount();
   for(class="type">int iter = class="num">0; (iter < Iterations && !IsStopped()); iter ++)
     {
      class="type">int i = class="num">0;
      class="type">int count = class="num">0;
      class="type">int total_max = class="num">0;
      i = (class="type">int)((MathRand() * MathRand() / MathPow(class="num">32767, class="num">2)) * (total - class="num">1));
      State1.AssignArray(Base[i].state);
      if(IsStopped())
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         ExpertRemove();
         class="kw">return;
        }
      if(!StudyNet.feedForward(GetPointer(State1), class="num">12, true))
         class="kw">return;
      class="type">int action = Base[i].total_actions;
      if(action < class="num">0)
        {
         iter--;
         class="kw">continue;
        }
      action = Base[i].actions[action];
      if(action < class="num">0 || action > class="num">3)
         action = class="num">3;
      StudyNet.getResults(Rewards);
      if(!Rewards.Update(action, -Base[i].value))
         class="kw">return;
      if(!StudyNet.backProp(GetPointer(Rewards)))
         class="kw">return;
      if(GetTickCount() - ticks > class="num">500)
        {
把状态回放交给小布盯盘
这些诊断小布盯盘的 AIGC 已内置,打开对应品种页即可看到策略在哪些状态区陷入探索停滞,省去你自己写存档逻辑的时间。

常见问题

前者显式记忆有前途状态并强制回归,不依赖随时间衰减的内在奖励,因此在稀疏奖励加中途惩罚的环境里更稳。
常用归一化后的价量窗口、持仓盈亏、与波动率分桶作为离散状态键,避免连续空间导致存档爆炸。
可以,小布盯盘的 AIGC 模块能标注探索覆盖率与存档命中率,你只需导入自己的状态定义即可看板化监控。
搜索阶段廉价铺开覆盖,重用阶段才消耗算力精炼轨迹,分离后能在大状态空间里控制计算开销。
过拟合历史状态存档的概率偏高,实盘滑点与点差可能让存档里的‘有前途状态’失效,需持续在线更新。