功能

回测引擎

同时跨多个资产和参数模拟订单、信号与回调

能否在 Python 中回测自定义交易逻辑,同时保留速度和控制力?VBT 是一个 Python 回测引擎,用编译代码逐根模拟订单,将每个资产与参数组合放入同一次向量化模拟的不同列,并返回可检查每笔订单、交易和持仓的投资组合。可从入场与离场信号开始,回放已有订单列表,或构建在模拟期间响应现金与持仓的自定义逻辑。每种方式都提供相同的投资组合分析工具。

Backtest four moving average pairs on two assets in one call
data = vbt.YFData.pull(["BTC-USD", "ETH-USD"], start="2021-01-01", end="2024-01-01")
fast = vbt.MA.run(data.close, window=[10, 10, 20, 20], short_name="fast")
slow = vbt.MA.run(data.close, window=[50, 100, 50, 100], short_name="slow")
entries = fast.ma_crossed_above(slow)
exits = fast.ma_crossed_below(slow)

pf = vbt.PF.from_signals(data, entries, exits, fees=0.001)  
pf.total_return.unstack("symbol")
symbol                    BTC-USD   ETH-USD
fast_window slow_window
10          50           0.346309  0.720064
            100          0.577382 -0.151935
20          50          -0.139554  0.017887
            100          0.365233 -0.110708
pf[(10, 50, "ETH-USD")].plot(settings=dict(bm_returns=False)).show()  

每个信号在出现当根收盘成交,每笔订单收取 0.1% 手续费。若要在下一根执行,参见符合因果关系的订单时机。

2021 至 2023 年 ETH-USD 10 日与 50 日均线交叉订单、交易收益和累计收益 图表数据(JSON)

三种模拟方式

每种模拟方法返回相同的 Portfolio 对象,差别在于如何描述交易决策:

方法您提供用于
vbt.PF.from_orders逐根数量、价格和方向再平衡、目标权重、订单列表
vbt.PF.from_signals入场与离场信号,以及止损和限价大多数规则策略
vbt.PF.from_order_func返回订单的编译函数依赖投资组合状态的逻辑

from_orders 最直接:提供数量与价格,引擎处理现金和执行。若目标权重需结合止损与限价,from_signals 可通过 order_mode=True 接受,详见将目标作为信号。两种方法每列每根最多成交一个订单。若要多个,例如开盘买入、收盘卖出,可使用灵活订单函数,详见订单函数。

from_orders 和 from_signals 接受模拟前准备的数组,执行仍响应当前账户。例如,目标权重根据投资组合净值和已有持仓转化为订单数量。from_signals 和 from_order_func 还接受在模拟期间决策的回调,详见事件驱动回测。无论静态或动态,方法共享订单执行规则。这里,同一纯多头策略的两种表达得到相同订单:

Run one strategy through two simulation methods
close = data.close["BTC-USD"]
entries = close.vbt.crossed_above(close.rolling(50).mean())
exits = close.vbt.crossed_below(close.rolling(50).mean())
pf_signals = vbt.PF.from_signals(close, entries, exits)

size = pd.Series(np.nan, index=close.index)  
size[entries] = np.inf
size[exits] = -np.inf
pf_orders = vbt.PF.from_orders(close, size, direction="longonly")

cols = ["Index", "Size", "Price", "Side"]
a = pf_signals.orders.readable.rename(columns={"Fill Index": "Index"})[cols]
b = pf_orders.orders.readable[cols]
a.equals(b), len(a)
(True, 51)

高层方法之下是接受普通 NumPy 数组的编译函数,可从自己的 Numba 代码调用,详见信号回测。

一次调用运行多个回测

输入相互广播。八列信号、两列价格和标量手续费产生八个回测,参数值成为可选择、分组和绘图的列层级。默认每列为独立账户。

分组可改变这一点。group_by 将多列合并为报告中的一个组合,cash_sharing=True 使它们使用同一现金余额,用于模拟多资产组合。按参数层级对首个网格分组,会将八个单资产回测转为四个双资产组合:

Turn the grid into four two-asset portfolios with shared cash
pf = vbt.PF.from_signals(
    data,
    fast.ma_crossed_above(slow),
    fast.ma_crossed_below(slow),
    size=0.5,  
    size_type="valuepercent",
    fees=0.001,
    group_by=["fast_window", "slow_window"],  
    cash_sharing=True,
)
pf.total_return
fast_window  slow_window
10           50             0.570373
             100            0.186409
