使用机器学习开发趋势交易策略:从数据标记看懂趋势跟踪基础(基础篇)
📈

使用机器学习开发趋势交易策略:从数据标记看懂趋势跟踪基础(基础篇)

(1/3)·多数交易者分不清趋势延续与均值回归的标注逻辑,导致模型训练从根上跑偏

新手友好 第 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。

MQL5 / C++
@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。

MQL5 / C++
@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
把 markup 从默认 0.00005 调到品种真实成本之上,再跑一遍 get_labels_trend_with_profit,对比丢弃样本占比,能直接看出这层过滤对你那套 EURUSD 或 XAUUSD 数据的严苛程度。

MQL5 / C++
@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
让小布替你跑这套标记流程
这些诊断小布盯盘的 AIGC 已内置,打开对应品种页即可看到 EURUSD 的趋势标记与波动率过滤结果,你只需专注判断机制是否匹配当前行情。

常见问题

均值回归预期价格逆转,标记围绕平均值波动;趋势跟踪要求准确识别当前趋势延续,标记依据平滑后价格方向,方向相反时排除样本。
EURUSD 处于趋势状态的频率相对更高,EURGBP 在前篇中被归为区间震荡类,出现趋势的频率较低,用作对照更直观。
可以,小布盯盘品种页已内置基于波动率过滤的趋势方向标记,无需自己写 Savitzky-Golay 滤波代码即可查看。
它平滑收盘价短期噪声,提取潜在趋势方向;本文关注滤波器方向而非价格偏差,方向为正标买入、为负标卖出、不明确则弃用。
用滚动窗口收盘价标准差算波动率,趋势除以波动率做规范化,低于阈值的弱趋势不进入训练集,避免噪音干扰。