功能

事件驱动回测

编写用于冷却期、持仓限制和自定义模拟器的编译回调

某些交易规则依赖投资组合中已发生的事件:止损后冷却、未平仓数量上限、每日亏损限制。VBT 通过在模拟循环内部运行的编译回调,支持 Python 事件驱动回测。回调逐根查看现金、持仓和成交,同时保持 Numba 速度。

Block re-entries for 10 bars after a stop loss
@njit
def cooldown_signal_nb(c, entries, exits, cooldown):  
    is_exit = vbt.pf_nb.select_nb(c, exits)
    if c.i < c.in_outputs.blocked_until[c.col]:
        return False, is_exit, False, False
    return vbt.pf_nb.select_nb(c, entries), is_exit, False, False

@njit
def cooldown_post_order_nb(c, cooldown):  
    if vbt.pf_nb.order_closed_position_nb(c):
        if vbt.pf_nb.get_last_order_nb(c)["stop_type"] >= 0:
            wait = vbt.pf_nb.select_nb(c, cooldown)
            c.in_outputs.blocked_until[c.col] = c.i + 1 + wait

data = vbt.YFData.pull("BTC-USD", start="2022-01-01", end="2024-01-01")
sma = data.close.rolling(20).mean()
pf = vbt.PF.from_signals(
    data,
    signal_func_nb=cooldown_signal_nb,
    signal_args=(vbt.Rep("entries"), vbt.Rep("exits"), vbt.Rep("cooldown")),
    post_order_func_nb=cooldown_post_order_nb,
    post_order_args=(vbt.Rep("cooldown"),),
    broadcast_named_args=dict(
        entries=data.close > sma,
        exits=data.close < sma,
        cooldown=vbt.Param([0, 10]),  
    ),
    in_outputs=dict(
        blocked_until=vbt.RepEval("np.zeros(wrapper.shape_2d[1], dtype=np.int_)")
    ),
    sl_stop=0.05,
)
pf.stats(["total_trades", "win_rate", "total_return", "max_dd"], agg_func=None)
          Total Trades  Win Rate [%]  Total Return [%]  Max Drawdown [%]
cooldown
0                   44     22.727273          4.394750         38.506912
10                  34     29.411765         34.063625         31.897484
pf[10].orders.readable[["Fill Index", "Side", "Stop Type"]].iloc[2:6]
                 Fill Index  Side Stop Type
2 2022-02-28 00:00:00+00:00   Buy      None
3 2022-03-04 00:00:00+00:00  Sell        SL
4 2022-03-16 00:00:00+00:00   Buy      None
5 2022-04-06 00:00:00+00:00  Sell      None

5% 止损在 2022 年 3 月 4 日触发。无冷却时,趋势过滤器 3 月 9 日再次买入,次日又被止损;启用后,下次入场等到 3 月 16 日。样本中,冷却减少 10 笔交易并降低回撤。关键不在结果,而在于规则需要模拟自身输出,因此不能预先算成信号数组。

选择定制程度

既可给现有信号策略添加规则,也可控制订单生成。选择符合策略需求的层级:

您想做什么使用
保留信号数组,随账户变化调整数量或止损from_signals 的调整回调
根据当前持仓、成交或损益决定入场与离场from_signals 的信号回调
直接选择每笔订单,包括同根多个订单from_order_func 的订单函数,多订单使用灵活模式
自行管理账户状态和模拟循环用 VBT 执行原语构建自定义模拟器

信号回调保留信号模拟器的数量、止损、限价与冲突处理。订单函数直接控制订单。两者都生成可用相同交易记录、度量与图表检查的投资组合。

这些回调可在一次调用中处理完整历史回测。新 K 线到达时延续模拟,参见实时模拟。

能查看投资组合的回调

vbt.PF.from_signals 接受 signal_func_nb 信号函数,替代四个信号数组。它接收包含当前 K 线 c.i、列 c.col 和实时模拟状态的上下文 c,返回本根四个信号。数量、止损、手续费等其他处理照常运行。

上下文公开常用状态:

状态上下文字段或辅助函数
持仓、现金和债务c.last_position、c.last_cash、c.last_debt
投资组合净值c.last_value、vbt.pf_nb.get_group_value_nb(c, c.group)
未平仓头寸的入场根、价格、损益和收益率c.last_pos_info[c.col]
待触发止损与限价单c.last_sl_info、c.last_tsl_info、c.last_tp_info、c.last_limit_info
自定义数组在当前根的值vbt.pf_nb.select_nb(c, array)
上一订单的操作vbt.pf_nb.order_closed_position_nb(c)、vbt.pf_nb.get_last_order_nb(c)

