海龟交易法则回测为什么容易“看起来很强”?

用户头像sh_***416jmt75L
2026-09-07 发布

海龟交易法则看起来非常适合用 Python 回测:计算过去 N 天最高价,突破就买;跌破退出线,就卖。

但真正让回测结果失真的,往往不是策略公式,而是数据。

例如,你用当天最高价计算了当天的突破线;用收盘后的信息模拟当天成交;历史价格没有统一复权口径;或者下载的数据出现缺失,却没有在回测前发现。

这些问题不会让 Python 报错,却可能让一套看起来非常漂亮的趋势追踪策略失去实际研究价值。

所以,如果你的目标是“用 Python 体验海龟交易法则”,第一步不应该急着画收益曲线,而应该先把数据和信号时序处理正确。

海龟策略最简单的规则其实不复杂

为了说明数据问题,可以使用一个简化的海龟趋势策略:

  • 过去 20 个交易日最高价作为入场参考;
  • 向上突破时产生买入信号;
  • 过去 10 个交易日最低价作为退出参考;
  • 向下突破时产生退出信号。

用 Pandas 表达:

df["entry_high"] = df["High"].rolling(20).max()
df["exit_low"] = df["Low"].rolling(10).min()

看起来没有什么问题。

真正的问题是:

这个 rolling(20) 到底包含哪些数据?

如果它包含当前 K 线,那么你的策略可能已经使用了今天才能知道的信息。

第一个坑:突破线不能偷看当前 K 线

假设今天是 9 月 7 日。

你在今天开盘时只能知道截至 9 月 6 日的数据。

那么“过去 20 日最高价”应该是:

8月11日 → 9月6日

而不是:

8月12日 → 9月7日

因此,回测代码更合理的写法是:

df["entry_high"] = (
    df["High"]
    .rolling(20)
    .max()
    .shift(1)
)

df["exit_low"] = (
    df["Low"]
    .rolling(10)
    .min()
    .shift(1)
)

这里的 shift(1) 是整个实现中非常重要的一行。

它表达的是:

当前交易日只能使用上一交易日已经确定的突破线。

对于任何基于历史窗口的突破策略,都值得先问一句:

这个指标在真实交易决策发生的那个时间点,真的已经知道了吗?

第二个坑:信号时间和成交时间不是一回事

假设:

df["entry_signal"] = df["Close"] > df["entry_high"]

那么信号是在收盘价确定之后才知道的。

这时如果回测代码马上写:

buy_price = df["Open"]

就有明显的时间顺序问题。

因为今天的开盘价发生在今天收盘之前,而你的突破条件却使用了今天的收盘价。

更合理的简化模型可以是:

T 日收盘
    ↓
发现突破
    ↓
产生交易信号
    ↓
T+1 日执行

例如:

df["signal"] = (
    df["Close"] > df["entry_high"]
).astype(int)

df["position"] = df["signal"].shift(1)

这并不是唯一正确的执行模型,但至少把“知道信号”和“执行交易”分开了。

真正的回测引擎还需要进一步明确:

  • 次日开盘成交还是其他时点;
  • 是否存在滑点;
  • 手续费如何计算;
  • 订单是否一定成交;
  • 停牌或缺失行情如何处理。

这些都是回测模型的一部分。

第三个坑:复权不是“下载下来就结束了”

如果你回测的是股票历史价格,复权口径会直接影响价格序列。

同一只股票可能存在:

  • 不复权;
  • 前复权;
  • 后复权;
  • 其他复权口径。

如果策略依赖历史最高价、最低价和突破条件,就不能在不同数据口径之间随意混用。

例如:

历史突破线
        ↓
复权价格
        ↓
当前价格
        ↓
信号

如果前后使用了不同价格体系,突破条件可能发生变化。

因此,在正式开始回测前,至少应该把这件事情写进数据处理层:

df = load_price_data(
    symbol=symbol,
    adjust="forward",
)

然后整个研究期间保持同一口径。

QuantDash 官方 Python 示例明确展示了日 K 线的复权参数,包括 forwardbackwardnone 等方式。

