简介:面向量化交易入门者与具备Python基础的数据分析学习者,这份海龟交易法则实现材料围绕经典趋势跟踪策略的Python落地展开,重点解决如何用pandas处理金融时间序列、组织行情数据并完成规则验证的问题。包内共3个文件,含2个csv行情数据文件和1个py策略脚本,压缩包约41KB,体量轻便,数据与代码配套,便于直接查阅和本地运行。已有1308人学习下载,说明其在量化策略入门场景中具有一定参考价值。内容以pandas的DataFrame为核心,覆盖数据获取与清洗、均线趋势指标计算、突破信号判定、基于ATR的止损与仓位管理、交易信号记录及账户模拟等环节,并保留结果统计与绩效评估的扩展空间。读者可据此理解海龟法则的规则结构,对照示例数据复现策略流程,并在此基础上调整参数、补充风控逻辑或接入其他行情源,适合作为程序化交易与股票软件相关学习的实践底稿。
1. 用 pandas 复刻海龟交易法则:真正难的不是入场信号,是头寸单位
某开发者拿一套唐奇安通道策略回测,净值曲线漂亮得让他以为自己摸到了量化交易的门,实盘两个月回撤 30%,原因不是信号失效,是他在 pandas 里把“1 个 unit”算成了“1 手”。海龟交易法则公开四十多年,入场规则早就不值钱,真正决定这套程序化交易能不能落地的是三件事:唐奇安通道别写出未来函数、ATR 定出的 N 值要参与仓位计算、离场和加仓必须共用同一个状态机。用 Python 做数据分析的人最容易犯的错,是把策略当成一串 pandas 指标拼起来,而它其实是一个带状态的记账循环。这一篇按我拆源码的顺序讲:先定数据契约,再算通道和 ATR,然后写仓位引擎,最后把回测跑通并逐个排查翻车点。适合已经会写rolling、但还没把一套完整规则跑成可复现回测的人。
2. 数据契约先立住:pandas 表结构决定后面能少改几次代码
2.1 为什么是“向量化算指标 + 循环管仓位”的混合结构
纯向量化回测写起来爽,df["signal"].shift(1) * df["ret"]一行出净值,但它有个致命前提:仓位和盈亏必须能表达成收益率的线性组合。海龟法则不满足这个前提。它的仓位是“几个 unit”,每个 unit 的大小由开仓当时的账户权益和当时的 N 值共同决定,加仓时还要用新的 N 值重算,止损价跟着加权均价上移。这是一条路径依赖的链,任何一步都依赖前面所有步的结果,向量化表达不了。
纯事件循环也不行。逐行iterrows去算 20 日最高价,等于把 pandas 最擅长的部分扔掉,几万根 K 线能跑几十秒,参数一扫就是几十分钟。我一般的做法是分两层:指标层全部用 pandas 向量化算好,落到列里;交易层用一次for循环按行推进,只读列、不算指标。这样指标算错的排查成本和循环写错的排查成本被拆开了,出问题能快速定位是哪一层。
分层之后,两层之间的接口就是那张 DataFrame。表结构定得清楚,后面改策略只动循环,不动数据加载;换品种只动一列参数,不动代码。所以我习惯在写第一行策略代码之前,先把数据契约固定下来。
2.2 一张 K 线表至少要准备哪些列
最小可用集是六列:date、open、high、low、close、volume。除此之外,海龟法则还必须补两列业务字段,否则仓位一定是错的:
| 列名 | 类型 | 作用 | 缺失后果 |
|---|---|---|---|
| date | datetime64 | 时间索引,排序与对齐基准 | 重采样、rolling 窗口全乱 |
| open/high/low/close | float64 | 通道、ATR、成交价 | 精度不足会让突破判定抖动 |
| volume | float64 | 停牌识别、流动性过滤 | 停牌日被当成正常交易日 |
| point_value | float64 | 每点价值,合约乘数 | 股票当期货算,仓位差 100 倍 |
| adj_factor | float64 | 复权因子,可选 | 除权日出现假突破 |
point_value这一列很多人漏掉。股票按股数交易,一手 100 股,价格就是金额;商品期货一手对应若干吨,报价乘上合约乘数才是金额。海龟原版用在期货上,unit 公式里的分母是N × point_value。如果拿股票数据直接套期货公式,或者反过来,算出来的手数会离谱到你自己都不信,但在小样本回测里它只是让收益曲线变得“很刺激”,不一定会报错,这是最阴的一类错。
import pandas as pd import numpy as np REQUIRED = ["date", "open", "high", "low", "close", "volume"] def load_bars(path, point_value=1.0): df = pd.read_csv(path) missing = set(REQUIRED) - set(df.columns) if missing: raise ValueError(f"缺少必需字段: {missing}") df["date"] = pd.to_datetime(df["date"]) df = (df.sort_values("date") .drop_duplicates("date", keep="last") .reset_index(drop=True)) # 统一转 float64:float32 在 rolling 累加里会有可见的精度漂移 for col in ["open", "high", "low", "close", "volume"]: df[col] = pd.to_numeric(df[col], errors="coerce").astype("float64") df = df.dropna(subset=["open", "high", "low", "close"]).reset_index(drop=True) # 停牌识别:成交量归零,或四价全等。标记而不是删除,删了会破坏交易日语义 df["suspended"] = (df["volume"] <= 0) | ( (df["high"] == df["low"]) & (df["open"] == df["close"]) ) df["point_value"] = float(point_value) return df这段代码里有三个参数值得说。keep="last"处理重复日期,行情源补数据时经常出现同一天两条记录,保留最后一条更接近已修正版本。drop_duplicates要在排序之后做,否则保留的是原始顺序里的“最后”,不是时间上的最后。point_value做成入参而不是写死,是为了同一套代码能跑股票和期货,股票传 1.0,期货传合约乘数。
dropna只对 OHLC 做,不对 volume 做,因为成交量缺失可以补 0,价格缺失不能补,补出来的价格会直接生成一个不存在的突破信号。
2.3 复权、缺失与停牌:三个必须先处理干净的前置问题
复权是回测里最容易被跳过、后果又最直接的一步。用未复权价格算唐奇安通道,除权除息那天价格会凭空跳一个缺口,20 日新高可能在毫无交易意义的位置被触发。更麻烦的是,用前复权价格做回测,历史价格会随着每次新的除权而整体变动,同一份代码今天跑和三个月后跑,历史信号不一致,参数也就没法比较了。我一般用后复权价算信号和收益,只在需要展示成交金额时按因子折算回真实报价。
缺失值的处理分两种。整行缺失(某个交易日完全没数据)用交易日历重采样补出来,价格前向填充、成交量填 0,并保留suspended标记;单列缺失(某天只有收盘价没最高最低)直接丢掉这一行,因为 ATR 需要真实的 high/low。
def align_to_calendar(df, max_ffill=5): # 用工作日历重采样,覆盖停牌和漏采的交易日 cal = pd.bdate_range(df["date"].min(), df["date"].max()) out = (df.set_index("date") .reindex(cal) .ffill(limit=max_ffill)) # 价格最多前填 5 天,长停牌不硬填 out["volume"] = out["volume"].fillna(0.0) out["suspended"] = out["suspended"].fillna(True).astype(bool) out.index.name = "date" return out.reset_index()max_ffill=5是个经验值:短停牌(三五天)前填价格合理,长停牌继续前填会制造出一段“价格不动”的假历史,ATR 被压到接近 0,接着 unit 数量被算到天上。宁可让长停牌段留 NaN,后面在循环里跳过。
到这里数据层就干净了。后面所有章节都默认输入是这张表,列名和类型不再变。
3. 唐奇安通道与 ATR:信号序列最容易埋未来函数的地方
3.1 通道的 shift(1):不加这一行,整套回测都是假的
唐奇安通道的定义很朴素:过去 N 根 K 线的最高价和最低价。写成 pandas 就是df["high"].rolling(20).max()。问题在于,滚动窗口默认包含当前行,也就是说当前这根 K 线的最高价参与了“过去 20 日最高价”的计算。
后果分两种写法。第一种,用收盘价和通道比较:close > hh。因为hh >= high >= close,这个条件几乎永远不成立,策略一笔都不开,你会以为海龟法则失效了。第二种,用最高价和通道比较:high >= hh,条件天天成立,因为high就是hh的组成部分之一,等于用当天的最低价买了当天的最高点,回测收益能飞到天上去,实盘一分钱赚不到。
正确的写法只有一种:把窗口整体后移一根,让“过去 N 根”严格不包含当前这根。
def add_channels(df, entry_n=20, exit_n=10): df = df.copy() # shift(1) 是关键:当前 bar 只能看到它之前的 N 根 df["hh_entry"] = df["high"].rolling(entry_n, min_periods=entry_n).max().shift(1) df["ll_entry"] = df["low"].rolling(entry_n, min_periods=entry_n).min().shift(1) df["hh_exit"] = df["high"].rolling(exit_n, min_periods=exit_n).max().shift(1) df["ll_exit"] = df["low"].rolling(exit_n, min_periods=exit_n).min().shift(1) return df两个参数:entry_n=20是入场通道,exit_n=10是离场通道,海龟原版用的是 20 进 10 出,也有一派用 55 进 20 出做长周期。min_periods显式写成和窗口一致,是为了避免默认行为在未来版本里变化,也避免你后面手滑改成min_periods=1而没有察觉。
min_periods设成 1 是另一个隐蔽的未来函数温床:前 19 行的通道值是“到目前为止的最高价”,样本不足却给出了一个值,回测早期会冒出一批本不该存在的信号。设成entry_n之后,前 19 行是 NaN,循环里用np.isfinite跳过即可。
3.2 ATR 的三种算法:为什么海龟必须用 Wilder 平滑
N 值是海龟法则的度量单位,定义为 20 日的真实波幅均值。真实波幅 TR 取三者最大:
- 当日最高减当日最低
- 当日最高减昨日收盘的绝对值
- 当日最低减昨日收盘的绝对值
第二步的平滑方式有三种常见写法,结果差别不小。第一种是简单算术平均tr.rolling(20).mean(),对每根 K 线等权;第二种是指数平滑tr.ewm(span=20).mean(),span换算成alpha = 2/(20+1) ≈ 0.095;第三种是 Wilder 平滑,alpha = 1/20 = 0.05,等价于ewm(alpha=1/20, adjust=False)。海龟原版用的是第三种,它的衰减更慢、对突变更钝,直接决定了加仓节奏。
def add_atr(df, n=20): df = df.copy() prev_close = df["close"].shift(1) tr = pd.concat([ df["high"] - df["low"], (df["high"] - prev_close).abs(), (df["low"] - prev_close).abs(), ], axis=1).max(axis=1) df["tr"] = tr # Wilder 平滑,等价于海龟原版的 N 值 df["n_unit"] = tr.ewm(alpha=1.0 / n, adjust=False, min_periods=n).mean() return dfadjust=False必须写。默认的adjust=True会用加权平均重算整个序列,序列开头几行的值和真实递推结果不一致,虽然影响会随长度衰减,但在参数敏感性测试里足够让你得出错误结论。min_periods=n保证前 19 行是 NaN,不给循环提供虚假的 N 值。
np.maximum的替代写法是tr.clip(lower=...),但没必要,pd.concat(...).max(axis=1)在几万行数据上足够快,可读性更好。
3.3 把突破信号写成可检查的布尔列
指标算完,先不要急着写循环。我习惯先把信号列出来,单独检查一遍:一共多少个入场信号、分布在哪些年份、有没有连续几天反复触发。这套检查能拦住八成以上的逻辑错。
def add_signals(df): df = df.copy() df["long_entry"] = df["close"] > df["hh_entry"] # 收盘突破 20 日高点 df["long_exit"] = df["close"] < df["ll_exit"] # 收盘跌破 10 日低点 df["valid"] = df[["hh_entry", "ll_entry", "n_unit"]].notna().all(axis=1) return df df = load_bars("bars.csv", point_value=1.0) df = align_to_calendar(df) df = add_channels(df, entry_n=20, exit_n=10) df = add_atr(df, n=20) df = add_signals(df) print(df.loc[df["valid"], "long_entry"].sum()) # 信号总数 print(df.loc[df["long_entry"], "date"].dt.year.value_counts().sort_index())判断突破用收盘价还是用盘中最高价,是一个明确的取舍。用收盘价,信号稳定、不会因为一根长上影线反复触发,代价是成交价偏离突破点;用盘中价,更贴近真实突破,但需要处理“当天冲高回落”的假突破,通常会加一个过滤条件,比如突破幅度超过 0.5 个 N 才算数。我一般先用收盘价把整套流程跑通,再换成盘中价做对比,两次结果的差距就是这套参数对成交假设的敏感度。
valid这一列是后面循环的开关。它同时检查了通道和 N 值是否就绪,缺一不可:只有通道没有 N 值,仓位算不出来;只有 N 值没有通道,没有入场依据。
4. 头寸规模、金字塔加仓与离场:一个能跑通的仓位引擎
4.1 从 N 值到 unit:1% 风险模型到底在算什么
海龟法则的仓位公式是这套系统里最容易被简化掉的部分。核心思想只有一句话:让 1 个 N 的价格波动,对应账户权益的 1%。写成公式:
unit = 账户权益 × 1% ÷ (N × point_value)这个公式的含义是,如果你只持有 1 个 unit,价格反向波动 1 个 N,你亏掉账户的 1%。N 大(波动大)的时候 unit 变小,N 小的时候 unit 变大,仓位自动随波动率反向调整。这就是波动率目标(volatility targeting)的最早实践之一。
def size_unit(equity, n_unit, point_value=1.0, risk_pct=0.01): if not np.isfinite(n_unit) or n_unit <= 0 or equity <= 0: return 0.0 return equity * risk_pct / (n_unit * point_value)risk_pct=0.01是海龟原版参数。这个值调大,收益和回撤一起放大,调小则相反,它不改变信号的胜率,只改变资金曲线的斜率。point_value在股票上填 1.0,在期货上填合约乘数,这一项填错,前面所有计算都白做。
还要加一道 floor 处理。A 股最小交易单位是 100 股,期货有最小变动价位和整手限制,算出来的 unit 是小数,直接下单会被拒。我一般在size_unit外面套一层向下取整到最小交易单位,宁可少买不可多买,因为多买会让实际风险超过 1% 的设定,长期跑下来资金曲线会被尾部风险拖垮。
4.2 加仓与止损上移:金字塔的三个硬约束
海龟法则的加仓规则是:每上涨 0.5 个 N,加一个 unit,最多加到 4 个 unit。同时,止损价不是固定在上一次开仓价下方 2N,而是跟着加权平均成本走。
这里有三个硬约束,少一个都会让回测结果偏离实盘:
第一,加仓间隔按“上一次成交价 + 0.5N”判断,不是按“最初开仓价”。用最初开仓价判断,价格一路上涨时你会一次性触发所有加仓条件,四个 unit 在同一根 K 线上全部成交,等于满仓追高。
第二,加仓之后必须重算加权平均成本,再把止损价设为“新均价 − 2N”。很多人加仓后忘了上移止损,结果浮盈回吐时止损还在最初的位置,回撤直接翻倍。
第三,加仓用的 N 值是加仓当天的 N,不是开仓当天的 N。N 在变,用旧值会让加仓间距失真。
def weighted_cost(pos_qty, pos_cost, add_qty, add_price): total = pos_qty + add_qty if total <= 0: return 0.0, 0.0 return total, (pos_qty * pos_cost + add_qty * add_price) / total止损价的更新放在加仓之后统一做:stop = avg_cost - stop_n * n_unit。如果做空,方向反过来,stop = avg_cost + stop_n * n_unit。这一段逻辑不长,但它是整套系统里最不能想当然的部分,我见过不止一个人把止损写成“上一次开仓价 − 2N”,在小样本上没暴露出问题,跑长周期立刻翻车。
4.3 事件循环:用 cash 和 position 记账,别用收益率累乘
循环的骨架是每天按顺序处理四件事:先判断已有持仓是否触发止损或离场,再判断空仓时是否入场,再判断持仓时是否加仓,最后把当天的权益记进曲线。顺序不能乱,先离场后入场,否则同一天平仓又开仓,会多出一笔不存在的手续费。
记账用cash + position * price而不是收益率累乘,好处是每一笔现金流都看得见,对账时能和交割单逐条比。代价是要自己维护avg_cost、units、last_fill、stop_price四个状态变量。
def run_backtest(df, equity0=1_000_000, risk_pct=0.01, max_units=4, add_step=0.5, stop_n=2.0, fee_rate=0.0003, lot=1): cash, pos, avg_cost = equity0, 0.0, 0.0 units, last_fill, stop_price = 0, np.nan, np.nan trades, curve = [], [] for _, row in df.iterrows(): if not row["valid"]: curve.append({"date": row["date"], "equity": cash + pos * row["close"]}) continue o, c = row["open"], row["close"] n = row["n_unit"] equity = cash + pos * c if pos > 0: # 止损优先:盘中触及即走,跳空时按开盘价成交 if np.isfinite(stop_price) and row["low"] <= stop_price: fill = min(o, stop_price) cash += pos * fill * (1 - fee_rate) trades.append({"date": row["date"], "side": "exit", "reason": "stop", "price": fill, "qty": pos}) pos, units, stop_price, last_fill = 0.0, 0, np.nan, np.nan elif row["long_exit"]: cash += pos * c * (1 - fee_rate) trades.append({"date": row["date"], "side": "exit", "reason": "channel", "price": c, "qty": pos}) pos, units, stop_price, last_fill = 0.0, 0, np.nan, np.nan if pos == 0 and row["long_entry"]: qty = np.floor(size_unit(equity, n, row["point_value"], risk_pct) / lot) * lot if qty > 0 and cash >= qty * c: cash -= qty * c * (1 + fee_rate) pos, avg_cost, units = qty, c, 1 last_fill = c stop_price = avg_cost - stop_n * n trades.append({"date": row["date"], "side": "entry", "reason": "breakout", "price": c, "qty": qty}) elif pos > 0 and units < max_units and c >= last_fill + add_step * n: equity = cash + pos * c qty = np.floor(size_unit(equity, n, row["point_value"], risk_pct) / lot) * lot if qty > 0 and cash >= qty * c: cash -= qty * c * (1 + fee_rate) pos, avg_cost = weighted_cost(pos, avg_cost, qty, c) units += 1 last_fill = c stop_price = avg_cost - stop_n * n # 止损跟着新均价上移 trades.append({"date": row["date"], "side": "add", "reason": "pyramid", "price": c, "qty": qty}) curve.append({"date": row["date"], "equity": cash + pos * c}) return pd.DataFrame(trades), pd.DataFrame(curve)参数逐个说。equity0是初始资金,只影响绝对金额不影响收益率。risk_pct=0.01是单 unit 风险敞口比例。max_units=4是金字塔上限,原版参数,调到 1 就退化成不加仓的版本,可以拿来做对照实验。add_step=0.5是加仓间距,单位是 N。stop_n=2.0是止损距离,也是单位 N。fee_rate通常按单边万分之三估,lot是最小交易单位,股票填 100,期货填 1。
iterrows确实不快,几万行大概几秒。参数扫描时我会把这段换成numpy数组加索引循环,能快十倍以上,但第一版一定用iterrows,因为可读性优先,写错了能一眼看出来。
还有一点,这段代码默认收盘价成交,属于偏乐观假设。真实情况是信号当天收盘确认、次日开盘成交,中间隔一个跳空。两种假设的差距就是策略对滑点的敏感度,第 6 章会专门量化这个差距。
净值曲线拿到之后,几个基础指标自己算一下,不要迷信任何现成库:
def perf(curve, freq=252): eq = curve["equity"].astype(float) ret = eq.pct_change().fillna(0.0) dd = eq / eq.cummax() - 1.0 years = len(eq) / freq return { "total_return": eq.iloc[-1] / eq.iloc[0] - 1, "cagr": (eq.iloc[-1] / eq.iloc[0]) ** (1 / years) - 1 if years > 0 else np.nan, "max_drawdown": dd.min(), "vol": ret.std() * np.sqrt(freq), }cagr里那个years用len(eq)/252粗估,遇到停牌多的品种会略微高估年化,够用。max_drawdown是这套系统最该盯的指标,海龟法则的胜率通常不到 40%,靠的是盈亏比,回撤深度直接决定你能不能拿住。
5. 回测跑不通先看这五处:海龟法则在 pandas 里的常见翻车现场
5.1 现象:回测年化 80%,实盘一笔不开
现象很典型:净值曲线陡得不像话,胜率 70% 以上,但把信号打印出来一看,入场点全部落在当天最低价附近。
原因是通道没做shift(1),rolling(20).max()包含了当前 bar,再用high >= hh判断突破,等于用当天已知的最高价去开仓。回测里你“买在最高点”,但因为后续计算收益用的也是当天收盘,逻辑自洽,看不出破绽。
解决方式是把通道整体后移一根,并且把信号判断统一改成用close而不是high。改完之后信号数量会掉一大截,这是正常的,掉下去的那些本来就是偷来的。
5.2 现象:除权日附近频繁出现假突破,收益忽高忽低
某只标的在分红除权那天价格跳空下跌 8%,20 日通道被瞬间击穿,策略在毫无成交意义的位置平仓,一年下来多出十几笔莫名其妙的亏损单。
原因是用了未复权价格。除权造成的价格跳空不是真实波动,但通道算法不知道这件事。更麻烦的是前复权:它会让历史价格随每次新除权整体变动,同一份代码这周跑和下周跑,历史信号不一致,参数根本没法比较。
解决方式是所有信号和 ATR 一律用后复权价计算,成交金额展示时再按复权因子折算回真实报价。如果数据源只给前复权,至少要在回测前固定一份快照,不要让历史数据随源站更新而漂移。
5.3 现象:仓位算出几十万手,或者直接除以零报错
回测跑到某一天突然出现inf或NaN,或者 unit 数量大得离谱,账户权益瞬间归零。
原因通常是 N 值在序列早期是 NaN,代码里用fillna(0)补了 0,size_unit里的除法变成除以 0;也可能是长停牌段价格被前向填充成一条直线,TR 被压到接近 0,N 值极小,仓位被反向放大。
解决办法是三道防线:min_periods设为窗口长度,不给早期行虚假值;size_unit里显式检查np.isfinite(n) and n > 0;长停牌段限制前向填充天数,超过就跳过不交易。这三处在第 2、3 章的代码里都已经埋好了,别为了“让回测跑满全周期”把它们去掉。
5.4 现象:加仓之后回撤翻倍,明明有止损却亏得更多
账户在某段行情里浮盈一度很可观,价格反转后回吐全部利润还倒亏,检查交易记录发现止损价一直停在第一次开仓的位置。
原因是加仓后没有重算加权均价,止损价没跟着上移。海龟法则的止损锚点是加权均价,不是首次开仓价,四个 unit 全加满之后,均价已经上移了 1.5 个 N 以上,止损还挂在原地的结果就是实际风险敞口远超 2N。
解决办法是把止损更新绑在加仓函数里,加完仓立刻执行stop_price = avg_cost - stop_n * n,并把avg_cost作为循环里的状态变量一路带下去,不要每次从交易记录里反推。
5.5 现象:股票和期货用同一套参数,结果一个赚一个亏
同一段代码、同一组参数,跑股票数据年化 15%,跑期货数据年化 −20%,怎么调都不对。
大概率是point_value没设对,或者最小交易单位没处理。股票价格就是金额,期货报价要乘合约乘数才是金额;股票最小 100 股,期货最小 1 手,向下取整的粒度差两个数量级。仓位算错之后,风险敞口和设计值完全不匹配,参数自然对不上。
解决办法是把point_value和lot做成配置项,按品种传入,回测启动时打印一行实际仓位样例人工核对。我现在的习惯是每个新品种上线前先跑一天的数据,看第一笔 unit 数量是不是在合理量级,不合理就先停下来查单位。
6. 参数稳健性与样本外验证:一条净值曲线说明不了任何问题
参数扫一遍出一张热力图,是我每次交付前的固定动作。海龟法则的可调参数不多:入场通道长度、离场通道长度、加仓间距、止损倍数、单 unit 风险比例。真正需要扫的是前两个和最后一个,另外两个影响的是持仓节奏,对总体收益的敏感度低一些。
我一般扫entry_n ∈ {10, 20, 30, 40, 55}和risk_pct ∈ {0.005, 0.01, 0.015, 0.02}的网格,看两件事:一是(20, 0.01)这个原版参数所在的邻域是不是一片平原,如果它是孤立的高峰,说明这个参数是拟合出来的;二是最大回撤随参数变化的形态,如果风险比例从 1% 提到 2%,回撤翻了四倍而不是两倍,说明资金曲线存在非线性尾部,实盘要打折用。
| 检查项 | 合格标准 | 不合格的信号 |
|---|---|---|
| 参数邻域 | 周围 8 个格子的收益差距在 30% 以内 | 单点突出,邻居塌陷 |
| 样本外 | 后 30% 数据的年化不低于样本内的 50% | 样本外直接转负 |
| 成本敏感 | 双边费率翻倍后仍为正收益 | 费率一涨就亏 |
| 起点敏感 | 随机 50 个起点,中位年化 > 0 | 少数起点撑起全部收益 |
样本外验证要做两件事。第一件是按时间切分,前 70% 调参、后 30% 只跑不改,这一步能筛掉大部分过拟合。第二件是随机起点重采样,从原始序列里随机抽 60% 的连续区间跑回测,重复 50 次,看收益分布的中位数和 5% 分位数。海龟法则这类趋势策略,收益高度依赖有没有抓到那几段大行情,随机起点测试能直接告诉你:换一个历史时段开始交易,你还能不能赚钱。
import numpy as np def random_start_test(df, n_trials=50, frac=0.6, seed=42): rng = np.random.default_rng(seed) span = int(len(df) * frac) out = [] for _ in range(n_trials): s = rng.integers(0, len(df) - span) sub = df.iloc[s:s + span].reset_index(drop=True) _, curve = run_backtest(sub) if len(curve) > 20: out.append(perf(curve)["cagr"]) return pd.Series(out).describe(percentiles=[0.05, 0.5, 0.95])frac=0.6是折中值,太长则样本重叠严重,太短则趋势策略抓不到完整行情。n_trials=50够看出分布形态,再多只是让数字更稳,不改变结论。看结果时我只看 5% 分位数,中位数好看但尾部很差的策略,实盘一定会在最差的时段被击穿。
最后一条是成交假设。把“收盘价成交”改成“次日开盘价成交”,加一个千分之二的滑点,重跑一遍,收益通常会掉 20% 到 40%,这是正常的。掉得超过一半,说明策略的利润主要来自信号产生后的瞬间价格优势,实盘拿不到,整条曲线就作废了。
我踩过最贵的一次坑,是拿一条扫了三十组参数挑出来的曲线去说服自己“这套参数很稳”,样本外一跑直接归零。从那以后我每次出回测结论前都强制走一遍参数邻域加随机起点这两步,任何一组参数只要邻域不平坦就直接丢掉,不管它当期收益多好看。希望帮到你。
本文还有配套的精品资源,点击获取