模拟前计算指标,在回调内选取当前值,便于跨 K 线复用。如果指标本身需要在模拟期间更新,可用流式指标。对于日历规则,c.index[c.i] 保存本根纳秒时间戳。

模拟器调用信号函数前按本根开盘估值,因此 c.last_value 和 c.last_pos_info 中的损益反映开盘而非收盘。若要按收盘判断持仓,可通过 vbt.pf_nb.select_nb(c, c.close) 计算。两个字段都会由模拟器重算,向它们写入不会改变结果。要改变结果,应返回不同信号,或修改待触发止损与限价记录。

回调中的时机

信号函数为调用它的 K 线决策。若要在本根开盘根据上一根数据操作,应在 c.i - 1 读取输入,并传入 price="open"。使用 price="nextopen" 时,模拟器将返回信号标为上一根,并在当前开盘成交,因此读取当前收盘的回调会在该收盘存在前交易。

调试回调

编译回调的错误表现为 Numba 类型错误。常见原因包括函数没有 @njit、某分支未返回四个标志,以及 signal_args 不是元组,例如 (0.1) 而非 (0.1,)。逐步检查逻辑时,移除 @njit 并传入 jitted=False:模拟器改用 Python 运行,可使用 print 和断点。

信号函数或调整函数

adjust_func_nb 在内置信号函数前运行,信号仍来自数组,同时修改止损或数量。传入自定义 signal_func_nb 后,调整函数不再自动调用,因此应从自己的信号函数内部调用调整逻辑。

有状态交易规则

多数有状态规则遵循相同模式:保留少量状态,在信号函数中读取,在订单成交后更新。这里将共享现金的 20 资产池限制为最多三个同时持仓:

Hold at most three positions across 20 assets
symbols = [f"S{i:02d}" for i in range(20)]
data = vbt.GBMOHLCData.pull(symbols, start="2024-01-01", end="2025-01-01", seed=42)
sma = data.close.rolling(20).mean()

@njit
def max_positions_nb(c, entries, exits, max_positions):
    if c.in_outputs.bar_entries[0] != c.i:  
        c.in_outputs.bar_entries[:] = (c.i, 0)
    if c.last_position[c.col] > 0:
        return False, vbt.pf_nb.select_nb(c, exits), False, False
    if vbt.pf_nb.select_nb(c, entries):
        n_open = vbt.pf_nb.get_n_active_positions_nb(c)
        if n_open + c.in_outputs.bar_entries[1] < max_positions:
            c.in_outputs.bar_entries[1] += 1
            return True, False, False, False
    return False, False, False, False

pf = vbt.PF.from_signals(
    data,
    signal_func_nb=max_positions_nb,
    signal_args=(vbt.Rep("entries"), vbt.Rep("exits"), 3),
    broadcast_named_args=dict(
        entries=data.close.vbt.crossed_above(sma),
        exits=data.close.vbt.crossed_below(sma),
    ),
    in_outputs=dict(bar_entries=vbt.RepEval("np.full(2, -1, dtype=np.int_)")),
    size=0.3,
    size_type="valuepercent",
    group_by=True,  
    cash_sharing=True,
)
print((pf.assets > 0).sum(axis=1).max())
3

无上限时,相同信号最多同时持有七个头寸;没有同根计数器时,上限会失效并达到五个。可按此方式构建的规则包括:

规则读取的状态
止损、亏损或任意离场后冷却上一订单及止损类型、已关闭持仓损益
最大同时持仓数量组内未平仓头寸加本根已批准入场
最小持有期c.last_pos_info 中未平仓头寸的入场根
每日交易或亏损上限每日首根重置的计数器和参考值
净值曲线过滤器自定义数组保存的投资组合净值历史
投资组合级止损组净值与阈值比较,再让所有列离场

信号函数和 post_segment_func_nb 每根运行。post_order_func_nb 仅在模拟器尝试执行订单时运行,包括拒单;信号被忽略或只创建限价挂单时不运行。每日计数或结算延迟等需在无交易 K 线上推进的记账逻辑,应放入 segment 回调;也可传入 skip_empty=False,让订单回调也逐根运行。

