文章介绍了如何用 Python 对比特币交易策略进行向量化回测,计算总收益、夏普比率、最大回撤和胜率,并将 SMA、动量和均值回归三种策略与买入持有进行比较。文章同时强调了回测中的常见陷阱,如未来函数、幸存者偏差、过拟合和交易成本。

第 5 部分(共 12 部分)- 向量化回测:在真实数据上测试策略
在上一篇文章中,我们实现了三种经典策略:SMA 交叉、动量和均值回归。对于每一种策略,我们都编写了代码,生成了信号,并在 BTC 图表上进行了可视化。但信号本身并不是结果。我们仍然不知道这些策略中的任何一个是否真的能赚到钱。
在本文中,我们将揭晓答案。我们将通过历史 BTC 数据运行每个策略,计算真实收益、最大回撤和夏普比率(Sharpe ratio)——然后将这三个策略相互比较,并与简单的买入并持有进行对比。
回测(Backtesting)是指在历史数据上测试交易策略。其逻辑很简单:如果我们过去应用了这个策略,会发生什么?
这听起来显而易见,但有一个重要的注意事项:回测并不保证该策略在未来也会奏效。市场会变化,模式会消失,交易条件也会不同。一个好的回测是部署策略到现实世界中的必要的——但不是充分的——条件。
即便如此,回测是第一个强制性的过滤器。如果一个策略在历史数据上不起作用,那么它在实时市场上几乎肯定也不起作用。
实现回测主要有两种方式:
对于第一次测试假设,向量化方法非常完美。
这个想法很简单。我们有两个数据序列:
log_return - BTC 的对数日收益率position - 策略在每一天的仓位(1 = 在场,0 = 离场)策略在每一天的收益就是两者的乘积:
strategy_return[t] = log_return[t] * position[t]
如果仓位是 1,我们获得市场收益。如果是 0,我们获得零收益——我们处于离场状态。这就是一行代码中的整个向量化回测。
累积收益是通过对对数收益率进行运行求和来计算的:
# 计算累积收益
cumulative = strategy_returns.cumsum().apply(np.exp)
这就是对数收益率的魅力所在:你可以简单地将它们相加。
我们加载数据并应用第 4 篇文章中的策略:
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from data_loader import fetch_btc_data, add_returns
from strategies import sma_crossover_strategy, momentum_strategy, mean_reversion_strategy
## 加载过去 2 年的 BTC 日线数据
df = fetch_btc_data(interval="1d", lookback="2 years ago UTC")
df = add_returns(df)
## 或者如果你已经有了文件,可以从文件加载:
## df = pd.read_csv("data/btc_daily.csv", index_col="date", parse_dates=True)
## df["log_return"] = np.log(df["close"] / df["close"].shift(1))
print(f"Period: {df.index[0].date()} -- {df.index[-1].date()}")
print(f"Rows: {len(df)}")
应用策略:
# 应用 SMA 交叉、动量和均值回归策略
df_sma = sma_crossover_strategy(df, fast=20, slow=50)
df_mom = momentum_strategy(df, window=20)
df_mr = mean_reversion_strategy(df, window=20, threshold=-1.0)
让我们编写一个通用函数,它接收一个策略 DataFrame 并返回我们需要的各种指标:
def backtest(df, strategy_name="Strategy"):
"""
策略的向量化回测。
参数:
df -- 包含 'log_return' 和 'position' 列的 DataFrame
strategy_name -- 策略名称(用于显示)
返回:
指标字典和包含结果的 DataFrame
"""
result = df.copy().dropna(subset=["position", "log_return"])
# 策略收益:市场收益乘以仓位
result["strategy_return"] = result["log_return"] * result["position"]
# 累积收益:策略 vs 买入并持有
result["cumret_strategy"] = result["strategy_return"].cumsum().apply(np.exp)
result["cumret_buyandhold"] = result["log_return"].cumsum().apply(np.exp)
# 指标计算
total_return = result["cumret_strategy"].iloc[-1] - 1
bh_return = result["cumret_buyandhold"].iloc[-1] - 1
annual_return = (1 + total_return) ** (252 / len(result)) - 1
annual_vol = result["strategy_return"].std() * np.sqrt(252)
sharpe = annual_return / annual_vol if annual_vol > 0 else 0
# 最大回撤
rolling_max = result["cumret_strategy"].cummax()
drawdown = (result["cumret_strategy"] - rolling_max) / rolling_max
max_drawdown = drawdown.min()
# 胜率:策略赚钱的天数比例
winning_days = (result["strategy_return"] > 0).sum()
trading_days = (result["position"] == 1).sum()
win_rate = winning_days / trading_days if trading_days > 0 else 0
metrics = {
"name": strategy_name,
"total_return": total_return,
"bh_return": bh_return,
"annual_return": annual_return,
"annual_vol": annual_vol,
"sharpe": sharpe,
"max_drawdown": max_drawdown,
"win_rate": win_rate,
"trading_days": int(trading_days),
}
return metrics, result
将函数应用于每个策略:
# 运行各策略回测
metrics_sma, res_sma = backtest(df_sma, "SMA Crossover (20/50)")
metrics_mom, res_mom = backtest(df_mom, "Momentum (20d)")
metrics_mr, res_mr = backtest(df_mr, "Mean Reversion (20/-1)")
以表格形式打印结果:
def print_metrics(metrics):
print(f"\n{'=' * 45}")
print(f" {metrics['name']}")
print(f"{'=' * 45}")
print(f" Strategy return: {metrics['total_return']:>8.1%}")
print(f" Buy-and-hold return: {metrics['bh_return']:>8.1%}")
print(f" Annual return: {metrics['annual_return']:>8.1%}")
print(f" Annual volatility: {metrics['annual_vol']:>8.1%}")
print(f" Sharpe ratio: {metrics['sharpe']:>8.2f}")
print(f" Max drawdown: {metrics['max_drawdown']:>8.1%}")
print(f" Win rate: {metrics['win_rate']:>8.1%}")
print(f" Days in market: {metrics['trading_days']:>8}")
for m in [metrics_sma, metrics_mom, metrics_mr]:
print_metrics(m)
示例输出(实际数字取决于时间段):
=============================================
SMA Crossover (20/50)
=============================================
Strategy return: +62.4%
Buy-and-hold return: +148.3%
Annual return: +27.1%
Annual volatility: 38.2%
Sharpe ratio: 0.71
Max drawdown: -28.4%
Win rate: 52.3%
Days in market: 412
=============================================
Momentum (20d)
=============================================
Strategy return: +51.8%
Buy-and-hold return: +148.3%
Annual return: +22.9%
Annual volatility: 40.1%
Sharpe ratio: 0.57
Max drawdown: -31.7%
Win rate: 51.1%
Days in market: 398
=============================================
Mean Reversion (20/-1)
=============================================
Strategy return: +9.2%
Buy-and-hold return: +148.3%
Annual return: +4.5%
Annual volatility: 22.1%
Sharpe ratio: 0.20
Max drawdown: -19.3%
Win rate: 53.8%
Days in market: 118
在解释结果之前,让我们确保理解每个指标。
最简单的指标:在整个期间内资本增长了多少。+62.4% 意味着 1,000 美元变成了 1,624 美元。但仅仅通过收益来比较策略是不够的。
夏普比率是比较策略的主要指标。它衡量单位风险的收益:
夏普比率 = 年化收益率 / 年化波动率
粗略的基准:
Sharpe < 0.5 - 较弱的策略0.5 -- 1.0 - 可接受1.0 -- 2.0 - 良好> 2.0 - 优秀(或者回测中有 Bug)最大回撤是投资组合价值从峰值到谷值的最大跌幅。如果夏普比率衡量平均表现,回撤则捕捉了最坏的情况。一个回撤为 -50% 的策略需要 +100% 的回升才能盈亏平衡。
策略赚钱的天数占其在场天数的比例。重要的不仅是获胜的频率,还有平均获利与平均亏损的比率。
让我们在一个图表上比较所有三个策略和买入并持有的资产净值曲线(equity curves):
def plot_equity_curves(results: dict):
fig, axes = plt.subplots(2, 1, figsize=(14, 9),
gridspec_kw={"height_ratios": [3, 1]})
colors = ["steelblue", "darkorange", "forestgreen"]
first_res = list(results.values())[0]
axes[0].plot(first_res.index, first_res["cumret_buyandhold"],
linewidth=1.2, color="black", linestyle="--",
label="Buy & Hold", alpha=0.7)
for (name, res), color in zip(results.items(), colors):
axes[0].plot(res.index, res["cumret_strategy"],
linewidth=1.2, color=color, label=name)
axes[0].set_title("Equity Curve: Strategies vs Buy & Hold (BTC/USDT)")
axes[0].set_ylabel("Cumulative Return (start = 1)")
axes[0].legend()
axes[0].grid(True, alpha=0.3)
axes[0].axhline(1, color="gray", linewidth=0.6, linestyle=":")
for (name, res), color in zip(results.items(), colors):
rolling_max = res["cumret_strategy"].cummax()
drawdown = (res["cumret_strategy"] - rolling_max) / rolling_max
axes[1].fill_between(res.index, 0, drawdown * 100,
alpha=0.4, color=color, label=name)
axes[1].set_ylabel("Drawdown (%)")
axes[1].set_xlabel("Date")
axes[1].legend(loc="lower left", fontsize=8)
axes[1].grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
results = {
"SMA Crossover (20/50)": res_sma,
"Momentum (20d)": res_mom,
"Mean Reversion (20/-1)": res_mr,
}
plot_equity_curves(results)

