神经网络变得简单(第 67 部分):按照过去的经验解决新任务·进阶篇
(2/3)·当环境交互受限且试错成本高企,如何靠过往轨迹存档训练出能落地的交易策略模型
成交单的止损止盈判定与动作聚合
这段逻辑在把历史成交记录灌进 CDeals 容器时,按买卖方向自动定止损/止盈:卖单若平仓价低于开盘价,就把 TakeProfit 设为 close_price、止损清零;否则反过来用 close_price 当止损。买单镜像处理,只有价格朝有利方向走才挂 TP,逆向则立刻记 SL,这种写法适合回测里「出场即离场」的简化假设。 文件读完用 FileClose(handle) 收尾,Add 失败会 PrintFormat 打出 GetLastError 码并返回 false,正常则 return true。注意 deal.StopLos 字段名拼错是原文如此,真要编译得改结构体定义。 Action 方法把多笔持仓合成一个长度为 NActions 的向量:索引 0 和 3 做累加,1/2/4/5 取各单最大值。这意味着群体持仓的「最激进保护位」会覆盖单笔,适合做风控阈值参考。外汇与贵金属杠杆高,这类聚合若直接接实盘信号,需先在 MT5 策略测试器跑历史段验证。
deal.OpenPrice = open_price; deal.Volume = volume; deal.point = point; if(type == "Sell") { deal.Type = POSITION_TYPE_SELL; if(close_price < open_price) { deal.TakeProfit = close_price; deal.StopLos = class="num">0; } else { deal.TakeProfit = class="num">0; deal.StopLos = close_price; } } else { deal.Type = POSITION_TYPE_BUY; if(close_price > open_price) { deal.TakeProfit = close_price; deal.StopLos = class="num">0; } else { deal.TakeProfit = class="num">0; deal.StopLos = close_price; } } ResetLastError(); if(!Deals.Add(deal)) { PrintFormat("Error of add new deal: %d", GetLastError()); class="kw">return false; } } FileClose(handle); class=class="str">"cmt">//--- class="kw">return true; } vector<class="type">float> CDeals::Action(class="type">class="kw">datetime current, class="type">class="kw">double ask, class="type">class="kw">double bid, class="type">int period_seconds) { vector<class="type">float> result = vector<class="type">float>::Zeros(NActions); for(class="type">int i = class="num">0; i < Deals.Total(); i++) { CDeal *deal = Deals.At(i); if(!deal) class="kw">continue; vector<class="type">float> action = deal.Action(current, ask, bid, period_seconds); result[class="num">0] += action[class="num">0]; result[class="num">3] += action[class="num">3]; result[class="num">1] = MathMax(result[class="num">1], action[class="num">1]); result[class="num">2] = MathMax(result[class="num">2], action[class="num">2]); result[class="num">4] = MathMax(result[class="num">4], action[class="num">4]); result[class="num">5] = MathMax(result[class="num">5], action[class="num">5]); } class=class="str">"cmt">//--- class="kw">return result; }
◍ 多指标EA的输入参数与全局对象声明
这段声明块定义了基于 RSI、CCI、ATR、MACD 四个指标协同判断的 EA 骨架,默认周期全部挂在 H1,RSI 与 MACD 取收盘价、CCI 取典型价,四个指标周期分别设为 14、14、14 以及 12/26/9。 MinProfit 输入项设为 -10000,含义是允许策略在浮亏达到该阈值前不强制平仓,属于宽松的回撤容忍参数,实盘外汇与贵金属波动剧烈,这类设置可能放大单边行情下的敞口风险。 全局里 CDeals Deals 被高亮,说明成交记录管理是后续轨迹学习的核心;Buffer[] 与 Frame[1] 两个 STrajectory 数组用来存基础轨迹与单帧状态,Agent 输入写死为 1,意味着当前只跑一个智能体实例。 指标句柄 CiRSI / CiCCI / CiATR / CiMACD 与 CSymbolInfo、CTrade 一并声明,开 MT5 把这段直接贴进新 EA 头文件,就能先编译出带参数面板的空壳,再补 OnTick 逻辑。
input ENUM_TIMEFRAMES TimeFrame = PERIOD_H1; input class="type">class="kw">double MinProfit = -class="num">10000; class=class="str">"cmt">//--- input group "---- RSI ----" input class="type">int RSIPeriod = class="num">14; class=class="str">"cmt">//Period input ENUM_APPLIED_PRICE RSIPrice = PRICE_CLOSE; class=class="str">"cmt">//Applied price class=class="str">"cmt">//--- input group "---- CCI ----" input class="type">int CCIPeriod = class="num">14; class=class="str">"cmt">//Period input ENUM_APPLIED_PRICE CCIPrice = PRICE_TYPICAL; class=class="str">"cmt">//Applied price class=class="str">"cmt">//--- input group "---- ATR ----" input class="type">int ATRPeriod = class="num">14; class=class="str">"cmt">//Period class=class="str">"cmt">//--- input group "---- MACD ----" input class="type">int FastPeriod = class="num">12; class=class="str">"cmt">//Fast input class="type">int SlowPeriod = class="num">26; class=class="str">"cmt">//Slow input class="type">int SignalPeriod= class="num">9; class=class="str">"cmt">//Signal input ENUM_APPLIED_PRICE MACDPrice = PRICE_CLOSE; class=class="str">"cmt">//Applied price class=class="str">"cmt">//--- input class="type">int Agent = class="num">1; SState sState; STrajectory Base; STrajectory Buffer[]; STrajectory Frame[class="num">1]; CDeals Deals; class=class="str">"cmt">//--- class="type">float dError; class="type">class="kw">datetime dtStudied; class=class="str">"cmt">//--- CSymbolInfo Symb; CTrade Trade; class=class="str">"cmt">//--- class="type">MqlRates Rates[]; CiRSI RSI; CiCCI CCI; CiATR ATR; CiMACD MACD; class=class="str">"cmt">//---
「初始化与每根新K线的指标装载」
EA 启动阶段先把四个核心指标对象绑到当前图表品种和周期上:RSI、CCI、ATR、MACD 各自 Create 后若失败直接返回 INIT_FAILED,保证后续逻辑不会跑在空句柄上。 缓冲区大小统一按 HistoryBars 重设,四个指标任一 BufferResize 不成功就打印函数名加行号并终止初始化,这种写法在排查 MT5 内存不足或参数异常时很实用。 交易填充类型用 Trade.SetTypeFillingBySymbol 按品种规则设定,外汇和贵金属的成交模式不同,忽略这步可能在 XAUUSD 上出现订单被拒。 Deals.LoadDeals 从 Signals\Signal%d.csv 读取代理信号,硬编码了 "EURUSD" 作为对照品种,跨品种跟单时这里是要改的第一个坑。 OnTick 里用 IsNewBar 拦截,只在新柱触发;CopyRates 取 HistoryBars 根数据后立刻 ArraySetAsSeries(Rates, true),让 Rates[0] 对应最新柱,否则下面的指标 Main(b) 索引会整反。 循环里把 open、rsi、cci、atr、macd 全部转 float 暂存,ATR 在循环内被反复覆盖,最终 atr 等于最后一根的读数——若想用历史 ATR 均值,这段代码得改。
class="type">class="kw">double PrevBalance = class="num">0; class="type">class="kw">double PrevEquity = class="num">0; class=class="str">"cmt">//+------------------------------------------------------------------+ 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(HistoryBars) || !CCI.BufferResize(HistoryBars) || !ATR.BufferResize(HistoryBars) || !MACD.BufferResize(HistoryBars)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); class="kw">return INIT_FAILED; } class=class="str">"cmt">//--- if(!Trade.SetTypeFillingBySymbol(Symb.Name())) class="kw">return INIT_FAILED; class=class="str">"cmt">//--- load history if(!Deals.LoadDeals(SignalFile(Agent), "EURUSD", SymbolInfoDouble(_Symbol, SYMBOL_POINT))) class="kw">return INIT_FAILED; class=class="str">"cmt">//--- PrevBalance = AccountInfoDouble(ACCOUNT_BALANCE); PrevEquity = AccountInfoDouble(ACCOUNT_EQUITY); class=class="str">"cmt">//--- class="kw">return(INIT_SUCCEEDED); } class="macro">#define SignalFile(agent) StringFormat("Signals\\Signal%d.csv",agent) class="type">void OnTick() { class=class="str">"cmt">//--- if(!IsNewBar()) class="kw">return; 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);
把持仓与账户状态压进特征向量
这段逻辑干的事很直接:先把每根 K 线的相对开盘价位移、高低点偏移、成交量(除以 1000 压缩量级)、RSI/CCI/ATR/MACD 主线与信号线,按 b*BarDescr 的偏移写进 sState.state 数组,单根 bar 占 9 个 float 槽位。任何指标返回 EMPTY_VALUE 就 continue 跳过,避免脏数据进模型。 账户侧另开 8 个槽:余额、净值、多空持仓量、多空浮动盈亏、一个带时间折扣的 position_discount,以及当前 bar 时间。其中 position_discount 用 multiplyer = 1/(60*60*10) 给每笔持仓盈利按存活秒数做衰减,老盈利单的贡献会被压低,倾向让模型更关注新鲜仓位。 最后 sState.rewards[0] 用 (当前余额 - 上帧余额)/上帧余额 算瞬时收益率,作为强化学习的 reward 信号。外汇与贵金属杠杆高,这套状态编码直接上实盘前,务必在 MT5 策略测试器用历史数据跑通维度对齐,否则数组越界会静默丢帧。
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; } 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; sState.rewards[class="num">0] = class="type">float((sState.account[class="num">0] - PrevBalance) / PrevBalance);
◍ 多空手数的净敞口与止损阈值判定
强化学习 agent 输出 temp 向量后,先要把双向仓位做净额对冲:若 temp[0](模型算出的买量)大于等于 temp[3](卖量),买量减去卖量、卖量归零;反之卖量减买量、买量归零。这一步直接决定后面是加仓还是减仓,而不是无脑双边下单。 净量算完就要卡 broker 的最小手数与止损距离。min_lot 取 LotsMin(),step_lot 取 LotsStep(),stops 用 StopsLevel 和 Point 乘积,且至少乘 1 个点——这意味着当模型给的 TP 或 SL 距离小于等于 broker 最小止损级时,该方向单子会被直接判定为不合规。 买侧控制里,temp[0]<min_lot 或 TP/SL 距离不足 stops,都会触发 CloseByDirection(POSITION_TYPE_BUY) 平掉已有多单;否则按 min_lot + round((temp[0]-min_lot)/step_lot)*step_lot 凑标准手数,并用 temp[1]*MaxTP*Point 算 TP、temp[2]*MaxSL*Point 算 SL。外汇与贵金属杠杆高,stops 卡不准会瞬间被拒单,建议先在策略测试器里把 Symb.StopsLevel() 打印出来核对。 卖侧逻辑镜像:temp[3] 不足 min_lot 或 temp[4]/temp[5] 的止盈止损距离小于等于 stops 时,平掉 POSITION_TYPE_SELL。净敞口和止损阈值的这套判定,是 agent 信号落地成真实订单前的最后一道闸。
sState.rewards[class="num">1] = class="type">float(class="num">1.0 - sState.account[class="num">1] / PrevBalance); PrevBalance = sState.account[class="num">0]; PrevEquity = sState.account[class="num">1]; vector<class="type">float> temp = Deals.Action(TimeCurrent(), SymbolInfoDouble(_Symbol, SYMBOL_ASK), SymbolInfoDouble(_Symbol, SYMBOL_BID), PeriodSeconds(TimeFrame) ); 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] > class="num">0 && (temp[class="num">1] * MaxTP * Symb.Point()) <= stops) || (temp[class="num">2] > class="num">0 && (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 = (temp[class="num">1] > class="num">0 ? NormalizeDouble(Symb.Ask() + temp[class="num">1] * MaxTP * Symb.Point(), Symb.Digits()) : class="num">0); class="type">class="kw">double buy_sl = (temp[class="num">2] > class="num">0 ? NormalizeDouble(Symb.Ask() - temp[class="num">2] * MaxSL * Symb.Point(), Symb.Digits()) : class="num">0); 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(temp[class="num">3] < min_lot || (temp[class="num">4] > class="num">0 && (temp[class="num">4] * MaxTP * Symb.Point()) <= stops) || (temp[class="num">5] > class="num">0 && (temp[class="num">5] * MaxSL * Symb.Point()) <= stops)) { if(sell_value > class="num">0) CloseByDirection(POSITION_TYPE_SELL);
「卖仓手数与防护位的动态落地」
这段逻辑只在空单分支里跑,核心是把数组 temp 里的决策值翻译成真实可下的卖单参数。sell_lot 用 min_lot 打底,再按 step_lot 步进向上取整,意味着手数永远卡在经纪商允许的最小单位整数倍上,不会因浮点误差报 4756 错。 sell_tp 和 sell_sl 都套了三元判断:temp[4] 或 temp[5] 大于 0 才挂防护,否则置 0 表示不挂。距离用 MaxTP、MaxSL 乘 Point 再按 Digits 归一化,黄金 XAUUSD 的 Digits 是 2,EURUSD 是 5,同一套代码换品种不会错位。 若 sell_value 已大于 0 说明场上有空单,先 TrailPosition 拖防护;手数不一致时,多了就 ClosePartial 减仓,少了就 Trade.Sell 补仓,差值精确等于 sell_lot - sell_value。 全场无单时 rewards[2] 扣 atr/PrevBalance 作闲置惩罚,有单则清零。最后把 temp 拷进 sState.action,写库失败直接 ExpertRemove 退出,外汇与贵金属波动剧烈,这类自清理能避免脏状态连续决策。
} 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 = (temp[class="num">4] > class="num">0 ? NormalizeDouble(Symb.Bid() - temp[class="num">4] * MaxTP * Symb.Point(), Symb.Digits()) : class="num">0); class="type">class="kw">double sell_sl = (temp[class="num">5] > class="num">0 ? NormalizeDouble(Symb.Bid() + temp[class="num">5] * MaxSL * Symb.Point(), Symb.Digits()) : class="num">0); 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); } } 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(); }