教程

会员专享的从 Python 到 Rust(英文)教程在四资产组合中构建止损冷却,并迁移到 Numba 内核与原生 Rust。

订单函数

信号回调仍产生信号,信号模拟器每列每根最多下一个订单。vbt.PF.from_order_func 去掉这一层,让回调直接返回订单。灵活订单函数还能选择列,并在同根返回多个订单。这里在向下跳空超过 0.5% 后开盘买入,当天收盘卖出:

Buy the open and sell the close on gap-down days
@njit
def open_to_close_nb(c, gap_down):
    col = c.from_col
    if not gap_down[c.i, col]:
        return -1, vbt.pf_enums.NoOrder
    if c.call_idx == 0:  
        return col, vbt.pf_nb.order_nb(size=1, price=c.open[c.i, col])
    if c.call_idx == 1:
        return col, vbt.pf_nb.close_position_nb(price=c.close[c.i, col])
    return -1, vbt.pf_enums.NoOrder  

data = vbt.YFData.pull("SPY", start="2024-01-01", end="2024-02-01")
gap_down = data.open < data.close.shift(1) * 0.995
pf = vbt.PF.from_order_func(
    data,
    flex_order_func_nb=open_to_close_nb,
    flex_order_args=(vbt.Rep("gap_down"),),
    broadcast_named_args=dict(gap_down=gap_down),
    max_order_records=len(data.index) * 2,  
)
pf.orders.readable[["Index", "Side", "Price"]]
                      Index  Side       Price
0 2024-01-09 00:00:00-05:00   Buy  456.917453
1 2024-01-09 00:00:00-05:00  Sell  458.863770
2 2024-01-17 00:00:00-05:00   Buy  456.869019
3 2024-01-17 00:00:00-05:00  Sell  457.324127

订单函数配有完整生命周期钩子。pre_sim_func_nb 和 post_sim_func_nb 包围整个运行,group 与 segment 钩子分别在各资产组与各根前后运行,post_order_func_nb 可看到每次执行结果。设置 row_wise=True 后,模拟器先处理所有组的同一时间戳,再进入下一时点,因此回调可协调各组。策略已无后续工作时,vbt.pf_nb.stop_group_sim_nb 可提前结束一组。

一根 K 线内的先后顺序

一根 K 线是黑箱,只有开盘与收盘顺序已知,因此在同根最高和最低价交易的回调,必须自行决定谁先发生。

记忆与自定义输出

回调通过传入数组共享状态。命名元组可将多个数组放入一个参数,in_outputs 更进一步:其中创建的数组在每个回调中可通过 c.in_outputs 访问,随投资组合返回,并按其索引和列封装。上例用它保存冷却和持仓计数,下方原地输出则逐根记录债务。

也可记录结果背后的决策:风险规则阻止了哪些入场、何时处于冷却、或下单前策略看到什么敞口。将这些数组与组合保存在一起,可结合交易检查规则,而非只看到最终收益。

编译回调内应预分配数组并显式计数,不要不断扩容。未知大小的状态,可将 Numba 类型化列表作为回调参数。

自定义模拟器

订单函数仍过于受限时,例如每个资产需多个账户状态或大量限价挂单,可自行编写模拟循环。VBT 公开自身模拟器使用的执行原语,因此结果仍是具备所有度量和图表的普通投资组合:

Write a dip-buying simulator from execution primitives
@njit
def dip_buyer_nb(close, init_cash, dip, rebound):
    order_records = np.empty(close.shape, dtype=vbt.pf_enums.order_dt)
    order_counts = np.zeros(close.shape[1], dtype=np.int_)
    for col in range(close.shape[1]):
        state = vbt.pf_enums.ExecState(  
            init_cash, 0.0, 0.0, 0.0, init_cash, np.nan, np.nan
        )
        peak = close[0, col]
        for i in range(close.shape[0]):
            price = close[i, col]
            peak = max(peak, price)
            if state.position == 0 and price <= peak * (1 - dip):
                order = vbt.pf_nb.order_nb(size=np.inf, price=price)
            elif state.position > 0 and price >= peak * (1 - rebound):
                order = vbt.pf_nb.close_position_nb(price=price)
                peak = price
            else:
                continue
            _, state = vbt.pf_nb.process_order_nb(  
                col, col, i, state, order,
                order_records=order_records, order_counts=order_counts
            )
    return vbt.nb.repartition_nb(order_records, order_counts)

