作 者:老余捞鱼
原创不易,转载请标明出处及原作者。

文中示例仅用于技术讨论,不构成任何操作建议。
量化策略开发应以学习和技术交流为目的。
本号不荐股、不卖课、不承诺收益。
市场有风险,请合法合规投资。
本文约 4600 字 | 预计阅读 11 分钟,后有完整代码,建议先收藏。
· · ·
先看一组数字。
某海外技术博客前几天发了一篇量化教程,讲的东西叫小波去噪加动量交易。配套的回测图里,策略从 2020 年初跑到 2026 年,六年半,累计收益 1750 倍。同期一直拿着英伟达不动,大约 25 倍上下。
更神的是回撤。六年半,策略最大回撤只有 14%。
英伟达本尊 2022 年那波,从高点跌掉六成。策略的曲线却像一条只会往上走的楼梯,几乎看不到像样的坑。
我盯着它的两张收益图看了很久。自觉告诉我这不可能是真的。
问题出在哪,我把它的代码逐行读了一遍,又在沪深300ETF(510300)上从头跑了一遍。这篇讲清楚三件事。这个策略在干什么,1750 倍是怎么被一行代码吹出来的,以及同样的逻辑搬到 A 股宽基上,真实成绩单长什么样。
答案比 1750 倍难看得多,也比 1750 倍有用得多。
一、筛子、信号,和那行出事的代码
先用大白话把方法过一遍。
小波变换(Wavelet Transform)这个东西,你可以把它理解成一把网眼可调的筛子。价格曲线倒进去,它按不同的摆动周期分开筛。两三天的毛刺是沙子,属于噪声,漏掉。五到二十天的起伏是你要的货,作者管这个区间叫 swing 尺度。一个月以上的慢趋势,先搁在另一边。
① 小波去噪,按尺度筛波动
import numpy as np
from scipy import signal as sig
widths = np.arange(1, 31) # 30 个尺度,30 把不同网眼的筛子
cwt_coeffs = sig.cwt(price.values, sig.ricker, widths)
denoised = cwt_coeffs.copy()
denoised[:5, :] = 0 # 5 以下,沙子,漏掉
denoised[20:, :] = 0 # 20 以上,慢趋势,搁一边
clean_trend = np.mean(denoised[5:20, :], axis=0) # 留下 5-20 的摆动筛完把 5 到 20 这几个网眼上的波动平均一下,就得到一条去掉毛刺的干净趋势线。
光有趋势还不够。作者又在每个尺度上算了一个比值,今天的波动能量除以十天前的能量。大于 1.02,说明这个尺度上的波动正在放大。两个条件同时满足才亮灯,波动在放大,干净趋势站在自己的 60 日均线上方。
② 信号合成,和那行致命代码
momentum = np.mean([cwt_coeffs[s-1, 10:] / cwt_coeffs[s-1, :-10]
for s in range(5, 21)], axis=0)
signal = (momentum > 1.02) & (clean_trend > ma60) # 双条件亮灯
strategy_returns = returns * signal # 问题就出在这一行注意看那行收益计算。signal 在这里没有往后挪。
这就是整篇文章的七寸。
二、1750 倍,是一行代码的功劳
回测里每天的收益,等于持仓状态乘以当天的涨跌幅。signal 表示的是今天收盘后算出来的判断,写对的话它只能决定你明天持不持有,代码里要加一个 shift(1)。
原文没加。
没加意味着什么。今天收盘之后才算得出来的信号,乘上的是今天白天已经涨完的幅度。好比一场球,你等终场哨响了才去下注,账本却按开球前的赔率算你赢。每天赢一点,六年半复利下来,就是 1750 倍。

终场哨响才下注,账本却按开球前算你赢
还有个更直观的验证角度,看原文自己的两张图。

原文 GRAPH 05,策略 1750 倍对比持有不动约 25 倍
六年 1750 倍的策略,最大回撤 14%。而它交易的标的,六年里自己腰斩过一回。

