English Русский Español Português
preview
因果推断与量化交易中的回归模型探索

因果推断与量化交易中的回归模型探索

MetaTrader 5积分 |
34 15
[删除]

引言

在之前的系列文章中,我们已经探究了多种时间序列分类方法,但尚未涉及回归模型。与二分类不同,回归模型预测的不是样本属于某一类别的概率,而是连续数值,这样扩展了其在自动化交易系统中的应用空间。

二分类是机器学习的基础任务,核心目标是将输入数据划分至两个不同类别中的任意一个。应用于外汇交易EA时,该模型通常用于预测买入信号(标记为0)或卖出信号(标记为1)。这种方式将复杂的市场运行逻辑,简化为单一的方向决策。

但在量化交易场景中,二分类模型存在一个核心的固有缺陷:无法量化价格波动的幅度与力度。二分类器仅能预判价格上涨或下跌的方向,无法给出价格的预期波动幅度。这种精细化数据的缺失,从根本上限制了交易决策的有效性。

仅依靠分类器的预测准确率,而忽略了价格波动幅度,因此对实盘交易参考价值有限。这一点至关重要,因为极高的方向预测准确率(例如70%),并不代表交易能够盈利。 

我们可以发现一个重要的市场规律:方向预测精准度高,无法保证交易盈利。举例而言,交易胜率30%依旧可以实现盈利,而胜率70%也可能面临亏损。这说明交易策略的最终盈亏结果,取决于盈利单的利润规模与亏损单的亏损规模之比,而非单纯的交易胜率。

二分类模型对所有方向正确的预测一视同仁,而忽略实际的价格波动大小,无法区分小幅无效波动与大幅高盈利行情。这会导致交易中出现大量小额盈利被少数大额亏损抵消的情况(反之亦然),最终即便看似方向预测胜率较高,整体盈亏(PnL)却依然为负。

由于无法量化价格波动幅度,交易EA无法优先选择预期收益更高的交易,也无法规避潜在亏损远大于潜在盈利的交易,即便方向预测正确也无济于事。缺少波动幅度数据,EA无法预判决策带来的实际盈亏效果,存在明显决策盲区,最终导致即便方向胜率偏高,整体累计收益依旧不佳,甚至出现亏损。


标注函数的优化

我们考虑这样一个场景:基于一组特征变量,对金融时间序列进行预测。在二分类模型中,我们仅能判定未来的交易方向(买入或卖出),且标注是固定不变的。我们无法通过不同的标注方式来更准确地估计未来价格偏移的幅度。无论价格实际变动了多少,这些交易在这种标注方式下都被视为等价的。

而回归模型可以同时预测交易方向与未来价格波动幅度。基于这一特性,我们可以优化交易系统,增设筛选条件,仅捕捉具备交易价值的有效价格波动,过滤掉无意义的小幅波动。

训练回归模型需要提前准备特征变量与目标变量。分类模型与回归模型可以共用特征变量,但二者的目标变量完全不同。

让我们编写一个简易函数,演示回归模型的标注逻辑:

@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])
    return labels

def get_labels_r(dataset, min = 1, max = 15) -> pd.DataFrame:
    # Extract closing prices from the dataset
    close_data = dataset['close'].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['labels'] = labels
    # Remove rows with NaN values (potentially introduced in 'calculate_labels')
    dataset = dataset.dropna()
    return dataset

回归标注与二分类标注的核心区别在于:我们不再只判定涨跌方向(买入或卖出),而是直接计算价格变动差值(未来价格减去当前价格)。代码通过Numba进行加速,因此生成目标标注的速度极快。

上述函数仅统计了在[min_val, max_val]区间内随机选取的未来价格与当前价格的差值。该方式存在一定缺陷,因为忽略了过程中可能出现的重要中间价格波动。因此,我提出另一种改进版的价格偏差计算函数,具体如下:

@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 + 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])
    return labels

def get_labels_r(dataset, min = 1, max = 15) -> pd.DataFrame:
    # Extract closing prices from the dataset
    close_data = dataset['close'].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['labels'] = labels
    # Remove rows with NaN values (potentially introduced in 'calculate_labels')
    dataset = dataset.dropna()
    return dataset

现在,该函数会统计指定区间内所有价格波动,并计算其平均值。随后,再计算未来价格均值与当前价格之间的差值。相应地,get_labels_r()函数现在会调用calculate_labels_mean_r()标注函数,而非之前的calculate_labels_r()。我们可以通过调用不同的标注函数来进行实验对比。


加入因果推断系统

为了获得更精准的预测结果,我们采用了与因果推断一文中类似的算法。主要区别在于这里使用的是回归模型,而非分类模型。

