数据科学和机器学习(第 34 部分):时间序列分解,剖析股票市场的核心·进阶篇
📊

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

(2/3)· 多数图表只给你市场概览,却藏起了趋势、季节与噪声的真实分量

含代码示例偏理论 第 2/3 篇
直接看 OHLC 图表做决策,常被杂音带偏——你看到的是混合信号,不是因果。把价格当成单一序列硬读,趋势和周期互相掩盖,形态判断容易失真。外汇贵金属杠杆高、波动剧烈,误读成分可能放大亏损概率。

◍ 用卷积平滑抓出价格主轨

移动平均线本质是固定窗口内的均值运算,把时间序列里的短噪点磨掉,让潜在方向浮出来。加法分解里趋势用周期 p 做窗口平均;乘法分解则换几何中值,因为季节效应是缩放数据而不是叠加数据。 MQL5 里别用老式滚动窗口,卷积法(Convolve)能灵活处理数组边缘缺失值,效率也更高。下面这段就是趋势提取的核心函数,k 为窗口大小,mode 控制边缘填充。 代码里 kernel 是长度为 k 且元素全为 1/k 的向量,与原始序列卷积即得移动平均。若 period 取 12,对 EURUSD 日线跑一遍,趋势线会比收盘价滞后约 6 根 K 线,这是边缘对齐 pad 造成的固有偏移,调参时要注意。 实际落地时把 timeseries 换成你的收盘价向量,period 设成季节周期(比如周线波动用 5,月内效应用 21),返回的 trend 直接画出来就能肉眼验证主趋势方向。外汇与贵金属杠杆高,趋势判断只作概率参考,别当单向押注依据。

MQL5 / C++
vector moving_average(class="kw">const vector &v, class="type">uint k, ENUM_VECTOR_CONVOLVE mode=VECTOR_CONVOLVE_VALID)
 {
   vector kernel = vector::Ones(k) / k;
   vector ma = v.Convolve(kernel, mode);
    
   class="kw">return ma;  
 }
class=class="str">"cmt">//--- compute the trend
   class="type">int n = (class="type">int)timeseries.Size();  
   res.trend = moving_average(timeseries, period);
   
   class=class="str">"cmt">// We align trend array with the original series length
   
   class="type">int pad = (class="type">int)MathFloor((n - res.trend.Size()) / class="num">2.0);
   class="type">int pad_array[] = {pad, n-(class="type">int)res.trend.Size()-pad};
   
   res.trend = Pad(res.trend, pad_array, edge);

「把重复节律从价格里抠出来」

季节性成分提取的本质,是按固定间隔把时间序列里反复出现的形态单独剥离,比如按日、按月或按年循环。做这一步之前必须先估出趋势,否则周期波动会被长期方向带偏。 加法模型下,先算去趋势序列:某时刻 t 的去趋势值等于原始序列值减去同位置趋势成分。也就是说,Y_t - T_t 得到的是剔除了大方向后的纯残差。 随后取完整季节周期 p 做平均。假设数据里有 n 个完整周期、每周期长度 m(例如月线周期含 30 个日值),季节性成分就是这些去趋势值在同一相位上的均值。外汇与贵金属受季节资金流影响明显,但这类节律只代表历史概率倾向,实盘仍属高风险。

乘法模型下用几何均值拆季节

