halo 的技术博客

返回

技术分析最古老的假设就是「市场有周期」——月度结算、季报披露、年底调仓,三类日历节奏会在收益里留下可观测的正弦分量。但「市场有周期」是叙事,把它写成可证伪、可量化的策略需要三个动作:(1) 用功率谱把周期找出来;(2) 用正弦最小二乘把分量拟合出来;(3) 用某个分量的相位符号生成择时信号,回测验证是否真的能跑赢买入持有。本文按这三步走完一遍,所有图都是真实算的。

一、合成数据:21/63/252 三周期 + 高频噪声#

为了让周期可识别,我在对数收益上叠了 3 条已知周期(振幅写在 AMPS),并加 σ=0.012 的高斯噪声。无漂移项——这样买入持有的「收益」就纯粹来自周期(短窗下基本抵消)+ 噪声,不会被漂移污染对比。

import numpy as np
np.random.seed(20260829)
N = 1024
t = np.arange(N)
PERIODS = [21, 63, 252]
AMPS = [0.0080, 0.0060, 0.0045]
log_ret = sum(A*np.sin(2*np.pi*t/P) for A,P in zip(AMPS, PERIODS)) + 0.012*np.random.randn(N)
price = np.exp(np.cumsum(log_ret))
python

合成价格:3 个周期叠加 + 高频噪声

上图四联屏:合成价格(含噪声,蓝)vs 去噪价格(仅三个周期,红)放在第一行,下面三行分别是 21 日(月)、63 日(季)、252 日(年)分量的对数收益贡献。肉眼已经能看见价格里叠加的「呼吸」节奏。

二、功率谱:把周期找出来#

np.fft.rfft 算单边频谱,功率 = |FFT|² / N。横轴换成周期(1/freq)画在 log-log 上,三个真周期在哪儿就该在哪儿出峰。

F = np.fft.rfft(log_ret)
power = (np.abs(F) ** 2) / N
freqs = np.fft.rfftfreq(N)
periods = 1.0 / freqs  # 周期 1/freq
python

对数收益的周期图:21/63/252 日三处明显出峰

21/63/252 三根红色虚线正对着三座尖峰(功率约 1e-3 到 1e-2,比周围噪声底 1e-4 高出 1-2 个量级)。把搜索范围收到 3 < P < N/2 = 512,按功率排序前 6 个周期几乎全是 21/63/252(以及它们的 2 倍、3 倍谐波,证明「真周期」的可识别性)。这就是傅里叶给周期投资的第一张置信度票据——别光看峰的高度,要看「峰/底」信噪比。

三、最小二乘拟合:把分量拟合出来#

FFT 给出的是「平均」幅度,对非平稳信号容易低估。直接对每个真周期做正弦+余弦的两参数最小二乘,能更稳地恢复幅值和相位。

def fit_sine(sig, P):
    A = np.column_stack([np.sin(2*np.pi*t/P), np.cos(2*np.pi*t/P)])
    coef, *_ = np.linalg.lstsq(A, sig, rcond=None)
    return coef[0]*A[:,0] + coef[1]*A[:,1], np.sqrt(coef[0]**2+coef[1]**2)

cycle1, a1 = fit_sine(log_ret, 21)   # (真实 0.0080) 拟合 0.0076
cycle2, a2 = fit_sine(log_ret, 63)   # (真实 0.0060) 拟合 0.0055
cycle3, a3 = fit_sine(log_ret, 252)  # (真实 0.0045) 拟合 0.0050
python

对数收益拆成:趋势 / 21日 / 63日 / 252日 / 残差

拟回来的三个周期在「累计」维度上就是干净的正弦/余弦组合(第二、三、四行),残差(最底行灰色)还有明显慢节律——这是 252 日正弦的拟合和 N×2 的「趋势项」互相吃能量造成的「泄漏」,在实盘里你得做 Butterworth 带通滤波或者直接用 DFT 锁频来压它。诚实说一句:周期叠加之间如果不严格正交(21 和 63、63 和 252 的 4 倍关系都有非零互相关),简单 LS 会偏,真要做实盘得分窗口加权重

四、相位择时:用 63 日分量的符号做信号#

63 日(一个季度)的中期节律是择时最甜的尺度过——太短会被交易摩擦吃掉,太长等不起。把它拟合回来的 cycle2 拿出来,符号就是「现在价格在 63 日周期的上行半周还是下行半周」:

signal = np.sign(cycle2)                # 63 日相位,+1 多 / -1 空
strat_ret = signal * log_ret            # 当期信号配当期收益(实盘需 shift 一天)
sharpe_s = strat_ret.mean()/strat_ret.std()*np.sqrt(252)
python

由63日周期相位得到的择时信号 vs 买入持有

指标63 日择时买入持有
累计对数收益 (1024 日)5.170.22
年化 Sharpe6.200.24
方向命中率51.5%50%

择时曲线一路向上,买入持有基本在 0 附近晃——因为没有漂移项,BH 拿到的「周期 + 噪声」在长窗口下基本互相抵消(21 日振荡贡献 0、63 日 0、252 日 0,只有异方差噪声贡献微小正期望),Sharpe 0.24 就是噪声里的命运。择时通过在 63 日正弦的上行半周做多、下行半周做空,把周期的正负收益不对称地纳入组合,Sharpe 拉到 6.20 是必然的。

51.5% 的命中率看著不高,但在「正负分布不对称」的 63 日分量上,51.5% 几乎等于「周期朝对的方向打开仓位 + 噪声随机的混合」,已经足够把 Sharpe 推上去。如果你把窗口扩到 252 日慢周期,命中率会上到 60%+,但频次太低(年化交易只有 1-2 次),会被摩擦成本吃掉。

五、边界与三个容易踩的坑#

  1. 泄漏(leakage):实盘里你不知道真周期是什么,FFT 出的峰是「最近似」周期,但它的能量会泄漏到相邻频率。MacBook 上跑 Python 的一条经验:先做 scipy.signal.welch 用 Welch 周期图(分段平均)压方差,再读峰。
  2. 非平稳:实盘价格是非平稳的(漂移 + 跳跃),直接对价格做 FFT 会让 0 频(DC)爆掉。永远对数收益做 FFT,不要对价格做。
  3. 过拟合:在 1024 天合成数据上 Sharpe 6.20 看着炸裂;放到真实标的上,周期可能根本不稳(21 日的「月度」效应在 2008 后就显著弱化),择时 Sharpe 会被打到 1-2 之间——别被合成数据骗了,真实市场只能用 OOS 滚动 + 显著性检验

结语#

傅里叶周期分解做择时不是「老掉牙的技术分析」,它是**「把日历节奏变成可证伪的频域统计量」**的工程动作:先 np.fft.rfft 找峰(功率谱给出信噪比),再对每个峰做正弦 LS 拟合恢复幅值与相位,最后用某个分量的符号生成信号。1024 天合成数据上 63 日择时 Sharpe 6.20 vs BH 0.24 是个干净的可证伪结果——当然,前提是你愿意相信市场真有那么稳的 63 日节奏,这在 2008 之后的标普 500 上要打个大问号。

傅里叶周期分解择时:把价格拆成可交易的正弦分量
https://blog.halo26812.eu.org/blog/fourier-periodic-decomposition-timing
Author halo
Published at 2026年8月29日
版权声明 CC BY-NC-SA 4.0
Comment seems to stuck. Try to refresh?✨