机器学习和交易中的元模型:交易订单的原始时序·进阶篇
🔁

机器学习和交易中的元模型:交易订单的原始时序·进阶篇

(2/3)· 当单一分类器在选择性交易里反复误触发,元模型如何自我校正训练集

案例拆解新手友好 第 2/3 篇
很多交易者把训练好的分类器直接塞进 EA,却忽略它在无形态时段也会硬猜信号。选择性交易系统本就不该全天候持仓,用错样本喂模型只会把假阳性当真机会。元模型的思路是先让一层过滤器认出哪些样本根本不该学。

◍ 双层模型预测与坏样本回收逻辑

堆叠模型跑完基础层和元层后,先各自输出概率再做二分类截断:p2 与 p2_meta 都把 predict_proba 的第 0 列小于 0.5 判为负类,这一步直接决定后续标签流向。 代码里用全局 BAD_SAMPLES_BOOK 收集元标签为 0.0 的索引,相当于把「这轮被元模型否掉的样本」记进黑名单,供后续迭代复用。 坏样本回标时有个阈值判断:若某索引在 BAD_SAMPLES_BOOK 中出现次数超过均值乘以 bad_samples_fraction,才把它在 pr2 的 meta_labels 强制置 0.0;否则只要进过黑名单就置 0.0。这个 fraction 调大,模型对历史坏样本会更宽容,调小则更激进地剔除。 最后用 full_pr 跑一次 tester 拿 R2 返回,说明评估是在未截断时间窗的全样本上做的,而标签修正只作用在 pr2 子窗。外汇与贵金属行情下用这套重标注,过拟合风险偏高,建议先在 MT5 用历史数据跑通再上实盘。

