写一个最简策略回测:跑通流程,但别信那条曲线
- 跑通一个最简单的均线交叉策略回测全流程
- 学会计算策略收益、累计净值、最大回撤等基础评价
- 亲手体会回测的陷阱,理解为什么回测好不代表能实盘赚钱
第一次亲手写完一个回测,跑出来一条向右上方翘起的净值曲线,那种感觉很上头——好像自己找到了一台印钞机。我也经历过这一关。但真正的收获不是那条曲线,而是后来慢慢看清的一件事:回测这东西,骗自己实在太容易了。 改几个数字,换一段时间,曲线就能从亏成赚;多挑几次参数,总能挑出一条好看的。这一篇我们就亲手跑通一个最简单的回测,目的不是教你一个能赚钱的策略——它大概率不能——而是让你摸清楚回测是怎么一步步做出来的,以及那些让人栽跟头的坑都藏在哪。
本篇是 R2 教育样例。所有代码用的是模拟生成的价格数据,不是任何真实标的的历史行情;策略规则也是为了演示流程而设计的最简版本,不构成任何买卖信号,不代表任何标的的真实表现,不预测未来。回测里跑出来的收益曲线,哪怕再漂亮,也不等于实盘能赚到——这正是本篇要反复讲的核心。看完别照着去实盘,先把后半段「回测到实盘的鸿沟」读透。
先想清楚:回测到底在做什么
回测的本质,是拿一段历史价格,假装你当时按某套规则操作,算一算「如果当年这么做,账户会变成什么样」。它回答的是一个假设性的问题:这套规则放在过去那段行情里,表现如何?
注意这个问句里的每一个限定词——「这套规则」「过去」「那段行情」。回测能告诉你的,仅仅是规则在那一段已经发生过的、你已经知道结果的历史上的表现。它管不了未来,也管不了别的行情。但人很容易把它当成「这套规则能赚钱」的证据,这是一切误判的起点。
我们这一篇要做的事,拆成六步:①造一段模拟价格;②定一条最简单的交易规则;③根据规则生成每天的持仓信号;④算出策略每天的收益;⑤把收益累乘成净值曲线,再算最大回撤和年化;⑥把策略和「啥也不干一直拿着」做个对比。每一步都给你代码,也讲清楚为什么这么写。
第一步:造一段模拟价格数据
真实回测当然要用真实历史数据。上一节我们讲过怎么用 pandas 把行情读成一张表(见 L5 阶梯)。但为了让这篇代码任何人复制粘贴就能跑、且绝不指向任何真实标的,这里我们用 numpy 随机生成一段价格。把它当成一只「虚构资产」的走势就行。
import numpy as np
import pandas as pd
# 固定随机种子,保证你我跑出来的数一样,方便对照
np.random.seed(42)
# 造 500 个交易日的模拟价格
# 思路:每天的收益率是一个均值很小、有波动的随机数,
# 再用累乘把日收益还原成价格序列(这是模拟资产价格的常见做法)
n_days = 500
daily_returns = np.random.normal(loc=0.0003, scale=0.015, size=n_days) # 日均0.03%,日波动1.5%
price = 100 * (1 + daily_returns).cumprod() # 从100块起步累乘
# 装进 DataFrame,配一列假装的交易日序号
df = pd.DataFrame({'close': price})
print(df.head())
print(f"起始价: {df['close'].iloc[0]:.2f}, 结束价: {df['close'].iloc[-1]:.2f}")
这段代码里 loc=0.0003 是我手动设的日均收益,scale=0.015 是波动幅度。请你记住这一点:这段「行情」长什么样,完全是我设的参数决定的。 我把均值调成正的,它整体就往上走;调成负的,它就往下跌。后面策略好不好看,很大程度上是被这两个数字喂出来的——这本身就是回测的第一个陷阱的雏形。
坑:把模拟数据或某一段精心挑选的历史当成「行情本来的样子」。为什么是坑——数据生成的假设(这里是收益率的均值和波动)直接决定了结论。真实世界里你换一只标的、换一段时间,行情的脾气可能完全不同。怎么避:永远清楚自己用的是哪段数据、为什么是这段,别拿一段数据的结论去套所有情况。
第二步:定一条最简单的规则——双均线交叉
我们用最经典也最简单的规则:双均线交叉。
- 算一条「短期均线」(比如最近 5 天收盘价的平均)和一条「长期均线」(比如最近 20 天的平均)。
- 当短期均线从下往上穿过长期均线(俗称金叉),认为趋势向上,持有。
- 当短期均线从上往下穿过长期均线(死叉),认为趋势转弱,空仓(清掉不拿)。
这个规则简单到一句话能说清,也正因为简单,特别适合用来演示流程。但先把丑话说在前面:双均线是教科书级别的入门例子,绝大多数实证研究里,单纯靠它在真实市场长期赚钱是站不住脚的。我们用它,纯粹因为它好懂。
# 算两条均线
df['ma_short'] = df['close'].rolling(window=5).mean() # 5日均线
df['ma_long'] = df['close'].rolling(window=20).mean() # 20日均线
print(df[['close', 'ma_short', 'ma_long']].tail())
rolling(window=5).mean() 就是「滚动取最近 5 个数求平均」。前 4 天因为凑不齐 5 个数,会是空值(NaN),这很正常,后面计算时会自然跳过。
第三步:生成持仓信号——这里藏着回测最致命的坑
现在我们把规则翻译成「每天到底拿不拿」。短期均线在长期均线上方就标 1(持有),否则标 0(空仓)。
# 短期均线 > 长期均线 → 信号为1(想持有),否则为0
df['signal'] = (df['ma_short'] > df['ma_long']).astype(int)
到这里都没问题。但接下来这一步,是新手最容易犯、又最难自己发现的错误——未来函数。
问题在于:signal 是用当天收盘后才能算出来的均线得到的。当天的均线,要等当天收盘价出来才能算。如果你直接拿当天的 signal 去乘当天的收益,等于假设你在当天开盘就知道了当天收盘的结果——这叫偷看未来,现实中你做不到。
正确的做法是:今天收盘算出信号,最早也只能明天才能按这个信号去持仓。 在代码里就是把信号往后挪一天,用 shift(1)。
# 关键一步:信号后移一天,避免「未来函数」
# 含义:今天收盘才知道的信号,明天才能拿来决定持仓
df['position'] = df['signal'].shift(1)
这一行 shift(1) 看着不起眼,是回测里最重要的一道防线之一。我见过太多人的回测曲线漂亮得离谱,扒开一看,全是因为漏了这个移位,等于让策略偷偷开了天眼。
未来函数是回测里最隐蔽、危害最大的错误,没有之一。它会让一个本来平平无奇甚至亏钱的策略,在回测里跑出惊艳的曲线。判断方法很简单:你在做某天的决策时,用到的每一个数,都必须是那个时刻之前已经能拿到的。 任何用到「当天还没发生」「未来才知道」信息的地方,都是危险信号。哪怕只漏一天,整条曲线就不可信了。再强调一次——回测曲线好看,绝不构成任何买卖信号。
第四步:算策略收益
有了每天的持仓(position,1 表示持有、0 表示空仓),策略的每日收益就好算了:持有那天,赚的就是标的当天的涨跌;空仓那天,收益为 0。
# 标的本身的每日收益率
df['asset_return'] = df['close'].pct_change()
# 策略收益 = 当天持仓状态 × 当天标的收益
# position=1时拿到标的收益,position=0时收益为0
df['strategy_return'] = df['position'] * df['asset_return']
print(df[['close', 'position', 'asset_return', 'strategy_return']].tail())
pct_change() 是 pandas 算「相对前一天涨跌幅」的快捷方法。这里你能更清楚地看到 shift 的作用:position 是昨天信号挪过来的,asset_return 是今天的真实涨跌,两者相乘,逻辑就对上了——昨天收盘决定拿不拿,今天承担今天的盈亏。
第五步:累计净值、最大回撤、年化
单日收益看不出名堂,要把它们「累乘」起来,看账户从 1 块钱开始,最后变成多少。这条线就是净值曲线。
# 累计净值:(1+每日收益) 连乘
df['strategy_nav'] = (1 + df['strategy_return'].fillna(0)).cumprod()
df['buyhold_nav'] = (1 + df['asset_return'].fillna(0)).cumprod() # 买入持有作对照
# 最大回撤:净值从历史最高点跌下来的最大幅度
def max_drawdown(nav):
peak = nav.cummax() # 到每个时点为止的历史最高净值
drawdown = (nav - peak) / peak # 当前相对最高点跌了多少(负数)
return drawdown.min() # 最深的那一下
# 简单年化:用总收益和天数折算(一年按252个交易日)
def annualized_return(nav, periods_per_year=252):
total_return = nav.iloc[-1] / nav.iloc[0] - 1
years = len(nav) / periods_per_year
return (1 + total_return) ** (1 / years) - 1
print(f"策略累计净值: {df['strategy_nav'].iloc[-1]:.3f}")
print(f"买入持有净值: {df['buyhold_nav'].iloc[-1]:.3f}")
print(f"策略最大回撤: {max_drawdown(df['strategy_nav']):.2%}")
print(f"策略简单年化: {annualized_return(df['strategy_nav']):.2%}")
这里三个指标讲人话解释一下:
- 累计净值:从 1 出发,最后变成 1.3 就是总共赚了 30%,变成 0.8 就是亏了 20%。
- 最大回撤:你账户从某个高点最惨跌到过多深。这是衡量「持有过程有多难受」的关键指标——年化再高,中途回撤 50% 你能不能扛住,是另一回事。回撤背后的风险管理,是单独一门大功课,见 风险管理卷R。
- 年化收益:把这段时间的总收益,折算成「平均每年赚多少」,方便不同时间长度的结果横向比。注意这只是个粗糙折算,时间越短越不可信。
第六步:和买入持有比一比
这一步特别重要,但常被忽略:你的策略再花哨,得先打得过「啥也不干一直拿着」(买入持有),才谈得上有价值。 很多策略费尽心思择时,最后还跑输了傻拿不动,那这套规则就是在做无用功,甚至帮倒忙。
import matplotlib.pyplot as plt
plt.figure(figsize=(10, 5))
plt.plot(df['strategy_nav'], label='Strategy (MA cross)')
plt.plot(df['buyhold_nav'], label='Buy & Hold', linestyle='--')
plt.title('Strategy vs Buy & Hold (SIMULATED DATA - not real)')
plt.xlabel('Trading Day')
plt.ylabel('Net Asset Value')
plt.legend()
plt.grid(alpha=0.3)
plt.show()
把这两条线画在一张图上,你会很直观地看到策略择时到底是帮了忙还是添了乱。在我们这段模拟数据上,结果可能策略略好、可能略差——别太当真,换个随机种子结论就变了。这恰恰是下一段要说的重点。
坑:只看策略自己的净值曲线,不和买入持有比。为什么是坑——一条向上的曲线看着很爽,但如果同期傻拿不动赚得更多,你这套规则其实是负贡献,还白白承担了频繁进出的成本和风险。怎么避:任何策略都先和「买入持有」这个最笨的基准比一比,赢不了基准的策略不值得做。
最关键的一段:这条曲线为什么不能信
好了,假设你跑出来一条漂亮的曲线,策略还赢了买入持有。先别激动。我们逐条数一数,这条曲线为什么离「能实盘赚钱」还差着十万八千里。
第一,我们一分钱手续费和滑点都没算。 真实交易,每买卖一次都要付佣金,还有「你想按这个价成交、实际成交价却偏了一点」的滑点。双均线这类策略买卖很频繁,把这些成本加进去,很多回测里赚钱的策略会立刻变亏钱。我们这版代码是「无摩擦理想世界」,现实没有这种好事。
第二,单一标的、单一时间段,说明不了任何普遍性。 我们只在一段模拟数据上跑了一次。一套规则在这段数据上行,换一只标的、换一段时间,可能立刻翻车。一次成功的回测,统计意义上接近于「抛一次硬币正面朝上」——不能证明硬币是正的。
第三,参数是「挑」出来的,不是「算」出来的。 我们用了 5 日和 20 日两条均线。可你有没有想过:为什么是 5 和 20,不是 6 和 18、不是 10 和 30?如果你把所有参数组合都试一遍,挑出回测里表现最好的那组拿来炫耀,这叫参数过拟合——你不是找到了规律,只是找到了「最贴合这段历史的巧合」。换一段数据,这组「最优参数」往往最先失效。
第四,幸存者偏差和事后视角。 我们是站在「已经知道这段行情怎么走」的上帝视角去设计规则的。真实交易时你站在当下,右边的曲线是黑的,你不知道接下来涨还是跌。回测里那种「该买就买、该卖就卖」的从容,实盘里根本不存在。
把上面四条合在一起说一句最重要的话:回测好≠实盘能赚钱。 回测最大的价值,是帮你排除掉那些明显错误的逻辑(连历史都跑不通的,未来更别想),以及让你熟悉流程、暴露 bug。它绝不是「这套规则未来能赚钱」的证明。任何人拿一条漂亮的回测曲线让你跟单、让你买某个策略产品,都请你先问这四个问题。本篇所有内容仅为教育演示,不构成任何投资建议或买卖信号。
关于过拟合和「赢得概率游戏」背后的数学,单独成篇更透彻,可以去 原理库 看一看为什么「试得越多、越容易撞到假规律」。
把这些坑列成一张自检表
下次你自己写回测,或者看别人甩给你一条曲线时,照着下面这张表过一遍:
| 检查项 | 没做会怎样 | 怎么避 |
|---|---|---|
| 有没有用未来函数 | 偷看未来,曲线虚高,纯属自欺 | 信号 shift 后移;决策只用当时已知信息 |
| 算没算手续费和滑点 | 高估收益,频繁交易的策略尤其失真 | 每笔交易扣成本,再看还赚不赚 |
| 是不是只跑了一段数据 | 一次成功不代表规律,可能是巧合 | 多标的、多时间段、样本外验证 |
| 参数是不是挑出来的 | 过拟合,对历史完美对未来无效 | 警惕「最优参数」,留一段数据不参与调参 |
| 有没有和买入持有比 | 不知道择时到底是帮忙还是添乱 | 永远设一个最笨的基准做对照 |
从回测到实盘,中间是一道鸿沟
跑通这个回测,你已经掌握了量化的基本动作。但请务必记住:从一条好看的回测曲线,到真金白银的稳定盈利,中间隔着的不是一条小沟,是一道深渊。 成本、过拟合、样本不足、心理压力、市场结构变化……每一项都能让回测里的「印钞机」在实盘里变成绞肉机。
所以这一篇的结尾,我最想对你说的是:别急着实盘。 把回测当成一个学习和验证逻辑的沙盘,而不是发财的捷径。下一节我们会专门讲「回测和实盘之间到底差了什么、怎么用更严谨的方法验证一套逻辑」,那一步走扎实了,再谈别的也不迟。投资有风险,量化也不例外,相关风险提示请认真读完 /disclaimer/。
小结
- 回测回答的是「这套规则在那段历史上表现如何」,仅此而已,它管不了未来,也管不了别的行情。
- 完整流程六步:造数据 → 定规则 → 生成信号(务必
shift防未来函数)→ 算策略收益 → 累计净值与回撤年化 → 和买入持有对比。 - 一条漂亮的回测曲线,至少要扣掉四件事才敢信:没算成本、单段数据、参数挑选、事后视角——任意一条都能让它从赚变亏。
- 回测好不代表实盘能赚钱。 它的真正价值是排错和练手,不是盈利证明。本篇全部为教育样例、模拟数据,不构成任何投资建议或买卖信号,不预测、不荐标的。
想继续往下走,可以回到 L5 阶梯 看量化工具链的下一节;想搞清楚回测背后「为什么试得越多越容易撞到假规律」,去 原理库;想把「回撤到底意味着什么、仓位该怎么管」补全,去 风险管理卷R。任何时候都别忘了先读 /disclaimer/。