回测方法
上一章的双均线信号已经能画出「什么时候该买、什么时候该卖」,但那张图刻意回避了真正重要的问题:**照这个信号做,到底赚不赚钱?**看图觉得「金叉之后确实涨了」不算数——涨了多少、够不够扣手续费、中间回撤能不能扛住,都要一笔一笔算过才知道。
回测就是干这个的:拿历史价格当剧本,让信号在剧本里逐日重演,严格模拟每一天的现金、持仓和成交,最后得到一条净值序列——相当于这个策略在历史上真实跑过一遍的账户对账单。本章回答三个问题:回测引擎怎么工作、成本怎么算、以及回测里最容易毁掉结论的错误——前视偏差——藏在哪里。
回测引擎:一台守规矩的时间机器
Section titled “回测引擎:一台守规矩的时间机器”回测引擎的结构出奇地简单,就是一个循环:对每个交易日,先按既定信号决定买还是卖,按假设的成交价撮合,再结算当天的账户净值。它像一台时间机器,每天醒来只能看到当天及之前的信息,看完信号、做完交易、记账、睡觉,第二天再来。
这台机器必须守两条规矩,缺一条回测就作废:
- 信息规矩:第 t 天的决策只能用第 t 天收盘前已经存在的数据。今天才知道的事,不许拿去指导昨天的交易。
- 守恒规矩:现金加持仓市值永远等于账户净值;买入花的每一分钱从现金里扣,卖出收的每一分钱进现金。引擎每笔账都要能对平。
这是本教程最重要的一段伪代码——以后每一个策略案例,引擎都是它,换的只是信号:
输入:价格序列、信号序列、初始资金 C₀、费率 fee、滑点 slipcash ← C₀;持仓 ← 0对 t 从 1 到 n: 按 t 日的信号决定操作(只能用 t 日及之前的信息) 若买入:成交价 ← close[t] × (1 + slip),扣除手续费 若卖出:成交价 ← close[t] × (1 − slip),扣除手续费 净值[t] ← cash + 持仓 × close[t]输出:净值序列逐行说明两个容易误解的地方:
- 「按 t 日的信号决定操作」指的是执行 t 日开盘前已经确定的信号,也就是上一交易日收盘后生成的 。收盘价 15:00 才定格,用 在 t 日成交,括号里「只能用 t 日及之前的信息」就满足了。
- 成交价用 close[t] 是「次日开盘成交」的简化。严格的做法是按 t 日开盘价成交;本教程的合成数据只有收盘价,于是用 t 日收盘价近似 t 日开盘价,再叠加滑点修正。这是一个近似假设,下面专门讲它的代价。
整个循环每天只记一笔账:净值 = 现金 + 持仓 × 当日收盘价。空仓时净值是一条水平线(本教程假设现金不生息),持仓时随价格波动——净值曲线的每一段的形状都直接对应持仓状态。
成交价假设与 T+1
Section titled “成交价假设与 T+1”真实的成交发生在次日开盘,不是次日收盘,更不是信号当天。用收盘价近似开盘价,差的是隔夜跳空那部分:信号日收盘到次日开盘之间可能高开 2% 也可能低开 2%,回测里这部分被抹平了。对流动性好、跳空少的品种,这个近似可以接受;对经常涨跌停、隔夜跳空剧烈的股票,它会系统性地高估收益——因为策略想买的时刻往往正是次日高开的时刻。数据条件允许时,用开盘价甚至 VWAP(成交量加权平均价)替代收盘价,是回测升级的第一步。
A 股的 T+1 制度在规则里体现为两层:
- 执行上的 T+1:信号当日收盘后判定,次日才能成交。这已经写进了上面的引擎—— 决定 t 日的操作。
- 制度上的 T+1:当日买入的股票,次日才能卖出。双均线策略每天最多一个信号,天然不会违反;但如果你的规则允许「早盘卖出、尾盘买回」这类日内反复,引擎里就必须显式禁止当日买入部分的卖出,否则回测做出了 A 股做不到的交易。
手续费与滑点:逐项拆开
Section titled “手续费与滑点:逐项拆开”回测里的成本有三项,性质完全不同,但每一项都会真实地从净值里扣钱:
| 成本 | 收取方 | 方向 | 费率 | 性质 |
|---|---|---|---|---|
| 佣金 | 券商 | 买卖双边 | 成交金额的 0.03%(万三) | 确定的费用(实务有单笔最低 5 元,此处忽略) |
| 印花税 | 国家 | 仅卖出 | 成交金额的 0.05% | 确定的费用 |
| 滑点 | 不交给谁 | 双边 | 假设 0.1% | 预期价与实际成交价的偏差,是估计值 |
滑点需要多解释一句:它不是谁收走的钱,而是「你想按 10.00 买,实际成交在 10.01」的价差。买入时实际价更贵(乘以 ),卖出时实际价更便宜(乘以 ),方向永远对你不利。0.1% 是对流动性正常股票的粗略假设,大盘股可能更低,小微盘股可能数倍于此。
手算一次完整买卖的成本:10 万元,股价 10.00 元,买入 10 000 股,假设持股期间股价不变再卖出:
- 买入成交价 = 10.00 × (1 + 0.001) = 10.01 元,成交金额 100 100 元;买入佣金 = 100 100 × 0.0003 = 30.03 元;总支出 100 130.03 元。
- 卖出成交价 = 10.00 × (1 − 0.001) = 9.99 元,成交金额 99 900 元;卖出佣金 = 99 900 × 0.0003 = 29.97 元,印花税 = 99 900 × 0.0005 = 49.95 元;实际到手 99 900 − 29.97 − 49.95 = 99 820.08 元。
- 股价没动,账户少了 100 130.03 − 99 820.08 = 309.95 元,约 0.31%:滑点双边约 0.2%、佣金双边 0.06%、印花税 0.05%。
一次买卖损耗 0.31% 看起来微不足道,但它按换手次数复利累积:一年完成 5 个来回,光成本就吃掉约 1.5 个百分点。本章的演示数据为简化只计了佣金和滑点(5/20 双均线回测演示:信号次日收盘成交,佣金双边万三 + 滑点 0.1%);把印花税加回去,低频策略结论通常不变,高频策略常常由盈转亏——成本假设是回测结论的地基之一。
前视偏差的三种藏身之处
Section titled “前视偏差的三种藏身之处”前视偏差(未来函数)是回测的头号杀手:它不露痕迹地让回测用上「当时不可能知道」的信息,把收益吹大。第 1 章讲过概念,这里看它在代码里的三个标准藏身处:
- 用了 t+1 的价格。循环里写出
若 close[t+1] > MA[t] 则今日买入之类的判断——下标越界一天,策略就等于提前看到了明天。这种错误在「对齐数据」时最常发生:把收益序列和信号序列错位一行,回测收益能凭空多出几十个百分点。防御:决策用到的每一个量,下标都不许大于信号日。 - 用了未来才发布的数据。财报按报告期对齐而不是按披露日对齐(一季报 4 月底才披露,却被用在 3 月 31 日的信号里);用全样本的均值和标准差做标准化后再生成信号;宏观数据用了事后修正值而非当时公布的初值。共同特征:数据上印的时间是「属于哪个时期」,而不是「什么时候能拿到」。防御:每个数据字段都问一句——t 日收盘时,这个数字已经公开了吗?
- 信号和成交同时用同一根 K 线的收盘价。信号由 close[t] 算出,又假设按 close[t] 成交——但收盘价 15:00 才确定,确定的那一刻已经没有交易机会了。这是前视偏差在回测引擎里的标准形态,本章引擎用「信号当日判定、次日成交」把它排除:信号用 t 日收盘,成交落在 t 日之后。
三种藏身之处的共同点:表面上每行代码都合法,错的是数据和时间的对应关系。审查回测代码时,盯着每一个下标和每一个日期对齐问「t 日那天我真的知道这个吗」,比检查语法重要得多。
图:价格、买卖点与净值
Section titled “图:价格、买卖点与净值”把引擎跑在上一章的双均线信号上(初始资金 100,000 元,250 个交易日):上图是收盘价和逐笔买卖点(红三角买入、绿三角卖出),下图是对应的逐日净值:
一年里共 6 次买入、5 次卖出,期末仍持有一笔未平仓的多头。期末净值 120,475 元,总收益 20.5%,同期买入并持有的收益是 4.1%——这一年这个策略跑赢了死拿不动。
几个值得指出的细节:
- 第一笔买卖是亏的,而且亏得很「干净」:2025-03-28 以 8.53 买入,2025-04-01 以 8.53 卖出,买卖同价、毛利为零,账户却少了约 60 元——全是两次佣金。成本不是抽象概念,它每天都在净值曲线上咬掉一小口。
- 五笔完整买卖里只有一笔大赚:2025-04-07 到 2025-05-21 那一笔赚了约 1.29 万元,其余四笔都小亏。这是趋势策略的典型盈亏分布——多数小亏,换少数几笔大赚。评价这类策略时盯着胜率看会被骗,它本来就靠盈亏比活着。
- 净值曲线的形状直接对应持仓状态:水平段是空仓(现金不生息),波动段是持仓。对着上图的买卖点看下图,能直观理解「净值 = 现金 + 持仓市值」这条结算公式。
两种回测流派:向量化 vs 逐日事件驱动
Section titled “两种回测流派:向量化 vs 逐日事件驱动”当你以后真正开始写 Python 回测时,会发现业界其实有两种完全不同的回测实现方式:
| 维度 | 向量化回测(Vectorized) | 逐日事件驱动回测(Event-Driven) |
|---|---|---|
| 核心机制 | 将整张价格表当成矩阵,用整列运算一次性算出所有收益(如 pandas shift) | 类似本章伪代码,像时间机器一样逐日(或逐 bar)循环推演撮合 |
| 执行速度 | 极快(几秒钟跑完几年全市场) | 较慢(涉及循环、对象创建与状态机维护) |
| 前视风险 | 极高(只要某一行代码漏了错位 shift(1),未来数据立刻污染全期) | 极低(时间单向流动,今天的数据绝不可能在昨天的循环里出现) |
| 细节保真度 | 粗糙(难以如实处理 T+1 冻结、动态资金仓位、停牌撮合与挂单排队) | 高度保真(账户现金、持仓股数、手续费对账每一笔清清楚楚) |
| 适用阶段 | 早期想法的粗筛、多因子截面打分的快速验证 | 策略定型、参数检验、准备上真金白银之前的终审验收 |
量化新手的典型翻车路径:学了 pandas 之后,用 5 行向量化代码写了一个均线策略,发现「年化 300%、夏普 4.5」,兴奋不已。其实只是在算收益率时漏了一行错位,把第 日收盘后才算出的信号,乘上了第 日当天的涨幅。
记住这条法则:快速看想法时可以用向量化,但真正要上实盘的策略,回测必须用逐日事件驱动引擎跑一遍对账单。
- 过拟合:把回测当调参器用。「5/20 收益 20%,那 4/17 呢?8/34 呢?」——在同一批历史数据上反复试参数、挑收益最高的那组,等于用同一份试卷既出题又阅卷,挑出来的不是最好的策略,是最适合这段历史的参数。本章只跑了一组事先定好的参数,结论才有最低限度的可信度;为什么「回测收益最高的参数几乎必然高估未来表现」、样本外验证怎么设防,是第 6 章的专章内容。
- 忽略流动性:假设想成交就能成交。引擎默认「按收盘价 ± 滑点,要多少有多少」。现实中:涨停买不进、跌停卖不出(A 股 ±10% 涨跌停,第 1 章讲数据时提过);小盘股挂单稀薄,10 万元能按 0.1% 滑点成交,1 个亿进场自己就把价格推走了——这部分自我冲击叫冲击成本,资金越大越不能忽略。最低限度的自救:在信号上加一条流动性过滤(如「当日成交额须大于持仓市值的若干倍」),并在结论里声明资金容量上限。
- 把一条净值曲线当成策略的期望收益。一次回测只走了一条历史路径,是 的样本。换一段时间、换一只股票,结果可能完全不同。第 2 章的统计思想在这里同样适用:关心的是分布,不是单次实现——这也是后面要做多样本、样本外检验的原因。
- 在向量化回测中漏掉时间错位。用表格列运算时误以为自己回测很漂亮,实则是在用今天的考卷去答昨天的题目。向量化代码必须逐行审查时间索引。
到这里,模糊的想法已经走完了「假设 → 信号 → 回测」的全流程,产出是一条净值序列和一组成交记录。但一条曲线本身还不是结论:20.5% 算高还是低?中间最深亏过多少?承担的风险换这些收益值不值?下一章把净值序列换算成可比较的指标——年化收益、最大回撤、夏普比率。