梯度提升(CatBoost)在交易系统开发中的应用. 初级的方法·进阶篇
(2/3)· 接上篇概念铺垫,本篇拆解特征滞后构造、自定义测试器与模型落地的关键步骤
很多交易者把 CatBoost 当成黑箱直接喂价,却忽略滞后特征与标签随机采样会直接决定模型泛化能力。接上篇的基础概念,我们继续深挖这套机器学习交易系统的工程实现。
EURUSD 小时级差分序列长这样
下面这段是 EURUSD 小时 K 线经差分处理后的原始输出片段,第一列时间戳,第二列即期价(如 2020-05-01 20:00 的 1.09817),其后 10 列是不同滞后阶的差分值,末列是某状态标记(1.0 或 0.0)。 看 2020-10-29 20:00 那行:即期价 1.16700,10 阶差分全为负,从 -0.003651 到 -0.002075,说明此前 10 个小时价格连续走低;末列标记 1.0 表示此刻被算法判定为有效样本。 紧接着 21:00–23:00 三行即期价微幅波动(1.16743 / 1.16731 / 1.16740),差分序列整体仍为负但末列全是 0.0,意味着这几根小时线未被计入建模窗口。 到 2020-10-30 00:00 即期价回落至 1.16695,末列重新变回 1.0,差分最末阶 -0.004328 仍为负。外汇与贵金属属高风险品种,这类差分序列仅描述历史价格结构,不代表后续方向。
class="num">2020-class="num">05-class="num">01 class="num">20:class="num">00:class="num">00 class="num">1.09817 class="num">0.000759 class="num">0.001577 class="num">0.002900 class="num">0.004227 class="num">0.001405 class="num">0.002169 class="num">0.001600 class="num">0.002595 class="num">0.002794 class="num">0.002442 class="num">1.0 ... ... ... ... ... ... ... ... ... ... ... ... ... class="num">2020-class="num">10-class="num">29 class="num">20:class="num">00:class="num">00 class="num">1.16700 -class="num">0.003651 -class="num">0.005429 -class="num">0.005767 -class="num">0.006750 -class="num">0.004699 -class="num">0.004328 -class="num">0.003475 -class="num">0.003769 -class="num">0.002719 -class="num">0.002075 class="num">1.0 class="num">2020-class="num">10-class="num">29 class="num">21:class="num">00:class="num">00 class="num">1.16743 -class="num">0.002699 -class="num">0.003651 -class="num">0.005429 -class="num">0.005767 -class="num">0.006750 -class="num">0.004699 -class="num">0.004328 -class="num">0.003475 -class="num">0.003769 -class="num">0.002719 class="num">0.0 class="num">2020-class="num">10-class="num">29 class="num">22:class="num">00:class="num">00 class="num">1.16731 -class="num">0.002276 -class="num">0.002699 -class="num">0.003651 -class="num">0.005429 -class="num">0.005767 -class="num">0.006750 -class="num">0.004699 -class="num">0.004328 -class="num">0.003475 -class="num">0.003769 class="num">0.0 class="num">2020-class="num">10-class="num">29 class="num">23:class="num">00:class="num">00 class="num">1.16740 -class="num">0.001648 -class="num">0.002276 -class="num">0.002699 -class="num">0.003651 -class="num">0.005429 -class="num">0.005767 -class="num">0.006750 -class="num">0.004699 -class="num">0.004328 -class="num">0.003475 class="num">0.0 class="num">2020-class="num">10-class="num">30 class="num">00:class="num">00:class="num">00 class="num">1.16695 -class="num">0.001655 -class="num">0.001648 -class="num">0.002276 -class="num">0.002699 -class="num">0.003651 -class="num">0.005429 -class="num">0.005767 -class="num">0.006750 -class="num">0.004699 -class="num">0.004328 class="num">1.0
◍ 自己写个回测器验证信号翻转
做交易系统不能只靠眼睛看信号,得有个能跑全量数据的测试器。下面这段 Python 风格的函数就是干这个的:喂进去带 labels 的数据集和可选点差,它按每根新柱读信号,标签一变就反手,卖信号既是平多也是开空。 不加点差跑原始数据,和按 70 个五位小点(即 7 个标准点)加点差跑,曲线形状会差出一截。前者是理想态——也是我们指望模型学到的样子;后者更贴近 MT5 实盘摩擦。 因为标签是依最短/最长持仓寿命参数随机抽的,每次跑 Y 轴权益增长和 X 轴成交笔数都不重样,但总体都呈现点数向上爬、交易数随参数漂移的现象。外汇和贵金属杠杆高,这种理想曲线绝不等于实盘能复现,拿去开 MT5 前先把自己参数的随机性跑透。 代码逐行拆一下逻辑:last_deal 初值 2 代表空仓,首次用 close 价锚定成本并按 pred 阈值 0.5 决定多空;之后 0 状态遇 pred>0.5 翻多、1 状态遇 pred<=0.5 翻空,每次翻转把 markup 点差扣掉再算价差盈亏累进 report 列表;末尾 get_prices 取量、add_labels 打标、tester 出序列后 matplotlib 画线。
def tester(dataset, markup = class="num">0.0): last_deal = class="type">int(class="num">2) last_price = class="num">0.0 report = [class="num">0.0] for i in range(dataset.shape[class="num">0]): pred = dataset[&class="macro">#x27;labels&class="macro">#x27;][i] if last_deal == class="num">2: last_price = dataset[&class="macro">#x27;close&class="macro">#x27;][i] 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: last_deal = class="num">1 report.append(report[-class="num">1] - markup + (dataset[&class="macro">#x27;close&class="macro">#x27;][i] - last_price)) last_price = dataset[&class="macro">#x27;close&class="macro">#x27;][i] class="kw">continue if last_deal == class="num">1 and pred <=class="num">0.5: last_deal = class="num">0 report.append(report[-class="num">1] - markup + (last_price - dataset[&class="macro">#x27;close&class="macro">#x27;][i])) last_price = dataset[&class="macro">#x27;close&class="macro">#x27;][i] class="kw">return report pr = get_prices(look_back=LOOK_BACK) pr = add_labels(pr, class="num">10, class="num">25) rep = tester(pr, MARKUP) plt.plot(rep) plt.show()
「CatBoost 训练时的切分与防过拟合参数」
把数据集随机打乱后按 1:1 拆成训练集和验证集,是压制过拟合的第一道闸。训练集上模型不断压低分类误差,验证集同步测同一指标;两者差距拉大就说明模型在死记训练样本,接近才代表泛化方向没错。 CatBoost 多数参数开箱即用,不必逐项调。iterations 设树的最大数量,本例 1000 次通常够用;depth 控制单树深度,6~10 之间信号和稀疏度较平衡,过小会少出交易;learning_rate 同神经网络的步长,0.01~0.1 合理,越低越慢但可能更稳。 use_best_model 让模型按准确度保存最优那次而非末轮;early_stopping_rounds=50 表示验证指标连续 50 轮不改善就停训。控制台里 learn 准确度到 1.0、test 在 0.77 附近波动,best 迭代锁在 165,总耗时约 11~20 秒,验证准确度 0.78 明显高于随机基准 0.5。 别把验证 0.78 当实盘胜率。外汇与贵金属属高风险品种,样本内表现不等于未来概率,上 MT5 跑通这段代码后再看测试器输出才作数。
class="macro">#splitting on train and validation subsets X = pr[pr.columns[class="num">1:-class="num">1]] y = pr[pr.columns[-class="num">1]] train_X, test_X, train_y, test_y = train_test_split(X, y, train_size = class="num">0.5, test_size = class="num">0.5, shuffle=True) class="macro">#learning with train and validation subsets model = CatBoostClassifier(iterations=class="num">1000, depth=class="num">6, learning_rate=class="num">0.01, custom_loss=[&class="macro">#x27;Accuracy&class="macro">#x27;], eval_metric=&class="macro">#x27;Accuracy&class="macro">#x27;, verbose=True, use_best_model=True, task_type=&class="macro">#x27;CPU&class="macro">#x27;) model.fit(train_X, train_y, eval_set = (test_X, test_y), early_stopping_rounds=class="num">50, plot=False) class="num">170: learn: class="num">1.0000000 test: class="num">0.7712509 best: class="num">0.7767795 (class="num">165) total: class="num">11.2s remaining: class="num">21.5s class="num">171: learn: class="num">1.0000000 test: class="num">0.7726330 best: class="num">0.7767795 (class="num">165) total: class="num">11.2s remaining: class="num">21.4s class="num">172: learn: class="num">1.0000000 test: class="num">0.7733241 best: class="num">0.7767795 (class="num">165) total: class="num">11.3s remaining: class="num">21.3s class="num">173: learn: class="num">1.0000000 test: class="num">0.7740152 best: class="num">0.7767795 (class="num">165) total: class="num">11.3s remaining: class="num">21.3s class="num">174: learn: class="num">1.0000000 test: class="num">0.7712509 best: class="num">0.7767795 (class="num">165) total: class="num">11.4s remaining: class="num">21.2s class="num">175: learn: class="num">1.0000000 test: class="num">0.7726330 best: class="num">0.7767795 (class="num">165) total: class="num">11.5s remaining: class="num">21.1s class="num">176: learn: class="num">1.0000000 test: class="num">0.7712509 best: class="num">0.7767795 (class="num">165) total: class="num">11.5s remaining: 21s
过拟合检测器叫停后的模型实测
上面这组日志是 CatBoost 类模型在第 177~180 轮迭代时的输出:训练集拟合度 learn 恒为 1.0000000,而测试集 test 在 0.7705598~0.7747063 之间波动,最佳测试值 best 锁定在 0.7767795,对应第 165 轮。 程序跑了 11.6~11.8 秒,剩余时间从 21s 递减到 20.7s,随后被 overfitting detector 以「等了 15 轮没提升」为由强制停止,最终 bestIteration = 165。 停训后代码直接拿训练好的 model 对特征矩阵 X 做 predict_proba,把概率第一列小于 0.5 的判为 True,再塞回 pr2['labels'] 交给 tester 函数按 MARKUP 算回报,最后用 matplotlib 把权益曲线 plt.plot 出来。外汇与贵金属杠杆高,这套回测曲线只反映历史样本,实盘信号衰减概率不低,开 MT5 接同样数据跑一遍才知泛化够不够。
p = model.predict_proba(X) p2 = [x[class="num">0]<class="num">0.5 for x in p] pr2 = pr.iloc[:len(p2)].copy() pr2[&class="macro">#x27;labels&class="macro">#x27;] = p2 rep = tester(pr2, MARKUP) plt.plot(rep) plt.show()
◍ 把训练好的模型直接落成 MQH
在 MT5 里跑 CatBoost,不一定要走 Python API 实时下单。把模型编译进 EA,在 VPS 上省去装 Python 环境,是更轻量的部署方式。 思路是用 Python 侧一个辅助函数,把训练好的 model 对象以 C++ 格式导出,再拼成一段 MQL5 能直接 include 的 MQH 文本。下面这段代码就是干这事的核心逻辑。 def export_model_to_MQL_code(model): // 接收训练好的 CatBoost model 对象 model.save_model('catmodel.h', format="cpp") // 以 C++ 头文件格式落盘 code = 'double catboost_model(const double &features[]) { \n' // 定义 MQL5 侧入口函数 with open('catmodel.h','r') as file: // 读回导出的 C++ 模型结构 data = file.read() code += data[data.find("unsigned int TreeDepth"):data.find("double Scale = 1;")] // 截取树深度到缩放常量之间的模型主体 code += '\n\nreturn ApplyCatboostModel(features, TreeDepth, TreeSplits , BorderCounts, Borders, LeafValues); } \n\n' // 收尾调用推理函数 code += 'double ApplyCatboostModel(...) { ... }' // 内联标准推理实现,输出 (0;1) 概率 file = open('C:/Users/.../MQL5/Include/cat_model.mqh', "w") // 写进终端 Include 目录 file.write(code); file.close() // 落盘完成 其中 ApplyCatboostModel 根据特征和保存的树结构返回 (0;1) 区间的计算结果,相当于把 Python 的预测概率搬到了 MQL5 本地。 路径里那串 Terminal 哈希目录每个人不同,改成你本机 MT5 的 \Include 实际位置即可。设好参数后点一次训练,MQH 就自动生成,EA 里 #include 一下就能用。外汇与贵金属波动剧烈,这类本地模型推理只给信号倾向,实盘仍属高风险。
def export_model_to_MQL_code(model): model.save_model(&class="macro">#x27;catmodel.h&class="macro">#x27;, format="cpp", export_parameters=None, pool=None) code = &class="macro">#x27;<span class="keyword">class="type">class="kw">double</span> catboost_model&class="macro">#x27; + &class="macro">#x27;(<span class="keyword">class="kw">const</span> <span class="keyword">class="type">class="kw">double</span> &features[]) { \n&class="macro">#x27; code += &class="macro">#x27; &class="macro">#x27; with open(&class="macro">#x27;catmodel.h&class="macro">#x27;, &class="macro">#x27;r&class="macro">#x27;) <span class="keyword">as</span> file: data = file.read() code += data[data.find("<span class="keyword">unsigned</span> <span class="keyword">class="type">int</span> TreeDepth"):data.find("<span class="keyword">class="type">class="kw">double</span> Scale = <span class="number">class="num">1</span>;")] code +=&class="macro">#x27;\n\n&class="macro">#x27; code+= &class="macro">#x27;<span class="keyword">class="kw">return</span> &class="macro">#x27; + &class="macro">#x27;ApplyCatboostModel(features, TreeDepth, TreeSplits , BorderCounts, Borders, LeafValues); } \n\n&class="macro">#x27; code += &class="macro">#x27;<span class="keyword">class="type">class="kw">double</span> ApplyCatboostModel(<span class="keyword">class="kw">const</span> <span class="keyword">class="type">class="kw">double</span> &features[],<span class="keyword">class="type">uint</span> &TreeDepth_[],<span class="keyword">class="type">uint</span> &TreeSplits_[],<span class="keyword">class="type">uint</span> &BorderCounts_[],<span class="keyword">class="type">class="kw">float</span> &Borders_[],<span class="keyword">class="type">class="kw">double</span> &LeafValues_[]) {\n\ <span class="keyword">class="type">uint</span> FloatFeatureCount=<span class="functions">ArrayRange</span>(BorderCounts_,<span class="number">class="num">0</span>);\n\ <span class="keyword">class="type">uint</span> BinaryFeatureCount=<span class="functions">ArrayRange</span>(Borders_,<span class="number">class="num">0</span>);\n\ <span class="keyword">class="type">uint</span> TreeCount=<span class="functions">ArrayRange</span>(TreeDepth_,<span class="number">class="num">0</span>);\n\ <span class="keyword">class="type">bool</span> binaryFeatures[];\n\ <span class="functions">ArrayResize</span>(binaryFeatures,BinaryFeatureCount);\n\ <span class="keyword">class="type">uint</span> binFeatureIndex=<span class="number">class="num">0</span>;\n\ <span class="keyword">for</span>(<span class="keyword">class="type">uint</span> i=<span class="number">class="num">0</span>; i<FloatFeatureCount; i++) {\n\ <span class="keyword">for</span>(<span class="keyword">class="type">uint</span> j=<span class="number">class="num">0</span>; j<BorderCounts_[i]; j++) {\n\ binaryFeatures[binFeatureIndex]=features[i]>Borders_[binFeatureIndex];\n\ binFeatureIndex++;\n\ }\n\ }\n\ <span class="keyword">class="type">class="kw">double</span> result=<span class="number">class="num">0.0</span>;\n\ <span class="keyword">class="type">uint</span> treeSplitsPtr=<span class="number">class="num">0</span>;\n\ <span class="keyword">class="type">uint</span> leafValuesForCurrentTreePtr=<span class="number">class="num">0</span>;\n\ <span class="keyword">for</span>(<span class="keyword">class="type">uint</span> treeId=<span class="number">class="num">0</span>; treeId<TreeCount; treeId++) {\n\ <span class="keyword">class="type">uint</span> currentTreeDepth=TreeDepth_[treeId];\n\ <span class="keyword">class="type">uint</span> index=<span class="number">class="num">0</span>;\n\ <span class="keyword">for</span>(<span class="keyword">class="type">uint</span> depth=<span class="number">class="num">0</span>; depth<currentTreeDepth; depth++) {\n\ index|=(binaryFeatures[TreeSplits_[treeSplitsPtr+depth]]<<depth);\n\ }\n\ result+=LeafValues_[leafValuesForCurrentTreePtr+index];\n\ treeSplitsPtr+=currentTreeDepth;\n\ leafValuesForCurrentTreePtr+=(<span class="number">class="num">1</span><<currentTreeDepth);\n\ }\n\ <span class="keyword">class="kw">return</span> <span class="number">class="num">1.0</span>/(<span class="number">class="num">1.0</span>+<span class="functions">MathPow</span>(<span class="macro">M_E</span>,-result));\n\ }&class="macro">#x27; file = open(&class="macro">#x27;C:/Users/dmitrievsky/AppData/Roaming/MetaQuotes/Terminal/D0E8209F77C8CF37AD8BF550E51FF075/MQL5/Include/&class="macro">#x27; + &class="macro">#x27;cat_model&class="macro">#x27; + &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;cat_model&class="macro">#x27; + &class="macro">#x27;.mqh &class="macro">#x27; + &class="macro">#x27;has been written to disc&class="macro">#x27;)