news 2026/10/26 2:05:03

pandas手搓海龟法则:量化回测从信号到仓位与绩效

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
pandas手搓海龟法则:量化回测从信号到仓位与绩效

简介:面向量化交易初学者与已具备pandas基础的个人交易者,这套海龟交易法则Python实现资料把经典趋势跟踪规则落到可运行的数据分析流程中,重点处理历史行情读取、指标计算、突破判断与交易管理等问题。压缩包共3个文件,包含2个CSV行情数据文件和1个Python脚本,整体约41KB;CSV可用于承载示例及个股行情,脚本对应策略主体,方便对照修改参数与复现实验。已有1308人学习,具备一定入门实践参考价值。读者可从中梳理20日与10日移动平均的趋势确认、买卖信号生成、按账户风险比例计算仓位、以ATR设置止损,以及交易记录和账户余额更新等环节,并借助盈亏比、夏普比率、最大回撤等指标评估策略表现。代码结构虽精简,却覆盖数据预处理、信号记录与模拟交易的主要步骤,适合作为理解海龟法则规则细节、搭建个人策略原型的可复用骨架。

1. 一个止损位引发的重写:这套 pandas 版海龟法则能干什么

去年帮人看一份回测报告,20 日唐奇安通道突破、2N 止损、四个单位加仓,年化 40% 以上,最大回撤不到 6%。代码拉下来跑十分钟就发现问题:判断突破用的是当天最高价,成交价却取了当天开盘价。把这一行改掉,年化掉到 11%,回撤翻了三倍。

这就是我建议刚接触量化交易的人自己写一遍海龟法则的原因。这套规则本身并不神奇,但你得亲手把每个数据对齐的时间点、ATR 的平滑口径、每一手仓位的取整方式落到 pandas 的某一行上,才知道那些漂亮数字是怎么来的。

这份实现没有引入任何回测框架,行情读取、指标计算、仓位管理、绩效统计全摊在几个 Python 文件里,适合想拿股票软件导出的日线数据、从零搭一条可复现回测链路的人。

2. 技术选型与整体架构:pandas 扛指标,Python 循环扛状态

海龟法则看着简单,落到代码里全是状态:今天有没有持仓、持了几个单位、最后一次加仓价在哪、止损位要不要跟着抬。把这些东西塞进向量化表达式,是最容易翻车的地方。这一章先把选型和文件边界讲清楚,后面拆代码时才知道每段该放在哪。

2.1 为什么是 pandas 手搓,而不是套一个回测框架

现成的事件驱动回测引擎能帮你管住撮合、订单、持仓这些事,代价是它们把成交逻辑封成了黑匣子。回测跑出异常曲线时,你得先读完它的源码,再回来怀疑自己的策略代码,排查链条一下子长了两倍。海龟法则的仓位逻辑又是典型的多单位加仓,不同框架对“加仓”这件事的建模差异很大,有的按独立订单处理、有的按均价合并,最后算出来的止损位完全不是一回事。

我一般的做法是:指标用 pandas 的rolling/shift/ewm算,状态推进老老实实写一个for循环。理由很实在——

  • 指标计算的正确性可以用几行assert卡住,通道值对不对、ATR 有没有 NaN,df.head(50)一眼就能看出来;
  • 有状态的逻辑留在循环里,断点打在任意一天,能看到当天的持仓、均价、止损位、权益,不用去猜框架内部发生了什么;
  • 没有隐藏的撮合规则,成交价就是代码里写的那一行,改起来没有心理负担。

代价也要说清楚:纯 Python 循环跑 10 万根日线大概几秒到几十秒,单品种够用;如果你要拿全市场几千个标的做横截面扫描,就得把内层换成 numpy 数组或者按品种分块并行,再往上才是上框架的事。

环境没什么讲究,三个包就够:

python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install pandas numpy matplotlib

pandas负责行情对齐、滚动窗口和绩效统计,numpy处理 NaN 和取整,matplotlib只用来画资金曲线看图。刻意不引入TA-Lib之类的技术指标库,是因为唐奇安通道和 ATR 加起来不到二十行,自己写反而能控制平滑口径——库里的 ATR 默认算法和你想要的 Wilder 平滑未必一致,这种口径差异在长周期回测里会慢慢放大。