上图显示了随时间推移的资本增长。下图显示了每个策略的回撤。注意,在总收益率方面,BTC 的买入并持有通常优于所有三个策略——但其回撤要严重得多。
def comparison_table(metrics_list):
rows = []
for m in metrics_list:
rows.append({
"Strategy": m["name"],
"Return": f"{m['total_return']:+.1%}",
"Buy & Hold": f"{m['bh_return']:+.1%}",
"Sharpe": f"{m['sharpe']:.2f}",
"Max Drawdown": f"{m['max_drawdown']:.1%}",
"Win Rate": f"{m['win_rate']:.1%}",
"Days in Market": m["trading_days"],
})
return pd.DataFrame(rows).set_index("Strategy")
table = comparison_table([metrics_sma, metrics_mom, metrics_mr])
print(table.to_string())
最危险的错误。如果你的策略使用了在决策时刻尚不可得的数据,回测将显示出不切实际的良好结果。我们使用 shift(1) 来防止这种情况。
如果你仅对今天存在的资产测试策略,你就会自动排除所有已经破产的资产。BTC 是一个安全的选择,但在测试一篮子资产时要小心。
如果你花费大量时间调整策略参数以适应特定的历史时期,你最终得到的策略在面对新数据时会崩溃。
目前形式的向量化回测没有考虑佣金或点差。对于交易频繁的策略,这是至关重要的。
"""
backtest_vectorized.py -- 三种经典策略的向量化回测
"""
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from data_loader import fetch_btc_data, add_returns
from strategies import sma_crossover_strategy, momentum_strategy, mean_reversion_strategy
def backtest(df, strategy_name="Strategy"):
# 准备结果数据,并丢弃缺失值
result = df.copy().dropna(subset=["position", "log_return"])
result["strategy_return"] = result["log_return"] * result["position"]
result["cumret_strategy"] = result["strategy_return"].cumsum().apply(np.exp)
result["cumret_buyandhold"] = result["log_return"].cumsum().apply(np.exp)
# 计算各项指标
total_return = result["cumret_strategy"].iloc[-1] - 1
bh_return = result["cumret_buyandhold"].iloc[-1] - 1
annual_return = (1 + total_return) ** (252 / len(result)) - 1
annual_vol = result["strategy_return"].std() * np.sqrt(252)
sharpe = annual_return / annual_vol if annual_vol > 0 else 0
rolling_max = result["cumret_strategy"].cummax()
drawdown = (result["cumret_strategy"] - rolling_max) / rolling_max
max_drawdown = drawdown.min()
winning_days = (result["strategy_return"] > 0).sum()
trading_days = (result["position"] == 1).sum()
win_rate = winning_days / trading_days if trading_days > 0 else 0
metrics = {
"name": strategy_name,
"total_return": total_return,
"bh_return": bh_return,
"annual_return": annual_return,
"annual_vol": annual_vol,
"sharpe": sharpe,
"max_drawdown": max_drawdown,
"win_rate": win_rate,
"trading_days": int(trading_days),
}
return metrics, result
def plot_equity_curves(results: dict):
# 绘制净值曲线和回撤图
fig, axes = plt.subplots(2, 1, figsize=(14, 9),
gridspec_kw={"height_ratios": [3, 1]})
colors = ["steelblue", "darkorange", "forestgreen"]
first_res = list(results.values())[0]
axes[0].plot(first_res.index, first_res["cumret_buyandhold"],
linewidth=1.2, color="black", linestyle="--",
label="Buy & Hold", alpha=0.7)
for (name, res), color in zip(results.items(), colors):
axes[0].plot(res.index, res["cumret_strategy"],
linewidth=1.2, color=color, label=name)
axes[0].set_title("Equity Curve: Strategies vs Buy & Hold (BTC/USDT)")
axes[0].set_ylabel("Cumulative Return (start = 1)")
axes[0].legend()
axes[0].grid(True, alpha=0.3)
axes[0].axhline(1, color="gray", linewidth=0.6, linestyle=":")
for (name, res), color in zip(results.items(), colors):
rolling_max = res["cumret_strategy"].cummax()
drawdown = (res["cumret_strategy"] - rolling_max) / rolling_max
axes[1].fill_between(res.index, 0, drawdown * 100,
alpha=0.4, color=color, label=name)
axes[1].set_ylabel("Drawdown (%)")
axes[1].set_xlabel("Date")
axes[1].legend(loc="lower left", fontsize=8)
axes[1].grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
def comparison_table(metrics_list):
# 构建指标对比表
rows = []
for m in metrics_list:
rows.append({
"Strategy": m["name"],
"Return": f"{m['total_return']:+.1%}",
"Buy & Hold": f"{m['bh_return']:+.1%}",
"Sharpe": f"{m['sharpe']:.2f}",
"Max Drawdown": f"{m['max_drawdown']:.1%}",
"Win Rate": f"{m['win_rate']:.1%}",
"Days in Market": m["trading_days"],
})
return pd.DataFrame(rows).set_index("Strategy")
if __name__ == "__main__":
# 加载数据并添加收益率
df = fetch_btc_data(interval="1d", lookback="2 years ago UTC")
df = add_returns(df)
# 实例化策略
df_sma = sma_crossover_strategy(df, fast=20, slow=50)
df_mom = momentum_strategy(df, window=20)
df_mr = mean_reversion_strategy(df, window=20, threshold=-1.0)
# 运行回测
metrics_sma, res_sma = backtest(df_sma, "SMA Crossover (20/50)")
metrics_mom, res_mom = backtest(df_mom, "Momentum (20d)")
metrics_mr, res_mr = backtest(df_mr, "Mean Reversion (20/-1)")
# 打印对比结果
table = comparison_table([metrics_sma, metrics_mom, metrics_mr])
print(table.to_string())
# 绘图可视化
results = {
"SMA Crossover (20/50)": res_sma,
"Momentum (20d)": res_mom,
"Mean Reversion (20/-1)": res_mr,
}
plot_equity_curves(results)
在本文中,我们构建了一个完整的向量化回测:
backtest() 函数。向量化回测非常适合快速验证想法,但它无法妥善处理滑点或部分成交等复杂场景。为此,我们需要事件驱动回测。
算法交易涉及真实的财务风险。本系列是教育材料。它将教你如何构建交易机器人,但不保证盈利。良好的回测结果并不意味着在实时市场上会有良好的结果。
在第 6 篇文章中,我们将转向事件驱动回测。我们将逐个柱线处理数据,增加佣金和杠杆,并通过网格搜索和步进分析进行参数优化。
- 原文链接: x.com/sopersone/status/2...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!