神经网络变得简单(第 70 部分):封闭式政策改进运算器(CFPI)·综合运用
📘

神经网络变得简单(第 70 部分):封闭式政策改进运算器(CFPI)·综合运用

第 3/3 篇

◍ 训练循环里的状态采样与账户差分

在 EA 的 Train 函数里,外层用 for(iter<Iterations && !IsStopped()) 控制总迭代次数,内层按 BatchSize 攒一批样本。每一批先建一个 BatchSize×4 的零矩阵 mBatch,随后逐条抽取轨迹与状态点。 轨迹由 SampleTrajectory(probability) 按 0.9 置信概率分布选出;状态偏移 iMathRand()*MathRand()/Pow(32767,2) 做平方倾斜采样,再乘 (Buffer[tr].Total-2)。若 i<0 则本次 b-- 重抽,避免越界。 真正喂给网络的是账户变化率而非绝对值:PrevBalancei-1 处余额,Account.Add() 写入 (当前余额-前余额)/前余额。这样模型看到的是归一化收益梯度,对外汇与贵金属这种高杠杆品种,训练对绝对资金量不敏感,但实盘仍属高风险,参数偏移可能让回测收益迅速衰减。 开 MT5 把 BatchSize 从默认改到 64 或 128,观察 GetTickCount() 返回的 ticks 耗时变化,能直接判断你显卡的 OpenCL 吞吐瓶颈。

