神经网络变得简单(第 67 部分):按照过去的经验解决新任务·进阶篇
🧠

神经网络变得简单(第 67 部分):按照过去的经验解决新任务·进阶篇

(2/3)·当环境交互受限且试错成本高企,如何靠过往轨迹存档训练出能落地的交易策略模型

偏理论进阶 第 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 策略测试器跑历史段验证。

MQL5 / C++
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 逻辑。

MQL5 / C++
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 均值,这段代码得改。

MQL5 / C++
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 策略测试器用历史数据跑通维度对齐,否则数组越界会静默丢帧。

MQL5 / C++
    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 信号落地成真实订单前的最后一道闸。

MQL5 / C++
 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 退出,外汇与贵金属波动剧烈,这类自清理能避免脏状态连续决策。

MQL5 / C++
   }
  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();
  }
把历史轨迹诊断交给小布
小布盯盘已内置基于过往行情交互的离线样本评估,打开对应品种页即可看到环境覆盖度与动作空间缺口提示,你只需判断哪些旧经验值得复用。

常见问题

在线依赖与环境持续互动采集奖励,离线仅用提前收集的固定轨迹存档训练,适合交互受限或试错成本高的场景,但易暴露分布偏移问题。
没有。它侧重在真实物理任务上评估已有ORL算法的泛化能力,指出模拟基准的理想数据常忽略动作延迟等现实约束。
有限样本难覆盖全部状态动作空间,复杂随机环境多变性大,模型遇未见过的过渡可能给出失真策略,概率上偏离预期。
可以。小布盯盘的AIGC模块能标记历史交互中负面奖励集中的区段,帮你剔除可能带来经济损失的高风险旧经验。
ExORL侧重上一篇文章讨论的探索表征选项,真实-ORL更强调真实世界延迟与数据同期性缺失下的落地评估,两者互补。