2.2 目录结构与各文件职责

目录按“数据 → 指标 → 仓位 → 引擎 → 报表”切分,每个文件只干一件事:

turtle/ ├── data/ │ └── bars_daily.csv # 股票软件导出的日线,字段:symbol,date,open,high,low,close,volume ├── turtle/ │ ├── __init__.py │ ├── loader.py # 行情读取、字段校验、排序去重 │ ├── indicators.py # 唐奇安通道、ATR/N 值 │ ├── sizing.py # 仓位单位、加仓间隔 │ ├── engine.py # 逐日回测主循环(唯一有状态的地方) │ ├── metrics.py # 年化、夏普、回撤、胜率、盈亏比 │ └── report.py # 资金曲线、交易明细落盘 └── run_backtest.py # 入口,串起上面所有模块

文件边界是刻意划的,改代码时不容易互相污染:

文件输入输出不该出现的东西
loader.pyCSV 路径、品种代码干净的 OHLCV DataFrame任何指标列
indicators.pyOHLCV追加hh_entry/ll_exit/atr任何资金、手数
sizing.py权益、ATR、合约乘数整数手数任何行情明细
engine.py上面三者的输出权益曲线 + 交易明细指标公式
metrics.py权益曲线、交易明细指标字典任何循环交易逻辑

indicators.py里绝不出现“手数”“资金”这类词,sizing.py里也绝不出现highlow——这条规矩听起来多余,但它能挡住一类很隐蔽的 bug:有人在算仓位时顺手引用了当天的收盘价,结果仓位大小和信号强度绑在一起,回测结果就再也解释不清了。

2.3 数据层:从股票软件导出的 CSV 到干净 OHLCV

数据这一层看着最没技术含量,实际上出问题最多。股票软件导出的 CSV 常见问题是:编码是 GBK、日期带时分秒、除权前后价格不连续、停牌日直接缺行。这几种情况混在一起,后面 ATR 会算出离谱的值。

import pandas as pd REQUIRED = ["symbol", "date", "open", "high", "low", "close", "volume"] def load_bars(path: str, symbol: str, adjust: str = "hfq") -> pd.DataFrame: # 股票软件默认导出 GBK,utf-8-sig 兼容带 BOM 的版本 df = pd.read_csv(path, encoding="gbk", parse_dates=["date"]) missing = set(REQUIRED) - set(df.columns) if missing: raise ValueError(f"缺少字段: {sorted(missing)},检查导出模板") df = (df[df["symbol"] == symbol] .sort_values("date") .drop_duplicates(subset="date", keep="last") .reset_index(drop=True)) # 价格必须为正,high 必须 >= low,成交量非负;脏行直接丢,别让它污染滚动窗口 ok = ((df["close"] > 0) & (df["high"] >= df["low"]) & (df["volume"] >= 0)) df = df[ok].copy() # 缺口标记:停牌或数据缺失会在这里暴露,后面算 ATR 前要心里有数 df["gap_days"] = df["date"].diff().dt.days.fillna(1) return df[["date", "open", "high", "low", "close", "volume", "gap_days"]]

这段代码有几个刻意的选择。

encoding="gbk"是因为大部分股票软件的中文列名导出走的是系统默认编码,用utf-8读会直接抛UnicodeDecodeError;如果你的文件有 BOM,换成utf-8-sig更稳。

drop_duplicates(subset="date", keep="last")处理的是同一天被导出两次的情况,比如你在盘中导了一次、收盘又导了一次,保留最后一条才是收盘价。

gap_days是我加的保险。海龟法则的通道是“最近 20 个交易日”,但如果中间停牌两周,这 20 根 K 线在日历时间上跨了一个多月,通道区间会被拉宽。gap_days不会自动修数据,它的作用是让你在排查阶段能一眼看到哪些日子的 ATR 是跨缺口算出来的——第 5 章会讲这种情况怎么处理。

adjust参数在这里没被使用,是留给复权的:如果你的 CSV 是不复权价,就得在loader.py里按除权因子做一次前复权或后复权,否则除权当天价格腰斩,20 日通道会被直接砸穿,ATR 也会瞬间放大好几倍。我的习惯是在导出阶段就选后复权,代码里不再重复处理,少一个出错的地方。

