上一篇我们用 TA-Lib 和 pandas-ta 给阶段二收官,SMA、MACD、RSI、ATR 从此都能一键算出,工具箱算是齐了。工具齐了,心里马上会冒出一个按捺不住的问题:「这个指标组合到底赚不赚钱?」想知道答案,就得让策略回到历史里「假装」跑一遍——这就是回测(Backtest)。可以说,从本篇开始,我们才算真正踏进量化交易的大门。
但回测是把双刃剑:它是量化区别于「凭感觉炒股」的核心工具,也是自欺欺人重灾区。同一个策略,有人回测出漂亮曲线兴奋冲进实盘,结果亏得怀疑人生——问题往往不在策略,而在回测本身藏着骗局。本篇先把「什么是回测、它能证明什么」讲清楚,再重点拆解三个最常见也最致命的陷阱:未来函数、幸存者偏差、过拟合。每个陷阱都配一段能跑的小代码,让你亲眼看看「假的好成绩」是怎么造出来的。认清骗局的样子,是做出诚实回测的第一步。
本篇目标
- 能说清回测的四个步骤,并指出回测能证明什么、不能证明什么。
- 能识别未来函数(Look-ahead Bias),用
shift(1)修正「当天信号当天交易」的错误写法。 - 能解释幸存者偏差(Survivorship Bias)为什么系统性抬高回测成绩,并知道缓解思路。
- 能用自己的话说出过拟合(Overfitting)的成因,并用「样本内 / 样本外」划分检验一组参数。
核心概念
什么是回测
回测,就是把写好的策略放到历史数据上模拟运行:假装你早在几年前提了这笔钱、按策略规则一条条执行,最后统计能赚多少、亏多深。一个最小回测分四步:
- 生成信号:按规则算出每天该持有(
1)还是空仓(0),比如「收盘价在 MA20 之上就持有」。 - 模拟成交:把信号翻译成持仓变化,并扣掉手续费。
- 计算收益:持仓乘以每日涨跌,逐日累乘得到净值曲线。
- 评估表现:算总收益、最大回撤等指标(第 19 篇专门讲)。
那回测能证明什么?三件事:策略逻辑有没有写错(该触发的时候有没有触发)、策略在历史上大致是什么表现、成绩对参数敏不敏感。
不能证明什么?这才是重点:
- 不能证明未来也成立。历史只是市场无数可能路径中的一次抽样,规律可能失效。
- 不能证明实盘能拿到回测里的价格。回测假设你总能按收盘价成交,实盘有滑点、有买不到的涨停板。
- 不能证明策略「显著」。一段好看的曲线可能只是运气,严格结论要靠统计检验(第 37 篇)。
划重点:回测是用来「证伪」的工具,不是「许愿机」。回测不及格的策略一定淘汰;回测及格的策略,也未必能上实盘。
陷阱一:未来函数——用还没发生的信息做决策
未来函数(Look-ahead Bias,也叫前视偏差),指决策时刻用到了决策时刻还不可能知道的信息。最经典的犯案现场是这句:
signal = (close > close.rolling(20).mean()).astype(int) # 用当天收盘价算信号
daily = signal * close.pct_change() # 又让信号吃当天的涨幅
第一行用当天收盘价算出信号,第二行却让这个信号去赚当天的涨幅——可当天的收盘价要等收盘那一刻才存在,你不可能在开盘时就知道它。这等于每天收盘后偷看答案再答题,回测自然好看到离谱。
修正方法只用一个词:shift(1)。今天算出的信号,明天才能执行。下一节代码里我们把错误版和正确版放在一起,让数字自己说话。
未来函数还有几个常见变体,先混个脸熟:用当天的最高价最低价判断「当天止损」;做归一化时用全区间最大最小值(未来数据混进了今天);把财务数据用「公告发布前」的日期对齐(数据其实是之后才公布的)。
陷阱二:幸存者偏差——死人不会说话
幸存者偏差(Survivorship Bias)的经典故事:二战时盟军统计返航轰炸机的中弹分布,机翼弹孔最多,想加固机翼;统计学家说反了——能返航说明机翼中弹不致命,该加固的是弹孔很少的引擎,因为引擎中弹的飞机根本没飞回来。
放到回测里:如果你的股票池是「今天还活着的股票」,那你回测的是一个已经筛掉了所有退市股的名单。退市的股票生前往往跌得惨不忍睹(A 股退市前常伴随连续跌停),把它们从历史里抹掉,回测成绩会系统性偏乐观。A 股 2024 年一年就有五十多只股票退市,这不是小概率事件。
缓解思路两条:一是用时点成分股名单(point-in-time),回测到哪天,就用那天的真实名单;二是干脆把退市股纳入股票池一起测。下一节的演示里,我们让两只「退市股」回到池子里,你会看到成绩立刻换了一副面孔。
陷阱三:过拟合——参数越多越漂亮,越危险
过拟合(Overfitting)用一个直觉就能讲透:让 1000 个人各抛 10 次硬币,总有那么一两个人能抛出 8 次以上正面。他厉害吗?不,他只是被随机性挑中的幸运儿。回测同理:策略参数越多(快线 3 天还是 4 天、阈值 0.5 还是 0.6……),组合数就越多,总有一组参数「恰好」贴合历史上的每个波段——但它贴合的是噪音,不是规律。参数越多越漂亮,就是这个意思。
划重点:判断过拟合最朴素的两招——一看样本外:换一段没参与调参的历史,成绩是否断崖;二看参数敏感性:快线从 5 改成 6 成绩就变脸,说明你站在「参数尖峰」上,而真正稳健的策略应该有平缓的「参数高原」。
本篇用「样本内调参、样本外验证」做最小检验:拿 2020–2022 年调出最靓的参数组,放到 2023–2024 年看它还灵不灵。系统化的防过拟合方法(Walk-Forward)留到第 25 篇。
完整代码
依赖安装:uv pip install pandas numpy akshare。三个陷阱各自独立成块,都可以单独复制运行。
代码块 1:陷阱一——「当天信号当天交易」有多离谱。我们构造一根规律明显的「猜拳行情」:涨一天、跌一天,反复 120 天。
import numpy as np
import pandas as pd
# —— 构造涨跌交替的极端行情:规律明显,谁看都想「跟着方向买」 ——
n = 120
rets = np.array([0.02, -0.02] * (n // 2)) # +2%、-2% 交替
price = pd.Series(100.0 * np.cumprod(1 + rets))
signal = (price > price.shift(1)).astype(int) # 今天比昨天涨 -> 信号 1
daily_ret = price.pct_change().fillna(0)
# 错误写法:当天收盘算出信号,当天就吃这个涨幅(用了未来信息)
ret_wrong = (1 + signal * daily_ret).prod() - 1
# 正确写法:信号 shift(1),明天才执行;明天的涨跌今天无法预知
pos_right = signal.shift(1).fillna(0)
ret_right = (1 + pos_right * daily_ret).prod() - 1
print(f"错误写法(当天信号当天买)总收益:{ret_wrong:+.1%}")
print(f"正确写法(信号次日执行) 总收益:{ret_right:+.1%}")
代码块 2:陷阱二——把退市股请回股票池。构造 10 只股票的年度收益:8 只「活到今天」的幸存股每年稳稳 +8%;2 只后来退市的股票前三年正常,第四年暴跌 90%、第五年再跌 50%。比一比「只用幸存者名单」和「用当时真实名单」两种回测。
import numpy as np
import pandas as pd
# —— 演示数据:8 只幸存股每年 +8%;2 只退市股生前轨迹如下 ——
survivor_year = 0.08 # 幸存股:每年 +8%
dead_years = [0.05, 0.05, 0.05, -0.90, -0.50] # 退市股:逐年收益
# 方案 A(错误):只用「今天还活着」的 8 只股票回测过去 5 年
ret_a = (1 + survivor_year) ** 5 - 1 # 等权组合每年 +8%
# 方案 B(正确):用「当时真实存在」的 10 只,等权、每年再平衡
nav_b = 1.0
for year_ret in dead_years:
port_ret = (8 * survivor_year + 2 * year_ret) / 10 # 10 只等权当年收益
nav_b *= 1 + port_ret # 逐年复利
ret_b = nav_b - 1
print(f"方案 A(幸存者名单)5 年总收益:{ret_a:+.1%}")
print(f"方案 B(含退市股) 5 年总收益:{ret_b:+.1%}")
print(f"两套方案差了 {ret_a - ret_b:.1%} ——这就是幸存者偏差的分量")
代码块 3:陷阱三——样本内最靓的参数,样本外还灵吗。先取一只股票 2020–2024 的日线(akshare 失败自动降级为模拟数据),然后网格搜索双均线参数:用前三年(样本内)选出收益最高的 3 组,再看它们在后两年(样本外)的表现。
import numpy as np
import pandas as pd
def load_close():
"""优先 akshare 真实行情,失败则退回固定种子的模拟数据。"""
try:
import akshare as ak
raw = ak.stock_zh_a_hist(symbol="000001", period="daily",
start_date="20200101", end_date="20241231",
adjust="qfq")
s = raw.set_index("日期")["收盘"].astype(float)
s.index = pd.to_datetime(s.index)
return s.rename("close")
except Exception:
rng = np.random.default_rng(7) # 固定种子,可复现
idx = pd.bdate_range("2020-01-01", "2024-12-31")
return pd.Series(10 * np.cumprod(1 + rng.normal(0.0002, 0.015, len(idx))),
index=idx, name="close")
def dual_ma_return(close, fast, slow, start, end):
"""双均线策略在指定区间的总收益;信号次日执行、双边 0.1% 手续费。"""
c = close.loc[start:end]
sig = (c.rolling(fast).mean() > c.rolling(slow).mean()).astype(int)
pos = sig.shift(1).fillna(0) # 关键:避免未来函数
daily = pos * c.pct_change().fillna(0)
cost = pos.diff().abs().fillna(0) * 0.001 # 换仓才扣费,双边 0.1%
return (1 + daily - cost).prod() - 1
close = load_close()
rows = []
for fast in range(3, 11): # 快线 3~10 天
for slow in range(10, 61, 5): # 慢线 10~60 天,步长 5
if fast >= slow:
continue
rows.append((fast, slow,
dual_ma_return(close, fast, slow, "2020-01-01", "2022-12-31"),
dual_ma_return(close, fast, slow, "2023-01-01", "2024-12-31")))
res = pd.DataFrame(rows, columns=["fast", "slow", "ret_in", "ret_out"])
top3 = res.sort_values("ret_in", ascending=False).head(3).round(3)
print("样本内(2020-2022)最靓的 3 组参数,放到样本外(2023-2024):")
print(top3.to_string(index=False))
代码逐段解释
块 1(未来函数):构造行情时故意用「涨一天跌一天」的完美规律——这种行情人眼一看都想做高抛低吸。signal 在涨的那天变成 1。错误版 signal * daily_ret 让信号吃当天涨幅:涨日全部吃到 +2%、跌日信号为 0,等于天天偷看答案,总收益约 +228%(复利 1.02^60)。正确版 signal.shift(1):昨天的信号今天执行——昨天涨、今天跌,方向全错,总收益约 −70%。同一套逻辑,一正一负天壤之别,未来函数的威力可见一斑。
示例输出(示意,数值由复利精确决定):
错误写法(当天信号当天买)总收益:+228.1%
正确写法(信号次日执行) 总收益:-70.2%
块 2(幸存者偏差):方案 A 数学上就是 1.08^5 − 1 ≈ +46.9%。方案 B 逐年算等权收益:前三年 (8×8% + 2×5%)/10 = 7.4%,第四年两只退市股各崩 90%,组合被拖到 −11.6%,第五年 −3.6%,复利下来约 +5.6%。名单里只多两只「死人」,五年成绩从 +46.9% 掉到 +5.6%——而真实市场中退市股的数量远不止两只。
示例输出(示意,数值由构造数据精确决定):
方案 A(幸存者名单)5 年总收益:+46.9%
方案 B(含退市股) 5 年总收益:+5.6%
两套方案差了 41.3% ——这就是幸存者偏差的分量
块 3(过拟合):dual_ma_return 就是第 18 篇手写回测的雏形,注意 pos = sig.shift(1) 已经把未来函数堵死,手续费按系列统一口径双边 0.1%、只在换仓时扣。网格共约 70 组参数,输出表格里你会经常看到这样的现象:样本内 +XX% 的冠军组,样本外收益明显缩水甚至翻负;而样本内外都还行的,往往是那些「不显眼」的参数组。这就是「样本内漂亮 ≠ 好策略」的直接证据。
划重点:三个陷阱有一个共同点——它们都让回测「变得比实际更好看」。凡是从回测里只学到「我的策略很棒」的,多半是掉进去了;诚实的回测总会告诉你一些不舒服的事实。
常见坑与小练习
坑 1:信号用当天数据算,却直接乘当天涨跌幅。 现象是回测收益好到不真实。原因是犯了未来函数。避免方式:任何信号统一 shift(1) 之后再和收益相乘,并养成「逐根 bar 问自己:这一步在当天收盘前能知道吗」的习惯。
坑 2:用 df["high"]、df["low"] 做当日止损判断。 现象是止损永远精准。原因是当天最高最低价收盘后才确定,盘中无法预知。避免方式:止损判断用前一日的价格,或改用逐 bar 推进的事件驱动框架(第 20 篇讲)。
坑 3:股票池直接用今天上市的全部股票。 现象是组合回测年年跑赢。原因是退市股被静默剔除,构成幸存者偏差。避免方式:用时点名单或纳入退市股;对指数成分回测,要用历史成分而非当前成分。
坑 4:参数组合数太多,挑了样本内冠军直接实盘。 现象是实盘表现远不如回测。原因是过拟合,冠军参数拟合的是噪音。避免方式:样本内外分开检验、看参数敏感性,系统方法等第 25 篇的 Walk-Forward。
小练习 1:把块 1 的行情改成「涨两天、跌一天」循环,先手算错误写法 40 个周期(120 天)能吃到多少收益,再跑代码验证。
提示:错误写法只在「上涨日」满仓,每个周期吃到
1.02^2 − 1,复利 40 个周期。
小练习 2:把块 3 的样本内外切分点改成 2021-12-31,重跑网格,看样本内冠军参数组还是不是原来那 3 组。
提示:冠军组换了,说明「最优参数」本身就不稳定——这正是参数尖峰的体现。
小练习 3:把块 2 的退市时间提前到第 4 年初(即只有 3 年正常期),重算方案 B 的五年总收益。
提示:思路不变,把
dead_years改成[0.05, 0.05, -0.90, -0.50]并循环 4 年,注意等权股数仍是 10 只。
本文策略仅用于教学,不构成投资建议,实盘前请充分测试。