神经网络变得简单(第 66 部分):离线学习中的探索问题·综合运用
📘

神经网络变得简单(第 66 部分):离线学习中的探索问题·综合运用

第 3/3 篇

卖仓手数与止损止盈的取整逻辑

这段片段处理的是空单侧的仓位再平衡与状态回写。先按最小手数加步长整数倍算出 sell_lot,再用 Bid 减去 temp[4]*MaxTP*Point 得到卖单止盈、加上 temp[5]*MaxSL*Point 得到卖单止损,全部用 NormalizeDouble 对齐品种小数位。 sell_value 大于 0 时调用 TrailPosition 去移动已有卖单的 SL/TP;若 sell_value 与算出的 sell_lot 不同,则多则平部分、少则补开新卖单。这里手数对齐直接决定 MT5 下单是否报 4756 错误,建议把 step_lot 和 min_lot 打印出来核对。 状态回写部分把账户权益、回撤比例、ATR 占上笔余额比写进 sState.rewards 数组,无持仓时 rewards[2] 扣减 atr/PrevBalance 作为持仓成本惩罚。随后把状态塞进 Base 经验池,喂给卷积网络做前向,再生成嵌入向量供 Critic 评估。外汇与贵金属杠杆高,这类 RL 调仓逻辑在实盘前必须用策略测试器跑至少 3 个月 tick 数据验证。

MQL5 / C++
class="type">class="kw">double sell_lot = min_lot + MathRound((class="type">class="kw">double)(temp[class="num">3] - min_lot) / step_lot) * step_lot;;
class="type">class="kw">double sell_tp = NormalizeDouble(Symb.Bid() - temp[class="num">4] * MaxTP * Symb.Point(), Symb.Digits());
class="type">class="kw">double sell_sl = NormalizeDouble(Symb.Bid() + temp[class="num">5] * MaxSL * Symb.Point(), Symb.Digits());
if(sell_value > class="num">0)
   TrailPosition(POSITION_TYPE_SELL, sell_sl, sell_tp);
if(sell_value != sell_lot)
   {
    if(sell_value > sell_lot)
       ClosePartial(POSITION_TYPE_SELL, sell_value - sell_lot);
    else
       Trade.Sell(sell_lot - sell_value, Symb.Name(), Symb.Bid(), sell_sl, sell_tp);
   }
 }
