基于机器学习构建均值回归策略·进阶篇
🤖

基于机器学习构建均值回归策略·进阶篇

(2/3)· 随机标注让八成模型倒在样本外,本篇用滤波分位数带把均值回归标签钉死在EURGBP平稳走势上

含代码示例 第 2/3 篇
很多交易者把聚类模型直接喂随机标注的标签,以为多跑几轮暴力训练就能出圣杯,结果样本外一测就崩。均值回归策略最怕标签本身没意义,模型逼近的只是噪声。EURGBP这类平稳货币对若还用随机抽样,等于主动扔掉它最好的结构性机会。

◍ 多周期滤波下的反转区信号生成逻辑

这套信号函数的核心,是用 Savitzky-Golay 滤波器对收盘价做不同窗口的平滑,再算价格与平滑线的偏离度,靠滚动分位数圈出「反转区」。默认 rolling_periods 取 [200, 400, 600],相当于在短、中、长三个时间维度上同时看价格是否偏离趋势过远。 函数入参里 quantiles 默认写的是 [.45, .55],但 docstring 文字描述却说是 [.05, .95] 定义反转区——这是原文不一致的地方,实盘前你得先定死一套参数,别直接无脑跑默认。window=100 控制滚动分位数的回看长度,polyorder=3 是滤波器的多项式阶数。 标签列规则很直接:0 为买、1 为卖,标 2 的无效行直接剔除,含 NaN 的行也删。多周期同时落入反转区才给信号,所以触发频率天然偏低,外汇与贵金属波动大、滑点高,这类信号仅作概率参考,实盘前务必在 MT5 导出的数据上回测验证。