3. 核心模块拆解:唐奇安通道、ATR 与仓位单位的三段式实现

这一章是整份代码的重心。三个模块的输入输出都是 DataFrame,每一个都可以单独拿出来写测试,不用跑完整回测就能验证对错。

3.1 唐奇安通道:两行 rolling 加一个 shift 的陷阱

import pandas as pd def add_donchian(df: pd.DataFrame, entry_n: int = 20, exit_n: int = 10) -> pd.DataFrame: """ entry_n: 入场通道回看周期(海龟原版 20 日) exit_n : 离场通道回看周期(海龟原版 10 日) """ # shift(1) 是关键:t 日能看到的通道,只能由 t-1 日及更早的数据构成。 # 少了这个 shift,当天的高点会进入当天的通道,突破判断等于用未来数据。 df["hh_entry"] = df["high"].rolling(entry_n).max().shift(1) df["ll_entry"] = df["low"].rolling(entry_n).min().shift(1) df["hh_exit"] = df["high"].rolling(exit_n).max().shift(1) df["ll_exit"] = df["low"].rolling(exit_n).min().shift(1) return df

四个通道列,含义分别是:hh_entry是前 20 个交易日的最高价,收盘价站上去就是做多突破信号;ll_exit是前 10 个交易日的最低价,跌破就离场。

shift(1)这一行是整份代码里最容易被删掉的四个字符。少了它,rolling(20).max()会把当天的高点算进去,而当天收盘价永远小于等于当天最高价,于是“收盘价大于 20 日最高价”这个条件变成几乎永远不成立——你会得到一个零交易的策略,然后花半小时怀疑数据。反过来,如果成交价用当天开盘价、信号却用了当天最高价,那就是标准的未来函数,回测曲线会漂亮得不真实。

还有一种写法是先算信号再shift(-1)到次日执行,两种写法都能避免前视,区别只在成交假设。我倾向在当根 K 线信号成立时按收盘价成交,同时在报表里标注“收盘成交”,避免读者误以为是次日开盘成交。

参数上,entry_n=20 / exit_n=10是原版,但 20 和 10 这两个数字跟市场结构强相关。日线级别的A股指数和商品期货,我见过用 55/20 更稳的组合;短周期品种用 20/10 会频繁进出,交易成本能把利润吃光。这个不是玄学,改参数后看交易笔数和单笔期望的变化就能判断。

3.2 ATR 与 N 值:Wilder 平滑和合约乘数决定仓位

import numpy as np import pandas as pd def add_atr(df: pd.DataFrame, n: int = 20) -> pd.DataFrame: 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) # 海龟原版用 Wilder 平滑,对应 pandas 里 alpha = 1/n 的指数平均 # 用 rolling(n).mean() 也能跑,但前期权重差异会让止损位偏移一个 N 左右 df["atr"] = tr.ewm(alpha=1 / n, adjust=False, min_periods=n).mean() return df

真实波幅tr要取三者最大值,这一步很多人会漏掉跳空。只算high - low的话,隔夜跳空完全体现不出来,ATR 会被系统性低估,进而导致仓位偏大——这是最危险的一类错误,因为它在震荡市里跑得很好看,一遇到连续单边行情就直接打穿风险预算。

平滑方式用ewm(alpha=1/n, adjust=False),对应的是经典的 Wilder 递推:N_t = (N_{t-1} * (n-1) + TR_t) / n。如果你用rolling(n).mean(),前 20 根之后两者会逐渐收敛,但每次通道切换的初期都会差出一截,止损位跟着偏,仓位也跟着偏。这种差异在单笔交易上微不足道,跑几百笔之后会体现为完全不同的资金曲线。

min_periods=n保证前 19 根是 NaN,引擎里遇到 NaN 直接跳过当天,不给策略在“ATR 还没算出来”的时候开仓的机会。

contract_multiplier不在这个函数里出现,但它是仓位计算的关键参数。股票的乘数是 1(1 手 = 100 股的话就是 100),股指期货是 300,商品期货各不相同。这个参数一旦搞错,仓位会差出一到两个数量级,属于必须从合约规格表里确认、不能凭印象填的数。

3.3 仓位单位:1% 风险预算怎么落到手数上

