因果推断与量化交易中的回归模型探索(基础篇)
📘

因果推断与量化交易中的回归模型探索(基础篇)

第 1/3 篇

「回归模型在MT5里的因果陷阱」

在 MetaTrader 5 上做量化,很多人第一反应是挂一个线性回归去拟合价格,再反过来推断「因为指标动了,所以价格会跟」。但统计上的相关性不等于因果,回归系数显著只说明样本内联动,样本外大概率塌房。

  • 年 7 月 17 日一篇社区帖拿到 64 次浏览、15 条互动,讨论的正是这类用回归做因果推断的坑——可见实盘圈早就开始警惕「拟合即真理」的错觉。

真要落地,先在 MT5 策略测试器里把回归窗口做成可变参数,用不同品种(如 XAUUSD、EURUSD)各跑一轮 2020–2025 年数据,看 R² 在样本外衰减有多快。外汇与贵金属杠杆高、跳空频繁,回归残差往往非正态,结论只能说「可能倾向反转」,绝不能当确定性信号。

◍ 为什么二分类 EA 容易赚对方向亏了钱

二分类把市场压成 0/1:在 MT5 的 EA 里常见写法是预测买入标 0、卖出标 1,模型只回答“往哪边”。这省事,但把仓位管理最需要的“动多少”删掉了。 核心盲区在幅度。分类器说涨,没说涨 5 点还是 50 点;说跌,没说回撤深度。外汇和贵金属杠杆高、滑点跳空频繁,这种幅度缺失会直接把决策引向亏损——尤其黄金秒级波动常超 20 点,方向对但止损扛不住的情况很普遍。 一个反直觉的数据点:胜率 70% 可能净亏,胜率 30% 也可能净赚。决定 PnL 的是盈利单均值/亏损单均值,不是猜对次数。二分类对所有“猜对”一视同仁,小波动蹭头和趋势大行情被同等对待,结果常是大量小赚被少数大亏吃掉。 对实盘而言,这意味着光看方向准确率挑 EA 是危险的。没有波动幅度估计,EA 没法优先做盈亏比优的单,也避不开亏大于盈的坑,方向胜率再高也填不平决策盲区。

回归标注怎么绕过分类模型的幅度盲区

二分类标注只给方向信号:涨或跌,等价处理所有波动。实际盘面上,未来偏移 5 点和 50 点在外汇与贵金属里完全是两种风险收益结构,固定标注会把噪声和真实趋势混为一谈。 回归标注直接算差值:未来价格减当前价格,把幅度也编码进目标变量。这样训练出的模型能顺带筛掉低于阈值的小幅波动,只留有可能覆盖点差和滑点的有效行情,贵金属隔夜跳空时这点尤其关键。 初版 calculate_labels_r 在 [min_val, max_val] 里随机抽一个未来点算差,速度靠 Numba JIT 提上来了,但漏掉了窗口内的中间路径。改进版 calculate_labels_mean_r 改成取窗口内全部收盘价均值再减当前价,相当于把区间波动摊平,标注对毛刺更不敏感。 两个 get_labels_r 除调用函数不同,其余裁剪、填列、去 NaN 逻辑一致。把 min=1、max=15 接进自己的 K 线序列,分别跑两版标注,对比标签分布标准差,能直观看出均值法是否压住了随机抽点带来的离散。外汇与贵金属杠杆高,标注再漂亮也只代表历史概率,实盘前务必小仓位验证。

MQL5 / C++
@njit
def calculate_labels_r(close_data, min_val, max_val):
    labels = []
    for i in range(len(close_data) - max_val):
        rand = random.randint(min_val, max_val)
        labels.append(close_data[i + rand] - close_data[i])
    class="kw">return labels