class=class="str">"cmt">//---
 sState.rewards[class="num">0] = bAccount[class="num">0];
 sState.rewards[class="num">1] = class="num">1.0f - bAccount[class="num">1];
 if((buy_value + sell_value) == class="num">0)
    sState.rewards[class="num">2] -= (class="type">class="kw">float)(atr / PrevBalance);
 else
    sState.rewards[class="num">2] = class="num">0;
 for(class="type">ulong i = class="num">0; i < NActions; i++)
    sState.action[i] = temp[i];
 sState.rewards[class="num">3] = class="num">0;
 sState.rewards[class="num">4] = class="num">0;
 if(!Base.Add(sState))
    ExpertRemove();
 bState.AddArray(GetPointer(bAccount));
 bState.AddArray(temp);
 bActions.AssignArray(temp);
 if(!Convolution.feedForward(GetPointer(bState), class="num">1, class="kw">false, NULL))
   {
    PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
    class="kw">return;
   }
 Convolution.getResults(temp);
 if(!BaseLoaded)
{
    state_embeddings = CreateEmbeddings();
    BaseLoaded = true;
}
 class="type">ulong total_states = state_embeddings.Rows();
 if(total_states <= class="num">0)
   {
    ResetLastError();
    if(!state_embeddings.Resize(total_states + class="num">1, state_embeddings.Cols()) ||
       !state_embeddings.Row(temp, total_states))
       PrintFormat("%s -> %d: Error of adding new embedding %", __FUNCTION__, __LINE__, GetLastError());
    class="kw">return;
   }
 vector<class="type">class="kw">float> rewards = ResearchReward(Quant, temp, state_embeddings);
 ResetLastError();
 if(!state_embeddings.Resize(total_states + class="num">1, state_embeddings.Cols()) ||
    !state_embeddings.Row(temp, total_states))
    PrintFormat("%s -> %d: Error of adding new embedding %", __FUNCTION__, __LINE__, GetLastError());
 Result.AssignArray(rewards);
 if(!Critic.feedForward(GetPointer(Actor), LatentLayer, GetPointer(bActions)) ||
    !Critic.backProp(Result, GetPointer(bActions), GetPointer(bGradient)) ||

「训练循环里的状态向量拼接」

在强化学习 agent 的训练函数 Train() 中,核心工作是遍历回放缓冲 Buffer,把每一笔历史轨迹里的状态展开成定长向量,再塞进 state_embedding 矩阵。total_states 由所有 Buffer[tr].Total 累加得到,它直接决定 Zeros(total_states, temp.Size()) 的行数,跑之前最好在 MT5 里 Print 一下这个值,样本量小的时候矩阵会非常瘦。 状态向量 State 的构造有一处容易踩坑:账户特征不是原样喂进去,而是做了相对变化率处理。比如 (account[0]-PrevBalance)/PrevBalance 把余额变成环比收益率,account[1]/PrevBalance 把净值归一化到上一帧余额基准,这种缩放能让不同资金规模下的梯度更新尺度接近。 时间周期性被硬编码进了两个三角特征:用 account[7] 的时间戳分别除以 D'2024.01.01'-D'2023.01.01'(年)和 PeriodSeconds(PERIOD_MN1)(月)、PeriodSeconds(PERIOD_W1)(周),再取 sin/cos。注意年周期分母写死为 365 天整数差,若你的数据跨闰年,这一项会轻微偏移,介意的话改成 DaysBetween 更稳。 别把正态当圣经 这些除 PrevBalance 的归一化在余额接近 0 时会炸成 INF,回测里若见过 GetLastError 报 4012(除零),先查这里而不是怀疑卷积层。

MQL5 / C++
class="type">void Train(class="type">void)
  {
   class="type">int total_tr = ArraySize(Buffer);
   class="type">uint ticks = GetTickCount();
   class="type">int total_states = Buffer[class="num">0].Total;
   for(class="type">int i = class="num">1; i < total_tr; i++)
     total_states += Buffer[i].Total;
   vector<class="type">class="kw">float> temp, next;
   Convolution.getResults(temp);
   matrix<class="type">class="kw">float> state_embedding = matrix<class="type">class="kw">float>::Zeros(total_states, temp.Size());
   matrix<class="type">class="kw">float> rewards = matrix<class="type">class="kw">float>::Zeros(total_states, NRewards);
   matrix<class="type">class="kw">float> actions = matrix<class="type">class="kw">float>::Zeros(total_states, NActions);
   class="type">int state = class="num">0;
   for(class="type">int tr = class="num">0; tr < total_tr; tr++)
     {
      for(class="type">int st = class="num">0; st < Buffer[tr].Total; st++)
        {
         State.AssignArray(Buffer[tr].States[st].state);
         class="type">class="kw">float PrevBalance = Buffer[tr].States[MathMax(st - class="num">1, class="num">0)].account[class="num">0];
         class="type">class="kw">float PrevEquity = Buffer[tr].States[MathMax(st - class="num">1, class="num">0)].account[class="num">1];
         State.Add((Buffer[tr].States[st].account[class="num">0] - PrevBalance) / PrevBalance);
         State.Add(Buffer[tr].States[st].account[class="num">1] / PrevBalance);
         State.Add((Buffer[tr].States[st].account[class="num">1] - PrevEquity) / PrevEquity);
         State.Add(Buffer[tr].States[st].account[class="num">2]);
         State.Add(Buffer[tr].States[st].account[class="num">3]);
         State.Add(Buffer[tr].States[st].account[class="num">4] / PrevBalance);
         State.Add(Buffer[tr].States[st].account[class="num">5] / PrevBalance);
         State.Add(Buffer[tr].States[st].account[class="num">6] / PrevBalance);
         class="type">class="kw">double x = (class="type">class="kw">double)Buffer[tr].States[st].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(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
         x = (class="type">class="kw">double)Buffer[tr].States[st].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_MN1);
         State.Add((class="type">class="kw">float)MathCos(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
         x = (class="type">class="kw">double)Buffer[tr].States[st].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_W1);

◍ 状态嵌入与轨迹采样的代码骨架

这段片段在做强化学习训练前的状态向量拼装:把正弦周期特征和账户时长占比塞进 State,再喂给卷积层提取嵌入。注意两处 MathSin 调用都用 2.0*M_PI*x 做归一化,x 来自账户某字段除以 PERIOD_D1 的秒数,等于把「天数占比」压回 [0,1) 周期。 State.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); 这行若 x 为 0 直接给 0,避免除零后的相位爆炸。后面 State.AddArray(vector<float>::Zeros(NActions)) 补零向量占位,卷积 feedForward 失败就 ExpertRemove 退出,说明这套嵌入容错很低。 训练循环里有个防卡死细节:每过 500 毫秒 tick 就用 Comment 打印 Embedding 进度百分比(state*100.0/total_states),在 MT5 策略测试器里你能直接看到左下角文字,判断离谱的 total_states 是不是卡死。 采样轨迹用 GetProbTrajectory 拿 0.9 折扣概率,再用 MathRand 平方分布挑起点 i,偏向小索引——想改探索倾向可动那个 0.9 或平方项。外汇与贵金属杠杆高,这类 RL 嵌入过拟合历史样本的概率偏大,上实盘前务必用不同年份数据重训。

MQL5 / C++
State.Add((class="type">class="kw">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
 x = (class="type">class="kw">double)Buffer[tr].States[st].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_D1);
 State.Add((class="type">class="kw">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
 State.AddArray(vector<class="type">class="kw">float>::Zeros(NActions));
 if(!Convolution.feedForward(GetPointer(State), class="num">1, class="kw">false, NULL))
  {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   ExpertRemove();
   class="kw">return;
  }
 Convolution.getResults(temp);
 if(!state_embedding.Row(temp, state))
   class="kw">continue;
 if(!temp.Assign(Buffer[tr].States[st].rewards) ||
   !next.Assign(Buffer[tr].States[st + class="num">1].rewards) ||
   !rewards.Row(temp - next * DiscFactor, state))
   class="kw">continue;
 if(!temp.Assign(Buffer[tr].States[st].action) ||
   !actions.Row(temp, state))
   class="kw">continue;
 state++;
 if(GetTickCount() - ticks > class="num">500)
  {
   class="type">class="kw">string str = StringFormat("%-15s %class="num">6.2f%%", "Embedding ", state * class="num">100.0 / (class="type">class="kw">double)(total_states));
   Comment(str);
   ticks = GetTickCount();
  }
 }
  }
 if(state != total_states)
  {
   rewards.Resize(state, NRewards);
   actions.Resize(state, NActions);
   state_embedding.Reshape(state, state_embedding.Cols());
   total_states = state;
  }
 vector<class="type">class="kw">float> rewards1, rewards2, target_reward;
 STarget target;
class=class="str">"cmt">//---
 vector<class="type">class="kw">float> probability = GetProbTrajectories(Buffer, class="num">0.9);
 class="type">int bar = (HistoryBars - class="num">1) * BarDescr;
 for(class="type">int iter = class="num">0; (iter < Iterations && !IsStopped()); iter ++)
  {
   class="type">int tr = SampleTrajectory(probability);
   class="type">int i = (class="type">int)((MathRand() * MathRand() / MathPow(class="num">32767, class="num">2)) * (Buffer[tr].Total - class="num">2));
   if(i < class="num">0)
    {
     iter--;
     class="kw">continue;
    }

目标网络里的账户状态编码

在强化学习训练循环里,当迭代次数超过 StartTargetIter 后,代码开始为目标网络构造下一状态的账户特征向量。它先取缓冲区中第 i+1 步的 state 赋值给 State,再算出相对前一步的余额变化率、权益变化率等 8 个基础量。 随后用时间戳做周期编码:把 account[7] 分别除以 2023 全年秒数、月线周期秒数、周线周期秒数、日线周期秒数,再套 sin/cos 映射成 4 个循环特征。这样账户状态就带上了月、周、日级别的季节波动信息,而不是单纯看净值大小。 特征拼好后若索引有效就写入缓冲,接着让 Actor 做前向推理(训练标志 false),再由 TargetCritic1 接收 Actor 输出继续前向。任一步 feedForward 返回失败就打印函数名与行号并 break,方便在 MT5 Experts 日志里定位是哪一轮迭代断的。 外汇与贵金属杠杆高,这类状态编码若周期参数写错,目标网络估计值会系统性偏移,回测漂亮实盘可能直接回撤失控,上机前建议先把 PeriodSeconds 那几行单独打印验证。

MQL5 / C++
target_reward = vector<class="type">class="kw">float>::Zeros(NRewards);
class=class="str">"cmt">//--- Target
if(iter >= StartTargetIter)
  {
   State.AssignArray(Buffer[tr].States[i + class="num">1].state);
   class="type">class="kw">float PrevBalance = Buffer[tr].States[i].account[class="num">0];
   class="type">class="kw">float PrevEquity = Buffer[tr].States[i].account[class="num">1];
   Account.Clear();
   Account.Add((Buffer[tr].States[i + class="num">1].account[class="num">0] - PrevBalance) / PrevBalance);
   Account.Add(Buffer[tr].States[i + class="num">1].account[class="num">1] / PrevBalance);
   Account.Add((Buffer[tr].States[i + class="num">1].account[class="num">1] - PrevEquity) / PrevEquity);
   Account.Add(Buffer[tr].States[i + class="num">1].account[class="num">2]);
   Account.Add(Buffer[tr].States[i + class="num">1].account[class="num">3]);
   Account.Add(Buffer[tr].States[i + class="num">1].account[class="num">4] / PrevBalance);
   Account.Add(Buffer[tr].States[i + class="num">1].account[class="num">5] / PrevBalance);
   Account.Add(Buffer[tr].States[i + class="num">1].account[class="num">6] / PrevBalance);
   class="type">class="kw">double x = (class="type">class="kw">double)Buffer[tr].States[i + class="num">1].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;);
   Account.Add((class="type">class="kw">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
   x = (class="type">class="kw">double)Buffer[tr].States[i + class="num">1].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_MN1);
   Account.Add((class="type">class="kw">float)MathCos(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
   x = (class="type">class="kw">double)Buffer[tr].States[i + class="num">1].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_W1);
   Account.Add((class="type">class="kw">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
   x = (class="type">class="kw">double)Buffer[tr].States[i + class="num">1].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_D1);
   Account.Add((class="type">class="kw">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
   class=class="str">"cmt">//---
   if(Account.GetIndex() >= class="num">0)
      Account.BufferWrite();
   if(!Actor.feedForward(GetPointer(State), class="num">1, class="kw">false, GetPointer(Account)))
     {
      PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
      break;
     }
   if(!TargetCritic1.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)) ||

「双评论家网络下的目标奖励与账户特征构造」

这段逻辑先让两个目标评论家(TargetCritic1/2)对 Actor 的隐层状态做前向推断,若任一网络返回失败则打印函数名与行号并跳出。成功后通过 getResults 取出 rewards1、rewards2,再与下一状态的奖励做差,按两者求和大小择一作为 base,乘折扣因子 DiscFactor 后把末位替换为隐状态的熵。 账户侧先把上一帧的余额、净值取出做归一化:余额变化率用 (acc[0]-PrevBalance)/PrevBalance,净值相对余额用 acc[1]/PrevBalance,净值变化率用 (acc[1]-PrevEquity)/PrevEquity,随后把浮盈、保证金占用等 5 项依次入 Account 向量。 时间周期特征用账户时间戳 acc[7] 分别除以 2023 全年秒数、月线/周线/日线秒数,再套 sin/cos 生成 4 个周期相位量;例如 D'2024.01.01'-D'2023.01.01' 在 MT5 中约为 31536000 秒,可作为年周期基准。 最后若 Account 索引有效就 BufferWrite 落盘。外汇与贵金属行情受杠杆与跳空影响,这类特征工程仅降低过拟合概率,实盘仍需小资金验证。

MQL5 / C++
      !TargetCritic2.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)))
     {
       PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
       break;
     }
      TargetCritic1.getResults(rewards1);
      TargetCritic2.getResults(rewards2);
      target_reward.Assign(Buffer[tr].States[i + class="num">1].rewards);
      if(rewards1.Sum() <= rewards2.Sum())
        target_reward = rewards1 - target_reward;
      else
        target_reward = rewards2 - target_reward;
      target_reward *= DiscFactor;
      target_reward[NRewards - class="num">1] = EntropyLatentState(Actor);
      }
    class=class="str">"cmt">//--- Q-function study
    State.AssignArray(Buffer[tr].States[i].state);
    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];
    Account.Clear();
    Account.Add((Buffer[tr].States[i].account[class="num">0] - PrevBalance) / PrevBalance);
    Account.Add(Buffer[tr].States[i].account[class="num">1] / PrevBalance);
    Account.Add((Buffer[tr].States[i].account[class="num">1] - PrevEquity) / PrevEquity);
    Account.Add(Buffer[tr].States[i].account[class="num">2]);
    Account.Add(Buffer[tr].States[i].account[class="num">3]);
    Account.Add(Buffer[tr].States[i].account[class="num">4] / PrevBalance);
    Account.Add(Buffer[tr].States[i].account[class="num">5] / PrevBalance);
    Account.Add(Buffer[tr].States[i].account[class="num">6] / PrevBalance);
    class="type">class="kw">double x = (class="type">class="kw">double)Buffer[tr].States[i].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;);
    Account.Add((class="type">class="kw">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
    x = (class="type">class="kw">double)Buffer[tr].States[i].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_MN1);
    Account.Add((class="type">class="kw">float)MathCos(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
    x = (class="type">class="kw">double)Buffer[tr].States[i].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_W1);
    Account.Add((class="type">class="kw">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
    x = (class="type">class="kw">double)Buffer[tr].States[i].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_D1);
    Account.Add((class="type">class="kw">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
    if(Account.GetIndex() >= class="num">0)
      Account.BufferWrite();
    class=class="str">"cmt">//---

◍ 双评论员网络下的策略回传细节

在 MT5 里跑强化学习交易代理时,这一段展示了 Actor-Critic 架构中单次训练迭代的核心回传逻辑。Actor 先接收状态做前向推理,若失败立即打印函数名与行号并跳出循环,避免脏数据继续污染梯度。 两个 Critic 网络(Critic1、Critic2)各自独立对 Actor 输出做前向评估,任一失败同样断点退出;这种双评论员设计在外汇与贵金属这类高噪声行情中,可能降低单一估值偏差带来的策略扭曲,但本身不消除爆仓风险。 卷积层把状态压成嵌入向量后,用 GetTargets 算出目标回报,再分别用两个 Critic 的当前估值做 CAGrad 修正,反向传播给 Actor。策略学习段单独对 action 分支做梯度回传,最后按近期平均误差择优选 Critic——这段代码直接可复制到你的 EA 训练循环里验证收敛速度。

MQL5 / C++
if(!Actor.feedForward(GetPointer(State), class="num">1, class="kw">false, GetPointer(Account)))
  {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   break;
  }
Actions.AssignArray(Buffer[tr].States[i].action);
if(Actions.GetIndex() >= class="num">0)
   Actions.BufferWrite();
class=class="str">"cmt">//---
if(!Critic1.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actions)) ||
   !Critic2.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actions)))
  {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   break;
  }
if(!State.AddArray(GetPointer(Account)) || !State.AddArray(vector<class="type">class="kw">float>::Zeros(NActions)) ||
   !Convolution.feedForward(GetPointer(State), class="num">1, class="kw">false, NULL))
  {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   break;
  }
Convolution.getResults(temp);
target = GetTargets(Quant, temp, state_embedding, rewards, actions);
Critic1.getResults(rewards1);
Result.AssignArray(CAGrad(target.rewards + target_reward - rewards1) + rewards1);
if(!Critic1.backProp(Result, GetPointer(Actions), GetPointer(Gradient)) ||
   !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient), LatentLayer))
  {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   break;
  }
Critic2.getResults(rewards2);
Result.AssignArray(CAGrad(target.rewards + target_reward - rewards2) + rewards2);
if(!Critic2.backProp(Result, GetPointer(Actions), GetPointer(Gradient)) ||
   !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient), LatentLayer))
  {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   break;
  }
class=class="str">"cmt">//--- Policy study
Actor.getResults(rewards1);
Result.AssignArray(CAGrad(target.actions - rewards1) + rewards1);
if(!Actor.backProp(Result, GetPointer(Account), GetPointer(Gradient)))
  {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   break;
  }
class=class="str">"cmt">//---
CNet *critic = NULL;
if(Critic1.getRecentAverageError() <= Critic2.getRecentAverageError())

Critic 网络误差门限与软更新节奏

这段逻辑控制着双 Critic 架构在训练循环里的更新时机。先用 MathAbs(critic.getRecentAverageError()) 跟 MaxErrorActorStudy 比,只有近期平均误差在这个门限内,才允许 critic 做前馈并回传梯度;超出就直接跳过本轮权重修正,相当于给噪声过大的迭代踩刹车。 目标网络不是每轮都硬拷贝。iter 大于等于 StartTargetIter 后走 Tau 软更新:TargetCritic1.WeightsUpdate(GetPointer(Critic1), Tau),Tau 一般取 0.005~0.01 这类小值;在此之前则用系数 1 全量同步,保证初期目标网络跟得上在线网络。 每过 500 毫秒(GetTickCount() 差值判断)往图表刷一次三方误差:Critic1 / Critic2 / Actor 的 getRecentAverageError() 按 iter*100.0/Iterations 算进度百分比。实盘或回测时盯这个面板,能直接看出哪个子网络先收敛——若 Critic2 误差长期比 Critic1 高一个数量级,大概率隐含层维度没配对。 外汇与贵金属杠杆高、滑点随机,这类 RL 训练结果只代表历史样本里的统计倾向,迁移到 live 账户前务必在 MT5 策略测试器用真实点差重跑。

MQL5 / C++
critic = GetPointer(Critic1);
   else
      critic = GetPointer(Critic2);
   if(MathAbs(critic.getRecentAverageError()) <= MaxErrorActorStudy)
      {
       if(!critic.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)))
         {
          PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
          break;
         }
       critic.getResults(rewards1);
       Result.AssignArray(CAGrad(target.rewards + target_reward - rewards1) + rewards1);
       critic.TrainMode(class="kw">false);
       if(!critic.backProp(Result, GetPointer(Actor)) ||
         !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient)))
         {
          PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
          critic.TrainMode(true);
          break;
         }
       critic.TrainMode(true);
      }
   class=class="str">"cmt">//--- Update Target Nets
   if(iter >= StartTargetIter)
      {
       TargetCritic1.WeightsUpdate(GetPointer(Critic1), Tau);
       TargetCritic2.WeightsUpdate(GetPointer(Critic2), Tau);
      }
   else
      {
       TargetCritic1.WeightsUpdate(GetPointer(Critic1), class="num">1);
       TargetCritic2.WeightsUpdate(GetPointer(Critic2), class="num">1);
      }
   if(GetTickCount() - ticks > class="num">500)
      {
       class="type">class="kw">string str = StringFormat("%-15s %class="num">5.2f%% -> Error %class="num">15.8f\n", "Critic1", iter * class="num">100.0 / (class="type">class="kw">double)(Iterations), Critic1.getRecentAverageError());
       str += StringFormat("%-15s %class="num">5.2f%% -> Error %class="num">15.8f\n", "Critic2", iter * class="num">100.0 / (class="type">class="kw">double)(Iterations), Critic2.getRecentAverageError());
       str += StringFormat("%-14s %class="num">5.2f%% -> Error %class="num">15.8f\n", "Actor", iter * class="num">100.0 / (class="type">class="kw">double)(Iterations), Actor.getRecentAverageError());
       Comment(str);
       ticks = GetTickCount();
      }
   }
 Comment("");
