CatBoost 模型中的交叉验证和因果推理基础及导出为 ONNX 格式·进阶篇
训练前的超参数怎么填
跑模型前先把输入参数对齐,不然训练结果会偏。路径指向终端 Include 文件夹,用来落训练好的模型文件;SYMBOL 写交易品种,MARKUP 把点差、佣金加滑点一起折算成价格偏移,EURUSD 示例里给了 0.00015。 PERIODS 是算价格增量的移动平均周期集合,代码里取 10 到 40 步、每隔 10 取一个,也就是 [10,20,30,40],这些周期直接决定喂给模型的属性维度。 BACKWARD 和 FORWARD 框出训练日期范围,示例是 2015-01-01 到 2022-01-01;这段区间左右两侧没参与训练的历史属于 OOS,拿来测泛化。外汇和贵金属杠杆高、滑点跳空频繁,MARKUP 设小了回测会虚胖,实盘可能直接打脸。 get_labels 里的 min 和 max 控制随机抽交易的持续时长,单位大概是根数或 bar 数;两边设成一样就变成固定持仓长度,设成 3 和 25 则每笔随机在 3~25 之间。 下面这段是原文里的 Python 配置骨架,路径和参数照抄到你的环境要改成本机 MT5 的 Include 实际位置。
export_path = &class="macro">#x27;/Users/dmitrievsky/Library/Application Support/MetaTrader class="num">5/\Bottles/metatrader5/drive_c/Program Files/MetaTrader class="num">5/MQL5/Include/&class="macro">#x27; # GLOBALS SYMBOL = &class="macro">#x27;EURUSD&class="macro">#x27; MARKUP = class="num">0.00015 PERIODS = [i for i in range(class="num">10, class="num">50, class="num">10)] BACKWARD = class="type">class="kw">datetime(class="num">2015, class="num">1, class="num">1) FORWARD = class="type">class="kw">datetime(class="num">2022, class="num">1, class="num">1) def get_labels(dataset, min= class="num">3, max= class="num">25) -> pd.DataFrame:
「把报价变成可训练特征与标签的本地管道」
做 ML 建模前,先把 MT5 导出的 EURUSD_H1 报价落到 Python 项目的 /files 子目录,文件名约定为 files/EURUSD_H1.csv,用空格分隔字段。读取后只保留 time 与 close 两列,并把 time 转成 datetime 索引,这一步直接决定后续滚动窗口能否对齐。 特征构造的核心在 get_prices():从 PERIODS 列表里逐个取窗口长度,用对应周期的滚动均值减去收盘价,生成若干列差值特征。PERIODS 是超参数,长度组合不同,模型捕捉的均值回归偏移尺度也不同。 标签函数 get_labels() 用随机步长 rand = random.randint(3, 25) 在未来 3~25 根 bar 内取价,比较未来价与当前 close 得到三分类 labels(涨/跌/持平),再用 MARKUP 阈值过滤出 meta_labels 作为次级标签。持平类(labels==2.0)在返回前被整体丢弃,只留多空样本。 tester() 已重写为纯数据帧驱动:它不再关心数据来自原始训练集还是模型预测后的输出,只要传入的 dataframe 带 labels、meta_labels 和 close 就能跑回测。这意味着你可以把训练好的 CatBoost 预测概率拼回原 dataframe,直接丢进同一个 tester 比对基准。 让小布替你跑这套:把 PERIODS 改成 [5, 15, 60] 重新生成特征,观察 tester 在 EURUSD_H1 上多空样本分布是否偏移,外汇与贵金属杠杆品类波动剧烈,回测盈利不代表实盘概率占优。
class="kw">import numpy as np class="kw">import pandas as pd class="kw">import random class="kw">import math from class="type">class="kw">datetime class="kw">import class="type">class="kw">datetime class="kw">import matplotlib.pyplot as put from catboost class="kw">import CatBoostClassifier from sklearn.model_selection class="kw">import train_test_split from sklearn.linear_model class="kw">import LinearRegression from sklearn.model_selection class="kw">import cross_val_predict def get_prices() -> pd.DataFrame: p = pd.read_csv(&class="macro">#x27;files/EURUSD_H1.csv&class="macro">#x27;, delim_whitespace=True) pFixed = pd.DataFrame(columns=[&class="macro">#x27;time&class="macro">#x27;, &class="macro">#x27;close&class="macro">#x27;]) pFixed[&class="macro">#x27;time&class="macro">#x27;] = p[&class="macro">#x27;<DATE>&class="macro">#x27;] + &class="macro">#x27; &class="macro">#x27; + p[&class="macro">#x27;<TIME>&class="macro">#x27;] pFixed[&class="macro">#x27;time&class="macro">#x27;] = pd.to_datetime(pFixed[&class="macro">#x27;time&class="macro">#x27;], format=&class="macro">#x27;mixed&class="macro">#x27;) pFixed[&class="macro">#x27;close&class="macro">#x27;] = p[&class="macro">#x27;<CLOSE>&class="macro">#x27;] pFixed.set_index(&class="macro">#x27;time&class="macro">#x27;, inplace=True) pFixed.index = pd.to_datetime(pFixed.index, unit=&class="macro">#x27;s&class="macro">#x27;) pFixed = pFixed.dropna() pFixedC = pFixed.copy() count = class="num">0 for i in PERIODS: pFixed[str(count)] = pFixedC.rolling(i).mean() - pFixedC count += class="num">1 class="kw">return pFixed.dropna() def get_labels(dataset, min= class="num">3, max= class="num">25) -> pd.DataFrame: labels = [] meta_labels = [] for i in range(dataset.shape[class="num">0]-max): rand = random.randint(min, max) curr_pr = dataset[&class="macro">#x27;close&class="macro">#x27;][i] future_pr = dataset[&class="macro">#x27;close&class="macro">#x27;][i + rand] if future_pr < curr_pr: labels.append(class="num">1.0) if future_pr + MARKUP < curr_pr: meta_labels.append(class="num">1.0) else: meta_labels.append(class="num">0.0) elif future_pr > curr_pr: labels.append(class="num">0.0) if future_pr - MARKUP > curr_pr: meta_labels.append(class="num">1.0) else: meta_labels.append(class="num">0.0) else: labels.append(class="num">2.0) meta_labels.append(class="num">0.0) dataset = dataset.iloc[:len(labels)].copy() dataset[&class="macro">#x27;labels&class="macro">#x27;] = labels dataset[&class="macro">#x27;meta_labels&class="macro">#x27;] = meta_labels dataset = dataset.dropna() dataset = dataset.drop( dataset[dataset.labels == class="num">2.0].index) class="kw">return dataset def tester(dataset: pd.DataFrame, plot= False): last_deal = class="type">int(class="num">2) last_price = class="num">0.0 report = [class="num">0.0] chart = [class="num">0.0] line = class="num">0 line2 = class="num">0 indexes = pd.DatetimeIndex(dataset.index) labels = dataset[&class="macro">#x27;labels&class="macro">#x27;].to_numpy() metalabels = dataset[&class="macro">#x27;meta_labels&class="macro">#x27;].to_numpy() close = dataset[&class="macro">#x27;close&class="macro">#x27;].to_numpy() for i in range(dataset.shape[class="num">0]):
◍ 回测里怎么用双标签卡住出场点
这段逻辑跑在 Python 侧,但思路可以直接搬进 MT5 的 EA 事件循环:用主标签判方向,用元标签(metalabels)当「是否允许交易」的闸门。原文里 pred_meta==1 才放行,等于把模型认为「行情不值得下手」的时段全过滤掉,外汇和贵金属这种高波动品种尤其该这么干,否则噪声时段会吃掉样本外收益。 核心状态机就三个值:last_deal=0 等做多信号,=1 等做空了结,=2 表示刚平完仓在观望。当 last_deal==0 且 pred>0.5 且元标签通过,就记录 last_price 并把状态切到 2,report 里扣掉 MARKUP 点差成本再加上价格差——这一步和 MT5 里 OrderClose 后算净值变动是一回事。 样本外切分靠 FORWARD 和 BACKWARD 两个索引,line 和 line2 分别标出两段 OOS 边界,画图时紫色和红色虚线就是这么来的。最后用 LinearRegression 对交易序号和累计 pip 收益做拟合,lr.score(X,y)*l 的符号决定策略斜率方向:l 为正输出 1,为负输出 -1,相当于给策略一个「样本外倾向盈利/亏损」的定性判分,而不是拍胸脯说能赚多少。 真要验证,把这段代码里的 report.append 逻辑改成 MT5 的 OnTradeTransaction 里累加 AccountInfoDouble(ACCOUNT_EQUITY),把 pred_meta 阈值从 0.5 调到 0.6 看看样本外 R² 是不是掉得更慢,这比看任何回测截图都实在。外汇与贵金属杠杆交易风险极高,样本外判分仅作概率参考。
if indexes[i] <= FORWARD: line = len(report) if indexes[i] <= BACKWARD: line2 = len(report) pred = labels[i] pr = close[i] pred_meta = metalabels[i] # class="num">1 = allow trades if last_deal == class="num">2 and pred_meta==class="num">1: last_price = pr last_deal = class="num">0 if pred <= class="num">0.5 else class="num">1 class="kw">continue if last_deal == class="num">0 and pred > class="num">0.5 and pred_meta == class="num">1: last_deal = class="num">2 report.append(report[-class="num">1] - MARKUP + (pr - last_price)) chart.append(chart[-class="num">1] + (pr - last_price)) class="kw">continue if last_deal == class="num">1 and pred < class="num">0.5 and pred_meta==class="num">1: last_deal = class="num">2 report.append(report[-class="num">1] - MARKUP + (last_price - pr)) chart.append(chart[-class="num">1] + (pr - last_price)) y = np.array(report).reshape(-class="num">1, class="num">1) X = np.arange(len(report)).reshape(-class="num">1, class="num">1) lr = LinearRegression() lr.fit(X, y) l = lr.coef_ if l >= class="num">0: l = class="num">1 else: l = -class="num">1 if(plot): plt.plot(report) plt.plot(chart) plt.axvline(x = line, class="type">class="kw">color=&class="macro">#x27;purple&class="macro">#x27;, ls=&class="macro">#x27;:&class="macro">#x27;, lw=class="num">1, label=&class="macro">#x27;OOS&class="macro">#x27;) plt.axvline(x = line2, class="type">class="kw">color=&class="macro">#x27;red&class="macro">#x27;, ls=&class="macro">#x27;:&class="macro">#x27;, lw=class="num">1, label=&class="macro">#x27;OOS2&class="macro">#x27;) plt.plot(lr.predict(X)) plt.title("Strategy performance R^class="num">2 " + str(format(lr.score(X, y) * l,".2f"))) plt.xlabel("the number of trades") plt.ylabel("cumulative profit in pips") plt.show() class="kw">return lr.score(X, y) * l def test_model(result: list, plt= False): pr_tst = get_prices() X = pr_tst[pr_tst.columns[class="num">1:]] pr_tst[&class="macro">#x27;labels&class="macro">#x27;] = result[class="num">0].predict_proba(X)[:,class="num">1] pr_tst[&class="macro">#x27;meta_labels&class="macro">#x27;] = result[class="num">1].predict_proba(X)[:,class="num">1] pr_tst[&class="macro">#x27;labels&class="macro">#x27;] = pr_tst[&class="macro">#x27;labels&class="macro">#x27;].apply(lambda x: class="num">0.0 if x < class="num">0.5 else class="num">1.0) pr_tst[&class="macro">#x27;meta_labels&class="macro">#x27;] = pr_tst[&class="macro">#x27;meta_labels&class="macro">#x27;].apply(lambda x: class="num">0.0 if x < class="num">0.5 else class="num">1.0) class="kw">return tester(pr_tst, plot=plt)
用 R² 挑出能跑 OOS 的五个模型
超参数定下来后,模型训练就是个循环活。这一轮我们直接训 25 个,再测一遍并把表现靠前的导进 MT5 终端。训练结果最吃三样东西:你选的参数、训练与测试用的日期区间、还有单笔交易持续时间,所以这些设置值得反复试。 按 R² 排个序,留前 5 个去测新数据。图里水平线分左右两侧 OOS,蓝线是余额、橙线是报价。25 个模型长相各不相同,根源在交易随机抽样加模型内置随机化;但它们已不像找圣杯,在 OOS 里跑得比较自由。还能比交易笔数、盈利点数和曲线形态——头两名明显占优,就导出这两个。 要记住,外汇和贵金属是高风市场,改训练参数、多重启几次,行为就会变,图几乎不会重复;但其中相当比例在 OOS 上可能给出不错结果,概率上值得做实验。 下面这段是 25 模型训练与筛选的 Python 原型,核心循环和排序逻辑可直接照搬到你的实验脚本里。
options = [] for i in range(class="num">25): print(&class="macro">#x27;Learn &class="macro">#x27; + str(i) + &class="macro">#x27; model&class="macro">#x27;) options.append(learn_final_models(meta_learner(folds_number= class="num">5, iter= class="num">150, depth= class="num">5, l_rate= class="num">0.01))) options.sort(key=lambda x: x[class="num">0]) test_model(options[-class="num">1][class="num">1:], plt=True)
「把 CatBoost 模型丢进 ONNX 给 MT5 用」
MT5 终端现在原生吃 ONNX,意味着你不必再手写 cpp 到 MQL 的解析层,几乎任何 Python 训出来的模型都能直接搬。CatBoost 自带 save_model 的 onnx 导出接口,比自己序列化权重省事太多。 导出函数一次收两个模型:主模型 catmodel 和元模型 catmodel_m,各自带 onnx_domain、graph_name 等元信息写进磁盘,同时顺手生成一份 .mqh 头文件,把 #resource 二进制挂进 Expert 的编译资源里。下面这段就是导出核心,注意 model[1]、model[2] 分别落盘,文件名用交易品种和序号拼出来。 [CODE] def export_model_to_ONNX(model, model_number): model[1].save_model( export_path +'catmodel' + str(model_number) +'.onnx', format="onnx", export_parameters={ 'onnx_domain': 'ai.catboost', 'onnx_model_version': 1, 'onnx_doc_string': 'test model for BinaryClassification', 'onnx_graph_name': 'CatBoostModel_for_BinaryClassification' }, pool=None) model[2].save_model( export_path + 'catmodel_m' + str(model_number) +'.onnx', format="onnx", export_parameters={ 'onnx_domain': 'ai.catboost', 'onnx_model_version': 1, 'onnx_doc_string': 'test model for BinaryClassification', 'onnx_graph_name': 'CatBoostModel_for_BinaryClassification' }, pool=None) code = '#include <Math\Stat\Math.mqh>' code += '\n' code += '#resource "catmodel'+str(model_number)+'.onnx" as uchar ExtModel[]' code += '\n' code += '#resource "catmodel_m'+str(model_number)+'.onnx" as uchar ExtModel2[]' code += '\n' code += 'int Periods' + '[' + str(len(PERIODS)) + '] = {' + ','.join(map(str, PERIODS)) + '};' code += '\n\n' code += 'void fill_arays' + '( double &features[]) {\n' code += ' double pr[], ret[];\n' code += ' ArrayResize(ret, 1);\n' code += ' for(int i=ArraySize(Periods)-1; i>=0; i--) {\n' code += ' CopyClose(NULL,PERIOD_H1,1,Periods[i],pr);\n' code += ' ret[0] = MathMean(pr) - pr[Periods[i]-1];\n' code += ' ArrayInsert(features, ret, ArraySize(features), 0, WHOLE_ARRAY); }\n' code += ' ArraySetAsSeries(features, true);\n' code += '}\n\n' file = open(export_path + str(SYMBOL) + ' ONNX include' + str(model_number) + '.mqh', "w") file.write(code) file.close() print('The file ' + 'ONNX include' + '.mqh ' + 'has been written to disk') #resource "catmodel.onnx" as uchar ExtModel[] #resource "catmodel_m.onnx" as uchar ExtModel2[] #include <Math\Stat\Math.mqh> int Periods[4] = {10,20,30,40}; void fill_arays( double &features[]) { double pr[], ret[]; 在 Netron 里打开 onnx,你会看到两个输出节点。第一个是类标签的 unit tensor,但 CatBoost 文档明说二元分类下 onnxruntime 有已知 bug,标签推断不正确,所以直接弃用。第二个 probabilities 才是拿来预测的——我在 MQL 里没显式声明它,运行时却自动绑上了,类概率从 tensor 数组第二项取。 机器人侧先 fill_arays 填特征,再把数组反序丢给 OnnxRuntime。主模型给 sig,元模型给 meta_sig;meta_sig 大于 0.5 才放行,按 sig 方向开平仓。外汇和贵金属波动剧烈、杠杆高风险大,这套信号仅作概率参考,实盘前务必在 MT5 策略测试器跑通导出链路。
def export_model_to_ONNX(model, model_number): model[class="num">1].save_model( export_path +&class="macro">#x27;catmodel&class="macro">#x27; + str(model_number) +&class="macro">#x27;.onnx&class="macro">#x27;, format="onnx", export_parameters={ &class="macro">#x27;onnx_domain&class="macro">#x27;: &class="macro">#x27;ai.catboost&class="macro">#x27;, &class="macro">#x27;onnx_model_version&class="macro">#x27;: class="num">1, &class="macro">#x27;onnx_doc_string&class="macro">#x27;: &class="macro">#x27;test model for BinaryClassification&class="macro">#x27;, &class="macro">#x27;onnx_graph_name&class="macro">#x27;: &class="macro">#x27;CatBoostModel_for_BinaryClassification&class="macro">#x27; }, pool=None) model[class="num">2].save_model( export_path + &class="macro">#x27;catmodel_m&class="macro">#x27; + str(model_number) +&class="macro">#x27;.onnx&class="macro">#x27;, format="onnx", export_parameters={ &class="macro">#x27;onnx_domain&class="macro">#x27;: &class="macro">#x27;ai.catboost&class="macro">#x27;, &class="macro">#x27;onnx_model_version&class="macro">#x27;: class="num">1, &class="macro">#x27;onnx_doc_string&class="macro">#x27;: &class="macro">#x27;test model for BinaryClassification&class="macro">#x27;, &class="macro">#x27;onnx_graph_name&class="macro">#x27;: &class="macro">#x27;CatBoostModel_for_BinaryClassification&class="macro">#x27; }, pool=None) code = &class="macro">#x27;class="macro">#include <Math\Stat\Math.mqh>&class="macro">#x27; code += &class="macro">#x27;\n&class="macro">#x27; code += &class="macro">#x27;class="macro">#resource "catmodel&class="macro">#x27;+str(model_number)+&class="macro">#x27;.onnx" as class="type">uchar ExtModel[]&class="macro">#x27; code += &class="macro">#x27;\n&class="macro">#x27; code += &class="macro">#x27;class="macro">#resource "catmodel_m&class="macro">#x27;+str(model_number)+&class="macro">#x27;.onnx" as class="type">uchar ExtModel2[]&class="macro">#x27; code += &class="macro">#x27;\n&class="macro">#x27; code += &class="macro">#x27;class="type">int Periods&class="macro">#x27; + &class="macro">#x27;[&class="macro">#x27; + str(len(PERIODS)) + &class="macro">#x27;] = {&class="macro">#x27; + &class="macro">#x27;,&class="macro">#x27;.join(map(str, PERIODS)) + &class="macro">#x27;};&class="macro">#x27; code += &class="macro">#x27;\n\n&class="macro">#x27; code += &class="macro">#x27;class="type">void fill_arays&class="macro">#x27; + &class="macro">#x27;( class="type">class="kw">double &features[]) {\n&class="macro">#x27; code += &class="macro">#x27; class="type">class="kw">double pr[], ret[];\n&class="macro">#x27; code += &class="macro">#x27; ArrayResize(ret, class="num">1);\n&class="macro">#x27; code += &class="macro">#x27; for(class="type">int i=ArraySize(Periods)-class="num">1; i>=class="num">0; i--) {\n&class="macro">#x27; code += &class="macro">#x27; CopyClose(NULL,PERIOD_H1,class="num">1,Periods[i],pr);\n&class="macro">#x27; code += &class="macro">#x27; ret[class="num">0] = MathMean(pr) - pr[Periods[i]-class="num">1];\n&class="macro">#x27; code += &class="macro">#x27; ArrayInsert(features, ret, ArraySize(features), class="num">0, WHOLE_ARRAY); }\n&class="macro">#x27; code += &class="macro">#x27; ArraySetAsSeries(features, true);\n&class="macro">#x27; code += &class="macro">#x27;}\n\n&class="macro">#x27; file = open(export_path + str(SYMBOL) + &class="macro">#x27; ONNX include&class="macro">#x27; + str(model_number) + &class="macro">#x27;.mqh&class="macro">#x27;, "w") file.write(code) file.close() print(&class="macro">#x27;The file &class="macro">#x27; + &class="macro">#x27;ONNX include&class="macro">#x27; + &class="macro">#x27;.mqh &class="macro">#x27; + &class="macro">#x27;has been written to disk&class="macro">#x27;) class="macro">#resource "catmodel.onnx" as class="type">uchar ExtModel[] class="macro">#resource "catmodel_m.onnx" as class="type">uchar ExtModel2[] class="macro">#include <Math\Stat\Math.mqh> class="type">int Periods[class="num">4] = {class="num">10,class="num">20,class="num">30,class="num">40}; class="type">void fill_arays( class="type">class="kw">double &features[]) { class="type">class="kw">double pr[], ret[];