基于机器学习的黄金单向趋势交易策略研究
概述
最近,我们一直在通过二元分类的视角研究对称交易系统的实现。我们假设买入和卖出交易可以在特征空间中被很好地分离,即存在某种分界线(超平面),使机器学习算法能够同样准确地预测多头和空头仓位。
然而,实际情况并非总是如此,尤其对于一些金属、指数以及加密货币等趋势性交易品种而言。当某种资产呈现明确的单向趋势时,双向交易系统可能面临过高风险。此外,此类交易的总体分布可能高度不对称,导致大量误分类。
此时,双向系统效率堪忧,不如专注于单向交易。本文旨在阐明利用机器学习创建此类单向策略的特性。
我认为,需针对单向交易任务对因果推断方法论进行系统性重构与适配。
我们以既往文章内容作为研究基础展开探讨:
建议阅读以下文章以全面理解因果推断理念与回测方法。
构建单向交易信号采样器
由于原有系统同时标记买卖信号,现需修改交易标记逻辑,使其仅针对选定方向生成交易信号。以下为本文中将使用的采样器实现示例:
@njit def calculate_labels_one_direction(close_data, markup, min, max, direction): labels = [] for i in range(len(close_data) - max): rand = random.randint(min, max) curr_pr = close_data[i] future_pr = close_data[i + rand] if direction == "sell": if (future_pr + markup) < curr_pr: labels.append(1.0) else: labels.append(0.0) if direction == "buy": if (future_pr - markup) > curr_pr: labels.append(1.0) else: labels.append(0.0) return labels def get_labels_one_direction(dataset, markup, min = 1, max = 15, direction = 'buy') -> pd.DataFrame: close_data = dataset['close'].values labels = calculate_labels_one_direction(close_data, markup, min, max, direction) dataset = dataset.iloc[:len(labels)].copy() dataset['labels'] = labels dataset = dataset.dropna() return dataset
新增参数“direction”可指定买入或卖出信号标记方向。现在,类别标签“1”表示当前存在符合选定方向的交易机会(做多或做空),类别标签“0”表示当前时段不宜开仓。参数同时定义了随机交易时长范围{min, max}。时长以开仓后经过的K线数量计量。经验证该简易采样器对此类策略相当有效。
自定义策略回测系统改造
单向交易策略需要全新的回测逻辑。因此,需对回测系统核心模块进行适配性改造。我们在tester_lib.py模块中新增了以下单向测试专用函数。下面对其进行解析。
单向数据处理函数process_data_one_direction()负责处理单向交易所需的数据流:
@jit(nopython=True) def process_data_one_direction(close, labels, metalabels, stop, take, markup, forward, backward, direction): last_deal = 2 last_price = 0.0 report = [0.0] chart = [0.0] line_f = 0 line_b = 0 for i in range(len(close)): line_f = len(report) if i <= forward else line_f line_b = len(report) if i <= backward else line_b pred = labels[i] pr = close[i] pred_meta = metalabels[i] # 1 = allow trades if last_deal == 2 and pred_meta == 1: last_price = pr last_deal = 2 if pred < 0.5 else 1 continue if last_deal == 1 and direction == 'buy': if (-markup + (pr - last_price) >= take) or (-markup + (last_price - pr) >= stop): last_deal = 2 profit = -markup + (pr - last_price) report.append(report[-1] + profit) chart.append(chart[-1] + profit) continue if last_deal == 1 and direction == 'sell': if (-markup + (pr - last_price) >= stop) or (-markup + (last_price - pr) >= take): last_deal = 2 profit = -markup + (last_price - pr) report.append(report[-1] + profit) chart.append(chart[-1] + (pr - last_price)) continue # close deals by signals if last_deal == 1 and pred < 0.5 and direction == 'buy': last_deal = 2 profit = -markup + (pr - last_price) report.append(report[-1] + profit) chart.append(chart[-1] + profit) continue if last_deal == 1 and pred < 0.5 and direction == 'sell': last_deal = 2 profit = -markup + (last_price - pr) report.append(report[-1] + profit) chart.append(chart[-1] + (pr - last_price)) continue return np.array(report), np.array(chart), line_f, line_b
其与基础函数的核心差异在于,通过显式标志指明了回测所用的交易类型 —— 做多或做空。该函数借助Numba实现极致加速,能够快速遍历历史行情数据,并逐笔计算每笔交易的盈亏。需要注意的是,回测系统支持模型信号触发和止损/止盈触发两种平仓方式,系统会优先执行先触发的条件。这样,您便能在训练阶段就根据可接受的风险水平来筛选模型。
tester_one_direction()函数处理一个包含价格和标签的标记数据集,并将这些数据传递给process_data_one_direction()函数,最终返回结果用于可视化图表渲染。
def tester_one_direction(*args): ''' This is a fast strategy tester based on numba List of parameters: dataset: must contain first column as 'close' and last columns with "labels" and "meta_labels" stop: stop loss value take: take profit value forward: forward time interval backward: backward time interval markup: markup value direction: buy/sell plot: false/true ''' dataset, stop, take, forward, backward, markup, direction, plot = args forw = dataset.index.get_indexer([forward], method='nearest')[0] backw = dataset.index.get_indexer([backward], method='nearest')[0] close = dataset['close'].to_numpy() labels = dataset['labels'].to_numpy() metalabels = dataset['meta_labels'].to_numpy() report, chart, line_f, line_b = process_data_one_direction(close, labels, metalabels, stop, take, markup, forw, backw, direction) y = report.reshape(-1, 1) X = np.arange(len(report)).reshape(-1, 1) lr = LinearRegression() lr.fit(X, y) l = 1 if lr.coef_[0][0] >= 0 else -1 if plot: plt.plot(report) plt.axvline(x=line_f, color='purple', ls=':', lw=1, label='OOS') plt.axvline(x=line_b, color='red', ls=':', lw=1, label='OOS2') plt.plot(lr.predict(X)) plt.title("Strategy performance R^2 " + str(format(lr.score(X, y) * l, ".2f"))) plt.xlabel("the number of trades") plt.ylabel("cumulative profit in pips") plt.show() return lr.score(X, y) * l
test_model_one_direction()函数通过选定数据获取模型的预测结果。该函数将预测结果传递给tester_one_direction()函数,完成对模型回测结果的打印输出。
def test_model_one_direction(dataset: pd.DataFrame, result: list, stop: float, take: float, forward: float, backward: float, markup: float, direction: str, plt = False): ext_dataset = dataset.copy() X = ext_dataset[ext_dataset.columns[1:]] ext_dataset['labels'] = result[0].predict_proba(X)[:,1] ext_dataset['meta_labels'] = result[1].predict_proba(X)[:,1] ext_dataset['labels'] = ext_dataset['labels'].apply(lambda x: 0.0 if x < 0.5 else 1.0) ext_dataset['meta_labels'] = ext_dataset['meta_labels'].apply(lambda x: 0.0 if x < 0.5 else 1.0) return tester_one_direction(ext_dataset, stop, take, forward, backward, markup, direction, plt)
元学习器:交易系统的核心引擎
让我们以交叉验证一文中的首个元学习器为例,从逻辑层面解析其为何在单向交易策略中的表现显著优于双向策略。
def meta_learner(folds_number: int, iter: int, depth: int, l_rate: float) -> pd.DataFrame: dataset = get_labels_one_direction(get_features(get_prices()), markup=hyper_params['markup'], min=1, max=15, direction=hyper_params['direction']) data = dataset[(dataset.index < hyper_params['forward']) & (dataset.index > hyper_params['backward'])].copy() X = data[data.columns[1:-2]] y = data['labels'] B_S_B = pd.DatetimeIndex([]) # learn meta model with CV method meta_model = CatBoostClassifier(iterations = iter, max_depth = depth, learning_rate=l_rate, verbose = False) cv = StratifiedKFold(n_splits=folds_number, shuffle=False) predicted = cross_val_predict(meta_model, X, y, method='predict_proba', cv=cv) coreset = X.copy() coreset['labels'] = y coreset['labels_pred'] = [x[0] < 0.5 for x in predicted] coreset['labels_pred'] = coreset['labels_pred'].apply(lambda x: 0 if x < 0.5 else 1) # select bad samples (bad labels indices) diff_negatives = coreset['labels'] != coreset['labels_pred'] B_S_B = B_S_B.append(diff_negatives[diff_negatives == True].index) to_mark = B_S_B.value_counts() marked_idx = to_mark.index data['meta_labels'] = 1.0 data.loc[data.index.isin(marked_idx), 'meta_labels'] = 0.0 data.loc[data.index.isin(marked_idx), 'labels'] = 0.0 return data[data.columns[:]]
为清晰呈现,元学习器函数架构图如下,以负责不同功能的关键模块形式呈现。

