均线交叉
快慢两条均线的交叉作为买卖事件,快线上穿慢线进场、下穿离场。它的优点是逻辑可复述、参数只有窗口长度;缺点是震荡行情里交叉频繁,成本会吃掉大部分理论收益,所以成本假设必须显式设置。
Backtesting.py / Strategy
策略开发是回测的核心:继承 Strategy 基类、在 init() 里注册指标、在 next() 里写买卖逻辑,再用内置优化器做参数网格搜索与热力图分析。官方 README 给的是一段完整示例,但真正卡人的是「每一步在做什么、写错会怎样」——本页把这条链路拆成可复制的步骤,并补上参数可信度判断与结构性错误排查。
Backtesting.py 的策略骨架只有两个方法:init() 算指标、next() 做决策。下面五步按顺序做,每步给出真实代码、一句话说明和可以核对的预期输出。跑不到预期结果时就停在这一步排查——报错信息往往指向最后一行,而真正的起因经常在前面几步。
从包顶层取 Backtest(回测器)与 Strategy(策略基类);策略必须继承 Strategy,回测器才能调度它。这三行同时验证了包是否装在当前解释器里——「装错环境」比「策略写错」更常见。
from backtesting import Backtest, Strategy from backtesting.lib import crossover from backtesting.test import SMA, GOOG
预期输出:无报错。若提示 ModuleNotFoundError,说明当前解释器没装包,先回下载部署页的自检清单。
init() 只在回测开始时执行一次,负责把指标算好并注册到策略上;self.I() 的返回值之后可以在 next() 里按序列取用。窗口写成类属性(n1 / n2)而不是写死数字,是为了后面能直接做参数搜索。
class SmaCross(Strategy):
n1 = 10
n2 = 20
def init(self):
price = self.data.Close
self.ma1 = self.I(SMA, price, self.n1)
self.ma2 = self.I(SMA, price, self.n2)预期输出:无输出。指标序列与数据等长;长度不匹配会在 run() 阶段才报错,所以别在这一步就以为「没问题了」。
next() 按 bar 逐个调用,这里用 crossover() 判断两条均线谁上穿谁:快线上穿慢线买入,慢线上穿快线卖出。规则要写成「事件」(交叉那一刻),不要写成「状态比较」(快线一直在慢线上方就一直买入),否则会反复下单。
def next(self):
if crossover(self.ma1, self.ma2):
self.buy()
elif crossover(self.ma2, self.ma1):
self.sell()预期输出:无输出。真正的成交记录要等 run() 之后才看得到。
self.position 能读到当前持仓(is_long / is_short),self.position.close() 平掉现有仓位。多空混合的策略里,平仓条件与开仓条件要成对写,避免方向叠加或反复开平。
def next(self):
if self.position.is_long and crossover(self.ma2, self.ma1):
self.position.close()预期输出:无输出。跑完后结果里的交易笔数应与你预期的量级一致;差得离谱通常是条件没写成对。
把数据、策略类、资金与成本参数交给回测器,run() 返回统计结果;exclusive_orders=True 让每个新信号先平掉反向仓。commission 一定要显式给,默认值不等于你真实市场的摩擦成本。
bt = Backtest(GOOG, SmaCross, cash=10_000, commission=.002, exclusive_orders=True) stats = bt.run() print(stats)
预期输出:打印一份以 Return [%]、Max. Drawdown [%]、Sharpe Ratio、# Trades 等字段开头的结果面板;字段名与口径以实际安装版本为准。
| 操作 | 写法 | 适用场景 | 注意点 |
|---|---|---|---|
| 开多 | self.buy() | 趋势跟随、突破入场 | 资金或成交量不足时会被拒单,先确认 cash 与标的最小交易单位 |
| 开空 | self.sell() | 单标的做空、对冲 | 先确认标的是否允许做空,不然结果没有实际意义 |
| 平仓 | self.position.close() | 反向信号、固定持有期 | 与开仓条件成对写,否则容易出现反复开平 |
| 读持仓 | self.position.is_long / is_short | 条件分支判断 | 不要用「价格涨了」代替方向判断,两者不等价 |
| 读行情 | self.data.Close / self.data.Open | 计算信号与过滤条件 | 数据只要求 OHLC(V) 列,其它字段要自己准备并对齐 |
| 读权益 | self.equity | 动态仓位、按权益止损 | 逐 bar 变化,不要拿它当静态阈值 |
官方 README 把「指标库无关(BYO)」写成一个特性,落地时要在三条路径里选:内置工具、第三方库、自己用 pandas / NumPy 算。三条都能跑,差别在依赖、可控性与可复现性。
| 路径 | 写法示例 | 适用场景 | 注意点 |
|---|---|---|---|
| 内置 lib | self.I(SMA, self.data.Close, 10) | 快速验证均线类想法 | 只提供基础工具,复杂指标要自己写 |
| talib | self.I(talib.RSI, self.data.Close, 14) | 需要成熟技术指标 | talib 要单独安装且依赖系统库,装不上时先查系统依赖再看 Python 侧 |
| pandas-ta | self.I(ta.rsi, self.data.Close, 14) | 想用 pandas 生态的技术指标、又不想装系统依赖 | 它与 TA-Lib 的同名指标由各自库实现,口径不保证一致;换库前先用同一段数据对照一次 |
| pandas 滚动 | self.I(lambda c: c.rolling(20).mean(), self.data.Close) | 自定义指标、快速试验 | 返回序列长度与索引要与数据对齐,否则 run() 阶段报错 |
| NumPy 向量化 | 自己写函数后交给 self.I() | 计算量大的指标 | 返回值必须与原序列等长,长度算错会很难定位 |
| 指标参数化 | 把窗口写成类属性 n1 / n2 | 准备做参数搜索的策略 | 属性名不要与基类已有属性冲突 |
| 多指标组合 | 在 init() 里依次注册 | 需要多个条件确认 | 指标越多越容易过拟合,先各自单独看再组合 |
| 预热期处理 | 前 N 根 bar 没有值 | 所有均线类与长周期指标 | 预热期内不产生信号,别误判成逻辑写错 |
self.I() 会缓存指标计算结果,next() 中可按索引取用;talib 的安装方式与支持的指标集以官方文档为准。指标本身不是策略,把指标阈值直接当买卖信号前,先想清楚它在什么行情里会失效。Backtesting.py 的内置优化器让「一组参数」变成「一片参数」,能一次看到参数的稳定区间。但同一套机制也会一次制造大量「偶然看起来很好」的组合,所以这一步的重点不是找峰值,而是判断哪片区域可信。
只有写成类属性的参数才能被优化器扫描;写死在 init() 里的数字对优化器不可见。
class SmaCross(Strategy):
n1 = 10
n2 = 20
def init(self):
self.ma1 = self.I(SMA, self.data.Close, self.n1)
self.ma2 = self.I(SMA, self.data.Close, self.n2)预期输出:无输出。可以先手动改 n1 跑两次 run(),确认结果确实随参数变化。
把参数取值(通常是 range)、优化目标 maximize 与可选约束 constraint 传给优化器;约束用来排除无意义的组合,例如快线窗口不小于慢线。
stats, heatmap = bt.optimize(
n1=range(5, 30, 5),
n2=range(10, 70, 10),
maximize='Equity Final [$]',
constraint=lambda p: p.n1 < p.n2)
print(heatmap)预期输出:返回优化后的结果与一张热力图对象(具体返回顺序与字段以官方文档为准);打印时会看到以参数为行列表头的数值矩阵。
热力图的价值在于形态:稳定区是一片连续的高值,偶然值是一个孤立的亮点。只盯那个亮点,等于把噪声当规律。
print(heatmap) # 打印参数矩阵 print(stats['# Trades']) # 顺带看这组参数产生了多少笔交易
打印出来的矩阵行与列分别对应两组参数,先看高值是否连续成片,再看笔数是否够多。
预期输出:能指出一片「参数变动、表现也不差」的区域;如果全程都是棋盘状的花纹,说明这组指标在这段数据上没有稳定结构。
把数据切成两段:一段用来搜索参数,另一段只做验证。样本内挑出来的参数在样本外经常直接失效,这一步是筛掉大部分假结论的地方。
SmaCross.n1, SmaCross.n2 = 10, 20 # 换成你搜索得到的那组参数 train, test = data.iloc[:-252], data.iloc[-252:] print(Backtest(test, SmaCross, cash=10_000, commission=.002).run()['Return [%]'])
参数赋值方式与区间划分方法只是示意,写法与返回字段以官方文档为准。
预期输出:样本外区间的结果明显弱于样本内是常态;如果样本外直接反向,通常说明参数在拟合噪声。
| 环节 | 怎么设 | 适用场景 | 注意点 |
|---|---|---|---|
| 搜索范围 | n1=range(5, 30, 5) | 首次扫某组参数 | 范围先粗后细,一上来就细分会把网格撑得很大 |
| 优化目标 | maximize='Equity Final [$]' | 需要单一排序指标 | 用哪个指标优化,结果就偏向哪个指标,回撤与交易次数要另外看 |
| 约束条件 | constraint=lambda p: p.n1 < p.n2 | 参数之间有大小关系 | 约束写错会静默筛掉大量组合,看到结果稀疏先回来检查它 |
| 优化后端 | 方法参数取值以官方文档为准 | 网格很大或要加速 | 不同后端的结果可能不完全一致,换后端要重跑对照 |
| 结果读取 | 同时看结果对象与热力图 | 任何参数搜索 | 只看单个数字会漏掉邻域信息 |
| 成本假设 | 显式设置 commission,必要时加滑点 | 任何打算落地的研究 | 不设成本会系统性高估所有组合,且高估幅度对高频策略更大 |
| 样本外复核 | 留出未参与优化的区间 | 所有要写进结论的参数 | 样本外表现明显变差属正常,关键是别把它藏起来 |
下面三类是单标的回测里最常见、也最容易验证的起步模式。它们的共同点是逻辑不复杂、参数少,适合先把「回测链路跑通 + 结果解释得清」这两件事做扎实,再往上加条件。
快慢两条均线的交叉作为买卖事件,快线上穿慢线进场、下穿离场。它的优点是逻辑可复述、参数只有窗口长度;缺点是震荡行情里交叉频繁,成本会吃掉大部分理论收益,所以成本假设必须显式设置。
用 RSI 的阈值(常见 30 / 70 一类的分界)判断超买超卖并反向操作。它适合区间震荡行情,但趋势行情里会持续给出反向信号;单看 RSI 容易忽略趋势过滤,建议配合均线或波动率条件一起看。
突破最近 N 根 bar 的高低点入场,属于动量类思路。它对参数(回看窗口)与前视偏差都敏感:突破位必须用「当时已经知道」的价格区间来计算,否则回测会异常好看而无法复现。
| 研究场景 | 常见做法 | 参数起点思路 | 注意点 |
|---|---|---|---|
| 趋势跟随 | 双均线交叉 | 先看 10 / 20 一类短中期组合,再按标的波动调整 | 震荡期回撤集中,需同时看回撤幅度与交易次数 |
| 震荡反转 | RSI 或通道内反向 | 阈值与回看窗口都要与标的波动幅度匹配 | 趋势行情里反向信号会连续出现 |
| 突破动量 | N 日高低点突破 | 窗口从较短周期起步,避免信号过稀 | 严格检查突破位是否用了未来信息 |
| 多指标确认 | 趋势指标 + 震荡指标同向才交易 | 先各自单独跑,再合并条件 | 条件越多信号越少,容易变成过拟合 |
| 控制回撤 | 加止损或按权益比例减仓 | 止损阈值与标的日常波动相关 | 止损过紧会被噪声触发,反而放大成本 |
| 降低交易频率 | 加过滤条件或拉长持有期 | 先用较长窗口做基线 | 频率降低会让统计样本变少,需要更长区间 |
这类问题的共同点是:代码能跑、不报错,结果还很好看,但结论是错的。它们大多不是语法问题,而是「你假设了自己在某一刻知道多少信息」这件事没对齐。
| 现象 | 可能成因 | 处理方式 | 提前规避 |
|---|---|---|---|
| 回测很漂亮,实盘差很多 | 前视偏差:用了当根 bar 收盘后的信息做决策 | 明确成交时点假设,按下一根 bar 成交或统一用收盘成交口径 | 写策略时先写下「这条信息我在哪一刻知道」 |
| 换一段区间结论就翻转 | 参数是在同一段数据上挑出来的 | 切出样本外区间复核,只把稳得住的结论留下来 | 先划分区间,再动参数 |
| 收益明显偏高 | 没有设置手续费与滑点,或按理想价格成交 | 按真实费率与成交价假设重跑一遍做对照 | 把成本当默认假设写进代码,而不是最后补 |
| 交易次数异常多 | 把「状态」当成「事件」来判断 | 改用交叉一类的事件判断,或加确认条件 | 先看结果里的交易笔数是否与预期量级一致 |
| 某段区间完全没有交易 | 指标预热期没值,或数据有缺口 | 检查前 N 根 bar 是否有值、数据是否连续 | 把数据质检放在策略之前 |
| 做空结果与预期相反 | 仓位方向与平仓条件没配对 | 用持仓状态明确判断方向,再决定开平 | 多空混合策略先用单方向跑通再加另一边 |
| 一组参数扫出来全是同款结果 | 搜索范围过窄,或约束把组合筛得只剩几个 | 放大范围、放宽约束,先看单参数的敏感度曲线 | 动手前先想清楚每个参数的合理数量级 |
Backtesting.py 里搜索结束只代表你拿到了一批候选,不代表其中任何一个可以写进结论。下面六项是判断参数可信度时最实际的检查点,顺序也建议照这个来。
| 看什么 | 怎么判断 | 常见误读 | 下一步 |
|---|---|---|---|
| 邻域稳定性 | 看热力图上高值区域是否成片 | 把孤立亮点当成较优解 | 取成片区域的中心参数,而不是尖峰 |
| 样本外表现 | 在未参与搜索的区间重跑同一组参数 | 把样本内指标当成预期收益 | 把样本外结果作为主要依据,样本内只做参考 |
| 交易笔数 | 看笔数是否足以支撑统计判断 | 认为笔数多就一定稳健 | 结合平均持有期一起看,笔数太少就延长区间 |
| 成本敏感性 | 提高手续费与滑点假设后结果是否崩塌 | 忽略摩擦成本对高频策略的影响 | 用偏高的成本假设做压力测试 |
| 时间分布 | 收益是否集中在少数几段行情里 | 只看总收益,不看分布 | 分段看每段表现,判断依赖什么行情 |
| 参数可解释性 | 参数值能否用交易含义解释清楚 | 把随机波动当成规律 | 优先选能复述、能在别的区间站住的参数 |
把前面几节压缩成一张可勾选的清单:每次改完策略,按这六项过一遍,能挡掉大部分「结果看起来不对但找不到原因」的情况。这些建议属于经验层面的检查项,不替代官方文档。
| 环节 | 建议 | 为什么 | 注意点 |
|---|---|---|---|
| 数据质量 | 确认 OHLCV 无缺漏、复权方式一致 | 数据口径不同会让同一策略跑出不同结论 | 跨数据源对比前先统一复权与时间区间 |
| 成本假设 | 显式设置 commission,必要时加滑点 | 成本是高频策略的主要侵蚀项 | 不要用「先跑通再看成本」的顺序做结论 |
| 成交时点 | 明确默认成交与 trade_on_close 的差别 | 成交时点决定你有没有用到未来信息 | 换成交口径后结论变化大,说明策略对执行很敏感 |
| 样本外验证 | 参数搜索与验证用不同区间 | 样本内挑参数几乎必然乐观 | 样本外不要反复调参,否则它也会被用成样本内 |
| 风险指标 | 同时看收益、回撤幅度、夏普与胜率 | 单一指标容易被局部行情诱导 | 指标口径以实际返回为准,跨框架比较前先统一 |
| 可复现性 | 把数据区间、参数与成本一起记录 | 半年后要能重跑出同样的结论 | 只记「用了双均线」是不够的 |
把双均线、RSI 反转、MACD、布林带反转、唐奇安突破写成同样结构的策略类,在同一批标的、同一区间、同一成本下各跑一遍,看的是「这一族规则有没有稳定优势」,而不是某一个策略的单点高值。
| 策略族 | 平均收益 | 平均超额(对买入持有) | 跑赢买入持有 | 平均交易笔数 | 平均胜率 |
|---|---|---|---|---|---|
| SMA(10/20) 双均线 | +8.11% | -223.54pp | 0 / 6 | 118 | 34.3% |
| RSI(14) 30/70 反转 | +71.47% | -157.45pp | 1 / 6 | 9 | 72.9% |
| MACD(12/26/9) 金叉死叉 | +20.25% | -210.87pp | 0 / 6 | 159 | 34.7% |
| 布林带(20,2) 反转 | -35.75% | -267.40pp | 0 / 6 | 100 | 40.4% |
| 唐奇安突破 20/10 | +145.37% | -84.01pp | 1 / 6 | 44 | 35.8% |
| 买入持有(基准) | +232.15%(6 只均值) | — | — | 0 | — |
止盈止损常被当成「风险控制」的默认动作,但它同时改动了两件事:被提前截断的盈利单,和被提前截断的亏损单。实测结果是——固定比例的止盈止损让两套策略都明显变差。
| 标的与版本 | 原版双均线收益 | 加 sl=5% / tp=10% 后 | 同期买入持有 | 说明 |
|---|---|---|---|---|
| 贵州茅台 600519(前复权 + A 股成本) | -69.16% | -87.40% | +107.61% | 止损把趋势中的回撤兑现成实际亏损 |
| 比亚迪 002594(前复权 + A 股成本) | -33.15% | -100.00% | +318.78% | 高波动标的被反复止损,资金耗尽 |
| 内置 GOOG 样例(2004-08-19~2013-03-01) | +469.86% | 未实测 | +607.37% | 本站未在这份样例上测试止盈止损 |
| 周线双均线(贵州茅台) | -46.05% | 未实测 | +107.61% | 放大周期改善了结果,但仍远低于基准 |
| 平安银行 000001(原版双均线) | -72.09% | 未在本轮实测 | +11.27% | 止损版本未单独跑,不给出数字 |
| 招商银行 600036(原版双均线) | -74.07% | 未在本轮实测 | +77.17% | 同上 |
class SmaWithStops(Strategy):
n1, n2, sl, tp = 10, 20, 0.05, 0.10
def init(self):
self.a = self.I(SMA, self.data.Close, self.n1)
self.b = self.I(SMA, self.data.Close, self.n2)
def next(self):
if crossover(self.a, self.b) and not self.position:
price = self.data.Close[-1]
self.buy(sl=price * (1 - self.sl), tp=price * (1 + self.tp))
elif crossover(self.b, self.a):
self.sell()
多时间框架不需要多份数据:用 resample_apply 把日线聚合成周线,再把指标喂回策略即可。要注意官方注释提醒的聚合方向——默认的 label='left' 会产生前视偏差,必须显式指定 label='right'。
from backtesting.lib import resample_apply
class SmaWeekly(Strategy):
n1, n2 = 10, 20
def init(self):
self.a = resample_apply('W-FRI', SMA, self.data.Close, self.n1)
self.b = resample_apply('W-FRI', SMA, self.data.Close, self.n2)
def next(self):
if crossover(self.a, self.b):
self.buy()
elif crossover(self.b, self.a):
self.sell()
| 口径 | 贵州茅台收益 | 同期买入持有 | 交易笔数 | 注意点 |
|---|---|---|---|---|
| 日线双均线 10/20 | -69.16% | +107.61% | 123 | 信号密集,被震荡反复打脸 |
| 周线双均线 10/20 | -46.05% | +107.61% | 明显减少 | 改善但仍大幅跑输;聚合方向必须 label='right' |
| 周线双均线(比亚迪 002594) | -68.88% | +318.78% | 明显减少 | 换标的结论同样成立:放大周期不改变跑输 |
| 日线双均线(比亚迪 002594,对照基线) | -33.15% | +318.78% | 124 | 供与周线版本横向比较 |
| 周线双均线(平安银行 000001) | 未在本轮实测 | +11.27% | 明显减少 | 未单独跑,标为待验证 |
| 周线双均线(招商银行 600036) | 未在本轮实测 | +77.17% | 明显减少 | 未单独跑,标为待验证 |
| 标的(样本内 2018-01-02~2022-12-30 寻优) | 样本内收益最高的参数 | 该参数样本内收益 | 说明 |
|---|---|---|---|
| 贵州茅台 600519 | 30 / 51 | +342.41% | 不同标的表现居前的窗口各不相同 |
| 平安银行 000001 | 3 / 69 | +99.13% | 极短与极长组合同时出现 |
| 招商银行 600036 | 3 / 15 | +39.32% | 同一网格上的另一端 |
| 比亚迪 002594 | 6 / 51 | +1388.71% | 样本内收益极高,属典型的「拟合好看」 |
| 宁德时代 300750 | 15 / 27 | +2579.99% | 样本内收益顶部,需样本外复核 |
| 赛力斯 601127 | 18 / 75 | +134.09% | 与贵州茅台的组合完全不同 |
官方 README 写明它是「指标库无关(BYO)」,所以外部模型只要能算出一条与行情等长的序列,就能接进来。难点不在接口,而在「这条序列是不是只用了当时能看到的数据」。下表按「接入方式 / 怎么写 / 适用场景 / 注意点」整理。
| 接入方式 | 怎么写 | 适用场景 | 注意点 |
|---|---|---|---|
| 外部模型的预测值当指标 | 离线算好预测序列,整理成与行情等长、同索引的数组后用 self.I() 注册 | 想验证「模型信号」在历史上到底赚不赚钱 | 预测值必须只用当时及更早的数据算出;一次性用全历史训练再回填整段,等于把未来信息喂进回测 |
| pandas-ta 指标 | self.I(ta.rsi, self.data.Close, 14),或先用 pandas-ta 算成列再传入 | 想用 pandas 生态的技术指标,又不想装系统依赖 | 它与 TA-Lib 的同名指标由各自库实现,口径不保证一致;换库前先用同一段数据对照一次 |
| TA-Lib 指标 | self.I(talib.RSI, self.data.Close, 14) | 需要成熟且有文档的技术指标 | 需单独安装且依赖系统库,装不上时先查系统依赖再看 Python 侧 |
| pandas / NumPy 自算 | self.I(lambda c: c.rolling(20).mean(), self.data.Close) | 自定义指标、快速试验 | 返回值必须与输入等长、索引对齐;长度算错往往到 run() 阶段才报错 |
| 在 next() 里实时训练 | 按滚动窗口在 next() 内重新拟合模型 | 想把训练过程也纳入回测 | 逐 bar 重训练非常慢,而且极易写成「用当根收盘价训练、当根决策」;重训练建议放到离线流程里 |
| 特征工程用了未来数据 | 用整段历史算均值 / 分位数做标准化 | 任何涉及归一化的策略 | 这是最常见也最隐蔽的前视偏差来源:实测用全序列 z-score 比扩展窗口好看 28.70pp,且全程不会报错 |
| 把模型概率直接当仓位 | 预测涨跌后按概率比例全仓买入 | 想把 ML 结果直接换成仓位 | 概率不等于仓位;还要过成本这一关——本站实测成本从 0 到 20bp 会让同一策略从 -64.44% 变 -78.22% |
| 样本外切分 | 训练区间与回测区间必须分开,必要时滚动重切 | 任何 ML 策略 | 0.6.6 不提供自动 walk-forward,也没有内建未来函数检测;切分与滚动要自己写,并写进结论 |
先在环境里装好 talib,再在 init() 中用 self.I(talib.RSI, self.data.Close, 14) 这类写法注册即可。talib 需要单独安装并依赖系统库,装不上时先排查系统依赖;支持的指标集与安装方式以官方 README 与文档为准。
会,而且网格越大越容易。参数是在同一段历史上挑出来的,样本内表现好并不奇怪。可行的做法是把搜索与验证分成不同区间,并在热力图上优先选成片区域而不是孤立高点;具体的优化参数取值以官方文档为准。
Backtesting.py 以单标的回测为主,不支持多资产组合与分数股交易,这一点官方 README 有明确说明。多标的要分别回测,或改用支持组合的框架;跨框架比较前先统一成本与成交口径。
默认按下一根 bar 的开盘价成交,这样避免用到当时还不知道的价格;设置 trade_on_close 可改为收盘价成交。两种口径的结果会有差异,写法与默认行为以官方 README 为准。
能。Backtesting.py 里把计算结果交给 self.I() 注册即可,例如用滚动窗口写成一行 lambda 或单独的函数。要注意返回序列的长度与索引必须与数据对齐,否则在 run() 阶段才会报错;细节以官方文档为准。
不建议。参数优化只说明它在某段历史上表现较好,不代表未来有效;资金规模、成交时点与滑点都不同。参数命名与返回字段以官方文档为准。较稳妥的顺序是先做样本外复核,再用小规模验证,最终是否投入实盘需自行评估风险。