20           50            -0.055170
             100            0.133235
Name: total_return, dtype: float64

模拟器在组内逐根延续现金与持仓。速度来自编译循环,以及多个独立组并排运行。可用并行执行与缓存页的选项扩展参数扫描。

每列一个持仓

一列持有一个净头寸。可通过加减仓增加持仓;也可将同一品种的各笔独立管理交易放入各自列,再分组,并可共享现金。信号展开可从一个信号数组构建这些列。

模拟窗口

指标在首个信号之前需要历史数据。无需切片数据,传入 sim_start 和 sim_end 即可:指标看到完整历史,模拟则在指定根从空仓开始,并在结束根之前停止。

Simulate only 2022 and 2023 while the indicator uses 2021
pf = vbt.PF.from_signals(close, entries, exits, sim_start="2022-01-01")
pf.stats(["start_index", "end_index", "total_return", "total_trades"])
Start Index         2022-01-01 00:00:00+00:00
End Index           2023-12-31 00:00:00+00:00
Total Return [%]                    25.991572
Total Trades                               18
Name: BTC-USD, dtype: object

窗口可逐列不同,sim_start="auto" 从各列首个信号开始,回调也可提前结束一组。度量、记录与图表都遵循窗口,详见下方模拟区间。也可在已有投资组合上分析不同日期区间,无须重跑回测。

以记录作为输入

可直接从时间戳与资产列匹配的 pandas Series 或 DataFrame 获取价格和信号。可使用已有数据流程或 VBT 的市场数据集成。

某些策略是一份事件列表:在某日期买入某品种。利用数据封装器容易构建稠密数组,例如先 data.symbol_wrapper.fill(False),再用 .loc 赋值。也可完全省略数组,直接以记录传入事件,见下方索引记录。

记录还可将券商、电子表格或其他回测器的成交日志转为投资组合。日志各列对应模拟方法的一个参数:

Replay a year of manual stock trades from a fill log
data = vbt.YFData.pull(["AAPL", "NVDA"], start="2024-01-01", end="2025-01-01")
fills = pd.DataFrame(
    [
        ("2024-02-05", "AAPL", 50, 188.20, 1.0),
        ("2024-03-11", "NVDA", 100, 86.40, 1.0),
        ("2024-04-22", "AAPL", -50, 166.90, 1.0),
        ("2024-06-18", "NVDA", -60, 131.70, 1.0),
        ("2024-08-05", "NVDA", 60, 101.20, 1.0),
        ("2024-11-25", "NVDA", -100, 137.40, 1.0),
    ],
    columns=["date", "symbol", "size", "price", "fixed_fees"],  
)
pf = vbt.PF.from_orders(
    data,
    records=fills,
    init_cash=20_000,
    group_by=True,
    cash_sharing=True,
)
pf.trades.readable[["Column", "Size", "Avg Entry Price", "Avg Exit Price", "PnL", "Return"]]
  Column   Size  Avg Entry Price  Avg Exit Price     PnL    Return
0   AAPL   50.0           188.20           166.9 -1067.0 -0.113390
1   NVDA   60.0            86.40           131.7  2716.4  0.523997
2   NVDA  100.0            95.28           137.4  4209.6  0.441814
pf.total_return
np.float64(0.29294999999999993)

NVDA 部分卖出将 60 股作为一笔交易平仓,余下 40 股与八月买入合并,平均价为 95.28。日志成为组合后,可检查度量与图表,或用不同手续费和离场日期重放订单,探索假设情景。

落在 K 线内部的时间戳会映射到数据中的下一根,因此应先将日内成交时间向下取整至 K 线起点,例如日线用 fills["date"].dt.floor("1D")。同一品种同根的后续记录覆盖前一条,因此传入前应合并同根成交。

运行前检查

模拟前,预处理器转换参数:字符串变枚举、输入广播至最终形状、模板填充、默认值应用。传入 return_prep_result=True 可取得该结果而非投资组合,检查或修改参数后再运行。下方投资组合预处理器逐步展示。

编译一次,快速重复运行