处理乘法型时间序列时,减法去趋势会放大乖离,正确做法是除法:用原序列除以趋势项得到去趋势序列,再从中提取季节成分。季节项不能用算术均值,必须取几何均值,否则乘法结构里的波动会被系统性高估。 几何均值的实现绕不开对数:先对去趋势序列每段取 MathLog,算均值后再 MathExp 回去。这带来一个硬约束——序列里只要出现 0 或负值,MathLog 直接报错,所以乘法模型只接受全正值输入,进函数前必须强制检查。 Pad 函数在向量两端补值,类似 Numpy.pad,让移动平均能居中而不偏移相位;Tile 则把算出的周期季节向量按 MathFloor(n/period)+1 次重复铺开,再截到原长 n,保证整段序列都挂上季节因子。外汇与贵金属价格虽多为正值,但停盘缺口或负tick异常仍可能击穿该条件,实盘前应在 MT5 里先跑一遍零值检查。 下面这段 MQL5 是季节成分计算的核心分支,乘法部分即几何均值路径: //--- compute the seasonal component if (model == multiplicative) { for (ulong i=0; i<timeseries.Size(); i++) if (timeseries[i]<=0) { printf("Error, Multiplicative seasonality is not appropriate for zero and negative values"); return res; } } vector detrended = {}; vector seasonal = {}; switch(model) { case additive: { detrended = timeseries - res.trend; seasonal = vector::Zeros(period); for (uint i = 0; i < period; i++) seasonal[i] = SliceStep(detrended, i, period).Mean(); //Arithmetic mean over cycles } break; case multiplicative: { detrended = timeseries / res.trend; seasonal = vector::Zeros(period); for (uint i = 0; i < period; i++) seasonal[i] = MathExp(MathLog(SliceStep(detrended, i, period)).Mean()); //Geometric mean } break; default: printf("Unknown model for seasonal component calculations"); break; } vector seasonal_repeated = Tile(seasonal, (int)MathFloor(n/period)+1); res.seasonal = Slice(seasonal_repeated, 0, n); if (model == multiplicative) { for (ulong i=0; i<timeseries.Size(); i++) if (timeseries[i]<=0) { printf("Error, Multiplicative seasonality is not appropriate for zero and negative values"); return res; } } 逐行看:开头两个 if 块都是乘法模型的正值哨兵,遇零或负直接打印错误并退出;detrended 在加法下是减趋势、乘法下是除趋势;seasonal 初始化为零向量长度 period;SliceStep 按周期抽子序列,加法取 Mean() 算术均、乘法包 MathLog 后 Mean 再 MathExp 得几何均;Tile 把周期向量重复铺满,Slice 截回 n 点。复制进 MT5 脚本,把 timeseries 换成你的 H1 收盘价,period 设 24,就能看到乘法季节项长什么样。

MQL5 / C++
class=class="str">"cmt">//--- compute the seasonal component
  if (model == multiplicative)
  {
    for (class="type">class="kw">ulong i=class="num">0; i<timeseries.Size(); i++)
      if (timeseries[i]<=class="num">0)
        {
          printf("Error, Multiplicative seasonality is not appropriate for zero and negative values");
          class="kw">return res;
        }
  }
  vector detrended = {};
  vector seasonal = {};
  class="kw">switch(model)
  {
    case  additive:
      {
        detrended = timeseries - res.trend;
        seasonal = vector::Zeros(period);
        for (class="type">uint i = class="num">0; i < period; i++)
          seasonal[i] = SliceStep(detrended, i, period).Mean(); class=class="str">"cmt">//Arithmetic mean over cycles
      }
      class="kw">break;
    case  multiplicative:
      {
        detrended = timeseries / res.trend;
        seasonal = vector::Zeros(period);
        for (class="type">uint i = class="num">0; i < period; i++)
          seasonal[i] = MathExp(MathLog(SliceStep(detrended, i, period)).Mean()); class=class="str">"cmt">//Geometric mean
      }
      class="kw">break;
    class="kw">default:
      printf("Unknown model for seasonal component calculations");
      class="kw">break;
  }
  vector seasonal_repeated = Tile(seasonal, (class="type">int)MathFloor(n/period)+class="num">1);
  res.seasonal = Slice(seasonal_repeated, class="num">0, n);
  if (model == multiplicative)
  {
    for (class="type">class="kw">ulong i=class="num">0; i<timeseries.Size(); i++)
      if (timeseries[i]<=class="num">0)
        {
          printf("Error, Multiplicative seasonality is not appropriate for zero and negative values");
          class="kw">return res;
        }
  }

◍ 把趋势和季节拿掉后剩下的东西

分解完趋势与季节分量之后,剩下没法被这两者解释的部分就是残差。它代表序列里纯噪声或突发扰动,可用于检验模型是否把规律抽干净。 对于加法模型,残差直接用原始值减趋势再减季节:r(t)=y(t)-T(t)-S(t)。乘法模型下季节是比例而非绝对值,残差要除而不是减:r(t)=y(t)/(T(t)*S(t))。 其中 y(t) 是时间 t 的原始报价或指标序列,T(t) 是同时刻趋势分量,S(t) 是该时刻季节性数值。外汇与贵金属波动受事件驱动,残差里常混入跳空,属正常高风险市场现象,验证时别把异常残差都当模型错误。

