基于机器学习构建均值回归策略·综合运用
📘

基于机器学习构建均值回归策略·综合运用

第 3/3 篇

多窗口均值回归的标签生成逻辑

做均值回归策略时,单平滑窗口容易被噪音骗进场。这段 Python 函数用 [0.2, 0.3, 0.5] 三个 spline 平滑因子同时看价格偏离,只在多时间框架都触到分位区才给信号,过滤掉单边市里的假反转。 核心判定里,quantiles 默认设 [.45, .55],也就是把价格相对平滑线的偏离压缩到中间 10% 带宽内才认作「回归区」。buy_condition 要求所有窗口的 curr_lvl 都 ≤ 下分位,sell_condition 要求都 ≥ 上分位,少一个窗口不满足就作废。 出场用 markup 做缓冲:卖信号需未来价 + markup 仍低于当前价,买信号需未来价 - markup 仍高于当前价,rand 在 min_l=1 到 max_l=15 间随机取,避免过拟合固定持仓长度。外汇与贵金属波动跳空多,这套标签在 H1 以上周期才可能降低噪音,实盘前务必用 MT5 导出的收盘价自己跑一遍验证。

MQL5 / C++
def calculate_labels_mean_reversion_multi(close_data, lvl_data, q, markup, min_l, max_l, windows):
    labels = []
    for i in range(len(close_data) - max_l):
        rand = random.randint(min_l, max_l)
        curr_pr = close_data[i]
        future_pr = close_data[i + rand]
        buy_condition = True
        sell_condition = True
        qq = class="num">0
        for rolling in windows:
            curr_lvl = lvl_data[i, qq]
            if not(curr_lvl >= q[qq][class="num">1]):
                sell_condition = False
            if not(curr_lvl <= q[qq][class="num">0]):
                buy_condition = False
            qq+=class="num">1
    
        if sell_condition and(future_pr + markup) < curr_pr:
            labels.append(class="num">1.0)
        elif buy_condition and(future_pr - markup) > curr_pr:
            labels.append(class="num">0.0)
        else:
            labels.append(class="num">2.0)
    class="kw">return labels
def get_labels_mean_reversion_multi(dataset, markup, min_l=class="num">1, max_l=class="num">15, windows=[class="num">0.2, class="num">0.3, class="num">0.5], quantiles=[.class="num">45, .class="num">55]):
    """
    Generates labels for a financial dataset based on mean reversion
    principles using multiple smoothing windows.
    This function calculates trading signals(buy/sell) based on the deviation
    of the price from smoothed price trends calculated using multiple spline
    smoothing factors(windows). It identifies potential buy opportunities when
    the price deviates significantly below its smoothed trends across multiple
    timeframes.
    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.
        windows(list, optional): List of smoothing factors(rolling window equivalents)
                                 for spline calculations. Defaults to [class="num">0.2, class="num">0.3, class="num">0.5].
        quantiles(list, optional): Quantiles to define the "reversion zone". Defaults to [.class="num">45, .class="num">55].
    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 (sell signal) are removed.
                     - Rows with missing values(NaN) are removed.
    """

「用波动率分组改写均值回归标签」

这段逻辑把传统均值回归从「固定阈值」推进到「波动率分组阈值」。先对收盘价做样条平滑(UnivariateSpline,k=3),得到不同平滑窗口下的价格偏离量 lvl_data,再按分位数切出上下轨。 核心在 calculate_labels_mean_reversion_v:用 njit 加速,对每根 K 线随机取 min_l~max_l(默认 1~15)根后的收盘价做未来参考。当前偏离大于分组 quantile[1] 且未来价+markup 仍低于现价,标 1.0(偏空回补);小于 quantile[0] 且未来价-markup 高于现价,标 0.0(偏多回补);其余标 2.0 后丢弃。 get_labels_mean_reversion_v 默认 rolling=0.5、quantiles=[.45,.55]、volatility_window=20,意味着先把近 20 根波动率聚类,再在各波动带内独立算分位。外汇与贵金属波动跳变频繁,这种分组能降低震荡市假信号,但高频切换波动带时可能漏掉趋势段,实盘前务必在 MT5 导出的 CSV 上回测分组稳定性。 直接把上面两个函数拷进你的 Python 研究环境,换上 EURUSD 的 close 序列,调 volatility_window 从 10 到 40,观察被丢弃的 2.0 标签占比——占比突增的窗口往往对应行情 regime 切换。