MQL5 / C++
def get_labels_multiple_filters(dataset,
                              rolling_periods=[class="num">200, class="num">400, class="num">600],
                              quantiles=[.class="num">45, .class="num">55],
                              window=class="num">100,
                              polyorder=class="num">3) -> pd.DataFrame:
    """
    Generates trading signals(buy/sell) based on price deviation from multiple
    smoothed price trends calculated using a Savitzky-Golay filter with different
    rolling periods and rolling quantiles.
    This function applies a Savitzky-Golay filter to the closing prices for each
    specified &class="macro">#x27;rolling_period&class="macro">#x27;. It then calculates the price deviation from these
    smoothed trends and determines dynamic "reversion zones" using rolling quantiles.
    Buy signals are generated when the price is within these reversion zones
    across multiple timeframes.
    Args:
        dataset(pd.DataFrame): DataFrame containing financial data with a &class="macro">#x27;close&class="macro">#x27; column.
        rolling_periods(list, optional): List of rolling window sizes for the Savitzky-Golay filter.
                                           Defaults to [class="num">200, class="num">400, class="num">600].
        quantiles(list, optional): Quantiles to define the "reversion zone". Defaults to [.class="num">05, .class="num">95].
        window(class="type">int, optional): Window size for calculating rolling quantiles. Defaults to class="num">100.
        polyorder(class="type">int, optional): Polynomial order for the Savitzky-Golay filter. Defaults to class="num">3.
    Returns:
        pd.DataFrame: The original DataFrame with a new &class="macro">#x27;labels&class="macro">#x27; column and filtered rows:
             - &class="macro">#x27;labels&class="macro">#x27; column:
                - class="num">0: Buy
                - class="num">1: Sell
             - Rows where &class="macro">#x27;labels&class="macro">#x27; is class="num">2 (no signal) are removed.
             - Rows with missing values(NaN) are removed.
    """

    # Create a copy of the dataset to avoid modifying the original
    dataset = dataset.copy()

    # Lists to store price deviation levels and quantiles for each rolling period
    all_levels = []
    all_quantiles = []

    # Calculate smoothed price trends and rolling quantiles for each rolling period
    for rolling in rolling_periods:
        # Calculate smoothed prices using the Savitzky-Golay filter
        smoothed_prices = savgol_filter(dataset[&class="macro">#x27;close&class="macro">#x27;].values,
                                        window_length=rolling,
                                        polyorder=polyorder)
        # Calculate the price deviation from the smoothed prices
        diff = dataset[&class="macro">#x27;close&class="macro">#x27;] - smoothed_prices

        # Create a temporary DataFrame to calculate rolling quantiles
        temp_df = pd.DataFrame({&class="macro">#x27;diff&class="macro">#x27;: diff})

双向滤波下的反转区间标定

这套逻辑用两条反向 Savitzky-Golay 滤波线(默认窗口均 200、多项式阶数 3)分别平滑收盘价,再各自算滚动分位数框出「偏离带」。当正向滤波的偏离值突破上分位(默认 .55)时打卖签 1.0,反向滤波的偏离值跌破下分位(默认 .45)时打买签 0.0,夹在中间的统一标 2.0 随后丢弃。 核心加速段用 Numba 的 @njit 把逐根 K 线的判断搬进机器码:lvl1 大于 q1[1] 即卖,lvl2 小于 q2[0] 即买,其余归 2.0。回测前务必 dropna 并删掉 label==2.0 的行,否则空信号会污染样本分布。 在 MT5 外接 Python 环境里直接拷这段,把 rolling1/rolling2 调到 100 看信号密度变化;外汇与贵金属杠杆高,分位阈值仅是概率边界,实盘须自担滑点与跳空风险。

MQL5 / C++
@njit
def calc_labels_bidirectional(close, lvl1, lvl2, q1, q2):
    labels = np.empty(len(close), dtype=np.float64)
    for i in range(len(close)):
        curr_lvl1 = lvl1[i]
        curr_lvl2 = lvl2[i]
        if curr_lvl1 > q1[class="num">1]:
            labels[i] = class="num">1.0
        elif curr_lvl2 < q2[class="num">0]:
            labels[i] = class="num">0.0
        else:
            labels[i] = class="num">2.0
    class="kw">return labels
def get_labels_filter_bidirectional(dataset, rolling1=class="num">200, rolling2=class="num">200, quantiles=[.class="num">45, .class="num">55], polyorder=class="num">3) -> pd.DataFrame:
    """
    Generates trading labels based on price deviation from two Savitzky-Golay filters applied
    in opposite directions(forward and reversed) to the closing price data.
    This function calculates trading signals(buy/sell) based on the price&class="macro">#x27;s
    position relative to smoothed price trends generated by two Savitzky-Golay filters
    with potentially different window sizes(`rolling1`, `rolling2`).
    Args:
        dataset(pd.DataFrame): DataFrame containing financial data with a &class="macro">#x27;close&class="macro">#x27; column.
        rolling1(class="type">int, optional): Window size for the first Savitzky-Golay filter. Defaults to class="num">200.
        rolling2(class="type">int, optional): Window size for the second Savitzky-Golay filter. Defaults to class="num">200.
        quantiles(list, optional): Quantiles to define the "reversion zones". Defaults to [.class="num">45, .class="num">55].
        polyorder(class="type">int, optional): Polynomial order for both Savitzky-Golay filters. Defaults to class="num">3.
    Returns:
        pd.DataFrame: The original DataFrame with a new &class="macro">#x27;labels&class="macro">#x27; column and filtered rows:
             - &class="macro">#x27;labels&class="macro">#x27; column:
                - class="num">0: Buy
                - class="num">1: Sell
             - Rows where &class="macro">#x27;labels&class="macro">#x27; is class="num">2 (no signal) are removed.
             - Rows with missing values(NaN) are removed.

「双平滑残差怎么切成交易标签」

这段处理把价格对两条 Savitzky-Golay 平滑曲线的偏离(diff1、diff2)先写进临时列 lvl1、lvl2,再按分位数 quantiles 切出两套回归区边界 q1、q2。 核心标签由 calc_labels_bidirectional(close, lvl1, lvl2, q1, q2) 算出,方向双向:价格同时掉到两条残差下界之外倾向买,反之倾向卖;标签 2.0 被显式 drop,代表双向条件冲突的废信号。 dropna 之后若原始 close 有 N 根 K 线、窗口 rolling1=21、rolling2=-55,头尾约 55 根会因平滑边界产生 NaN 被弃,实际可用样本约 N-55,外汇与贵金属品种需先确认历史根数够跑分位。 临时的 lvl1、lvl2 在 return 前被 drop,最终 DataFrame 只留 close 与 labels,方便直接喂给小布的后端做样本平衡检查。

MQL5 / C++
    # Apply the first Savitzky-Golay filter(forward direction)
    smoothed_prices = savgol_filter(dataset[&class="macro">#x27;close&class="macro">#x27;].values, window_length=rolling1, polyorder=polyorder)
    
    # Apply the second Savitzky-Golay filter(could be in reverse direction if rolling2 is negative)
    smoothed_prices2 = savgol_filter(dataset[&class="macro">#x27;close&class="macro">#x27;].values, window_length=rolling2, polyorder=polyorder)
    # Calculate price deviations from both smoothed price series
    diff1 = dataset[&class="macro">#x27;close&class="macro">#x27;] - smoothed_prices
    diff2 = dataset[&class="macro">#x27;close&class="macro">#x27;] - smoothed_prices2
    # Add price deviations as new columns to the DataFrame
    dataset[&class="macro">#x27;lvl1&class="macro">#x27;] = diff1
    dataset[&class="macro">#x27;lvl2&class="macro">#x27;] = diff2
    
    # Remove rows with NaN values 
    dataset = dataset.dropna()
    # Calculate quantiles for the "reversion zones" for both price deviation series
    q1 = dataset[&class="macro">#x27;lvl1&class="macro">#x27;].quantile(quantiles).to_list()
    q2 = dataset[&class="macro">#x27;lvl2&class="macro">#x27;].quantile(quantiles).to_list()
    # Extract relevant data for label calculation
    close = dataset[&class="macro">#x27;close&class="macro">#x27;].values
    lvl1 = dataset[&class="macro">#x27;lvl1&class="macro">#x27;].values
    lvl2 = dataset[&class="macro">#x27;lvl2&class="macro">#x27;].values
    
    # Calculate buy/sell labels using the &class="macro">#x27;calc_labels_bidirectional&class="macro">#x27; function
    labels = calc_labels_bidirectional(close, lvl1, lvl2, q1, q2)
    # Process the dataset and labels
    dataset = dataset.iloc[:len(labels)].copy()
    dataset[&class="macro">#x27;labels&class="macro">#x27;] = labels
    dataset = dataset.dropna()
    dataset = dataset.drop(dataset[dataset.labels == class="num">2.0].index) # Remove bad signals(if any)
    
    # Return the DataFrame with temporary columns removed
    class="kw">return dataset.drop(columns=[&class="macro">#x27;lvl1&class="macro">#x27;, &class="macro">#x27;lvl2&class="macro">#x27;])

◍ 盈利过滤与多滤波器标注的实现细节

标记方法本就允许出现被标注却明显亏损的样本,这不算 bug,而是有意保留的设计。若想让资金曲线更接近理想直线、压低回撤,可以加一道盈利校验:只保留未来指定步数内确实盈利的交易,其余打上 2.0 直接从数据集删掉。 原方案只用 Savitzky-Golay 滤波器,现在补进简单移动平均线和样条曲线。样条不是拿一个高阶多项式硬拟合全段,而是把定义域按节点切开,每段单独跑多项式,边界处靠系数配比保证函数值和一阶导数连续,所以比单一曲线灵活得多。金融序列里它能做插值平滑、拆趋势、估导数,样条平滑因子 s 建议 0.1~1,多项式阶数通常 1~3,代码里默认 k=3 但能改。 get_labels_mean_reversion 支持 method 切到 'mean' / 'spline' / 'savgol',先算价格相对平滑曲线的偏离 lvl,再按分位数 q 圈信号区。calculate_labels_mean_reversion 里 rand 在 min_l~max_l 随机取,未来价跌破(升破)加 markup 的当前价才记 1.0 / 0.0,否则 2.0。默认 max_l=15,意味着标签数组比收盘价短 15 根,给未来留出空间。 想增多样性可改多周期版:多个滤波器同方向(全买或全卖)且未来 n 根盈利才留,否则弃样。另一种改法是分位数用滑动窗口算,而不是全历史统一分位,能缓解波动率跳变时的信号噪声。外汇和贵金属波动剧烈,这类标注只是样本筛选,实盘仍属高风险,信号失效概率不低。

MQL5 / C++
@njit
def calculate_labels_mean_reversion(close, lvl, markup, min_l, max_l, q):
    labels = np.empty(len(close) - max_l, dtype=np.float64)
    for i in range(len(close) - max_l):
        rand = random.randint(min_l, max_l)
        curr_pr = close[i]
        curr_lvl = lvl[i]
        future_pr = close[i + rand]
        if curr_lvl > q[class="num">1] and(future_pr + markup) < curr_pr:
            labels[i] = class="num">1.0
        elif curr_lvl < q[class="num">0] and(future_pr - markup) > curr_pr:
            labels[i] = class="num">0.0
        else:
            labels[i] = class="num">2.0
    class="kw">return labels
def get_labels_mean_reversion(dataset, markup, min_l=class="num">1, max_l=class="num">15, rolling=class="num">0.5, quantiles=[.class="num">45, .class="num">55], method=&class="macro">#x27;spline&class="macro">#x27;, shift=class="num">0) -> pd.DataFrame:
    """
    Generates labels for a financial dataset based on mean reversion principles.
    This function calculates trading signals(buy/sell) based on the deviation of
    the price from a chosen moving average or smoothing method. It identifies
    potential buy opportunities when the price deviates significantly below its
    smoothed trend, anticipating a reversion to the mean.
    Args:
        dataset(pd.DataFrame): DataFrame containing financial data with a &class="macro">#x27;close&class="macro">#x27; column.
        markup(class="type">float): The percentage markup used to determine buy signals.
        min_l(class="type">int, optional): Minimum number of consecutive days the markup must hold. Defaults to class="num">1.
        max_l(class="type">int, optional): Maximum number of consecutive days the markup is considered. Defaults to class="num">15.

偏离度的三种算法与信号过滤

做均值回归类策略,第一步是把价格对「基准线」的偏离算出来,这个偏离量在代码里叫 lvl。原文给了三种 method:mean 直接用滚动均值做差;spline 用三次样条平滑(k=3)再减;savgol 用 Savitzky-Golay 滤波器(多项式阶数固定 3)得到平滑价再减。rolling 参数在 spline 下就是样条平滑因子 s,默认 0.5,调大曲线更平、信号更少。 quantiles 默认 [0.45, 0.55],意思是只把 lvl 落在中间 10% 分位带里的样本视为「回归区」,其余标为 2(无信号)并整行删掉。返回表里只留 labels 为 0(买)和 1(卖)的行,同时 dropna 清掉 spline/shift 可能引入的 NaN,临时列 lvl 也不留。 shift 参数值得单独试:对 spline 路径做 np.roll 偏移,正数是把平滑线前推、制造领先预警,负数则滞后。外汇与贵金属波动跳空多,shift 不当容易在周一根基缺口处误删数据,MT5 里接自己的 close 序列跑一遍就能看见 dropna 后样本少了多少。

MQL5 / C++
if method == &class="macro">#x27;mean&class="macro">#x27;:
    dataset[&class="macro">#x27;lvl&class="macro">#x27;] = (dataset[&class="macro">#x27;close&class="macro">#x27;] - dataset[&class="macro">#x27;close&class="macro">#x27;].rolling(rolling).mean())
elif method == &class="macro">#x27;spline&class="macro">#x27;:
    x = np.array(range(dataset.shape[class="num">0]))
    y = dataset[&class="macro">#x27;close&class="macro">#x27;].values
    spl = UnivariateSpline(x, y, k=class="num">3, s=rolling)
    yHat = spl(np.linspace(min(x), max(x), num=x.shape[class="num">0]))
    yHat_shifted = np.roll(yHat, shift=shift) # Apply the shift
    dataset[&class="macro">#x27;lvl&class="macro">#x27;] = dataset[&class="macro">#x27;close&class="macro">#x27;] - yHat_shifted
    dataset = dataset.dropna()  # Remove NaN values potentially introduced by spline/shift
elif method == &class="macro">#x27;savgol&class="macro">#x27;:
    smoothed_prices = savgol_filter(dataset[&class="macro">#x27;close&class="macro">#x27;].values, window_length=class="type">int(rolling), polyorder=class="num">3)
    dataset[&class="macro">#x27;lvl&class="macro">#x27;] = dataset[&class="macro">#x27;close&class="macro">#x27;] - smoothed_prices
dataset = dataset.dropna()  # Remove NaN values before proceeding
q = dataset[&class="macro">#x27;lvl&class="macro">#x27;].quantile(quantiles).to_list()  # Calculate quantiles for the &class="macro">#x27;reversion zone&class="macro">#x27;
# Prepare data for label calculation
close = dataset[&class="macro">#x27;close&class="macro">#x27;].values

「用样条平滑叠加分位带把均值回归信号画出来」

把前面算出的标签接回数据框后,真正能拿来肉眼验证的是这张图:用三次样条(k=3)对收盘价做平滑,再把价格与平滑值的残差按分位切开,上下轨就自然浮出来了。 核心函数 plot_close_filter_quantiles 默认 rolling=200、quantiles=[0.2,0.8]。它先把 datetime 索引转成数值(Unix 时间戳),喂给 UnivariateSpline;平滑因子 s 直接取 rolling 值,意味着窗口越长曲线越硬。残差 lvl = close - smoothed 的 20% 与 80% 分位,分别加回 smoothed 得到 lower_band 与 upper_band。 图上蓝线是原始 close(透明度 0.5 防止喧宾夺主),橙线是样条,绿/红虚线就是分位带。当价格触及红带附近且标签为均值回归买入时,外汇或贵金属品种出现回拉的概率倾向更高,但杠杆市场高风险,触碰不代表反转必现。 直接把 MT5 导出的 close 序列塞进这个函数跑一遍,比看任何文字描述都直观;若把 quantiles 改成 [0.1,0.9],带子变宽、信号变稀,可自行对比噪声过滤效果。

MQL5 / C++
lvl = dataset[&class="macro">#x27;lvl&class="macro">#x27;].values

# Calculate buy/sell labels 
labels = calculate_labels_mean_reversion(close, lvl, markup, min_l, max_l, q)
# Process the dataset and labels
dataset = dataset.iloc[:len(labels)].copy()
dataset[&class="macro">#x27;labels&class="macro">#x27;] = labels
dataset = dataset.dropna()
dataset = dataset.drop(dataset[dataset.labels == class="num">2.0].index)  # Remove sell signals(if any)
class="kw">return dataset.drop(columns=[&class="macro">#x27;lvl&class="macro">#x27;])  # Remove the temporary &class="macro">#x27;lvl&class="macro">#x27; column 
class="kw">import pandas as pd
from scipy.interpolate class="kw">import UnivariateSpline
class="kw">import matplotlib.pyplot as plt
def plot_close_filter_quantiles(dataset, rolling=class="num">200, quantiles=[class="num">0.2, class="num">0.8]):
    """
    Plots close prices with spline smoothing and quantile bands.
    Args:
        dataset(pd.DataFrame): DataFrame with &class="macro">#x27;close&class="macro">#x27; column and class="type">class="kw">datetime index.
        rolling(class="type">int, optional): Rolling window size for spline smoothing.
                                Defaults to class="num">200.
        quantiles(list, optional): Quantiles for band calculation.
                                     Defaults to [class="num">0.2, class="num">0.8].
        s(class="type">float, optional): Smoothing factor for UnivariateSpline.
                             Adjusts the spline stiffness. Defaults to class="num">1000.
    """
    
    # Create spline smoothing
    # Convert class="type">class="kw">datetime index to numerical values(Unix timestamps)
    numerical_index = pd.to_numeric(dataset.index)
    
    # Create spline smoothing using the numerical index
    spline = UnivariateSpline(numerical_index, dataset[&class="macro">#x27;close&class="macro">#x27;], k=class="num">3, s=rolling)  
    smoothed = spline(numerical_index)
    
    # Calculate difference between prices and filter
    lvl = dataset[&class="macro">#x27;close&class="macro">#x27;] - smoothed
    
    # Get quantile values
    q_low, q_high = lvl.quantile(quantiles).tolist()
    
    # Calculate bands based on quantiles
    upper_band = smoothed + q_high
    lower_band = smoothed + q_low
    
    # Create plot
    plt.figure(figsize=(class="num">14, class="num">7))
    plt.plot(dataset.index, dataset[&class="macro">#x27;close&class="macro">#x27;], label=&class="macro">#x27;Close Prices&class="macro">#x27;, class="type">color=&class="macro">#x27;blue&class="macro">#x27;, alpha=class="num">0.5)
    plt.plot(dataset.index, smoothed, label=f&class="macro">#x27;Spline Smoothed(s={rolling})&class="macro">#x27;, class="type">color=&class="macro">#x27;orange&class="macro">#x27;, linewidth=class="num">2)
    plt.plot(dataset.index, upper_band, label=f&class="macro">#x27;Upper Quantile({quantiles[class="num">1]*class="num">100:.0f}%)&class="macro">#x27;,
             class="type">color=&class="macro">#x27;green&class="macro">#x27;, linestyle=&class="macro">#x27;--&class="macro">#x27;)
    plt.plot(dataset.index, lower_band, label=f&class="macro">#x27;Lower Quantile({quantiles[class="num">0]*class="num">100:.0f}%)&class="macro">#x27;,
             class="type">color=&class="macro">#x27;red&class="macro">#x27;, linestyle=&class="macro">#x27;--&class="macro">#x27;)
    
    # Configure display
    plt.title(&class="macro">#x27;Price and Spline with Quantile Bands&class="macro">#x27;)
    plt.xlabel(&class="macro">#x27;Date&class="macro">#x27;)
    plt.ylabel(&class="macro">#x27;Price&class="macro">#x27;)
    plt.legend()
    plt.grid(True)
    plt.show()
把模式切分交给小布盯盘
这些多模式聚类与分位数带诊断,小布盯盘的AIGC已内置,打开EURGBP对应品种页即可看到实时平稳带与偏离提示,你只需判断触发哪类历史模式。

常见问题

它平滑价格却不产生滞后,剩余噪声即为相对均值的偏差,更适合做无延迟的均值回归触发,而非等MA拐头才反应。
随机标签没定义模型该逼近什么模式,逼近对象本身是噪声,仅少数模型侥幸过样本外,不具备可复用性。
目前小布内置的是通用平稳带与模式诊断,自训模型需按知识库接口导出特征;高频重训可交给小布后台跑,你专注决策。
平稳只提高策略适配概率,外汇贵金属仍属高风险,分位数带交叉失效时回撤可能扩大,需配合仓位约束。
多是环境路径与库文件位置不匹配,改绝对导入或把模块置于PYTHONPATH即可,解释器版本差异通常不影响运行。