MQL5 / C++
  # predict the learned models(base and meta)
  p = model.predict_proba(X)
  p_meta = meta_model.predict_proba(X_meta)
  p2 = [x[class="num">0] < class="num">0.5 for x in p]
  p2_meta = [x[class="num">0] < class="num">0.5 for x in p_meta]
  pr2 = pr_tst.iloc[:len(p2)].copy()
  pr2[&class="macro">#x27;labels&class="macro">#x27;] = p2
  pr2[&class="macro">#x27;meta_labels&class="macro">#x27;] = p2_meta
  pr2[&class="macro">#x27;labels&class="macro">#x27;] = pr2[&class="macro">#x27;labels&class="macro">#x27;].astype(class="type">float)
  pr2[&class="macro">#x27;meta_labels&class="macro">#x27;] = pr2[&class="macro">#x27;meta_labels&class="macro">#x27;].astype(class="type">float)
  full_pr = pr2.copy()
  pr2 = pr2[pr2.index >= TSTART_DATE]
  pr2 = pr2[pr2.index <= STOP_DATE]
  # add bad samples of this iteratin(bad meta labels)
  global BAD_SAMPLES_BOOK
  BAD_SAMPLES_BOOK = BAD_SAMPLES_BOOK.append(pr2[pr2[&class="macro">#x27;meta_labels&class="macro">#x27;]==class="num">0.0].index)
  
  # test mdels and resample meta labels
  R2, meta_labels = tester(pr2, MARKUP, use_meta=True, plot=False)
  pr2[&class="macro">#x27;meta_labels&class="macro">#x27;] = meta_labels
  # resample labels based on meta labels
  pr2 = labelling_relabeling(pr2, relabeling=True)
  pr2[&class="macro">#x27;labels&class="macro">#x27;] = pr2[&class="macro">#x27;labels&class="macro">#x27;].astype(class="type">float)
  pr2[&class="macro">#x27;meta_labels&class="macro">#x27;] = pr2[&class="macro">#x27;meta_labels&class="macro">#x27;].astype(class="type">float)
  # mark bad labels from bad_samples_book
  if BAD_SAMPLES_BOOK.value_counts().max() > class="num">1:
    to_mark = BAD_SAMPLES_BOOK.value_counts()
    mean = to_mark.mean()
    marked_idx = to_mark[to_mark > mean*bad_samples_fraction].index
    pr2.loc[pr2.index.isin(marked_idx), &class="macro">#x27;meta_labels&class="macro">#x27;] = class="num">0.0
  else:
    pr2.loc[pr2.index.isin(BAD_SAMPLES_BOOK), &class="macro">#x27;meta_labels&class="macro">#x27;] = class="num">0.0
  R2, _ = tester(full_pr, MARKUP, use_meta=True, plot=False)
  class="kw">return [R2, model, meta_model, pr2]
marked_idx = to_mark[to_mark > mean*bad_samples_fraction].index

「迭代训练里怎么把亏钱信号洗掉」

元模型打标靠的是 tester() 返回的 R^2 和带标记成交帧。把这个结果帧挂到原始数据上,再用第二个标签 meta_labels 对主标签 labels 做二次重标,本质就是从数据集里剔掉被验证无利可图的成交。外汇与贵金属波动剧烈,这种清洗只能降低样本偏差,不承诺实盘胜率。 重训练循环先清空上一轮留下的 bad_samples_book,再跑指定迭代次数。我本地跑 25 轮、bad_samples_fraction=0.7 时,每一轮 brute_force() 把模型和返回数据写进 res[],并打印当轮 R^2。第一次迭代通常 R^2 偏低,后续演算让模型自我修正,测试时成交数减少就是演变的特征信号。 pr 变量承载当轮转换后的数据,喂给下一轮。理论建议扩展基准模型训练区间:把训练起始日按天数后移,但窗口不能超过元模型 TSTART_DATE 的跨度。启用 use_GMM_resampling 思路相近,不过我后来从函数库删了重采样——它多耗训练时间,结果却没明显改观。 从新数据回测看,靠后的第五个模型成交更多,第七个模型成交数翻倍且点数总利润更高。具体选哪个,取决于你更看重样本量还是单边点数,两者都只是概率倾向。

MQL5 / C++
# make dataset
pr = get_prices()
pr = labelling_relabeling(pr, relabeling=False)
a, b = tester(pr, MARKUP, use_meta=False, plot=False)
pr[&class="macro">#x27;meta_labels&class="macro">#x27;] = b
pr = pr.dropna()
pr = labelling_relabeling(pr, relabeling=True)
# iterative learning
res = []
BAD_SAMPLES_BOOK = pd.DatetimeIndex([])
for i in range(class="num">25):
    res.append(brute_force(pr[pr.columns[class="num">1:]], bad_samples_fraction=class="num">0.7))
    print(&class="macro">#x27;Iteration: {}, R^class="num">2: {}&class="macro">#x27;.format(i, res[-class="num">1][class="num">0]))
    pr = res[-class="num">1][class="num">3]
>>> pr = get_prices(START_DATE, STOP_DATE)
>>> pr = labelling_relabeling(pr, relabeling=False)
>>> pr

从小时序列看标签对齐的实况

上面这组 EURUSD 小时数据截取自 2021-04-13 23:00 至次日凌晨 03:00,close 从 1.19474 走到 1.19543,单根小时 K 最大正向变动约 0.000356(04-14 03:00)。第 0 至第 7 列是不同滞后期收益率,最远一列 0.004888→0.005286 反映 8 小时前的滚动收益,说明近端动量偏弱、远端残留更明显。 用 tester 跑完不含元特征的版本后,把返回的 b 直接挂到 pr['meta_labels'],再 dropna 得到可用样本。2020-05-06 20:00 起连续 5 根 close 从 1.08086 跌到 1.07968,原始 labels 全为 1.0,而 meta_labels 也给出 1.0,说明该时段下跌段被元标签一致确认。 整表原始规模 5670 行 × 9 列,dropna 后尾行出现在 2021-04-13 18:00,close 1.19385、原始 label 为 0.0 但 meta_labels 为 1.0——这种原始标签与元标签背离的格子,才是你开 MT5 对照裸 K 时该重点翻的地方。外汇和贵金属杠杆高,标签背离只代表历史统计倾向,不等于后续必反转。

◍ 迭代重标注把拟合度拉过 0.9

上面那张 5665 行的小时级 EURUSD 片段,最后一列是类标(1.0 表示触发方向信号),前面 8 列是波动率类特征。直接拿原始标签跑模型,R² 往往卡在 0.3 附近,说明噪声样本把边界搅浑了。 用 relabeling=True 做标签重标定后,再跑 25 轮 brute_force,每轮丢弃 70% 的坏样本(bad_samples_fraction=0.7)。迭代 6 出现 R²=0.9168,迭代 14 冲到 0.9413,说明越往后留存的样本越「干净」,模型解释力倾向抬升。 外汇与贵金属属高风险品种,这种重标注只是提高历史样本可学习性,对未来行情只能给概率性参考,不构成方向保证。 别把 0.94 当天花板 迭代 1 的 R² 只有 0.74,迭代 3 跳到 0.889,迭代 8 又掉回 0.827,说明丢弃比例固定时,随机子集差异会让分数抖动量级到 0.1 以上。实盘前建议把迭代次数提到 50 轮,看 R² 分布的中位数而非单点峰值。

MQL5 / C++
class="num">2021-class="num">04-class="num">13 class="num">19:class="num">00:class="num">00  class="num">1.19379  class="num">0.000546  class="num">0.003121  class="num">0.003015  ...  class="num">0.003522  class="num">0.004166     class="num">0.0               class="num">1.0
class="num">2021-class="num">04-class="num">13 class="num">20:class="num">00:class="num">00  class="num">1.19423  class="num">0.000622  class="num">0.003269  class="num">0.003349  ...  class="num">0.003904  class="num">0.004555     class="num">0.0               class="num">1.0
class="num">2021-class="num">04-class="num">13 class="num">21:class="num">00:class="num">00  class="num">1.19465  class="num">0.000820  class="num">0.003315  class="num">0.003640  ...  class="num">0.004267  class="num">0.004929     class="num">0.0               class="num">1.0
class="num">2021-class="num">04-class="num">13 class="num">22:class="num">00:class="num">00  class="num">1.19552  class="num">0.001112  class="num">0.003733  class="num">0.004311  ...  class="num">0.005092  class="num">0.005733     class="num">1.0               class="num">1.0
[class="num">5665 rows x class="num">10 columns]
pr = labelling_relabeling(pr, relabeling=True)
# iterative learning
res = []
BAD_SAMPLES_BOOK = pd.DatetimeIndex([])
for i in range(class="num">25):
    res.append(brute_force(pr[pr.columns[class="num">1:]], bad_samples_fraction=class="num">0.7))
    print(&class="macro">#x27;Iteration: {}, R^class="num">2: {}&class="macro">#x27;.format(i, res[-class="num">1][class="num">0]))
    pr = res[-class="num">1][class="num">3]
Iteration: class="num">0, R^class="num">2: class="num">0.30121038659012245
Iteration: class="num">1, R^class="num">2: class="num">0.7400055934041012
Iteration: class="num">2, R^class="num">2: class="num">0.6221261327516192
Iteration: class="num">3, R^class="num">2: class="num">0.8892813889403367
Iteration: class="num">4, R^class="num">2: class="num">0.787251984980149
Iteration: class="num">5, R^class="num">2: class="num">0.794241109825588
Iteration: class="num">6, R^class="num">2: class="num">0.9167876214355855
Iteration: class="num">7, R^class="num">2: class="num">0.903399695678254
Iteration: class="num">8, R^class="num">2: class="num">0.8273236332747745
Iteration: class="num">9, R^class="num">2: class="num">0.8646088124681762
Iteration: class="num">10, R^class="num">2: class="num">0.8614746864767437
Iteration: class="num">11, R^class="num">2: class="num">0.7900599001415054
Iteration: class="num">12, R^class="num">2: class="num">0.8837049280116869
Iteration: class="num">13, R^class="num">2: class="num">0.784793801426211
Iteration: class="num">14, R^class="num">2: class="num">0.941340102099874
Iteration: class="num">15, R^class="num">2: class="num">0.8715065229034792
Iteration: class="num">16, R^class="num">2: class="num">0.8104990158946458
Iteration: class="num">17, R^class="num">2: class="num">0.8542444489379808
Iteration: class="num">18, R^class="num">2: class="num">0.8307365677342298
Iteration: class="num">19, R^class="num">2: class="num">0.9092509787525882

「双模型落地到 EA 的编译要点」

把训练好的两个 CatBoost 模型都导成头文件再塞进 EA:基准模型负责买卖方向,元模型做开关——只有它输出大于 0.5 时才放行交易。这套机制直接改写了传统单模型 EA 的逻辑,避免在不该出手的时间段被迫成交。 代码里用 catboost_model() 和 catboost_meta_model() 分别读取 catmodel.h 与 meta_catmodel.h,两者都靠 ApplyCatboostModel 做推理。OnTick 中先判新 K 线,再填特征数组;若特征维度与 MAs 不一致就打印缺失历史并退出,防止数组越界。 被高亮的 meta_sig > 0.5 是实盘过滤核心:它包住了平仓与开仓两块。基准模型 sig > 0.5 倾向做多、sig < 0.5 倾向做空,但前提是元模型已授权。外汇与贵金属杠杆高,模型误判可能在分钟级扩大浮亏,建议先在策略测试器用 2020—2023 年 tick 数据跑通再上模拟盘。 编译时若报 TreeDepth 未声明,通常是头文件截取区间卡在 Scale=1 之前漏了常量,手动核对 data.find 的起止位置即可。

MQL5 / C++
# add CatBosst base model
	code += &class="macro">#x27;<span class="keyword">class="type">class="kw">double</span> catboost_model&class="macro">#x27; + &class="macro">#x27;(<span class="keyword">const</span> <span class="keyword">class="type">class="kw">double</span> &amp;features[]) { \n&class="macro">#x27;
	code += &class="macro">#x27;&nbsp;&nbsp;&nbsp;&nbsp;&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;
	# add CatBosst meta model
	code += &class="macro">#x27;<span class="keyword">class="type">class="kw">double</span> catboost_meta_model&class="macro">#x27; + &class="macro">#x27;(<span class="keyword">const</span> <span class="keyword">class="type">class="kw">double</span> &amp;features[]) { \n&class="macro">#x27;
	code += &class="macro">#x27;&nbsp;&nbsp;&nbsp;&nbsp;&class="macro">#x27;
	with open(&class="macro">#x27;meta_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;
<span class="keyword">class="type">void</span> <span class="functions">OnTick</span>() {
<span class="comment">class=class="str">"cmt">//---</span>
&nbsp;&nbsp; <span class="keyword">if</span>(!isNewBar()) <span class="keyword">class="kw">return</span>;
&nbsp;&nbsp; <span class="functions">TimeToStruct</span>(<span class="functions">TimeCurrent</span>(), hours);
&nbsp;&nbsp; <span class="keyword">class="type">class="kw">double</span> features[];
&nbsp;&nbsp; fill_arays(features);
&nbsp;&nbsp; <span class="keyword">if</span>(<span class="functions">ArraySize</span>(features) !=<span class="functions">ArraySize</span>(MAs)) {
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="functions">Print</span>(<span class="class="type">class="kw">string">"No history availible, will try again on next signal!"</span>);
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">class="kw">return</span>;
&nbsp;&nbsp; }
&nbsp;&nbsp; <span class="keyword">class="type">class="kw">double</span> sig = catboost_model(features);
&nbsp;&nbsp; <span style="background-class="type">color:rgb(class="num">255, class="num">242, class="num">153);"><span class="keyword">class="type">class="kw">double</span> meta_sig = catboost_meta_model(features);</span>
<span class="comment">class=class="str">"cmt">// close positions by an opposite signal</span>
&nbsp;&nbsp; <span style="background-class="type">color:rgb(class="num">255, class="num">242, class="num">153);"><span class="keyword">if</span>(meta_sig &gt; <span class="number">class="num">0.5</span>)</span>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">if</span>(count_market_orders(<span class="number">class="num">0</span>) || count_market_orders(<span class="number">class="num">1</span>))
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <span class="keyword">for</span>(<span class="keyword">class="type">int</span> b = <span class="functions">OrdersTotal</span>() - <span class="number">class="num">1</span>; b &gt;= <span class="number">class="num">0</span>; b--)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">if</span>(<span class="functions">OrderSelect</span>(b, SELECT_BY_POS) == <span class="macro">true</span>) {
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <span class="keyword">if</span>(OrderType() == <span class="number">class="num">0</span> &amp;&amp; OrderSymbol() == <span class="predefines">_Symbol</span> &amp;&amp; OrderMagicNumber() == OrderMagic &amp;&amp; sig &gt; <span class="number">class="num">0.5</span>)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">if</span>(OrderClose(OrderTicket(), OrderLots(), OrderClosePrice(), <span class="number">class="num">0</span>, Red)) {
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;}
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <span class="keyword">if</span>(OrderType() == <span class="number">class="num">1</span> &amp;&amp; OrderSymbol() == <span class="predefines">_Symbol</span> &amp;&amp; OrderMagicNumber() == OrderMagic &amp;&amp; sig &lt; <span class="number">class="num">0.5</span>)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">if</span>(OrderClose(OrderTicket(), OrderLots(), OrderClosePrice(), <span class="number">class="num">0</span>, Red)) {
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;}
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;}
<span class="comment">class=class="str">"cmt">// open positions and pending orders by signals</span>
&nbsp;&nbsp; <span style="background-class="type">color:rgb(class="num">255, class="num">242, class="num">153);"><span class="keyword">if</span>(meta_sig &gt; <span class="number">class="num">0.5</span>)</span>

无持仓时按信号开仓的执行分支

这段逻辑只在当前账户没有任何挂单或成交单、且保证金校验通过时才触发,避免重复加仓把净值曲线打爆。 信号阈值卡在 0.5:sig 小于该值走多单,否则走空单。多单以 Ask 入场,止损放在 Bid 减去 stoploss 个点,止盈放在 Ask 加上 takeprofit 个点;空单反向对称,以 Bid 入场、止损在 Ask 加 stoploss 点、止盈在 Bid 减 takeprofit 点。 LotsOptimized() 返回的手数直接用于下单,没有再二次缩放,所以这套仓位函数在外盘贵金属上可能把杠杆用到边界,实盘前务必在 MT5 策略测试器用 1:30 以下杠杆回测一遍。外汇与贵金属属高风险品种,上述触发条件仅描述代码行为,不预示任何胜率。

MQL5 / C++
if(countOrders() == class="num">0 && CheckMoneyForTrade(_Symbol,LotsOptimized(),ORDER_TYPE_BUY)) {
      class="type">class="kw">double l = LotsOptimized();
      if(sig < class="num">0.5) {
            OrderSend(Symbol(),OP_BUY,l, Ask, class="num">0, Bid-stoploss*_Point, Ask+takeprofit*_Point, NULL, OrderMagic);
      } else {
            OrderSend(Symbol(),OP_SELL,l, Bid, class="num">0, Ask+stoploss*_Point, Bid-takeprofit*_Point, NULL, OrderMagic);
      }
}
把样本筛选交给小布盯盘
这些诊断小布盯盘的 AIGC 已内置,打开对应品种页即可看到元模型标记出的零分类区间,你只需决定要不要把这段行情踢出训练集。

常见问题

元模型需要更长的条件历史来判定哪些样本属于难以分类部分,基准训练集若超过它就会失去过滤边界,迭代校正无从谈起。
小布盯盘内置了元模型标记与样本剔除视图,可导出整理后的训练集,但模型编译进 EA 仍需在 MQL5 侧完成。
在真实行情中单指标改善常伴随另一指标劣化,元模型目标是平衡二者,而非追求某一项极致。
文中将其定义为基准模型的假阳性,即本应判为零却被分成触发类,元模型的作用就是提前滤掉这类样本。