import numpy as np def unit_size(equity: float, atr: float, multiplier: int = 1, risk_pct: float = 0.01) -> int: """ equity : 当前账户权益(现金 + 浮盈) atr : 当日 N 值 multiplier : 合约乘数,股票填 100 或 1,股指 300,商品按合约规格 risk_pct : 单个 N 单位的风险预算,原版为 1% """ if atr is None or np.isnan(atr) or atr <= 0 or equity <= 0: return 0 dollar_vol = atr * multiplier # 一个 N 对应的每手波动金额 return int(equity * risk_pct // dollar_vol)

公式本身只有一行:手数 = 权益 × 风险比例 ÷ (N × 合约乘数)。它的含义是“如果价格反向走一个 N,我这个单位亏掉总权益的 1%”。四个单位加满,理论最大风险就是 4%。

几个实际会踩的坑写在这里:

int()是向下取整,不是四舍五入。宁少不多,这是风险预算的基本纪律。当你发现某品种的unit_size长期返回 0,说明你的权益太小、或者 N 太大(比如高波动商品),这个品种在你的资金体量下根本没法按海龟法则做,硬凑成 1 手等于把风险比例放大到 3%~5%,那就不是海龟法则了。

dollar_vol里乘multiplier不能省。商品期货的报价单位是元/吨,一手 10 吨,N=50 的时候一手波动就是 500 块,不是 50 块。忘了乘,算出来的手数会大十倍。

参数表整理一下:

参数原版取值调大的效果调小的效果
risk_pct0.01仓位更重,回撤放大收益和回撤同步变小
stop_n2.0止损更松,胜率升、单笔亏损变大止损更紧,频繁被打
entry_n20信号更少、更偏趋势信号变多、噪音增加
exit_n10持仓更久、回吐更多离场更快、容易错过大段

3.4 加仓、止损与离场:一个只有三个状态的状态机

引擎的主体是一个循环,每天只做三件事:先判离场,再判入场和加仓,最后按收盘价结算权益。

import numpy as np import pandas as pd def run_backtest(df, capital=1_000_000, risk_pct=0.01, multiplier=1, max_units=4, stop_n=2.0, cost_rate=0.0003, slippage=0.0): cash = float(capital) units = [] # [(成交价, 手数), ...],空列表表示空仓 entry_cost = 0.0 # 开仓/加仓累计成本,平仓时并入这笔交易的盈亏 trades = [] # 每笔平仓的净盈亏 curve = [] # 每日收盘后的账户权益 for i in range(1, len(df)): bar = df.iloc[i] atr = bar["atr"] if pd.isna(atr) or atr <= 0: curve.append(cash) # ATR 未就绪,今天不交易 continue pos = sum(s for _, s in units) avg = sum(p * s for p, s in units) / pos if pos else 0.0 # ---- 1. 离场:2N 止损优先于通道离场 ---- if pos > 0: stop = units[-1][0] - stop_n * atr # 以最后一次加仓价为锚向下 2N if bar["low"] <= stop: fill = min(bar["open"], stop) - slippage # 跳空低开时按开盘价成交 exit_cost = pos * fill * multiplier * cost_rate pnl = pos * (fill - avg) * multiplier - exit_cost - entry_cost cash += pos * (fill - avg) * multiplier - exit_cost trades.append(pnl) units, entry_cost = [], 0.0 elif bar["close"] < bar["ll_exit"]: fill = bar["close"] - slippage exit_cost = pos * fill * multiplier * cost_rate pnl = pos * (fill - avg) * multiplier - exit_cost - entry_cost cash += pos * (fill - avg) * multiplier - exit_cost trades.append(pnl) units, entry_cost = [], 0.0 # ---- 2. 入场与加仓:突破前 20 日高点 ---- pos = sum(s for _, s in units) if bar["close"] > bar["hh_entry"]: if pos == 0: size = int(cash * risk_pct // (atr * multiplier)) if size > 0: fill = bar["close"] + slippage c = size * fill * multiplier * cost_rate cash -= c entry_cost += c units.append((fill, size)) elif len(units) < max_units and bar["close"] >= units[-1][0] + 0.5 * atr: # 每上涨 0.5N 加一个单位,最多 max_units 个 size = int(cash * risk_pct // (atr * multiplier)) if size > 0: fill = bar["close"] + slippage c = size * fill * multiplier * cost_rate cash -= c entry_cost += c units.append((fill, size)) # ---- 3. 按收盘价把浮盈计入权益曲线 ---- pos = sum(s for _, s in units) avg = sum(p * s for p, s in units) / pos if pos else 0.0 curve.append(cash + pos * (bar["close"] - avg) * multiplier) equity = pd.Series(curve, index=df["date"].iloc[1:], name="equity") return equity, pd.DataFrame(trades, columns=["pnl"])