原文 GRAPH 06,1750 倍策略的每日损益与回撤
一个只做多的策略,收益是标的的 70 倍,回撤却不到标的的一半。数学上只有一种解释,它在每一个大跌的日子几乎都不持仓。可信号是收盘后才算的,它没有本事提前避开任何一根大阴线,除非它提前知道答案。
把 shift(1) 加上之后曲线会缩水成什么样,原文没有展示。
我替它跑了一遍。
感兴趣的朋友也可以去Coble上看下源码:
三、同样的代码,搬到 510300
复现设置先交代清楚。
数据用通达信的前复权日线,2020 年 1 月 2 日到 2026 年 9 月 4 日,一共 1619 根。参数全部照搬原文,尺度 1 到 30,保留 5 到 20,动量阈值 1.02,趋势过滤 60 日均线,一个都没动。
有个技术细节必须说。原文用的 scipy.signal.cwt,在 scipy 1.15 之后已经被官方移除。我按旧版源码逐行复刻了这套卷积逻辑,保证跟作者用的是同一个筛子。你要自己跑,注意版本。
③ 数据,通达信前复权日线
import pandas as pd
df = pd.read_csv("510300_daily.csv", parse_dates=["date"]).set_index("date")
price = df["close"].astype(float) # 通达信前复权,1619 根
returns = price.pct_change().fillna(0.0)④ 旧版 cwt + ricker 的 1:1 复刻
def cwt_old(data, wavelet, widths):
# 旧版 scipy.signal.cwt 的 1 比 1 复刻,新版 scipy 已删除
out = np.empty((len(widths), len(data)))
for i, w in enumerate(widths):
n = min(10 * w, len(data))
kernel = np.conj(wavelet(n, w)[::-1])
out[i] = np.convolve(data, kernel, mode="same")
return out
def ricker_old(points, a):
# 同样复刻旧版 ricker 小波
A = 2.0 / (np.sqrt(3 * a) * (np.pi ** 0.25))
vec = np.arange(points) - (points - 1) / 2
return A * (1 - vec**2 / a**2) * np.exp(-vec**2 / (2 * a**2))⑤ 两种口径的回测
def backtest(sig_series, shift_signal):
# shift_signal 为 True 时信号后移一天,收盘出信号,次日执行
s = sig_series.shift(1).fillna(False) if shift_signal else sig_series
r = returns * s.astype(int)
return (1 + r).cumprod()
sig_clean = (swing_momentum > 1.02) & (clean_trend > clean_trend.rolling(60).mean())
cum_flaw = backtest(sig_clean, shift_signal=False) # 原文口径
cum_fix = backtest(sig_clean, shift_signal=True) # 修正口径先看筛子本身的效果。

510300 原始价格对比小波去噪趋势,筛子本身没毛病
毛刺滤掉之后,趋势轮廓确实清爽了不少。单看这张图,你可能会觉得这方法有点东西。
筛子没问题。问题从来不在筛子。
四、结果,连作弊都不赚钱
表出来了,咱们拆数字。
| 口径 | 六年半累计 | 年化 | 最大回撤 | 夏普 |
|---|---|---|---|---|
| 原文口径,信号不后移 | 1.04 倍 | 0.6% | -10.4% | 0.12 |
| 修正口径,信号后移一天 | 0.95 倍 | -0.8% | -18.6% | -0.08 |
| 拿着不动 | 1.26 倍 | 3.5% | -44.7% | 0.28 |
先看好的一面。照抄原文的算法,连未来函数都原样保留,策略六年半 1.04 倍,年化 0.6%,至少没亏。
但真正该盯着看的,在下面。
把信号老老实实后移一天,六年半 0.95 倍,亏 5%。全程 94 笔交易,胜率 40.4%,平均一笔负 0.04%。
拿着不动是 1.26 倍。数字不起眼,但它的最大回撤 44.7%,2022 一年跌掉 21.7%。策略把回撤从 44.7% 压到 18.6%,代价是六年半白忙活。

信号后移一天前后,三条净值曲线(对数坐标)