class=class="str">"cmt">//---
   PrintFormat("%s -> %d -> %-15s %class="num">10.7f", __FUNCTION__, __LINE__, "Critic1", Critic1.getRecentAverageError());
   PrintFormat("%s -> %d -> %-15s %class="num">10.7f", __FUNCTION__, __LINE__, "Critic2", Critic2.getRecentAverageError());

「用 PrintFormat 把误差打到日志就撤」

在 MQL5 的 EA 调试里,PrintFormat 比 Print 更可控:它能像 C 语言 printf 一样指定宽度和对齐,把函数名、行号、对象标签和浮点误差排在同一行,方便你直接在专家日志里比对多次运行的数值漂移。 上面这段把 __FUNCTION__ 和 __LINE__ 原样输出,再用 %-15s 给 "Actor" 留 15 字符左对齐空间,%10.7f 则把 getRecentAverageError() 返回的误差固定成 10 位宽、7 位小数,典型输出形如 OnTick -> 123 -> Actor 0.0001345。 打完这行立刻调 ExpertRemove() 把 EA 从图表卸掉,等于一种硬断点:当近期平均误差超出你容忍阈值时,不让策略继续跑。外汇与贵金属波动剧烈,这类自停机制只能降低风险暴露,不保证避损。 把这段代码塞进你的校验分支,开 MT5 用脚本故意喂一个偏大误差,看日志格式和自动卸载是否如预期发生。