研究意味着反复运行同一模拟。以下设置有助于保持速度:

  • 只编译一次。 带回调的模拟器在每个新 Python 会话中编译。staticized=True 让 VBT 为回调写出可缓存模拟器副本,后续会话跳过编译。回调源码、模拟设置或 VBT 版本改变时,副本自动重新生成。不会检测回调导入的其他模块改动,修改它们后应传入 staticized=dict(override=True)。无回调模拟使用已缓存模拟器,因此每次安装只编译一次。
  • 保存将要读取的内容。 默认模拟存储订单,在请求时重建余额和收益。save_returns=True 及相关标志改为模拟期间计算,以内存换速度。
  • 注意内存。 每个完整 float64 数组占行数 × 列数 × 8 字节。20 年加密货币日线、10,000 列,每个数组约 580 MB,模拟会保留多个。大网格应分块,参见并行执行与缓存。

以属性读取结果

每项结果都是投资组合属性。pf.sharpe_ratio 按默认设置返回并缓存,pf.get_sharpe_ratio(risk_free=0.02) 则接受参数。属性也可由您提供的数组计算,因此无需重跑模拟,就能测试不同基准或覆盖某个序列。

添加自定义度量或行为时,可继承 vbt.Portfolio。from_signals 等类方法返回子类实例。若需重新标记结果,可用 pf.replace(wrapper=dict(columns=...), nested_=True) 创建新组合。

文档

会员专享的投资组合文档(英文)介绍从订单执行到记录与封装的模拟生命周期。

✅ 参数传给 Portfolio.from_signals 等模拟方法时,会经过复杂预处理流程,转换为适合 Numba 的格式。通常包括枚举映射、广播、数据类型检查、模板替换等步骤。为使 VBT 更透明,该流程已移到独立类,让您完整控制到达 Numba 函数的参数!甚至可扩展预处理器,为自己的模拟器自动准备参数。

Get a prepared argument, modify it, and use in a new simulation
data = vbt.YFData.pull("BTC-USD", end="2017-01")
prep_result = vbt.PF.from_holding(
    data,
    stop_ladder="uniform",
    tp_stop=vbt.Param([
        [0.1, 0.2, 0.3, 0.4, 0.5],
        [0.4, 0.5, 0.6],
    ], keys=["tp_ladder_1", "tp_ladder_2"]),
    return_prep_result=True  
)
prep_result.target_args["tp_stop"]  
array([[0.1, 0.4],
       [0.2, 0.5],
       [0.3, 0.6],
       [0.4, nan],
       [0.5, nan]])
new_tp_stop = prep_result.target_args["tp_stop"] + 0.1
new_prep_result = prep_result.replace(target_args=dict(tp_stop=new_tp_stop), nested_=True)  
new_prep_result.target_args["tp_stop"]
array([[0.2, 0.5],
       [0.3, 0.6],
       [0.4, 0.7],
       [0.5, nan],
       [0.6, nan]])
pf = vbt.PF.from_signals(new_prep_result)  
pf.total_return
tp_stop
tp_ladder_1    0.4
tp_ladder_2    0.6
Name: total_return, dtype: float64
sim_out = new_prep_result.target_func(**new_prep_result.target_args)  
pf = vbt.PF(sim_out=sim_out, **new_prep_result.pf_args)
pf.total_return
tp_stop
tp_ladder_1    0.4
tp_ladder_2    0.6
Name: total_return, dtype: float64

静态化

✅ Numba 的主要限制之一是:作为参数传入的函数(即回调)会使主函数无法缓存,每次新运行时都必须反复编译。这尤其影响模拟器,因为编译可能长达一分钟。现在可以使用“静态化”技巧:先用特殊语法标记函数源码,再提取(也称“裁切”✂️)代码,将回调从参数中移除,修改为可缓存版本并保存到 Python 文件。下次调用执行可缓存版本。听起来复杂?看下方示例!

Define the signal function in a Python file, here signal_func_nb.py
from vectorbtpro import *

@njit
def signal_func_nb(ctx, fast_sma, slow_sma):  
    long = vbt.pf_nb.iter_crossed_above_nb(ctx, fast_sma, slow_sma)
    short = vbt.pf_nb.iter_crossed_below_nb(ctx, fast_sma, slow_sma)
    return long, False, short, False