def meta_learners(data, models_number: int, iterations: int, depth: int):
    data = data.copy()
    data = data[(data.index < hyper_params['forward']) & (data.index > hyper_params['backward'])].copy()

    X = data[data.columns[1:-1]]
    y = data['labels']
    data['meta_labels'] = 0

    for i in range(models_number):
        X_train, X_val, y_train, y_val = train_test_split(
            X, y, train_size = 0.5, test_size = 0.5, shuffle = True)
        
        # fit debias model with train and validation subsets
        meta_m = CatBoostRegressor(iterations = iterations,
                                depth = depth,
                                verbose = False,
                                use_best_model = True)
        
        meta_m.fit(X_train, y_train, eval_set = (X_val, y_val), plot = False)
        
        coreset = X.copy()
        coreset['labels'] = y
        coreset['labels_pred'] = meta_m.predict(X)
        data['meta_labels'] += abs(coreset['labels'] - coreset['labels_pred'])

    data['meta_labels'] = data['meta_labels'] / models_number
    return data

该函数在原始数据集的随机子数据集上训练多个回归器,然后将真实目标值与模型预测值进行比对。因此,该元模型不再输出0或1(是否交易)的分类结果,而是输出预测值相对真实值的平均偏差值。通过这种方式,我们可以过滤掉与预期值偏差过大的无效预测。


模型训练与测试

本文针对回归模型的特性,对测试器进行了改造,新版带有后缀r标识。接下来即可训练模型。本文将训练10个模型,并从中筛选最优模型。

hyper_params = {
    'symbol': 'EURUSD_H1',
    'export_path': '/Users/dmitrievsky/drive_c/Program Files/MetaTrader 5/MQL5/Include/Trend following/',
    'model_number': 0,
    'markup': 0.00010,
    'stop_loss':  0.00500,
    'take_profit': 0.00200,
    'periods': [i for i in range(5, 100, 30)],
    'backward': datetime(2010, 1, 1),
    'forward': datetime(2024, 1, 1),
}

models = []
for i in range(10):
    print('Learn ' + str(i) + ' model')
    data = get_labels_r(get_features(get_prices()), min=1, max=15)
    dataset = meta_learners(data=data, models_number=5, iterations=15, depth=3)
    models.append(fit_final_models(dataset, tol=3e-2))

这里,我们需要重点关注传入最终模型训练函数的tol参数。为了让主模型尽可能保持稳健,我们没必要对全部样本进行训练,只使用预测值与真实值偏差小于tol的样本进行训练。

由于预测偏差实际以点数(Point)计算,因此,tol=3e-2表示最大允许偏差为0.03,即300个四位小数点数的点。这个过滤阈值看起来偏大,但要注意这是绝对值偏差,而预测值既可以为正也可以为负。您可以用该参数进行调参实验。函数实现如下:

def fit_final_models(dataset, tol=1e-2) -> list:
    # features for model\meta models. We learn main model only on filtered labels
    X = dataset[dataset['meta_labels'] < tol]
    X, X_meta = X[X.columns[1:-2]], dataset[dataset.columns[1:-2]]
    # labels for model\meta models
    y = dataset[dataset['meta_labels'] < tol]
    y, y_meta = y[y.columns[-2]], dataset[dataset.columns[-1]]
    
    # fit main model with train and validation subsets
    model = RandomForestRegressor(n_estimators=50, max_depth=10)
    model.fit(X, y)
    # fit meta model with train and validation subsets
    meta_model = RandomForestRegressor(n_estimators=50, max_depth=10)
    meta_model.fit(X_meta, y_meta)

    data = get_features(get_prices())
    R2 = test_model_r(data, 
                    [model, meta_model], 
                    hyper_params['stop_loss'], 
                    hyper_params['take_profit'],
                    hyper_params['forward'],
                    hyper_params['backward'],
                    hyper_params['markup'],
                    plt=False)
    
    if math.isnan(R2):
        R2 = -1.0
        print('R2 is fixed to -1.0')
    print('R2: ' + str(R2))
    result = [R2, model, meta_model]
    return result

现在,我们对模型进行排序,并调用自定义测试函数:

models.sort(key=lambda x: x[0])
data = get_features(get_prices())
test_model_r(data, 
        models[-1][1:], 
        hyper_params['stop_loss'], 
        hyper_params['take_profit'],
        hyper_params['forward'],
        hyper_params['backward'],
        hyper_params['markup'],
        plt=True)

该模型出现过拟合,在新数据上表现很差:

图例1. 使用基础标注方法测试模型

我们执行完全相同的操作,但改用计算未来价格均值的交易标注函数calculate_labels_mean_r()