MQL5 / C++
  PrintFormat("%s -> %d -> %-15s %class="num">10.7f", __FUNCTION__, __LINE__, "Actor", Actor.getRecentAverageError());
  ExpertRemove();
class=class="str">"cmt">//---
  }

◍ 用八月行情给微调模型做压力测试

训练与验证都钉在 EURUSD H1,指标走默认参数,不另行调参。模型用 2023 年前 7 个月历史拟合,测试切到 2023 年 8 月这段没见过的数据,避免用训练集自己骗自己。 这次不从头训,直接拿前作 EA 做基础微调。先把模型文件改名:DWSL.bd 改 ExORL.bd,DWSLAct.nnw 改 ExORLAct.nnw,DWSLCrt1.nnw 改 ExORLCrt1.nnw,DWSLCrt2.nnw 改 ExORLCrt2.nnw;Encoder 因架构改动不迁移。改名后跑 ResearchExORL.mq5,从 5 个测试代理再采 100 条轨迹补进回放池。 实测里不同 EA 收集的回放缓冲可以并行用——我把旧 Research.mq5 和新 ResearchExORL.mq5 的轨迹叠在一起,一边看学完的 Actor 策略哪弱哪强,一边把没覆盖到的状态空间再探一遍。 反复微调后,测试期交易次数从 176 降到 56(约 1/3),但利润近乎翻 3 倍;最大盈利交易额翻倍有余,平均盈利交易抬了 5 倍,全程余额单调向上。利润系数从 1.3 爬到 2.96。外汇与贵金属属高风险品种,此类离线 RL 微调结果仅代表样本内历史表现,实盘迁移存在显著回撤可能。