几处值得单独解释。

离场判断放在入场之前。这个顺序不能反:如果某天既跌破 10 日低点、又突破了 20 日高点(在大波动品种上真的会发生),先入场后离场会让你在同一天平掉旧仓再开新仓,多付两笔成本。先离场后入场,至少语义上清楚——“昨天的仓位今天先结算,今天的新仓今天再考虑”。

止损锚点用units[-1][0],也就是最后一次加仓价。原版海龟的规则是每加一次仓,全部持仓的止损一起上移到新入场价下方 2N,我用的就是这个口径。也有人用加权均价减 2N,回撤会更小但更容易被打掉,两种都能跑,改这一行就能对比。

fill = min(bar["open"], stop)处理跳空。如果某天开盘就已经跌穿止损价,实际成交价是开盘价而不是止损价,用stop当成交价会系统性低估亏损——这类“止损总是完美成交”的回测,实盘会给你补上这一课。

加仓条件bar["close"] >= units[-1][0] + 0.5 * atr用的是 0.5N 间隔,原版就是这样。len(units) < max_units是硬约束,四个单位封顶,对应 4% 的理论最大风险。这个上限别随便调,加满五个单位看着收益更高,本质上是把单品种风险敞口放大了 25%。

curve记录的是浮动权益而不是现金,所以资金曲线在持仓期间会有波动,这是对的。如果你只记现金,曲线会变成阶梯状,夏普和回撤全都不准。

4. 回测引擎与绩效统计:从逐笔交易到资金曲线

主循环跑通只完成了一半,剩下的一半是让结果可信。这一章讲三件事:事件顺序、成本模型、指标口径。

4.1 事件推进:先离场再入场的顺序为什么不能反

上一节的循环结构里,每天的处理顺序是固定的:ATR 校验 → 离场 → 入场/加仓 → 结算权益。这个顺序对应的是“收盘后决策、当日收盘价成交”的假设,也是回测里最常见的一种简化。

真正需要留意的是同一天既触发止损又触发信号的场景。举个具体的:某品种连续上涨,你持有多头并已加仓两轮,某天大幅高开后跳水,收盘跌破 10 日低点。这一天的low可能已经穿过止损位,同时close又低于ll_exit。代码里止损分支在前,所以按止损价成交,符合“止损优先”的风控原则。

另一种顺序错误更隐蔽:把入场写在离场之前,且入场时用了pos == 0判断。这时候当天先平仓再开仓,pos会在同一个循环里从 0 变成新仓,止损锚点被重置成今天的价格,昨天那笔交易的记录就丢了。表现出来就是交易笔数比预期少,资金曲线还算正常,极难发现。

还有一个和事件推进相关的问题:for i in range(1, len(df))从 1 开始,是因为要保证至少有 1 根历史 K 线。实际上由于min_periods=20,前 20 根 ATR 是 NaN,引擎会自动跳过,所以从 0 开始也没问题,但从 1 开始可以顺手访问df.iloc[i-1],后面要加“昨日收盘价”这类条件时不用改循环起点。

性能上,df.iloc[i]每次都在做位置索引,10 万根 K 线大概几秒。嫌慢可以用df.itertuples(),能快五到十倍,代价是字段访问从bar["atr"]变成bar.atr,代码可读性略降。我的判断标准是:调试阶段用iloc图方便,跑参数扫描时再换itertuples。

4.2 成本模型:手续费、滑点与跳空成交价

成本模型就三个参数,但每一个都能把回测结果改头换面:

# 单边手续费率,A 股大概万三含规费,商品期货按合约另算 cost_rate = 0.0003 # 滑点,单位是价格,不是比例;一跳滑点在流动性好的品种上够用 slippage = 0.01

