数据科学和机器学习(第 31 部分):利用 CatBoost AI 模型进行交易·综合运用
🤖

数据科学和机器学习(第 31 部分):利用 CatBoost AI 模型进行交易·综合运用

(3/3)· 把前两篇的模型训练与原理,真正接进 MetaTrader 5 的智能交易系统

进阶 第 3/3 篇
不少交易者训好 CatBoost 就卡在部署:模型躺在 Python 里,MT5 一笔单都跑不了。还有人直接把回测准确率当实盘概率,忽略分类特征编码在线上环境的偏移风险。

◍ CatBoost 导 ONNX 的暗坑与绕法

把 CatBoost 弄进 MT5,绕不开 ONNX,但和 Sklearn、Keras 不一样,它没现成一行 convert 就完事的办法,转换链路更绕。作者实测照官方文档流程能跑通,但中间若管道里带分类变量,直接转 ONNX 会报错崩掉。 修法是把分类变量统一成 float64(双精度),和 MT5 里采集时的类型对齐。这样转换不再抛异常,后续在 MQL5 调模型也不用纠结双精度、浮点、整数混用的问题。 关键一点:强转成 float64 后模型准确率数值没变,CatBoost 本身对数据类型包容度够高,不会因为你提前规范了变量类型就掉点。用 Netron 打开导出的 .onnx,网络结构和 XGBoost、LightGBM 看着一样——本质都是梯度提升决策树,核心拓扑相近。 下面这段是实际可用的转换封装,重点看 skl2onnx_convert_catboost:它从 CatBoost 原生 onnx 对象里抠出 TreeEnsemble 节点,塞进主图,只认 1 或 2 个节点(后者须为 ZipMap), initializer 非空直接 NotImplementedError,相当于帮你在转换期就把大多数异常结构挡在门外。

MQL5 / C++
from onnx.helper class="kw">import get_attribute_value
class="kw">import onnxruntime as rt
from skl2onnx class="kw">import convert_sklearn, update_registered_converter
from skl2onnx.common.shape_calculator class="kw">import (
    calculate_linear_classifier_output_shapes,
)  # noqa
from skl2onnx.common.data_types class="kw">import (
    FloatTensorType,
    Int64TensorType,
    guess_tensor_type,
)
from skl2onnx._parse class="kw">import _apply_zipmap, _get_sklearn_operator_name
from catboost class="kw">import CatBoostClassifier
from catboost.utils class="kw">import convert_to_onnx_object
def skl2onnx_parser_castboost_classifier(scope, model, inputs, custom_parsers=None):
    
    options = scope.get_options(model, dict(zipmap=True))
    no_zipmap = isinstance(options["zipmap"], class="type">bool) and not options["zipmap"]
    alias = _get_sklearn_operator_name(type(model))
    this_operator = scope.declare_local_operator(alias, model)
    this_operator.inputs = inputs
    label_variable = scope.declare_local_variable("label", Int64TensorType())
    prob_dtype = guess_tensor_type(inputs[class="num">0].type)
    probability_tensor_variable = scope.declare_local_variable(
        "probabilities", prob_dtype
    )
    this_operator.outputs.append(label_variable)
    this_operator.outputs.append(probability_tensor_variable)
    probability_tensor = this_operator.outputs
    if no_zipmap:
        class="kw">return probability_tensor
    class="kw">return _apply_zipmap(
        options["zipmap"], scope, model, inputs[class="num">0].type, probability_tensor
    )
def skl2onnx_convert_catboost(scope, class="kw">operator, container):
    """
    CatBoost returns an ONNX graph with a single node.
    This function adds it to the main graph.
    """
    onx = convert_to_onnx_object(class="kw">operator.raw_operator)
    opsets = {d.domain: d.version for d in onx.opset_import}
    if "" in opsets and opsets[""] >= container.target_opset:
        raise RuntimeError("CatBoost uses an opset more recent than the target one.")
    if len(onx.graph.initializer) > class="num">0 or len(onx.graph.sparse_initializer) > class="num">0:
        raise NotImplementedError(
            "CatBoost returns a model initializers. This option is not implemented yet."
        )
    if (
        len(onx.graph.node) not in(class="num">1, class="num">2)
        or not onx.graph.node[class="num">0].op_type.startswith("TreeEnsemble")
        or(len(onx.graph.node) == class="num">2 and onx.graph.node[class="num">1].op_type != "ZipMap")
    ):
        types = ", ".join(map(lambda n: n.op_type, onx.graph.node))
        raise NotImplementedError(
            f"CatBoost returns {len(onx.graph.node)} != class="num">1 (types={types}). "
            f"This option is not implemented yet."
        )
    node = onx.graph.node[class="num">0]
    atts = {}
    for att in node.attribute:
        atts[att.name] = get_attribute_value(att)
    container.add_node(
        node.op_type,
        [class="kw">operator.inputs[class="num">0].full_name],
        [class="kw">operator.outputs[class="num">0].full_name, class="kw">operator.outputs[class="num">1].full_name],
        op_domain=node.domain,
        op_version=opsets.get(node.domain, None),
        **atts,
    )