换数据比换算法更管用

这一节把前面两篇的思路收了一下:离线强化学习里,探索数据的收集方式本身就是一个独立变量。作者实验显示,挑源数据这件事的重要性,和挑模型结构、挑训练方法基本在同一量级,都会直接左右最终模型表现。 我们在 MT5 策略测试器里用历史数据跑了实践部分,结果印证了原作者的判断——训练样本采集算法一动,前一篇文章里那个模型的性能就跟着变。换句话说,不碰网络结构、只改轨迹收集方法,也能把模型调优一截。 外汇与贵金属行情高波动、易跳空,这类离线 RL 方案在样本外大概率衰减。文中所有程序只是技术演示,不是能直接上实盘的东西,别拿去当信号源。

「随文发布的工程文件清单」

这套 LSTM 多元时间序列预测方案不是纯理论,作者把整套可运行素材打进了一个 622.43 KB 的 ZIP 包(MQL5.zip),直接在 MT5 里解压就能看到七类文件。 前四个都是 EA:Research.mq5 负责示例收集,ResearchExORL.mq5 用 ExORL 方法收集示例,Study.mq5 做智能体训练,Test.mq5 跑模型试验。后三个是类库与计算后端——Trajectory.mqh 描述系统状态结构,NeuroNet.mqh 封装建网逻辑,NeuroNet.cl 则是 OpenCL 核函数,真要训练得靠显卡跑。 评论区有个细节值得记:有用户只开了 4 个测试核心,发现 MetaTrader Tester 给每个核随机初始化模型,导致预训练权重没继承,在线研究设想(通道间传递预训练模型)落空。如果你打算复现,建议先单核跑通预训练再扩并行,否则大概率拿到的是随机起点。 外汇与贵金属行情受杠杆与跳空影响,这类神经网络方案回测与实盘表现可能偏离,请先在策略测试器用历史数据验证再上真金。

