Blankly / 首个离线回测

三种调用方式,只有一种能跑通:Blankly 离线回测实测

Blankly 支持用 KeylessExchange 不接任何交易所直接回测。本机用一份自造的 400 根日线价格序列试了三种调用写法:显式传 start_date/end_date 成功to='1y'IndexError完全不传日期则抛 TypeError。这一页把三种写法的代码、真实报错原文与成因都列出来,让你少走几次弯路。

实测版本:1.18.25b0数据:自造合成价格 400 根日线不使用任何密钥非真实标的收益
① 六列 CSVtime/open/high/low/close/volume,至少 2 行
② KeylessExchangeprice_reader=PriceReader(文件, 标的)
③ 注册事件add_price_event(fn, symbol, resolution, init)
④ 显式起止start_date/end_date 传 epoch 秒
离线回测最小调用链示意(依据发行版 1.18.25b0 实测整理)。第 ④ 步是关键:省略它或换成 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_stopNone,减去分辨率时直接崩
第二条的报错信息会误导人。它提示「Try adding arguments such as 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.jsonGUI_output 开关影响
    .get_quantstats_metrics()需要额外的第三方库想要更完整的绩效报告属额外依赖,不在默认安装清单里
    .to_dict()转成字典想把结果序列化保存比直接 dict(result) 可靠(后者会报不可迭代)
    实测到的 12 个绩效字段:calmarcagrcavrcum_returnsmax_drawdownresampled_timerisk_free_ratesharpesortinovalue_at_riskvariancevolatility。注意条件风险价值那一项的键名是 cavr 而不是常见的 cvar——按 cvar 取值会拿到 KeyError
    数据准备

    喂进去的价格文件有什么硬要求

    Blankly 离线回测的成败有一半取决于数据文件格式。读取器的校验规则写在源码里,违反任一条都会直接抛异常,没有容错。

    要求具体规则违反后的表现怎么自查
    列名必须包含 openhighlowclosevolumetimeAssertionError: Must have at least these columns打印 df.columns 与要求列表逐项核对
    行数至少 2 行AssertionError: Must give data with at least 2 rowslen(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 / .dfLookupError: 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 columnsCSV 缺列或列名大小写不符补齐六列,列名全小写pd.read_csv 打印列名核对
    回测能跑但没有任何成交事件回调里条件从未成立,或 init 没预热指标在回调里打印信号值,确认条件真的会触发看到日志里出现下单调用
    一个经验:离线回测阶段的问题几乎全在「数据格式」和「时间参数」这两处,很少来自策略逻辑。所以第一次跑的时候,先把这两件事确认好,再去调策略。
    FAQ

    关于离线回测的高频问题

    回答基于发行版 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_feetaker_fee默认都是 0。也就是说如果你什么都不传,回测就是在零成本假设下跑的。要让结论更接近真实,应该在构造时显式传入与你实际费率档位相符的数值,并把这个假设记录下来。

    下一步:怎么确认你用的是哪一套框架?

    跑通之后,你会发现 Blankly 提供了三套不同的基类。选错基类会让后面所有代码都要重写,所以值得先花五分钟看清楚它们的定位差异。