图例1. 元学习器meta_learner()函数架构示意图
需要明确的是,元学习器是衔接原始数据与最终模型的"传动齿轮"。通过元模型生成元标签,承担高效数据预处理的核心任务。本函数独特性在于可生成经过清理且结构完整的数据集,为构建稳健模型与交易系统奠定基础。
函数输入参数:
- l_rate(学习率)是元模型或CatBoost分类器的梯度步长。推荐的设置范围为 {0.01, 0.5}。算法对该参数较为敏感。
- depth参数决定了CatBoost分类器每次迭代构建的决策树深度。推荐的取值范围为{1, 6}。
- iter是训练迭代次数,推荐范围为{5, 25}。
- folds_number是交叉验证的折数。通常来说,{5, 15}折已经足够。
函数运行机制:
- 创建包含特征和标签的数据集。使用前述的get_labels_one_direction()函数作为采样器。
- 在此数据上使用StratifiedKFold()函数以交叉验证模式训练具有指定参数的元学习器。该函数会按照类别平衡原则,将训练数据划分为若干折。在每折上训练元模型,然后保存所有预测结果。这是为了在训练数据上获得模型误差的无偏估计所必需的。这些预测将用于与原始标签进行比较。

图例2. 通过StratifiedKFold()函数将数据划分为折的方案
- 创建一个独立的核心集数据集,通过交叉验证记录原始标签和预测标签。
- 创建一个单独的变量diff_negatives,用于存储原始标签与预测标签是否匹配的二进制标识。
- 创建一份不良样本清单B_S_B。用于记录数据集中预测标签与原始标签不匹配样本的时间索引。
- 确定所有折中错误预测示例的唯一索引。
- 在原始数据集中新增“meta_labels”列,并将所有观测值指定为“1”(可以交易)。
- 对于交叉验证过程中的所有预测错误样本,在“meta_labels”列中指定为'0'(不可交易)。
- 对于所有错误预测的样本,在包含主模型标签的“labels”列中,也指定为'0'(不可交易)。
- 该函数返回修改后的数据集。
更可靠的因果推断
在前一章节中,我们讨论了基本的元学习器,其示例有助于解释基本概念。在本章节中,我们将适当提升方法复杂度,直接聚焦于因果推断过程本身展开分析。我已对链接文章中的因果推断函数进行了适配调整,使其适用于单向交易场景。这样一来,技术更先进,功能灵活度更高。
def meta_learners(models_number: int, iterations: int, depth: int, bad_samples_fraction: float): dataset = get_labels_one_direction(get_features(get_prices()), markup=hyper_params['markup'], min=1, max=15, direction=hyper_params['direction']) data = dataset[(dataset.index < hyper_params['forward']) & (dataset.index > hyper_params['backward'])].copy() X = data[data.columns[1:-2]] y = data['labels'] BAD_WAIT = pd.DatetimeIndex([]) BAD_TRADE = pd.DatetimeIndex([]) 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) # learn debias model with train and validation subsets meta_m = CatBoostClassifier(iterations = iterations, depth = depth, custom_loss = ['Accuracy'], eval_metric = 'Accuracy', 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_proba(X)[:, 1] coreset['labels_pred'] = coreset['labels_pred'].apply(lambda x: 0 if x < 0.5 else 1) # add bad samples of this iteration (bad labels indices) coreset_w = coreset[coreset['labels']==0] coreset_t = coreset[coreset['labels']==1] diff_negatives_w = coreset_w['labels'] != coreset_w['labels_pred'] diff_negatives_t = coreset_t['labels'] != coreset_t['labels_pred'] BAD_WAIT = BAD_WAIT.append(diff_negatives_w[diff_negatives_w == True].index) BAD_TRADE = BAD_TRADE.append(diff_negatives_t[diff_negatives_t == True].index) to_mark_w = BAD_WAIT.value_counts() to_mark_t = BAD_TRADE.value_counts() marked_idx_w = to_mark_w[to_mark_w > to_mark_w.mean() * bad_samples_fraction].index marked_idx_t = to_mark_t[to_mark_t > to_mark_t.mean() * bad_samples_fraction].index data['meta_labels'] = 1.0 data.loc[data.index.isin(marked_idx_w), 'meta_labels'] = 0.0 data.loc[data.index.isin(marked_idx_t), 'meta_labels'] = 0.0 data.loc[data.index.isin(marked_idx_t), 'labels'] = 0.0 return data[data.columns[:]]
以下是元学习器函数示意图,以负责各项功能的关键模块形式呈现:

图例3. 元学习器meta_learner()函数架构示意图
该函数的显著特点是包含多个元模型“头”,这些头通过有放回的自助采样法,从原始数据集中随机抽取子样本进行训练。此方法能够对源数据集进行多次子采样生成。可以基于伪样本集估计统计和概率特征。
例如,我们感兴趣的是:如果从总体中抽取特定样本,并在该总体的不同子样本上训练模型,该样本被预测的平均效果如何。这样将使我们对每个聚合样本获得偏差更小的评估,从而能够以更高的置信度将其标记为良好或不良样本。
函数输入参数:
- models_number是算法中使用的元模型数量。推荐的取值范围为{5, 100}。
- iterations是每个模型的训练迭代次数。推荐的取值范围为{15, 35}。
- depth是CatBoost分类器训练每次迭代所构建决策树的深度。推荐的取值范围为{1, 6}。
- bad_samples_fraction是将被标记为"不良"的样本数量。推荐的取值范围为{0.4, 0.9}。
函数运行机制:
- 创建基于给定交易方向特征和标签的数据集。
- 在循环中,使用bootstrap方法在随机选择的训练和验证数据上训练每个元模型。

图例 4. 自助采样法架构示意图
- 对于每个元模型,将其预测结果与真实标签对比。将分类错误的样本索引存入"不良样本簿"。
- 计算所有轮次中错误预测索引的平均出现次数。
- 筛选出错误次数超过(平均值×阈值)的索引,在"meta_labels"列中将高频错误索引标记为“0”(禁止交易)。
- 按相同逻辑将“labels”设置为0。
将元学习器用于单向交易的理念:
- 元学习器代码解决了在标记数据集中过滤"不良"信号的问题。
- 简化分类任务。特征仅针对一种类型的模式进行优化(例如趋势延续)。
- 即使类别平衡(50% 买入/50% 卖出),趋势中仍存在不对称性。趋势性买入具有更稳定的模式,而逆势卖出通常与难以预测的噪声事件(回调、假反转)相关。
- 对于上涨趋势中的卖出,大多数信号最初就是错误的(假阳性),这样会导致分类错误。因此,在双向交易的情况下,买入和卖出交易的性质不同,无法进行比较。
- 在单向系统中,错误仅与错误进入趋势交易相关(例如在回调前入场)。元学习器能有效发现此类情况,因为它们具有明显的特征(例如超买)。在双向交易系统中,错误类型包含假买入和假卖出,这两类错误可能呈现相似的模式特征。元学习器无法可靠地将"噪声"与真实信号分离,因为买入/卖出的特征混杂在一起。
- 在单向系统中,模型专注于一种类型的非平稳性(例如趋势强化),特征适应当前市场阶段。
- 而在双向系统中,存在两种非平稳性(趋势+回调),这需要两倍的数据量,且更难泛化。
结果:
- 分类任务简化 —— 只需正确预测一个类别而非两个。
- 噪声降低 —— 最终的元模型仅过滤一种类型的错误。
- 交叉验证改善 —— 因为分层功能正确运作。
- 趋势非平稳性考量 —— 模型仅适应一个市场阶段。
元学习器的重要特征:
元学习器不应与最终的元模型混淆,后者是基于元学习器生成的标签进行训练的。元学习器并非最终模型,仅用于预处理阶段。任何二元分类算法都可以作为基础学习器,包括逻辑回归、神经网络、“基于树”的模型以及其他特殊模型。此外,通过略微修改代码也可以使用回归模型,但这超出了本文的范围,可在后续文章中探讨。为了方便,我选用了熟悉的CatBoost二元分类器。
与学习属性相关的特征
前文中已经提及,单向交易简化了分类任务。特征仅针对一种类型的模式(例如买入)进行优化,因此不需要对称特征。对称特征可指两类数据:一类是带有不同时间滞后的原始时间序列(其数值分别对应买入和卖出交易),另一类是各类衍生指标(如价格增量)及其他振荡指标。非对称特征包括不同时间窗口的波动率,因为其仅反映价格变化的速度,而非方向。本项目将使用在“periods”变量中设置的不同窗口的波动率。
实现特征构建的完整代码如下:
def get_features(data: pd.DataFrame) -> pd.DataFrame: pFixed = data.copy() pFixedC = data.copy() count = 0 for i in hyper_params['periods']: pFixed[str(count)] = pFixedC.rolling(i).std() count += 1 return pFixed.dropna()
在配置字典中指定用于计算此类特征的滑动窗口周期参数。用户可任意调整这些参数并观察模型性能的变化。
hyper_params = {
'periods': [i for i in range(5, 300, 30)],
}
在该示例中,我们生成10个特征,首个特征的周期设为5,后续每个特征的周期依次递增30,直至最后一个特征的周期达到300:
>>> [i for i in range(5, 300, 30)] [5, 35, 65, 95, 125, 155, 185, 215, 245, 275]
单向模型的训练与测试:
hyper_params = {
'symbol': 'XAUUSD_H1',
'export_path': '/drive_c/Program Files/MetaTrader 5/MQL5/Include/Mean reversion/',
'model_number': 0,
'markup': 0.25,
'stop_loss': 10.0000,
'take_profit': 5.0000,
'direction': 'buy',
'periods': [i for i in range(5, 300, 30)],
'backward': datetime(2020, 1, 1),
'forward': datetime(2024, 1, 1),
'full forward': datetime(2026, 1, 1),
}
models = []
for i in range(10):
print('Learn ' + str(i) + ' model')
models.append(fit_final_models(meta_learner(5, 15, 3, 0.1)))
# models.append(fit_final_models(meta_learners(25, 15, 3, 0.8))) 首先,我们将测试第一个元学习器,第二个元学习器代码将被注释掉。由于当前黄金处于上涨趋势,选择买入方向作为优先交易方向。训练数据范围为2020年初至2024年。测试数据范围为2024年初至2025年4月1日。
元学习器配置如下:
- 交叉验证折数:5折
- CatBoost算法训练迭代次数:15次
- 每次迭代树深度:3
- 学习率:0.1
以下为策略测试器中针对“买入”方向的最优模型表现:

