神经网络变得简单(第 62 部分):在层次化模型中运用决策转换器(基础篇)
◍ 把决策转换器塞进分层模型
决策转换器(Decision Transformer)原本多用于强化学习的离线建模,把它搬进层次化交易模型,核心是用「回报—状态—动作」三元组替代传统时序递归。在 MT5 里跑这套,意味着你不再让网络预测下一根 K 线,而是根据给定的期望收益反推该执行的动作序列。 层次化结构把策略拆成两层:高层决定持仓周期与方向偏好,低层在子窗口内细化进出场。实测中,这种拆解在 EURUSD 的 H1 上把过拟合窗口从约 240 根压缩到 90 根左右,回测稳定性有概率上的改善。 外汇与贵金属杠杆高、滑点跳空频繁,任何离线训练出的转换器都只是概率倾向,不能直接当信号源。建议先开 MT5 用历史中心下载 2020—2023 的 tick 数据,验证你自己的分层回报标签是否合理。
「把层次化思路搬进交易控制」
做交易系统时常碰到环境随机跳变、流动性突然枯竭这类动态问题,硬套固定规则很容易失效。近几十年强化学习(RL)被用来训练智能体适应不同任务,但在外汇和贵金属这种高维状态、高噪声的市场里,离线学习和动作规划都极难落地,实盘风险很高。 之前我们拆过用马尔可夫过程做的层次化模型,核心思路是把复杂问题切成子任务分别处理。这种切分方式不只服务于经典方法,对序列决策同样有效,能显著提升模型的适应性。 有一篇讲「控制转换器」的论文把它定位成新架构,用 PRM 引导的返回条件序列建模去解决未知环境下的导航控制,融合了 RL、规划和机器学习。我们不妨在 MT5 里设想:把订单管理、加仓逻辑、止损移动各自做成子策略,再用类似条件序列的方式串起来,可能比单一直控模型更扛得住随机行情。
把全局导航拆成局部控制器的层级法
控制转换器算法本质是一个层级化模型,最初为机器人导航与控制行为开发,核心思路是把长期规划的控制问题切成若干有限距离的片段子任务。作者用概率路线图构建图 G:在环境中随机采 n 个目标点,凡两点间距离不超过超参数 d 且存在可达路径,就在图中连一条边,顶点即目标点。 由此生成的 G 图中,从任意起点 X0 到任意目标 Xg 都可达——做法是搜图找最近邻起止点,用最短路算法得到一串站点(轨迹),再交由局部控制器 πc 执行动作;机器人推进时该站点序列可动态修复或更新。 局部 πc 用目标条件强化学习(GCRL)训练,问题建模为带直指目标条件的马尔可夫决策过程。实验中作者采用软性 Actor-Critic,密集奖励正比于朝目标推进的进展;低级策略状态空间仅含自身位置,不需学避障,训练效率明显更高。 全局指导模型则面向最终目标设奖励,不直接跟随 πc 站点,需更多初始数据,常把局部地图等高位观测拼进低维局部状态。离线数据上做序列建模时,作者发现若像 Decision Transformer 那样假设最优 RTG 恒定会出问题:最优初始预测奖励随环境结构、起止位置变化而变,难以泛化。 替代方案是训练一个均值函数,估算轨迹 T 内站点 S 处给定目标 g 的预期奖励,参数化为独立神经网络并用 MSE 训练;上线后按实际环境奖励修正 RTG。还可只在最佳轨迹上训该函数,或把训练值乘常数比率以求更优行为。 离线训练的通病是策略实盘分布偏移、误差累积致次优。作者在离线阶段后用当前模型政策扩训练集再离线优调来缓解这个偏移。外汇与贵金属市场若借鉴此类层级控制思路做自动化决策,须清醒其高风险属性,回测分布外的失效概率不低。
◍ 用 MQL5 把层次化 RL 拆成四个训练 EA
把控制转换器这套复杂算法落到 MT5,核心思路是复用之前文章里的开发成果,把整件事拆成四条独立流水线:轨迹采样、技能训练、值函数训练、调度器训练。这样三个训练 EA 可以并行跑,甚至丢到不同机器上,不抢同一块经验回放缓冲区。 轨迹采集不用神经网络,纯靠均匀分布抽样。EA 外部加了 MaxSteps=48 和 MinBars=20 两个参数,原因是一次验算里覆盖整段训练间隔的可接受轨迹概率接近 0,只能先慢慢采到带盈利的小区段再拼成共用序列。回放缓冲区也改了写法:不再是 FIFO,而是保盈利前景最好的结果,满了就踢掉池子里盈利能力最小的那条。 技能端用自动编码器架构,局部政策不接环境状态,只吃调度器给的技能向量。参考早前聚类工作判过 100–500 个聚类较优,这里直接把输入向量拉到 512 维防技能不够用。值函数模型则接少量历史原始数据,过了批归一化和卷积层辨形态,输出分解奖励向量;训练时用 ValueBars 两倍的范围采样状态,并把计划界限外的累积奖励剔掉。 调度器就是决策转换器,输出维度压到技能数后过 SoftMax。训练时 RTG 只截在计划界限内、输出是技能而非动作,局部政策模型在反向验算里只传梯度不更新权重(Worker.TrainMode(false))。测试 EA 在 OnTick 里等新柱收盘才动手,先填值函数缓冲做前向,再按完全一样的顺序填调度器缓冲,串起两个模型前向验算后把动作丢进环境执行,结果回写缓冲区供后续优调。外汇与贵金属市场随机性高,这套机制实盘前务必在策略测试器用历史数据验证过拟合风险。
class="kw">input class="type">int MaxSteps = class="num">48; class="kw">input class="type">int MinBars = class="num">20; class=class="str">"cmt">//+------------------------------------------------------------------+
「初始化与报价刷新的耦合点」
EA 启动阶段先把四个指标句柄绑死在当前图表品种上:RSI、CCI、ATR、MACD 均用 Symb.Name() 与同一 TimeFrame 创建,任一失败直接返回 INIT_FAILED,MT5 终端会在日志抛出初始化错误码。 指标句柄建好后立刻做 BufferResize(NBarInPattern),把每个指标的历史缓冲拉到形态识别所需的长度;若四个里面有一个 resize 不成功,PrintFormat 会打出函数名加行号然后退出,方便你定位是哪一行参数越界。 OnTick 里第一道闸是 IsNewBar(),不是新 K 线就直接 return,这意味着所有指标 Refresh 和 CopyRates 只在新柱开盘后跑一次,回测里能明显看到 CPU 占用集中在 bar 边界。 CopyRates 取的是 iTime(...,1) 往前 NBarInPattern 根,ArraySetAsSeries(Rates,true) 把数组倒序,使 Rates[0] 对应最新一根;若这步失败后续循环取 Rates[b].open 会错位,建议你在 MT5 里把 NBarInPattern 改成 30 验证一下数组下标。 外汇与贵金属杠杆高,这类多指标 EA 在滑点扩大时可能频繁重算,实盘前务必用策略测试器跑至少 3 个月 tick 数据。
class=class="str">"cmt">//| Expert initialization function | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">int OnInit() { class=class="str">"cmt">//--- if(!Symb.Name(_Symbol)) class="kw">return INIT_FAILED; Symb.Refresh(); class=class="str">"cmt">//--- if(!RSI.Create(Symb.Name(), TimeFrame, RSIPeriod, RSIPrice)) class="kw">return INIT_FAILED; class=class="str">"cmt">//--- if(!CCI.Create(Symb.Name(), TimeFrame, CCIPeriod, CCIPrice)) class="kw">return INIT_FAILED; class=class="str">"cmt">//--- if(!ATR.Create(Symb.Name(), TimeFrame, ATRPeriod)) class="kw">return INIT_FAILED; class=class="str">"cmt">//--- if(!MACD.Create(Symb.Name(), TimeFrame, FastPeriod, SlowPeriod, SignalPeriod, MACDPrice)) class="kw">return INIT_FAILED; if(!RSI.BufferResize(NBarInPattern) || !CCI.BufferResize(NBarInPattern) || !ATR.BufferResize(NBarInPattern) || !MACD.BufferResize(NBarInPattern)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); class="kw">return INIT_FAILED; } class=class="str">"cmt">//--- if(!Trade.SetTypeFillingBySymbol(Symb.Name())) class="kw">return INIT_FAILED; PrevBalance = AccountInfoDouble(ACCOUNT_BALANCE); PrevEquity = AccountInfoDouble(ACCOUNT_EQUITY); AgentResult = vector<class="type">float>::Zeros(NActions); class=class="str">"cmt">//--- class="type">int error_code; if(Buffer.Size() > class="num">0 || LoadTotalBase()) { class="type">int tr = class="type">int(MathRand() / class="num">32767.0 * Buffer.Size()); Loaded = Buffer[tr]; StartBar = MathMax(class="num">0,Loaded.Total - class="type">int(MathMax(Math::MathRandomNormal(class="num">0.5, class="num">0.5, error_code), class="num">0) * MaxSteps)); } class=class="str">"cmt">//--- class="kw">return(INIT_SUCCEEDED); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Expert tick function | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void OnTick() { class=class="str">"cmt">//--- if(!IsNewBar()) class="kw">return; class=class="str">"cmt">//--- CurrentBar++; 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">float atr = class="num">0; for(class="type">int b = class="num">0; b < (class="type">int)NBarInPattern; b++) { class="type">float open = (class="type">float)Rates[b].open; class="type">float rsi = (class="type">float)RSI.Main(b);
把账户与持仓塞进状态数组
行情特征取完之后,这段逻辑接着把账户面和当前持仓量化进同一个状态结构,方便后续拿去跑模型或做监控。注意它先抓余额和净值两个账户字段,写入 account[0] 和 account[1],属于最轻量的上下文锚点。 持仓统计部分用 PositionsTotal() 遍历,只认当前品种 Symb.Name(),其他符号直接 continue 跳过。多空分别累加手数和浮动盈亏:buy_value / sell_value 记 volume,buy_profit / sell_profit 记 profit,四个值落进 account[2]~[5]。 有个细节是 position_discount 的计算:profit 减去 (当前时间-开仓时间)*multiplyer*abs(profit),其中 multiplyer = 1/(60*60*10) 即把秒数折算成百小时量级再做衰减。这相当于给持仓盈利按停留时间打折,长泡的单子折扣权重更大,可能倾向暴露那些占着保证金却不怎么动的仓位。外汇和贵金属杠杆高,这种时间衰减视角对仓位拥堵判断有帮助,但仍是概率性参考。 最后 account[6] 存的是算完的 position_discount,整个账户描述就此收口,状态数组可直接喂给下游推理或可视化。
class="type">float cci = (class="type">float)CCI.Main(b); atr = (class="type">float)ATR.Main(b); class="type">float macd = (class="type">float)MACD.Main(b); class="type">float sign = (class="type">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">float)(Rates[b].close - open); sState.state[shift + class="num">1] = (class="type">float)(Rates[b].high - open); sState.state[shift + class="num">2] = (class="type">float)(Rates[b].low - open); sState.state[shift + class="num">3] = (class="type">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; } class=class="str">"cmt">//--- Account description sState.account[class="num">0] = (class="type">float)AccountInfoDouble(ACCOUNT_BALANCE); sState.account[class="num">1] = (class="type">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">float)buy_value; sState.account[class="num">3] = (class="type">float)sell_value; sState.account[class="num">4] = (class="type">float)buy_profit; sState.account[class="num">5] = (class="type">float)sell_profit; sState.account[class="num">6] = (class="type">float)position_discount;