update_registered_converter(
    CatBoostClassifier,
    "CatBoostCatBoostClassifier",

「CatBoost 转 ONNX 时的类别特征坑」

把 CatBoost 管线通过 skl2onnx 导出成 ONNX 模型时,若特征里带类别型字段,运行会直接抛 CatBoostError:model_exporter.cpp:96 明确写着 ONNX-ML 格式暂不支持 categorical features。 报错前那段转换代码里,convert_sklearn 指定了 target_opset 为 {"": 12, "ai.onnx.ml": 2},并把管线存成 CatBoost.EURUSD.OHLC.D1.onnx,这一步在日线 EURUSD OHLC 数据上跑不通。 原 pipeline 里曾用 X_train[categorical_features] = X_train[categorical_features].astype(str) 把 Day、DayofWeek、DayofYear、Month 转成字符串充当类别特征。要顺利导出,就得注释掉这两行字符串化操作,让这些字段以数值型进模型,再用 X_train.info() 核对 dtype 是否已回归 float/int。 外汇与贵金属行情受宏观事件扰动大,这类 ML 信号仅作概率参考,实盘务必小仓位验证高风险属性。

MQL5 / C++
calculate_linear_classifier_output_shapes,
    skl2onnx_convert_catboost,
    parser=skl2onnx_parser_castboost_classifier,
    options={"nocl": [True, False], "zipmap": [True, False, "columns"]},
)
model_onnx = convert_sklearn(
    pipe,
    "pipeline_catboost",
    [("class="kw">input", FloatTensorType([None, X_train.shape[class="num">1]]))],
    target_opset={"": class="num">12, "ai.onnx.ml": class="num">2},
)
# And save.
with open("CatBoost.EURUSD.OHLC.D1.onnx", "wb") as f:
    f.write(model_onnx.SerializeToString())
CatBoostError: catboost/libs/model/model_export/model_exporter.cpp:class="num">96: ONNX-ML format class="kw">export does yet not support categorical features
categorical_features = ["Day","DayofWeek", "DayofYear", "Month"]
# Remove these two lines of code operations
# X_train[categorical_features] = X_train[categorical_features].astype(str)
# X_test[categorical_features] = X_test[categorical_features].astype(str)
X_train.info() # we print the data types now

把 CatBoost 模型塞进 EA 实盘跑信号

先把训练好的 ONNX 模型以资源形式嵌进主程序,再引用 GBDT 函数库,EA 在 OnTick 里按训练时的字段顺序抓取上一根 K 线的 OHLC 与日期分量,喂给模型得到概率向量与信号类。类别 0 倾向对应熊市、1 倾向对应牛市,predict_proba 给概率、predict_bin 给类别。 交易包装逻辑很直白:模型吐出看涨类就买入并把已有空单平仓,吐出看跌类就反向处理。外汇与贵金属杠杆高,这类极简策略仅作验证用途,实盘可能频繁假信号。 我在策略测试器用 EURUSD、H4、仅开盘价建模,区间 2021.01.01–2024.10.08 跑过一遍:盈利交易占比约 55%,净盈利 96 美元。数据集与模型都极简单、手数最小,这个成绩只能说勉强能看,不能当收益预期。 别把 55% 当护身符 回测用「仅开盘价」跳过滑点与点差摩擦,真实环境里这层损耗可能把薄利吃光,上 MT5 自己换「每笔tick」重测才知深浅。

MQL5 / C++
class="macro">#resource "\Files\CatBoost.EURUSD.OHLC.D1.onnx" as class="type">uchar catboost_onnx[]
class="macro">#include <MALE5\Gradient Boosted Decision Trees(GBDTs)\CatBoost\CatBoost.mqh>
CCatBoost cat_boost;
class="type">void OnTick()
{
   if (CopyRates(Symbol(), timeframe, class="num">1, class="num">1, rates) < class="num">0) class=class="str">"cmt">//Copy information from the previous bar
   {
      printf("Failed to obtain OHLC price values error = ",GetLastError());
      class="kw">return;
   }

   class="type">MqlDateTime time_struct;
   class="type">class="kw">string time = (class="type">class="kw">string)class="type">class="kw">datetime(rates[class="num">0].time); class=class="str">"cmt">//converting the date from seconds to class="type">class="kw">datetime then to class="type">class="kw">string
   TimeToStruct((class="type">class="kw">datetime)StringToTime(time), time_struct); class=class="str">"cmt">//converting the time in class="type">class="kw">string format to date then assigning it to a structure

