神经网络变得简单(第 64 部分):保守加权行为克隆(CWBC)方法·综合运用
◍ 把账户状态喂给智能体前先打时间戳
这段逻辑发生在每根 K 线收线后,先把买卖浮盈、持仓折扣等 8 个账户字段写进 sState.account 数组,下标 2~7 分别对应买仓市值、卖仓市值、买仓利润、卖仓利润、持仓时间折扣和当前报价时间。注意 position_discount 用 (current - PositionGetInteger(POSITION_TIME)) 乘 multiplyer 再乘利润绝对值,时间越长折扣越大,外汇和贵金属这种高杠杆品种里持仓过夜成本可能显著侵蚀净值。 bState 向量随后被塞入相对值:账户收益率用 (sState.account[0]-PrevBalance)/PrevBalance,权益回撤用 (sState.account[1]-PrevEquity)/PrevEquity,买卖利润也都除以 PrevBalance 归一。这样做能让智能体在不同本金规模下读到可比特征,但归一化后微观爆仓风险被压缩,实盘仍需人工盯保证金。 时间标签部分用年/月/周/日周期把当前 K 线时间转成正弦余弦:x = Rates[0].time / PeriodSeconds(PERIOD_D1) 再乘 2π 取 sin,等于把“一天内时刻”编码成圆周坐标。四个三角函数特征给网络提供季节记忆,你可以把 D'2024.01.01'-D'2023.01.01' 改成自己样本区间验证周期相位。 喂前向之前对 AgentResult 加 ±0.03 均匀噪声:rnd = MathRand()/32767.0f - 0.5f 乘 0.03f,越界就反向。Clip(0,1) 保输出合法。最后刷新 PrevBalance、PrevEquity,并取 LotsMin / LotsStep / StopsLevel 准备下单——开 MT5 把这段贴进 EA,改 multiplyer 看折扣项对净值曲线的影响。
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;
sState.account[class="num">7] = (class="type">float)Rates[class="num">0].time;
class=class="str">"cmt">//---
bState.Add((class="type">float)((sState.account[class="num">0] - PrevBalance) / PrevBalance));
bState.Add((class="type">float)(sState.account[class="num">1] / PrevBalance));
bState.Add((class="type">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">float)(sState.account[class="num">4] / PrevBalance));
bState.Add((class="type">float)(sState.account[class="num">5] / PrevBalance));
bState.Add((class="type">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;);
bState.Add((class="type">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">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">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">float)MathSin(class="num">2.0 * M_PI * x));
class=class="str">"cmt">//--- Prev action
bState.AddArray(AgentResult);
class=class="str">"cmt">//--- Latent representation
RTG.getResults(Result);
bState.AddArray(Result);
class=class="str">"cmt">//---
if(!Agent.feedForward(GetPointer(bState), class="num">1, false, (CBufferFloat *)NULL))
class="kw">return;
Agent.getResults(AgentResult);
for(class="type">ulong i = class="num">0; i < AgentResult.Size(); i++)
{
class="type">float rnd = ((class="type">float)MathRand() / class="num">32767.0f - class="num">0.5f) * class="num">0.03f;
class="type">float t = AgentResult[i] + rnd;
if(t > class="num">1 || t < class="num">0)
t = AgentResult[i] - rnd;
AgentResult[i] = t;
}
AgentResult.Clip(class="num">0.0f, class="num">1.0f);
PrevBalance = sState.account[class="num">0];
PrevEquity = sState.account[class="num">1];
class="type">class="kw">double min_lot = Symb.LotsMin();
class="type">class="kw">double step_lot = Symb.LotsStep();
class="type">class="kw">double stops = MathMax(Symb.StopsLevel(), class="num">1) * Symb.Point();
if(AgentResult[class="num">0] >= AgentResult[class="num">3])
{多空手数的对冲与下单兜底
AgentResult 数组里 0 和 3 分别存买、卖的 agent 决策手数,前面一段逻辑会把两者对冲掉较小的一边并清零,避免双向同时持仓占用双倍保证金。 买侧控制先看 AgentResult[0] 是否小于 0.9*min_lot,或者 TP/SL 距离换算成点数后小于等于 stops(止损底限)。任一成立且当前已有买仓,就直接 CloseByDirection 平掉买方向。外汇与贵金属杠杆高,这类硬平逻辑能防止模型在极端波动里给出无意义微仓。 不满足平仓条件时,买仓手数按 min_lot + MathRound((AgentResult[0]-min_lot)/step_lot)*step_lot 对齐步长;TP 用 Ask + AgentResult[1]*MaxTP*Point,SL 用 Ask - AgentResult[2]*MaxSL*Point,均过 NormalizePrice。已有买仓就 TrailPosition 移动止损止盈,手数不符则多减仓、少补仓。 卖侧完全对称:AgentResult[3] 为卖决策,TP 挂在 Bid 下方、SL 在 Bid 上方。最后 shift = BarDescr*(NBarInPattern-1) 把上一图案末端状态写回 sState.rewards,若双向持仓都为 0 则扣减 atr/PrevBalance 作为空仓惩罚,供下一次决策参考。
AgentResult[class="num">0] -= AgentResult[class="num">3]; AgentResult[class="num">3] = class="num">0; } else { AgentResult[class="num">3] -= AgentResult[class="num">0]; AgentResult[class="num">0] = class="num">0; } class=class="str">"cmt">//--- buy control if(AgentResult[class="num">0] < class="num">0.9*min_lot || (AgentResult[class="num">1] * MaxTP * Symb.Point()) <= stops || (AgentResult[class="num">2] * MaxSL * Symb.Point()) <= stops) { if(buy_value > class="num">0) CloseByDirection(POSITION_TYPE_BUY); } else { class="type">class="kw">double buy_lot = min_lot + MathRound((class="type">class="kw">double)(AgentResult[class="num">0] - min_lot) / step_lot) * step_lot; class="type">class="kw">double buy_tp = Symb.NormalizePrice(Symb.Ask() + AgentResult[class="num">1] * MaxTP * Symb.Point()); class="type">class="kw">double buy_sl = Symb.NormalizePrice(Symb.Ask() - AgentResult[class="num">2] * MaxSL * Symb.Point()); if(buy_value > class="num">0) TrailPosition(POSITION_TYPE_BUY, buy_sl, buy_tp); if(buy_value != buy_lot) { if(buy_value > buy_lot) ClosePartial(POSITION_TYPE_BUY, buy_value - buy_lot); else Trade.Buy(buy_lot - buy_value, Symb.Name(), Symb.Ask(), buy_sl, buy_tp); } } class=class="str">"cmt">//--- sell control if(AgentResult[class="num">3] < class="num">0.9*min_lot || (AgentResult[class="num">4] * MaxTP * Symb.Point()) <= stops || (AgentResult[class="num">5] * MaxSL * Symb.Point()) <= stops) { if(sell_value > class="num">0) CloseByDirection(POSITION_TYPE_SELL); } else { class="type">class="kw">double sell_lot = min_lot + MathRound((class="type">class="kw">double)(AgentResult[class="num">3] - min_lot) / step_lot) * step_lot;; class="type">class="kw">double sell_tp = Symb.NormalizePrice(Symb.Bid() - AgentResult[class="num">4] * MaxTP * Symb.Point()); class="type">class="kw">double sell_sl = Symb.NormalizePrice(Symb.Bid() + AgentResult[class="num">5] * MaxSL * Symb.Point()); 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">int shift = BarDescr * (NBarInPattern - class="num">1); sState.rewards[class="num">0] = bState[shift]; sState.rewards[class="num">1] = bState[shift + class="num">1] - class="num">1.0f; if((buy_value + sell_value) == class="num">0) sState.rewards[class="num">2] -= (class="type">float)(atr / PrevBalance); else
「把智能体输出写回状态缓冲」
这段逻辑出现在强化学习代理每步推演之后,负责把本次动作结果落盘到经验基。先给奖励数组第三项置零,意味着该槽位暂不作为正向激励信号,避免污染后续策略梯度。 循环把 AgentResult 里每个元素搬进 sState.action,NActions 决定搬运长度,这一步确保状态对象携带当前可用动作全集。 若 Base.Add(sState) 返回失败,直接调 ExpertRemove() 终止 EA,说明经验存储已满或句柄异常,实盘里会立刻停掉策略,外汇与贵金属波动剧烈,这种硬退出能防止用脏数据继续下单。
sState.rewards[class="num">2] = class="num">0; for(class="type">ulong i = class="num">0; i < NActions; i++) sState.action[i] = AgentResult[i]; if(!Base.Add(sState)) ExpertRemove(); }
◍ EURUSD H1 上的保守加权克隆实测
用 EURUSD H1 跑这套保守加权行为克隆,训练切到 2023 年前 7 个月,测试只用 2023 年 8 月单月数据,避免前视偏差。两个 EA 分别训两个模型,可丢到不同设备并行,互不干扰。 初始训练后,在策略测试器里按训练周期跑 Research.mq5 和 Test.mq5 收集新轨迹,启动顺序不影响过程;再用更新后的经验回放缓冲区做下游训练。我这边只在下游第一次迭代后看到性能提升,后续深挖轨迹重训没给出预期改善,可能只是个案。 训练样本区段上模型下了 141 笔单,约 40% 盈利了结;最大盈利超最大亏损 4 倍,平均盈利近平均亏损 2 倍,且平均盈利比最大亏损还高 13%,盈利系数 1.11,新数据表现相近。外汇高风险,样本外能否延续未知。 负面点是模型只开多头,贴合那段时间 EURUSD global 向上趋势,余额曲线跟价格图几乎一个样。细看月度,2023 年 2 月、5 月亏损在之后月份重叠,3 月最肥;按周拆,周三赚钱效应最强。
超参数选错等于白训
前面几节把保守加权行为克隆(CWBC)在 MT5 里的实现拆完了。实盘历史回测里有个现象值得记一下:高回报轨迹在训练集里占比极小(往往不到 5%)时,CWBC 仍能把离线策略训得比较稳,不容易被少数暴利样本带偏。 但这套方法的命门在超参数。学习率、保守系数、轨迹权重衰减这三组若没按品种波动特性调,模型可能在验证集直接塌掉。外汇与贵金属杠杆高、跳空频繁,用 CWBC 训出来的策略实盘前必须过小周期压力测试。 开 MT5 把上节的 EA 加载到 XAUUSD 的 M15,先只动保守系数从 0.1 调到 0.3,看回测权益曲线是否少了几处尖针式回撤,比盲目加样本更有用。
「随包附带的八个程序文件」
这套 LSTM 优化方案不是只给了一段伪代码,作者把整套训练与推理链路拆成了 8 个可运行的 mq5/mqh 文件,打包在 581.08 KB 的 MQL5.zip 里,下载即可在 MT5 里直接编译验证。 具体分工是这样的:Faza1.mq5 负责样本收集,Research.mq5 收集额外轨迹,StudyAgentmq5 训练局部策略模型,StudyRTG.mq5 训练成本函数,Test.mq5 做模型测试;底层依赖 Trajectory.mqh 定义系统状态结构,NeuroNet.mqh 提供建网类库,NeuroNet.cl 则是 OpenCL 端的算子代码。 对外汇与贵金属这类高波动品种做神经网络训练,务必先在策略测试器用历史数据跑通 Test.mq5,再考虑接实盘——模型在历史集表现好,不代表未来行情中概率优势仍在。