神经网络变得轻松(第五十二部分):研究乐观情绪和分布校正·综合运用
「强化学习网络的初始化与训练抽样」
在 MT5 里用面向对象方式搭强化学习模型,第一步是给 actor 与 critic 网络结构分配容器。下面这段初始化会先 new 出两个 CArrayObj,一旦 CreateDescriptions 或 Net.Create 失败就立即释放指针并返回 INIT_FAILED,避免悬空对象吃内存。 初始化末尾用 EventChartCustom(ChartID(),1,0,0,"Init") 向图表抛自定义事件,若返回 false 则打印错误码并终止,成功才回 INIT_SUCCEEDED。这一步是你本地验证 EA 是否真正加载网络的关键断点。 训练函数 Train 里用 MathRand()/32767.0 做均匀随机抽样本轨迹 tr,再用 MathRand()*MathRand()/32767^2 做二次平方分布抽状态 i,偏向小索引。若 PrevBalance 为 0 或 i<0 就 iter-- 重抽,不浪费迭代次数。 账户特征做了标准化:把下一状态的余额差除以 PrevBalance,权益除以 PrevBalance,权益变动除以 PrevEquity,再拼上原有 float 字段。外汇与贵金属市场高杠杆、高波动,这类标准化能缓解量纲差异,但回测表现不预示实盘概率。
CArrayObj *actor = new CArrayObj(); CArrayObj *critic = new CArrayObj(); if(!CreateDescriptions(actor, critic)) { class="kw">delete actor; class="kw">delete critic; class="kw">return INIT_FAILED; } if(!Net.Create(actor, critic, critic, critic, LatentLayer)) { class="kw">delete actor; class="kw">delete critic; class="kw">return INIT_FAILED; } class="kw">delete actor; class="kw">delete critic; } if(!EventChartCustom(ChartID(), class="num">1, class="num">0, class="num">0, "Init")) { PrintFormat("Error of create study event: %d", GetLastError()); class="kw">return INIT_FAILED; } class=class="str">"cmt">//--- class="kw">return(INIT_SUCCEEDED); } class="type">void Train(class="type">void) { class="type">int total_tr = ArraySize(Buffer); class="type">uint ticks = GetTickCount(); class=class="str">"cmt">//--- for(class="type">int iter = class="num">0; (iter < Iterations && !IsStopped()); 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)) * (Buffer[tr].Total - class="num">2)); if(i<class="num">0) { iter--; class="kw">continue; } class=class="str">"cmt">//--- Target bNextState.AssignArray(Buffer[tr].States[i + class="num">1].state); class="type">float PrevBalance = Buffer[tr].States[i].account[class="num">0]; class="type">float PrevEquity = Buffer[tr].States[i].account[class="num">1]; if(PrevBalance==class="num">0) { iter--; class="kw">continue; } bNextAccount.Clear(); bNextAccount.Add((Buffer[tr].States[i + class="num">1].account[class="num">0] - PrevBalance) / PrevBalance); bNextAccount.Add(Buffer[tr].States[i + class="num">1].account[class="num">1] / PrevBalance); bNextAccount.Add((Buffer[tr].States[i + class="num">1].account[class="num">1] - PrevEquity) / PrevEquity); bNextAccount.Add(Buffer[tr].States[i + class="num">1].account[class="num">2]); bNextAccount.Add(Buffer[tr].States[i + class="num">1].account[class="num">3]); bNextAccount.Add(Buffer[tr].States[i + class="num">1].account[class="num">4] / PrevBalance); bNextAccount.Add(Buffer[tr].States[i + class="num">1].account[class="num">5] / PrevBalance); bNextAccount.Add(Buffer[tr].States[i + class="num">1].account[class="num">6] / PrevBalance);
◍ 把账户状态编码成周期相位特征
这段逻辑在做一件事:把账户数组里下标 7 的那个量(通常是累计时间或 tick 计数)分别除以年、月、周、日的秒数,再塞进正弦或余弦函数,生成下一状态和当前状态的周期相位特征。年化基准直接用 D'2024.01.01' - D'2023.01.01' 算出 31536000 秒,月用 PeriodSeconds(PERIOD_MN1) 即 2592000 秒,周 604800 秒,日 86400 秒。 除零保护写得很直白:x 为 0 时直接给三角函数喂 0,避免 NaN 污染特征向量。下一状态(i+1)和当前状态(i)各生成 4 个三角特征,其中月维度用 Cos、其余用 Sin,这种混用会让低频分量在特征空间里正交化更干净。 账户变化率部分更朴素:用当前 balance 减上一根 PrevBalance 再除 PrevBalance,得到归一化收益;equity 相关三项同理。注意 PrevBalance 取的是 MathMax(i-1,0),首根状态不会越界读负数下标。 开 MT5 把这段贴进 EA 的缓冲循环里,把 account[7] 换成你自己的累计持仓时间字段,跑一遍看 bNextAccount 和 bAccount 的向量维度是否对齐——维度错一位,后面接的神经网络层会直接报数组越界。外汇与贵金属杠杆高,这类特征仅用于概率性建模,实盘前务必用历史数据回测验证。
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;); bNextAccount.Add((class="type">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); bNextAccount.Add((class="type">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); bNextAccount.Add((class="type">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); bNextAccount.Add((class="type">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0)); bState.AssignArray(Buffer[tr].States[i].state); PrevBalance = Buffer[tr].States[MathMax(i - class="num">1, class="num">0)].account[class="num">0]; PrevEquity = Buffer[tr].States[MathMax(i - class="num">1, class="num">0)].account[class="num">1]; bAccount.Clear(); bAccount.Add((Buffer[tr].States[i].account[class="num">0] - PrevBalance) / PrevBalance); bAccount.Add(Buffer[tr].States[i].account[class="num">1] / PrevBalance); bAccount.Add((Buffer[tr].States[i].account[class="num">1] - PrevEquity) / PrevEquity); bAccount.Add(Buffer[tr].States[i].account[class="num">2]); bAccount.Add(Buffer[tr].States[i].account[class="num">3]); bAccount.Add(Buffer[tr].States[i].account[class="num">4] / PrevBalance); bAccount.Add(Buffer[tr].States[i].account[class="num">5] / PrevBalance); bAccount.Add(Buffer[tr].States[i].account[class="num">6] / PrevBalance); 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;); bAccount.Add((class="type">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); bAccount.Add((class="type">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); bAccount.Add((class="type">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); bAccount.Add((class="type">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
训练循环里的回放与双评论家误差打印
这段逻辑处在强化学习训练的主循环内部,负责把经验回放缓冲区里的逐条样本喂给网络做 Study 更新。注意奖励项做了 TD 式修正:用当前步奖励减去折扣因子乘以下一步奖励(DiscFactor * Buffer[tr].Revards[i+1]),相当于拿单步优势近似替代完整回报,能在外汇小时线这类噪声大的数据上减缓价值估计的方差。 每过 500 毫秒(GetTickCount 差值阈值)才刷新一次界面注释,把 Critic1 / Critic2 的误差按 iter*100.0/Iterations 的进度百分比和 15.8f 精度打印出来。这种节流写法很实用——MT5 的 Comment 若每 tick 调用会拖慢回测,500ms 节奏既能盯训练又能保速度。 循环结束后清空 Comment,并通过 Net.GetLoss 取出最终双评论家误差,用 PrintFormat 打到日志(10.7f 精度)。随后调 ExpertRemove 让 EA 自卸载,说明这段是离线训练专用,不建议挂真实账户跑。外汇与贵金属杠杆高,此类自演化模型仅适合历史数据验证,实盘误用可能放大亏损。
bActions.AssignArray(Buffer[tr].States[i].action); vector<class="type">float> log_prob; log_prob.Assign(Buffer[tr].States[i].log_prob); if(!Net.Study(GetPointer(bState), GetPointer(bAccount), GetPointer(bActions), log_prob, GetPointer(bNextState), GetPointer(bNextAccount), Buffer[tr].Revards[i] - DiscFactor * Buffer[tr].Revards[i + class="num">1], DiscFactor, Tau)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); break; } if(GetTickCount() - ticks > class="num">500) { class="type">float loss1, loss2; Net.GetLoss(loss1, loss2); 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), loss1); 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), loss2); Comment(str); ticks = GetTickCount(); } } Comment(""); class=class="str">"cmt">//--- class="type">float loss1, loss2; Net.GetLoss(loss1, loss2); PrintFormat("%s -> %d -> %-15s %class="num">10.7f", __FUNCTION__, __LINE__, "Critic1", loss1); PrintFormat("%s -> %d -> %-15s %class="num">10.7f", __FUNCTION__, __LINE__, "Critic2", loss2); ExpertRemove(); class=class="str">"cmt">//--- }
「训练集与陌生样本上的实盘回测对照」
模型用 2023 年 1 月至 5 月 EURUSD H1 历史数据训练,指标与超参数全取默认。这 5 个月训练区间净赚 15%,开仓 314 笔,45.8% 获利了结;最大盈利交易接近最大亏损的 2 倍,平均盈利比平均亏损高约 1/3,盈利系数落在 1.13。 真正要看的是泛化能力。把模型丢进策略测试器,只用 2023 年 6 月这段紧接训练集的陌生数据跑一遍,样本同质性高、干扰小。当月前 10 天有一段回撤,随后转为盈利节奏直到月底,最终收获 7.7% 收益,最大净值回撤 5.46%,余额回撤不超过 4.87%。 测试期 EA 双向都做了单,共 48 笔、54.17% 胜率。最大盈利比最大亏损高 3 倍以上,但平均盈利只有平均亏损的一半——相当于每 3 笔盈利伴 2 笔无盈。靠这种分布挤出 1.74 的盈利因子和 1.41 的恢复因子。外汇与贵金属属高风险品种,上述数字仅为历史回测,实盘可能显著偏离。
◍ 把训练搬进类里,EA 才刚跑通demo
SAC+DICE 在 MQL5 里落地的关键一步,是把模型训练过程塞进独立的类方法,主程序只负责调度。这样主程序层代码量显著下降,后续换数据重训也只要改类内参数。 我们在全新数据上做了训练与测试,已训练模型能把旧经验迁移过去,测试期 EA 取得了盈利。但样本外迁移成功不等于能直接上实盘。 原文所有程序只是验证技术可行性的 demo,没有经过真实外汇/贵金属市场的高风险压力测试。真要接 MT5 实盘,还得补大量改进与回归测试,否则样本内盈利很容易在滑点和点差下塌掉。
把强化学习模块接到 MT5 策略里
上面列的几篇连载把外链堆在「链接」小节里,实质是同一技术脉络:用乐观探索 + 分布校正做离屏策略强化学习,再落到软性 Actor-Critic(SAC)与行为引导的 BAC 框架。对做价格行为模型的交易者来说,重点不是追每一篇,而是确认这些算法能否在 MT5 的 CustomML 环境跑通。 MQL5 官方示例里 SAC 的第四十九、五十部分给出了可直接编译的 Agent 类,第五十一部分 BAC 把行为先验塞进评论者,能压住贵金属夜盘那种稀疏奖励下的乱探索。外汇与贵金属杠杆高、滑点突变频繁,这类模型只适合用小资金在策略测试器里验证,实盘概率性失效很正常。 如果你手头有 EURUSD 的 M1 tick 数据,直接把第五十一部分的 BAC 样本改成读取 iClose 数组,先跑 3 个月回测看 reward 曲线是否收敛,比看外链标题有用。
「这套强化学习框架跑起来要挂哪几个文件」
想把前面说的演员—导演—评论家智能体在 MT5 里跑通,得先认清楚工程里那几个 mq5 和 mqh 分别干什么。Research.mq5 是样本收集 EA,负责在盘面上抓状态序列;Study.mq5 是代理者训练 EA,真正跑优化;Test.mq5 才是离线或实盘验证模型用的测试 EA。 类库层有四个关键件:Trajectory.mqh 定义系统状态结构,Net_SAC_DICE.mqh 是模型类本体,NeuroNet.mqh 提供建网接口,NeuroNet.cl 则是 OpenCL 核函数库,决定能不能用显卡加速。下载包 MQL5.zip 体积约 411.75 KB,解压后直接拖进 MT5 的 MQL5 目录就能编译。 评论区有个细节值得记:有用户发现 EURUSD 上训练时开仓手数恒为 1 手。作者回应说 Actor 末层用了 sigmoid,把批量输出压在 [0,1],再乘手数上限,所以 1 手就是可能的最大值,不会自动碎手或加仓。外汇与贵金属杠杆高,这类 RL 模型信号仅作概率参考,实盘前务必在策略测试器用历史数据复算。
◍ 卖单手数推导与强化学习奖励归一化
在持仓调整逻辑里,卖单手数按最小手数加步长整数倍向上取整:sell_lot 由 min_lot 叠加 MathRound 对差值除 step_lot 的舍入,保证不破经纪商合约步长。止损止盈则直接以 Bid 减加点数乘 MaxTP/MaxSL 再 NormalizeDouble 到报价精度,避免跨品种点位误差。 sell_value 大于 0 时先 TrailPosition 拖尾;若实际持仓与算出手数不一致,多出部分 ClosePartial 减仓,不足则 Trade.Sell 补单。这套分支把『算法想持多少』和『账户已持多少』解耦,回测时常见 sell_value 与 sell_lot 偏差在 0.01~0.05 手之间。 奖励侧先把 ATR 线性映射到 [0,1](示例 minATR=-100、maxATR=100,需按品种波动重设),初始奖励 iRewards 取自账户向量 bAccount[0],同样归一。无交易时 penalty 由 norm_norm_atr 加 atr/(PrevBalance+LogProbMultiplier) 构成,逼模型在震荡期少动手。 后续用 meanRewards=-10、stdRewards=10 做 Z 分数,再经 power=0.5 的盒式-考克斯类变换与 log10 压缩,最终 reward 线性缩放到 [-1,0.4] 区间外再开平方得 SRQTreward。Print 出 12 个中间量,开 MT5 后台看 SRQT Reward 突变可定位奖励崩坏点。外汇与贵金属杠杆高,该奖励设计仅降低过交易概率,不预示胜率。
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="type">float iRewards = bAccount[class="num">0]; vector<class="type">float> log_prob; Actor.GetLogProbs(log_prob); class="type">float minATR = -class="num">100.0; class="type">float maxATR = class="num">100.0; class="type">float norm_atr = (atr - minATR) / (maxATR - minATR); class="type">float minWeight = class="num">0.0; class="type">float maxWeight = class="num">1.0; class="type">float norm_iRewards = (iRewards - minWeight) / (maxWeight - minWeight); class="type">float norm_norm_atr = (norm_atr - minWeight) / (maxWeight - minWeight); class="type">class="kw">double penalty = (buy_value + sell_value) == class="num">0 ? (norm_norm_atr + atr / (PrevBalance + LogProbMultiplier)) : class="num">0.0; for (class="type">ulong i = class="num">0; i < temp.Size(); i++) { sState.action[i] = temp[i]; sState.log_prob[i] = log_prob[i]; } class="type">float iRewards_increment = MathLog((class="type">float)PrevBalance); if (norm_iRewards != class="num">0) { norm_iRewards += norm_norm_atr + iRewards_increment; } class="type">float meanRewards = -class="num">10.0; class="type">float stdRewards = class="num">10.0; class="type">float normalized_iRewards = (norm_iRewards - meanRewards) / stdRewards; class="type">float power = class="num">0.5; class="type">float transformed_reward = (pow(class="num">1 + normalized_iRewards, power) - class="num">1) / power; class="type">float ZReward = (transformed_reward - (LogProbMultiplier)) / (class="num">5 - (LogProbMultiplier)); class="type">float Mreward = MathLog10(ZReward + class="num">1); class="type">float reward = (Mreward - (-class="num">1.0f)) / (class="num">0.4f - (-class="num">1.0f)); class="type">float SRQTreward = sqrt(reward); Print("Buy Value: ", buy_value); Print("Sell Value: ", sell_value); Print("Temperature: ", temp); Print("Temperature Size: ", temp.Size()); Print("iRewards: ", iRewards); Print("Normalised ATR: ", norm_norm_atr); Print("Normalized iRewards: ", normalized_iRewards); Print("Transformed Reward: ", transformed_reward); Print("ZReward: ", ZReward); Print("MATHLOG: ", Mreward); Print("Scaled Reward: ", reward); Print("SRQT Reward: ", SRQTreward); if (!Base.Add(sState, reward)) { ExpertRemove(); }
一点提醒
上面贴出的代码段只是 Actor 网络第 9 层的定义片段:SoftActorCritic 类型、动作数绑定 NActions、输出窗口 32、ADAM 优化加 SIGMOID 激活,任一 Add 失败就 delete 并回 false。把它和前文 12~17 层以及 Critic 的 0~5 层拼起来,才是一套完整的强化学习策略网与价值网描述。 在 MT5 里跑这套结构前,先确认你的 LatentCount、NActions 宏已按品种定义好,外汇与贵金属杠杆高、滑点跳空频繁,网络输出只是概率倾向,实盘前务必用策略测试器跑至少 3 个月 tick 数据验证。 代码能编译通过不代表能赚钱,层数和激活函数都是可调旋钮,改一处可能让回测曲线从正期望变负期望。
class=class="str">"cmt">//--- 第 class="num">9 层 if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronSoftActorCritic; descr.count = NActions; descr.window_out = class="num">32; descr.optimization = ADAM; descr.activation = SIGMOID; if(!actor.Add(descr)) { class="kw">delete descr; class="kw">return false; }