数据科学与机器学习(第24部分):使用常规AI模型进行外汇时间序列预测·进阶篇
(2/3)·从回归到分类,跳过平稳性陷阱的模型在实盘里多半撑不过一周
不少交易者把历史K线直接丢进LightGBM就以为拿到了预测引擎,结果在策略测试器里秒崩。外汇序列里藏着趋势与季节扰动,不先问数据稳不稳,模型学到的只是噪声记忆。本篇接着基础概念,直接进训练与检验的实操层。
「用LightGBM回归给收盘价建模」
把整理好的特征矩阵按 7:3 切分,前 700 行喂训练、后 300 行留测试,总共 26 个特征列。LGBMRegressor 拟合后,在测试集上用 r2_score 打分,全量数据预测收盘价的 R² 达到 0.84,看起来能用,但别急着当圣杯。 树模型算特征重要性看的是分裂频次和分裂带来的增益,跟线性类方法不是一回事。从 gain 图的输出看,DAYOFWEEK、MONTH、DAYOFMONTH、DAYOFYEAR 这几个想抓季节性的字段贡献几乎垫底,可怪的是 ADF 检验里它们又都是平稳的——平稳却不重要,说明周期性噪声大概率盖过了真实信号。 想交叉验证重要性,可以上 SHAP 的 TreeExplainer 把每个样本的贡献拆开画 summary plot。外汇和贵金属这种高杠杆品种,模型回测漂亮不代表实盘能跑,样本外漂移随时可能发生,验证时务必留足滚动窗口。
X = df.drop(columns=["TARGET_CLOSE"]) Y = df["TARGET_CLOSE"] train_size = class="num">0.7 class="macro">#configure train size train_size = round(train_size*df.shape[class="num">0]) x_train = X.iloc[:train_size,:] x_test = X.iloc[train_size:, :] y_train = Y.iloc[:train_size] y_test = Y.iloc[train_size:] print(f"x_train_size{x_train.shape} x_test_size{x_test.shape} y_train{y_train.shape} y_test{y_test.shape}") x_train_size(class="num">700, class="num">26) x_test_size(class="num">300, class="num">26) y_train(class="num">700,) y_test(class="num">300,) model = lgb.LGBMRegressor(**params) model.fit(x_train, y_train) from sklearn.metrics class="kw">import r2_score test_pred = model.predict(x_test) accuracy = r2_score(y_test, test_pred) class="macro">#showing actual test values and predictions plt.figure(figsize=(class="num">8, class="num">6)) plt.plot(y_test, label=&class="macro">#x27;Actual Values&class="macro">#x27;) plt.plot(test_pred, label=&class="macro">#x27;Predicted Values&class="macro">#x27;) plt.xlabel(&class="macro">#x27;Index&class="macro">#x27;) plt.ylabel(&class="macro">#x27;Values&class="macro">#x27;) plt.title(&class="macro">#x27;Actual vs. Predicted Values&class="macro">#x27;) plt.legend(loc="lower center") # Add R-squared(accuracy) score in a corner plt.text(class="num">0.05, class="num">0.95, f"LightGBM(Accuracy): {accuracy:.4f}", ha=&class="macro">#x27;left&class="macro">#x27;, va=&class="macro">#x27;top&class="macro">#x27;, transform=plt.gca().transAxes, fontsize=class="num">10, bbox=dict(boxstyle=&class="macro">#x27;round&class="macro">#x27;, facecolor=&class="macro">#x27;white&class="macro">#x27;, alpha=class="num">0.7)) plt.grid(True) plt.savefig("LighGBM Test plot") plt.show() # Plot feature importance using Gain lgb.plot_importance(model, importance_type="gain", figsize=(class="num">8,class="num">6), title="LightGBM Feature Importance(Gain)") plt.tight_layout() plt.savefig("LighGBM feature importance(Gain)") plt.show() explainer = shap.TreeExplainer(model) shap_values = explainer(x_train) shap.summary_plot(shap_values, x_train, max_display=len(x_train.columns), show=False) # Show all features # Adjust layout and set figure size plt.subplots_adjust(left=class="num">0.12, bottom=class="num">0.1, right=class="num">0.9, top=class="num">0.9) plt.gcf().set_size_inches(class="num">6, class="num">8) plt.tight_layout() plt.savefig("SHAP_Feature_Importance_Summary_Plot.png") plt.show()
用ADF筛掉会骗人的非平稳变量
ADF检验干一件事:判断时间序列是否平稳。平稳意味着均值、方差、自相关不随时间漂移,这是很多预测模型成立的前提。外汇和贵金属价格本身的OHLC多数非平稳,但派生出的差值类序列往往平稳——这点在做特征工程时直接决定你喂给模型的数据有没有用。 实测对27个候选变量跑全样本ADF,只有9个通过平稳性(显著性0.05):7DAY_STDDEV、DAYOFMONTH、DAYOFWEEK、DAYOFYEAR、MONTH、DIFF_LAG1_OPEN、DIFF_LAG1_HIGH、DIFF_LAG1_LOW、DIFF_LAG1_CLOSE。其余18个价格类原始字段大概率是漂移的,硬塞进回归或机器学习里会放大伪相关。 肉眼初筛比跑代码快:把变量分布图画出来,数据点紧密裹在均值周围、没有明显扩张或偏移趋势的,大概率平稳。但真要落地还是得看p值。下面这段Python可直接套用,注意它是pandas+statsmodels环境,不是MT5内置,但逻辑你搬到MQL5做预处理前先用Python探一遍最省事。 别把正态当圣经 平稳不等于正态,ADF过了只说明统计属性不随时间变,不保证分布形状好看。贵金属跳空造成的厚尾照样能让模型翻车,风控上外汇贵金属本身就是高杠杆高风险品种,平稳化只是降低过拟合概率,不是免死金牌。
from statsmodels.tsa.stattools class="kw">import adfuller def adf_test(series, signif=class="num">0.05): """ Performs the ADF test on a pandas Series and interprets the results. Args: series: The pandas Series containing the time series data. signif: Significance level for the test(class="kw">default: class="num">0.05). Returns: A dictionary containing the test statistic, p-value, used lags, critical values, and interpretation of stationarity. """ dftest = adfuller(series, autolag=&class="macro">#x27;AIC&class="macro">#x27;) adf_stat = dftest[class="num">0] # Access test statistic pvalue = dftest[class="num">1] # Access p-value usedlag = dftest[class="num">2] # Access used lags critical_values = dftest[class="num">4] # Access critical values interpretation = &class="macro">#x27;Stationary&class="macro">#x27; if pvalue < signif else &class="macro">#x27;Non-Stationary&class="macro">#x27; result = {&class="macro">#x27;Statistic&class="macro">#x27;: adf_stat, &class="macro">#x27;p-value&class="macro">#x27;: pvalue, &class="macro">#x27;Used Lags&class="macro">#x27;: usedlag, &class="macro">#x27;Critical Values&class="macro">#x27;: critical_values, &class="macro">#x27;Interpretation&class="macro">#x27;: interpretation} class="kw">return result for col in df.columns: adf_results = adf_test(df[col], signif=class="num">0.05) print(f"ADF Results for column {col}:\n {adf_results}")
◍ 平稳性为何左右模型可信度
做时间序列分析时,大量统计方法默认数据是平稳的。一旦序列带趋势或结构突变,这类方法给出的结论往往会偏掉,甚至完全误导交易决策。 可以设想一只持续单边上行的股票价格:传统线性外推在这种环境里几乎捕捉不到内在节奏,预测误差会随 horizon 拉长而失控。 我们跑过的 LightGBM 确实能啃下 OHLC 之间的非线性,对平稳性的依赖比 ARIMA 那类模型低。特征重要性排序里,最靠前的恰恰是非平稳的开盘、最高、最低、收盘四个变量。 但这不代表星期几这种平稳特征没用。特征重要性只是切片,不是全貌;在 EURUSD 上,我认为日历效应仍会通过微观流动性渗透进价格,单纯因为它排名靠后就把列删掉并不明智。外汇与贵金属杠杆高、跳空频繁,这类隐性变量在高波动时段的影响概率会被放大,建议开 MT5 用特征消融自己验一遍。
「把收盘价做成一阶差分试试」
做时间序列预测时,目标变量平稳往往能提升不少机器学习模型的稳定性——它的均值、方差和自相关不随时间漂移。直接猜下一段行情往哪走很难,但猜下一段能跑出多少点,相对更可行;若能预测点数,就能反推止损和获利位置。 具体做法是对收盘价取一阶差分:用下一根收盘价减前一根收盘价,得到的新序列在统计上趋于平稳。下面这段 Python 只是思路示意,MQL5 里可用 iClose 自行错位相减。 用 ADF 检验验证,统计量落到 -23.37、p-value 为 0.0,远低于 1% 临界值 -3.437,结论明确是平稳。但把这平稳变量喂给 LightGBM 回归,实测性能反而很差;至少在这份数据上,LightGBM 处理差分目标并不讨好,后续仍沿用直接预测目标收盘价的回归模型。 不过连续收盘价的回归模型,实用价值不如直接出买卖信号的模型。要做信号过滤或触发,得另训一个分类模型,而不是只盯价格数值。
用LightGBM给K线方向打二分类标签
把行情转成机器学习能吃的样本,第一步是把目标变量压成 0/1:收盘高于开盘记 1(偏买入),否则记 0(偏卖出)。这套标签逻辑直接决定了分类器学的是什么,而不是去猜涨跌幅度。 下面这段 Python 先把 TARGET_OPEN 与 TARGET_CLOSE 逐根比对生成 Y,再按 train_size 切出训练集和测试集;随后用 Pipeline 把 StandardScaler 标准化和 LGBMClassifier 串起来,参数里 num_leaves=25、max_depth=5、learning_rate=0.05,属于浅树低学习率的保守配置。 [CODE] Y = [] target_open = df["TARGET_OPEN"] target_close = df["TARGET_CLOSE"] for i in range(len(target_open)): if target_close[i] > target_open[i]: # if the candle closed above where it opened thats a buy signal Y.append(1) else: #otherwise it is a sell signal Y.append(0) # split Y into irrespective training and testing samples y_train = Y[:train_size] y_test = Y[train_size:] from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler params = { 'boosting_type': 'gbdt', # Gradient Boosting Decision Tree 'objective': 'binary', # For binary classification (use 'regression' for regression tasks) 'metric': ['auc','binary_logloss'], # Evaluation metric 'num_leaves': 25, # Number of leaves in one tree 'n_estimators' : 100, # number of trees 'max_depth': 5, 'learning_rate': 0.05, # Learning rate 'feature_fraction': 0.9 # Fraction of features to be used for each boosting round } pipe = Pipeline([ ("scaler", StandardScaler()), ("lgbm", lgb.LGBMClassifier(**params)) ]) # Fit the pipeline to the training data pipe.fit(x_train, y_train) [/CODE] 回测切分后测试集共 300 根样本,模型总体准确率只有 0.53,基本贴近随机猜。分类报告里 0 类 recall 0.79 但 precision 0.49,1 类 precision 0.62 但 recall 仅 0.30——说明模型倾向把多数棒线判为卖出,买入信号漏抓严重。 别把 53% 当废案丢掉 外汇与贵金属属高杠杆高风险品种,这种浅层 LightGBM 单看准确率没用;recall 和 precision 的错位反而提示你:拿它做反向过滤或仓位触发,比直接跟单更稳。开 MT5 导出自家品种的分时 OHLC,重跑上面管道,先盯自己数据的 0/1 分布再谈调参。
Y = [] target_open = df["TARGET_OPEN"] target_close = df["TARGET_CLOSE"] for i in range(len(target_open)): if target_close[i] > target_open[i]: # if the candle closed above where it opened thats a buy signal Y.append(class="num">1) else: class="macro">#otherwise it is a sell signal Y.append(class="num">0) # split Y into irrespective training and testing samples y_train = Y[:train_size] y_test = Y[train_size:] from sklearn.pipeline class="kw">import Pipeline from sklearn.preprocessing class="kw">import StandardScaler params = { &class="macro">#x27;boosting_type&class="macro">#x27;: &class="macro">#x27;gbdt&class="macro">#x27;, # Gradient Boosting Decision Tree &class="macro">#x27;objective&class="macro">#x27;: &class="macro">#x27;binary&class="macro">#x27;, # For binary classification(use &class="macro">#x27;regression&class="macro">#x27; for regression tasks) &class="macro">#x27;metric&class="macro">#x27;: [&class="macro">#x27;auc&class="macro">#x27;,&class="macro">#x27;binary_logloss&class="macro">#x27;], # Evaluation metric &class="macro">#x27;num_leaves&class="macro">#x27;: class="num">25, # Number of leaves in one tree &class="macro">#x27;n_estimators&class="macro">#x27; : class="num">100, # number of trees &class="macro">#x27;max_depth&class="macro">#x27;: class="num">5, &class="macro">#x27;learning_rate&class="macro">#x27;: class="num">0.05, # Learning rate &class="macro">#x27;feature_fraction&class="macro">#x27;: class="num">0.9 # Fraction of features to be used for each boosting round } pipe = Pipeline([ ("scaler", StandardScaler()), ("lgbm", lgb.LGBMClassifier(**params)) ]) # Fit the pipeline to the training data pipe.fit(x_train, y_train)