功能
事件驱动回测
编写用于冷却期、持仓限制和自定义模拟器的编译回调
某些交易规则依赖投资组合中已发生的事件:止损后冷却、未平仓数量上限、每日亏损限制。VBT 通过在模拟循环内部运行的编译回调,支持 Python 事件驱动回测。回调逐根查看现金、持仓和成交,同时保持 Numba 速度。
@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.897484pf[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 None5% 止损在 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 资产池限制为最多三个同时持仓:
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% 后开盘买入,当天收盘卖出:
@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 公开自身模拟器使用的执行原语,因此结果仍是具备所有度量和图表的普通投资组合:
@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 回调,甚至订单修改回调,可以深度定制模拟行为。
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()文档
会员专享的回调指南(英文)介绍 from_signals
接受的全部回调。
✅ 投资组合现在可接受并返回在模拟期间填充的任意用户数组,例如信号。借助模板和可广播命名参数,原地输出数组可与普通数组一起广播。此外,VBT 会半自动确定各数组正确的封装与索引方式,例如从整个组合中选择一列时。
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✅ 想定制信号模拟,甚至根据当前回测环境动态生成信号?两个新回调带来更强灵活性:一个逐根逐资产生成或覆盖信号,另一个在每根结束时为整个组计算自定义度量。两者都接受包含当前模拟状态的“上下文”,使交易决策可类似事件驱动回测器那样进行。
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()版权所有 © 2021–2026 Oleg Polakow。保留所有权利。
本站内容与文档供使用和评估 VectorBT PRO 以及教育用途。任何其他用途,包括开发或支持竞争产品或服务,均须事先取得书面同意。