图例2. 使用均值标注方法测试模型

第二种交易标注函数在新数据上整体表现更稳定,原因通常在于其引入了未来价格的均值信息。

自定义测试器目前无法为主回归模型设置阈值,因此只能简单地将模型预测值分为正值和负值,但信号依然比较粗糙。但我们会在MetaTrader 5终端中直接完善这一点。


将模型导出至MetaTrader 5终端

现在,我们需要将模型以ONNX格式导出到终端,并配置交易系统。导出函数看上去和之前类似:

export_model_to_ONNX(model = models[-1],
                     symbol = hyper_params['symbol'],
                     periods = hyper_params['periods'],
                     periods_meta = hyper_params['periods'],
                     model_number = hyper_params['model_number'],
                     export_path = hyper_params['export_path'])

需要注意的是,我无法在终端中使用ONNX格式的CatBoost回归模型,因此改用随机森林(Random Forest)。

输入张量的维度会根据训练前设定的超参数(特征数量)自动调整。之后,通过convert_sklearn()函数将模型转换为ONNX格式,并保存到超参数中指定的目录下。

def export_model_to_ONNX(**kwargs):
    model = kwargs.get('model')
    symbol = kwargs.get('symbol')
    periods = kwargs.get('periods')
    periods_meta = kwargs.get('periods_meta')
    model_number = kwargs.get('model_number')
    export_path = kwargs.get('export_path')

    initial_type = [('float_input', FloatTensorType([None, len(hyper_params['periods'])]))]
    onnx_model = convert_sklearn(model[1], initial_types=initial_type)
    # save main model to ONNX
    with open(export_path +'catmodel ' + symbol + ' ' + str(model_number) +'.onnx', "wb") as f:
        f.write(onnx_model.SerializeToString())
    onnx_model_meta = convert_sklearn(model[2], initial_types=initial_type)
    # save meta model to ONNX
    with open(export_path +'catmodel_m ' + symbol + ' ' + str(model_number) +'.onnx', "wb") as f:
        f.write(onnx_model_meta.SerializeToString())
    
    code = '#include <Math\Stat\Math.mqh>'
    code += '\n'
    code += '#resource "catmodel '+ symbol + ' '+str(model_number)+'.onnx" as uchar ExtModel_' + symbol + '_' + str(model_number) + '[]'
    code += '\n'
    code += '#resource "catmodel_m '+ symbol + ' '+str(model_number)+'.onnx" as uchar ExtModel2_' + symbol + '_' + str(model_number) + '[]'
    code += '\n\n'
    code += 'int Periods' + symbol + '_' + str(model_number) + '[' + str(len(periods)) + \
        '] = {' + ','.join(map(str, periods)) + '};'
    code += '\n'
    code += 'int Periods_m' + symbol + '_' + str(model_number) + '[' + str(len(periods_meta)) + \
        '] = {' + ','.join(map(str, periods_meta)) + '};'
    code += '\n\n'

    # get features
    code += 'void fill_arays' + symbol + '_' + str(model_number) + '( double &features[]) {\n'
    code += '   double pr[], ret[];\n'
    code += '   ArrayResize(ret, 1);\n'
    code += '   for(int i=ArraySize(Periods'+ symbol + '_' + str(model_number) + ')-1; i>=0; i--) {\n'
    code += '       CopyClose(NULL,PERIOD_H1,1,Periods' + symbol + '_' + str(model_number) + '[i],pr);\n'
    code += '       ret[0] = MathStandardDeviation(pr);\n'
    code += '       ArrayInsert(features, ret, ArraySize(features), 0, WHOLE_ARRAY); }\n'
    code += '   ArraySetAsSeries(features, true);\n'
    code += '}\n\n'

    # get features
    code += 'void fill_arays_m' + symbol + '_' + str(model_number) + '( double &features[]) {\n'
    code += '   double pr[], ret[];\n'
    code += '   ArrayResize(ret, 1);\n'
    code += '   for(int i=ArraySize(Periods_m' + symbol + '_' + str(model_number) + ')-1; i>=0; i--) {\n'
    code += '       CopyClose(NULL,PERIOD_H1,1,Periods_m' + symbol + '_' + str(model_number) + '[i],pr);\n'
    code += '       ret[0] = MathStandardDeviation(pr);\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')


在MetaTrader 5终端中设置阈值

现在,我们使用两个回归模型代替两个分类模型,因此可以设置具体的数值阈值。

