神经网络变得简单(第 65 部分):距离加权监督学习(DWSL)·进阶篇
◍ Critic 与卷积网络的层定义拆解
在 MT5 里搭强化学习交易智能体,critic 网络的第一层用 defNeuronBaseOCL 类型,神经元数直接取 LatentCount,激活函数设 None、优化器走 ADAM,这一步只是把潜在向量原样接进网络。 紧接着的 layer 1 换成 defNeuronConcatenate,window 绑 prev_count(也就是 LatentCount)、step 绑 NActions,相当于把动作空间按步长拼到潜在向量后面;后面三层都是 defNeuronBaseOCL 配 LReLU,直到 layer 4 把输出数压到 NRewards 且激活回 None,给出原始奖励打分。 卷积侧先清掉旧描述,输入层神经元数算作 (HistoryBars * BarDescr) + AccountDescr,把K线描述和账户状态一并喂入;layer 1 只留 HistoryBars * BarDescr 个基元神经元不做激活,layer 2 用 defNeuronSoftMaxOCL、count 为 HistoryBars、step 为 BarDescr,对每根K线做软最大化权重。 照这段代码在 EA 的 CNet 派生类里复刻,把 LatentCount、NActions、NRewards、HistoryBars、BarDescr、AccountDescr 这几个宏先定好,编译后能在观测面板看到各层维度是否对得上。外汇与贵金属杠杆高,这类网络只是概率意义上的策略近似,实盘前务必用历史数据校验。
class=class="str">"cmt">//--- Input layer if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = LatentCount; descr.activation = None; descr.optimization = ADAM; if(!critic.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- layer class="num">1 if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronConcatenate; descr.count = LatentCount; descr.window = prev_count; descr.step = NActions; descr.optimization = ADAM; descr.activation = LReLU; if(!critic.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- layer class="num">2 if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation = LReLU; descr.optimization = ADAM; if(!critic.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- layer class="num">3 if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation = LReLU; descr.optimization = ADAM; if(!critic.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- layer class="num">4 if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronBaseOCL; descr.count = NRewards; descr.optimization = ADAM; descr.activation = None; if(!critic.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- Convolution convolution.Clear(); class=class="str">"cmt">//--- Input layer if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = (HistoryBars * BarDescr) + AccountDescr; descr.activation = None; descr.optimization = ADAM; if(!convolution.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- layer class="num">1 if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronBaseOCL; descr.count = HistoryBars * BarDescr; descr.optimization = ADAM; descr.activation = None; if(!convolution.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- layer class="num">2 if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronSoftMaxOCL; descr.count = HistoryBars; descr.step = BarDescr; descr.optimization = ADAM; descr.activation = None; if(!convolution.Add(descr)) { class="kw">delete descr; class="kw">return false; }
「卷积堆叠与奖励分位的代码落地」
这段构建逻辑把第 3 到第 7 层直接堆成一条卷积链:第 3 层窗口取 BarDescr、步长相同,输出窗口砍半;第 4、5 层继续用上一层输出窗口 prev_wout 当窗和步长,每层再除 2,到第 5 层强制 window_out=2。 所有卷积层统一挂 LReLU 和 ADAM,任何一次 Add 失败就 delete 描述符并回 false,这种写法在 MT5 终端里若显 'Inconsistent embedding size' 说明 embedding 维度和状态矩阵列数对不上。 末层用 defNeuronSoftMaxOCL 把 prev_count*prev_wout 展平做概率输出,再接一个 defNeuronBaseOCL 全连接层压到 EmbeddingSize 维,返回 true 才算网络描述合法。 GetTargets 里 k = states * percentile / 100 直接算出分位样本数,比如 states=1000、percentile=70 时 k=700,后续可凭此切高奖励轨迹,外汇与贵金属行情下这类筛选只反映历史概率、实盘仍属高风险。
class=class="str">"cmt">//--- layer class="num">3 if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronConvOCL; prev_count = descr.count = HistoryBars; descr.window = BarDescr; descr.step = BarDescr; prev_wout = descr.window_out = BarDescr / class="num">2; descr.activation = LReLU; descr.optimization = ADAM; if(!convolution.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- layer class="num">4 if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronConvOCL; prev_count = descr.count = prev_count; descr.window = prev_wout; descr.step = prev_wout; prev_wout = descr.window_out = prev_wout / class="num">2; descr.activation = LReLU; descr.optimization = ADAM; if(!convolution.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- layer class="num">5 if(!(descr = new CLayerDescription())) class="kw">return false; descr.type = defNeuronConvOCL; prev_count = descr.count = prev_count; descr.window = prev_wout; descr.step = prev_wout; prev_wout = descr.window_out = class="num">2; descr.activation = LReLU; descr.optimization = ADAM; if(!convolution.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 = defNeuronSoftMaxOCL; descr.count = prev_count * prev_wout; descr.optimization = ADAM; descr.activation = None; if(!convolution.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 = EmbeddingSize; descr.activation = LReLU; descr.optimization = ADAM; if(!convolution.Add(descr)) { class="kw">delete descr; class="kw">return false; } class=class="str">"cmt">//--- class="kw">return true; } class="kw">struct STarget { vector<class="type">float> rewards; vector<class="type">float> actions; }; STarget GetTargets(class="type">int percentile, vector<class="type">float> &embedding, matrix<class="type">float> &state_embedding, matrix<class="type">float> &rewards, matrix<class="type">float> &actions ) { STarget result; if(embedding.Size() != state_embedding.Cols()) { PrintFormat("%s -> %d Inconsistent embedding size", __FUNCTION__, __LINE__); class="kw">return result; } class="type">ulong size = embedding.Size(); class="type">ulong states = state_embedding.Rows(); class="type">ulong k = class="type">ulong(states * percentile / class="num">100);
近邻回放里的距离权重与嵌入降维
这段逻辑干的是从经验池里挑 k 个近邻状态,再合成一套可用于策略更新的奖励与动作参考。先建一个 states×size 的零矩阵 temp,逐列算当前嵌入 embedding 与历史 state_embedding 的绝对差,用 temp.Max() 取出最大距离 alpha 做数值压扁,避免后面 exp 溢出。 距离向量 dist 走的是 log-sum-exp 技巧:MathLog(MathExp(temp/(-alpha)).Sum(1))*(-alpha),等效于减去最大值后的软最大值,数值稳定。dist.Percentile(percentile) 给出截断阈值 max,只有距离不超过该分位数的历史样本才进 k 近邻集合,k 不满就继续扫。 挑中的样本把 rewards、actions、state_embedding 按行搬进 k_rewards / k_actions / k_embedding,最后一行塞入当前 embedding。min_dist 取负后过 AF_SOFTMAX 得到 sf,result.rewards 就是 sf 对 k_rewards 的加权和——距离越近权重越大。 k_embedding 做 SVD 分解,奇异值和除以嵌入矩阵二范数与维度折中项,写进倒数第二个奖励位,当作潜状态复杂度惩罚;倒数第一位是 EntropyLatentState(Actor),逼着策略别过早塌缩。外汇与贵金属波动大,这套近邻合成在滑点扩开时可能失真,上 MT5 用 EURUSD 的 M15 跑一遍看 k=16、percentile=90 的回放分布再调。
matrix<class="type">float> temp = matrix<class="type">float>::Zeros(states, size); for(class="type">ulong i = class="num">0; i < size; i++) temp.Col(MathAbs(state_embedding.Col(i) - embedding[i]), i); class="type">float alpha=temp.Max(); vector<class="type">float> dist = MathLog(MathExp(temp/(-alpha)).Sum(class="num">1))*(-alpha); vector<class="type">float> min_dist = vector<class="type">float>::Zeros(k); matrix<class="type">float> k_rewards = matrix<class="type">float>::Zeros(k, NRewards); matrix<class="type">float> k_actions = matrix<class="type">float>::Zeros(k, NActions); matrix<class="type">float> k_embedding = matrix<class="type">float>::Zeros(k + class="num">1, size); matrix<class="type">float> U, V; vector<class="type">float> S; class="type">float max = dist.Percentile(percentile); class="type">float min = dist.Min(); for(class="type">ulong i = class="num">0, cur = class="num">0; (i < states && cur < k); i++) { if(max < dist[i]) class="kw">continue; min_dist[cur] = dist[i]; k_rewards.Row(rewards.Row(i), cur); k_actions.Row(actions.Row(i), cur); k_embedding.Row(state_embedding.Row(i), cur); cur++; } k_embedding.Row(embedding, k); vector<class="type">float> sf; (min_dist*(-class="num">1)).Activation(sf, AF_SOFTMAX); result.rewards = sf.MatMul(k_rewards); k_embedding.SVD(U, V, S); result.rewards[NRewards - class="num">2] = S.Sum() / (MathSqrt(MathPow(k_embedding, class="num">2.0f).Sum() * MathMax(k + class="num">1, size))); result.rewards[NRewards - class="num">1] = EntropyLatentState(Actor); vector<class="type">float> act_sf; alpha=MathAbs(k_rewards).Max(); dist = MathLog(MathExp(k_rewards/(-alpha)).Sum(class="num">1))*(-alpha); 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), HistoryBars, 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="type">float atr = class="num">0; for(class="type">int b = class="num">0; b < (class="type">int)HistoryBars; b++) { class="type">float open = (class="type">float)Rates[b].open; class="type">float rsi = (class="type">float)RSI.Main(b); 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)
◍ 把账户与持仓状态塞进特征向量
这段逻辑紧接 K 线特征之后,负责把账户 equity、balance 以及当前持仓的双向敞口写进同一套 state 数组,供后续模型或决策模块直接读。 先填账户头两个槽位:account[0] 取 ACCOUNT_BALANCE,account[1] 取 ACCOUNT_EQUITY,都是转 float 存。注意这里没做归一化,裸金额进数组,后面 bAccount 才做相对处理。
| 持仓统计那段用 PositionsTotal() 遍历,非当前品种直接 continue 跳过。buy_value / sell_value 分别累加多空手数,buy_profit / sell_profit 累加各自浮盈。position_discount 这行最值得盯:用 (current - 开仓时间) * multiplyer * | profit | 做时间衰减惩罚,multiplyer = 1/(60*60*10) 即每持仓 1 秒约扣 1/36000 的盈利权重,老仓对状态的“折扣”更大。 |
|---|
最后 bAccount 里第一维是 (balance - PrevBalance)/PrevBalance 的相对变动,第二维是 equity/PrevBalance,第三维是 equity 相对 PrevEquity 的变化率——这三个数能直接反映本周期账户斜率,接 ML 或阈值判断都够用。外汇贵金属杠杆高,equity 回撤速度可能远快于 balance,跑这段代码前先确认 PrevBalance 在初始化时正确赋值。
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; } bState.AssignArray(sState.state); 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; sState.account[class="num">7] = (class="type">float)Rates[class="num">0].time; class=class="str">"cmt">//--- bAccount.Clear(); bAccount.Add((class="type">float)((sState.account[class="num">0] - PrevBalance) / PrevBalance)); bAccount.Add((class="type">float)(sState.account[class="num">1] / PrevBalance)); bAccount.Add((class="type">float)((sState.account[class="num">1] - PrevEquity) / PrevEquity)); bAccount.Add(sState.account[class="num">2]); bAccount.Add(sState.account[class="num">3]);
「把账户状态塞进网络再算下单手数」
这段逻辑干了两件事:先把账户环比与多个时间周期的正弦/余弦特征写进特征容器 bAccount,再丢给 Actor 网络前向推理,用返回向量 temp 控制买卖手数与止损止盈。 特征构造里用 Rates[0].time 除以 PeriodSeconds(PERIOD_MN1/W1/D1) 得到当前柱在月、周、日周期里的归一化位置,再乘 2*M_PI 做 Sin/Cos,等于把‘时间相位’编码成连续周期量,喂给模型捕捉季节节律。 推理后 temp[0] 与 temp[3] 分别代表净多空权重,代码用互斥减法归零一方,保证同根信号不双向开仓。随后用 Symb.LotsMin()、LotsStep()、StopsLevel() 把模型输出换算成合规手数和距价:若 temp[0] 小于最小手数,或 TP/SL 折算点数不超过 stops 级别,就触发 CloseByDirection 平多,否则按 MathRound 对齐步长开/跟 Trailing。 外汇与贵金属杠杆高,这类基于模型输出的自动下单若 MaxTP/MaxSL 或 stops 设错,可能在几根 K 线内触发频繁平仓,建议先在 MT5 策略测试器用 2023 全年数据跑一遍观察相位特征贡献。
bAccount.Add((class="type">float)(sState.account[class="num">4] / PrevBalance)); bAccount.Add((class="type">float)(sState.account[class="num">5] / PrevBalance)); bAccount.Add((class="type">float)(sState.account[class="num">6] / PrevBalance)); 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;); bAccount.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); bAccount.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); bAccount.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); bAccount.Add((class="type">float)MathSin(class="num">2.0 * M_PI * x)); if(bAccount.GetIndex() >= class="num">0) if(!bAccount.BufferWrite()) class="kw">return; class=class="str">"cmt">//--- if(!Actor.feedForward(GetPointer(bState), class="num">1, false, GetPointer(bAccount))) class="kw">return; PrevBalance = sState.account[class="num">0]; PrevEquity = sState.account[class="num">1]; class=class="str">"cmt">//--- vector<class="type">float> temp; Actor.getResults(temp); class=class="str">"cmt">//--- 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(temp[class="num">0] >= temp[class="num">3]) { temp[class="num">0] -= temp[class="num">3]; temp[class="num">3] = class="num">0; } else { temp[class="num">3] -= temp[class="num">0]; temp[class="num">0] = class="num">0; } class=class="str">"cmt">//--- buy control if(temp[class="num">0] < min_lot || (temp[class="num">1] * MaxTP * Symb.Point()) <= stops || (temp[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)(temp[class="num">0] - min_lot) / step_lot) * step_lot; class="type">class="kw">double buy_tp = NormalizeDouble(Symb.Ask() + temp[class="num">1] * MaxTP * Symb.Point(), Symb.Digits()); class="type">class="kw">double buy_sl = NormalizeDouble(Symb.Ask() - temp[class="num">2] * MaxSL * Symb.Point(), Symb.Digits()); if(buy_value > class="num">0) TrailPosition(POSITION_TYPE_BUY, buy_sl, buy_tp); if(buy_value != buy_lot) { if(buy_value > buy_lot)
卖仓与回测帧的收尾处理
空仓条件判定之后,卖单分支和买单走的是同一套逻辑镜像。当 temp[3] 小于 min_lot,或止盈、止损距离不足 stops 时,若已有卖仓市值 sell_value 大于 0,直接调用 CloseByDirection(POSITION_TYPE_SELL) 清空,不再补单。 否则按 min_lot 加步长取整算出 sell_lot,并用 Bid 减 MaxTP 点差、加 MaxSL 点差归一化出 sell_tp / sell_sl。若 sell_value 与 sell_lot 不等,多出部分走 ClosePartial 平局部,不足部分用 Trade.Sell 补至目标仓位——外汇与贵金属杠杆高,这类动态调仓在滑点行情中可能触发连续小额成交,回测和实盘偏差需自己跑 MT5 比对。 奖励数组在仓量为 0 时按 atr / PrevBalance 扣减第 3 项,否则置 0;随后把 temp 拷进 sState.action,调用 Base.Add 写缓冲,失败即 ExpertRemove 结束策略。 OnTesterPass 里用 FrameNext 遍历优化帧,只认当前程序名且 id 大于 0 的帧;缓冲超 MaxReplayBuffer 时扫一遍找最小轨迹准备覆盖,这是把多遍回测轨迹喂给后续强化学习模块的底層动作。
if(temp[class="num">3] < min_lot || (temp[class="num">4] * MaxTP * Symb.Point()) <= stops || (temp[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)(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); } } sState.rewards[class="num">0] = bAccount[class="num">0]; sState.rewards[class="num">1] = class="num">1.0f - bAccount[class="num">1]; if((buy_value + sell_value) == class="num">0) sState.rewards[class="num">2] -= (class="type">float)(atr / PrevBalance); else sState.rewards[class="num">2] = class="num">0; for(class="type">ulong i = class="num">0; i < NActions; i++) sState.action[i] = temp[i]; sState.rewards[class="num">3] = class="num">0; sState.rewards[class="num">4] = class="num">0; if(!Base.Add(sState)) ExpertRemove(); } class="type">void OnTesterPass() { class=class="str">"cmt">//--- class="type">ulong pass; class="type">class="kw">string name; class="type">long id; class="type">class="kw">double value; STrajectory array[]; class="kw">while(FrameNext(pass, name, id, value, array)) { class="type">int total = ArraySize(Buffer); if(name != MQLInfoString(MQL_PROGRAM_NAME)) class="kw">continue; if(id <= class="num">0) class="kw">continue; if(total >= MaxReplayBuffer) { for(class="type">int a = class="num">0; a < id; a++) { class="type">float min = FLT_MAX; class="type">int min_tr = class="num">0;