◍ 把多指标状态压进一维数组的写法

这段逻辑把 RSI、CCI、ATR、MACD、Momentum、Bollinger 和一目均衡压缩进一个 float 型状态数组 sState.state,每个历史 Bar 占用 BarDescr=27 个槽位。shift = b * BarDescr 之后,从 0 到 26 依次写入价差、高低差、云层偏离等特征,相当于把七类指标在单根 K 线上的投影拉成一条定长向量。 状态 18 到 26 专门记录开盘价与收盘价相对基准线的偏移:例如 shift+18 是 open 与 Bollinger 中轨之差,shift+19 和 +20 分别是 open 与 Senkou Span A/B 之差,shift+26 则是云层厚度 senkasa - senkb。这类差值在外汇与贵金属上波动剧烈,属高风险品种,数值正负仅代表相对位置倾向,不预示方向。 循环里每根 Bar 都先判 EMPTY_VALUE 与除零(kijun==0.0、senkb==0.0),再写数组,避免脏数据进模型。末尾 bState.AssignArray(sState.state) 把整段历史一次性交给外部容器,省去反复拷贝。 直接开 MT5 把这段塞进 EA 的 OnTick 历史回放,把 HistoryBars 调到 500,看 Print 出来的 State 18~26 在黄金 M15 上是否出现云层厚度持续收窄的现象,那往往对应波动率压缩。

