Python量化实战:比特币交易机器人开发与策略回测指南

  • sopersone
  • 发布于 2026-04-07 12:51
  • 阅读 21

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

Image

第 5 部分(共 12 部分)- 向量化回测:在真实数据上测试策略

在上一篇文章中,我们实现了三种经典策略:SMA 交叉、动量和均值回归。对于每一种策略,我们都编写了代码,生成了信号,并在 BTC 图表上进行了可视化。但信号本身并不是结果。我们仍然不知道这些策略中的任何一个是否真的能赚到钱。

在本文中,我们将揭晓答案。我们将通过历史 BTC 数据运行每个策略,计算真实收益、最大回撤和夏普比率(Sharpe ratio)——然后将这三个策略相互比较,并与简单的买入并持有进行对比。

什么是回测

回测(Backtesting)是指在历史数据上测试交易策略。其逻辑很简单:如果我们过去应用了这个策略,会发生什么?

这听起来显而易见,但有一个重要的注意事项:回测并不保证该策略在未来也会奏效。市场会变化,模式会消失,交易条件也会不同。一个好的回测是部署策略到现实世界中的必要的——但不是充分的——条件。

即便如此,回测是第一个强制性的过滤器。如果一个策略在历史数据上不起作用,那么它在实时市场上几乎肯定也不起作用。

两种方法:向量化和事件驱动

实现回测主要有两种方式:

  1. 向量化回测(Vectorized backtesting) - 我们一次性对整个数据数组应用数学运算。速度快、简洁且方便研究。这是我们在本文中使用的方法。
  2. 事件驱动回测(Event-driven backtesting) - 我们一次处理一个柱线(bar)的数据,就像时间在向前移动一样。速度较慢,但更准确:它可以考虑佣金、滑点和部分订单成交。这是第 6 篇文章的主题。

对于第一次测试假设,向量化方法非常完美。

向量化回测如何工作

这个想法很简单。我们有两个数据序列:

  • 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

理解指标

在解释结果之前,让我们确保理解每个指标。

总收益率(Total Return)

最简单的指标:在整个期间内资本增长了多少。+62.4% 意味着 1,000 美元变成了 1,624 美元。但仅仅通过收益来比较策略是不够的。

夏普比率(Sharpe Ratio)

夏普比率是比较策略的主要指标。它衡量单位风险的收益: 夏普比率 = 年化收益率 / 年化波动率

粗略的基准:

  • Sharpe < 0.5 - 较弱的策略
  • 0.5 -- 1.0 - 可接受
  • 1.0 -- 2.0 - 良好
  • > 2.0 - 优秀(或者回测中有 Bug)

最大回撤(Maximum Drawdown)

最大回撤是投资组合价值从峰值到谷值的最大跌幅。如果夏普比率衡量平均表现,回撤则捕捉了最坏的情况。一个回撤为 -50% 的策略需要 +100% 的回升才能盈亏平衡。

胜率(Win Rate)

策略赚钱的天数占其在场天数的比例。重要的不仅是获胜的频率,还有平均获利与平均亏损的比率。

可视化结果

让我们在一个图表上比较所有三个策略和买入并持有的资产净值曲线(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)

Image

上图显示了随时间推移的资本增长。下图显示了每个策略的回撤。注意,在总收益率方面,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())

解释结果

  • SMA 交叉在三者中显示出最好的夏普比率。这是一种趋势跟踪方法,而 BTC 是一种趋势性资产。主要问题是:在震荡市场中,该策略会产生大量虚假信号并在佣金上亏损。
  • 动量的表现与 SMA 类似,但反应更快。更多的交易意味着在实际交易中会有更多的交易成本。频繁的出入场也意味着策略可能会在反转前被洗出头寸。
  • 均值回归在收益上落后,但在三者中波动性最低且回撤最小。问题在于 BTC 并不是理想的均值回归资产:它容易出现强趋势,价格可能大幅偏离其均值。
  • 买入并持有 BTC 在牛市期间的收益通常优于所有三种策略——但它带有最大的回撤,有时达到 -70% 或更糟。目标是实现更好的风险/收益曲线。

要避免的陷阱

先验偏差(Look-Ahead Bias)

最危险的错误。如果你的策略使用了在决策时刻尚不可得的数据,回测将显示出不切实际的良好结果。我们使用 shift(1) 来防止这种情况。

生还者偏差(Survivorship Bias)

如果你仅对今天存在的资产测试策略,你就会自动排除所有已经破产的资产。BTC 是一个安全的选择,但在测试一篮子资产时要小心。

过拟合(Overfitting)

如果你花费大量时间调整策略参数以适应特定的历史时期,你最终得到的策略在面对新数据时会崩溃。

交易成本(Transaction Costs)

目前形式的向量化回测没有考虑佣金或点差。对于交易频繁的策略,这是至关重要的。

完整的回测脚本

"""
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)

实践练习

  1. 将 SMA 交叉参数更改为 (10/30) 和 (50/200)。夏普比率如何变化?
  2. 对于动量策略,尝试 10、20 和 40 天的窗口。在一个图表上绘制所有三条资产净值曲线。
  3. 增加简单的佣金:假设每笔交易的双边成本为 0.2%。这对总收益有何影响?
  4. 下载 ETH/USDT 数据并运行相同的回测。结果与 BTC 有区别吗?

总结

在本文中,我们构建了一个完整的向量化回测:

  • 实现了一个通用的 backtest() 函数。
  • 计算了关键指标:总收益率、夏普比率、最大回撤、胜率。
  • 将策略与买入并持有进行了比较。
  • 讨论了先验偏差和过拟合等主要陷阱。

向量化回测非常适合快速验证想法,但它无法妥善处理滑点或部分成交等复杂场景。为此,我们需要事件驱动回测。

重要免责声明

算法交易涉及真实的财务风险。本系列是教育材料。它将教你如何构建交易机器人,但不保证盈利。良好的回测结果并不意味着在实时市场上会有良好的结果。

下一步是什么

在第 6 篇文章中,我们将转向事件驱动回测。我们将逐个柱线处理数据,增加佣金和杠杆,并通过网格搜索和步进分析进行参数优化。

  • 原文链接: x.com/sopersone/status/2...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
sopersone
sopersone
江湖只有他的大名,没有他的介绍。