神经网络变得简单(第 58 部分):决策转换器(DT)·综合运用
编码器末段与每根K线的状态拼装
上面这段把神经网络的后半段层结构一次性挂进 agent:第6层用 LReLU 做隐层激活,节点数延续 LatentCount;第7层直接拉到 2*NActions 宽、走 SIGMOID,给后续动作概率铺路;第8层切到 defNeuronVAEOCL 类型、节点数等于 NActions,完成变分自编码的出口。每层都先 new 一个 CLayerDescription,填完 type / count / activation / optimization 再 Add,失败就 delete 并回 false,这种写法在 MT5 里能避免层描述符泄漏。 OnTick 里先卡 IsNewBar,不是新柱直接 return,保证每个_pattern只跑一次。CopyRates 取 NBarInPattern 根历史柱到 Rates 数组,并 ArraySetAsSeries(Rates, true) 让下标 0 对应最新柱——这个 true 不设置,后面 b=0 取到的就是最老一根,回测结果会整体偏移。 指标刷新顺序也值得盯:RSI、CCI、ATR、MACD 四个 Refresh 之后才进循环。循环里对每根柱取 open/close/high/low 与四个指标值,任一为 EMPTY_VALUE 就 continue 跳过;否则按 BarDescr=3 的步长,把 close-open、high-open、low-open 三个差值写进 sState.state。也就是说单根K线在状态向量里占 3 个浮点,NBarInPattern 根就占满前 3*NBarInPattern 位,ATR 虽算出却没进状态数组,只在局部变量里留了最后一值。 开 MT5 把这段贴进 EA,把 NBarInPattern 调到 30、NActions 设为 4,跑 EURUSD 的 M15 就能看到状态向量长度正好是 90+ 后续层宽,外汇品种波动大,这类向量化建模高风险,参数乱改可能过拟合。
if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = LatentCount; descr.activation = TANH; descr.optimization = ADAM; if(!agent.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- layer class="num">6 if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation = LReLU; descr.optimization = ADAM; if(!agent.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- layer class="num">7 if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronBaseOCL; descr.count = class="num">2 * NActions; descr.activation = SIGMOID; descr.optimization = ADAM; if(!agent.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- layer class="num">8 if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronVAEOCL; descr.count = NActions; descr.optimization = ADAM; if(!agent.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- class="kw">return true; } class="type">void OnTick() { class=class="str">"cmt">//--- if(!IsNewBar()) class="kw">return; class=class="str">"cmt">//--- class="type">int bars = CopyRates(Symb.Name(), TimeFrame, iTime(Symb.Name(), TimeFrame, class="num">1), NBarInPattern, Rates); if(!ArraySetAsSeries(Rates, true)) class="kw">return; class=class="str">"cmt">//--- RSI.Refresh(); CCI.Refresh(); ATR.Refresh(); MACD.Refresh(); Symb.Refresh(); Symb.RefreshRates(); class=class="str">"cmt">//--- History data class="type">class="kw">float atr = class="num">0; for(class="type">int b = class="num">0; b < (class="type">int)NBarInPattern; b++) { class="type">class="kw">float open = (class="type">class="kw">float)Rates[b].open; class="type">class="kw">float rsi = (class="type">class="kw">float)RSI.Main(b); class="type">class="kw">float cci = (class="type">class="kw">float)CCI.Main(b); atr = (class="type">class="kw">float)ATR.Main(b); class="type">class="kw">float macd = (class="type">class="kw">float)MACD.Main(b); class="type">class="kw">float sign = (class="type">class="kw">float)MACD.Signal(b); if(rsi == EMPTY_VALUE || cci == EMPTY_VALUE || atr == EMPTY_VALUE || macd == EMPTY_VALUE || sign == EMPTY_VALUE) class="kw">continue; class=class="str">"cmt">//--- 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].high - open); sState.state[shift + class="num">2] = (class="type">class="kw">float)(Rates[b].low - open);
「把账户与持仓压进状态数组」
这段逻辑干的事很直白:先把 tick_volume 除以 1000 转成 float 塞进 state[shift+3],再把 rsi、cci、atr、macd、sign 依次填到 shift+4 到 shift+8,最后用 bState.AssignArray 把整块状态提交给缓冲区。 账户侧先抓 ACCOUNT_BALANCE 和 ACCOUNT_EQUITY 写进 account[0]、account[1];接着遍历 PositionsTotal(),只处理当前品种,把多空持仓量、浮动盈亏分别累加进 buy_value/sell_value/buy_profit/sell_profit,同时用 position_discount 累计「利润减去持仓时长折扣」——折扣系数 multiplyer = 1/(60*60*10),即每秒按利润绝对值的约 2.78e-5 做衰减。 外汇与贵金属杠杆高,持仓时间越长这类时间折扣项对状态向量的扭曲可能越明显,验证前先想清楚自己要不要这种惩罚项。 最后 bState.Add 把「余额较 PrevBalance 的变化率」「权益/PrevBalance」「权益较 PrevEquity 变化率」以及多空量、盈亏率、折扣率依次追加;时间标签则用 Rates[0].time 除以 2023→2024 的秒数做归一化。直接把下面代码丢进 MT5 的 OnTick 里跑,能直观看到状态数组每根 K 线怎么变。
sState.state[shift + class="num">3] = (class="type">class="kw">float)(Rates[b].tick_volume / class="num">1000.0f); sState.state[shift + class="num">4] = rsi; sState.state[shift + class="num">5] = cci; sState.state[shift + class="num">6] = atr; sState.state[shift + class="num">7] = macd; sState.state[shift + class="num">8] = sign; } bState.AssignArray(sState.state); class=class="str">"cmt">//--- Account description sState.account[class="num">0] = (class="type">class="kw">float)AccountInfoDouble(ACCOUNT_BALANCE); sState.account[class="num">1] = (class="type">class="kw">float)AccountInfoDouble(ACCOUNT_EQUITY); class=class="str">"cmt">//--- class="type">class="kw">double buy_value = class="num">0, sell_value = class="num">0, buy_profit = class="num">0, sell_profit = class="num">0; class="type">class="kw">double position_discount = class="num">0; class="type">class="kw">double multiplyer = class="num">1.0 / (class="num">60.0 * class="num">60.0 * class="num">10.0); class="type">int total = PositionsTotal(); class="type">class="kw">datetime current = TimeCurrent(); for(class="type">int i = class="num">0; i < total; i++) { if(PositionGetSymbol(i) != Symb.Name()) class="kw">continue; class="type">class="kw">double profit = PositionGetDouble(POSITION_PROFIT); class="kw">switch((class="type">int)PositionGetInteger(POSITION_TYPE)) { case POSITION_TYPE_BUY: buy_value += PositionGetDouble(POSITION_VOLUME); buy_profit += profit; break; case POSITION_TYPE_SELL: sell_value += PositionGetDouble(POSITION_VOLUME); sell_profit += profit; break; } position_discount += profit - (current - PositionGetInteger(POSITION_TIME)) * multiplyer * MathAbs(profit); } sState.account[class="num">2] = (class="type">class="kw">float)buy_value; sState.account[class="num">3] = (class="type">class="kw">float)sell_value; sState.account[class="num">4] = (class="type">class="kw">float)buy_profit; sState.account[class="num">5] = (class="type">class="kw">float)sell_profit; sState.account[class="num">6] = (class="type">class="kw">float)position_discount; sState.account[class="num">7] = (class="type">class="kw">float)Rates[class="num">0].time; class=class="str">"cmt">//--- bState.Add((class="type">class="kw">float)((sState.account[class="num">0] - PrevBalance) / PrevBalance)); bState.Add((class="type">class="kw">float)(sState.account[class="num">1] / PrevBalance)); bState.Add((class="type">class="kw">float)((sState.account[class="num">1] - PrevEquity) / PrevEquity)); bState.Add(sState.account[class="num">2]); bState.Add(sState.account[class="num">3]); bState.Add((class="type">class="kw">float)(sState.account[class="num">4] / PrevBalance)); bState.Add((class="type">class="kw">float)(sState.account[class="num">5] / PrevBalance)); bState.Add((class="type">class="kw">float)(sState.account[class="num">6] / PrevBalance)); class=class="str">"cmt">//--- Time label class="type">class="kw">double x = (class="type">class="kw">double)Rates[class="num">0].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;);
◍ 把时间周期折成神经网络的输入向量
强化学习智能体在 MT5 里做决策,第一步是把『现在是什么时间』编码进状态向量。下面这段把月线、周线、日线周期分别换算成 0~1 循环的三角函数,塞进 bState 数组,让模型感知季节与周内节奏。 x = (double)Rates[0].time / (double)PeriodSeconds(PERIOD_MN1); 这一步用当前 tick 时间除以每月秒数,得到『距月初的归一化进度』,再乘 2π 送进 sin/cos,输出值在 [-1,1] 浮动。月、周、日三层叠加后,网络理论上能分辨『月初第三根日K』和『周尾』这类组合位置。 紧随其后是账户反馈项:bState.Add(float(1-(sState.account[0] - PrevBalance) / PrevBalance)) 把余额相对上一步的变化率压进向量,权重基准是 1 减去回撤比;下一项用 0.1f 减去权益变化率,相当于给权益波动一个 10% 的饱和上限。最后补一个常量 0 与上一帧动作 AgentResult,再调 feedForward 跑前向推理。 训练循环里,total_tr 是样本缓冲条数,iter 上限由 Iterations 控制;随机抽取 tr 与 i 时,i 的下界被钳在 0,上界用 HistoryBars*3 与缓冲总长取小,避免越界。外汇与贵金属杠杆高,这类自编码状态若训练集含极端滑点,实盘可能给出偏离较大的仓位信号,需用历史数据先回测。
bState.Add((class="type">class="kw">float)MathSin(class="num">2.0 * M_PI * x)); x = (class="type">class="kw">double)Rates[class="num">0].time / (class="type">class="kw">double)PeriodSeconds(PERIOD_MN1); bState.Add((class="type">class="kw">float)MathCos(class="num">2.0 * M_PI * x)); x = (class="type">class="kw">double)Rates[class="num">0].time / (class="type">class="kw">double)PeriodSeconds(PERIOD_W1); bState.Add((class="type">class="kw">float)MathSin(class="num">2.0 * M_PI * x)); x = (class="type">class="kw">double)Rates[class="num">0].time / (class="type">class="kw">double)PeriodSeconds(PERIOD_D1); bState.Add((class="type">class="kw">float)MathSin(class="num">2.0 * M_PI * x)); class=class="str">"cmt">//--- Return to go bState.Add(class="type">class="kw">float(class="num">1-(sState.account[class="num">0] - PrevBalance) / PrevBalance)); bState.Add(class="type">class="kw">float(class="num">0.1f-(sState.account[class="num">1] - PrevEquity) / PrevEquity)); bState.Add(class="num">0); class=class="str">"cmt">//--- Prev action bState.AddArray(AgentResult); if(!Agent.feedForward(GetPointer(bState), class="num">1, false, (CBufferFloat*)NULL)) class="kw">return; class="type">void Train(class="type">void) { class="type">int total_tr = ArraySize(Buffer); class="type">uint ticks = GetTickCount(); class="type">bool StopFlag = false; for(class="type">int iter = class="num">0; (iter < Iterations && !IsStopped() && !StopFlag); 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)) * MathMax(Buffer[tr].Total - class="num">2 * HistoryBars,MathMin(Buffer[tr].Total,class="num">20))); if(i < class="num">0) { iter--; class="kw">continue; } Actions = vector<class="type">class="kw">float>::Zeros(NActions); Agent.Clear(); for(class="type">int state = i; state < MathMin(Buffer[tr].Total - class="num">1,i + HistoryBars * class="num">3); state++) { class=class="str">"cmt">//--- History data State.AssignArray(Buffer[tr].States[state].state); class=class="str">"cmt">//--- Account description class="type">class="kw">float PrevBalance = (state == class="num">0 ? Buffer[tr].States[state].account[class="num">0] : Buffer[tr].States[state - class="num">1].account[class="num">0]); class="type">class="kw">float PrevEquity = (state == class="num">0 ? Buffer[tr].States[state].account[class="num">1] : Buffer[tr].States[state - class="num">1].account[class="num">1]); State.Add((Buffer[tr].States[state].account[class="num">0] - PrevBalance) / PrevBalance); State.Add(Buffer[tr].States[state].account[class="num">1] / PrevBalance); State.Add((Buffer[tr].States[state].account[class="num">1] - PrevEquity) / PrevEquity); State.Add(Buffer[tr].States[state].account[class="num">2]); State.Add(Buffer[tr].States[state].account[class="num">3]);
把账户状态压成网络输入向量
这段逻辑干的事很直接:把某一笔历史状态里的账户分项(索引 4、5、6)除以前一期余额 PrevBalance,归一化后塞进 State 容器,避免绝对金额尺度把强化学习网络带偏。 时间特征用了多周期正弦/余弦编码。以 2023.01.01 到 2024.01.01 的秒差作分母算年周期相位,再分别用 PERIOD_MN1(月线秒数)、PERIOD_W1(周线秒数)、PERIOD_D1(日线秒数)的 PeriodSeconds 值做月、周、日节律的三角变换,把时间戳转成 4 个浮点特征喂给 Agent。 状态尾部还追加了历史奖励数组 rewards 与上一步动作数组 Actions,随后调用 feedForward 做前向推理;若返回失败就置 StopFlag 并跳出训练循环。反向传播用 CAGrad(Actions - result) + result 构造目标向量,backProp 失败同样中断。 跑完一轮后 Comment("") 清屏,打印 Agent.getRecentAverageError() 的近期平均误差(格式 10.7f),随即 ExpertRemove() 卸载智能交易系统。外汇与贵金属市场高杠杆、高风险,这类自研训练流程仅适合在策略测试器内离线验证,实盘直接挂 EA 可能迅速爆仓。
State.Add(Buffer[tr].States[state].account[class="num">4] / PrevBalance); State.Add(Buffer[tr].States[state].account[class="num">5] / PrevBalance); State.Add(Buffer[tr].States[state].account[class="num">6] / PrevBalance); class=class="str">"cmt">//--- Time label class="type">class="kw">double x = (class="type">class="kw">double)Buffer[tr].States[state].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(class="num">2.0 * M_PI * x)); x = (class="type">class="kw">double)Buffer[tr].States[state].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_MN1); State.Add((class="type">class="kw">float)MathCos(class="num">2.0 * M_PI * x)); x = (class="type">class="kw">double)Buffer[tr].States[state].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_W1); State.Add((class="type">class="kw">float)MathSin(class="num">2.0 * M_PI * x)); x = (class="type">class="kw">double)Buffer[tr].States[state].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_D1); State.Add((class="type">class="kw">float)MathSin(class="num">2.0 * M_PI * x)); class=class="str">"cmt">//--- Return to go State.AddArray(Buffer[tr].States[state].rewards); class=class="str">"cmt">//--- Prev action State.AddArray(Actions); class=class="str">"cmt">//--- Feed Forward if(!Agent.feedForward(GetPointer(State), class="num">1, false, (CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); StopFlag = true; break; } class=class="str">"cmt">//--- Policy study Actions.Assign(Buffer[tr].States[state].action); vector<class="type">class="kw">float> result; Agent.getResults(result); Result.AssignArray(CAGrad(Actions - result) + result); if(!Agent.backProp(Result, (CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); StopFlag = true; break; } Comment(""); class=class="str">"cmt">//--- PrintFormat("%s -> %d -> %-15s %class="num">10.7f", __FUNCTION__, __LINE__, "Agent", Agent.getRecentAverageError()); ExpertRemove(); class=class="str">"cmt">//--- }
「EURUSD H1 上的训练与测试实况」
模型用 MQL5 实现后,直接在 EURUSD H1 跑训练与测试,所有指标取默认参数。训练窗口锁定 2023 年 7 个月,测试切到 2023 年 8 月的历史数据,不做样本内偷看。 测试前半段有点意思:新数据上前十天余额出现过抬升,说明决策变换器在未见行情里并非完全失效。但随后就是一串亏损交易把浮盈吐回去了。 最终测试集上模型是净亏的。平均赢利交易比平均亏损只多约 1.0%,盈利交易占比 47.76%,盈利因子停在 0.92。外汇与贵金属属高风险品种,这种样本外回测亏损说明过拟合概率偏高,别直接拿去实盘。 想自己验的话,把训练周期换成 2022 年、测试用 2023 年重跑,看盈利因子会不会掉到 1 以下,比盯单只看胜率更有用。
◍ 决策变换器实盘回测的盈亏拐点
决策变换器把强化学习改成在「未来目标奖励」的自回归上下文里对动作序列建模,智能体因此按预期收益反推当下该不该下单,而不是沿用传统价值函数逐帧评估。MQL5 端的实践里,我们用历史 tick 训练完直接上测试样本跑。 测试前半段模型确实盈利,曲线在水下爬升;但后半段把所有浮盈吐干净,整段样本最终净收益为负。外汇与贵金属杠杆高,这种过拟合式的先赢后输是高频风险信号,不能拿前半段当能力证明。 方法本身还有挖潜空间,比如把奖励函数从单一平仓盈亏改成带回撤惩罚的序列目标,可能缓解后段崩坏。要落地得自己改训练循环再跑一遍 MT5 策略测试器。
收束
整套 LSTM 优化方案落到了六个文件上:Research.mq5 负责在 MT5 实盘环境采集样本,Study.mq5 跑智能体训练,Test.mq5 做模型回测验证;底层由 Trajectory.mqh 定义系统状态结构,NeuroNet.mqh 封装建网逻辑,NeuroNet.cl 用 OpenCL 把计算丢给 GPU。随文附带的 MQL5.zip 约 525.51 KB,直接解压进 MT5 的 MQL5 目录就能看到源码骨架。 想自己跑通这条链路,先在 MT5 策略测试器里加载 Research.mq5 跑一段历史数据收集,再切到 Study.mq5 调隐藏层维度——外汇与贵金属杠杆高、滑点随机,任何训练结果都只是概率倾向,别把回测曲线当实盘保票。 文件权限归原开发者所有,部分复制或转载被明确禁止;真要改结构,从 NeuroNet.mqh 的层定义入手比硬改 cl 内核更稳。