data = vbt.YFData.pull("BTC-USD", start="2023-01-01", end="2024-01-01")
records = dip_buyer_nb(data.close.values[:, None], 100.0, 0.1, 0.02)
pf = vbt.PF(
    data.symbol_wrapper,
    close=data.close,
    order_records=records,
    init_cash=100.0
)
pf.orders.readable[["Index", "Side", "Price"]]
                      Index  Side         Price
0 2023-03-07 00:00:00+00:00   Buy  22219.769531
1 2023-03-14 00:00:00+00:00  Sell  24746.074219
2 2023-04-21 00:00:00+00:00   Buy  27276.910156
3 2023-06-21 00:00:00+00:00  Sell  30027.296875
4 2023-08-17 00:00:00+00:00   Buy  26664.550781
5 2023-10-23 00:00:00+00:00  Sell  33086.234375

模拟器在价格从动态峰值下跌 10% 后买入,回升至距峰值 2% 以内时卖出。同一组基础组件还支持自行管理的限价单、同根多订单,以及跨持仓共享现金的账户。

文档

会员专享的模拟(英文)指南用这些原语逐步构建模拟器。

完整回调支持

✅ 基于信号的投资组合模拟现已在各个步骤完整支持回调,包括整个模拟的前后处理、逐组和逐 segment 回调,甚至订单修改回调,可以深度定制模拟行为。

DCA in $100 every month until 2x in profit, take out initial investment, and DCA out
DCAMode = namedtuple("DCAMode", ["In", "Out"])(0, 1)  

@njit
def pre_group_func_nb(ctx):  
    total_deposited = np.full(1, 0.0)
    dca_mode = np.full(1, DCAMode.In)
    return (total_deposited, dca_mode)

@njit
def pre_segment_func_nb(ctx, total_deposited, dca_mode, cash_deposits, dca_amount):  
    if ctx.i == 0 or vbt.dt_nb.month_nb(ctx.index[ctx.i - 1]) != vbt.dt_nb.month_nb(ctx.index[ctx.i]):
        dca_amount_now = vbt.pf_nb.select_from_group_nb(ctx, ctx.group, dca_amount)
        if dca_mode[0] == DCAMode.In and ctx.track_cash_deposits:
            cash_deposits[ctx.i, ctx.group] = dca_amount_now
            total_deposited[0] += dca_amount_now
    else:
        dca_amount_now = 0.0
    return (total_deposited, dca_mode, dca_amount_now)

@njit
def signal_func_nb(ctx, total_deposited, dca_mode, dca_amount_now, size):  
    if dca_amount_now > 0:
        size[ctx.i, ctx.col] = dca_amount_now
        if dca_mode[0] == DCAMode.In:
            return True, False, False, False
        return False, True, False, False
    return False, False, False, False

@njit
def post_order_func_nb(ctx, total_deposited, dca_mode, dca_amount_now):  
    if dca_mode[0] == DCAMode.In:
        if vbt.pf_nb.order_increased_position_nb(ctx):
            tp_info = ctx.last_tp_info[ctx.col]
            tp_info["stop"] = 1.0
            tp_info["init_price"] = ctx.last_pos_info[ctx.col]["entry_price"]
            tp_info["exit_size"] = total_deposited[0]
            tp_info["exit_size_type"] = vbt.pf_enums.SizeType.Value
        elif vbt.pf_nb.get_last_order_nb(ctx)["stop_type"] == vbt.pf_enums.StopType.TP:
            dca_mode[0] = DCAMode.Out

pf = vbt.PF.from_signals(
    vbt.YFData.pull("AAPL", start="2018"),
    pre_group_func_nb=pre_group_func_nb,
    pre_segment_func_nb=pre_segment_func_nb,
    pre_segment_args=(
        vbt.Rep("cash_deposits"),
        vbt.Rep("dca_amount")
    ),
    signal_func_nb=signal_func_nb,
    signal_args=(
        vbt.Rep("size"),
    ),
    post_order_func_nb=post_order_func_nb,
    broadcast_named_args=dict(dca_amount=100),
    arg_config=dict(
        cash_deposits=dict(full_shape=True),  
        size=dict(full_shape=True)
    ),
    accumulate=True,
    size_type="value",
    cash_sharing=True
)
pf.plot_orders().show()
AAPL OHLC 与月度定投订单,收回初始投资后改为定期卖出 图表数据(JSON)