cost_rate我在开仓和平仓两侧都扣了,这一点必须一致。只扣平仓不扣开仓,等于每笔交易白赚一次成本,高频加仓策略会把这个误差放大到不可忽略——四个单位加仓一次完整交易要付八次成本,漏掉一半直接虚增收益。

slippage用绝对值而不是比例,是因为滑点的物理含义是“成交价差了几跳”,跟你买多少钱无关。用比例的话,高价股和小价股会被区别对待,反而不合理。海龟法则的加仓间隔是 0.5N,如果滑点设成 0.5N 的一半,加仓逻辑基本无法正常工作,这也从侧面给了滑点上限一个参考。

fill = min(bar["open"], stop)这一句前面提过,它属于成本模型的一部分。反过来,止盈侧的通道离场(close < ll_exit)按收盘价成交没有跳空问题,因为收盘价就是当天最后一笔价格。这里的不对称是有意的:止损是风控,必须悲观;离场是正常退出,可以按市价。

我做参数对比时习惯把slippage分别设成 0、一跳、两跳跑三遍。如果策略只在滑点为 0 时正收益,那它就不是策略,是给交易所送手续费的机器。

4.3 绩效统计:年化、夏普、最大回撤与单笔期望

import numpy as np import pandas as pd def performance(equity: pd.Series, trades: pd.DataFrame, bars_per_year: int = 252) -> pd.Series: ret = equity.pct_change().dropna() total = equity.iloc[-1] / equity.iloc[0] - 1 years = max((equity.index[-1] - equity.index[0]).days / 365.25, 1e-9) cagr = (1 + total) ** (1 / years) - 1 std = ret.std(ddof=1) sharpe = np.sqrt(bars_per_year) * ret.mean() / std if std > 0 else np.nan # 最大回撤用权益的滚动最高点算,别用累计收益的最高点 max_dd = (equity / equity.cummax() - 1).min() pnl = trades["pnl"] avg_win = pnl[pnl > 0].mean() avg_loss = -pnl[pnl < 0].mean() return pd.Series({ "总收益": total, "年化": cagr, "夏普": sharpe, "最大回撤": max_dd, "交易笔数": len(pnl), "胜率": (pnl > 0).mean(), "盈亏比": avg_win / avg_loss if avg_loss else np.nan, "单笔期望": pnl.mean(), })

bars_per_year=252是按日线算的,如果你用的是周线要改成 52,分钟线是 252×每天 bar 数。这个参数填错,夏普会差出一个数量级,而且不会有任何报错提示。

(equity / equity.cummax() - 1).min()是标准的最大回撤定义,得到的是负数。cummax卡的是历史权益峰值,比用累计收益率的峰值更严谨,因为后者在浮盈浮亏来回摆的时候会算错。

海龟法则这类趋势跟踪策略的典型特征是胜率低、盈亏比高——胜率三成到四成、盈亏比二到三倍都很正常。所以只看胜率很容易误判,单笔期望才是核心:期望为正,笔数够多,长期才成立。我一般会把单笔期望 / 平均亏损单独算一遍,这个比值低于 0.2 的时候,滑点多一跳策略就翻红了,属于不够扎实的那一类。

另外,trades只记录了平仓的净盈亏,没有手续费拆项。真要做成本归因,就在引擎里把手续费和滑点损失分成两列记录,跑完求和,看看利润里有多少被成本吃掉了。这件事我在早期项目上吃过亏:一个看起来年化 15% 的策略,成本拆开后发现毛收益 28%,纯靠高换手,实盘根本活不下来。从那以后我每次跑回测都会先看成本占比,超过毛收益的三分之一就直接放弃调参。

5. 避坑与排查:回测曲线漂亮得不像话时先查这四处

下面四条都是我在实际项目里真实遇到过的,每一条都曾经让一组参数看起来能用,最后发现是代码的问题。

5.1 现象:曲线几乎不回撤 → 原因:突破用了当天数据

现象很典型:年化三四十,最大回撤个位数,资金曲线几乎是直线往上。跑的时候就该警觉,趋势跟踪策略不可能这么顺。

原因基本锁定在通道计算上。rolling(20).max()忘了shift(1),当天的高点进入当天通道;更隐蔽的一种是信号写对了,但成交价用了当天开盘价,等于用当天收盘后才知道的信息去当天开盘成交。

