CatBoost 模型中的交叉验证和因果推理基础及导出为 ONNX 格式·综合运用
🧠

CatBoost 模型中的交叉验证和因果推理基础及导出为 ONNX 格式·综合运用

(3/3)·从交叉验证到 ONNX 落地,把因果判断与模型自检缝进 MT5 的最后一环

新手友好 第 3/3 篇
很多交易者把一次训练跑通的 CatBoost 直接上实盘,却忽略模型可能只是记住了联想噪声。给模型加一层交叉验证式的自我控制,才能在外汇贵金属这种高风险市场里分辨真规律与随机巧合。

把均值偏离塞进 ONNX 输入张量

这段逻辑干的事很直接:用 H1 收盘价序列算每个周期窗口的均值偏离,再拼成模型要的特征向量。外汇与贵金属杠杆高,回测和实盘偏差可能很大,任何信号都只是概率倾向。 先看特征构造,ArrayResize(ret,1) 先给单值容器留位,随后倒序遍历 Periods 数组,对每个窗口 CopyClose 取 H1 最近 N 根收盘,用 MathMean(pr) 减最新价得到偏离值,经 ArrayInsert 累进 features,最后 ArraySetAsSeries(features,true) 让索引按时间倒序。 模型初始化时,ExtInputShape 被定为 {1, ArraySize(Periods)},也就是一次喂一根 K 线、宽度等于周期数。两个 ONNX 句柄 ExtHandle 与 ExtHandle2 都走 OnnxCreateFromBuffer,任一句柄无效就 Print 错误并 INIT_FAILED;之后分别 OnnxSetInputShape / OnnxSetOutputShape,输出形状写死为 {1}。 OnTick 里用 isNewBar 拦掉旧柱,fill_arays 填好 features 后拷贝进 f[],再调两次 OnnxRun:第一次出主预测 out,第二次出元信息 out_meta。你开 MT5 把 Periods 改成 {12,24,48} 这类组合,能直接观察输入维度变化对模型推理的影响。

