交互式图表看什么
价格走势叠加买卖标记,下方是权益曲线。重点看两类异常:信号是否密集出现在同一段震荡里(成本会被吃掉),以及权益曲线是否由少数几笔交易撑起来——后者通常意味着结论不稳。
First run · SmaCross
装好之后的首要任务不是写复杂策略,而是用官方 README 的 SmaCross 双均线交叉示例跑通一遍完整链路:导入模块 → 定义策略 → 创建回测 → 出结果与图。这个示例用库内置的 GOOG 日线数据,不需要联网、也不需要自备行情文件,几分钟内就能看到一份真实可解释的输出。本页把它拆成「可复制代码 + 每步预期输出 + 结果字段怎么读 + 首跑常见坑」,让你确认环境与理解链路,而不是只看到一串数字。之所以从这里开始,是因为 Backtesting.py 的绝大多数「结果不对」都发生在数据与口径环节,而不是策略写法:先用内置数据确认代码链路,再把数据换成自己的,出问题时才好定位是哪一环变了。
下面这段代码来自 Backtesting.py 官方 README 的快速开始(中文注释为本页补充,2026-09 核验)。它一次性展示四件事:怎么导入、怎么定义策略、怎么创建回测、怎么取结果与出图。整段代码不到 20 行,却覆盖了后面所有复杂策略的同一个骨架——先用它把「能跑通」和「看得懂」这两件事同时拿到手。
from backtesting import Backtest, Strategy
from backtesting.lib import crossover
from backtesting.test import SMA, GOOG
class SmaCross(Strategy):
def init(self):
price = self.data.Close
self.ma1 = self.I(SMA, price, 10)
self.ma2 = self.I(SMA, price, 20)
def next(self):
if crossover(self.ma1, self.ma2):
self.buy()
elif crossover(self.ma2, self.ma1):
self.sell()
bt = Backtest(GOOG, SmaCross, cash=10_000, commission=.002, exclusive_orders=True)
stats = bt.run()
print(stats)
bt.plot()
cash 是初始资金、commission 是单边手续费比例、exclusive_orders=True 表示每次下单前先平掉反向仓位(不会同时持有多空)。参数名与默认值以官方文档为准;换成自己的数据前,建议先原样跑通一次,确认环境没问题,也能顺手记下这组基线结果作为后续对照。把这段示例拆成五步,每一步都给出代码、说明和可以直接核对的预期输出。这样做的原因是:报错信息往往指向最后一行,而真正的起因经常在前面几步;跑不到预期结果时就停在这一步排查,而不是继续往下写代码。
从 backtesting 取 Backtest / Strategy 两个类,从 backtesting.lib 取交叉判断函数,从 backtesting.test 取示例指标 SMA 与示例数据 GOOG——这三行同时验证了 Backtesting.py 是否装在了当前解释器里。「包没装对」比「策略写错」更常见,也更容易被误判成策略问题。
from backtesting import Backtest, Strategy from backtesting.lib import crossover from backtesting.test import SMA, GOOG
预期输出:无报错。若提示 ModuleNotFoundError,说明当前解释器里没有装包(见安装页自检清单)。
继承 Strategy,在 init() 里计算指标(这里用 10 日与 20 日两条均线),在 next() 里逐 bar 写交易逻辑;指标通过 self.I() 注册后,策略里随时可以按序列取用。
class SmaCross(Strategy):
def init(self):
price = self.data.Close
self.ma1 = self.I(SMA, price, 10)
self.ma2 = self.I(SMA, price, 20)预期输出:类定义完成,无输出。若把窗口改得比数据长度还大,指标会全是空值,回测不会报错但不会成交——这类「静默失效」是首跑最常见的坑,所以下面专门给了一张对照表。
用 crossover() 判断两条均线谁上穿谁:快线上穿慢线时 self.buy(),慢线上穿快线时 self.sell()。这一段就是策略的全部「决策逻辑」。
def next(self):
if crossover(self.ma1, self.ma2):
self.buy()
elif crossover(self.ma2, self.ma1):
self.sell()预期输出:无输出。真正的成交记录要到 run() 之后才能看到。
把数据、策略类与资金/成本参数交给 Backtest;run() 会按 bar 推进并返回统计结果。exclusive_orders=True 让每次信号先平反向仓,避免同时多空。
bt = Backtest(GOOG, SmaCross, cash=10_000, commission=.002, exclusive_orders=True) stats = bt.run()
预期输出:若无异常,说明整条链路已跑通;耗时通常在一秒级(随机器与数据长度变化)。
print(stats) 会打印一份纵向的统计 Series;bt.plot() 打开基于 bokeh 的交互式图表,可缩放查看价格、买卖点与权益曲线。
print(stats) bt.plot()
预期输出:终端打印以 Return [%]、Max. Drawdown [%]、Sharpe Ratio、# Trades 等字段开头的结果面板;浏览器打开一张可交互图表(价格 + 买卖点 + 权益曲线)。字段名以实际返回为准,不同版本可能略有差异。
Backtesting.py 的 run() 返回的是一份统计结果(可按 Series 处理)。下面这八项是初次跑完最该看的,重点不是数值大小,而是「这个数字在什么前提下才算可信」——同一份代码换区间、换成本假设,读数就会变,所以字段含义要和对它的判断方式一起记。字段名与计算口径以官方实现为准。
| 字段 | 含义 | 怎么判断 | 常见误读 |
|---|---|---|---|
Equity Final [$] | 回测结束时的账户权益 | 与初始资金对比看盈亏方向 | 把它当成「能拿到的钱」,忽略成本与滑点假设 |
Return [%] | 区间总收益率 | 先确认区间长度,再和基准对比 | 拿不同区间的收益率直接互相比较 |
Buy & Hold Return [%] | 同期买入持有的收益率 | 策略跑不赢它,就要问为什么还要交易 | 完全忽略这条基准,只看策略绝对收益 |
Max. Drawdown [%] | 区间内回撤幅度 | 先问自己能否承受这个回撤再谈收益 | 当成「未来的回撤上限」——换区间就会变 |
Sharpe Ratio | 单位波动的超额收益 | 配合交易次数看,样本太少时意义有限 | 把它当成「必赚」或跨标的可比的绝对分数 |
Win Rate [%] | 盈利交易占比 | 结合盈亏比一起看,高胜率也可能亏 | 只看胜率高低,忽略单笔盈亏大小 |
# Trades | 成交笔数 | 笔数太少说明结论不稳,太多说明成本吃收益 | 认为笔数越多越可信 |
Commission [$] 等成本项 | 手续费等成本合计 | 看成本占收益的比例是否过高 | 用零成本参数跑完就下结论 |
Start / End / Duration | 回测区间与跨度 | 确认区间是否覆盖完整涨跌、是否够长 | 不看区间就横向比较两个策略 |
Profit Factor / SQN | 盈亏比与系统质量类指标 | 样本笔数足够时再看,作为稳定性参考 | 把单一指标当成挑选策略的全部依据 |
示例能跑通不代表结论可信。下面这六种情况在初次换数据时最常见,共同点是「不报错但结论不对」,所以要主动检查而不是等报错。检查顺序建议固定下来:先看索引与列名,再看缺失与复权,最后才动策略参数——顺序反了会把数据问题误判成策略问题。
| 现象 | 成因 | 处理方式 | 提前规避 |
|---|---|---|---|
| 跑完几乎不成交 | 均线窗口比数据长度还长,或数据段太短 | 缩短窗口或换更长区间,先让信号出现 | 跑之前先打印数据行数与起止日期 |
| 报找不到列或列全是空 | 列名大小写/拼写与要求不一致(如 close 与 Close) | 把列名统一成 Open/High/Low/Close/Volume | 加载数据后立刻打印 df.columns |
| 结果明显好得反常 | 时间索引未按升序排列,或存在重复日期 | 排序并去重后重跑,对比差异 | 每次加载后检查索引是否单调递增 |
| 某些区间整段无交易 | 缺失值 / 停牌日未处理,序列出现断点 | 补齐或明确剔除,并在结论里说明处理方式 | 统计缺失比例,超过容忍度就换数据源 |
| 换数据后收益暴涨 | 未复权或复权口径不同,价格跳空被当成机会 | 统一复权口径,注明数据来源与处理方式 | 在结论里写清口径,别让两次实验混在一起 |
| 同一份数据越跑越慢 | 在 next() 里做逐 bar 重计算等低效操作 | 把指标计算移到 init(),只为需要的位置取数 | 先跑小样本验证逻辑,再上全量数据 |
| 改了代码结果不变 | 运行的仍是缓存/旧文件,或改的不是被导入的那个模块 | 确认文件路径与运行命令,必要时重启解释器 | 改完先跑一次「明显会变」的小改动做对照 |
图不只是好看——它是排查「买卖点是不是打在意料之外的位置」最快的工具。首次运行之后通常有三个后续动作:看交互图的项目、把图表存成文件、把结果拿去二次分析。三条都做完,这次回测才算留下可复核的材料,而不是屏幕上闪过的一串数字。
价格走势叠加买卖标记,下方是权益曲线。重点看两类异常:信号是否密集出现在同一段震荡里(成本会被吃掉),以及权益曲线是否由少数几笔交易撑起来——后者通常意味着结论不稳。
图表基于 bokeh 渲染,可以输出成独立的 HTML 文件,便于归档与分享;如果要嵌到报告里,也可以在环境里导出静态图片。归档时建议连参数与数据区间一起记下来,否则过几周再看这张图,很难还原当时的假设。具体导出方式与依赖以 bokeh 官方文档为准。
stats 可按 Series 处理,也能取到交易记录等明细用于二次分析(如按年统计、按持仓时长分组)。把结论落到 Excel 或 CSV 之前,先确认字段口径与区间,避免与其他区间混用。
首次运行只是把链路跑通。要让结论站得住,还需要在下结论前过一遍下面这张清单——它们比「换个更聪明的参数」更能决定你的结论对不对。执行顺序上,先排掉会系统性放大收益的那几项(成交时点、成本假设),再看稳定性类问题(参数邻域、样本长度)。
| 陷阱 | 表现 | 规避做法 | 注意点 |
|---|---|---|---|
| 前瞻偏差 | 用当根 bar 的收盘信息决定当根成交 | 明确成交时点假设;默认下一根 bar 成交,若开启按收盘成交要说明理由 | 改这个开关会立刻改变收益,属于结论级变量 |
| 参数过拟合 | 参数微小变化收益断崖式下跌 | 看参数邻域是否成片稳定,而不是只看单点高点 | 样本外验证比「再多扫几组」更有价值 |
| 成本假设过低 | 把手续费设成 0 或远低于实际 | 按真实费率与滑点假设重跑一遍对比 | 高频信号策略对成本最敏感 |
| 样本太短 | 只跑了几个月就下结论 | 至少覆盖一轮完整涨跌,并分段验证 | 交易笔数太少时胜率没有统计意义 |
| 单一标的结论外推 | 把一只标的的表现当成规律 | 换标的、换区间交叉验证 | 本项目面向单资产回测,多资产需另做组合层面的评估 |
| 只跑一遍不记录 | 参数与数据版本没留档,无法复现 | 记录数据区间、参数、成本假设与代码版本 | 不能复现的结论等于没有结论 |
| 把回测读数当预期 | 用历史收益估算未来收益 | 把它当成「策略在历史区间的表现」这一事实描述 | 回测不构成收益保证或投资建议 |
| 忽略成交价口径 | 用开盘价、收盘价或中间价成交却未说明 | 在结论里写明成交价假设,并做敏感性对比 | 同一策略换成交口径,收益可能量级不同 |
把内置 GOOG 换成贵州茅台 600519 的日线(前复权,2018-01-02~2026-09-18),策略与参数一个字不改,结果会完全不同——这就是本站实测的起点。下面三步给出完整可跑代码,每步都写清预期输出与「数字是从哪来的」。
用数据接口取日线,再把字段统一成 Open / High / Low / Close / Volume,并把日期列设为时间索引。列名与索引是回测里最容易出错的一环,取完先打印一次确认。
import akshare as ak, pandas as pd
df = ak.stock_zh_a_daily(symbol="sh600519", start_date="20180101", end_date="20260918", adjust="qfq")
df["time"] = pd.to_datetime(df["date"])
df = df.rename(columns={"open": "Open", "high": "High", "low": "Low",
"close": "Close", "volume": "Volume"})
data = df[["time", "Open", "High", "Low", "Close", "Volume"]].set_index("time").astype(float)
print(len(data), data.index[0].date(), data.index[-1].date())预期:打印 2116 2018-01-02 2026-09-18。若行数明显偏少,多数是接口或复权参数与本文不同(本站实测使用新浪接口 adjust="qfq")。
策略代码与官方示例一致;区别只在成本:A 股的佣金与过户费双边收、印花税只在卖出侧收,所以用一个 callable 把它写清楚。
def ashare_cost(size, price, comm=0.00025, transfer=0.00001, stamp=0.0005):
notional = abs(size) * price
fee = notional * comm + notional * transfer # 佣金 + 过户费:双边
if size < 0:
fee += notional * stamp # 印花税:仅卖出侧(订单为负代表卖出)
return fee
bt = Backtest(data, SmaCross, cash=100000, commission=ashare_cost,
exclusive_orders=True, finalize_trades=True)
stats = bt.run()预期:run() 正常返回统计结果,交易笔数在十几到上百笔之间(本站实测 123 笔)。若一笔都不成交,先检查数据行数与均线窗口。
只看策略收益没有意义——同一区间的「买入持有」才是最直接的对照:如果你的规则连它都跑不赢,就要先回答「为什么还要交易」。
print(stats[["Return [%]", "Buy & Hold Return [%]", "# Trades",
"Sharpe Ratio", "Max. Drawdown [%]"]])预期:本站实测输出为 Return -69.16%、买入持有 +107.61%、123 笔、Sharpe -0.514、最大回撤 -83.87%。字段名与口径以官方实现为准。
| 口径(同一套 SmaCross 10/20) | 策略收益 | 买入持有 | 交易笔数 | Sharpe | 最大回撤 |
|---|---|---|---|---|---|
| 内置 GOOG 样例(2004-08-19~2013-03-01,2148 根,commission=0.002) | +469.86% | +607.37% | 94 | 0.599 | -34.12% |
| 贵州茅台 600519 前复权 + A 股成本(本站实测) | -69.16% | +107.61% | 123 | -0.514 | -83.87% |
| 同标的不计任何成本 | -64.44% | +107.61% | 123 | -0.448 | -82.88% |
| 同标的按 20bp 对称佣金 | -78.22% | +107.61% | 123 | -0.678 | -86.21% |
| 同标的 trade_on_close=True(按本根收盘成交) | -35.22% | +107.61% | 123 | -0.188 | -77.94% |
| 腾讯 00700 前复权 + 港股费用近似 | -20.91% | +1.43% | 113 | -0.079 | -56.85% |
本站每个实测数字都能用下面这张记录表复现。反过来说:缺任何一项,几周后连你自己也说不清那份结果是在什么假设下跑出来的——而「说不清假设」是回测最常见的失效方式,比代码写错更隐蔽。
| 记录项 | 本站实测值示例 | 为什么必须记 | 漏记会怎样 |
|---|---|---|---|
| Python 版本 | 3.11.9 | 不同版本的依赖与行为差异会影响运行结果 | 复现时结果对不上,却先去怀疑代码 |
| backtesting 版本 | 0.6.6 | 统计字段、默认参数与校验行为随版本变化 | 字段缺失或口径不同,两次实验不可比 |
| pandas / numpy / bokeh | 2.2.3 / 2.4.6 / 3.3.0 | 索引、年化与绘图行为受这些依赖影响 | 绘图失败或年化口径不一致 |
| 数据来源与复权口径 | 新浪接口日线 · 前复权 · 2018-01-02~2026-09-18 · 2116 根 | 换口径会改变基准:实测同标的 +107.61% 与 +70.73% | 同一策略在不同口径下差几十个百分点 |
| 成本与撮合设置 | 佣金 2.5bp 双边 + 过户费 0.1bp 双边 + 卖出印花税 5bp;trade_on_close=False | 实测仅改成交时点就差 33.94pp | 无法判断结论属于哪套假设,等于没有结论 |
| 参数与输出文件 | n1=10 / n2=20 / cash=100000;stats、交易明细 CSV、权益曲线 CSV | 没有输出就没有可比对的依据 | 只能重跑一遍,而重跑未必得到同一结果 |
# 复现记录模板:建议与结果文件放在同一目录,随结果一起归档
环境 : Python 3.11.9 / backtesting 0.6.6 / pandas 2.2.3 / numpy 2.4.6 / bokeh 3.3.0
数据 : 新浪日线 · 600519 · 前复权 · 2018-01-02~2026-09-18 · 2116 根
成本 : 佣金 2.5bp 双边 + 过户费 0.1bp 双边 + 卖出印花税 5bp(callable)
撮合 : trade_on_close=False, exclusive_orders=True, finalize_trades=True
策略 : SmaCross(n1=10, n2=20), cash=100000
输出 : Return -69.16% / Buy&Hold +107.61% / #Trades 123 / MDD -83.87%
结论 : 该区间跑输买入持有 176.77pp;换成本或成交时点结论量级会变
内置示例数据(backtesting.test 里的示例标的)不需要联网、不需要自备文件,能先把「代码链路对不对」和「数据对不对」这两件事分开验证。等示例跑通、字段看懂了,再换成自己的 OHLCV 数据,出问题时也更容易定位。示例数据的具体来源与字段以官方文档为准。等换成自己的数据之后,也建议保留这份示例作为「环境是否正常」的对照基线。
先确认绘图依赖已按官方要求装好(bokeh 版本需满足官方 README 与 PyPI 上的要求),再确认是在能启动浏览器的环境里运行;如果是服务器环境,可改为导出 HTML 文件后本地打开。具体用法与要求以官方文档为准。
它是「在这段历史区间、这组成本假设下」的统计结果,可信的部分是链路与口径是否清楚,不可信的部分是把它当成未来收益预期。判断可信度先看三件事:区间多长、成本是否计入、交易笔数够不够;统计项只是把这三件事算成了数,前提仍要你自己确认。结论不构成收益保证或投资建议。
可以。把示例数据换成自己的 OHLCV 数据即可,核心要求是「列名可识别 + 时间索引有序」:列一般需要 Open/High/Low/Close/Volume,索引为日期且单调递增、无重复。列名规则、是否允许额外列等细节以官方文档为准;换数据后建议先跑一遍数据自检再回测。
按顺序查四件事:①数据行数与索引是否正常(排序、重复、缺失);②列名与数据类型是否匹配要求(数值列别是字符串);③是否运行在装了包的那个 Python 环境里;④信号与成交时点假设是否写成了「用当根信息做当根决策」。多数首跑「跑不通」都落在这四点里。排查时建议一次只改一个变量,改完立刻对比,否则很难判断是哪一处真正起了作用。
两条独立路线:本页是「自己写代码、自己准备数据」的回测框架路线;本机技能路线是用对话方式做行情、指标与风险摘要,适合先快速把问题问清楚。两者没有已证实集成,不能互相替代;选哪条取决于你要的是可复现的策略工程,还是先快速验证想法。能力边界以各自官方说明为准。