「把分解逻辑收进一个函数里」

把趋势、季节、残差三件事塞进同一个 seasonal_decompose 函数,返回结构里直接带三个 vector,调用端只管喂序列和周期。加法模型下序列长度小于周期会直接报错退出,这条边界检查能省掉不少乱序数据导致的崩脚本。 乘法模型要求序列全为正,代码里对小于等于 0 的值做了拦截并返回空结构;这一点在外币对或贵金属价差序列里尤其要注意,负值或零值会让几何平均那步直接废掉。 季节项的计算分两路:加法用各周期同位点的算术平均,乘法用对数平均再 exp 回去取几何平均。两种模型在图上尺度接近、肉眼难分,但导出 CSV 后数值差异明显,和 Python statsmodels 的 seasonal_decompose 结果一致。 在 MT5 里接上之前那套数据帧函数库加载两个时间序列,跑完把结果写新 CSV,丢给 Python 可视化就能比对。外汇和贵金属属高风险品种,分解仅辅助判断周期倾向,不构成方向结论。

MQL5 / C++
enum seasonal_model
{
   additive,
   multiplicative
};
class="kw">struct seasonal_decompose_results
{
   vector trend;
   vector seasonal;
   vector residuals;
};
seasonal_decompose_results seasonal_decompose(class="kw">const vector &timeseries, class="type">uint period, seasonal_model model=additive)
{
   seasonal_decompose_results res;
   
   if (timeseries.Size() < period)
   {
      printf("%s Error: Time series length is smaller than the period. Cannot compute seasonal decomposition.",__FUNCTION__);
      class="kw">return res;
   }
   
class=class="str">"cmt">//--- compute the trend
   class="type">int n = (class="type">int)timeseries.Size();   
   res.trend = moving_average(timeseries, period);
   
   class=class="str">"cmt">// We align trend array with the original series length
   
   class="type">int pad = (class="type">int)MathFloor((n - res.trend.Size()) / class="num">2.0);
   class="type">int pad_array[] = {pad, n-(class="type">int)res.trend.Size()-pad};
   
   res.trend = Pad(res.trend, pad_array, edge);
class=class="str">"cmt">//--- compute the seasonal component
   
   if (model == multiplicative)
    {
      for (class="type">class="kw">ulong i=class="num">0; i<timeseries.Size(); i++)
      if (timeseries[i]<=class="num">0)
       {
         printf("Error, Multiplicative seasonality is not appropriate for zero and negative values");
         class="kw">return res;
       }
    }
   
   vector detrended = {};
   vector seasonal = {};
   
   class="kw">switch(model)
    {
      case  additive:
       {
         detrended = timeseries - res.trend;
         seasonal = vector::Zeros(period);
         
         for (class="type">uint i = class="num">0; i < period; i++)
         seasonal[i] = SliceStep(detrended, i, period).Mean(); class=class="str">"cmt">//Arithmetic mean over cycles
       }     
       class="kw">break;
      case  multiplicative:
       {
         detrended = timeseries / res.trend;
         seasonal = vector::Zeros(period);
         
         for (class="type">uint i = class="num">0; i < period; i++)
         seasonal[i] = MathExp(MathLog(SliceStep(detrended, i, period)).Mean()); class=class="str">"cmt">//Geometric mean
       }     
       class="kw">break;
      class="kw">default:
       printf("Unknown model for seasonal component calculations");
       class="kw">break;
    }
    
让小布替你跑这套分解
这些诊断小布盯盘的 AIGC 已内置,打开对应品种页即可看到趋势、季节与残差分量,把重复劳动交给小布,你专注决策。

常见问题

不是。均线只是平滑,季节性分解把长期方向与周期、残差明确拆开,能分离出被周期掩盖的移动方向,判断更干净。
不能直接套。金融资产周期多受宏观与时段流动性影响,需用历史样本检验是否存在稳定周期,倾向先分解再下结论。
可以。小布盯盘对应品种页已内置该分解视图,无需自己写 MQL5,打开即可切换趋势、季节、残差三层观察。
差在成分耦合方式:乘法用乘积还原序列,季节波动随趋势放大;代码里残差与成分组合公式不同,需按数据波动结构选。
不一定。残差大可能意味存在未建模的外部冲击或非线性结构,概率上更该怀疑数据含突发事件噪声而非算法错。