回测到底是什么?
回测就是用历史数据模拟策略过去的表现。它是策略验证的基础——也是大多数量化人翻车的根源,做错的话。
每次回测都在撒谎。问题在于你把哪些谎话挑出来了。
说白了就这么回事。回测,就是拿历史数据跑一遍你的交易规则,看看它当时会怎么表现。它会吐出一个数:模拟夏普、模拟最大回撤、模拟收益。这串数字里的每一个环节都建立在一堆假设上,而那些假设几乎总是偏向你这边。
这不是我在贩卖焦虑,这就是这个行业的现实。量化研究员的工作不是找到一个长得漂亮的回测,而是找到一个老实的回测,跑一遍,然后假设它还在某个你没注意到的地方骗你。
回测的四块拼图
剥掉任何回测框架的外壳——Arithmion、Backtrader、Zipline,或者你自己写的 50 行脚本——里面就是四块拼图:
- 数据。 你要交易的那个品种的日 K 线。开高低收、成交量。要做复权处理,没做的话数字全是垃圾。
- 信号生成。 根据截止当前的数据,告诉你”买”或者”卖”的规则。均线金叉、均值回归 z-score,随你。
- 执行模型。 信号触发之后会发生什么。是按今天的收盘成交?还是明天的开盘?中间价?买一价?大部分谎话都藏在这里。
- 仓位管理。 你砸多少钱进去。每次信号一手?等权?波动率倒数加权?
就这么多。如果你的回测里这四块没有显式写出来,那不叫回测,叫画图。
从零写一个回测,30 行就够
不上任何 SDK。就 numpy、pandas,再加上一颗诚实的心。
import numpy as np
import pandas as pd
import yfinance as yf
# 1. 数据:SPY 2010-2024 日收盘价
prices = yf.download("SPY", start="2010-01-01", end="2024-01-01",
auto_adjust=True)["Close"]
prices.columns = ["Close"]
prices = prices.squeeze()
# 2. 信号:20 日动量。今天 > 20 日均线就持仓,否则空仓。
sma = prices.rolling(20).mean()
signal = (prices > sma).astype(int).shift(1) # shift 一下避免用到未来
# 3. 执行:按次日开盘成交。我们这里用当日收盘近似
# (这本身就是个谎——后面我们会修)。
returns = prices.pct_change() * signal
# 4. 仓位:满仓,不加杠杆。
equity = (1 + returns).cumprod()
print(f"总收益: {(equity.iloc[-1] - 1) * 100:.1f}%")
print(f"夏普比率: {(returns.mean() / returns.std()) * np.sqrt(252):.2f}")
print(f"最大回撤: {((equity / equity.cummax()) - 1).min() * 100:.1f}%")
print(f"胜率: {(returns > 0).sum() / returns.notna().sum() * 100:.1f}%")
# 在真实数据上大概的输出:
# 总收益: 178.4%
# 夏普比率: 0.91
# 最大回撤: -16.2%
# 胜率: 55.3%
提示词
用 yfinance 拿 SPY 数据,跑一个 20 日均线动量回测,零成本,输出总收益、Sharpe、最大回撤和胜率。
这个数——夏普 0.91,总收益 178%——看起来很美。你第一反应肯定是赶紧上线。别急。
回测吐出来的五个数
上面那段输出就是标准报告:
- 总收益。 资金曲线的复利增长。光看这个意义不大,几乎每个回测都能搞出一个漂亮的总收益。
- 夏普比率。 年化收益除以年化波动率。行业里衡量风险调整后收益的标准分。 扣完成本夏普还能过 1.0,那是真有水平。低于 0.5 基本是业余选手区间。
- 最大回撤。 最惨的一次从峰顶到谷底。如果最大回撤 50%,那你的策略要翻倍才能回本。大部分人熬不到那一天。
- 胜率。 赚钱的交易日(或交易笔数)占比。40% 的胜率照样能赚大钱,前提是你赚的时候赚得多、亏的时候亏得少。
- 平均每笔交易。 平掉一仓之后的平均盈亏。夏普为负说明平均每笔都在亏钱。
这几个指标你会盯很久。但没一个能告诉你策略明天行不行。
这个回测错在哪(三处)
回头看那 30 行。里面埋了三个谎:
谎 1:用到未来。 我把信号 shift 了一天”避免”用到未来。但规则本身——prices > sma——是用今天的收盘来决定今天的仓位。问题是你只有在收盘之后才知道今天收在哪儿,所以你实际能下单的是明天的开盘,不是今天的收盘。shift(1) 解决了信号的时序问题,但它假设你能瞬间按昨天的收盘价成交。真实执行是明天的开盘,还有滑点。把 shift 的位置动一下,夏普就掉下来了。
谎 2:没算交易成本。 信号每次从 0 变 1 或者从 1 变 0,我们就交易一次。我们付了 0 成本。现实里 SPY 散户双边 1-3 个 bp,流动性差的品种更高。单笔听起来不多,但几百次来回复利下来就不少了。
谎 3:假设完美成交。 回测里假设我们按收盘价成交了。实际我们是在买卖价差里面的某个位置成交的,还可能有大单冲击成本。SPY 上这点事影响不算大;但放到小盘股上,就是赚钱和破产的区别。
现在把成本和滑点加到同一个回测里:
cost_bps = 2.5 # 单边 2.5 个 bp;双边 = 5 个 bp,散户现实水平
turnover = signal.diff().abs().fillna(0)
returns = prices.pct_change() * signal - turnover * cost_bps / 10000
print(f"扣费后夏普: {(returns.mean() / returns.std()) * np.sqrt(252):.2f}")
# 夏普从 0.91 掉到大概 0.55。同样代码,同样信号,
# 老实记账。策略从"很棒"变成了"凑合"。
提示词
用 pandas 给同一个回测加交易成本:算换手率扣单边 2.5bp,打印扣费后 Sharpe,看怎么从 0.91 掉到 0.55。
同一个回测,老实记账。夏普 ~0.55。突然你就不好意思拿这个策略退休了。
第一条原则
每个回测结果都必须用一段独立的样本外数据复现一遍。复现不了,就是过拟合。
流程是这样的:把数据对半分。前半段用来开发——调参数、改阈值、加过滤器。后半段代码原封不动跑一遍。如果后半段的表现跟前半段差不多,那可能真有东西。如果直接崩掉,那说明你拟合的是噪声。
大部分做量化的,尤其是新人,做反了。他们拿全量数据跑一遍,看到结果不错,就停了。他们盯着的那个数,正是他们用来挑策略的数。这不叫验证,这叫描述。
更糟糕的是参数搜索。试试 100 个不同的均线长度,挑出夏普最高的那一个。这不是策略,这是曲线拟合。这个坑后面专门讲。
回测存在的意义,是让你找到策略不工作的理由
这是没人爱听的部分。
你要是 30 分钟跑完一个回测,结果漂亮得不行,那你不是在做回测,你只是跑了一段代码。一个真正扎实的回测要花几周:参数稳健性检验、行情分段、交易成本敏感性、前推验证、样本外复现、模拟盘,再加至少一个”突然意识到数据有幸存者偏差,推倒重来”的瞬间。
把看起来漂亮的结果当成可疑对象,而不是兴奋的理由。回测夏普 2.0 的时候,正确的反应是”bug 在哪儿?“——不是”赶紧部署”。
说到底就一句话:找那个你用尽各种办法都打不死的策略。在你把所有能抹黑它的招都试过之后还站得住的那个数,才接近真相。
下一步
现在你知道回测是什么、能吐出什么、为什么要怀疑输出结果了。下一步是老老实实搭一个。咱们用均线交叉——量化策略里的”hello world”——来看看它具体是怎么坏掉的。
参考资料
- Chan, Ernest. Quantitative Trading: How to Build Your Own Algorithmic Trading Business. Wiley, 2008. Chapters 8-9. ISBN 978-0470284889.
- Aronson, David. Evidence-Based Technical Analysis: Applying the Scientific Method and Statistical Inference to Trading Signals. Wiley, 2006. Chapters 1-3. ISBN 978-0470008744.