上一篇我们认识了回测的三大陷阱:未来函数、幸存者偏差、过拟合,还亲眼看到了「当天信号当天买」能把 +2%/−2% 交替的行情做出 +228% 的荒唐成绩。概念都懂了,那一个真正干净的回测到底怎么写?本篇就动手,把上篇立的规矩逐条落实,写出你人生第一个完整的向量化回测。
策略选最经典的双均线策略(Dual Moving Average Crossover):短周期均线上穿长周期均线看多、下穿看空。它规则简单到五行以内能说完,却又完整覆盖了回测的全部环节——信号、持仓、成本、净值、对比基准,麻雀虽小五脏俱全。更重要的是,上一篇埋的雷(未来函数)会在代码里被 shift(1) 正面拆掉。读完本篇,你将拥有一个可以反复魔改的回测「毛坯房」,第 19 篇的评价指标和第 20 篇的事件驱动框架都在它上面盖楼。
本篇目标
- 能用
rolling与比较运算写出 MA5/MA20 金叉死叉信号,并说出两个信号的准确含义。 - 能按「信号
shift(1)变持仓」的纪律,写出不含未来函数的策略日收益公式。 - 能按系列统一口径扣除双边 0.1% 手续费,并解释「双边」具体指什么。
- 能画出策略累计净值与买入持有的对比图,并说出从图上读策略优劣的方法。
核心概念
双均线:金叉与死叉
把最近 5 天收盘价的平均连成线,就是 5 日均线(MA5);20 天的就是 MA5 的老大哥 MA20。快线反应灵敏、慢线稳重迟缓,两条线的相对位置就是策略的全部世界观:
- 金叉(Golden Cross):快线自下而上穿越慢线,短期成本快速抬高,视为趋势走强的迹象,策略在此转入持有。
- 死叉(Death Cross):快线自上而下穿越慢线,视为趋势转弱的迹象,策略在此清仓离场。
写成 pandas 只要一行:signal = (MA5 > MA20).astype(int)。快线在慢线上方时 signal 为 1,否则为 0。金叉死叉发生的时刻,恰好就是 signal 数值跳变的时刻(0 变 1 是金叉,1 变 0 是死叉),后面代码里就用「跳变」来统计交易次数。
从信号到净值:四步流水线
向量化回测的全部逻辑就是一条四步流水线,每一步都是一次 DataFrame 运算:
- 信号:
signal = (ma_fast > ma_slow).astype(int)——今天收盘后才能算出的判断。 - 持仓:
position = signal.shift(1).fillna(0)——今天的信号,明天才执行。这是第 17 篇用血泪换来的纪律:signal 用了当天收盘价,而当天收盘价在决策时刻还不存在,所以必须整体平移一天。 - 日收益:
strategy_ret = position × 标的日收益 − 换仓名义金额 × 费率。持有 1 份就享受 1 份涨跌,空仓日收益为零。 - 净值:
(1 + strategy_ret).cumprod()——每日收益逐日复利连乘,得到累计净值曲线。
第 3 步里的成本要单独说清楚。系列统一口径:手续费按成交额的双边 0.1%(万 10)。「双边」的意思是买一次收 0.1%、卖一次再收 0.1%,一买一卖一个来回总共 0.2%。写成代码就是:trade = position.diff().abs() 算出当天换仓的名义金额比例(0 变 1 是买入 100% 仓位,1 变 0 是卖出 100% 仓位),cost = trade × 0.001 从当天收益里扣除。空仓转满仓扣一次、满仓转空仓扣一次,天然就是「双边」。
划重点:判断回测有没有未来函数,只需对每一行代码问一句「这一步在当天收盘前能知道吗」。
signal.shift(1)之后的持仓列,每一格都只包含昨天及更早的信息,这句问话就有了标准答案。
教学简化,丑话在前
本篇回测刻意做了四处简化,让代码保持五十行以内:无滑点(实际买卖价差会再吃掉一截)、全仓进出(不做仓位管理)、收盘价成交(假设信号次日收盘价精确成交)、无停牌与涨跌停约束(A 股现实中涨停买不进、跌停卖不出)。这些简化在第 22 篇会逐一补上,但你要清楚:简化后的成绩天然偏乐观,读数时心里要打个折扣。
买入持有:最诚实的陪跑员
光看策略净值涨了不够,要知道「如果什么都不做、第一天买 entry 一直拿着」会怎样——这就是买入持有(Buy & Hold)基准,它的净值就是 close / close.iloc[0]。策略跑不赢基准,那所有的进进出出只是在给券商打工;跑赢了基准,才有资格谈下一步。本篇的对比图上,这两条线会同台竞技。
完整代码
依赖安装:uv pip install pandas numpy akshare matplotlib。三个代码块按顺序放在同一个脚本里执行。
代码块 1:加载数据(akshare 失败自动降级为固定种子的模拟数据,保证一定能跑):
import numpy as np
import pandas as pd
def load_data():
"""优先 akshare 真实行情,失败则退回固定种子的模拟数据。"""
try:
import akshare as ak
raw = ak.stock_zh_a_hist(symbol="000001", period="daily",
start_date="20200101", end_date="20241231",
adjust="qfq") # 前复权,保证价格连续
raw = raw.rename(columns={"日期": "date", "收盘": "close"})
df = raw[["date", "close"]].copy()
df["date"] = pd.to_datetime(df["date"])
df = df.set_index("date")
print(f"已加载 akshare 真实数据:000001,共 {len(df)} 个交易日")
except Exception as e:
print(f"akshare 不可用({e}),改用本地模拟数据")
rng = np.random.default_rng(7)
idx = pd.bdate_range("2020-01-01", "2024-12-31")
df = pd.DataFrame(
{"close": 10 * np.cumprod(1 + rng.normal(0.0002, 0.015, len(idx)))},
index=idx)
df.index.name = "date"
print(f"已生成模拟数据,共 {len(df)} 个交易日")
return df
df = load_data()
代码块 2:计算双均线信号与持仓:
import numpy as np
import pandas as pd
# —— 第一步:均线与原始信号(用了当天收盘价,还不能交易!)——
df["ma_fast"] = df["close"].rolling(5).mean() # MA5 快线
df["ma_slow"] = df["close"].rolling(20).mean() # MA20 慢线
df["signal"] = (df["ma_fast"] > df["ma_slow"]).astype(int)
# —— 第二步:持仓 = 信号平移一天,彻底消灭未来函数 ——
df["position"] = df["signal"].shift(1).fillna(0).astype(int)
# —— 第三步:用信号跳变统计金叉死叉(0->1 金叉,1->0 死叉)——
df["trade"] = df["position"].diff() # +1 买入,-1 卖出
golden = df.index[df["trade"] == 1]
death = df.index[df["trade"] == -1]
print(f"金叉次数:{len(golden)},死叉次数:{len(death)}")
print("前 3 次金叉日期:", [d.strftime("%Y-%m-%d") for d in golden[:3]])
print("前 3 次死叉日期:", [d.strftime("%Y-%m-%d") for d in death[:3]])
print("\n最近 5 个交易日信号与持仓:")
print(df[["close", "ma_fast", "ma_slow", "signal", "position"]].tail())
代码块 3:向量化回测、扣除成本、画净值对比图:
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
plt.rcParams["font.sans-serif"] = ["SimHei", "Microsoft YaHei"] # 中文显示
plt.rcParams["axes.unicode_minus"] = False
# —— 第四步:策略日收益 = 持仓 × 标的日收益 − 换仓名义 × 费率(双边 0.1%)——
df["asset_ret"] = df["close"].pct_change().fillna(0) # 标的日收益
df["cost"] = df["trade"].abs().fillna(0) * 0.001 # 每次换仓扣 0.1%
df["strategy_ret"] = df["position"] * df["asset_ret"] - df["cost"]
# —— 第五步:累计净值 + 买入持有基准 ——
df["nav_strategy"] = (1 + df["strategy_ret"]).cumprod() # 策略净值
df["nav_bnh"] = df["close"] / df["close"].iloc[0] # 买入持有净值
n_trades = int(df["trade"].abs().sum()) # 单边换仓总次数
total_cost = df["cost"].sum() * 100 # 累计成本(%)
print(f"单边换仓次数:{n_trades},累计手续费约 {total_cost:.2f}%(按名义金额简单相加)")
print(f"期末净值:策略 {df['nav_strategy'].iloc[-1]:.3f} | "
f"买入持有 {df['nav_bnh'].iloc[-1]:.3f}")
fig, ax = plt.subplots(figsize=(10, 5))
ax.plot(df.index, df["nav_strategy"], label="双均线策略", lw=1.5)
ax.plot(df.index, df["nav_bnh"], label="买入持有", lw=1.5, alpha=0.7)
ax.set_title("双均线策略 vs 买入持有(2020-2024,教学简化回测)")
ax.set_ylabel("累计净值")
ax.legend()
ax.grid(alpha=0.3)
fig.tight_layout()
plt.savefig("dual_ma_backtest.png", dpi=150)
print("已保存对比图 dual_ma_backtest.png")
代码逐段解释
块 1(取数):与系列其他篇同一套降级写法——akshare 拉 000001(平安银行)2020–2024 前复权日线,网络或接口异常时退回固定种子的模拟数据。用前复权(qfq)很关键:分红除权造成的价格跳空会让均线产生假信号,前复权把历史价格调整连续,均线才反映真实的持有体验。
块 2(信号与持仓):先算 signal——注意这一列只能看不能用,因为它建立在当天收盘价上。真正的交易依据是 position = signal.shift(1):今天收盘算出信号,明天才体现在持仓上。trade = position.diff() 里 +1 表示明天买入建仓、−1 表示卖出清仓,跳变的日期就是金叉死叉的执行日。打印的 tail() 能让你肉眼核对最近几天「signal 今天变 1、position 明天才变 1」的错位关系——这是检查未来函数最直观的办法。
块 3(回测与作图):核心公式就一行:strategy_ret = position × asset_ret − cost。逐项拆开:持仓为 1 的日子享受标的涨跌,为 0 的日子收益为零;trade.abs() × 0.001 在每次建仓和平仓日扣 0.1%——买卖各扣一次,正好是「双边」口径。注意成本项不受持仓乘数保护,换仓当天该扣就扣,这是很多人写漏的地方。最后 (1 + ret).cumprod() 逐日复利成净值,和 close / 首日收盘价 的买入持有基准画在同一张图上。读图方法:策略线在基准线上方且更平滑(回撤更浅)才算赢;两条线粘得很近时,说明进出折腾全给手续费交了朋友。
示例输出(示意,具体日期与数值取决于你拉到的数据与装了哪个库):
已加载 akshare 真实数据:000001,共 1215 个交易日
金叉次数:14,死叉次数:14
前 3 次金叉日期: ['2020-02-21', '2020-04-14', '2020-06-01']
前 3 次死叉日期: ['2020-03-09', '2020-05-11', '2020-07-16']
最近 5 个交易日信号与持仓:
close ma_fast ma_slow signal position
date
2024-12-25 10.85 10.82 10.71 1 1
2024-12-26 10.90 10.85 10.73 1 1
2024-12-27 10.88 10.87 10.76 1 1
2024-12-30 10.79 10.84 10.78 0 1
2024-12-31 10.75 10.79 10.79 0 0
单边换仓次数:28,累计手续费约 2.80%(按名义金额简单相加)
期末净值:策略 1.423 | 买入持有 1.187
已保存对比图 dual_ma_backtest.png
划重点:期末净值的读数每台机器都可能不同(数据源、复权、区间起点都会影响),真正稳定的是方法本身。请把「信号 shift、成本双边、对照基准」这套流程当成肌肉记忆,数字只是流程的产物。
常见坑与小练习
坑 1:把 signal 直接乘收益,忘了 shift(1)。 现象是回测收益好得离谱,实盘却完全不是回事。原因就是第 17 篇拆解的未来函数。避免方式:固定写 position = signal.shift(1).fillna(0),并打印几行 signal 与 position 的对照做目检。
坑 2:手续费只在清仓时扣,或干脆不扣。 现象是回测净值虚高,越频繁的策略虚得越多。原因是每次成交都有成本。避免方式:cost = position.diff().abs() × 费率,建仓、平仓各扣一次;本策略五年才换仓 28 次,可 0.1% 已经吃掉约 2.8% 名义金额——高频策略下这个数字会吞掉全部利润。
坑 3:数据复权方式前后不一致。 现象是均线在除权日附近出现假金叉假死叉。原因是不复权价格在除权日有向下跳空,快线被「砸穿」慢线。避免方式:统一用前复权(或后复权)数据做回测,并且整条流水线只用一种复权口径。
坑 4:开局 NaN 处理不当。 现象是净值曲线开头出现异常跳变,或者 diff 产生的首行 NaN 让统计次数少 1。原因:pct_change 与 diff 的首行都是 NaN。避免方式:像本篇一样显式 fillna(0),并在统计交易次数前确认 NaN 已处理。
坑 5:只测一只股票一段行情就下结论。 现象是「我的策略五年赚 40%」的兴奋。原因:单一标的单一区间的成绩可能是运气(第 17 篇的过拟合)。避免方式:换标的、换区间、换参数多跑几组,成绩稳健才有底气;系统化的检验方法在第 25 篇。
小练习 1:把均线参数改成 MA10/MA60 重跑全部代码,记录金叉死叉次数与期末净值,和 MA5/MA20 对比。
提示:周期越长换仓越少、信号越迟钝;重点观察「次数减少」和「成本下降」的关系。
小练习 2:把费率从 0.001 分别改成 0.0005 和 0.003,各跑一遍,算出期末净值的变化。
提示:改动只涉及
df["cost"]一行;对比结果会让你对「成本是策略的头号敌人」有直观认识。
小练习 3:给策略加一条风控规则——收盘价跌破 MA20 也清仓(即 signal = ((ma_fast > ma_slow) & (close > ma_slow)).astype(int)),重跑并对比净值曲线。
提示:只需改块 2 里 signal 那一行,其余流水线原样复用;观察它在下跌段是否躲得更早。
本文策略仅用于教学,不构成投资建议,实盘前请充分测试。