图例 5. 元学习器meta_learner()函数在买入方向的特征测试结果
作为对比,以下为“卖出”方向的最优模型表现:

图例 6. 元学习器meta_learner()函数在卖出方向的特征测试结果
该算法已具备双向交易预测能力,可以同时预测买入和卖出信号。但在上升趋势市场中,卖出交易数量较少,符合市场规律。即使未进行超参数调优,元模型在训练集和前瞻测试中均表现优异。
现在,将探讨更高级的元学习器实现。对meta_learners()函数执行类似操作。让我们取消注释该函数,同时注释掉前一个版本。
models = [] for i in range(10): print('Learn ' + str(i) + ' model') # models.append(fit_final_models(meta_learner(5, 15, 3, 0.1))) models.append(fit_final_models(meta_learners(25, 15, 3, 0.8)))
元学习器配置如下:
- 25个元模型(或称为"头部"模型)
- 每个元模型均采用CatBoost算法进行15次训练迭代
- 每次迭代树深度:3
- bad_samples_fraction参数值设为0.8,该参数用于控制训练集中保留的不良样本比例。0.1表示强过滤,仅保留极少量不良样本;0.9表示弱过滤,保留大部分不良样本。
以下为策略测试器中针对“买入”方向的最优模型表现:

图例 7. 元学习器the meta_learners()函数在买入方向的特征测试结果
作为对比,以下为“卖出”方向的最优模型表现:

图例 8. 元学习器meta_learner()函数在卖出方向的特征测试结果
更高级的元学习器函数展现出更平滑的收益曲线。而买卖交易平衡性得到保留。显然,最优策略为买入策略。完成模型训练并筛选最优模型后,可将其导出至MetaTrader 5终端。虽然您可以同时使用两种策略,但鉴于做空策略效果较差,最终仅选择做多策略。
下表为高级元学习器参数对学习效果的影响分析:
| 参数名称: | 对过滤质量的影响: |
|---|---|
| models_number:整型 | 元模型("头部")数量越多,对不良样本的评估偏差越小。 训练集规模影响模型数量选择。历史数据越长,所需模型数量可能越多。 建议尝试的参数范围为5至100之间。 |
| iterations:整型 | 显著影响过滤效率。每个元学习器的训练迭代次数越多,过滤速度越慢。 参数值过小可能导致训练不足,进而引发过度过滤,最终输出交易数量减少。 建议将该参数范围设置在5至50之间。 |
| depth:整型 | 树深度取值范围为1至6。每次迭代时,算法会构建指定深度的决策树。默认使用深度3,但您可以尝试对该参数进行调整。 |
| bad_samples_fraction:浮点型 | 该参数影响不良样本的最终筛选结果及交易数量。0.9对应“宽松”过滤,产生的交易较多。0.5对应“严格”过滤,产生的交易较少。 |
收尾工作:模型导出与EA创建
模型导出与其他文章中使用的方式完全一致。从排序后的模型列表中选择目标模型,调用导出函数即可完成。
models.sort(key=lambda x: x[0]) data = get_features(get_prices()) test_model_one_direction(data, models[-1][1:], hyper_params['stop_loss'], hyper_params['take_profit'], hyper_params['forward'], hyper_params['backward'], hyper_params['markup'], hyper_params['direction'], plt=True) export_model_to_ONNX(models[-1], 0)
交易EA的逻辑已调整为:仅根据导出模型的方向执行单向交易。
input bool direction = true; //True = Buy, False = Sell