def get_labels_r(dataset, min = class="num">1, max = class="num">15) -> pd.DataFrame:
    # Extract closing prices from the dataset
    close_data = dataset[&class="macro">#x27;close&class="macro">#x27;].values
    labels = calculate_labels_r(close_data, min, max)
    # Trim the dataset to match the length of calculated labels
    dataset = dataset.iloc[:len(labels)].copy() 
    # Add the calculated labels as a new column
    dataset[&class="macro">#x27;labels&class="macro">#x27;] = labels
    # Remove rows with NaN values(potentially introduced in &class="macro">#x27;calculate_labels&class="macro">#x27;)
    dataset = dataset.dropna()
    class="kw">return dataset
@njit
def calculate_labels_mean_r(close_data, min_val, max_val):
    labels = []
    for i in range(len(close_data) - max_val):
        # Calculate the average price value in the window from min_val to max_val
        future_prices = close_data[i + min_val : i + max_val + class="num">1]
        mean_future_price = np.mean(future_prices)
        # Calculate the difference between the average future value and the current price
        labels.append(mean_future_price - close_data[i])
    class="kw">return labels
def get_labels_r(dataset, min = class="num">1, max = class="num">15) -> pd.DataFrame:
    # Extract closing prices from the dataset
    close_data = dataset[&class="macro">#x27;close&class="macro">#x27;].values
    # Calculate buy/hold labels based on future price movements
    labels = calculate_labels_mean_r(close_data, min, max)
    # Trim the dataset to match the length of calculated labels
    dataset = dataset.iloc[:len(labels)].copy() 
    # Add the calculated labels as a new column
    dataset[&class="macro">#x27;labels&class="macro">#x27;] = labels
    # Remove rows with NaN values(potentially introduced in &class="macro">#x27;calculate_labels&class="macro">#x27;)
    dataset = dataset.dropna()
    class="kw">return dataset

「用回归偏差过滤无效信号」

把因果推断思路接进预测流程时,这里换成了回归模型而不是分类模型。核心动作是在原始数据的随机子样本上训多个回归器,再把真实目标值和模型预测值放一起比,不再输出‘做不做交易’的0/1,而是吐出预测值相对真实值的平均偏差。 偏差过大往往意味着这套特征组合在该样本上失效,拿平均偏差当门槛就能把这类无效预测筛掉。外汇和贵金属波动受突发事件干扰明显,这种过滤只能降低噪声,不保证胜率,杠杆品种高风险依旧。 下面这段 Python 风格函数可直接在本地复现元学习过滤逻辑:

MQL5 / C++
<span class="keyword">def</span> meta_learners(data, models_number: <span class="built_in">class="type">int</span>, iterations: <span class="built_in">class="type">int</span>, depth: <span class="built_in">class="type">int</span>):
&nbsp;&nbsp;&nbsp;&nbsp;data = data.copy()
&nbsp;&nbsp;&nbsp;&nbsp;data = data[(data.index &lt; hyper_params[<span class="class="type">class="kw">string">&class="macro">#x27;forward&class="macro">#x27;</span>]) &amp; (data.index &gt; hyper_params[<span class="class="type">class="kw">string">&class="macro">#x27;backward&class="macro">#x27;</span>])].copy()
&nbsp;&nbsp;&nbsp;&nbsp;X = data[data.columns[<span class="number">class="num">1</span>:-<span class="number">class="num">1</span>]]
&nbsp;&nbsp;&nbsp;&nbsp;y = data[<span class="class="type">class="kw">string">&class="macro">#x27;labels&class="macro">#x27;</span>]
&nbsp;&nbsp;&nbsp;&nbsp;data[<span class="class="type">class="kw">string">&class="macro">#x27;meta_labels&class="macro">#x27;</span>] = <span class="number">class="num">0</span>
&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(models_number):
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;X_train, X_val, y_train, y_val = train_test_split(
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;X, y, train_size = <span class="number">class="num">0.5</span>, test_size = <span class="number">class="num">0.5</span>, shuffle = <span class="literal">True</span>)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="comment"># fit debias model with train and validation subsets</span>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;meta_m = <b>CatBoostRegressor</b>(iterations = iterations,
&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;depth = depth,
&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;verbose = <span class="literal">False</span>,
&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;use_best_model = <span class="literal">True</span>)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;meta_m.fit(X_train, y_train, eval_set = (X_val, y_val), plot = <span class="literal">False</span>)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;coreset = X.copy()
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;coreset[<span class="class="type">class="kw">string">&class="macro">#x27;labels&class="macro">#x27;</span>] = y
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;coreset[<span class="class="type">class="kw">string">&class="macro">#x27;labels_pred&class="macro">#x27;</span>] = meta_m.predict(X)
<b>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;data[<span class="class="type">class="kw">string">&class="macro">#x27;meta_labels&class="macro">#x27;</span>] += <span class="built_in">abs</span>(coreset[<span class="class="type">class="kw">string">&class="macro">#x27;labels&class="macro">#x27;</span>] - coreset[<span class="class="type">class="kw">string">&class="macro">#x27;labels_pred&class="macro">#x27;</span>])
</b>
<b>&nbsp;&nbsp;&nbsp;&nbsp;data[<span class="class="type">class="kw">string">&class="macro">#x27;meta_labels&class="macro">#x27;</span>] = data[<span class="class="type">class="kw">string">&class="macro">#x27;meta_labels&class="macro">#x27;</span>] / models_number
</b>&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">class="kw">return</span> data
逐行看:函数先按 hyper_params 的 forward/backward 窗口切数据,避免未来函数;X 取除首列和末列外的特征,y 是 labels。每个循环里 50%/50% 切训练验证集,用 CatBoostRegressor 拟合,predict 全量 X 后把真实与预测差的绝对值累进 meta_labels。最后除以 models_number 得到平均绝对偏差,数值越高代表该样本越可能被模型误读。 实盘前建议先把 models_number 调到 10~20 跑一遍历史段,观察 meta_labels 分布;若某段价差异常放大,大概率对应行情结构突变,这时候手动降权比硬跟信号更稳妥。

MQL5 / C++
<span class="keyword">def</span> meta_learners(data, models_number: <span class="built_in">class="type">int</span>, iterations: <span class="built_in">class="type">int</span>, depth: <span class="built_in">class="type">int</span>):
&nbsp;&nbsp;&nbsp;&nbsp;data = data.copy()
&nbsp;&nbsp;&nbsp;&nbsp;data = data[(data.index &lt; hyper_params[<span class="class="type">class="kw">string">&class="macro">#x27;forward&class="macro">#x27;</span>]) &amp; (data.index &gt; hyper_params[<span class="class="type">class="kw">string">&class="macro">#x27;backward&class="macro">#x27;</span>])].copy()
&nbsp;&nbsp;&nbsp;&nbsp;X = data[data.columns[<span class="number">class="num">1</span>:-<span class="number">class="num">1</span>]]
&nbsp;&nbsp;&nbsp;&nbsp;y = data[<span class="class="type">class="kw">string">&class="macro">#x27;labels&class="macro">#x27;</span>]
&nbsp;&nbsp;&nbsp;&nbsp;data[<span class="class="type">class="kw">string">&class="macro">#x27;meta_labels&class="macro">#x27;</span>] = <span class="number">class="num">0</span>
&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">for</span> i <span class="keyword">in</span> <span class="built_in">range</span>(models_number):
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;X_train, X_val, y_train, y_val = train_test_split(
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;X, y, train_size = <span class="number">class="num">0.5</span>, test_size = <span class="number">class="num">0.5</span>, shuffle = <span class="literal">True</span>)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="comment"># fit debias model with train and validation subsets</span>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;meta_m = <b>CatBoostRegressor</b>(iterations = iterations,
&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;depth = depth,
&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;verbose = <span class="literal">False</span>,
&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;use_best_model = <span class="literal">True</span>)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;meta_m.fit(X_train, y_train, eval_set = (X_val, y_val), plot = <span class="literal">False</span>)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;coreset = X.copy()
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;coreset[<span class="class="type">class="kw">string">&class="macro">#x27;labels&class="macro">#x27;</span>] = y
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;coreset[<span class="class="type">class="kw">string">&class="macro">#x27;labels_pred&class="macro">#x27;</span>] = meta_m.predict(X)
<b>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;data[<span class="class="type">class="kw">string">&class="macro">#x27;meta_labels&class="macro">#x27;</span>] += <span class="built_in">abs</span>(coreset[<span class="class="type">class="kw">string">&class="macro">#x27;labels&class="macro">#x27;</span>] - coreset[<span class="class="type">class="kw">string">&class="macro">#x27;labels_pred&class="macro">#x27;</span>])
</b>
<b>&nbsp;&nbsp;&nbsp;&nbsp;data[<span class="class="type">class="kw">string">&class="macro">#x27;meta_labels&class="macro">#x27;</span>] = data[<span class="class="type">class="kw">string">&class="macro">#x27;meta_labels&class="macro">#x27;</span>] / models_number
</b>&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">class="kw">return</span> data

常见问题

二分类只判涨跌不判幅度,遇到小幅波动手续费和滑点就能吃掉利润。建议叠加回归模型估计预期幅度,过滤掉幅度过小的信号。
把标签从涨跌改成未来N根K线的实际价格偏差值,用连续变量训练。这样模型能区分大波动和小噪音,不再只看方向。
小布可自动计算品种回归偏差阈值并标记当前信号是否有效,你打开对应页面就能直接看过滤结果,不用自己写脚本。
把相关当因果,用滞后指标去解释价格导致过拟合。回测前先隔离外生变量,确认结构再上实盘。
偏差绝对值大才给常规仓位,临界区减半或空仓。外汇贵金属波动随机性强,务必控风险。