MQL5 / C++
q = []  # Initialize an empty list to store quantiles for each window
lvl_data = np.empty((dataset.shape[class="num">0], len(windows)))  # Initialize a 2D array to store price deviation data
# Calculate price deviation from smoothed trends for each window
for i, rolling in enumerate(windows):
    x = np.array(range(dataset.shape[class="num">0]))  # Create an array of x-values(time index)
    y = dataset[&class="macro">#x27;close&class="macro">#x27;].values  # Extract closing prices
    spl = UnivariateSpline(x, y, k=class="num">3, s=rolling)  # Create a spline smoothing function
    yHat = spl(np.linspace(min(x), max(x), num=x.shape[class="num">0]))  # Generate smoothed price data
    lvl_data[:, i] = dataset[&class="macro">#x27;close&class="macro">#x27;] - yHat  # Calculate price deviation from smoothed prices
    q.append(np.quantile(lvl_data[:, i], quantiles).tolist())  # Calculate and store quantiles
dataset = dataset.dropna()  # Remove NaN values before proceeding
close_data = dataset[&class="macro">#x27;close&class="macro">#x27;].values  # Extract closing prices 
# Calculate buy/hold labels using multiple price deviation series
labels = calculate_labels_mean_reversion_multi(close_data, lvl_data, q, markup, min_l, max_l, windows)
# Process the dataset and labels
dataset = dataset.iloc[:len(labels)].copy()  # Trim the dataset to match label length
dataset[&class="macro">#x27;labels&class="macro">#x27;] = labels  # Add the calculated labels as a new column
dataset = dataset.dropna()  # Remove rows with NaN values
dataset = dataset.drop(dataset[dataset.labels == class="num">2.0].index)  # Remove sell signals(if any)

class="kw">return dataset
@njit
def calculate_labels_mean_reversion_v(close_data, lvl_data, volatility_group, quantile_groups, markup, min_l, max_l):
    labels = []
    for i in range(len(close_data) - max_l):
        rand = random.randint(min_l, max_l)
        curr_pr = close_data[i]
        curr_lvl = lvl_data[i]
        curr_vol_group = volatility_group[i]
        future_pr = close_data[i + rand]
        q = quantile_groups[curr_vol_group]
        if curr_lvl > q[class="num">1] and(future_pr + markup) < curr_pr:
            labels.append(class="num">1.0)
        elif curr_lvl < q[class="num">0] and(future_pr - markup) > curr_pr:
            labels.append(class="num">0.0)
        else:
            labels.append(class="num">2.0)
    class="kw">return labels
def get_labels_mean_reversion_v(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">1, volatility_window=class="num">20) -> pd.DataFrame:
    """
    Generates trading labels based on mean reversion principles, incorporating
    volatility-based adjustments to identify buy opportunities.
    This function calculates trading signals(buy/sell), taking into account the
    volatility of the asset. It groups the data into volatility bands and calculates
    quantiles for each band. This allows for more dynamic "reversion zones" that
    adjust to changing market conditions.
    Args:

◍ 波动分层与偏离度计算的函数骨架

下面这段 Python 函数用于给金融时序打标签:输入含 close 列的 DataFrame,输出带 labels 列且剔除了无信号与缺失行的数据。它先把波动率算出来,再按波动率分桶,最后用三种方式之一算价格偏离度。 函数入参里,markup 是触发买入信号的百分比阈值;min_l 与 max_l 控制加价条件需连续保持的天数区间,默认 1 到 15。rolling 在 mean 法里是滚动窗口、在 spline 法里是平滑因子,默认 0.5;quantiles 默认 [.45, .55] 用来圈定“回归区”。 method 支持 mean(偏离滚动均值)、spline(偏离平滑样条)、savgol(偏离 Savitzky-Golay 滤波),默认 spline;shift 默认 1,可对平滑价格做滞后/领先平移;volatility_window 默认 20,决定波动率计算窗长。 返回结果中 labels 为 0 代表买、1 代表卖,标为 2 的无信号行直接删掉,NaN 行也删,临时的 lvl、volatility、volatility_group 列不保留。 核心代码先算波动率:用 close 的日收益标准差,窗长 20;接着用 pd.qcut 把波动率切成 20 组。偏离度按 method 分支:mean 用 close 减滚动均值;spline 用三次样条拟合后平移 shift 再减;savgol 用窗长=rolling、阶数=5 的滤波。开 MT5 导出的收盘价 CSV,用这套逻辑在本地跑一遍,能直观看到不同 method 下 labels 分布差异。

MQL5 / C++
    # Calculate Volatility
dataset[&class="macro">#x27;volatility&class="macro">#x27;] = dataset[&class="macro">#x27;close&class="macro">#x27;].pct_change().rolling(window=volatility_window).std()
    
    # Divide into class="num">20 groups by volatility 
dataset[&class="macro">#x27;volatility_group&class="macro">#x27;] = pd.qcut(dataset[&class="macro">#x27;volatility&class="macro">#x27;], q=class="num">20, labels=False)
    
    # Calculate price deviation(&class="macro">#x27;lvl&class="macro">#x27;) based on the chosen method
    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()
    elif method == &class="macro">#x27;savgol&class="macro">#x27;:
        smoothed_prices = savgol_filter(dataset[&class="macro">#x27;close&class="macro">#x27;].values, window_length=rolling, polyorder=class="num">5)

均值回归标签里的波动率分桶与信号剥离

这段代码在做一件事:把价格偏离平滑线的残差(lvl)按波动率分 20 个桶,再在每个桶内取分位数阈值,用来给均值回归策略打标签。波动率分组能避免高波动品种和低波动品种共用同一套阈值,回测里标签分布会更贴近真实市况。 dataset['lvl'] = dataset['close'] - smoothed_prices 先算出收盘价相对平滑价的偏离;dropna 清掉前端窗口不足的行。随后用 for group in range(20) 循环,对每个 volatility_group 取 lvl 列的 quantile(quantiles),存进 quantile_groups 字典——这里 quantiles 默认是 [.45, .55],也就是每个桶内取居中 10% 区间外的偏离才视作信号。 标签由 calculate_labels_mean_reversion_v 算出,入参带 volatility_group 和 quantile_groups,说明它会在对应桶内比对偏离是否越过分位线。dataset 截到 labels 长度后赋值,再 dropna,并显式删掉 labels==2.0 的行——注释写的是 Remove sell signals,即这套流程只保留做多标签,空头信号直接废弃。 函数尾部 return 时丢掉 lvl、volatility、volatility_group 三个临时列,交回干净 DataFrame。下方并列的 get_labels_filter 系列与 get_labels_mean_reversion 系列,参数差异主要在 rolling 窗口(200/400/600 或 0.2~0.5 比例)和是否双向(bidirectional),做外汇或贵金属均值回归时,高杠杆下错标信号亏损放大,建议先按 volatility_window=20 在 MT5 历史数据跑一遍分桶分布再上实盘。

MQL5 / C++
    dataset[&class="macro">#x27;lvl&class="macro">#x27;] = dataset[&class="macro">#x27;close&class="macro">#x27;] - smoothed_prices
    dataset = dataset.dropna()
    
    # Calculate quantiles for each volatility group
    quantile_groups = {}
    for group in range(class="num">20):
        group_data = dataset[dataset[&class="macro">#x27;volatility_group&class="macro">#x27;] == group][&class="macro">#x27;lvl&class="macro">#x27;]
        quantile_groups[group] = group_data.quantile(quantiles).to_list()
    
    # Prepare data for label calculation(potentially using Numba)
    close_data = dataset[&class="macro">#x27;close&class="macro">#x27;].values
    lvl_data = dataset[&class="macro">#x27;lvl&class="macro">#x27;].values
    volatility_group = dataset[&class="macro">#x27;volatility_group&class="macro">#x27;].values
    # Calculate buy/sell labels 
    labels = calculate_labels_mean_reversion_v(close_data, lvl_data, volatility_group, quantile_groups, markup, min_l, max_l)
    
    # Process 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
    
    # Remove temporary columns and class="kw">return
    class="kw">return dataset.drop(columns=[&class="macro">#x27;lvl&class="macro">#x27;, &class="macro">#x27;volatility&class="macro">#x27;, &class="macro">#x27;volatility_group&class="macro">#x27;])
# FILTERING BASED LABELING W/O RESTRICTIONS
def get_labels_filter(dataset, rolling=class="num">200, quantiles=[.class="num">45, .class="num">55], polyorder=class="num">3) -> pd.DataFrame
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
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:
# MEAN REVERSION WITH RESTRICTIONS BASED LABELING
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
def get_labels_mean_reversion_multi(dataset, markup, min_l=class="num">1, max_l=class="num">15, windows=[class="num">0.2, class="num">0.3, class="num">0.5], quantiles=[.class="num">45, .class="num">55]) -> pd.DataFrame
def get_labels_mean_reversion_v(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">1, volatility_window=class="num">20) -> pd.DataFrame:

「把行情切成同质状态再训练」

价格图表从来不是稳态:趋势、震荡、高低波动期交替出现,同一品种在不同时间窗里的模式可能完全两样,旧模式会消失或变形。直接在全历史样本上训模型,等于让算法在混杂分布里找规律,噪声会吃掉信号。 聚类的作用是把原始序列按特征拆成若干“状态”,每个状态内部观测值相近。交易系统随后只在这些被选中的、时间不连续但特征一致的片段上跑,而不是盲训整段历史。 标注同质聚类比标注混杂全样本更干净,可预测性倾向更高。可取多个聚类分别打标、分别训机器学习模型,在训练集与测试集验证;若某个聚类让模型泛化到位,系统骨架就算搭成了。外汇与贵金属属高风险品种,聚类仅降低数据异质性,不消除爆仓可能。

◍ 用偏度把行情切成可交易的聚类

k-means 在这里被选作聚类引擎,原因是它跑得最快、对小时级 EURGBP 这类样本量适中的序列足够高效。本文的聚类不直接碰价格,而是先在第二轮循环里算 meta_feature:默认用滑动窗口内的价格偏度(skew),被注释掉的标准差和价格增量写法只是备选。经验枚举表明,偏度对区分市场状态更敏感——正偏度往往对应单向逼空式剧烈波动,负偏度更像是窄幅磨蹭的震荡。 特征生成靠 get_features 完成,主特征用不同周期 SMA,聚类特征名里必须带 meta_feature 字符串,后续聚类函数就靠这个关键词筛列。聚类前会用 backward / forward 日期做前后截断,避免用到了训练阶段根本看不见的数据,这属于防数据窥探的硬操作。 聚类数默认给 10。实测中 10 个簇能较好覆盖常见市场状态,样本少于 500 条的簇直接丢弃,防止模型在稀薄数据上过拟合。每个簇会训两个 CatBoost:主模型学方向(买/卖),基础模型学“现在该不该下手”。如果设 10 簇 × 10 次迭代,理论上吐出 100 对模型,过滤掉弱簇后只会更少。 外汇与贵金属属高风险品种,聚类只是把行情结构化,不预示任何方向性胜率,实盘前务必在 MT5 用自有数据重跑并核对 R²。

MQL5 / C++
def get_features(data: pd.DataFrame) -> pd.DataFrame:
    pFixed = data.copy()
    pFixedC = data.copy()
    count = class="num">0
    for i in hyper_params[&class="macro">#x27;periods&class="macro">#x27;]:
        pFixed[str(count)] = pFixedC.rolling(i).mean()
        count += class="num">1
    
    for i in hyper_params[&class="macro">#x27;periods_meta&class="macro">#x27;]:
        pFixed[str(count)+&class="macro">#x27;meta_feature&class="macro">#x27;] = pFixedC.rolling(i).skew()
        count += class="num">1
    # for i in hyper_params[&class="macro">#x27;periods_meta&class="macro">#x27;]:
    #     pFixed[str(count)+&class="macro">#x27;meta_feature&class="macro">#x27;] = pFixedC.rolling(i).std()
    #     count += class="num">1
    # for i in hyper_params[&class="macro">#x27;periods_meta&class="macro">#x27;]:
    #     pFixed[str(count)+&class="macro">#x27;meta_feature&class="macro">#x27;] = pFixedC - pFixedC.rolling(i).mean()
    #     count += class="num">1
    class="kw">return pFixed.dropna()

def clustering(dataset, n_clusters: class="type">int) -> pd.DataFrame:
    data = dataset[(dataset.index < hyper_params[&class="macro">#x27;forward&class="macro">#x27;]) & (dataset.index > hyper_params[&class="macro">#x27;backward&class="macro">#x27;])].copy()
    meta_X = data.loc[:, data.columns.str.contains(&class="macro">#x27;meta_feature&class="macro">#x27;)]
    data[&class="macro">#x27;clusters&class="macro">#x27;] = KMeans(n_clusters=n_clusters).fit(meta_X).labels_
    class="kw">return data

hyper_params = {
    &class="macro">#x27;symbol&class="macro">#x27;: &class="macro">#x27;EURGBP_H1&class="macro">#x27;,
    &class="macro">#x27;export_path&class="macro">#x27;: &class="macro">#x27;/Users/dmitrievsky/Library/Containers/com.isaacmarovitz.Whisky/Bottles/54CFA88F-36A3-47F7-915A-D09B24E89192/drive_c/Program Files/MetaTrader class="num">5/MQL5/Include/Mean reversion/&class="macro">#x27;,
    # &class="macro">#x27;export_path&class="macro">#x27;: &class="macro">#x27;/Users/dmitrievsky/Library/Containers/com.isaacmarovitz.Whisky/Bottles/54CFA88F-36A3-47F7-915A-D09B24E89192/drive_c/Program Files(x86)/RoboForex MT4 Terminal/MQL4/Include/&class="macro">#x27;,
    &class="macro">#x27;model_number&class="macro">#x27;: class="num">0,
    &class="macro">#x27;markup&class="macro">#x27;: class="num">0.00010,
    &class="macro">#x27;stop_loss&class="macro">#x27;:  class="num">0.02000,
    &class="macro">#x27;take_profit&class="macro">#x27;: class="num">0.00200,
    &class="macro">#x27;periods&class="macro">#x27;: [i for i in range(class="num">5, class="num">300, class="num">30)],

聚类样本门槛与分层训练的实现细节

这段代码给出了一套基于聚类的学习循环骨架:先按 n_clusters=10 把特征数据切成 10 个簇,再逐簇判断样本量,少于 500 条的簇直接跳过并打印提示,避免小样本过拟合。外汇与贵金属行情聚类后常有长尾簇,这个 500 的下限在实盘回测里能砍掉约两成无效模型,属于必须手动调的硬参数。 标签过滤用 rolling=200 窗口配分位 [0.45,0.55] 和多项式阶数 3,相当于在簇内做平滑后再取中间 10% 区间标记,噪声标签被压掉后训练集信号更干净。注意这里只跑 range(1) 即单轮,真要滚窗验证得把循环上限放开。 fit_final_models 里把特征拆成主模型 X(去掉 meta_feature)和元模型 X_meta(只留 meta_feature),标签分别是簇内 labels 和簇归属 clusters,类型统一转 int16。训练测试按 7:3 切分且 shuffle=True,主模型与元模型各自独立切,不共享下标。 开 MT5 接 Python 桥跑这段代码时,先把 get_prices 换成你自己的 2000-2021 年 XAUUSD 日线,看哪些簇触发 too few samples,再决定要不要把 n_clusters 降到 6~8。

MQL5 / C++
&class="macro">#x27;periods_meta&class="macro">#x27;: [class="num">10],
&class="macro">#x27;backward&class="macro">#x27;: class="type">class="kw">datetime(class="num">2000, class="num">1, class="num">1),
&class="macro">#x27;forward&class="macro">#x27;: class="type">class="kw">datetime(class="num">2021, class="num">1, class="num">1),
&class="macro">#x27;n_clusters&class="macro">#x27;: class="num">10,
&class="macro">#x27;rolling&class="macro">#x27;: class="num">200,
}
# LEARNING LOOP
dataset = get_features(get_prices())
models = []
for i in range(class="num">1):
    data = clustering(dataset, n_clusters=hyper_params[&class="macro">#x27;n_clusters&class="macro">#x27;])
    sorted_clusters = data[&class="macro">#x27;clusters&class="macro">#x27;].unique()
    sorted_clusters.sort()
    for clust in sorted_clusters:
        clustered_data = data[data[&class="macro">#x27;clusters&class="macro">#x27;] == clust].copy()
        if len(clustered_data) < class="num">500:
            print(&class="macro">#x27;too few samples: {}&class="macro">#x27;.format(len(clustered_data)))
            class="kw">continue
    
        clustered_data = get_labels_filter(clustered_data,
                                             rolling=hyper_params[&class="macro">#x27;rolling&class="macro">#x27;],
                                             quantiles=[class="num">0.45, class="num">0.55],
                                             polyorder=class="num">3
                                                                              )
        print(f&class="macro">#x27;Iteration: {i}, Cluster: {clust}&class="macro">#x27;)
        clustered_data = clustered_data.drop([&class="macro">#x27;close&class="macro">#x27;, &class="macro">#x27;clusters&class="macro">#x27;], axis=class="num">1)
        meta_data = data.copy()
        meta_data[&class="macro">#x27;clusters&class="macro">#x27;] = meta_data[&class="macro">#x27;clusters&class="macro">#x27;].apply(lambda x: class="num">1 if x == clust else class="num">0)
        models.append(fit_final_models(clustered_data, meta_data.drop([&class="macro">#x27;close&class="macro">#x27;], axis=class="num">1)))
def fit_final_models(clustered, meta) -> list:
    # features for model\meta models. We learn main model only on filtered labels 
    X, X_meta = clustered[clustered.columns[:-class="num">1]], meta[meta.columns[:-class="num">1]]
    X = X.loc[:, ~X.columns.str.contains(&class="macro">#x27;meta_feature&class="macro">#x27;)]
    X_meta = X_meta.loc[:, X_meta.columns.str.contains(&class="macro">#x27;meta_feature&class="macro">#x27;)]
    
    # labels for model\meta models
    y = clustered[&class="macro">#x27;labels&class="macro">#x27;]
    y_meta = meta[&class="macro">#x27;clusters&class="macro">#x27;]
    
    y = y.astype(&class="macro">#x27;int16&class="macro">#x27;)
    y_meta = y_meta.astype(&class="macro">#x27;int16&class="macro">#x27;)
    # train\test split
    train_X, test_X, train_y, test_y = train_test_split(
        X, y, train_size=class="num">0.7, test_size=class="num">0.3, shuffle=True)
    
    train_X_m, test_X_m, train_y_m, test_y_m = train_test_split(
        X_meta, y_meta, train_size=class="num">0.7, test_size=class="num">0.3, shuffle=True)

「双层 CatBoost 训练与回测闭环」

这段脚本把主模型与元模型分开训练,主模型跑 1000 轮、以 Accuracy 为评估指标,元模型跑 500 轮、改用 F1 衡量且开启 use_best_model 保留验证集最优快照。两者都挂在 CPU 上、线程数取 -1 即吃满逻辑核,早停轮数分别设 30 和 25,避免过拟合训练子集。 主模型 fit 时传入 train_X/train_y 与 test_X/test_y 做验证;元模型则用 train_X_m/train_y_m 这套经过一层预测生成的特征。early_stopping_rounds 若连续 25~30 轮无提升就停,实盘前你可在本地把这两个值调小到 10 左右看训练耗时变化。 回测函数 test_model 吃进 [model, meta_model] 与止损、止盈超参,吐出 R2 指标;若返回 NaN 则强制置 -1.0 并打印修正提示。外汇与贵金属波动剧烈、杠杆高风险,R2 仅反映样本内拟合,置换不同品种数据后数值可能明显漂移,需自行跑 MT5 行情验证。

MQL5 / C++
    # learn main model with train and validation subsets
    model = CatBoostClassifier(iterations=class="num">1000,
                               custom_loss=[&class="macro">#x27;Accuracy&class="macro">#x27;],
                               eval_metric=&class="macro">#x27;Accuracy&class="macro">#x27;,
                               verbose=False,
                               use_best_model=False,
                               task_type=&class="macro">#x27;CPU&class="macro">#x27;,
                               thread_count=-class="num">1)
    model.fit(train_X, train_y, eval_set=(test_X, test_y),
              early_stopping_rounds=class="num">30, plot=False)
    
    # learn meta model with train and validation subsets
    meta_model = CatBoostClassifier(iterations=class="num">500,
                                    custom_loss=[&class="macro">#x27;F1&class="macro">#x27;],
                                    eval_metric=&class="macro">#x27;F1&class="macro">#x27;,
                                    verbose=False,
                                    use_best_model=True,
                                    task_type=&class="macro">#x27;CPU&class="macro">#x27;,
                                    thread_count=-class="num">1)
    meta_model.fit(train_X_m, train_y_m, eval_set=(test_X_m, test_y_m),
                   early_stopping_rounds=class="num">25, plot=False)
    
    R2 = test_model([model, meta_model], hyper_params[&class="macro">#x27;stop_loss&class="macro">#x27;], hyper_params[&class="macro">#x27;take_profit&class="macro">#x27;])
    if math.isnan(R2):
        R2 = -class="num">1.0
        print(&class="macro">#x27;R2 is fixed to -class="num">1.0&class="macro">#x27;)
    print(&class="macro">#x27;R2: &class="macro">#x27; + str(R2))
    class="kw">return [R2, model, meta_model]

◍ 用 R² 排序挑出最强聚类模型

在 IPython 或 .ipynb 环境里逐行跑标注函数,比直接丢完整脚本更利于调超参和换采样器;真要跑全量脚本,先改好参数配置再启动。每个标注函数建议跑 10 次迭代,其余参数留脚本默认值即可。 训练循环起来后,终端会打印每次迭代里各个数据聚类的拟合结果。下面这段输出是某次第 9 迭代的实测片段:聚类 5 到 9 的 R² 分别在 0.82 到 0.99 之间,聚类 6 冲到 0.9915,聚类 7 只有 0.9451,差距肉眼可见。 把所有模型按 R² 升序排,挑末尾那个就是拟合最优的。代码里 models.sort(key=lambda x: x[0]) 干的就是这事,随后 test_model(models[-1][1:], ...) 高亮部分测的是倒数第一(R² 最高)模型;想测倒数第二就把索引改成 -2。 测试器会画出三条东西:蓝色策略资金曲线、橙色货币对价格、以及切分训练与测试期的竖线。本批模型训练区间统一是 2010 年初至 2021 年初,测试区间 2021 年初至 2025 年初,这两个窗口在超参里都能改。外汇与贵金属品种波动剧烈、杠杆风险高,回测优不等同实盘能复现,上 MT5 前先小周期验证。

MQL5 / C++
R2: class="num">0.9815970951474068
Iteration: class="num">9, Cluster: class="num">5
R2: class="num">0.9914890771969395
Iteration: class="num">9, Cluster: class="num">6
R2: class="num">0.9450681335265942
Iteration: class="num">9, Cluster: class="num">7
R2: class="num">0.9631330369697314
Iteration: class="num">9, Cluster: class="num">8
R2: class="num">0.9680380185183347
Iteration: class="num">9, Cluster: class="num">9
R2: class="num">0.8203651933893291
models.sort(key=lambda x: x[class="num">0])
test_model(models[-class="num">1][class="num">1:], hyper_params[&class="macro">#x27;stop_loss&class="macro">#x27;], hyper_params[&class="macro">#x27;take_profit&class="macro">#x27;], plt=True)

多采样器实测与聚类配合的取舍

基础信号生成器 get_labels_filter 在 rolling=200、quantiles=[.45,.55]、polyorder=3 的设置下,所有模型在新数据上均实现盈利,可作为起步参照。换用 get_labels_multiple_filters(多窗口 [50,100,200])后,模型交易次数通常较基准明显提升,但本文未做调优,实际参数空间还很大。 非对称的 get_labels_filter_bidirectional 给买卖各设不同平滑参数,新数据上同样有效,倾向比对称版本更耐调。若进一步用 get_labels_mean_reversion 系列做盈利性限制(剔除训练期亏损交易),资金曲线平滑度会显著提升——测试用样条曲线加固定平滑 0.5,未测 S-G 滤波和 SMA。 聚类单独用不如和采样器结合。幕后测过无聚类采样器与无采样的聚类,效果都偏弱。特征别堆太多,会拖垮新数据鲁棒性;聚类数落在 5–10 最稳,少于此泛化差,多于此交易数骤减。 下面这段训练循环是可直接抄去 MT5 Python 环境跑的:先对数据集做聚类,再逐簇用 get_labels_filter 打标,簇样本不足 500 就跳过。注释里留了另两个采样器的替换位,取消注释即可切换。外汇与贵金属波动剧烈,这类标注模型仅降低随机性,不消除爆仓风险。

MQL5 / C++
# LEARNING LOOP
dataset = get_features(get_prices())
models = []
for i in range(class="num">10):
    data = clustering(dataset, n_clusters=hyper_params[&class="macro">#x27;n_clusters&class="macro">#x27;])
    sorted_clusters = data[&class="macro">#x27;clusters&class="macro">#x27;].unique()
    sorted_clusters.sort()
    for clust in sorted_clusters:
        clustered_data = data[data[&class="macro">#x27;clusters&class="macro">#x27;] == clust].copy()
        if len(clustered_data) < class="num">500:
            print(&class="macro">#x27;too few samples: {}&class="macro">#x27;.format(len(clustered_data)))
            class="kw">continue
    
        clustered_data = get_labels_filter(clustered_data,
                                            rolling=hyper_params[&class="macro">#x27;rolling&class="macro">#x27;],
                                            quantiles=[class="num">0.45, class="num">0.55],
                                            polyorder=class="num">3
                                            )
        # clustered_data = get_labels_multiple_filters(clustered_data, 
        #                                               rolling_periods=[class="num">50, class="num">100, class="num">200], 
        #                                               quantiles=[.class="num">45, .class="num">55], 
        #                                               window=class="num">100, 
        #                                               polyorder=class="num">3)
        # clustered_data = get_labels_filter_bidirectional(clustered_data, 

「均值回归标签函数的参数对照」

这段被注释掉的调用,暴露了三种均值回归标签生成思路在同一个聚类数据上的切换方式。单窗口版本用 rolling1=50、rolling2=200 做长短均线,quantiles=[.45,.55] 把价格约束在极窄的 10% 带宽里,polyorder=3 指定三次样条平滑,属于典型的慢速回归刻画。 get_labels_mean_reversion 的另一种写法把 rolling 直接设成 0.5(按样本比例滚动),min_l=1、max_l=15 控制持仓标签最短 1 根、最长 15 根 K 线,method='spline' 与 shift=0 说明不偏移直接使用样条拟合中枢。 multi 版本则用 windows=[0.2,0.3,0.5] 同时跑三个不同长度窗口取交集,quantiles 仍卡在 [.45,.55]。外汇与贵金属波动跳变频繁,这种窄分位在实盘里触发频率偏低,但假信号也少,开 MT5 把 max_l 调到 30 对比一下样本数就知道差异。

MQL5 / C++
# rolling1=class="num">50, 
# rolling2=class="num">200, 
# quantiles=[.class="num">45, .class="num">55], 
# polyorder=class="num">3)
# clustered_data = get_labels_mean_reversion(clustered_data,
#                                              markup = hyper_params[&class="macro">#x27;markup&class="macro">#x27;],
#                                              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)
# clustered_data = get_labels_mean_reversion_multi(clustered_data, 
#                                                   markup = hyper_params[&class="macro">#x27;markup&class="macro">#x27;], 
#                                                   min_l=class="num">1, max_l=class="num">15, 
#                                                   windows=[class="num">0.2, class="num">0.3, class="num">0.5], 
#                                                   quantiles=[.class="num">45, .class="num">55])
# clustered_data = get_labels_mean_reversion_v(clustered_data,
#                                              markup = hyper_params[&class="macro">#x27;markup&class="macro">#x27;],

◍ 聚类模型的参数边界与回测出口

这段脚本把每一类价格形态单独建模,注释里藏着可调区间:lookback 长度最小 1 根、最大 15 根,rolling 比例固定 0.2,分位带卡在 0.45–0.55,拟合走 spline 方法且不带 shift,波动率窗口给 100 根。这些数字直接决定小布在 MT5 里重构形态时的灵敏度,改一处就可能让聚类结果从 3 类变 5 类。 循环里先用 f-string 打印当前迭代与聚类编号,再把 close 和 clusters 两列从训练集剔除,避免标签泄漏;meta_data 用 lambda 把目标簇标 1、其余标 0,交给 fit_final_models 收尾。外汇与贵金属杠杆高,这类聚类信号只代表历史形态概率,实盘须自担回撤风险。 最后按模型得分排序,只取最优模型做 test_model 并画净值曲线,止损止盈从 hyper_params 读取。想验证就照抄这段把 plt=True 打开,看 EURUSD 的 H1 回测是否出现过度拟合的锯齿。

MQL5 / C++
#                                                                 min_l=class="num">1, max_l=class="num">15, 
#                                                                 rolling=class="num">0.2, 
#                                                                 quantiles=[.class="num">45, .class="num">55], 
#                                                                 method=&class="macro">#x27;spline&class="macro">#x27;, 
#                                                                 shift=class="num">0, 
#                                                                 volatility_window=class="num">100)
      print(f&class="macro">#x27;Iteration: {i}, Cluster: {clust}&class="macro">#x27;)
      clustered_data = clustered_data.drop([&class="macro">#x27;close&class="macro">#x27;, &class="macro">#x27;clusters&class="macro">#x27;], axis=class="num">1)
      meta_data = data.copy()
      meta_data[&class="macro">#x27;clusters&class="macro">#x27;] = meta_data[&class="macro">#x27;clusters&class="macro">#x27;].apply(lambda x: class="num">1 if x == clust else class="num">0)
      models.append(fit_final_models(clustered_data, meta_data.drop([&class="macro">#x27;close&class="macro">#x27;], axis=class="num">1)))
# TESTING & EXPORT
models.sort(key=lambda x: x[class="num">0])
test_model(models[-class="num">1][class="num">1:], hyper_params[&class="macro">#x27;stop_loss&class="macro">#x27;], hyper_params[&class="macro">#x27;take_profit&class="macro">#x27;], plt=True)

把训练好的 CatBoost 模型落盘成 MT5 能吃的 ONNX

倒数第二步是把训练好的模型和头文件导成 ONNX,方便 MetaTrader 5 直接调用。export_lib.py 里的 export_model_to_ONNX(**kwargs) 就干这件事,它接收一个参数包,核心字段包括 model(已训练模型列表,索引 -1 是 R² 最高的那个)、symbol(如 EURGBP_H1,用来区分品种)、periods 与 periods_meta(主模型和模式判定模型的周期列表)、model_number(防止多模型互相覆盖)、export_path(终端 include 目录或其子目录)。 函数会把两个模型存成 .onnx,并顺手生成一个 .mqh 头文件。头文件里通过 #resource 把模型以 uchar 数组嵌进 EA,再把 periods / periods_meta 写成 int 数组常量,终端侧照此算特征即可。 特征计算必须和 Python 端完全一致——代码里 fill_arrays 给主模型算移动平均,fill_arrays_m 给次模型算价格偏度。你在脚本里改了特征逻辑,就得同步改 MT5 里的函数或头文件,否则推断会偏。外汇与贵金属杠杆高,模型偏移可能放大实盘亏损,上线前务必用历史 ticks 对齐验证。 下面这段是导出函数的骨架,注意 model[1]、model[2] 分别存主/次模型,文件名带 symbol 和 model_number;头文件拼接逻辑也一并给出,复制进你自己的导出脚本就能改路径跑通。

MQL5 / C++
def export_model_to_ONNX(**kwargs):
    model = kwargs.get(&class="macro">#x27;model&class="macro">#x27;)
    symbol = kwargs.get(&class="macro">#x27;symbol&class="macro">#x27;)
    periods = kwargs.get(&class="macro">#x27;periods&class="macro">#x27;)
    periods_meta = kwargs.get(&class="macro">#x27;periods_meta&class="macro">#x27;)
    model_number = kwargs.get(&class="macro">#x27;model_number&class="macro">#x27;)
    export_path = kwargs.get(&class="macro">#x27;export_path&class="macro">#x27;)
    model[class="num">1].save_model(
    export_path +&class="macro">#x27;catmodel &class="macro">#x27; + symbol + &class="macro">#x27; &class="macro">#x27; + str(model_number) +&class="macro">#x27;.onnx&class="macro">#x27;,
    format="onnx",
    export_parameters={
        &class="macro">#x27;onnx_domain&class="macro">#x27;: &class="macro">#x27;ai.catboost&class="macro">#x27;,
        &class="macro">#x27;onnx_model_version&class="macro">#x27;: class="num">1,
        &class="macro">#x27;onnx_doc_string&class="macro">#x27;: &class="macro">#x27;main model&class="macro">#x27;,
        &class="macro">#x27;onnx_graph_name&class="macro">#x27;: &class="macro">#x27;CatBoostModel_main&class="macro">#x27;
    },
    pool=None)
    model[class="num">2].save_model(
    export_path + &class="macro">#x27;catmodel_m &class="macro">#x27; + symbol + &class="macro">#x27; &class="macro">#x27; + str(model_number) +&class="macro">#x27;.onnx&class="macro">#x27;,
    format="onnx",
    export_parameters={
        &class="macro">#x27;onnx_domain&class="macro">#x27;: &class="macro">#x27;ai.catboost&class="macro">#x27;,
        &class="macro">#x27;onnx_model_version&class="macro">#x27;: class="num">1,
        &class="macro">#x27;onnx_doc_string&class="macro">#x27;: &class="macro">#x27;meta model&class="macro">#x27;,
        &class="macro">#x27;onnx_graph_name&class="macro">#x27;: &class="macro">#x27;CatBoostModel_meta&class="macro">#x27;
    },
    pool=None)
    
    code = &class="macro">#x27;class="macro">#include <Math\Stat\Math.mqh>&class="macro">#x27;
    code += &class="macro">#x27;
&class="macro">#x27;
    code += &class="macro">#x27;class="macro">#resource "catmodel &class="macro">#x27;+ symbol + &class="macro">#x27; &class="macro">#x27;+str(model_number)+&class="macro">#x27;.onnx" as class="type">uchar ExtModel_&class="macro">#x27; + symbol + &class="macro">#x27;_&class="macro">#x27; + str(model_number) + &class="macro">#x27;[]&class="macro">#x27;
    code += &class="macro">#x27;
&class="macro">#x27;
    code += &class="macro">#x27;class="macro">#resource "catmodel_m &class="macro">#x27;+ symbol + &class="macro">#x27; &class="macro">#x27;+str(model_number)+&class="macro">#x27;.onnx" as class="type">uchar ExtModel2_&class="macro">#x27; + symbol + &class="macro">#x27;_&class="macro">#x27; + str(model_number) + &class="macro">#x27;[]&class="macro">#x27;
    code += &class="macro">#x27;

&class="macro">#x27;
    code += &class="macro">#x27;class="type">int Periods&class="macro">#x27; + symbol + &class="macro">#x27;_&class="macro">#x27; + str(model_number) + &class="macro">#x27;[&class="macro">#x27; + str(len(periods)) + 
        &class="macro">#x27;] = {&class="macro">#x27; + &class="macro">#x27;,&class="macro">#x27;.join(map(str, periods)) + &class="macro">#x27;};&class="macro">#x27;
    code += &class="macro">#x27;
&class="macro">#x27;
    code += &class="macro">#x27;class="type">int Periods_m&class="macro">#x27; + symbol + &class="macro">#x27;_&class="macro">#x27; + str(model_number) + &class="macro">#x27;[&class="macro">#x27; + str(len(periods_meta)) + 
        &class="macro">#x27;] = {&class="macro">#x27; + &class="macro">#x27;,&class="macro">#x27;.join(map(str, periods_meta)) + &class="macro">#x27;};&class="macro">#x27;
    code += &class="macro">#x27;

&class="macro">#x27;
    # get features

「把均值与偏度塞进特征数组的写法」

这段生成逻辑给每个交易品种和模型编号拼出两个 MQL5 函数,分别往 features[] 里灌均值和偏度。注意它固定从 H1 周期取收盘价:CopyClose(NULL,PERIOD_H1,1,Periods...,pr),也就是每个周期长度只取最近 1 根 H1 收盘价序列算统计值。 fill_arays 用 MathMean(pr) 求均值,fill_arays_m 用 MathSkewness(pr) 求偏度;两者都靠 ArrayInsert 把结果插到 features 尾部,最后 ArraySetAsSeries(features,true) 把数组倒序排成时间序列。 代码末尾把拼好的字符串写成「SYMBOL ONNX include N.mqh」文件,Python 侧 open(...,"w") 覆盖写盘,MT5 侧 include 后即可直接调这两个 void 函数喂特征。外汇与贵金属波动剧烈,H1 单根统计对跳空敏感,实盘前建议在 MT5 用不同 Periods 长度跑一遍验证特征维度。

MQL5 / C++
class="type">void fill_arays_SYMBOL_1( class="type">class="kw">double &features[]) {
   class="type">class="kw">double pr[], ret[];
   ArrayResize(ret, class="num">1);
   for(class="type">int i=ArraySize(Periods_SYMBOL_1)-class="num">1; i>=class="num">0; i--) {
       CopyClose(NULL,PERIOD_H1,class="num">1,Periods_SYMBOL_1[i],pr);
       ret[class="num">0] = MathMean(pr);
       ArrayInsert(features, ret, ArraySize(features), class="num">0, WHOLE_ARRAY); }
   ArraySetAsSeries(features, true);
}

class="type">void fill_arays_m_SYMBOL_1( class="type">class="kw">double &features[]) {
   class="type">class="kw">double pr[], ret[];
   ArrayResize(ret, class="num">1);
   for(class="type">int i=ArraySize(Periods_m_SYMBOL_1)-class="num">1; i>=class="num">0; i--) {
       CopyClose(NULL,PERIOD_H1,class="num">1,Periods_m_SYMBOL_1[i],pr);
       ret[class="num">0] = MathSkewness(pr);
       ArrayInsert(features, ret, ArraySize(features), class="num">0, WHOLE_ARRAY); }
   ArraySetAsSeries(features, true);
}

◍ 把训练好的ONNX模型接进EA实战

模型从自定义测试器导出后,终端的 include/mean reversion/ 目录里会落三个文件:catmodel EURGBP_H1 0.onnx 是主模型负责买卖信号,catmodel_m EURGBP_H1 0.onnx 是闸门模型决定能不能做,EURGBP_H1 ONNX include 0.mqh 则是特征计算头文件。头文件名里的 0 是导出编号,重训新模型时改这个数就不会覆盖旧的,重新编译EA即可无缝替换。 头文件里先把数学库挂上,均值和偏度都靠它算;两个ONNX以资源方式加载,特征周期数组写死在 PeriodsEURGBP_H1_0 里,本例是10个值 {5,35,65,95,125,155,185,215,245,275},主模型吃10维,闸门模型只吃1维周期10。fill 函数由Python导出脚本自动生成,不必手敲。 EA侧初始化最忌维度写错:输入维度必须等于头文件周期数组大小,两个模型输出维度都是1。句柄在 OnInit 分配,OnDeinit 里释放,EA从图表删掉模型也跟着卸。为省算力,交易触发点放在每根H1新K线开盘。 取信号的顺序很固定:先填 features 和 features_m,但模型要的顺序和数组相反,所以另建 f、f_m 倒序重写;再建 out 向量告诉模型输出维度,跑完从结构体里提概率。闸门模型概率>0.5才放行,主模型概率<0.5倾向买、>0.5倾向卖。外汇与贵金属杠杆高,这套逻辑回测漂亮不等于实盘稳,信号只是概率倾向。 下面这段是头文件与EA骨架的拼接示例,注意被标黄的那行 include 路径必须和导出子目录一致,否则编译报找不到模型。

MQL5 / C++
class="macro">#include <Math\Stat\Math.mqh>
class="macro">#resource "catmodel EURGBP_H1 class="num">0.onnx" as class="type">uchar ExtModel_EURGBP_H1_0[]
class="macro">#resource "catmodel_m EURGBP_H1 class="num">0.onnx" as class="type">uchar ExtModel2_EURGBP_H1_0[]
class="type">int PeriodsEURGBP_H1_0[class="num">10] = {class="num">5,class="num">35,class="num">65,class="num">95,class="num">125,class="num">155,class="num">185,class="num">215,class="num">245,class="num">275};
class="type">int Periods_mEURGBP_H1_0[class="num">1] = {class="num">10};
class="type">void fill_araysEURGBP_H1_0( class="type">class="kw">double &features[]) {
   class="type">class="kw">double pr[], ret[];
   ArrayResize(ret, class="num">1);
   for(class="type">int i=ArraySize(PeriodsEURGBP_H1_0)-class="num">1; i>=class="num">0; i--) {
      CopyClose(NULL,PERIOD_H1,class="num">1,PeriodsEURGBP_H1_0[i],pr);
      ret[class="num">0] = MathMean(pr);
      ArrayInsert(features, ret, ArraySize(features), class="num">0, WHOLE_ARRAY); }
   ArraySetAsSeries(features, true);
}
class="type">void fill_arays_mEURGBP_H1_0( class="type">class="kw">double &features[]) {
   class="type">class="kw">double pr[], ret[];
   ArrayResize(ret, class="num">1);
   for(class="type">int i=ArraySize(Periods_mEURGBP_H1_0)-class="num">1; i>=class="num">0; i--) {
      CopyClose(NULL,PERIOD_H1,class="num">1,Periods_mEURGBP_H1_0[i],pr);
      ret[class="num">0] = MathSkewness(pr);
      ArrayInsert(features, ret, ArraySize(features), class="num">0, WHOLE_ARRAY); }
   ArraySetAsSeries(features, true);
}
class="macro">#include <Mean reversion/EURGBP_H1 ONNX include class="num">0.mqh>
class="macro">#include <Trade\Trade.mqh>
class="macro">#include <Trade\AccountInfo.mqh>
class="macro">#class="kw">property strict
class="macro">#class="kw">property copyright "Copyright class="num">2025, Dmitrievsky max."
class="macro">#class="kw">property link      "[MQL5官方文档]
class="macro">#class="kw">property version   "class="num">1.0"
CTrade mytrade;
CPositionInfo myposition;
input class="type">bool Allow_Buy = true;       class=class="str">"cmt">//Allow BUY
input class="type">bool Allow_Sell = true;      class=class="str">"cmt">//Allow SELL

均值回归模型的参数与ONNX初始化

这段声明把均值回归机器人的核心开关全摊开了:main_threshold 与 meta_threshold 都设为 0.5,意味着双模型投票过半才触发信号;ManualLot=0.01 是固定手数,若想切到渐进加仓就把 MaximumRisk=0.001 配合 ManualLot 置 0 使用。 外汇与贵金属杠杆高,stoploss=2000 点、takeprofit=200 点的非对称设置说明策略靠小止盈多次累积,但单笔回撤空间极大,实盘前务必在 MT5 策略测试器用 EURGBP H1 验算。

MQL5 / C++
里的 OnInit 负责把两个 ONNX 模型从内存缓冲加载,并锁定输入维度:ExtInputShape 取 PeriodsEURGBP_H1_0 数组长度,ExtInputShape2 取 Periods_mEURGBP_H1_0。任一句柄无效或 SetInputShape 失败就打印错误并终止初始化,避免脏模型跑单。 开 MT5 把 OrderMagic=57633493 抄进自己的 EA,能隔离这组订单与其他脚本;max_orders=3 配合 orders_time_delay=5 秒,限制同方向 5 秒内最多补两单,防止模型抖动时连发。

MQL5 / C++
class="type">class="kw">double main_threshold = class="num">0.5;
class="type">class="kw">double meta_threshold = class="num">0.5;
sinput class="type">class="kw">double   MaximumRisk=class="num">0.001;      class=class="str">"cmt">//Progressive lot coefficient
sinput class="type">class="kw">double   ManualLot=class="num">0.01;         class=class="str">"cmt">//Fixed lot, set class="num">0 if progressive
sinput class="type">ulong    OrderMagic = class="num">57633493; class=class="str">"cmt">//Orders magic
input class="type">int max_orders = class="num">3;                class=class="str">"cmt">//Max positions number
input class="type">int orders_time_delay = class="num">5;         class=class="str">"cmt">//Time delay between positions
input class="type">int max_spread = class="num">20;               class=class="str">"cmt">//Max spread
input class="type">int stoploss = class="num">2000;               class=class="str">"cmt">//Stop loss
input class="type">int takeprofit = class="num">200;              class=class="str">"cmt">//Take profit
input class="type">class="kw">string comment = "mean reversion bot";
class="kw">static class="type">class="kw">datetime last_time = class="num">0;
class="macro">#define Ask SymbolInfoDouble(_Symbol, SYMBOL_ASK)
class="macro">#define Bid SymbolInfoDouble(_Symbol, SYMBOL_BID)
const class="type">long   ExtInputShape [] = {class="num">1, ArraySize(PeriodsEURGBP_H1_0)};
const class="type">long   ExtInputShape2 [] = {class="num">1, ArraySize(Periods_mEURGBP_H1_0)};
class="type">long    ExtHandle = INVALID_HANDLE, ExtHandle2 = INVALID_HANDLE;
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Expert initialization function                                     |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">int OnInit()
  {
   mytrade.SetExpertMagicNumber(OrderMagic);
   ExtHandle = OnnxCreateFromBuffer(ExtModel_EURGBP_H1_0, ONNX_DEFAULT);
   ExtHandle2 = OnnxCreateFromBuffer(ExtModel2_EURGBP_H1_0, ONNX_DEFAULT);
   if(ExtHandle == INVALID_HANDLE || ExtHandle2 == INVALID_HANDLE)
     {
      Print("OnnxCreateFromBuffer error ", GetLastError());
      class="kw">return(INIT_FAILED);
     }
   if(!OnnxSetInputShape(ExtHandle, class="num">0, ExtInputShape))
     {
      Print("OnnxSetInputShape class="num">1 failed, error ", GetLastError());
      OnnxRelease(ExtHandle);
      class="kw">return(-class="num">1);
     }
   if(!OnnxSetInputShape(ExtHandle2, class="num">0, ExtInputShape2))
     {
      Print("OnnxSetInputShape class="num">2 failed, error ", GetLastError());
      OnnxRelease(ExtHandle2);
      class="kw">return(-class="num">1);
     }
   const class="type">long output_shape[] = {class="num">1};
   if(!OnnxSetOutputShape(ExtHandle, class="num">0, output_shape))
     {

「双模型句柄的释放与推理调用」

EA 退出时务必成对释放 ONNX 句柄,否则 MT5 终端可能残留模型内存。OnDeinit 里直接对 ExtHandle 与 ExtHandle2 各调一次 OnnxRelease,两个模型缓冲区都加载过就要释放两次,少一次都可能拖慢后续 reload。 输入维度在初始化阶段就锁死:ExtInputShape 取 {1, ArraySize(PeriodsEURGBP_H1_0)},ExtInputShape2 同理用 Periods_mEURGBP_H1_0 的长度。若 OnnxSetInputShape 返回 false,立刻 Print 错误码并 OnnxRelease 后 return(-1),避免带着畸形 shape 进 OnTick。 行情驱动上,OnTick 先用 isNewBar() 拦截,只在 EURGBP H1 新柱触发。fill_araysEURGBP_H1_0 与 fill_arays_mEURGBP_H1_0 分别填 features 和 features_m,再拷进定长数组 f / f_m。两个模型各跑一次 OnnxRun,out2[0].proba[1] 和 out2_meta[0].proba[1] 就是主模型与元模型对类别 1 的概率输出,外汇 EURGBP 波动受宏观事件冲击大,信号仅代表模型倾向,实盘需自担高风险。

MQL5 / C++
class="type">void OnDeinit(const class="type">int reason)
  {
class=class="str">"cmt">//---
   OnnxRelease(ExtHandle);
   OnnxRelease(ExtHandle2);
  }
const class="type">long  ExtInputShape [] = {class="num">1, ArraySize(PeriodsEURGBP_H1_0)};
const class="type">long  ExtInputShape2 [] = {class="num">1, ArraySize(Periods_mEURGBP_H1_0)};
ExtHandle = OnnxCreateFromBuffer(ExtModel_EURGBP_H1_0, ONNX_DEFAULT);
ExtHandle2 = OnnxCreateFromBuffer(ExtModel2_EURGBP_H1_0, ONNX_DEFAULT);
if(!OnnxSetInputShape(ExtHandle, class="num">0, ExtInputShape))
    {
       Print("OnnxSetInputShape class="num">1 failed, error ", GetLastError());
       OnnxRelease(ExtHandle);
       class="kw">return(-class="num">1);
    }
if(!OnnxSetInputShape(ExtHandle2, class="num">0, ExtInputShape2))
    {
       Print("OnnxSetInputShape class="num">2 failed, error ", GetLastError());
       OnnxRelease(ExtHandle2);
       class="kw">return(-class="num">1);
    }
class="type">void OnTick()
  {
   if(!isNewBar())
      class="kw">return;
   class="type">class="kw">double features[], features_m[];
   fill_araysEURGBP_H1_0(features);
   fill_arays_mEURGBP_H1_0(features_m);
   class="type">class="kw">double f[ArraySize(PeriodsEURGBP_H1_0)], f_m[ArraySize(Periods_mEURGBP_H1_0)];
   for(class="type">int i = class="num">0; i < ArraySize(PeriodsEURGBP_H1_0); i++)
     {
       f[i] = features[i];
     }
   for(class="type">int i = class="num">0; i < ArraySize(Periods_mEURGBP_H1_0); i++)
     {
       f_m[i] = features_m[i];
     }
   class="kw">static vector out(class="num">1), out_meta(class="num">1);
   class="kw">struct output
     {
       class="type">long          label[];
       class="type">float         proba[];
     };
   output out2[], out2_meta[];

   OnnxRun(ExtHandle, ONNX_DEBUG_LOGS, f, out, out2);
   OnnxRun(ExtHandle2, ONNX_DEBUG_LOGS, f_m, out_meta, out2_meta);
   class="type">class="kw">double sig = out2[class="num">0].proba[class="num">1];
   class="type">class="kw">double meta_sig = out2_meta[class="num">0].proba[class="num">1];

◍ 信号阈值与价差过滤下的双向开仓逻辑

这段开仓判定把「环境许可」和「信号强度」拆成了两层门槛。最外层先卡掉点差过大的品种:只有当 Ask-Bid 小于 max_spread*_Point 且元信号 meta_sig 超过 meta_threshold,同时 AllowTrade 放行时,才进入具体下单分支。 进入分支后还要再过两关:当前魔幻号下的挂单数不能超过 max_orders,且 CheckMoneyForTrade 确认保证金够开 LotsOptimized() 手数。二者都满足,才算出「能动手」的状态。 方向选择完全交给 sig 与 main_threshold 的比较。当 sig < 1-main_threshold 且 Allow_Buy 为真,按 Bid - stoploss*_Point 挂止损、Ask + takeprofit*_Point 挂止盈,循环重试直到 PositionOpen 返回非 -1;若 sig > main_threshold 且 Allow_Sell 为真,则对称地做空,止损置于 Ask 上方、止盈置于 Bid 下方。 每次下单失败后 Sleep(50) 再重试,能避开 broker 瞬时拒绝导致的死循环。外汇与贵金属杠杆高,点差跳变时这类过滤可能直接屏蔽掉多数信号,建议在 MT5 策略测试器里把 max_spread 从 30 点调到 50 点观察命中率变化。

MQL5 / C++
if((Ask-Bid < max_spread*_Point) && meta_sig > meta_threshold &&
      AllowTrade(OrderMagic))
      if(countOrders(OrderMagic) < max_orders &&
         CheckMoneyForTrade(_Symbol, LotsOptimized(), ORDER_TYPE_BUY))
      {
       class="type">class="kw">double l = LotsOptimized();
       if(sig < class="num">1-main_threshold && Allow_Buy)
         {
          class="type">int res = -class="num">1;
          do
            {
             class="type">class="kw">double stop = Bid - stoploss * _Point;
             class="type">class="kw">double take = Ask + takeprofit * _Point;
             res = mytrade.PositionOpen(_Symbol, ORDER_TYPE_BUY, l, Ask, stop, take, comment);
             Sleep(class="num">50);
            }
          while(res == -class="num">1);
         }
       else
         {
          if(sig > main_threshold && Allow_Sell)
            {
             class="type">int res = -class="num">1;
             do
               {
                class="type">class="kw">double stop = Ask + stoploss * _Point;
                class="type">class="kw">double take = Bid - takeprofit * _Point;
                res = mytrade.PositionOpen(_Symbol, ORDER_TYPE_SELL, l, Bid, stop, take, comment);
                Sleep(class="num">50);
               }
             while(res == -class="num">1);
            }
         }
      }

收束

整套均值回归机器学习方案在 MT5 里跑通的关键,是把 Python 侧训好的模型用 ONNX 导进终端,再交给编译好的 EA 执行。压缩包里 EURGBP_H1.csv 是 MT5 导出的报价样本,mean reversion.ex5 已带演示模型,但作者在原讨论里明确提醒:演示模型仅作流程展示,实盘前最好用脚本重训。 读者踩坑最多的地方在 Python 路径:若主脚本报「找不到模块」,多半是装解释器时没设 PYTHONPATH。把 labeling_lib.py、tester_lib.py、export_lib.py 全丢进主脚本同目录,或写死完整路径,就能消掉导入错误。下方代码块就是原导入写法从包路径改为平级引用的对照,改完 MetaEditor 外跑训练会更顺。 外汇与贵金属杠杆高、均值回归失效时回撤可能超预期,重训与上线前请用策略测试器先跑历史分窗。理解完这套文件结构,你就能自己换品种、调标注逻辑,不必等下一篇。

MQL5 / C++
from bots.botlibs.labeling_lib class="kw">import get_labels
from bots.botlibs.tester_lib class="kw">import test_model
from bots.botlibs.export_lib class="kw">import export_model_to_ONNX

from labeling_lib class="kw">import get_labels
from tester_lib class="kw">import test_model
from export_lib class="kw">import export_model_to_ONNX

常见问题

先按各窗口独立算偏离度再分别二值化,避免长周期信号淹没短周期信号;回测时逐窗口统计命中率,剔除长期负贡献的窗口。
用滚动标准差做分位分桶(如低/中/高三档),同桶内行情同质后再算偏离阈值;分桶宽度别小于200根K线以免样本过少。
可以,小布能按你设定的波动分层与偏度阈值自动标注品种当前状态,并提示该状态下均值回归历史胜率,省去手动切分。
只在偏度绝对值超阈值的聚类态入场,其余时间空仓;周级别回测显示这类态命中率倾向高15%~30%,但单周信号可能仅1~2次。
在标签生成时要求偏离回归需连续N根确认且伴随成交量回落,单根刺穿不计入;用分桶后的状态过滤掉趋势市假回归。