文档

会员专享的回调指南(英文)介绍 from_signals 接受的全部回调。

原地输出

✅ 投资组合现在可接受并返回在模拟期间填充的任意用户数组,例如信号。借助模板和可广播命名参数,原地输出数组可与普通数组一起广播。此外,VBT 会半自动确定各数组正确的封装与索引方式,例如从整个组合中选择一列时。

Track the debt of a random portfolio during the simulation
data = vbt.YFData.pull(["BTC-USD", "ETH-USD"], missing_index="drop")
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):
        col_debt = ctx.last_debt[col]
        ctx.in_outputs.debt[ctx.i, col] = col_debt
        if col_debt > ctx.in_outputs.max_debt[col]:
            ctx.in_outputs.max_debt[col] = col_debt

pf = vbt.PF.from_def_order_func(
    data.close,
    size=size,
    post_segment_func_nb=post_segment_func_nb,
    in_outputs=dict(
        debt=vbt.RepEval("np.empty_like(close)"),
        max_debt=vbt.RepEval("np.full(close.shape[1], 0.)")
    )  
)
pf.get_in_output("debt")  
symbol                       BTC-USD    ETH-USD
Date
2017-11-09 00:00:00+00:00   0.000000   0.000000
2017-11-10 00:00:00+00:00   0.000000   0.000000
2017-11-11 00:00:00+00:00   0.000000   0.000000
2017-11-12 00:00:00+00:00   0.000000   0.000000
2017-11-13 00:00:00+00:00   0.000000   0.000000
...                              ...        ...
2023-02-08 00:00:00+00:00  43.746892  25.054571
2023-02-09 00:00:00+00:00  43.746892  25.054571
2023-02-10 00:00:00+00:00  43.746892  25.054571
2023-02-11 00:00:00+00:00  43.746892  25.054571
2023-02-12 00:00:00+00:00  43.746892  25.054571

[1922 rows x 2 columns]
pf.get_in_output("max_debt")  
symbol
BTC-USD    75.890464
ETH-USD    25.926328
Name: max_debt, dtype: float64

信号回调

✅ 想定制信号模拟,甚至根据当前回测环境动态生成信号?两个新回调带来更强灵活性:一个逐根逐资产生成或覆盖信号,另一个在每根结束时为整个组计算自定义度量。两者都接受包含当前模拟状态的“上下文”,使交易决策可类似事件驱动回测器那样进行。

Backtest SMA crossover iteratively
InOutputs = namedtuple("InOutputs", ["fast_sma", "slow_sma"])

def initialize_in_outputs(target_shape):
    return InOutputs(
        fast_sma=np.full(target_shape, np.nan),
        slow_sma=np.full(target_shape, np.nan)
    )

@njit
def signal_func_nb(ctx, fast_window, slow_window):
    fast_sma = ctx.in_outputs.fast_sma
    slow_sma = ctx.in_outputs.slow_sma
    fast_start_i = ctx.i - fast_window + 1
    slow_start_i = ctx.i - slow_window + 1
    if fast_start_i >= 0 and slow_start_i >= 0:
        fast_sma[ctx.i, ctx.col] = np.nanmean(ctx.close[fast_start_i : ctx.i + 1])
        slow_sma[ctx.i, ctx.col] = np.nanmean(ctx.close[slow_start_i : ctx.i + 1])
        is_entry = vbt.pf_nb.iter_crossed_above_nb(ctx, fast_sma, slow_sma)
        is_exit = vbt.pf_nb.iter_crossed_below_nb(ctx, fast_sma, slow_sma)
        return is_entry, is_exit, False, False
    return False, False, False, False

pf = vbt.PF.from_signals(
    vbt.YFData.pull("BTC-USD"),
    signal_func_nb=signal_func_nb,
    signal_args=(50, 200),
    in_outputs=vbt.RepFunc(initialize_in_outputs),
)
fig = pf.get_in_output("fast_sma").vbt.plot()
pf.get_in_output("slow_sma").vbt.plot(fig=fig)
pf.orders.plot(plot_ohlc=False, plot_close=False, fig=fig)
fig.show()
BTC-USD 迭代式 50 日与 200 日 SMA 交叉及买卖订单 图表数据(JSON)

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

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