从「有数据」到「有结论」的一步。本篇用一份完整可运行的 Python 代码,带你跑通「读数据 → 生成信号 → 模拟成交 → 计算绩效」的最小回测闭环,并学会看懂回测结果、第一次理解什么是过拟合。
免责声明:本站全部内容仅用于学习与研究,不构成任何投资建议。市场有风险,投资需谨慎。
一、最小回测流程
回测的本质是:在历史数据上重放你的规则,假装当时你在场。四个环节缺一不可:
读数据 → 生成信号 → 模拟成交(含手续费/滑点)→ 计算绩效
1.1 完整可运行示例:双均线策略回测
前置条件:已按 02-数据获取实战 拉好 data/600519_daily_hfq.csv(后复权,含中文列名)。
示例代码仅教学用,实盘风险自负:
import numpy as np
import pandas as pd
FEE_RATE = 0.001 # 单边手续费(按成交金额比例,示例值)
SLIPPAGE = 0.0002 # 单边滑点(按成交金额比例,示例值)
def load_data(path):
df = pd.read_csv(path)
df.columns = [c.strip() for c in df.columns]
rename = {"日期": "date", "开盘": "open", "收盘": "close",
"最高": "high", "最低": "low", "成交量": "volume"}
df = df.rename(columns=rename)
df["date"] = pd.to_datetime(df["date"])
return df.set_index("date").sort_index()
def backtest_double_ma(df, fast=5, slow=20, fee=FEE_RATE, slip=SLIPPAGE):
df = df.copy()
# 1. 生成信号
df["ma_fast"] = df["close"].rolling(fast).mean()
df["ma_slow"] = df["close"].rolling(slow).mean()
df["signal"] = (df["ma_fast"] > df["ma_slow"]).astype(int)
# 2. 模拟成交:信号次日生效(避免未来函数),全仓进出
df["position"] = df["signal"].shift(1).fillna(0)
df["ret"] = df["close"].pct_change().fillna(0)
df["strat_ret"] = df["position"] * df["ret"]
# 3. 交易成本:持仓变化的那天扣掉手续费+滑点
changed = df["position"] != df["position"].shift(1)
df.loc[changed, "strat_ret"] -= (fee + slip)
df["equity"] = (1 + df["strat_ret"]).cumprod()
return df
def report(df, benchmark=None):
equity = df["equity"]
total = equity.iloc[-1] / equity.iloc[0] - 1
ann = (1 + total) ** (252 / len(df)) - 1
dd = equity / equity.cummax() - 1
r = {
"总收益": f"{total:.2%}",
"年化收益": f"{ann:.2%}",
"最大回撤": f"{dd.min():.2%}",
"夏普比率": f"{df['strat_ret'].mean() / df['strat_ret'].std() * np.sqrt(252):.2f}",
"日胜率": f"{(df['strat_ret'] > 0).mean():.2%}",
"交易次数": f"{int((df['position'] != df['position'].shift(1)).sum())}",
}
if benchmark is not None:
r["基准同期收益"] = f"{benchmark.iloc[-1] / benchmark.iloc[0] - 1:.2%}"
return r
df = load_data("data/600519_daily_hfq.csv")
result = backtest_double_ma(df, fast=5, slow=20)
print(report(result))
print(result[["close", "ma_fast", "ma_slow", "position", "equity"]].tail())
运行后你会看到一份包含总收益、年化、最大**回撤、夏普、胜率**的报表——这就是你第一个「真实」回测。
二、信号生成与仓位
上面示例里最关键的两个设计决策:
2.1 信号 → 目标仓位
信号(signal)表达的是「想不想买」,仓位(position)表达的是「现在持没持有」。二者之间隔着一拍:
t 日收盘:算出 ma_fast > ma_slow → 信号 = 1(想买)
t+1 日开盘:执行 → 仓位 = 1(持有)
signal.shift(1) 就是这一拍的实现——信号与成交必须错开一个 bar,否则就是未来函数(见 01-量化工具链 7.3)。
2.2 成交价假设
回测里「成交在哪根 K 线的哪个价」直接决定结果可信度:
| 假设 | 简单 | 真实 |
|---|---|---|
| 信号 bar 的收盘价成交 | 容易实现 | 信号本身靠收盘价算出,已偷看未来 ❌ |
| 次日开盘价成交 | 推荐个人起步 | 现实:开盘价是可成交的参考价,仍偏理想 ⭐ |
| 次日开盘价 + 滑点 | 更贴近现实 | 滑点估计不准也会引入误差 |
| 按日内高低点/成交量加权 | 复杂 | 越复杂越难验证,先用简单假设 |
💡 成交价假设的起步建议
次日开盘价 + 比例滑点,然后通过改滑点幅度做敏感性测试(滑点翻倍收益变化多大),比纠结模型精度更重要。
三、回测引擎选择:自己写 vs backtrader vs vn.py
| 方案 | 上手成本 | 能力 | 适用 |
|---|---|---|---|
| 自己写(如第一节示例) | 低,几十行 | 日线、单一标的、规则简单 | 研究初期、理解原理,强烈建议先做一次 |
| backtrader | 中 | 多标的多周期、滑点/佣金模型、内置分析器 | 从自写升级的成熟事件驱动框架 |
| vectorbt | 中 | 向量化、参数扫描极快 | 参数遍历量大的研究场景 |
| vn.py | 高 | 回测 + 实盘一体化,支持柜台/交易所 | 想直接通向实盘工程化的重型方案 |
边界建议:
- 信号基于日线、单标的、规则固定 → 自己写,20 行搞定,还能完全理解每个细节;
- 要扫几百组参数、画复杂资金曲线 → 换 backtrader 或 vectorbt,省的是体力;
- 要做多标的组合、事件驱动、tick 级 → 上 vn.py,但它复杂度会吃掉你大量研究时间。
💡 关键认知
引擎只是载体,规则才是策略。换引擎前后,同一策略同一数据应产出几乎一致的结果——这是验证自己理解正确的试金石。
四、绩效指标计算
四个必算指标,全部由净值序列(equity)推导(示例代码仅教学用,实盘风险自负):
ret = df["strat_ret"] # 策略日收益序列
equity = df["equity"] # 净值
total = equity.iloc[-1] / equity.iloc[0] - 1
n_years = len(df) / 252
ann = (1 + total) ** (1 / n_years) - 1 # 年化(几何)
dd = equity / equity.cummax() - 1 # 回撤序列
max_dd = dd.min() # 最大回撤
sharpe = ret.mean() / ret.std() * np.sqrt(252) # 年化夏普
win_rate = (ret > 0).mean() # 日胜率
| 指标 | 含义 | 怎么读 |
|---|---|---|
| 年化收益 | 折算成一年能赚多少 | 低于货基/国债就说明策略不创造价值 |
| 最大回撤 | 历史上从峰值亏多少 | 决定你能不能拿得住,比收益更重要 |
| 夏普比率 | 每单位波动换多少收益 | >1 合格,>2 优秀,回测里 >3 先怀疑未来函数 |
| 胜率 | 赚钱的日子占比 | 趋势策略胜率天然低(30-40%),别单独看 |
💡 个人交易者最直觉的体检指标
**收益回撤比(年化收益 / 最大回撤)**是个人交易者最直觉的体检指标:1 以下几乎不值得做,2 以上才算及格线。
五、回测结果怎么读
5.1 资金曲线 vs 基准
把策略净值和基准(沪深 300 指数同期)画在一张图上,看的是相对表现:
import matplotlib.pyplot as plt
bench = pd.read_csv("data/沪深300指数.csv", parse_dates=["date"]) # 自行获取
bench["equity"] = (1 + bench["close"].pct_change().fillna(0)).cumprod()
ax = result["equity"].plot(label="策略", figsize=(12, 6))
bench.set_index("date")["equity"].plot(ax=ax, label="沪深300")
plt.legend(); plt.title("策略 vs 基准")
plt.show()
- 跑赢基准且回撤可控 → 策略有研究价值;
- 全程跑不赢基准 → 直接买指数基金更省心;
- 大幅跑赢但曲线在最后一段陡峭上升 → 优先怀疑数据或代码 bug,而不是策略英明。
5.2 回撤区间分布
最大回撤只是最坏的一天,更该看回撤都发生在哪:
# 列出每次回撤的起止与幅度
dd = result["equity"] / result["equity"].cummax() - 1
peak = 0; draws = []
for i, v in enumerate(dd):
if v == 0:
peak = i
else:
draws.append((peak, i, v))
d = min(draws, key=lambda x: x[2])
print(f"最深回撤区间: {result.index[d[0]]} ~ {result.index[d[1]]}, 幅度 {d[2]:.2%}")
如果回撤集中在某一年(如 2022 熊市),说明策略本质是「牛市放大器」;如果回撤均匀分布,说明策略在低相关行情中挣扎——这决定你后续改进方向。
🛑 参数不敏感的高原才是真正有效的区域
任何参数组合都能在历史中「事后」找到盈利点,这是规律不是能力。真正有效的是参数不敏感的区域——一段高原,而不是一座尖峰。 相邻参数组绩效应平滑过渡,5/20 一枝独秀、4/19 就崩,多半是过拟合。
六、第一次认知回测过拟合
把第一节代码的 fast/slow 改成 3/17,也许收益更高;改成 8/32,也许最大回撤更小。然后呢?
# 参数遍历:扫一组均线参数
for fast in [3, 5, 10, 15]:
for slow in [20, 30, 40, 60]:
if fast >= slow:
continue
r = backtest_double_ma(df, fast=fast, slow=slow)
ann = (1 + r["equity"].iloc[-1] / r["equity"].iloc[0]) ** (252 / len(r)) - 1
print(f"f{fast}/s{slow}: 年化 {ann:.2%}")
你会得到一个「最佳参数」——但这份最佳是在这段历史上挑出来的,不是预测出来的。参数越多、历史越短、挑出来的组合越精致,越说明你在拟合噪音而非规律。这就是**过拟合**:均线 5/20 好于 3/17,不代表未来仍然如此。
判断要点:
- 相邻参数组的绩效应该是平滑过渡的(5/20 好,6/22 也不差);若 5/20 一枝独秀、4/19 就崩,多半是过拟合;
- 任何参数组合都能在历史中「事后」找到盈利点,这是规律不是能力;
- 真正有效的是参数不敏感的区域——一段高原,而不是一座尖峰。
七、把回测跑成「研究流程」
单次回测 = 一次观察,不是结论。最小研究流程是:参数域设计 + 样本内/样本外切分。
7.1 样本内 / 样本外切分
split = int(len(df) * 0.7)
train, test = df.iloc[:split], df.iloc[split:]
# 样本内:用来选参数(允许稍微优化)
best = None
for fast in [3, 5, 10]:
for slow in [20, 30, 40]:
if fast >= slow:
continue
r = backtest_double_ma(train, fast=fast, slow=slow)
score = (r["equity"].iloc[-1] / r["equity"].iloc[0]) ** (252 / len(r)) - 1
if best is None or score > best[2]:
best = (fast, slow, score)
print("样本内最优:", best)
# 样本外:用样本内选出的参数,只在没见过的数据上跑一次
r_test = backtest_double_ma(test, fast=best[0], slow=best[1])
print("样本外年化:", f"{(r_test['equity'].iloc[-1]/r_test['equity'].iloc[0]) ** (252/len(r_test)) - 1:.2%}")
规则:
- 参数只能在样本内挑;
- 样本外只跑一次,跑完不回去调参数(再调就废了);
- 样本外绩效与样本内差距过大(衰减 > 50%)→ 样本内大概率是过拟合;
- 进阶:滚动窗口前推(rolling walk-forward),比一次性切分更接近实盘节奏。
7.2 实验留档
每次回测都记录:数据范围、参数、手续费滑点假设、commit 号、结果数字(模板见 01-量化工具链 6.2)。没有记录的结论等于没结论——三个月后你会彻底忘记这次回测为什么这么做。
八、下一步
回测跑通了,先别急着实盘。先去 05-策略代码实战 看看更多策略形态,再回到 04-实盘自动化 搞清楚合规边界与自动化现实,最后决定要不要把策略推向真实资金。
⚠️ 风险提示
回测收益 ≠ 实盘收益。回测天然低估滑点、高估成交、遗漏现实约束(涨跌停买不进、**流动性**不足、停牌无法交易);经过参数优化的策略还叠加了过拟合风险。单次回测、参数遍历的高收益都不能作为投资依据;务必采用「样本内选参、样本外验证、留档可复现」的研究流程,并以模拟盘长期验证后再考虑小资金实盘。本文代码与结论仅用于学习与研究,不构成任何投资建议。