解决方式是把通道列和成交价分开打印核对:

chk = df.iloc[25:30][["date", "high", "hh_entry", "close"]].copy() chk["prev20_max_ok"] = chk["hh_entry"] == df["high"].shift(1).rolling(20).max().iloc[25:30] print(chk)

prev20_max_ok全是 True 才说明通道没有吃到当天数据。这个检查我建议加到indicators.py的单元测试里,五行代码,能省掉一整轮排查。

5.2 现象:止损总是精准成交 → 原因:忽略了跳空

现象是每笔亏损都恰好等于 2N 乘以手数,误差极小,感觉策略的风险控制特别精准。

原因是回测里把stop直接当成了成交价。现实中价格跳空低开,你的止损单在开盘那一刻以开盘价成交,实际亏损远超 2N。连续涨跌停的时候更极端,可能连续几天都出不来。

解决就是代码里那一行fill = min(bar["open"], stop),同时在报表里统计“实际亏损 / 理论亏损”的分布,理想情况是绝大多数接近 1,少数明显大于 1。如果分布里出现大量小于 0.8 的记录,说明有地方在占市场的便宜,得回去查成交价。

5.3 现象:主力连续合约的 ATR 突然翻倍 → 原因:换月没有做价差调整

现象是回测跑到某个月份,ATR 突然变大两三倍,仓位瞬间缩小,之后又恢复正常,资金曲线在这个位置出现一个明显的台阶。

原因是行情数据用的是主力连续合约,换月时新老合约价差被直接拼进了价格序列,形成一个假跳空。ATR 里的真实波幅会把这段价差当成一天之内的巨大波动,通道也会被这段假价格撑开。

解决分两种。数据端:在导出阶段就做价差调整(后复权连续),让拼接点平滑。代码端:如果没有条件重做数据,就在loader.py里把gap_days > 1或者单日涨跌幅超过某阈值的位置标记出来,在指标计算时跳过——但这是打补丁,能修数据还是修数据。

5.4 现象:回测赚钱,实盘一开仓就亏 → 原因:手续费和滑点漏算

现象是回测年化 12%,实盘跑三个月净值原地踏步甚至小亏,交易记录一笔笔对下来,方向没错,就是每笔都少赚一点。

原因是成本只在平仓侧扣了,或者压根没扣。海龟法则加仓频繁,一次完整交易可能涉及四次开仓加一次平仓,成本是普通单笔策略的三到五倍。万三的单边费率,一个来回就是万六,加上一跳滑点,做 20 个交易日就磨掉 1% 以上的净值。

解决办法是把成本参数做成显式变量,跑三组对比:

for cost, slip in [(0.0, 0.0), (0.0003, 0.01), (0.0005, 0.02)]: eq, tr = run_backtest(df, cost_rate=cost, slippage=slip) print(cost, slip, performance(eq, tr)[["年化", "最大回撤", "单笔期望"]].round(4).to_dict())

三组结果的差距就是策略对成本的敏感度。如果最悲观那组单笔期望转负,那这个参数组合就不该进实盘。

6. 二次开发:从单品种跑通到多品种组合的改造路径

单品种回测跑通之后,绝大多数人会立刻想试多品种组合,因为海龟法则的原版就是在多个品种上分散风险。改造量比想象中小,关键是别把资金算错。

6.1 资金分配与信号聚合

最直接的改法是把run_backtest包一层,按品种分配独立资金池:

def run_portfolio(frames: dict, capital: float, per_symbol_cap: float = 0.25): """每个品种最多占用 25% 的资金,回测完按日对齐再相加""" curves = {} for sym, df in frames.items(): eq, tr = run_backtest(df, capital=capital * per_symbol_cap) curves[sym] = eq # 不同品种上市时间不同,按日期外连接后 forward fill 再求和 return pd.concat(curves, axis=1).ffill().sum(axis=1)

per_symbol_cap是必须的。四个单位满仓、每个 1% 风险,单品种理论风险 4%,25 个品种如果等权分配,总风险预算会到 100%,实际波动远超预期。用固定上限切分,等于给自己留了一条后路。

