数据科学和机器学习(第 34 部分):时间序列分解,剖析股票市场的核心·综合运用
📘

数据科学和机器学习(第 34 部分):时间序列分解,剖析股票市场的核心·综合运用

第 3/3 篇

◍ 用合成序列跑通加法与乘法分解

把时间序列拆成趋势、季节和残差三类成分,MT5 里可以直接调用 seasonal_decompose 做经典 STL 式分解。加法模型假设成分线性叠加,乘法模型则假设季节波动随趋势放大,二者对价格序列的适用场景不同,外汇与贵金属这类高波动品种更常出现乘法特征,但杠杆交易风险极高,验证前先认清回撤可能。 下面这段合成数据用 numpy 造了一条 365 天的日线:线性趋势斜率 0.05、30 天周期正弦振幅 5、正态噪声标准差 2,随机种子锁在 42 保证可复现。乘法分解要求序列全为正,所以代码里做了一次平移修正再写盘。

MQL5 / C++
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");
  }
代码逐行拆解:Tile 把基础季节向量按周期倍数铺满全长,Slice 截出前 n 点避免溢出;加法残差用减号、乘法残差用除法,这是两类模型在数学上的分水岭。OnStart 里先读 ts_df.csv 跑加法,再读平移过的 pos_ts_df.csv 跑乘法,各写出带 trend/seasonal/residuals 列的 CSV,你开 MT5 把这两个文件拖进数据目录就能直接复算。 残差列若近似白噪声,说明 30 天周期假设成立;若残差还带明显波动聚集,周期参数可能要重调。外汇实盘里直接套用有滑点与时区错位风险,建议先用历史 CSV 验证再上模拟盘。

MQL5 / C++
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 是否跳升,外汇和贵金属同理但杠杆高、风险大,参数容错更薄。

MQL5 / C++
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_ 验证不同季节窗口的分离效果。外汇与贵金属杠杆品种波动剧烈,季节项只描述历史周期倾向,不构成方向保证。

MQL5 / C++
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 占用可能在切换周期时突增,可照这段代码改循环边界验证。

MQL5 / C++
  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.mq5Seasonal Decomposition residuals.mq5,看残差里是否还藏着规律性波动再决定下一步。 若分解后季节性成分振幅稳定、周期清晰,季节性 Holt-Winters 可能比 ARIMA 更贴数据;若残差像白噪声、季节项弱到可忽略,硬套季节模型反而引入噪声。外汇与贵金属杠杆高、跳空频繁,这类统计分解在极端行情下失效概率偏大,仅作特征工程参考而非下单依据。 随文给的 Scripts\seasonal_decompose test.mq5 能直接在 MT5 里单步调参,建议先拿历史收盘价跑通,再谈接机器学习。

常见问题

先造一条带固定周期和趋势的假数据,分别跑加法和乘法模型,看季节项是否随水平缩放;加法季节项恒定,乘法季节项随趋势放大。
22 日只是近似月周期,先用频谱确认主周期再设参;拆完看季节项是否重复出现,不重复就说明周期设错或数据有结构断点。
可以,小布能按你给的品种和周期跑分解,直接画出趋势、季节和残差,并提示周期是否稳定,省去手写代码。
在 init 里只算首段历史,OnCalculate 里用 prev_calculated 做偏移续算,别每次从头拟合,否则会重复消耗且结果抖动。
正常,近期结构可能已变;只算指定根数可避免旧形态干扰,但样本太短季节项会不稳,建议对比两段周期再下结论。