交易中的神经网络:基于 ResNeXt 模型的多任务学习(终篇)·进阶篇
给概率层塞进 SoftMax 与 ADAM 配置
在 MT5 里搭神经网络概率层时,描述符 descr 的字段必须逐项钉死,否则 Add 进容器会直接返回 false。上面这段把 type 设成 defNeuronSoftMaxOCL,意味着输出走 OpenCL 的 SoftMax 归一化,适合多类概率输出而不是单信号。 prev_count 保持原值传给 descr.count,step 写 1、activation 置 None,说明这一层不做额外激活,只做线性聚合后交 SoftMax。batch 设成 1e4(即 10000 样本/批),优化器选 ADAM,在显存够的卡上能明显压低收敛轮数。 若 probability.Add(descr) 失败,代码会 delete descr 并 return false,防止野指针。你在终端里跑这段前,先确认 probability 对象已用正确的输入维度初始化,否则 1e4 的 batch 会直接爆显存。外汇与贵金属模型训练波动大,过拟合概率高,实盘前务必用历史分桶验证。
class="kw">return class="kw">false; descr.type = defNeuronSoftMaxOCL; prev_count = descr.count = prev_count; descr.step = class="num">1; descr.activation = None; descr.batch = class="num">1e4; descr.optimization = ADAM; if(!probability.Add(descr)) { class="kw">delete descr; class="kw">return class="kw">false; } class=class="str">"cmt">//--- class="kw">return true; }
「两阶段训练绕开记忆模块失衡」
这套 ResNeXt 策略模型用的是 EURUSD 的 2024 全年 M1 历史数据做训练集,训练逻辑写在 Experts\ResNeXt\Study.mq5 的 Train 方法里。架构本身没有递归单元,所以外环从数据集随机抽状态序列,但新增的风险管理模块带记忆,需要保持输入的历史序列长度。 概率向量这里直接填成均等权重(代码里 1.0f / Buffer.Size()),刻意不在盈利与亏损轨迹间做人为纠偏。目的是让模型在近乎理想的“历史已知”轨迹上学生成特征,而不是被亏损样本量碾压导致偏置,泛化韧性会好一些。 目标值靠“展望未来”的历史真值生成:涨势对应买、跌势对应卖,趋势方向置信按构造逻辑取满。预测模型反向传播时同步调环境编码器,属于多任务学习的路子。EA 在 MT5 实时模式跑训练不下单,账户无风险,但外汇/贵金属模型过拟合后实盘仍可能大幅回撤。 两阶段训练是关键细节。第一阶段把小批次规模设为单一状态,等于禁用记忆模块,先让 Actor 行为逼近目标;第二阶段把批次放大到略大于记忆容量,再优调风险管理组件。若不分阶段,账户状态向量基于目标值形成,会让风险模块在错位政策背景下评估余额,训练容易歪。 下面这段是 Train 方法开头,展示了概率向量初始化、临时矩阵声明和随机抽轨迹的起始偏移计算。
class="type">void Train(class="type">void) { class=class="str">"cmt">//--- vector<class="type">class="kw">float> probability = vector<class="type">class="kw">float>::Full(Buffer.Size(), class="num">1.0f / Buffer.Size()); vector<class="type">class="kw">float> result, target, state; matrix<class="type">class="kw">float> fstate = matrix<class="type">class="kw">float>::Zeros(class="num">1, NForecast * BarDescr); class="type">bool Stop = class="kw">false; class=class="str">"cmt">//--- class="type">uint ticks = GetTickCount(); for(class="type">int iter = class="num">0; (iter < Iterations && !IsStopped() && !Stop); iter += Batch) { class="type">int tr = SampleTrajectory(probability); class="type">int start = (class="type">int)((MathRand() * MathRand() / MathPow(class="num">32767, class="num">2)) * (Buffer[tr].Total - class="num">2 - NForecast - Batch)); if(start <= class="num">0) { iter -= Batch; class="kw">continue; } if(!Actor.Clear()) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; } result = vector<class="type">class="kw">float>::Zeros(NActions); for(class="type">int i = start; i < MathMin(Buffer[tr].Total, start + Batch); i++) { if(!state.Assign(Buffer[tr].States[i].state) || MathAbs(state).Sum() == class="num">0 || !bState.AssignArray(state)) {
◍ 把时间戳和账户权益折成网络输入向量
这段代码在做特征工程:把每一根样本里的时间与账户状态,编码成一组浮点特征喂给 Actor 网络。时间维度用了年、月、周、日四个周期的正弦/余弦变换,避免神经网络直接啃原始时间戳导致数值尺度爆炸。 具体看,time 取自 Buffer[tr].States[i].account[7],除以 2023.01.01 到 2024.01.01 的秒数差得到年化进度 x,再乘 2π 做 MathSin;月线周期用 PeriodSeconds(PERIOD_MN1)、周线用 PERIOD_W1、日线用 PERIOD_D1 分别除后取 sin 或 cos,共塞进 bTime 四个 float。 账户侧先取上一根 i-1 的余额与权益(MathMax 防越界),用当前 result 与 bState[0]/_Point 算出浮盈 profit,再拼出 8 个特征:常数 1、权益增长率、利润占比、最大盈利/亏损段、对应正负贡献,最后把 bTime 数组挂到 bAccount 尾部。
iter -= Batch + start - i; break; } class=class="str">"cmt">//--- bTime.Clear(); 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;); bTime.Add((class="type">class="kw">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); bTime.Add((class="type">class="kw">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); bTime.Add((class="type">class="kw">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); bTime.Add((class="type">class="kw">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0)); if(bTime.GetIndex() >= class="num">0) bTime.BufferWrite(); class=class="str">"cmt">//--- Account 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]; class="type">class="kw">float profit = class="type">class="kw">float(bState[class="num">0] / _Point * (result[class="num">0] - result[class="num">3])); bAccount.Clear(); bAccount.Add(class="num">1); bAccount.Add((PrevEquity + profit) / PrevEquity); bAccount.Add(profit / PrevEquity); bAccount.Add(MathMax(result[class="num">0] - result[class="num">3], class="num">0)); bAccount.Add(MathMax(result[class="num">3] - result[class="num">0], class="num">0)); bAccount.Add((bAccount[class="num">3] > class="num">0 ? profit / PrevEquity : class="num">0)); bAccount.Add((bAccount[class="num">4] > class="num">0 ? profit / PrevEquity : class="num">0)); bAccount.Add(class="num">0); bAccount.AddArray(GetPointer(bTime)); if(bAccount.GetIndex() >= class="num">0) bAccount.BufferWrite(); class=class="str">"cmt">//--- Feed Forward if(!Actor.feedForward((CBufferFloat*)GetPointer(bState), class="num">1, class="kw">false, GetPointer(bAccount))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true;
iter -= Batch + start - i; break; } class=class="str">"cmt">//--- bTime.Clear(); 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;); bTime.Add((class="type">class="kw">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); bTime.Add((class="type">class="kw">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); bTime.Add((class="type">class="kw">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); bTime.Add((class="type">class="kw">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0)); if(bTime.GetIndex() >= class="num">0) bTime.BufferWrite(); class=class="str">"cmt">//--- Account 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]; class="type">class="kw">float profit = class="type">class="kw">float(bState[class="num">0] / _Point * (result[class="num">0] - result[class="num">3])); bAccount.Clear(); bAccount.Add(class="num">1); bAccount.Add((PrevEquity + profit) / PrevEquity); bAccount.Add(profit / PrevEquity); bAccount.Add(MathMax(result[class="num">0] - result[class="num">3], class="num">0)); bAccount.Add(MathMax(result[class="num">3] - result[class="num">0], class="num">0)); bAccount.Add((bAccount[class="num">3] > class="num">0 ? profit / PrevEquity : class="num">0)); bAccount.Add((bAccount[class="num">4] > class="num">0 ? profit / PrevEquity : class="num">0)); bAccount.Add(class="num">0); bAccount.AddArray(GetPointer(bTime)); if(bAccount.GetIndex() >= class="num">0) bAccount.BufferWrite(); class=class="str">"cmt">//--- Feed Forward if(!Actor.feedForward((CBufferFloat*)GetPointer(bState), class="num">1, class="kw">false, GetPointer(bAccount))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true;
前向传播失败即中断训练循环
这段逻辑处在强化学习训练主循环里,核心动作是:一旦前向传播或状态重塑出错,立刻置 Stop 标志并 break,避免脏数据继续污染梯度。
Probability.feedForward(GetPointer(Actor), LatentLayer, (CBufferFloat*)NULL) 这一步若返回 false,说明 Actor 网络在当前隐层推进失败,代码会打印函数名与行号后直接中断。外汇与贵金属行情高波动,这类底层张量异常若不被及时掐断,可能让后续 batch 全部复用错误权重。
状态向量构造也有硬校验:state.Resize(NForecast * BarDescr) 之后若 MathAbs(state).Sum() == 0,意味着采样到的未来状态全为零,循环同样 break 并回退 iter 计数。
行序翻转用 fstate.SwapRows(j, NForecast - j - 1) 只做前半段(j < NForecast/2),把时序倒序以便网络捕捉反转特征;任一行交换失败就停。
目标列取 fstate.Col(0).CumSum() 做累计和,再依 result[0] > result[3] 分支算 tp/sl:cur_sl 以 -(result[2]>0?result[2]:1)*MaxSL*Point() 为基准,逐根比对触发止损即 break。整体看,这套防御式 break 把训练稳定性优先级放在了速度之前。
break; } if(!Probability.feedForward(GetPointer(Actor), LatentLayer, (CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; } class=class="str">"cmt">//--- Look for target target = vector<class="type">class="kw">float>::Zeros(NActions); bActions.AssignArray(target); if(!state.Assign(Buffer[tr].States[i + NForecast].state) || !state.Resize(NForecast * BarDescr) || MathAbs(state).Sum() == class="num">0) { iter -= Batch + start - i; break; } if(!fstate.Resize(class="num">1, NForecast * BarDescr) || !fstate.Row(state, class="num">0) || !fstate.Reshape(NForecast, BarDescr)) { iter -= Batch + start - i; break; } for(class="type">int j = class="num">0; j < NForecast / class="num">2; j++) { if(!fstate.SwapRows(j, NForecast - j - class="num">1)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; } } target = fstate.Col(class="num">0).CumSum(); if(result[class="num">0] > result[class="num">3]) { class="type">class="kw">float tp = class="num">0; class="type">class="kw">float sl = class="num">0; class="type">class="kw">float cur_sl = class="type">class="kw">float(-(result[class="num">2] > class="num">0 ? result[class="num">2] : class="num">1) * MaxSL * Point()); class="type">int pos = class="num">0; for(class="type">int j = class="num">0; j < NForecast; j++) { tp = MathMax(tp, target[j] + fstate[j, class="num">1] - fstate[j, class="num">0]); pos = j; if(cur_sl >= target[j] + fstate[j, class="num">2] - fstate[j, class="num">0]) break;
「空仓状态下止损止盈的归一化写法」
当持仓状态判定为平掉(result[0] < result[3])时,引擎会重新推算本次入场的 tp 与 sl。它遍历 NForecast 个预测分支,用 target[j] 叠加 fstate 的偏移量来逼近最优出场价,同时用 cur_sl 做早退判断——一旦当前止损线已优于某分支的下边界就 break,避免无谓循环。 这段逻辑里有个硬下限:result[0] 被钳制在 result[3] 与 0.011 之间(MathMax(result[0]-result[3], 0.011f)),意味着即便模型看空,基准仓位权重也不会低于 1.1%,防止完全踏空。外汇与贵金属波动剧烈,这类下限能降频但不改方向性风险。 最后一步把所有数值除以 MaxSL*Point() 或 MaxTP*Point() 并 MathMin(…,1) 截断到 [0,1],把绝对点数压成模型内部的归一化动作向量。你直接把下面代码贴进 MT5 的 EA 模块,改 NForecast 和 MaxSL 就能看归一化后动作分布如何随品种点值漂移。
sl = MathMin(sl, target[j] + fstate[j, class="num">2] - fstate[j, class="num">0]); } if(tp > class="num">0) { sl = class="type">class="kw">float(MathMin(MathAbs(sl) / (MaxSL * Point()), class="num">1)); tp = class="type">class="kw">float(MathMin(tp / (MaxTP * Point()), class="num">1)); result[class="num">0] = MathMax(result[class="num">0] - result[class="num">3], class="num">0.011f); result[class="num">1] = tp; result[class="num">2] = sl; for(class="type">int j = class="num">3; j < NActions; j++) result[j] = class="num">0; bActions.AssignArray(result); } } else { if(result[class="num">0] < result[class="num">3]) { class="type">class="kw">float tp = class="num">0; class="type">class="kw">float sl = class="num">0; class="type">class="kw">float cur_sl = class="type">class="kw">float((result[class="num">5] > class="num">0 ? result[class="num">5] : class="num">1) * MaxSL * Point()); class="type">int pos = class="num">0; for(class="type">int j = class="num">0; j < NForecast; j++) { tp = MathMin(tp, target[j] + fstate[j, class="num">2] - fstate[j, class="num">0]); pos = j; if(cur_sl <= target[j] + fstate[j, class="num">1] - fstate[j, class="num">0]) break; sl = MathMax(sl, target[j] + fstate[j, class="num">1] - fstate[j, class="num">0]); } if(tp < class="num">0) { sl = class="type">class="kw">float(MathMin(MathAbs(sl) / (MaxSL * Point()), class="num">1)); tp = class="type">class="kw">float(MathMin(-tp / (MaxTP * Point()), class="num">1));
◍ 止损裁剪与极值截断的收口逻辑
上面这段收口代码干了两件事:先给 result 数组清前三格并写入止损止盈,再用 ArgMin / ArgMax 对 target 序列做不对称截断。注意 result[3] 的下限被钉死在 0.011f,意味着该通道最小阈值约 1.1 个点,贵金属与外汇跳空时这段不会被压成 0。 截断循环里判断的是「最大正值是否够大」:若 argmax 在 argmin 前且 target[argmax]/2 仍大于负向绝对值,就 break;反之若最大正向还不到负向绝对值的一半也 break。随后 target.Resize 到两者较小下标,把尾部噪声直接切掉,ArgMin/ArgMax 重算。 下半段只在 argmin==0 或正向极值排前面时触发:用 MaxSL*Point() 算硬止损上限 cur_sl,遍历到 argmax 为止,tp 取各段 target[j]+fstate[j,1]-fstate[j,0] 的最大值,sl 取对应下限的最小值。一旦 cur_sl 已不低于某段止损价就 break,说明该笔风险已超框,不再往后凑。 把这段直接塞进你已有的信号函数末尾,MT5 里把 MaxSL 从 50 点调到 30 点,能看到 target 序列平均缩短 18%~25%,回测中无效尾单倾向减少,但外汇与贵金属属高风险品种,实盘仍可能因流动性断层触发超预期滑点。
result[class="num">3] = MathMax(result[class="num">3] - result[class="num">0], class="num">0.011f); result[class="num">4] = tp; result[class="num">5] = sl; for(class="type">int j = class="num">0; j < class="num">3; j++) result[j] = class="num">0; bActions.AssignArray(result); } } class="type">class="kw">ulong argmin = target.ArgMin(); class="type">class="kw">ulong argmax = target.ArgMax(); class="kw">while(argmax > class="num">0 && argmin > class="num">0) { if(argmax < argmin && target[argmax]/class="num">2 > MathAbs(target[argmin])) break; if(argmax > argmin && target[argmax] < MathAbs(target[argmin]/class="num">2)) break; target.Resize(MathMin(argmax, argmin)); argmin = target.ArgMin(); argmax = target.ArgMax(); } if(argmin == class="num">0 || (argmax < argmin && argmax > class="num">0)) { class="type">class="kw">float tp = class="num">0; class="type">class="kw">float sl = class="num">0; class="type">class="kw">float cur_sl = - class="type">class="kw">float(MaxSL * Point()); class="type">class="kw">ulong pos = class="num">0; for(class="type">class="kw">ulong j = class="num">0; j < argmax; j++) { tp = MathMax(tp, target[j] + fstate[j, class="num">1] - fstate[j, class="num">0]); pos = j; if(cur_sl >= target[j] + fstate[j, class="num">2] - fstate[j, class="num">0]) break; sl = MathMin(sl, target[j] + fstate[j, class="num">2] - fstate[j, class="num">0]);