ffill()处理的是品种上市时间不一致的问题:新上市的品种在前面是 NaN,直接sum会得到 NaN,整条曲线就废了。

信号聚合上有个常见误区:不是把所有品种的突破信号排个序取前几名,而是每个品种独立判断、独立管仓位。海龟法则的分散化来自“不同品种的趋势不同时发生”,不是来自横截面选强。

6.2 参数验证:走样本外,而不是全样本寻优

entry_n从 20 调到 55,年化涨了五个点,大部分人第一反应是“那就用 55”。这个判断在样本内永远成立,因为你就是拿这段数据挑出来的。

我现在的做法是把数据切成三段:前 60% 调参,中间 20% 做验证,最后 20% 只跑一次。最后一跑的结果如果和前两段差太多,问题不在参数,在策略本身对市场状态的依赖。参数扫描用itertools.product生成组合,跑完按“样本外单笔期望”排序,而不是按年化排序——年化容易被一两次大行情带偏,期望更稳。

调参之外,还有一个值得加的改造:把持仓明细落盘成 CSV,字段包含开仓日、成交价、加仓次数、平仓日、盈亏、持仓天数。这份明细比任何指标都好用,翻几页就能看出策略在什么行情下赚钱、什么行情下持续失血。

我自己踩过最深的一个坑是拿一个只跑了半年的回测结果去说服别人,样本里刚好包含一段单边趋势,四个单位加满吃了整段。后来我把所有回测的默认起点改成“至少覆盖一次完整的上涨和下跌”,每次跑之前先看一眼样本里有没有这段结构,没有就换数据。这个习惯比任何参数优化都值钱。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 2:04:41

YOLOv5+ArcFace人脸检测与识别一体化实战

简介&#xff1a;本资源是一个基于YOLOv5与ArcFace双模型协同的人脸识别完整项目实现&#xff0c;面向深度学习初学者、计算机视觉开发者及AI安防应用实践者&#xff0c;解决端到端人脸检测高精度身份识别的技术落地问题&#xff0c;适用于门禁系统、考勤管理、智能监控等实际场…

作者头像 李华
网站建设 2026/10/26 2:04:33

数学建模竞赛实战指南:从真题代码到论文模板的完整链路

简介&#xff1a;这份资源面向备战数学建模竞赛的高校学生与指导教师&#xff0c;系统整合了从基础理论到实战策略的学习内容&#xff0c;帮助参赛者在概率统计、线性代数、优化方法等知识框架下掌握建模与求解的完整流程。压缩包共61个文件&#xff0c;约2.02MB&#xff0c;以…

作者头像 李华
网站建设 2026/10/26 2:04:31

JDK17 Linux ARM64实战:环境配置、编译运行与jlink精简镜像

简介&#xff1a;JDK17-Linux-Arm64 是面向 Linux 操作系统 Arm64 架构的 Java 开发工具包发行版&#xff0c;适合在 64 位 ARM 服务器、嵌入式设备及 Apple M1 等平台上构建与运行 Java 应用的开发者使用。该版本集成密封类、记录类、instanceof 模式匹配、增强的 switch 表达…

作者头像 李华
网站建设 2026/10/26 2:04:25

C# WinForms 日志输出例程:分级、异步落盘与全局异常兜底

简介&#xff1a;面向C# WinForm初学者与需要快速接入日志模块的开发者&#xff0c;这套日志输出例程围绕系统事件与操作记录展开&#xff0c;演示程序启动、关闭及日常操作如何同时呈现在窗口并落盘保存&#xff0c;适合学习日志记录思路或直接嵌入小型桌面项目。压缩包共36个…

作者头像 李华
网站建设 2026/10/26 2:04:23

FUSB302驱动开发指南:从架构原理到调试与二次开发

简介&#xff1a;面向嵌入式Linux开发者的USB Type-C控制芯片FUSB302驱动源码&#xff0c;聚焦RK平台内核中正反插切换能力缺失的实际问题。原厂驱动未定义正反插切换管脚&#xff0c;导致正插USB3.0设备正常、反插仅能识别USB2.0设备&#xff0c;本驱动通过新增fusb340-switch…

作者头像 李华
网站建设 2026/10/26 2:03:16

在 Windows 输入框上悬浮显示中英输入状态指示器

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华