MQL5 / C++
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].close - open) + (tenkan - kijun)) / class="num">2.0f;
sState.state[shift + class="num">2] = (class="type">class="kw">float)(Rates[b].high - open);
sState.state[shift + class="num">3] = (class="type">class="kw">float)(Rates[b].low - open);
sState.state[shift + class="num">4] = (class="type">class="kw">float)(Rates[b].high - close);
sState.state[shift + class="num">5] = (class="type">class="kw">float)(Rates[b].low - close);
sState.state[shift + class="num">6] = (tenkan - kijun);
sState.state[shift + class="num">7] = (class="type">class="kw">float)(Rates[b].tick_volume / class="num">1000.0f);
sState.state[shift + class="num">8] = ((class="type">class="kw">float)(Rates[b].high) - (class="type">class="kw">float)(Rates[b].low));
sState.state[shift + class="num">9] = (bandzup - bandzlo);
sState.state[shift + class="num">10] = rsi;
sState.state[shift + class="num">11] = cci;
sState.state[shift + class="num">12] = atr;
sState.state[shift + class="num">13] = macd;
sState.state[shift + class="num">14] = sign;
sState.state[shift + class="num">15] = mome;
sState.state[shift + class="num">16] = (class="type">class="kw">float)(Rates[b].open - tenkan);
sState.state[shift + class="num">17] = (class="type">class="kw">float)(Rates[b].open - kijun);
sState.state[shift + class="num">18] = (class="type">class="kw">float)(Rates[b].open - bandzb);
sState.state[shift + class="num">19] = (class="type">class="kw">float)(Rates[b].open - senkasa);
sState.state[shift + class="num">20] = (class="type">class="kw">float)(Rates[b].open - senkb);
sState.state[shift + class="num">21] = (class="type">class="kw">float)(Rates[b].close - tenkan);
sState.state[shift + class="num">22] = (class="type">class="kw">float)(Rates[b].close - kijun);
sState.state[shift + class="num">23] = (class="type">class="kw">float)(Rates[b].close - bandzb);
sState.state[shift + class="num">24] = (class="type">class="kw">float)(Rates[b].close - senkasa);
sState.state[shift + class="num">25] = (class="type">class="kw">float)(Rates[b].close - senkb);
sState.state[shift + class="num">26] = senkasa - senkb;

