数据科学和机器学习(第 31 部分):利用 CatBoost AI 模型进行交易·综合运用
(3/3)· 把前两篇的模型训练与原理,真正接进 MetaTrader 5 的智能交易系统
◍ 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,相当于帮你在转换期就把大多数异常结构挡在门外。
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 信号仅作概率参考,实盘务必小仓位验证高风险属性。
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」重测才知深浅。
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 数据回测,信号翻转频率可能偏高,点差会吞噬概率优势。
代码里 PosExists 与 ClosePosition 是两个自定义函数,前者检查同方向仓位是否存在,后者按传入的仓位类型平掉反向单。若你自己的 EA 里没这两函数,编译会直接报错,得先补上。
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 解到对应目录,先跑通回测再谈信任。 机器学习只是概率工具,别把社区里那句「正常工作」当成免死金牌;下一套数据分布偏移时,该重写还得重写。