MQL5 / C++
  ArrayResize(ret, class="num">1);
  for(class="type">int i=ArraySize(Periods)-class="num">1; i>=class="num">0; i--) {
    CopyClose(NULL,PERIOD_H1,class="num">1,Periods[i],pr);
    ret[class="num">0] = MathMean(pr) - pr[Periods[i]-class="num">1];
    ArrayInsert(features, ret, ArraySize(features), class="num">0, WHOLE_ARRAY); }
  ArraySetAsSeries(features, true);
}
class="macro">#include <EURUSD ONNX include1.mqh>
class="kw">const class="type">long  ExtInputShape [] = {class="num">1, ArraySize(Periods)};
class="type">int OnInit() {
  ExtHandle = OnnxCreateFromBuffer(ExtModel, ONNX_DEFAULT);
  ExtHandle2 = OnnxCreateFromBuffer(ExtModel2, ONNX_DEFAULT);
  if(ExtHandle == INVALID_HANDLE || ExtHandle2 == INVALID_HANDLE) {
    Print("OnnxCreateFromBuffer error ", GetLastError());
    class="kw">return(INIT_FAILED);
  }
  if(!OnnxSetInputShape(ExtHandle, class="num">0, ExtInputShape)) {
    Print("OnnxSetInputShape failed, error ", GetLastError());
    OnnxRelease(ExtHandle);
    class="kw">return(-class="num">1);
  }
  if(!OnnxSetInputShape(ExtHandle2, class="num">0, ExtInputShape)) {
    Print("OnnxSetInputShape failed, error ", GetLastError());
    OnnxRelease(ExtHandle2);
    class="kw">return(-class="num">1);
  }
  class="kw">const class="type">long output_shape[] = {class="num">1};
  if(!OnnxSetOutputShape(ExtHandle, class="num">0, output_shape)) {
    Print("OnnxSetOutputShape error ", GetLastError());
    class="kw">return(INIT_FAILED);
  }
  if(!OnnxSetOutputShape(ExtHandle2, class="num">0, output_shape)) {
    Print("OnnxSetOutputShape error ", GetLastError());
    class="kw">return(INIT_FAILED);
  }
  class="kw">return(INIT_SUCCEEDED);
}
class="type">void OnTick() {
  if(!isNewBar())
    class="kw">return;
  class="type">class="kw">double features[];
  fill_arays(features);
  class="type">class="kw">double f[ArraySize(Periods)];
  class="type">int k = ArraySize(Periods) - class="num">1;
  for(class="type">int i = class="num">0; i < ArraySize(Periods); i++) {
    f[i] = features[i];
    k--;
  }
  class="kw">static vector out(class="num">1), out_meta(class="num">1);
  class="kw">struct output {
    class="type">long        label[];
    class="type">class="kw">float       tensor[];
  };
  output out2[], out2_meta[];
  OnnxRun(ExtHandle, ONNX_DEBUG_LOGS, f, out, out2);
  OnnxRun(ExtHandle2, ONNX_DEBUG_LOGS, f, out_meta, out2_meta);

「模型信号触发后的平仓与新单逻辑」

这段逻辑跑在 EA 的 OnTick 里,前提是 meta_sig 大于 0.5,也就是元模型判定当前市场状态值得干预。此时若账户里已有 0 号(买)或 1 号(卖)品种的市场订单,就倒序遍历 OrdersTotal 逐个检查。 平仓分支很直接:买单且魔法码匹配、sig 大于 0.5 时,要求当前 Bid 与开仓价距离超过冻结级别才动手;卖单对应 sig 小于 0.5 的镜像条件。OrderClose 放在 do-while 里以 -1 为失败重试标志,每次 Sleep(50) 毫秒避免 broker 拒单后硬怼。 新单侧在 countOrders() 低于 max_orders 且 CheckMoneyForTrade 通过后才进。sig 小于 0.5 走卖单分支,stop 用 Bid - stoploss*_Point、take 用 Ask + takeprofit*_Point 计算,手数由 LotsOptimized(meta_sig) 动态给出。外汇与贵金属杠杆高,冻结级别和重试机制只是防错,不表示信号胜率,实盘前请在 MT5 策略测试器用历史数据验证这套阈值。

MQL5 / C++
  class="type">class="kw">double sig = out2[class="num">0].tensor[class="num">1];
  class="type">class="kw">double meta_sig = out2_meta[class="num">0].tensor[class="num">1];
if(meta_sig > class="num">0.5)
      if(count_market_orders(class="num">0) || count_market_orders(class="num">1))
          for(class="type">int b = OrdersTotal() - class="num">1; b >= class="num">0; b--)
          if(OrderSelect(b, SELECT_BY_POS) == true) {
              if(OrderType() == class="num">0 && OrderSymbol() == _Symbol && OrderMagicNumber() == OrderMagic && sig > class="num">0.5)
              if(SymbolInfoInteger(_Symbol, SYMBOL_TRADE_FREEZE_LEVEL) < MathAbs(Bid - OrderOpenPrice())) {
                  class="type">int res = -class="num">1;
                  do {
                      res = OrderClose(OrderTicket(), OrderLots(), OrderClosePrice(), class="num">0, Red);
                      Sleep(class="num">50);
                  } class="kw">while (res == -class="num">1);
              }
              if(OrderType() == class="num">1 && OrderSymbol() == _Symbol && OrderMagicNumber() == OrderMagic && sig < class="num">0.5)
              if(SymbolInfoInteger(_Symbol, SYMBOL_TRADE_FREEZE_LEVEL) < MathAbs(Bid - OrderOpenPrice())) {
                  class="type">int res = -class="num">1;
                  do {
                      res = OrderClose(OrderTicket(), OrderLots(), OrderClosePrice(), class="num">0, Red);
                      Sleep(class="num">50);
                  } class="kw">while (res == -class="num">1);
              }
          }
  if(meta_sig > class="num">0.5)
      if(countOrders() < max_orders && CheckMoneyForTrade(_Symbol, LotsOptimized(meta_sig), ORDER_TYPE_BUY)) {
          class="type">class="kw">double l = LotsOptimized(meta_sig);
          if(sig < class="num">0.5) {
              class="type">int res = -class="num">1;
              do {
                  class="type">class="kw">double stop = Bid - stoploss * _Point;
                  class="type">class="kw">double take = Ask + takeprofit * _Point;

◍ 用 do-while 兜底发单失败

在信号强度 sig 大于 0.5 时,脚本进入卖出分支,用 do-while 循环强行把 OrderSend 跑通。每次下单若返回 -1(经纪商拒绝或网络抖动),就 Sleep(50) 毫秒再试,直到 res 不是 -1 才退出循环。 卖出单的止损放在 Ask 加 stoploss 个 _Point,止盈放在 Bid 减 takeprofit 个 _Point,和买入分支的止损方向正好相反。这种对称写法能避免把止损挂到不利价位。 外汇与贵金属杠杆高,实盘里反复重试发单可能在不流动时段堆出意外仓位,建议在 MT5 策略测试器里先把 stoploss、takeprofit 和 Sleep 间隔调一遍,确认成交逻辑符合你的风控。

MQL5 / C++
      res = OrderSend(Symbol(), OP_BUY, l, Ask, class="num">0, stop, take, comment, OrderMagic);
      Sleep(class="num">50);
      } class="kw">while (res == -class="num">1);
      } else {
      if(sig > class="num">0.5) {
         class="type">int res = -class="num">1;
         do {
            class="type">class="kw">double stop = Ask + stoploss * _Point;
            class="type">class="kw">double take = Bid - takeprofit * _Point;
            res = OrderSend(Symbol(), OP_SELL, l, Bid, class="num">0, stop, take, comment, OrderMagic);
            Sleep(class="num">50);
         } class="kw">while (res == -class="num">1);
      }
      }
      }

把自定义回测和 MT5 原生结果对齐

接上两篇的模型封装,现在要把两个编译好的文件挂到自选模型上跑一遍。核心验证点只有一个:自定义测试器的逐笔成交与 MetaTrader 5 自带测试器的输出必须逐字一致,任何偏差都说明行情回放或下单逻辑有漏。 对齐之后,就可以切到真实 tick 报价跑机器人。重点不在看曲线漂亮,而是用 MT5 优化器去扫止损、止盈和手数组合,让样本内交易次数先堆起来,避免过拟合到几十笔的小样本上。外汇与贵金属杠杆品种跳空频繁,这类优化结果仅代表历史报价下的概率倾向,实盘仍存在较高风险。

「换个思路训练模型才有戏」

这套把交易任务按时间序列分类的做法,并没有现成的学术定论,是反复试错攒出来的路数,但在 EURUSD 上跑出来的几个 ONNX 模型文件体积差异很大——catmodel1.onnx 有 450.34 KB,而 catmodel_m2.onnx 只有 94.61 KB,说明不同时间框架压出来的模型复杂度差出好几倍。 机器学习模型有时候不能按显而易见的套路去训,架构之外,喂数据和用结果的方式才是分水岭。全自动的「交易员+研究员」流水线能跑通,需要专家干预的「老师」算法也同样轻便有效。 外汇和贵金属杠杆高、滑点狠,任何模型输出都只是概率倾向,真要上 MT5 验证,先把 ZIP 里的 ONNX_bot.mq5 和那几个 include 挂上 EURUSD 图表,看推理延迟再决定敢不敢用实盘。

把样本筛选交给小布
这些诊断与小布盯盘的 AIGC 已内置,打开对应品种页即可看到模型在相似行情下的错误聚类,你只需决定哪些样本该剔除。

常见问题

可能遗漏,所以原文用第二个模型专门区分高/低可预测性区间,把被删案例的预测权交给它,倾向形成互补覆盖而非硬删。
小布内置的 AIGC 诊断可展示品种页上的错误分布与相似子样本表现,把重复劳动交给小布,你专注决策阈值设定。
可以,ONNX 只是推理格式,训练期的前向验证结论仍适用,但实盘前建议用新数据重测一次避免环境偏移。
指算法只拟合训练分布不主动追溯变量间的生成关系,外汇贵金属中容易把临时相关当因果,概率上不可靠。
关于元模型与误差自分析的完整讨论见《CatBoost 模型中的交叉验证和因果推理基础及导出为 ONNX 格式·实战篇》。