Skip to content

为什么回测翻1750倍、换到A股就不行?我拿数据验了一遍(附源码)

作 者:老余捞鱼

原创不易,转载请标明出处及原作者。

文中示例仅用于技术讨论,不构成任何操作建议。
量化策略开发应以学习和技术交流为目的。
本号不荐股、不卖课、不承诺收益。
市场有风险,请合法合规投资。

本文约 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上看下源码:

https://colab.research.google.com/drive/1A4i-V9IHSf8SL17kVKIYkTikaNKxMjz9?usp=sharing&utm_source=algoedgeinsights.beehiiv.com&utm_medium=newsletter&utm_campaign=welcome-3-battle-tested-colab-notebooks-inside

三、同样的代码,搬到 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%,代价是六年半白忙活。

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

修正口径六年半回撤,深坑都集中在震荡年份

再拆一层,看持仓时间。

年份持有不动收益策略收益策略在场天数
202031.1%4.1%17
2021-4.3%-2.9%47
2022-21.7%-1.4%18
2023-10.4%-7.2%36
202418.4%0.9%30
202521.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 #策略复现 #回测陷阱 #信号延迟 #金融工程 #散户防坑


免责声明:本文所有回测数据仅为技术研究用途,历史表现不代表未来。文中不构成任何操作建议,据此操作风险自负。

Published inAI&Invest专栏

Be First to Comment

    发表回复