Run a staticized simulation. Running the script again won't re-compile.
data = vbt.YFData.pull("BTC-USD")
pf = vbt.PF.from_signals(
    data,
    signal_func_nb="signal_func_nb.py",  
    signal_args=(vbt.Rep("fast_sma"), vbt.Rep("slow_sma")),
    broadcast_named_args=dict(
        fast_sma=data.run("sma", 20, hide_params=True, unpack=True),
        slow_sma=data.run("sma", 50, hide_params=True, unpack=True)
    ),
    staticized=True  
)

预计算

✅ 内存和执行速度之间存在取舍:1000 列数据通常比单列数据处理 1000 次快得多,但也需要 1000 倍内存。因此,模拟阶段 VBT 主要生成订单,余额、净值与收益等其他属性在分析阶段按需重建。性能最重要时,现在可通过参数在模拟期间预先计算这些属性!⏩

Benchmark a random portfolio with 1000 columns without and with pre-computation
data = vbt.YFData.pull("BTC-USD")
%%timeit  
for n in range(1000):
    pf = vbt.PF.from_random_signals(data, n=n, seed=42, save_returns=False)
    pf.sharpe_ratio
15 s ± 829 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%%timeit
pf = vbt.PF.from_random_signals(data, n=np.arange(1000).tolist(), seed=42, save_returns=False)
pf.sharpe_ratio
855 ms ± 6.26 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%%timeit
pf = vbt.PF.from_random_signals(data, n=np.arange(1000).tolist(), seed=42, save_returns=True)
pf.sharpe_ratio
593 ms ± 7.07 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

灵活属性

✅ 投资组合属性现在可部分甚至全部由用户数组计算。模拟后分析更加可控,例如覆盖部分模拟数据、不重跑完整组合即可测试超参数,或在关闭缓存时避免重复重建。

Compute the net exposure by caching its components
data = vbt.YFData.pull("BTC-USD")
pf = vbt.PF.from_random_signals(data.close, n=100, seed=42)
value = pf.get_value()  
long_exposure = vbt.PF.get_gross_exposure(  
    asset_value=pf.get_asset_value(direction="longonly"),
    value=value,
    wrapper=pf.wrapper
)
short_exposure = vbt.PF.get_gross_exposure(
    asset_value=pf.get_asset_value(direction="shortonly"),
    value=value,
    wrapper=pf.wrapper
)
del value  
net_exposure = vbt.PF.get_net_exposure(
    long_exposure=long_exposure,
    short_exposure=short_exposure,
    wrapper=pf.wrapper
)
del long_exposure
del short_exposure
net_exposure
Date
2014-09-17 00:00:00+00:00    1.0
2014-09-18 00:00:00+00:00    1.0
2014-09-19 00:00:00+00:00    1.0
2014-09-20 00:00:00+00:00    1.0
2014-09-21 00:00:00+00:00    1.0
                             ...
2023-02-08 00:00:00+00:00    0.0
2023-02-09 00:00:00+00:00    0.0
2023-02-10 00:00:00+00:00    0.0
2023-02-11 00:00:00+00:00    0.0
2023-02-12 00:00:00+00:00    0.0
Freq: D, Length: 3071, dtype: float64

快捷属性

✅ 原地输出数组可覆盖普通投资组合属性。组合自动采用预计算数组,之后所有计算都使用它,避免重复重建。

Modify the returns from within the simulation
data = vbt.YFData.pull("BTC-USD")
size = data.symbol_wrapper.fill(np.nan)
np.random.seed(42)
rand_indices = np.random.choice(np.arange(len(size)), 10)
size.iloc[rand_indices[0::2]] = -np.inf
size.iloc[rand_indices[1::2]] = np.inf

@njit
def post_segment_func_nb(ctx):
    for col in range(ctx.from_col, ctx.to_col):
        return_now = ctx.last_return[col]
        return_now = 0.5 * return_now if return_now > 0 else return_now
        ctx.in_outputs.returns[ctx.i, col] = return_now

pf = vbt.PF.from_def_order_func(
    data.close,
    size=size,
    size_type="targetpercent",
    post_segment_func_nb=post_segment_func_nb,
    in_outputs=dict(
        returns=vbt.RepEval("np.empty_like(close)")
    )
)

