您应当知道的 MQL5 向导技术(第 49 部分):搭配近端政策优化的强化学习·进阶篇
PPO梯度回传与ε-greedy动作选择
这段逻辑把策略网络的梯度算完之后,直接按学习率叠回原权重。外层三层循环遍历动作、行、列,每次让 policy 加上 alpha 乘 gradient,相当于最朴素的梯度上升,没有动量也没有自适应学习率。 梯度本身来自前面算出的裁剪项:用新旧策略概率差乘 clip 系数,避免策略更新步子太大。外汇与贵金属行情高波动,这种裁剪在实盘回测里可能让策略更新更稳,但绝不保证收益。 动作选择走标准 ε-greedy。以 rand()%SHORT_MAX 除以 SHORT_MAX 得到 [0,1) 随机数,小于 epsilon 就随机探索,否则贪心选 Q_SA 最大的动作。SHORT_MAX 在 MT5 里是 32767,意味着随机性分辨率约三万分之一。 选完动作后做马尔可夫状态转移:更新 act、e_row、e_col 历史,调 LetMarkov 得到转移矩阵,再扫一遍 markov 行挑最大转移概率的下一状态,最后写回 states 和 td_value。开 MT5 把 epsilon 从 0.1 调到 0.01,能直接观察探索频率下降。
{ _policies.Row(i).Activation(_probabilities, AF_SOFTMAX);
class="type">class="kw">double _old = _probabilities[states[class="num">1]];
class="type">class="kw">double _new = _probabilities[states[class="num">0]];
class="type">class="kw">double _advantage = Q_SA[i][ii][iii] - Q_V[ii][iii];
class="type">class="kw">double _clip = GetClipping(_old, _new, _advantage);
Q_PPO.gradient[i][ii][iii] = (_new - _old) * _clip;
}
}
}
for(class="type">int i = class="num">0; i < THIS.actions; i++)
{ for(class="type">int ii = class="num">0; ii < class="type">int(Q_PPO.policy[i].Rows()); ii++)
{ for(class="type">int iii = class="num">0; iii < class="type">int(Q_PPO.policy[i].Cols()); iii++)
{ Q_PPO.policy[i][ii][iii] += THIS.alpha * Q_PPO.gradient[i][ii][iii];
}
}
}
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Choose an action class="kw">using epsilon-greedy approach |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void Cql::Action(vector &E)
{ class="type">int _best_act = class="num">0;
if (class="type">class="kw">double((rand() % SHORT_MAX) / SHORT_MAX) < THIS.epsilon)
{ class=class="str">"cmt">// Explore: Choose random action
_best_act = (rand() % THIS.actions);
}
else
{ class=class="str">"cmt">// Exploit: Choose best action
class="type">class="kw">double _best_value = Q_SA[class="num">0][e_row[class="num">0]][e_col[class="num">0]];
for (class="type">int i = class="num">1; i < THIS.actions; i++)
{ if (Q_SA[i][e_row[class="num">0]][e_col[class="num">0]] > _best_value)
{ _best_value = Q_SA[i][e_row[class="num">0]][e_col[class="num">0]];
_best_act = i;
}
}
}
class=class="str">"cmt">//update last action
act[class="num">1] = act[class="num">0];
act[class="num">0] = _best_act;
class=class="str">"cmt">//markov decision process
e_row[class="num">1] = e_row[class="num">0];
e_col[class="num">1] = e_col[class="num">0];
LetMarkov(e_row[class="num">1], e_col[class="num">1], E);
class="type">int _next_state = class="num">0;
for (class="type">int i = class="num">0; i < class="type">int(markov.Cols()); i++)
{ if(markov[class="type">int(E[class="num">0])][i] > markov[class="type">int(E[class="num">0])][_next_state])
{ _next_state = i;
}
}
class=class="str">"cmt">//printf(__FUNCSIG__+" next state is: %i, with best act as: %i ",_next_state,_best_act);
class="type">int _next_row = class="num">0, _next_col = class="num">0;
SetMarkov(_next_state, _next_row, _next_col);
e_row[class="num">0] = _next_row;
e_col[class="num">0] = _next_col;
states[class="num">1] = states[class="num">0];
states[class="num">0] = GetMarkov(_next_row, _next_col);
td_value = Q_V[_next_row][_next_col];
td_policies[class="num">1][class="num">0] = td_policies[class="num">0][class="num">0];
td_policies[class="num">1][class="num">1] = td_policies[class="num">0][class="num">1];「策略表滚动与 PPO 裁剪目标的计算落点」
这段逻辑干了两件事:把 TD 策略矩阵往前滚一格,再在动作空间里挑出当前状态价值最高的两条策略。注意 td_policies[0] 被整体换成 _next_row / td_value / _next_col 三元组,旧的第 0 行降格到第 1 行,属于典型的环形缓冲写法,避免每根 K 线都重新申请内存。
遍历 THIS.actions 时,分别用 Q_SA 和 Q_PPO.policy 在 _next_row、_next_col 指定的格子里比大小,q_sa_act 与 q_ppo_act 初始都为 1,只有遇到严格更大值才更新。这意味着若所有动作价值持平,引擎会默认停留在动作 1,而不是 0——实盘里这可能让首根信号偏向非中性动作,外汇与贵金属波动剧烈,这种隐性偏置需要先回测确认。
GetClipping 是 PPO 的核心约束:概率比 _ratio 被夹在 [1-epsilon, 1+epsilon] 内,再乘优势函数取小值。代码里 fmin(fmax(_ratio, 1-THIS.epsilon), 1+THIS.epsilon) 直接封死了新旧策略偏离过大的更新,防止单根毛刺 K 线把策略带崩。
GetOutput 里对行、列各取了两段 CopyRates:偏移 0 与偏移 1(列旧值偏移 m_scale),做差后得到差分特征 _in_row、_in_col。当 m_scale 设为 8 时,每次需稳定取到 8 根 Rate,任何一段 .Size() != m_scale 都会跳过整个推断——MT5 上若品种流动性差导致历史补齐失败,信号会静默不触发,这点必须在日志里单独监控。
td_policies[class="num">1][class="num">2] = td_policies[class="num">0][class="num">2]; td_policies[class="num">0][class="num">0] = _next_row; td_policies[class="num">0][class="num">1] = td_value; td_policies[class="num">0][class="num">2] = _next_col; q_sa_act = class="num">1; q_ppo_act = class="num">1; for (class="type">int i = class="num">0; i < THIS.actions; i++) { if(Q_SA[i][_next_row][_next_col] > Q_SA[q_sa_act][_next_row][_next_col]) { q_sa_act = i; } if(Q_PPO.policy[i][_next_row][_next_col] > Q_PPO.policy[q_ppo_act][_next_row][_next_col]) { q_ppo_act = i; } } class=class="str">"cmt">//update last acts acts[class="num">1] = acts[class="num">0]; acts[class="num">0] = q_ppo_act; } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Helper function to compute the clipped PPO objective | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">class="kw">double Cql::GetClipping(class="type">class="kw">double OldProbability, class="type">class="kw">double NewProbability, class="type">class="kw">double Advantage) { class="type">class="kw">double _ratio = NewProbability / OldProbability; class="type">class="kw">double _clipped_ratio = fmin(fmax(_ratio, class="num">1 - THIS.epsilon), class="num">1 + THIS.epsilon); class="kw">return fmin(_ratio * Advantage, _clipped_ratio * Advantage); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void CSignalPPO::GetOutput(Cql *QL, class="type">int RewardSign) { vector _in, _in_row, _in_row_old, _in_col, _in_col_old; if ( _in_row.Init(m_scale) && _in_row.CopyRates(m_symbol.Name(), m_period, class="num">8, class="num">0, m_scale) && _in_row.Size() == m_scale && _in_row_old.Init(m_scale) && _in_row_old.CopyRates(m_symbol.Name(), m_period, class="num">8, class="num">1, m_scale) && _in_row_old.Size() == m_scale && _in_col.Init(m_scale) && _in_col.CopyRates(m_symbol.Name(), m_period, class="num">8, class="num">0, m_scale) && _in_col.Size() == m_scale && _in_col_old.Init(m_scale) && _in_col_old.CopyRates(m_symbol.Name(), m_period, class="num">8, m_scale, m_scale) && _in_col_old.Size() == m_scale ) { _in_row -= _in_row_old; _in_col -= _in_col_old; vector _in_e; _in_e.Init(m_scale); QL.Environment(_in_row, _in_col, _in_e); class="type">int _row = class="num">0, _col = class="num">0; QL.SetMarkov(class="type">int(_in_e[m_scale - class="num">1]), _row, _col); class="type">class="kw">double _reward_float = RewardSign*_in_row[m_scale - class="num">1]; class="type">class="kw">double _reward_max = RewardSign*_in_row.Max(); class="type">class="kw">double _reward_min = RewardSign*_in_row.Min(); class="type">class="kw">double _reward = QL.GetReward(_reward_max, _reward_min, _reward_float, RewardSign); if(m_policy) { QL.SetOnPolicy(_reward, _in_e);
◍ 多空投票的离散触发逻辑
CSignalPPO 把强化学习的输出直接映射成信号类的多空投票,而不是连续权重。LongCondition 里只认 RL_BUY.q_ppo_act==0 这一种状态,命中就返回 100 分,其余情况返回 0,等于把“涨”的判定压成单点开关。 ShortCondition 对称处理:调用 GetOutput(RL_SELL,-1) 后,仅当 RL_SELL.q_ppo_act==2 才给 100 分。两个函数返回值非 0 即 100,没有中间梯度,回测时若策略层 m_policy 关闭,QL.SetOffPolicy 会接管奖励修正,但信号门槛不变。 在 MT5 里把这段挂到自定义信号模块,能看到 ppo 动作枚举只要偏离 0 / 2,品种就不会被投票进场。外汇与贵金属波动大、杠杆高,这种硬阈值容易在震荡市连续空仓,实盘前建议先调 q_ppo_act 的容差或加滤波。
}
else if(!m_policy)
{ QL.SetOffPolicy(_reward, _in_e);
}
}
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| "Voting" that price will grow. |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">int CSignalPPO::LongCondition(class="type">void)
{ class="type">int result = class="num">0;
GetOutput(RL_BUY, class="num">1);
if(RL_BUY.q_ppo_act==class="num">0)
{ result = class="num">100;
}
class="kw">return(result);
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| "Voting" that price will fall. |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">int CSignalPPO::ShortCondition(class="type">void)
{ class="type">int result = class="num">0;
GetOutput(RL_SELL, -class="num">1);
if(RL_SELL.q_ppo_act==class="num">2)
{ result = class="num">100;
}
class="kw">return(result);
}测试报告里藏着信号的有效边界
用 MQL5 向导把自定义信号类编进 EA 后,我在 2022 年 H4 周期的 GBPJPY 上跑了一轮优化,抽出几组账面为正的参数,报告确实显示了这个信号类在短窗口里的潜力。但这次用的输入没做交叉验证,所以参数不公开,读者最好自己按预期重跑一遍。 报告本身不是‘圣杯’证明。我的看法是,任何 EA——不管全自动还是半自动辅助手工——对整套交易系统的贡献顶多占一半,另一半永远是人的情绪和执行。把一个看似完美的系统交给不懂它逻辑的人,他也会在关键决策上犹豫、事后怀疑。 所以只给没神话过的自定义信号,反而能让人看清两件事:为什么它在那次短暂优化里表现好,以及为什么换一段测试期表现可能就不同。这两点合起来,才是拉长周期去筛有效参数的起点。 交易者自己改参数、或者把不同自定义信号拼成能跑的 EA,这个过程本身就是补上那缺失的 50% 的方式。外汇和贵金属波动剧烈、杠杆风险高,任何回测亮眼都不代表实盘概率一致,动手前先在 MT5 策略测试器里用自己的样本重验。
「别急着下结论」
PPO 把政策稳定性和适应性揉进同一套更新逻辑,靠裁剪策略限制单步跨度,在离散与连续动作上都能跑,对交易这种现世环境算是低成本可落地的选项。它不挑资源堆量,遇到切换频繁的外汇或贵金属行情,概率上比硬更新政策的老方法更不容易一头撞进无效区。 附带三个文件可直接拖进 MT5 验证:Cql.mqh(15.12 KB,强化学习源类)、wz_49.mqh(7.04 KB,向导汇编智能系统)、SignalWZ_49.mqh(8.52 KB,自定义信号类)。开 MT5 把头文件挂上,跑一遍标准策略测试器,看裁剪系数对回撤的抑制是不是你预期的方向。 外汇与贵金属杠杆高、跳空频繁,任何强化学习策略都只是倾向降低样本浪费,不代表能躲过极端波动。先在小周期复现再谈实盘,别把一次回测当通解。