   vector x = {rates[class="num">0].open,
               rates[class="num">0].high,
               rates[class="num">0].low,
               rates[class="num">0].close,
               time_struct.day,
               time_struct.day_of_week,
               time_struct.day_of_year,
               time_struct.mon}; class=class="str">"cmt">//class="kw">input features from the previously closed bar

   vector proba = cat_boost.predict_proba(x); class=class="str">"cmt">//predict the probability between the classes
   class="type">long signal = cat_boost.predict_bin(x); class=class="str">"cmt">//predict the trading signal class

   Comment("Predicted Probability = ", proba,"\nSignal = ",signal);
}

◍ 把模型信号接进下单逻辑

CatBoost 模型在 MT5 里跑完推理后,重点不是看概率数字,而是把 predict_bin 返回的类别直接映射成开平仓动作。下面这段把信号等于 1 判为多头、其余判为空头,并先做反手平仓再开新仓,避免双向持仓。 读取行情用 SymbolInfoTick 拿实时 ask/bid,配合 m_trade.Buy/Sell 以最小手数 min_lot 进场,止损止盈先传 0 表示不挂。外汇与贵金属杠杆高,实盘前务必在策略测试器用 2020—2023 年 H1 数据回测,信号翻转频率可能偏高,点差会吞噬概率优势。 代码里 PosExistsClosePosition 是两个自定义函数,前者检查同方向仓位是否存在,后者按传入的仓位类型平掉反向单。若你自己的 EA 里没这两函数,编译会直接报错,得先补上。

MQL5 / C++
   time_struct.mon}; class=class="str">"cmt">//class="kw">input features from the previously closed bar

   vector proba = cat_boost.predict_proba(x); class=class="str">"cmt">//predict the probability between the classes
   class="type">long signal = cat_boost.predict_bin(x); class=class="str">"cmt">//predict the trading signal class

   Comment("Predicted Probability = ", proba,"\nSignal = ",signal);

class=class="str">"cmt">//---

   class="type">MqlTick ticks;
   SymbolInfoTick(Symbol(), ticks);

   if (signal==class="num">1) class=class="str">"cmt">//if the signal is bullish
   {
      if (!PosExists(POSITION_TYPE_BUY)) class=class="str">"cmt">//There are no buy positions
         m_trade.Buy(min_lot, Symbol(), ticks.ask, class="num">0, class="num">0); class=class="str">"cmt">//Open a buy trade

      ClosePosition(POSITION_TYPE_SELL); class=class="str">"cmt">//close the opposite trade  
   }
   else class=class="str">"cmt">//Bearish signal
    {
      if (!PosExists(POSITION_TYPE_SELL)) class=class="str">"cmt">//There are no Sell positions
         m_trade.Sell(min_lot, Symbol(), ticks.bid, class="num">0, class="num">0); class=class="str">"cmt">//open a sell trade

      ClosePosition(POSITION_TYPE_BUY); class=class="str">"cmt">//close the opposite trade
    }

「把这条线请下神坛」

在资源受限、不想耗在特征工程上的场景里,CatBoost 这类梯度增强树确实是能直接落地的选择:EURUSD 日线 OHLC 训练出的模型导出为 ONNX 后,EA 体积仅 40.07 KB 的压缩包就能带走全部依赖。它入门门槛低,却不是外汇贵金属的圣杯——评论区已有实盘反馈,当黄金价格突破训练集最高值,模型对超出分布的数据只回恒定概率,这种失效在杠杆市场可能直接放大亏损。 附件里 Experts\CatBoost EA.mq5 加载的是 Files\CatBoost.EURUSD.OHLC.D1.onnx,Include\CatBoost.mqh 负责部署;但 EA 没有固定止损,外汇贵金属高波动下需自行补风控。开 MT5 把 ZIP 解到对应目录,先跑通回测再谈信任。 机器学习只是概率工具,别把社区里那句「正常工作」当成免死金牌;下一套数据分布偏移时,该重写还得重写。

让小布替你盯模型漂移
这些 CatBoost 与 ONNX 的接入诊断,小布盯盘的 AIGC 已内置,打开对应品种页即可看到信号衰减与特征分布变化,你只需专注是否跟单。

常见问题

可能源于分类特征的目标编码在导出时未固化,或浮点精度差异。建议在导出前用相同预处理管线做对齐校验。
对称树内存占用低、推理快,倾向更适合分钟级品种;但小扰动敏感,贵金属跳空时概率需打折看待。
至少跑跨期外样本与滑点模拟,观察有序增强是否引入泄漏。外汇贵金属属高风险,实盘仓薄为宜。
目前小布内置的是通用漂移与信号诊断,自定义模型权重需按知识库规范上传,再由 AIGC 做可视化监控。
本篇假设已有决策树与 XGBoost 概念,完整铺垫见《数据科学和机器学习(第 31 部分):利用 CatBoost AI 模型进行交易·基础篇》。