图例3. 在终端中设置信号激活阈值

  • buy_threshhold(买入阈值)与sell_threshhold(卖出阈值)用于过滤主回归模型的信号。如果信号值低于该阈值,则不开仓。例如,如果预测的价格波动小于10点,那么这笔交易意义不大,因为盈利无法覆盖点差和手续费。
  • meta_threshhold(元模型阈值)基于前文所述的因果推断逻辑,对主模型信号进行过滤。该阈值用于评估预测值与未来实际价格波动之间的可能偏差。如果偏差过大,同样不会开仓。

现在,让我们在MetaTrader 5策略测试器中使用设定好的阈值测试该模型:

图例4. 使用设定的阈值测试模型

提醒一下,前向测试期从2024年初开始,模型在这段时间内表现相当稳定。这充分说明了合理设置阈值的重要性。您可以自行对阈值参数进行优化。

模型种类可以非常丰富,取决于特征类型(本文两个模型均基于标准差训练)以及模型自身参数。例如,另一组不同参数训练出的模型,即便不调整阈值,在新数据上依然表现良好。

图例5. 使用不同的训练参数训练与测试另一模型

在调整阈值后,该模型从2024年初开始呈现出稳定的收益增长。

图例6. 在终端设置阈值后测试模型

基于第二种交易标注函数并结合阈值过滤进行模型测试,得到了更为出色且精准的结果:

图例7. 基于均值交易标注函数且tol = 1e‑2测试模型

如果将训练时的tol参数从1e-2调整为1e-3,效果会更好:

图例8. 基于均值交易标注函数且tol = 1e-3测试模型


补充信息

为导出模型,我们需要安装并导入skl2onnx包:

from skl2onnx import convert_sklearn
from skl2onnx.common.data_types import FloatTensorType

还要对交易EA中运行ONNX模型的代码做出相应的修改,以正确适配新版回归模型:

vectorf y_main(1), y_meta(1);
   
OnnxRun(ExtHandle, ONNX_DEBUG_LOGS, f, y_main);
OnnxRun(ExtHandle2, ONNX_DEBUG_LOGS, f_m, y_meta);

float sig = y_main[0];
float meta_sig = y_meta[0];

新增了用于阈值调整与优化的输入变量。

input double buy_threshold = 0.00001;
input double sell_threshold = -0.00001;
input double meta_threshold = 0.001;

 现在通过#define宏定义来指定 ONNX 模型名称,从而更方便地引入不同名称的模型。

#define model ExtModel_EURUSD_H1_0
#define model_m ExtModel2_EURUSD_H1_0
#define periods PeriodsEURUSD_H1_0
#define periods_m Periods_mEURUSD_H1_0
#define fill_arrays fill_araysEURUSD_H1_0
#define fill_arrays_m fill_arays_mEURUSD_H1_0

根据阈值条件触发后生成交易信号:

if((Ask-Bid < max_spread*_Point) && MathAbs(meta_sig) < meta_threshold &&
      AllowTrade(OrderMagic))
      if(countOrders(OrderMagic) < max_orders &&
         CheckMoneyForTrade(_Symbol, LotsOptimized(), ORDER_TYPE_BUY))
        {
         double l = LotsOptimized();
         if(sig > buy_threshold && Allow_Buy)
           {
            int res = -1;
            do
              {
               double stop = Bid - stoploss * _Point;
               double take = Ask + takeprofit * _Point;
               res = mytrade.PositionOpen(_Symbol, ORDER_TYPE_BUY, l, Ask, stop, take, bot_comment);
               Sleep(50);
              }
            while(res == -1);
           }
         else
           {
            if(sig < sell_threshold && Allow_Sell)
              {
               int res = -1;
               do
                 {
                  double stop = Ask + stoploss * _Point;
                  double take = Bid - takeprofit * _Point;
                  res = mytrade.PositionOpen(_Symbol, ORDER_TYPE_SELL, l, Bid, stop, take, bot_comment);
                  Sleep(50);
                 }
               while(res == -1);
              }
           }
        }

将修改后的策略测试器与模型导出函数添加至对应模块,并随本文一并附上。


结论

本文介绍了一种基于回归模型构建交易系统的可行但并非唯一的思路。该方法能够对机器学习交易机器人进行更精细化的调优,还可以通过设置阈值,将明显亏损的模型优化为盈利模型。您可以使用任意特征组合与交易标注函数,并在其他交易品种和时间周期上测试该算法。

Python files.zip压缩包内包含以下适用于Python环境开发的文件:

文件名 描述
causal regression.py 
训练模型的主脚本
labeling_lib.py
更新后的交易标注模块
tester_lib.py
基于机器学习的更新版自定义策略测试器
export_lib.py 将模型导出至交易终端的模块
EURUSD_H1.csv
从MetaTrader 5中导出的报价数据