图例9. 在MetaTrader 5终端中测试EA

图例10. 仅针对2024年初以来的前瞻时段进行测试
结论
基于机器学习的单向交易策略理念令我颇感兴趣,因此我开展了一系列实验,包括本文提出的实现方法。该方法虽非唯一可行方案,但能较好地完成既定任务。此类趋势依赖型策略在趋势延续时表现优异。因此实时监测全球趋势变化并根据当前市场环境调整系统参数至关重要。
Python开发文件压缩包Python files.zip包含以下文件:
| 文件名 | 描述 |
|---|---|
| causal one direction.py | 学习模型的主脚本 |
| labeling_lib.py | 带交易标记的模块(更新版) |
| tester_lib.py | 基于机器学习策略的定制化回测模块(更新版) |
| XAUUSD_H1.csv | 从MetaTrader 5终端导出的行情数据文件 |
MQL5 files.zip archive contains files for MetaTrader 5:
| 文件名 | 描述 |
|---|---|
| one direction.ex5 | 本文中编译后的EA |
| one direction.mq5 | 本文中EA的源代码 |
| folder Include//Trend following | ONNX模型文件及连接EA头文件的存放位置 |
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/17654
注意: MetaQuotes Ltd.将保留所有关于这些材料的权利。全部或部分复制或者转载这些材料将被禁止。
本文由网站的一位用户撰写,反映了他们的个人观点。MetaQuotes Ltd 不对所提供信息的准确性负责,也不对因使用所述解决方案、策略或建议而产生的任何后果负责。
神经Boid优化算法2(NOA2)
新手在交易中的10个基本错误
市场模拟(第 11 部分):套接字(五)