修正口径六年半回撤,深坑都集中在震荡年份
再拆一层,看持仓时间。
| 年份 | 持有不动收益 | 策略收益 | 策略在场天数 |
|---|---|---|---|
| 2020 | 31.1% | 4.1% | 17 |
| 2021 | -4.3% | -2.9% | 47 |
| 2022 | -21.7% | -1.4% | 18 |
| 2023 | -10.4% | -7.2% | 36 |
| 2024 | 18.4% | 0.9% | 30 |
| 2025 | 21.5% | 6.5% | 37 |
| 2026 | -0.3% | -4.3% | 48 |
一年 250 个交易日,这个信号真正在场的时间最少 14 天,最多 48 天,不到两成。剩下两百多天,它都在场外看戏。
这不是 bug,在动量策略在震荡市里就是这个待遇。510300 这六年半,涨一年跌一年来回拉锯,指数总共只走了 26%。波动刚放大,行情就掉头,信号追进去挨一巴掌,离场,等下一种走法。周而复始。
至于英伟达上的 1750 倍,看它这六年的价格路径就明白,涨了 25 倍,中间几乎没有像样的整理。单边大牛里,动量是台印钞机,连未来函数的破绽都被涨幅盖住了。
同一个策略,换个标的,命运全换。1750 倍里,大头是英伟达自己的贝塔,剩下的零头,是未来函数的润色。
回测收益如果主要来自标的自己往上走,策略本身就没挣到什么钱。这话朴素得像废话,可 1750 倍这种数字一出场,多数人都会忘了它。
五、实话实说
有几件事必须摆在台面上。
第一,参数全是照搬原文,一个没调。510300 上或许存在能让数字变好看的参数,但调参救不了方向性就不成立的想法。
第二,手续费和滑点没算。毛收益都是负的,加上成本只会更难看。
第三,我只测了 510300 一个标的。它代表不了全部 A 股,但作为动量策略的试金石,宽基指数已经在作者方法的舒适区之外。
第四,前复权数据对分红的处理是近似的。对六年半级别的日频回测,这点误差动摇不了结论的方向。
第五,原文没有提手续费,没有提 shift,正文里也一处实盘数字都没有给。所有成绩都在图里。我不替它编数字,只把能核验的部分核验给你看。
六、我的验伪三步
以后再看到倍数吓人的回测,按这三步过筛子。
第一步,后移一天。把信号 shift(1),一行代码的事。收益大幅缩水,说明原回测偷看了答案。
第二步,核对回撤。收益是标的的十倍,回撤却比标的还浅,多半不是天赋,是未来函数。
第三步,换个标的。牛股上验证过的策略,搬到指数或者震荡品种上再跑一遍。还活着,才算真本事。
三步加起来花不了半小时,能挡掉市面上大半的漂亮曲线。
观点总结
这篇拆到最后就两层。
1750 倍的数字里,英伟达自己的涨幅贡献了大头,一行没后移的信号贡献了剩下的幻觉。把同样的代码放进一个老老实实的震荡市场,它连一分钱都挣不到。
方法本身,小波去噪是个正经工具,做数据清洗、看曲线结构都有用处。拿它当收益引擎,至少在宽基指数上,这次的数据没给我任何说服力。
最后留一个跟数据直接挂钩的问题。你手上跑过的策略,把信号后移一天,收益还剩几成?测过的,评论区报个数。
完整可运行脚本在文末,数据换成你自己的标的,一分钟出结果。
觉得有用的话,把这篇转给同样在搞回测的朋友。
完整复现脚本(复制即用)
# -*- coding: utf-8 -*-
"""
小波去噪 + 动量策略复现(510300,2020-2026)
依赖:pip install pandas numpy matplotlib
数据:请将 510300_daily.csv 放在同目录下,包含列 date, close
"""
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# 中文字体设置
plt.rcParams["font.sans-serif"] = ["Microsoft YaHei", "SimHei"]
plt.rcParams["axes.unicode_minus"] = False
# ---------- 1. 数据加载 ----------
df = pd.read_csv("510300_daily.csv", parse_dates=["date"]).set_index("date")
price = df["close"].astype(float)
returns = price.pct_change().fillna(0.0)
# ---------- 2. 旧版 scipy 的 cwt + ricker 复刻(scipy 1.15+ 已移除) ----------
def ricker_old(points, a):
"""Ricker(墨西哥帽)小波,与 scipy.signal.ricker 一致"""
A = 2.0 / (np.sqrt(3 * a) * (np.pi ** 0.25))
vec = np.arange(points) - (points - 1) / 2
return A * (1 - vec**2 / a**2) * np.exp(-vec**2 / (2 * a**2))
def cwt_old(data, wavelet, widths):
"""连续小波变换,使用卷积实现(与 scipy.signal.cwt 行为相同)"""
out = np.empty((len(widths), len(data)))
for i, w in enumerate(widths):
n = min(10 * w, len(data))
kernel = np.conj(wavelet(n, w)[::-1])
out[i] = np.convolve(data, kernel, mode="same")
return out
# ---------- 3. 小波去噪 + 多尺度动量(严格按原文) ----------
widths = np.arange(1, 31)
coeffs = cwt_old(price.values, ricker_old, widths)
# 去噪:仅保留尺度 5~20 的成分
den = coeffs.copy()
den[:5, :] = 0
den[20:, :] = 0
clean_trend = pd.Series(np.mean(den[5:20, :], axis=0), index=price.index)
# 多尺度动量:计算尺度 5~20 的 10 期收益率均值
mom = np.mean([coeffs[s-1, 10:] / coeffs[s-1, :-10] for s in range(5, 21)], axis=0)
swing = pd.Series(mom, index=price.index[10:]).reindex(price.index).fillna(0.0)
# 生成信号:swing > 1.02 且 clean_trend 高于 60 日均线
sig_raw = swing > 1.02
sig_clean = (sig_raw & (clean_trend > clean_trend.rolling(60).mean())).fillna(False)
# ---------- 4. 回测函数及两种口径 ----------
def backtest(s_series, do_shift):
"""
回测函数
do_shift=True 表示信号后移一天(次日执行),避免未来函数
"""
s = s_series.shift(1).fillna(False) if do_shift else s_series.astype(bool)
r = returns * s.astype(int)
return r, (1 + r).cumprod()
r_flaw, cum_flaw = backtest(sig_clean, False) # 原文口径(含未来函数)
r_fix, cum_fix = backtest(sig_clean, True) # 修正口径(次日执行)
cum_bh = (1 + returns).cumprod() # 持有不动
def stats(r, cum):
"""计算总收益、年化收益、最大回撤、夏普比率"""
years = (cum.index[-1] - cum.index[0]).days / 365.25
dd = (cum / cum.cummax() - 1).min()
return {
"total": round(float(cum.iloc[-1]), 2),
"cagr": round(float(cum.iloc[-1] ** (1 / years) - 1) * 100, 1),
"maxdd": round(float(dd) * 100, 1),
"sharpe": round(float(r.mean() / r.std() * np.sqrt(252)), 2),
}
print("原文口径(含未来函数)", stats(r_flaw, cum_flaw))
print("修正口径(次日执行) ", stats(r_fix, cum_fix))
print("持有不动 ", stats(returns, cum_bh))
# ---------- 5. 净值对比图 ----------
fig, ax = plt.subplots(figsize=(12.8, 7.2), dpi=150)
ax.plot(cum_flaw, label="原文口径(信号未后移)", color="#8C2F2F")
ax.plot(cum_fix, label="修正口径(次日执行)", color="#C39A3B", lw=1.8)
ax.plot(cum_bh, label="持有不动", color="#5a7fa8")
ax.set_yscale("log")
ax.legend()
ax.grid(alpha=0.3)
ax.set_title("信号后移一天前后")
fig.tight_layout()
fig.savefig("wavelet_check.png")
plt.show()#量化回测 #小波变换 #未来函数 #动量策略 #Python量化 #沪深300ETF #510300 #策略复现 #回测陷阱 #信号延迟 #金融工程 #散户防坑
免责声明:本文所有回测数据仅为技术研究用途,历史表现不代表未来。文中不构成任何操作建议,据此操作风险自负。
Be First to Comment