MQL5 files.zip压缩包内含适用于MetaTrader 5终端的相关文件:

文件名 描述
regression trader.ex5
来自本文的编译后的EA
regression trader.mq5
来自本文的EA源代码
Include//Trend following folder
ONNX模型以及用于连接EA的头文件

本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/18603

附加的文件 |
MQL5_files.zip (356.92 KB)
Python_files.zip (1092.44 KB)
最近评论 | 前往讨论 (15)
[删除] | 8 8月 2025 在 08:50
Guanlin Qi #:

我使用 causal_regression_orig.py 生成 ea 头文件,然后编译 ea。

结果如下图所示:test_result。

交易次数比你发布的要少得多。

那么这两者之间有什么区别呢?

Python Tester 里的交易量大吗?如果大,那就需要重新调整 MQL5 程序中的开仓阈值,也许这些阈值设得太高了。
Guanlin Qi
Guanlin Qi | 14 8月 2025 在 07:00
Maxim Dmitrievsky #:
Python测试器里的交易量大吗?如果是的话,就需要在MQL5程序中重新调整开仓阈值,也许这些阈值设得太大了。


这是 Python 测试器的结果,我使用的是你在这篇帖子中附带的同一段代码。我在 MT5 测试器的配置面板中检查过,buy_threshold 和 sell_threshold 分别是 0.0001 和 -0.0001,与这篇帖子中的设置一致。

我检查了代码,但不明白这两者之间有什么区别。

[删除] | 18 8月 2025 在 21:05
Guanlin Qi #:


这是Python模拟器的测试结果,我使用的代码与你在本帖中附带的代码完全相同。我在MT5模拟器的配置面板中检查过,买入阈值和卖出阈值分别是0.0001和-0.0001,与本帖中的设置一致。

我检查了代码,但不知道这两者之间有什么区别。

文章中提到的 meta-threshold 值为 0.001,这可能也是导致交易量较少的原因之一。需要提高这个阈值,建议在测试时将其设为 0.01 或 0.1。这样应该就能正常运行了。 所有阈值之后都可以进行优化,以便进行更精细的调整。
GOT.IT
GOT.IT | 12 9月 2025 在 14:37

你好,马克西姆,非常感谢你与我们分享这些内容。

有个问题。在根据新代码计算 ONNX 并将其放入 Trend Following 文件夹后,如何确认该专家顾问确实开始使用针对新代码的训练数据了? 对于其他报价符号,即使删除 ONNX_EURUSD 并放入另一个文件,专家顾问的行为依然完全相同。更换报价符号时,是否需要在 MQL5 专家顾问中进行任何修改?

[删除] | 21 9月 2025 在 10:30
GOT.IT #:

你好,马克西姆,非常感谢你与我们分享这些内容。

有个问题。在根据新代码计算 ONNX 并将其放入 Trend Following 文件夹后,如何确认该专家顾问确实开始使用针对新代码的训练数据了? 对于其他报价,即使删除 ONNX_EURUSD 并放入其他文件,专家顾问的行为也完全相同。更换报价时,是否需要在 MQL5 专家顾问中进行任何修改?

你好,需要在专家顾问的代码中加载包含新代码标识符的 .mqh 文件

#include <Trend following/EURUSD_H1 ONNX include 0.mqh>

也就是说,将选中的文件替换为另一个名称不同的文件,该文件是由 Python 脚本生成的。

交易策略 交易策略
各种交易策略的分类都是任意的,下面这种分类强调从交易的基本概念上分类。
交易所市场算法(EMA) 交易所市场算法(EMA)
本文对交易所市场算法(EMA)进行了详细分析,该算法的灵感来源于股票市场交易者的行为模式。算法模拟股票交易过程,市场中不同成功程度的参与者采用不同策略以实现利润最大化。
新手在交易中的10个基本错误 新手在交易中的10个基本错误
新手在交易中会犯的10个基本错误: 在市场刚开始时交易, 获利时不适当地仓促, 在损失的时候追加投资, 从最好的仓位开始平仓, 翻本心理, 最优越的仓位, 用永远买进的规则进行交易, 在第一天就平掉获利的仓位,当发出建一个相反的仓位警示时平仓, 犹豫。
MQL5中的价格时间缺口分析(第二部分):构建时间维度下的流动性分布热力图 MQL5中的价格时间缺口分析(第二部分):构建时间维度下的流动性分布热力图
这是一份详细的MetaTrader 5热力图指标构建教程,能够可视化展示价格随时间的分布状态。本文阐述了时间密度分析的数学原理,将各个价格区间以色彩区分:红色代表价格停留时间最短,蓝色代表价格停留时间最长。