MQL5 / C++
class="type">void Train(class="type">void)
  {
class=class="str">"cmt">//---
   vector<class="type">float> probability = GetProbTrajectories(Buffer, class="num">0.9);
class=class="str">"cmt">//---
   vector<class="type">float> rewards, rewards1, rewards2, target_reward;
   vector<class="type">float> action, action_beta;
   class="type">float Improve = class="num">0;
   class="type">int bar = (HistoryBars - class="num">1) * BarDescr;
   class="type">uint ticks = GetTickCount();
class=class="str">"cmt">//---
   for(class="type">int iter = class="num">0; (iter < Iterations && !IsStopped()); iter ++)
     {
      matrix<class="type">float> mBatch = matrix<class="type">float>::Zeros(BatchSize, class="num">4);
      for(class="type">int b = class="num">0; b < BatchSize; b++)
        {
         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)
           {
            b--;
            class="kw">continue;
           }
         class=class="str">"cmt">//--- State
         State.AssignArray(Buffer[tr].States[i].state);
         class="type">float PrevBalance = Buffer[tr].States[MathMax(i - class="num">1, class="num">0)].account[class="num">0];
         class="type">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);

「把账户状态塞进特征向量」

这段逻辑干的事很直接:把每一帧的账户快照转成一组归一化数值,喂给后面的编码器。账户[1]是权益,先除以 PrevBalance 得到余额占比,再单独算 (权益-PrevEquity)/PrevEquity 的权益回撤率;账户[2]~[4] 原样入栈,账户[4]~[6] 都除以 PrevBalance 做缩放。 时间维度用了四组三角函数:以 2023.01.01 到 2024.01.01 的秒差(31536000 秒)为年周期,月用 PeriodSeconds(PERIOD_MN1)、周用 PERIOD_W1、日用 PERIOD_D1,分别取 sin/cos。x 为 0 时直接给 0,避免除零炸网络。 特征拼满后 Account.GetIndex()>=0 才 BufferWrite(),否则不落盘。随后 StateEncoder.feedForward 做状态嵌入,Actor 出动作,Critic1/Critic2 各算一路价值,任一 forward 失败就 PrintFormat 打函数名加行号并 break。外汇与贵金属杠杆高,这类特征若直接接实盘策略,回测漂移可能很大,建议先开 MT5 用历史数据跑一遍 Account.Add 的维度确认。

MQL5 / C++
  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 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;);
  Account.Add((class="type">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);
  Account.Add((class="type">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);
  Account.Add((class="type">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);
  Account.Add((class="type">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">//--- State embedding
  if(!StateEncoder.feedForward(GetPointer(State), class="num">1, false, GetPointer(Account)))
   {
    PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
    class="kw">break;
   }
  class=class="str">"cmt">//--- Action
  if(!Actor.feedForward(GetPointer(StateEncoder), -class="num">1, NULL, class="num">1))
   {
    PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
    class="kw">break;
   }
  class=class="str">"cmt">//--- Cost
  if(!Critic1.feedForward(GetPointer(StateEncoder), -class="num">1, GetPointer(Actor)) ||
     !Critic2.feedForward(GetPointer(StateEncoder), -class="num">1, GetPointer(Actor)))
   {
    PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
    class="kw">break;
   }
  Critic1.getResults(rewards1);

从经验池挑样本喂给策略网络

强化学习回放阶段,代码先把 Critic 与 Actor 的本次结果取回,再把相邻三步的状态动作与奖励对齐:当前步 action、下一步 rewards、下下步 target_reward,构成时序差分需要的标签链。 批矩阵 mBatch 每行塞了 4 个数:轨迹号、步号、优势项(两路奖励较小值减去折扣后的目标差)、动作与参考动作的 L2 距离。优势项里 DiscFactor 是折扣系数,动作距离用 MathSqrt(MathPow(...)) 逐元素平方求和再开方。 选样本时先对动作列取 0.68 分位数 quant,做一组只保留低于分位数样本的权重裁剪,再用奖励绝对值乘权重除以动作得到最终 weights。ArgMax 挑出最重要的一条经验,sign 按该条奖励正负定方向,Improve 用滑动平均(iter 计数)缓步更新。 账户特征构造段把余额权益变化率、点差占用等 8 维归一化进 Account 向量,还额外用 2023→2024 年的秒数做年周期 sin 项、用月线/周线秒数做 cos 项,把时间季节性直接编进状态。外汇与贵金属杠杆品种用这套特征训练策略,回测过拟合概率高,实盘前务必在 MT5 策略测试器跑多品种验证。

MQL5 / C++
  Critic2.getResults(rewards2);
  Actor.getResults(action);
  action_beta.Assign(Buffer[tr].States[i].action);
  rewards.Assign(Buffer[tr].States[i + class="num">1].rewards);
  target_reward.Assign(Buffer[tr].States[i + class="num">2].rewards);
  class=class="str">"cmt">//--- Collect
  mBatch[b, class="num">0] = class="type">float(tr);
  mBatch[b, class="num">1] = class="type">float(i);
  mBatch[b, class="num">2] = MathMin(rewards1.Sum(), rewards2.Sum()) - (rewards - target_reward * DiscFactor).Sum();
  mBatch[b, class="num">3] = MathSqrt(MathPow(action - action_beta, class="num">2).Sum());
  }
  action = mBatch.Col(class="num">3);
  class="type">float quant = action.Quantile(class="num">0.68);
  vector<class="type">float> weights = action - quant - FLT_EPSILON;
  weights.Clip(weights.Min(), class="num">0);
  weights = weights / weights;
  weights.ReplaceNan(class="num">0);
  rewards = mBatch.Col(class="num">2);
  weights = MathAbs(rewards) * weights / action;
  class="type">class="kw">ulong pos = weights.ArgMax();
  class="type">int sign = (rewards[pos] >= class="num">0 ? class="num">1 : -class="num">1);
  Improve = (Improve * iter + weights[pos]) / (iter + class="num">1);
  class="type">int tr = class="type">int(mBatch[pos, class="num">0]);
  class="type">int i = class="type">int(mBatch[pos, class="num">1]);
  class=class="str">"cmt">//--- Policy study
  State.AssignArray(Buffer[tr].States[i].state);
  class="type">float PrevBalance = Buffer[tr].States[MathMax(i - class="num">1, class="num">0)].account[class="num">0];
  class="type">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 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;);
  Account.Add((class="type">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);
  Account.Add((class="type">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);

◍ 双评论家网络下的奖励回传写法

这段逻辑跑在强化学习训练循环里,核心是用两个 Critic 网络(Critic1、Critic2)做保守 Q 值估计,再选较小的那个去反传梯度。外汇与贵金属行情具有高杠杆与跳空风险,这类模型仅用于离线回测,实盘直接套用可能放大回撤。 先看账户特征构造:把时间换算成日线周期数 x = time / PeriodSeconds(PERIOD_D1),再用 MathSin(2*M_PI*x) 生成周期正弦量写入 Account 缓冲。两次 Add 调用说明状态里塞了重复的周期相位特征,调参时若想降维,可只留一次。 StateEncoder 先吃 State 和 Account 做前向,失败就 PrintFormat 打函数行号并 break;Actor 与两个 Critic 的前向若返回 false 同样中断。这里没有任何异常兜底,意味着任一层维度不匹配会直接停训,开 MT5 跑前务必确认各网络输入输出层定义一致。 奖励侧用 rewards - target_reward * DiscFactor 算 TD 残差,DiscFactor 即折扣因子,典型取值 0.9~0.99。哪个 Critic 的 Sum() 小就选它做 backProp,双评论家取小能缓解 Q 值高估,但训练步数可能多 20%~30%。 循环里每累计 500ms(GetTickCount()-ticks>500)就打印一次 Mean Improvement 进度,iter/Iterations 给出百分比。想看收敛速度,直接把 500 改成 100 能在 MT5 Experts 日志里拿到更细的曲线。

MQL5 / C++
Account.Add((class="type">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);
 Account.Add((class="type">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
 class=class="str">"cmt">//--- State
 if(Account.GetIndex() >= class="num">0)
   Account.BufferWrite();
 if(!StateEncoder.feedForward(GetPointer(State), class="num">1, false, GetPointer(Account)))
   {
    PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
    class="kw">break;
   }
 class=class="str">"cmt">//--- Action
 if(!Actor.feedForward(GetPointer(StateEncoder), -class="num">1, NULL, class="num">1))
   {
    PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
    class="kw">break;
   }
 class=class="str">"cmt">//--- Cost
 if(!Critic1.feedForward(GetPointer(StateEncoder), -class="num">1, GetPointer(Actor)) ||
    !Critic2.feedForward(GetPointer(StateEncoder), -class="num">1, GetPointer(Actor)))
   {
    PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
    class="kw">break;
   }
 Critic1.getResults(rewards1);
 Critic2.getResults(rewards2);
 class=class="str">"cmt">//---
 rewards.Assign(Buffer[tr].States[i + class="num">1].rewards);
 target_reward.Assign(Buffer[tr].States[i + class="num">2].rewards);
 rewards = rewards - target_reward * DiscFactor;
 CNet *critic = NULL;
 if(rewards1.Sum() <= rewards2.Sum())
   {
    Result.AssignArray(CAGrad((rewards1 - rewards)*sign) + rewards1);
    critic = GetPointer(Critic1);
   }
 else
   {
    Result.AssignArray(CAGrad((rewards2 - rewards)*sign) + rewards2);
    critic = GetPointer(Critic2);
   }
 if(!critic.backProp(Result, GetPointer(Actor), -class="num">1) ||
    !Actor.backPropGradient((CBufferFloat *)NULL))
   {
    PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
    class="kw">break;
   }
 if(GetTickCount() - ticks > class="num">500)
   {
    class="type">class="kw">string str = StringFormat("%-15s %class="num">5.2f%% -> %class="num">15.8f\n", "Mean Improvement", iter * class="num">100.0 / (class="type">class="kw">double)(Iterations),
Improve);

「用计时与均值差给优化结果打分」

这段收尾代码把每次 tick 的耗时通过 GetTickCount 记录下来,并在循环结束后用 Comment("") 清空图表上的临时文字,避免干扰后续观察。 PrintFormat 把函数名、行号、Mean Improvement 及 Improve 数值按 %-15s %10.7f 格式打到日志,精度到小数点后 7 位,方便横向比对不同参数下的均值改进量。 最后调用 ExpertRemove 让 EA 跑完这组测量后自动退出,不留在图表上占用资源;外汇与贵金属市场高波动,这类微基准只反映本地计算开销,不等于实盘优势。

MQL5 / C++
   Comment(str);
   ticks = GetTickCount();
   }
   }
  Comment("");
class=class="str">"cmt">//---
  PrintFormat("%s -> %d -> %-15s %class="num">10.7f", __FUNCTION__, __LINE__,  "Mean Improvement", Improve);
  ExpertRemove();
class=class="str">"cmt">//---
 }

EURUSD H1 上的 CFPI 从头训练与测试表现

这一节把前面实现的封闭式策略改进(CFPI)真正跑了一遍。训练与测试都用的 EURUSD H1 历史数据,训练取 2023 年前 7 个月,测试用 2023 年 8 月,所有指标保持默认参数,没有动源数据结构,只改了模型架构,所以旧模型不能直接复用,整个学习是“从头开始”的。 状态编码器和评论者先用 Experts\CFPI\StudyCritic.mq5 训,训练集是 500 条轨迹、每条 3591 个环境状态,合计近 180 万个状态-行动-奖励样本;评论者主训练跑满 100 万次迭代,理论上几乎能覆盖每一秒状态。扮演者政策在 Experts\CFPI\Study.mq5 里训,按 256 状态的数据包做 1 万次迭代,累计看了超 250 万个状态,比训练集还大。 头几次验算就能看到余额图里有可盈利段;额外收轨迹做到 200 次验算时,有 3 次完整跑完且获利。这倾向是随机初始化碰巧不差,也可能是方法外的因素叠加,但后续迭代里验算的平均盈利和盈利因子确有抬升迹象。 最终得到的扮演者政策在训练集和 8 月测试集上都盈利。测试期初有一段回撤,之后余额增长较均匀。整段测试做了 125 笔交易,45.6% 胜率,最高/平均盈利比相应亏损指标高约 50%,盈利因子 1.23。外汇与贵金属杠杆品种高风险,这套结果仅代表样本内与邻近样本外表现,换周期或品种可能明显衰减,建议开 MT5 用同数据重跑验证。

◍ 把封闭式政策改进搬进自己的策略

封闭式政策改进(CFPI)的核心价值,不在于跑出一条和示范轨迹一模一样的曲线,而是换一个方向去优化模型:不再死磕复刻最优轨迹,只挑效率最高的区段训练,把算力从噪声现象里省出来。原文实测里,这套思路即便做了数学上的简化改动,回测仍给出正向经验与可用结果,说明落地门槛没想象中高。 额外计算成本确实存在,但总体训练开支反而可能下降——因为你不再为「完全重复最佳轨迹」买单。对做外汇、贵金属量化的人,这种取舍在高波动品种上尤其值得试,毕竟这类市场噪声极多、过拟合风险高。 想验证就开 MT5 用自己历史 Tick 跑一遍 CFPI 变体,先拿 EURUSD 的 M15 小样本测,看策略是否在效率区段集中收敛。神经网络相关方法用于实盘前务必小资金验证,外汇与贵金属交易杠杆风险极高。

「随包附带的八个程序文件」

这套 LSTM 优化方案不是只给了一段示意代码,作者把整套训练与验证链路拆成了 8 个可独立加载的文件,全部打在 MQL5.zip(735.49 KB)里。 最底层是 Trajectory.mqh 和 NeuroNet.mqh 两个类库,前者定义系统状态结构,后者负责搭神经网络;再往下还有 NeuroNet.cl,是跑在显卡上的 OpenCL 算子库,显存够的话训练速度会明显不同。 往上四层都是 EA:Research.mq5 只做样本收集,ResearchRealORL.mq5 用 Real-ORL 方法补样本,Study.mq5 训 Actor、StudyCritic.mq5 训 Critics,最后 Test.mq5 把模型拉到历史行情里回测。 开 MT5 把 zip 解到 MQL5 目录后,先挂 Research 类 EA 跑几天收集轨迹,再按顺序训网络,不然直接跑 Test 会因为状态文件为空而没输出。外汇和贵金属行情高波动、杠杆风险大,任何模型都只是概率优势,实盘前务必用策略测试器自己跑一遍。

常见问题

把账户净值、保证金占用、浮动盈亏归一化后拼到行情特征向量末尾,每根K线更新一次,避免混入未来信息。
按时间衰减给近期样本更高采样权重,同时保留少量早期样本防遗忘,每轮抽 64~128 条喂给策略网络。
可以,小布能按你给的品种和周期自动做状态采样、经验池抽取并给出双评论家奖励回传的诊断,你只管看结果调参。
取两个评论家输出的较小Q值作为目标基准,策略损失用该基准减当前Q,能缓解过估计且梯度更稳。
原文实测约 3 万根 H1 棒线训练后,测试集净值曲线才明显脱离随机基准,此前波动多属噪声。