简介面向量化交易初学者与已具备pandas基础的个人交易者这套海龟交易法则Python实现资料把经典趋势跟踪规则落到可运行的数据分析流程中重点处理历史行情读取、指标计算、突破判断与交易管理等问题。压缩包共3个文件包含2个CSV行情数据文件和1个Python脚本整体约41KBCSV可用于承载示例及个股行情脚本对应策略主体方便对照修改参数与复现实验。已有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 有没有 NaNdf.head(50)一眼就能看出来有状态的逻辑留在循环里断点打在任意一天能看到当天的持仓、均价、止损位、权益不用去猜框架内部发生了什么没有隐藏的撮合规则成交价就是代码里写的那一行改起来没有心理负担。代价也要说清楚纯 Python 循环跑 10 万根日线大概几秒到几十秒单品种够用如果你要拿全市场几千个标的做横截面扫描就得把内层换成 numpy 数组或者按品种分块并行再往上才是上框架的事。环境没什么讲究三个包就够python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install pandas numpy matplotlibpandas负责行情对齐、滚动窗口和绩效统计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: # 股票软件默认导出 GBKutf-8-sig 兼容带 BOM 的版本 df pd.read_csv(path, encodinggbk, 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(subsetdate, keeplast) .reset_index(dropTrue)) # 价格必须为正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]]这段代码有几个刻意的选择。encodinggbk是因为大部分股票软件的中文列名导出走的是系统默认编码用utf-8读会直接抛UnicodeDecodeError如果你的文件有 BOM换成utf-8-sig更稳。drop_duplicates(subsetdate, keeplast)处理的是同一天被导出两次的情况比如你在盘中导了一次、收盘又导了一次保留最后一条才是收盘价。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_n20 / exit_n10是原版但 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(), ], axis1).max(axis1) # 海龟原版用 Wilder 平滑对应 pandas 里 alpha 1/n 的指数平均 # 用 rolling(n).mean() 也能跑但前期权重差异会让止损位偏移一个 N 左右 df[atr] tr.ewm(alpha1 / n, adjustFalse, min_periodsn).mean() return df真实波幅tr要取三者最大值这一步很多人会漏掉跳空。只算high - low的话隔夜跳空完全体现不出来ATR 会被系统性低估进而导致仓位偏大——这是最危险的一类错误因为它在震荡市里跑得很好看一遇到连续单边行情就直接打穿风险预算。平滑方式用ewm(alpha1/n, adjustFalse)对应的是经典的 Wilder 递推N_t (N_{t-1} * (n-1) TR_t) / n。如果你用rolling(n).mean()前 20 根之后两者会逐渐收敛但每次通道切换的初期都会差出一截止损位跟着偏仓位也跟着偏。这种差异在单笔交易上微不足道跑几百笔之后会体现为完全不同的资金曲线。min_periodsn保证前 19 根是 NaN引擎里遇到 NaN 直接跳过当天不给策略在“ATR 还没算出来”的时候开仓的机会。contract_multiplier不在这个函数里出现但它是仓位计算的关键参数。股票的乘数是 11 手 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 吨N50 的时候一手波动就是 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, capital1_000_000, risk_pct0.01, multiplier1, max_units4, stop_n2.0, cost_rate0.0003, slippage0.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, indexdf[date].iloc[1:], nameequity) 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_periods20前 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.01cost_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(ddof1) 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_year252是按日线算的如果你用的是周线要改成 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_ratecost, slippageslip) 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, capitalcapital * per_symbol_cap) curves[sym] eq # 不同品种上市时间不同按日期外连接后 forward fill 再求和 return pd.concat(curves, axis1).ffill().sum(axis1)per_symbol_cap是必须的。四个单位满仓、每个 1% 风险单品种理论风险 4%25 个品种如果等权分配总风险预算会到 100%实际波动远超预期。用固定上限切分等于给自己留了一条后路。ffill()处理的是品种上市时间不一致的问题新上市的品种在前面是 NaN直接sum会得到 NaN整条曲线就废了。信号聚合上有个常见误区不是把所有品种的突破信号排个序取前几名而是每个品种独立判断、独立管仓位。海龟法则的分散化来自“不同品种的趋势不同时发生”不是来自横截面选强。6.2 参数验证走样本外而不是全样本寻优entry_n从 20 调到 55年化涨了五个点大部分人第一反应是“那就用 55”。这个判断在样本内永远成立因为你就是拿这段数据挑出来的。我现在的做法是把数据切成三段前 60% 调参中间 20% 做验证最后 20% 只跑一次。最后一跑的结果如果和前两段差太多问题不在参数在策略本身对市场状态的依赖。参数扫描用itertools.product生成组合跑完按“样本外单笔期望”排序而不是按年化排序——年化容易被一两次大行情带偏期望更稳。调参之外还有一个值得加的改造把持仓明细落盘成 CSV字段包含开仓日、成交价、加仓次数、平仓日、盈亏、持仓天数。这份明细比任何指标都好用翻几页就能看出策略在什么行情下赚钱、什么行情下持续失血。我自己踩过最深的一个坑是拿一个只跑了半年的回测结果去说服别人样本里刚好包含一段单边趋势四个单位加满吃了整段。后来我把所有回测的默认起点改成“至少覆盖一次完整的上涨和下跌”每次跑之前先看一眼样本里有没有这段结构没有就换数据。这个习惯比任何参数优化都值钱。希望帮到你。本文还有配套的精品资源点击获取