Blankly / 首个离线回测
三种调用方式,只有一种能跑通:Blankly 离线回测实测
Blankly 支持用 KeylessExchange 不接任何交易所直接回测。本机用一份自造的 400 根日线价格序列试了三种调用写法:显式传 start_date/end_date 成功;to='1y' 抛 IndexError;完全不传日期则抛 TypeError。这一页把三种写法的代码、真实报错原文与成因都列出来,让你少走几次弯路。
to= 都会失败,原因见本页「三种调用对照」。为什么三种写法只有一种成功?同一策略、同一份数据
下面三次执行除 backtest() 的参数外完全相同。这正是「照抄官方模板会踩坑」的地方——官方 rsi_bot.py 模板用的正是会失败的那一种写法。
| 写法 | 结果 | 真实报错 / 输出 | 原因 |
|---|---|---|---|
backtest(start_date=1609459200, end_date=1640995200, initial_values={'USD': 10000}) | 成功 | 先输出 No cached data found for TEST-USD from: 1609459200 to 1640908800 at a resolution of 86400 seconds.,再输出 Backtesting...,最后返回 BacktestResult 对象 | 显式起止时间让引擎知道要推进到哪一天 |
backtest(to='1y', initial_values={'USD': 10000}) | 失败 | IndexError: No cached or downloaded data available. Try adding arguments such as to="1y" in the backtest command... | to= 以「当前时间」为终点,实测窗口是 2025-09-21 到 2026-09-21,落在本地 CSV 数据区间之外 |
backtest(initial_values={'USD': 10000}) | 失败 | TypeError: unsupported operand type(s) for -: 'NoneType' and 'int',位置在 backtest_controller.py:384 sync_prices | 没有起止时间时引擎内部的 epoch_stop 为 None,减去分辨率时直接崩 |
to="1y"」——而 to='1y' 正是你刚才传进去的参数。真实原因是数据区间不覆盖「最近一年」,报错文案与实际情况不一致。遇到这个 IndexError,先检查你的数据起止范围,而不是继续调 to= 的值。完整最小示例怎么跑?从造数据到打印绩效
下面这段代码不需要任何 API 密钥,也不访问网络(把 GUI_output 关掉后连绘图都不会触发)。它先用 Python 造一段合成价格,再跑一次双均线回测。
import math, random
random.seed(7)
# 1) 造一份六列价格数据(真实项目里换成你自己的导出文件)
rows = ["time,open,high,low,close,volume"]
start, price = 1609459200, 200.0 # 2021-01-01 起,400 根日线
for i in range(400):
t = start + i * 86400
nxt = max(1.0, price * (1 + math.sin(i / 20.0) * 0.012
+ random.uniform(-0.006, 0.006)))
rows.append("%d,%.4f,%.4f,%.4f,%.4f,%.4f"
% (t, price, max(price, nxt) * 1.002,
min(price, nxt) * 0.998, nxt, 1000 + i))
price = nxt
open("probe_prices.csv", "w").write("\n".join(rows))
import blankly
from blankly.data import PriceReader
def price_event(price, symbol, state):
state.variables.history.append(price)
h = list(state.variables.history)
if len(h) < 20:
return
fast, slow = sum(h[-8:]) / 8, sum(h[-20:]) / 20
curr = state.interface.account[state.base_asset].available
if fast > slow and not curr: # 金叉且空仓
size = blankly.trunc(state.interface.cash / price * 0.98, 3)
state.interface.market_order(symbol, side="buy", size=size)
elif fast < slow and curr: # 死叉且持仓
state.interface.market_order(symbol, side="sell", size=curr)
def init(symbol, state):
# 预热:抓一段历史给指标用
state.variables.history = state.interface.history(
symbol, to=60, return_as="deque", resolution=state.resolution)["close"]
exchange = blankly.KeylessExchange(
price_reader=PriceReader("probe_prices.csv", "TEST-USD"))
strategy = blankly.Strategy(exchange)
strategy.add_price_event(price_event, symbol="TEST-USD",
resolution="1d", init=init)
# 关键:必须显式传 epoch 秒,不能用 to=
result = strategy.backtest(start_date=1609459200, end_date=1640995200,
initial_values={"USD": 10000})
print(result.metrics)
这段代码里三个容易被忽略的点
工作目录必须有 settings.json
没有它,构造 KeylessExchange 时会直接抛 FileNotFoundError: Make sure a settings.json file is placed in the same folder as the project working directory!。用 blankly init 生成的文件里包含这一份。
init 回调不是可选的优化
指标需要预热数据。如果不调 state.interface.history(...) 预填 history,策略要等到数据足够长才开始产生信号,前面的行情会被空跑掉。
initial_values 的键是计价资产
示例里是 {'USD': 10000}。用 USDT 计价的交易所应写 {'USDT': ...}。键写错不会报错,只会让你看到奇怪的初始权益。
回测返回的是什么?不是一个字典,而是一个对象
很多人第一次跑完会写 print(strategy.backtest(...)),然后看到一行对象地址,以为回测没结果。其实结果都在对象的成员里。
| 成员 | 类型 / 内容 | 什么时候用 | 注意点 |
|---|---|---|---|
.metrics | 字典,12 个 {value, display_name, type} | 看汇总绩效 | 不含交易次数与胜率 |
.get_metrics() | 实测返回结果与 .metrics 一致 | 想用方法形式取值时 | — |
.get_returns() | pandas DataFrame,列为 time / value | 自己画曲线或算额外指标 | 实测 400 根日线产生 366 行,首行 value 为 NaN |
.get_account_history() | 账户历史 | 查权益变化 | 与 resample_account_value_for_metrics 口径相关 |
.trades | 成交记录 | 算交易次数、逐笔盈亏 | 框架不帮你汇总胜率,要自己算 |
.figures | 图表对象 | 需要可视化时 | 受 backtest.json 的 GUI_output 开关影响 |
.get_quantstats_metrics() | 需要额外的第三方库 | 想要更完整的绩效报告 | 属额外依赖,不在默认安装清单里 |
.to_dict() | 转成字典 | 想把结果序列化保存 | 比直接 dict(result) 可靠(后者会报不可迭代) |
calmar、cagr、cavr、cum_returns、max_drawdown、resampled_time、risk_free_rate、sharpe、sortino、value_at_risk、variance、volatility。注意条件风险价值那一项的键名是 cavr 而不是常见的 cvar——按 cvar 取值会拿到 KeyError。喂进去的价格文件有什么硬要求
Blankly 离线回测的成败有一半取决于数据文件格式。读取器的校验规则写在源码里,违反任一条都会直接抛异常,没有容错。
| 要求 | 具体规则 | 违反后的表现 | 怎么自查 |
|---|---|---|---|
| 列名 | 必须包含 open、high、low、close、volume、time | 抛 AssertionError: Must have at least these columns | 打印 df.columns 与要求列表逐项核对 |
| 行数 | 至少 2 行 | 抛 AssertionError: Must give data with at least 2 rows | 看 len(df) |
time 的格式 | 读取器会按 epoch 处理并做差分推断分辨率 | 分辨率推断为 0 时抛 LookupError: Resolution is 0 | 确认时间是数值型 epoch,不是字符串日期 |
| 数据区间 | 必须覆盖你传给 backtest() 的起止时间 | 抛 IndexError: No cached or downloaded data available | 比较 df.time.min()/max() 与起止参数 |
| 排序 | 读取器会按 time 排序 | 一般不会报错,但乱序数据会让分辨率推断失真 | 自己先 sort_values('time') 更保险 |
| 文件扩展名 | 按结尾判断类型:.csv / .json / .df | 抛 LookupError: Unknown filetype for ... | 别用 .txt 存 CSV 内容 |
| 同名标的不重复 | 同一个 PriceReader 不允许重复标的 | 抛 AssertionError: Cannot use duplicate symbols | 需要同标的的多段数据时,用多个 PriceReader |
离线回测最可能遇到哪六种报错?
下面每一条都在本机实测中出现过,或由读取器源码的校验逻辑直接决定。排查顺序建议从上往下。
| 报错 | 触发条件 | 处置 | 验证方法 |
|---|---|---|---|
FileNotFoundError: Make sure a settings.json file is placed in the same folder... | 工作目录下没有 settings.json | 运行 blankly init 生成,或手写一份最小配置 | 同目录 settings.json 存在即可 |
TypeError: unsupported operand type(s) for -: 'NoneType' and 'int' | backtest() 没有传 start_date/end_date | 显式传 epoch 秒 | 能打印出 Backtesting... |
IndexError: No cached or downloaded data available | 数据区间不覆盖请求的时间窗 | 改成覆盖数据的绝对时间;或扩大 CSV 数据范围 | 比较数据 min/max 与请求窗口 |
LookupError: Resolution is 0 for <symbol> | 时间列无法推断出非零分辨率(常见于指数记数法或行数太少) | 确认 time 是整秒 epoch,并保证行数足够 | 打印时间列的差分众数 |
AssertionError: Must have at least these columns | CSV 缺列或列名大小写不符 | 补齐六列,列名全小写 | 用 pd.read_csv 打印列名核对 |
| 回测能跑但没有任何成交 | 事件回调里条件从未成立,或 init 没预热指标 | 在回调里打印信号值,确认条件真的会触发 | 看到日志里出现下单调用 |
关于离线回测的高频问题
回答基于发行版 1.18.25b0 的实测行为;代码细节以官方仓库实现为准。
为什么不能用 to='1y'?这不是官方示例里的写法吗?
to='1y' 确实出现在官方 rsi_bot.py 模板里,但那个模板面向的是连了真实交易所的场景——它可以从交易所下载最近一年的数据。用 KeylessExchange 时数据只来自你本地的文件,to='1y' 会以采集当天为终点往前推一年,这个区间通常不在你的文件里,于是抛 IndexError。所以问题不在 to= 本身,而在「本地数据的区间」与「请求的区间」不重合。
为什么要传 epoch 秒,不能直接传日期字符串吗?
从函数签名看,start_date/end_date 标注为 Union[str, float, int],所以字符串形式在类型上是被接受的。但本机实测用的是 epoch 秒,且官方模板同样使用 epoch 秒。稳妥做法是先用 epoch 秒跑通,再尝试日期字符串形式,并且每次都用「能打印出 Backtesting...」作为通过标准。
回测跑通了,能据此判断策略好坏吗?
不能。本站这次跑的是自造的合成价格序列,目的是验证调用链是否成立,它不包含任何真实标的信息,因此结果没有任何策略含义。即使换成真实数据,也要先补三件事才能讨论策略优劣:基准对照、交易成本(离线模式默认零手续费)、以及滑点假设(引擎当前没有滑点模型)。详见回测引擎审计页。
怎么知道回测真的成交了,而不是一直空仓?
看两个地方。第一,回测过程中如果发生下单,日志里会有相应输出;第二,回测结束后读 BacktestResult.trades,逐条看成交记录。如果 trades 为空,而你的信号条件理论上应当触发,大概率是 init 里没有预热足够的历史数据,导致指标一直不可用。
可以同时回测多个标的吗?
可以,思路是给同一个策略注册多个 price_event(每个标的一个 symbol),并让 PriceReader 接收多个文件加多个标的的列表。要注意的是:同一个 PriceReader 里不允许出现重复的 symbol,需要同一标的的多段数据时应拆成多个 PriceReader,这一点在源码的断言里写得很明确。
回测里怎么加手续费?
离线模式下,KeylessExchange 的构造函数签名里有 maker_fee 与 taker_fee,默认都是 0。也就是说如果你什么都不传,回测就是在零成本假设下跑的。要让结论更接近真实,应该在构造时显式传入与你实际费率档位相符的数值,并把这个假设记录下来。