pf.returns  
Date
2014-09-17 00:00:00+00:00    0.000000
2014-09-18 00:00:00+00:00    0.000000
2014-09-19 00:00:00+00:00    0.000000
2014-09-20 00:00:00+00:00    0.000000
2014-09-21 00:00:00+00:00    0.000000
                                  ...
2023-02-08 00:00:00+00:00   -0.015227
2023-02-09 00:00:00+00:00   -0.053320
2023-02-10 00:00:00+00:00   -0.008439
2023-02-11 00:00:00+00:00    0.005569  << modified
2023-02-12 00:00:00+00:00    0.001849  << modified
Freq: D, Length: 3071, dtype: float64
pf.get_returns()  
Date
2014-09-17 00:00:00+00:00    0.000000
2014-09-18 00:00:00+00:00    0.000000
2014-09-19 00:00:00+00:00    0.000000
2014-09-20 00:00:00+00:00    0.000000
2014-09-21 00:00:00+00:00    0.000000
                                  ...
2023-02-08 00:00:00+00:00   -0.015227
2023-02-09 00:00:00+00:00   -0.053320
2023-02-10 00:00:00+00:00   -0.008439
2023-02-11 00:00:00+00:00    0.011138
2023-02-12 00:00:00+00:00    0.003697
Freq: D, Length: 3071, dtype: float64

模拟区间

✅ 每次模拟都有起止点,通常与数据首末行一致。可事先设置不同区间,现在也能在模拟期间调整,无需继续处理时即可停止。日期区间保存在组合对象中,所有度量和子图都能识别。只处理相关日期可加快执行,并为分析增添独立时间窗口这一维度 🔬

Simulate a quick liquidation scenario
@njit
def post_segment_func_nb(ctx):
    value = vbt.pf_nb.get_group_value_nb(ctx, ctx.group)
    if value <= 0:
        vbt.pf_nb.stop_group_sim_nb(ctx, ctx.group)  

pf = vbt.PF.from_random_signals(
    "BTC-USD",
    n=10,
    seed=42,
    sim_start="auto",  
    post_segment_func_nb=post_segment_func_nb,
    leverage=10,
)
pf.plot_value()  
固定随机种子的杠杆 BTC-USD 投资组合从开始至资金耗尽的净值 图表数据(JSON)

索引记录

✅ 如何回测“日期 Z 买入资产 Y 的 X 单位”等绑定时间与资产的指令?通常需要构建完整数组并手动设置每个细节。借助预处理器和重新设计的智能索引,现在可以用压缩记录格式提供全部信息!底层将记录数组转化为一组索引字典,每个参数一个。

Define a basic signal strategy using records
data = vbt.YFData.pull(["BTC-USD", "ETH-USD"], missing_index="drop")
records = [
    dict(date="2022", symbol="BTC-USD", long_entry=True),  
    dict(date="2022", symbol="ETH-USD", short_entry=True),
    dict(row=-1, exit=True),
]
pf = vbt.PF.from_signals(data, records=records)  
pf.orders.readable
   Order Id   Column              Signal Index            Creation Index
0         0  BTC-USD 2022-01-01 00:00:00+00:00 2022-01-01 00:00:00+00:00  \
1         1  BTC-USD 2023-04-25 00:00:00+00:00 2023-04-25 00:00:00+00:00
2         0  ETH-USD 2022-01-01 00:00:00+00:00 2022-01-01 00:00:00+00:00
3         1  ETH-USD 2023-04-25 00:00:00+00:00 2023-04-25 00:00:00+00:00

                 Fill Index      Size         Price  Fees  Side    Type
0 2022-01-01 00:00:00+00:00  0.002097  47686.812500   0.0   Buy  Market  \
1 2023-04-25 00:00:00+00:00  0.002097  27534.675781   0.0  Sell  Market
2 2022-01-01 00:00:00+00:00  0.026527   3769.697021   0.0  Sell  Market
3 2023-04-25 00:00:00+00:00  0.026527   1834.759644   0.0   Buy  Market

  Stop Type
0      None
1      None
2      None
3      None

版权所有 © 2021–2026 Oleg Polakow。保留所有权利。

本站内容与文档供使用和评估 VectorBT PRO 以及教育用途。任何其他用途,包括开发或支持竞争产品或服务,均须事先取得书面同意。