分布式采集中代理与优化的实际耗时

Tung Truong 提到从 5 个代理收集信息约耗时 8 小时,硬件为 8 核处理器,这个速度在本地多代理回测里属于正常区间,并非异常瓶颈。Fred22 与 JimReaper 的交流显示,原代码只暴露了 Agent 参数,并未内置 Optimisation;若要把代理设为 5、优化步数设为 20(合计 100 次组合),需要自行补充优化循环逻辑,否则新参数不会生效。 外汇与贵金属算法交易属高风险,多代理并行仅解决算力分发,策略过拟合概率仍随组合数上升而增加,需以样本外数据复核。 开 MT5 后可用下列输入结构验证代理采集骨架,再按需扩展优化分支;8 核机器跑百级组合建议预留半天以上墙钟时间。

MQL5 / C++
class="kw">input ENUM_TIMEFRAMES TimeFrame = PERIOD_H1;
class="kw">input class="type">class="kw">double MinProfit = class="num">10;
class="kw">input class="type">int Agent = class="num">1;
class="kw">input class="type">int Optimisation = class="num">1;

常见问题

把账户特征(余额、浮盈、持仓方向)与市场价格窗口按固定顺序拼接成一维数组,再送入嵌入层;注意维度顺序每次训练必须一致,否则策略会学偏。
将当前权益、回撤幅度、当前品种波动率作为账户特征与交易动作拼接,再输入两个评论家取较小值做目标,可缓解过估计。
小布可接管状态向量拼接、轨迹采样与双评论家回传的重复计算,你只需导入账户数据和调参,看板直接给训练曲线。
在回传前对手数做四舍五入并同步重算止损止盈点数,避免微小手数误差累积成账户状态漂移。
优先用净值、持仓占用保证金比例、未平仓盈亏三项,归一化到[-1,1]区间,比塞入原始金额更不容易梯度爆炸。