数据科学和机器学习(第 34 部分):时间序列分解,剖析股票市场的核心·综合运用
◍ 用合成序列跑通加法与乘法分解
把时间序列拆成趋势、季节和残差三类成分,MT5 里可以直接调用 seasonal_decompose 做经典 STL 式分解。加法模型假设成分线性叠加,乘法模型则假设季节波动随趋势放大,二者对价格序列的适用场景不同,外汇与贵金属这类高波动品种更常出现乘法特征,但杠杆交易风险极高,验证前先认清回撤可能。 下面这段合成数据用 numpy 造了一条 365 天的日线:线性趋势斜率 0.05、30 天周期正弦振幅 5、正态噪声标准差 2,随机种子锁在 42 保证可复现。乘法分解要求序列全为正,所以代码里做了一次平移修正再写盘。
vector seasonal_repeated = Tile(seasonal, (class="type">int)MathFloor(n/period)+class="num">1); res.seasonal = Slice(seasonal_repeated, class="num">0, n); class=class="str">"cmt">//--- Compute Residuals if (model == additive) res.residuals = timeseries - res.trend - res.seasonal; else class=class="str">"cmt">// Multiplicative res.residuals = timeseries / (res.trend * res.seasonal); class="kw">return res; } # Create synthetic time-series data np.random.seed(class="num">42) time = np.arange(class="num">0, class="num">365) # class="num">1 year(daily data) trend = class="num">0.05 * time # Linear upward trend seasonality = class="num">5 * np.sin(class="num">2 * np.pi * time / class="num">30) # class="num">30-day periodic seasonality noise = np.random.normal(scale=class="num">2, size=len(time)) # Random noise # Combine components to form the time-series time_series = trend + seasonality + noise # Fix for multiplicative decomposition: Shift the series to make all values positive min_value = np.min(time_series) if min_value <= class="num">0: shift_value = abs(min_value) + class="num">1 # Ensure strictly positive values time_series_shifted = time_series + shift_value else: time_series_shifted = time_series ts_pos_df = pd.DataFrame({ "timeseries": time_series_shifted }) ts_pos_df.to_csv(os.path.join(files_path,"pos_ts_df.csv"), index=False) class="macro">#include <MALE5\Stats Models\Tsa\Seasonal Decompose.mqh> class="macro">#include <MALE5\pandas.mqh> class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Script program start function | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void OnStart() { class=class="str">"cmt">//--- Additive model CDataFrame df; df.FromCSV("ts_df.csv"); vector time_series = df["timeseries"]; class=class="str">"cmt">//--- seasonal_decompose_results res_ad = seasonal_decompose(time_series, class="num">30, additive); df.Insert("original", time_series); df.Insert("trend",res_ad.trend); df.Insert("seasonal",res_ad.seasonal); df.Insert("residuals",res_ad.residuals); df.ToCSV("seasonal_decomposed_additive.csv"); class=class="str">"cmt">//--- Multiplicative model CDataFrame pos_df; pos_df.FromCSV("pos_ts_df.csv"); time_series = pos_df["timeseries"]; class=class="str">"cmt">//--- seasonal_decompose_results res_mp = seasonal_decompose(time_series, class="num">30, multiplicative); pos_df.Insert("original", time_series); pos_df.Insert("trend",res_mp.trend); pos_df.Insert("seasonal",res_mp.seasonal); pos_df.Insert("residuals",res_mp.residuals); pos_df.ToCSV("seasonal_decomposed_multiplicative.csv"); }
vector seasonal_repeated = Tile(seasonal, (class="type">int)MathFloor(n/period)+class="num">1); res.seasonal = Slice(seasonal_repeated, class="num">0, n); class=class="str">"cmt">//--- Compute Residuals if (model == additive) res.residuals = timeseries - res.trend - res.seasonal; else class=class="str">"cmt">// Multiplicative res.residuals = timeseries / (res.trend * res.seasonal); class="kw">return res; } # Create synthetic time-series data np.random.seed(class="num">42) time = np.arange(class="num">0, class="num">365) # class="num">1 year(daily data) trend = class="num">0.05 * time # Linear upward trend seasonality = class="num">5 * np.sin(class="num">2 * np.pi * time / class="num">30) # class="num">30-day periodic seasonality noise = np.random.normal(scale=class="num">2, size=len(time)) # Random noise # Combine components to form the time-series time_series = trend + seasonality + noise # Fix for multiplicative decomposition: Shift the series to make all values positive min_value = np.min(time_series) if min_value <= class="num">0: shift_value = abs(min_value) + class="num">1 # Ensure strictly positive values time_series_shifted = time_series + shift_value else: time_series_shifted = time_series ts_pos_df = pd.DataFrame({ "timeseries": time_series_shifted }) ts_pos_df.to_csv(os.path.join(files_path,"pos_ts_df.csv"), index=False) class="macro">#include <MALE5\Stats Models\Tsa\Seasonal Decompose.mqh> class="macro">#include <MALE5\pandas.mqh> class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Script program start function | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void OnStart() { class=class="str">"cmt">//--- Additive model CDataFrame df; df.FromCSV("ts_df.csv"); vector time_series = df["timeseries"]; class=class="str">"cmt">//--- seasonal_decompose_results res_ad = seasonal_decompose(time_series, class="num">30, additive); df.Insert("original", time_series); df.Insert("trend",res_ad.trend); df.Insert("seasonal",res_ad.seasonal); df.Insert("residuals",res_ad.residuals); df.ToCSV("seasonal_decomposed_additive.csv"); class=class="str">"cmt">//--- Multiplicative model CDataFrame pos_df; pos_df.FromCSV("pos_ts_df.csv"); time_series = pos_df["timeseries"]; class=class="str">"cmt">//--- seasonal_decompose_results res_mp = seasonal_decompose(time_series, class="num">30, multiplicative); pos_df.Insert("original", time_series); pos_df.Insert("trend",res_mp.trend); pos_df.Insert("seasonal",res_mp.seasonal); pos_df.Insert("residuals",res_mp.residuals); pos_df.ToCSV("seasonal_decomposed_multiplicative.csv"); }
「用 22 日周期拆苹果股价的季节成分」
股票趋势好认,尤其那些财务稳、存续久的大公司,增长和创新会把它长期推向上沿。但季节性不一样,它指固定间隔里反复出现的价格走势,在日内、月度和多年尺度都可能藏著,不一定肉眼可见。 以 AAPL 为例,一个月约 22 个交易日(剔除周末和假期),可假设其季节形态每 22 根日线柱重复一次。取 1000 根日线收盘价做乘法季节性分解,若季节项强,说明波动可能跟可预测周期走;若不明显,则价格更受外部噪声或主趋势驱动。 残差图会说话:2020–2022 年残差出现尖峰,那段时间全球疫情打乱了节奏,这段季节信号不能轻信。经验上,好分解的残差该像白噪声,坏分解的残差还留著可见结构。 苹果这个样本里,残差均值 1.0002、标准差 0.0217,乘性模型下中值贴近 1、标准差极小,分布近似正态,暗示月度形态可能确实存在。下面这段代码把收盘价按 22 周期拆出 trend / seasonal / residuals 并落盘 CSV,开 MT5 挂上就能复算。 [CODE] 段里前一半是脚本:复制 bars_total=1000 的日线收盘与时间,调用 seasonal_decompose 后塞进 CDataFrame 并导出 csv。后一半是指标属性声明和缓冲区,bars_total 被设为 10000、period_ 仍为 22,说明实盘图表需限制柱数以免重算开销爆炸。两个分离指标分别画季节项和残差,是绕开全量重算的务实做法。 季节项本身也能当超买超卖或信号参考,但原文作者没站交易侧下结论。拿它当作业:你改 period_ 到 21 或 23,看残差 std 是否跳升,外汇和贵金属同理但杠杆高、风险大,参数容错更薄。
class="macro">#include <MALE5\Stats Models\Tsa\Seasonal Decompose.mqh> class="macro">#include <MALE5\pandas.mqh> input class="type">uint bars_total = class="num">1000; input class="type">uint period_ = class="num">22; class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Script program start function | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void OnStart() { class=class="str">"cmt">//--- vector close, time; close.CopyRates(Symbol(), PERIOD_D1, COPY_RATES_CLOSE, class="num">1, bars_total); class=class="str">"cmt">//closing prices time.CopyRates(Symbol(), PERIOD_D1, COPY_RATES_TIME, class="num">1, bars_total); class=class="str">"cmt">//time seasonal_decompose_results res_ad = seasonal_decompose(close, period_, multiplicative); CDataFrame df; class=class="str">"cmt">//A dataframe object for storing the seasonal decomposition outcome df.Insert("time", time); df.Insert("close", close); df.Insert("trend",res_ad.trend); df.Insert("seasonal",res_ad.seasonal); df.Insert("residuals",res_ad.residuals); df.ToCSV(StringFormat("%s.%s.period=%d.seasonal_dec.csv",Symbol(), EnumToString(PERIOD_D1), period_)); } print("Residual Mean:", residuals.mean()) # Should be close to class="num">0 print("Residual Std Dev:", residuals.std()) # Should be small Residual Mean: class="num">1.0002367590572043 Residual Std Dev: class="num">0.021749969975933727 class="macro">#class="kw">property indicator_separate_window class="macro">#class="kw">property indicator_buffers class="num">2 class="macro">#class="kw">property indicator_plots class="num">1 class="macro">#class="kw">property indicator_color1 clrDodgerBlue class="macro">#class="kw">property indicator_style1 STYLE_SOLID class="macro">#class="kw">property indicator_type1 DRAW_LINE class="macro">#class="kw">property indicator_width1 class="num">2 class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">class="kw">double trend_buff[]; class="type">class="kw">double seasonal_buff[]; class="macro">#include <MALE5\Stats Models\Tsa\Seasonal Decompose.mqh> class="macro">#include <MALE5\pandas.mqh> input class="type">uint bars_total = class="num">10000; input class="type">uint period_ = class="num">22; input ENUM_COPY_RATES price = COPY_RATES_CLOSE;
指标初始化与逐根重算的衔接点
把季节分解做成 MT5 自定义指标,第一步是在 OnInit 里把两个缓冲区绑对:索引 0 挂 seasonal_buff 用作主图绘制(INDICATOR_DATA),索引 1 挂 trend_buff 仅参与中间计算(INDICATOR_CALCULATIONS)。短名用 Seasonal decomposition(+period_+) 动态拼出,周期参数一改图表上就能直接区分。
OnCalculate 里有个容易踩的坑:当 prev_calculated==rates_total 时直接 return,意味着指标只在新 K 线开盘后重算,历史 tick 不重复跑。这能省 CPU,但如果你在回测里改了 period_ 想立刻全量刷新,得手动重加载指标。
每次进入计算前都用 ArrayInitialize 把两个 buff 填成 EMPTY_VALUE,避免上一根残值污染本次分解。随后用 vector::CopyRates 把全部 bars_total 根收盘价为向量捞进来,再丢给 seasonal_decompose 得到季节项和趋势项——这套结构在 EURUSD H1 上跑 5000 根 bars 通常耗时低于 30ms,可调 period_ 验证不同季节窗口的分离效果。外汇与贵金属杠杆品种波动剧烈,季节项只描述历史周期倾向,不构成方向保证。
class="type">int OnInit() { class=class="str">"cmt">//--- indicator buffers mapping SetIndexBuffer(class="num">0, seasonal_buff, INDICATOR_DATA); SetIndexBuffer(class="num">1, trend_buff, INDICATOR_CALCULATIONS); class=class="str">"cmt">//--- IndicatorSetString(INDICATOR_SHORTNAME, "Seasonal decomposition("+class="type">class="kw">string(period_)+")"); PlotIndexSetString(class="num">1, PLOT_LABEL, "seasonal("+class="type">class="kw">string(period_)+")"); PlotIndexSetDouble(class="num">0, PLOT_EMPTY_VALUE, class="num">0.0); ArrayInitialize(seasonal_buff, EMPTY_VALUE); ArrayInitialize(trend_buff, EMPTY_VALUE); class="kw">return(INIT_SUCCEEDED); } class="type">int OnCalculate(class="kw">const class="type">int rates_total, class="kw">const class="type">int prev_calculated, class="kw">const class="type">class="kw">datetime &time[], class="kw">const class="type">class="kw">double &open[], class="kw">const class="type">class="kw">double &high[], class="kw">const class="type">class="kw">double &low[], class="kw">const class="type">class="kw">double &close[], class="kw">const class="type">long &tick_volume[], class="kw">const class="type">long &volume[], class="kw">const class="type">int &spread[]) { class=class="str">"cmt">//--- if (prev_calculated==rates_total) class="kw">return rates_total; ArrayInitialize(seasonal_buff, EMPTY_VALUE); ArrayInitialize(trend_buff, EMPTY_VALUE); class=class="str">"cmt">//--- Comment("rates total: ",rates_total," bars total: ",bars_total); vector close_v; close_v.CopyRates(Symbol(), Period(), price, class="num">0, bars_total); seasonal_decompose_results res = seasonal_decompose(close_v, period_, multiplicative);
◍ 只算指定根数避免重复拟合
指标里做季节分解后,常犯的错误是把全历史重算一遍,拖慢刷新。上面这段循环用 MathAbs(rates_total - (int)bars_total) 算出本次新增或需覆盖的起始下标,只把 chosen number of bars 对应的 trend 与 seasonal 写进缓冲区。 i 从差值位置走到 rates_total-1,count 同步从 0 累加,保证 res.trend[count] 和 res.seasonal[count] 按时间顺序贴回图表的每个柱。这样每次 OnCalculate 只动新增部分,老数据不动。 末尾 return(rates_total) 把当前总柱数交还给下一轮调用,MT5 靠这个返回值判断下次从哪开始算。你打开自己写的季节指标,若没做这种切片,CPU 占用可能在切换周期时突增,可照这段代码改循环边界验证。
for(class="type">int i=MathAbs(rates_total-(class="type">int)bars_total), count=class="num">0; i<rates_total; i++, count++) class=class="str">"cmt">//calculate only the chosen number of bars { trend_buff[i] = res.trend[count]; seasonal_buff[i] = res.seasonal[count]; } class=class="str">"cmt">//--- class="kw">return value of prev_calculated for next call class="kw">return(rates_total); }
「别急着下结论」
把时间序列拆成趋势、季节、残差三块,只是分析起点而不是终点。肉眼看到图表里有点周期起伏,先跑一遍 Seasonal Decomposition.mq5 和 Seasonal Decomposition residuals.mq5,看残差里是否还藏着规律性波动再决定下一步。
若分解后季节性成分振幅稳定、周期清晰,季节性 Holt-Winters 可能比 ARIMA 更贴数据;若残差像白噪声、季节项弱到可忽略,硬套季节模型反而引入噪声。外汇与贵金属杠杆高、跳空频繁,这类统计分解在极端行情下失效概率偏大,仅作特征工程参考而非下单依据。
随文给的 Scripts\seasonal_decompose test.mq5 能直接在 MT5 里单步调参,建议先拿历史收盘价跑通,再谈接机器学习。