文档
基础概念
理解 VBT 的向量化架构与核心概念
VBT 旨在解决许多回测库中常见的性能问题。它的核心理念是:每个交易策略实例都可以用向量化形式表示。这样,多个策略实例便可合并为一个多维数组,从而实现高效处理和直观分析。
技术栈
交易数据以时间序列为基础,因此回测的大多数环节都可以表示为数组。VBT 尤其依赖 NumPy 数组,它们得益于经过优化、预先编译的 C 代码,速度极快。在活跃的 Python 生态中,许多科学计算包都支持 NumPy 数组,包括 Pandas、NumPy 和 Numba。您很可能已经使用过其中一些包!
虽然 NumPy 性能出色,但它并不总是时间序列分析中最直观的工具。看看下面这个使用 NumPy 计算移动平均的例子:
from vectorbtpro import *
def rolling_window(a, window):
shape = a.shape[:-1] + (a.shape[-1] - window + 1, window)
strides = a.strides + (a.strides[-1],)
return np.lib.stride_tricks.as_strided(a, shape=shape, strides=strides)
np.mean(rolling_window(np.arange(10), 3), axis=1)array([1., 2., 3., 4., 5., 6., 7., 8.])这种方法虽然很快,但理解其原理需要一些时间,正确编写这类向量化代码也需要经验。那么,更高级指标中使用的其他滚动函数呢?重采样、分组以及其他涉及日期和时间的操作呢?
这时就该 Pandas 出场了!Pandas 提供丰富的时间序列功能、数据对齐、兼容缺失值的统计运算、groupby、merge 和 join 方法,以及其他许多实用工具。它有两种主要数据结构:Series(一维)和 DataFrame(二维)。您可以将它们视为附带时间戳、列名等有用信息的 NumPy 数组。前面的移动平均只需一行就能写出:
index = vbt.date_range("2020-01-01", periods=10)
sr = pd.Series(range(len(index)), index=index)
sr.rolling(3).mean()2020-01-01 NaN
2020-01-02 NaN
2020-01-03 1.0
2020-01-04 2.0
2020-01-05 3.0
2020-01-06 4.0
2020-01-07 5.0
2020-01-08 6.0
2020-01-09 7.0
2020-01-10 8.0
Freq: D, dtype: float64VBT 非常依赖 Pandas,但使用方式可能与您想象的不同。对我们而言,Pandas 有一个主要局限:处理大型数据集和用户自定义函数时,速度会变慢。滚动平均等许多内置函数底层使用 Cython,速度足够快。但当您尝试实现更复杂的函数,例如涉及多条时间序列的滚动排名指标时,情况就会变得复杂且缓慢。另外,无法向量化的函数怎么办?例如,包含资金管理的投资组合策略无法直接通过向量计算模拟。在这些情况下,我们需要编写快速的迭代代码,逐元素处理数据。
如果有一个 Python 包,让您能以机器码速度运行 for 循环呢?而且它与 NumPy 无缝配合,无需大幅修改 Python 代码?这将解决许多问题:代码会变得非常快,同时依然易读。这个包就是 Numba。Numba 将一部分 Python 和 NumPy 代码转换为高效的机器码。
@njit
def moving_average_nb(a, window_len):
b = np.empty_like(a, dtype=float_)
for i in range(len(a)):
window_start = max(0, i + 1 - window_len)
window_end = i + 1
if window_end - window_start < window_len:
b[i] = np.nan
else:
b[i] = np.mean(a[window_start:window_end])
return b
moving_average_nb(np.arange(10), 3)array([nan, nan, 1., 2., 3., 4., 5., 6., 7., 8.])现在,我们可以清楚地看到程序在做什么:逐个时间戳遍历时间序列,检查窗口内的数据是否足够,如果足够,就计算均值。Numba 不仅能帮助我们编写更易读、更不易出错的代码,速度还可媲美 C!
big_a = np.arange(1000000)
%timeit moving_average_nb.py_func(big_a, 10) 6.54 s ± 142 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)%timeit np.mean(rolling_window(big_a, 10), axis=1) 24.7 ms ± 173 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)%timeit pd.Series(big_a).rolling(10).mean() 10.2 ms ± 309 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)%timeit moving_average_nb(big_a, 10) 5.12 ms ± 7.21 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)那么,问题在哪里?遗憾的是,Numba 只理解 NumPy,不理解 Pandas。这意味着我们会失去日期时间索引和时间序列分析必需的其他功能。这正是 VBT 的用武之地:它用 Numba 重新实现了许多 Pandas 函数,甚至还添加了新功能。结果是,Pandas 的一部分功能不仅更快,也变得更强大!
其工作方式如下:
- 从 Pandas 对象中提取 NumPy 数组。
- 在该数组上运行由 Numba 编译的函数。
- 将结果重新封装为 Pandas 对象。
arr = sr.values
result = moving_average_nb(arr, 3)
new_sr = pd.Series(result, index=sr.index, name=sr.name)
new_sr2020-01-01 NaN
2020-01-02 NaN
2020-01-03 1.0
2020-01-04 2.0
2020-01-05 3.0
2020-01-06 4.0
2020-01-07 5.0
2020-01-08 6.0
2020-01-09 7.0
2020-01-10 8.0
Freq: D, dtype: float64或者,使用 VBT:
sr.vbt.rolling_mean(3)2020-01-01 NaN
2020-01-02 NaN
2020-01-03 1.0
2020-01-04 2.0
2020-01-05 3.0
2020-01-06 4.0
2020-01-07 5.0
2020-01-08 6.0
2020-01-09 7.0
2020-01-10 8.0
Freq: D, dtype: float64访问器
注意到 vbt 是如何直接附加在 Series
对象上的吗?这称为访问器,是一种无需创建子类即可扩展
Pandas 对象的便捷方式。通过访问器,您可以轻松切换原生 Pandas 和 VBT 功能。此外,每个 VBT 方法都能灵活接受输入,同时适用于 Series 和 DataFrame。
df = pd.DataFrame({'a': range(10), 'b': range(9, -1, -1)})
df.vbt.rolling_mean(3) a b
0 NaN NaN
1 NaN NaN
2 1.0 8.0
3 2.0 7.0
4 3.0 6.0
5 4.0 5.0
6 5.0 4.0
7 6.0 3.0
8 7.0 2.0
9 8.0 1.0您可以在 API 文档中进一步了解 VBT 的访问器。例如,rolling_mean 属于 GenericAccessor 访问器,可直接通过 vbt 访问。另一个常用访问器
ReturnsAccessor 用于处理收益率。它是 GenericAccessor 的子类,可通过 vbt.returns 访问。
ret = pd.Series([0.1, 0.2, -0.1])
ret.vbt.returns.total()0.18800000000000017每个访问器都要求数据已准备为可直接使用的格式。例如,处理收益率的访问器要求输入收益率,而不是价格!
多维性
请记住,VBT 与传统回测工具的区别,在于将交易数据视为多维数组。具体来说,VBT 将每一列视为独立的回测实例,而不是一个特征。来看一个简单的 OHLC DataFrame:
p1 = pd.DataFrame({
'open': [1, 2, 3, 4, 5],
'high': [2.5, 3.5, 4.5, 5.5, 6.5],
'low': [0.5, 1.5, 2.5, 3.5, 4.5],
'close': [2, 3, 4, 5, 6]
}, index=vbt.date_range("2020-01-01", periods=5))
p1 open high low close
2020-01-01 1 2.5 0.5 2
2020-01-02 2 3.5 1.5 3
2020-01-03 3 4.5 2.5 4
2020-01-04 4 5.5 3.5 5
2020-01-05 5 6.5 4.5 6这里的各列是不同的特征,描述同一个抽象对象:价格。将这个 DataFrame 直接传给 VBT 似乎很自然(类似于 scikit-learn 和其他要求以列表示特征的机器学习工具),但这种方式在回测中存在一些缺点:
- 不同长度或类型的特征难以用 NumPy 拼接。
- 可选特征无法恰当地表示为数组。
- 多个回测可以堆叠为三维数组(立方体),但 Pandas 无法正确处理。
- 不变的特征和常数需要转换为数组,并在所有回测中复制,造成内存浪费。
VBT 通过将各特征作为独立数组处理,来应对这种差异。因此,您无需传入一个大型 DataFrame,而是分别提供每个特征:
single_pf = vbt.Portfolio.from_holding(
open=p1['open'],
high=p1['high'],
low=p1['low'],
close=p1['close']
)
single_pf.value2020-01-01 100.0
2020-01-02 150.0
2020-01-03 200.0
2020-01-04 250.0
2020-01-05 300.0
Freq: D, dtype: float64现在,如果您想处理多个抽象对象,例如多个交易标的,只需将 Series 换为 DataFrame:
p2 = pd.DataFrame({
'open': [6, 5, 4, 3, 2],
'high': [6.5, 5.5, 4.5, 3.5, 2.5],
'low': [4.5, 3.5, 2.5, 1.5, 0.5],
'close': [5, 4, 3, 2, 1]
}, index=vbt.date_range("2020-01-01", periods=5))
p2 open high low close
2020-01-01 6 6.5 4.5 5
2020-01-02 5 5.5 3.5 4
2020-01-03 4 4.5 2.5 3
2020-01-04 3 3.5 1.5 2
2020-01-05 2 2.5 0.5 1multi_open = pd.DataFrame({
'p1': p1['open'],
'p2': p2['open']
})
multi_high = pd.DataFrame({
'p1': p1['high'],
'p2': p2['high']
})
multi_low = pd.DataFrame({
'p1': p1['low'],
'p2': p2['low']
})
multi_close = pd.DataFrame({
'p1': p1['close'],
'p2': p2['close']
})
multi_pf = vbt.Portfolio.from_holding(
open=multi_open,
high=multi_high,
low=multi_low,
close=multi_close
)
multi_pf.value p1 p2
2020-01-01 100.0 100.0
2020-01-02 150.0 80.0
2020-01-03 200.0 60.0
2020-01-04 250.0 40.0
2020-01-05 300.0 20.0这里,每个特征 DataFrame 中的每一列(VBT 中有时称为一条“线”)都表示独立的回测实例,并生成独立的权益曲线。因此,要增加一个回测,只需为各特征增加一列即可 ✨
将特征分开还有一个主要优势:便于组合。更进一步,我们可以通过向量化,一次性组合所有回测实例。例如,下面在前一根 K 线为上涨 K 线时设置入场信号,在前一根 K 线为下跌 K 线时设置离场信号(这是一个用于说明的基础示例):
candle_green = multi_close > multi_open
prev_candle_green = candle_green.vbt.signals.fshift(1)
prev_candle_green p1 p2
2020-01-01 False False
2020-01-02 True False
2020-01-03 True False
2020-01-04 True False
2020-01-05 True Falsecandle_red = multi_close < multi_open
prev_candle_red = candle_red.vbt.signals.fshift(1)
prev_candle_red p1 p2
2020-01-01 False False
2020-01-02 False True
2020-01-03 False True
2020-01-04 False True
2020-01-05 False TruePandas 对象 multi_close 和 multi_open 可以是任意形状的 Series 或 DataFrame,我们的这个小型流水线仍会按预期运行。
标签
在上面的例子中,我们创建多组 OHLC DataFrame 时使用了 p1 和 p2 两列,便于之后在分析阶段识别。为此,VBT 确保这些列会在整个回测流水线中保留,从信号生成一直到绩效建模。
但如果单个列表示更复杂的配置,例如涉及多种超参数组合,该怎么办?这时,将复杂对象直接存为列标签并不合适。幸运的是,Pandas 提供了分层列:它们类似普通列,但由多个层级叠加而成。层级中的每一层,都可以用于识别某个特定输入或参数。
以简单的均线交叉策略为例,它取决于快、慢窗口的长度。每个超参数都成为操作数据时的一个额外维度,并存为独立的列层级。下面是一个更复杂的例子,展示 MACD 指标的列层级结构:
macd = vbt.MACD.run(
multi_close,
fast_window=2,
slow_window=(3, 4),
signal_window=2,
macd_wtype="simple",
signal_wtype="weighted"
)
macd.signalmacd_fast_window 2 2 << fast window for MACD line
macd_slow_window 3 4 << slow window for MACD line
macd_signal_window 2 2 << window for signal line
macd_macd_wtype simple simple << window type for MACD line
macd_signal_wtype weighted weighted << window type for signal line
p1 p2 p1 p2 << price
2020-01-01 NaN NaN NaN NaN
2020-01-02 NaN NaN NaN NaN
2020-01-03 NaN NaN NaN NaN
2020-01-04 0.5 -0.5 NaN NaN
2020-01-05 0.5 -0.5 1.0 -1.0上面的列表示两种不同的回测配置,现在可以使用 Pandas 轻松分析和比较。这是一种强大的数据分析方式。例如,您可以按 macd_fast_window
对绩效分组,观察快窗口大小如何影响策略盈利能力。是不是很神奇?
广播
广播是 VBT 中最重要的概念之一。由于 VBT 函数将时间序列作为独立数组接受,它们需要知道如何连接不同数组中的元素,以便 1)信息完整,2)涵盖所有数组,且 3)覆盖每个时间步。
如果所有数组大小相同,VBT 很容易逐元素执行运算。如果某个数组更小,VBT 会尝试将其“拉伸”,以匹配其他数组的长度。这种方法深受 NumPy 广播的启发,内部也以此为基础。与 NumPy 的主要区别是,一维数组始终按行指定,因为我们主要处理时间序列数据。
为什么广播重要?因为它让您可以向 VBT 中几乎每个函数传入任意形状的类数组对象,无论是常数还是完整的 DataFrame,VBT 都会自动判断每个元素应处的位置。
part_arrays = dict(
close=pd.DataFrame({
'a': [1, 2, 3, 4],
'b': [4, 3, 2, 1]
}),
size=pd.Series([1, -1, 1, -1]),
direction=[['longonly', 'shortonly']],
fees=0.01
)
full_arrays = vbt.broadcast(part_arrays)
full_arrays['close'] a b
0 1 4
1 2 3
2 3 2
3 4 1full_arrays['size'] a b
0 1 1
1 -1 -1
2 1 1
3 -1 -1full_arrays['direction'] a b
0 longonly shortonly
1 longonly shortonly
2 longonly shortonly
3 longonly shortonlyfull_arrays['fees'] a b
0 0.01 0.01
1 0.01 0.01
2 0.01 0.01
3 0.01 0.01可以遵循以下经验法则:
- 只要有一个数组是 Pandas 对象,输出就始终是 Pandas 对象。
- 只要有一个数组是二维的,输出就始终是二维数组。
- 如果所有数组都是常数或一维数组,输出就始终是一维数组。
- 如果数组是一维 NumPy 数组或 Series,就始终按行指定。
- 列表和其他序列会在广播之前转换为 NumPy 数组。
与 NumPy 和 Pandas 不同,VBT 知道如何广播标签:如果两个对象中的列或某个列层级不同,就会将它们堆叠在一起。例如,您可以使用 (2, 3) 和 (3, 4) 这两组窗口组合,检查快均线何时高于慢均线。
fast_ma = vbt.MA.run(multi_close, window=[2, 3], short_name='fast')
slow_ma = vbt.MA.run(multi_close, window=[3, 4], short_name='slow')
fast_ma.mafast_window 2 2 3 3
p1 p2 p1 p2
2020-01-01 NaN NaN NaN NaN
2020-01-02 2.5 4.5 NaN NaN
2020-01-03 3.5 3.5 3.0 4.0
2020-01-04 4.5 2.5 4.0 3.0
2020-01-05 5.5 1.5 5.0 2.0slow_ma.maslow_window 3 3 4 4
p1 p2 p1 p2
2020-01-01 NaN NaN NaN NaN
2020-01-02 NaN NaN NaN NaN
2020-01-03 3.0 4.0 NaN NaN
2020-01-04 4.0 3.0 3.5 3.5
2020-01-05 5.0 2.0 4.5 2.5fast_ma.ma > slow_ma.ma ValueError: Can only compare identically-labeled DataFrame objectsfast_ma.ma.values > slow_ma.ma.values array([[False, False, False, False],
[False, False, False, False],
[ True, False, False, False],
[ True, False, True, False],
[ True, False, True, False]])fast_ma.ma.vbt > slow_ma.ma fast_window 2 2 3 3
slow_window 3 3 4 4
p1 p2 p1 p2
2020-01-01 False False False False
2020-01-02 False False False False
2020-01-03 True False False False
2020-01-04 True False True False
2020-01-05 True False True False在左侧的 Pandas 对象后加上 .vbt,即可通过 VBT 广播两个操作数,并通过 NumPy/Numba 执行运算。这样就能兼得强大功能与便捷体验 🧨
与 Pandas 不同,VBT 按行和列的绝对位置广播,而不是按标签。这种广播方式与 NumPy 非常相似:
df1 = pd.DataFrame({'a': [0], 'b': [1]})
df2 = pd.DataFrame({'b': [0], 'a': [1]})
df1 + df2 a b
0 1 1df1.values + df2.valuesarray([[0, 2]])df1.vbt + df2 a b
b a
0 0 2向 VBT 传入多个数据数组时,请确保它们的列在位置上一一对应!
如果列的顺序不正确,您会发现结果有多个列层级,标签相同但顺序不同。
VBT 的另一个特性,是能够广播形状不兼容、但多级索引层级存在重叠的对象,也就是共享相同名称或值的对象。接着上面的例子,我们来检查快均线何时高于价格:
fast_ma.ma > multi_close ValueError: Can only compare identically-labeled DataFrame objectsfast_ma.ma.values > multi_close.values ValueError: operands could not be broadcast together with shapes (5,4) (5,2)fast_ma.ma.vbt > multi_close fast_window 2 2 3 3
p1 p2 p1 p2
2020-01-01 False False False False
2020-01-02 False True False False
2020-01-03 False True False True
2020-01-04 False True False True
2020-01-05 False True False True还有更多(请继续看):您可以将多个类标量超参数作为 Pandas Index 传入,轻松进行测试。让我们看看价格是否位于某些阈值之间:
above_lower = multi_close.vbt > vbt.Param([1, 2], name='lower')
below_upper = multi_close.vbt < vbt.Param([3, 4], name='upper')
above_lower.vbt & below_upperlower 1 1 2 2
upper 3 3 4 4
p1 p2 p1 p2
2020-01-01 True False False False
2020-01-02 False False True False
2020-01-03 False False False True
2020-01-04 False True False False
2020-01-05 False False False False可以看到,在合并信息方面,智能广播堪称 💎。请参阅 broadcast,进一步了解广播原理和组合数组的新方法。
灵活索引
广播多个大型数组可能占用大量内存,最终拖慢处理速度。因此,VBT 引入了“灵活索引”:从任意形状的一维或二维数组中选取一个元素。例如,如果一维数组只有一个元素,却需要广播到 1000 行,无论查询哪一行,VBT 都会返回这一个元素,因为该数组可以广播到任意形状:
a = np.array([1])
vbt.flex_select_1d_nb(a, 0)1vbt.flex_select_1d_nb(a, 1)1vbt.flex_select_1d_nb(a, 2)1这等价于:
full_a = np.broadcast_to(a, (1000,))
full_a[2]1二维数组提供更大的灵活性。假设您需要处理 1000 列,并有几个参数应用于每个元素。有些参数可能是所有元素共享的标量,有些可能是在每列重复的一维数组,还有些可能在每行相同。无需完整广播这些数组,您只需保留原有元素数量,并按需扩展为二维,使其能够通过 NumPy 正确广播:
a = np.array([[0]])
b = np.array([[1, 2, 3]])
c = np.array([[4], [5], [6]])
vbt.flex_select_nb(a, 2, 1) 0vbt.flex_select_nb(b, 2, 1)2vbt.flex_select_nb(c, 2, 1)6这种方法的一个优点是,几乎不会增加额外的内存开销,而且无论目标形状多大,都可以沿任意方向广播。这也是 Portfolio.from_signals 能够广播 50
多个参数,同时保持内存效率和性能的关键之一 😉
版权所有 © 2021–2026 Oleg Polakow。保留所有权利。
本站内容与文档供使用和评估 VectorBT PRO 以及教育用途。任何其他用途,包括开发或支持竞争产品或服务,均须事先取得书面同意。