这并不意味着某一种复权方式永远是海龟策略的“正确答案”,而是提醒你:

回测首先需要定义数据口径,然后才能比较策略结果。

第四个坑:缺一天数据,策略可能悄悄改变含义

Pandas 的 rolling 操作并不知道你的数据是不是完整交易日序列。

例如:

df["entry_high"] = (
    df["High"].rolling(20).max().shift(1)
)

这里的“20”实际上是 20 行数据

如果中间存在缺失:

交易日 A
交易日 B
交易日 C
缺失
交易日 E
...

那么它不一定代表你以为的 20 个交易日。

因此,在回测之前最好先检查:

df = df.sort_index()

print(df.index.min())
print(df.index.max())
print(df.isna().sum())
print(df.index.duplicated().sum())

至少确认:

  • 日期已经排序;
  • 没有重复日期;
  • OHLC 字段没有异常空值;
  • 数据区间符合预期。

如果发现空 DataFrame,则更应该先排查数据,而不是让策略继续运行。

第五个坑:标的代码错误,比策略错误更难发现

量化数据接口通常需要特定的代码格式。

例如,同一个市场里的股票可能需要交易所后缀,而不同市场又有不同编码规则。

QuantDash 官方示例当前使用:

600519.SH
000001.SZ
510300.SH
00700.HK
AAPL.US

分别对应 A 股、ETF、港股和美股的示例标的格式。

这意味着,在多市场研究中,最好不要把:

symbol = "AAPL"

这种裸代码直接散落在项目各处。

更适合建立一个统一的标的层:

symbols = {
    "us_stock": ["AAPL.US", "MSFT.US"],
    "hk_stock": ["00700.HK"],
    "cn_stock": ["600519.SH"],
}

策略层只处理标准化后的代码。

这样以后更换数据源时,需要修改的主要是数据接入层,而不是所有策略。

第六个坑:把“数据获取”和“回测”写成一个函数

初学者很容易写出这样的程序:

def backtest(symbol):
    # 下载数据
    # 清洗数据
    # 计算指标
    # 生成信号
    # 买卖
    # 计算收益
    # 输出结果
    ...

开始时很方便。

但当你需要:

  • 更换数据源;
  • 更换复权方式;
  • 增加 ETF;
  • 回测多个股票;
  • 增加缓存;
  • 调整策略参数;

这个函数很快会失控。

更好的结构是:

get_data()
    ↓
clean_data()
    ↓
calculate_indicators()
    ↓
generate_signals()
    ↓
simulate_trades()
    ↓
analyze_results()

这样海龟策略只是其中的一层。

QuantDash 应该放在哪一层?

如果你的任务只是体验海龟交易法则,用 CSV 加 Pandas 完全可以。

但如果你已经在做量化研究,需要持续获取股票或 ETF 历史行情,那么数据接入层就值得单独处理。

QuantDash 当前官方 Python 示例提供了这样的数据获取方式:

from quantdash import QuantDash

qd = QuantDash()

df = qd.klines.get(
    "AAPL.US",
    period="1d",
    count=1000,
    adjust="forward",
    to_dataframe=True,
)

官方仓库说明,Python SDK 通过 PyPI 分发,并支持 Python 3.9 及以上版本;示例中的行情结果可以直接以 DataFrame 形式使用。

于是整个研究流程可以保持清晰:

QuantDash
   │
   │ 历史行情
   ↓
Pandas DataFrame
   │
   ├── 计算20日突破
   ├── 计算10日退出
   ├── 计算ATR
   └── 生成信号
          │
          ↓
     你的回测代码

这一区分很重要。

QuantDash 是行情数据获取层,不是海龟策略回测引擎。

海龟规则、仓位管理、交易模拟和最终分析仍然应该由你的研究代码负责。

第七个坑:参数越调越好,不代表策略越可靠

海龟策略很容易产生参数优化冲动:

20 日突破?
→ 试试 18

10 日退出?
→ 试试 12

ATR 20?
→ 试试 ATR 14

