使用机器学习开发趋势交易策略:从数据标记看懂趋势跟踪基础(基础篇)
(1/3)·多数交易者分不清趋势延续与均值回归的标注逻辑,导致模型训练从根上跑偏
在 MT5 里用机器学习做趋势策略的起点
MetaTrader 5 从 build 2085 起原生支持 Python 调用与 ONNX 模型推理,这让在终端内直接跑轻量机器学习模型成为可能,而不必把行情导出到外部再做离线训练。 对趋势交易来说,最容易被验证的切入点是用历史 H1 收盘价训练一个二分类模型,输出「下一根 K 线收阳概率」,再结合价格结构过滤假信号。 外汇与贵金属杠杆高、滑点随机,任何模型在样本外都只具备概率优势,实盘前必须用 MT5 策略测试器做至少 12 个月 tick 级回测。 开 MT5 按 F4 打开 MetaEditor,新建 Python 脚本调用本地训练好的 .onnx,是第一步可落地的动作。
「从均值回归切到趋势跟踪」
均值回归策略在系列前作里已经跑通过一套标注与训练流程,但那套逻辑只解决价格回到中枢的问题。本文换一个角度,用数据聚类去识别市场处在趋势态还是震荡态,再据此构造趋势跟踪的标注样本。 两种策略共享「先聚类、后标注」的骨架,差异集中在标签怎么打:回归类标的是偏离回收,趋势类标的是方向延续与突破。建议先翻完前一篇再回来读这篇,否则训练集构造的上下文会断。 外汇与贵金属杠杆高、滑点跳空频繁,聚类识别出的趋势态在任何模型下都只是概率倾向,实盘前务必在 MT5 用历史数据复算一遍样本分布。
◍ 用方向滤波给趋势单打标签
趋势跟踪和均值回归是两套相反逻辑:前者赌当前趋势延续,后者赌价格回到均值附近发生逆转。做趋势标记时,先判明趋势方向比算偏离度更关键。 以 EURUSD 为例,它常被归为趋势型货币对,而前篇里的 EURGBP 偏区间震荡——这种分类只看哪种状态出现得更频繁,不代表某段行情里不会切换。外汇和贵金属都是高杠杆高风险品种,分类只是概率倾向,不能当铁律。 图 1 的标记思路是拿 Savitzky-Golay 滤波器(前篇已拆过)的输出方向当趋势指标:方向为正标买入,为负标卖出,方向模糊的样本直接踢出训练集。标签函数里还嵌了基于波动性的趋势强度阈值,把不重要的弱趋势过滤掉,避免噪声被当成信号。
用波动率归一化给趋势打标签
给趋势类策略做样本标注,核心不是看价格涨了没,而是看‘涨得相对波动够不够明显’。这套思路先把收盘价做平滑,再算梯度、算波动、最后归一化,阈值卡住噪声。 具体流程:Savitzky-Golay 滤波器对 close 列去噪,窗口默认 200、多项式阶数 3;梯度用 np.gradient 取平滑价的一阶变化,正为向上、负为向下。波动率取 50 根 K 线收盘价的滚动标准差,把梯度除以波动率得到 normalized_trend,波动大时同样的价格变动会被‘压小’。 阈值经验区间在 0.01–0.5,默认取 0.5 偏保守,只标强趋势。归一化趋势大于阈值标 0(多)、小于负阈值标 1(空)、中间标 2(无信号)。外汇与贵金属杠杆高、跳空频繁,归一化也救不了极端行情,信号仅作概率参考。 下面这段是可跑的标注函数,注意它用 @njit 加速循环,MT5 虽不直接跑 Python,但逻辑可平移成 MQL5 的 iStdDev + 自定义平滑。逐行看:calculate_labels_trend 按阈值把数组映射成 0/1/2;get_labels_trend 串起平滑、梯度、波动、归一、打标,并 dropna 清掉除零产生的 NaN。
@njit def calculate_labels_trend(normalized_trend, threshold): labels = np.empty(len(normalized_trend), dtype=np.float64) for i in range(len(normalized_trend)): if normalized_trend[i] > threshold: labels[i] = class="num">0.0 # Buy(Up trend) elif normalized_trend[i] < -threshold: labels[i] = class="num">1.0 # Sell(Down trend) else: labels[i] = class="num">2.0 # No signal class="kw">return labels def get_labels_trend(dataset, rolling=class="num">200, polyorder=class="num">3, threshold=class="num">0.5, vol_window=class="num">50) -> pd.DataFrame: smoothed_prices = savgol_filter(dataset[&class="macro">#x27;close&class="macro">#x27;].values, window_length=rolling, polyorder=polyorder) trend = np.gradient(smoothed_prices) vol = dataset[&class="macro">#x27;close&class="macro">#x27;].rolling(vol_window).std().values normalized_trend = np.where(vol != class="num">0, trend / vol, np.nan) # Set NaN where vol is class="num">0 labels = calculate_labels_trend(normalized_trend, threshold) dataset = dataset.iloc[:len(labels)].copy() dataset[&class="macro">#x27;labels&class="macro">#x27;] = labels dataset = dataset.dropna() # Remove rows with NaN class="kw">return dataset smoothed_prices = savgol_filter(dataset[&class="macro">#x27;close&class="macro">#x27;].values, window_length=rolling, polyorder=polyorder) trend = np.gradient(smoothed_prices) vol = dataset[&class="macro">#x27;close&class="macro">#x27;].rolling(vol_window).std().values normalized_trend = np.where(vol != class="num">0, trend / vol, np.nan) labels = calculate_labels_trend(normalized_trend, threshold)
「只留盈利样本的标记取舍」
基础的趋势标记法天然夹带亏损单,它们常出现在反转前最后一段,属真实信号但也会错。胜率终究要看盈利对亏损的比例,但训练集里硬塞亏损样本,容易把公平性曲线和回测路径搅毛。 一种取巧做法是只把盈利交易写进标签,亏损与无信号统一标成 2.0 丢弃。这样训练数据更平滑,测试集的权益曲线也可能更少毛刺。代价是模型没见过亏损分布,泛化到实盘外汇或贵金属这种高波动品种时,要随时警惕样本偏差带来的滑点吞噬。 相对基础版,这里多了四个参数:min_l 与 max_l 限定未来观察柱形的最小、最大数量,两者设成相等就是固定持有长度;markup 是点差+佣金+滑点甚至预缴的合计阈值,值越高,达到盈利的样本越少。代码里 rand 在 [min_l, max_l] 随机抽一根未来柱,close[i+rand] 越过 close[i]±markup 才记 0.0/1.0,否则 2.0。
@njit def calculate_labels_trend_with_profit(close, normalized_trend, threshold, markup, min_l, max_l): labels = np.empty(len(normalized_trend) - max_l, dtype=np.float64) for i in range(len(normalized_trend) - max_l): if normalized_trend[i] > threshold: # Проверяем условие для Buy rand = random.randint(min_l, max_l) future_pr = close[i + rand] if future_pr >= close[i] + markup: labels[i] = class="num">0.0 # Buy(Profit reached) else: labels[i] = class="num">2.0 # No profit elif normalized_trend[i] < -threshold: # Проверяем условие для Sell rand = random.randint(min_l, max_l) future_pr = close[i + rand] if future_pr <= close[i] - markup: labels[i] = class="num">1.0 # Sell(Profit reached) else: labels[i] = class="num">2.0 # No profit else: labels[i] = class="num">2.0 # No signal class="kw">return labels def get_labels_trend_with_profit(dataset, rolling=class="num">200, polyorder=class="num">3, threshold=class="num">0.5, vol_window=class="num">50, markup=class="num">0.00005, min_l=class="num">1, max_l=class="num">15) -> pd.DataFrame: # Smoothing and trend calculation smoothed_prices = savgol_filter(dataset[&class="macro">#x27;close&class="macro">#x27;].values, window_length=rolling, polyorder=polyorder) trend = np.gradient(smoothed_prices) # Normalizing the trend by volatility vol = dataset[&class="macro">#x27;close&class="macro">#x27;].rolling(vol_window).std().values normalized_trend = np.where(vol != class="num">0, trend / vol, np.nan) # Removing NaN and synchronizing data valid_mask = ~np.isnan(normalized_trend) normalized_trend_clean = normalized_trend[valid_mask] close_clean = dataset[&class="macro">#x27;close&class="macro">#x27;].values[valid_mask] dataset_clean = dataset[valid_mask].copy() # Generating labels labels = calculate_labels_trend_with_profit(close_clean, normalized_trend_clean, threshold, markup, min_l, max_l) # Trimming the dataset and adding labels dataset_clean = dataset_clean.iloc[:len(labels)].copy() dataset_clean[&class="macro">#x27;labels&class="macro">#x27;] = labels # Filtering the results dataset_clean = dataset_clean.dropna() class="kw">return dataset_clean
@njit def calculate_labels_trend_with_profit(close, normalized_trend, threshold, markup, min_l, max_l): labels = np.empty(len(normalized_trend) - max_l, dtype=np.float64) for i in range(len(normalized_trend) - max_l): if normalized_trend[i] > threshold: # Проверяем условие для Buy rand = random.randint(min_l, max_l) future_pr = close[i + rand] if future_pr >= close[i] + markup: labels[i] = class="num">0.0 # Buy(Profit reached) else: labels[i] = class="num">2.0 # No profit elif normalized_trend[i] < -threshold: # Проверяем условие для Sell rand = random.randint(min_l, max_l) future_pr = close[i + rand] if future_pr <= close[i] - markup: labels[i] = class="num">1.0 # Sell(Profit reached) else: labels[i] = class="num">2.0 # No profit else: labels[i] = class="num">2.0 # No signal class="kw">return labels def get_labels_trend_with_profit(dataset, rolling=class="num">200, polyorder=class="num">3, threshold=class="num">0.5, vol_window=class="num">50, markup=class="num">0.00005, min_l=class="num">1, max_l=class="num">15) -> pd.DataFrame: # Smoothing and trend calculation smoothed_prices = savgol_filter(dataset[&class="macro">#x27;close&class="macro">#x27;].values, window_length=rolling, polyorder=polyorder) trend = np.gradient(smoothed_prices) # Normalizing the trend by volatility vol = dataset[&class="macro">#x27;close&class="macro">#x27;].rolling(vol_window).std().values normalized_trend = np.where(vol != class="num">0, trend / vol, np.nan) # Removing NaN and synchronizing data valid_mask = ~np.isnan(normalized_trend) normalized_trend_clean = normalized_trend[valid_mask] close_clean = dataset[&class="macro">#x27;close&class="macro">#x27;].values[valid_mask] dataset_clean = dataset[valid_mask].copy() # Generating labels labels = calculate_labels_trend_with_profit(close_clean, normalized_trend_clean, threshold, markup, min_l, max_l) # Trimming the dataset and adding labels dataset_clean = dataset_clean.iloc[:len(labels)].copy() dataset_clean[&class="macro">#x27;labels&class="macro">#x27;] = labels # Filtering the results dataset_clean = dataset_clean.dropna() class="kw">return dataset_clean