风险 1%?
→ 试试 0.8%

最后可能找到一组历史结果特别漂亮的参数。

但这时真正的问题是:

你是在研究趋势追踪规律,还是在记忆历史数据?

因此,参数调整应该有明确的研究目的。

例如:

训练区间
    ↓
确定参数范围
    ↓
测试区间
    ↓
观察策略是否仍然符合预期

而不是不断修改参数直到历史收益最大。

尤其不要因为一次回测结果漂亮,就把它包装成“海龟策略的真实收益”。

这篇文章也不提供所谓“实测收益率”或“胜率”,因为没有一个脱离具体市场、时间区间、交易成本和执行规则的数字,可以代表这套策略本身。

第八个坑:把数据问题误认为策略问题

当回测结果异常时,可以按照下面的顺序排查:

确认标的代码
    ↓
确认数据区间
    ↓
确认交易日期是否合理
    ↓
确认 OHLC 是否存在空值
    ↓
确认复权口径
    ↓
确认 rolling 是否 shift
    ↓
确认信号时间
    ↓
确认成交时间
    ↓
确认手续费和滑点
    ↓
最后再检查策略逻辑

如果使用 API 获取数据,还应该额外检查请求状态和权限。

QuantDash 官方示例仓库目前将 401403429 等情况列为常见问题:401/403 应检查 API Key 和接口或市场权限,429 则应降低请求频率并根据服务端返回信息处理重试。

这比看到回测结果异常后直接修改策略参数更有效。

一套更干净的 Python 回测骨架

如果把前面的原则组合起来,可以得到一个非常简洁的研究骨架:

import pandas as pd


def prepare_data(df):
    df = df.copy()
    df = df.sort_index()

    df["entry_high"] = (
        df["High"]
        .rolling(20)
        .max()
        .shift(1)
    )

    df["exit_low"] = (
        df["Low"]
        .rolling(10)
        .min()
        .shift(1)
    )

    return df


def generate_signals(df):
    df = df.copy()

    df["entry"] = df["Close"] > df["entry_high"]
    df["exit"] = df["Close"] < df["exit_low"]

    return df

注意这里仍然没有把“策略收益”写死在代码里。

这是有意为之。

一个好的研究框架应该让数据、信号和交易模拟保持相对独立。

以后你可以把:

rolling(20)

换成其他参数,也可以把:

AAPL.US

换成股票或 ETF 池,而不用重新设计整个程序。

海龟策略真正值得学习的地方

如果把所有细节去掉,海龟交易法则其实给 Python 量化开发提供了一个很好的练习题:

一个自然语言策略
        ↓
明确规则
        ↓
转换成时间序列计算
        ↓
处理信息可得性
        ↓
生成交易信号
        ↓
模拟执行
        ↓
分析结果

这比单纯调用一个现成回测函数更值得学习。

因为当你以后研究均线突破、通道突破或者其他趋势策略时,仍然会遇到完全相同的问题:

指标在什么时候才真正可用?

订单在什么时候才能成交?

历史数据到底是什么口径?

数据缺失会不会改变策略含义?

这些问题解决好了,策略公式反而只是最简单的一部分。

总结

用 Python 回测海龟交易法则,真正需要认真处理的并不是几十行 Pandas 代码,而是数据与时间的关系。

至少要记住四件事:

  1. 突破线使用历史数据时,要避免包含当前 K 线;
  2. 信号生成时间和模拟成交时间必须分开;
  3. 复权、缺失数据和标的代码必须在数据层明确处理;
  4. 数据获取、策略信号和回测执行最好保持解耦。

如果只是学习,CSV + Pandas 已经足够。

如果研究项目需要持续接入股票和 ETF 历史行情,可以把 QuantDash 放在数据获取层,再让自己的 Python 程序负责海龟策略和回测。这样数据源与策略逻辑不会绑死在一起,也更方便后续扩展到多标的研究。QuantDash 官方资料目前确认其 Python SDK、日 K 线、复权方式以及 A 股、ETF、港股、美股的代码格式等能力。

## QuantDash 官方资源

评论