文档

基础概念

理解 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: float64

VBT 非常依赖 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)

如果您好奇 VBT 如何使用 Numba,可以查找名为 nb 的目录或文件。这个子包包含所有基础函数,而这个模块则处理一些高级主题(⚠️ 仅限高手)。

那么,问题在哪里?遗憾的是,Numba 只理解 NumPy,不理解 Pandas。这意味着我们会失去日期时间索引和时间序列分析必需的其他功能。这正是 VBT 的用武之地:它用 Numba 重新实现了许多 Pandas 函数,甚至还添加了新功能。结果是,Pandas 的一部分功能不仅更快,也变得更强大!

其工作方式如下:

  1. 从 Pandas 对象中提取 NumPy 数组。
  2. 在该数组上运行由 Numba 编译的函数。
  3. 将结果重新封装为 Pandas 对象。
arr = sr.values
result = moving_average_nb(arr, 3)
new_sr = pd.Series(result, index=sr.index, name=sr.name)
new_sr
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:

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 和其他要求以列表示特征的机器学习工具),但这种方式在回测中存在一些缺点:

  1. 不同长度或类型的特征难以用 NumPy 拼接。
  2. 可选特征无法恰当地表示为数组。
  3. 多个回测可以堆叠为三维数组(立方体),但 Pandas 无法正确处理。
  4. 不变的特征和常数需要转换为数组,并在所有回测中复制,造成内存浪费。

VBT 通过将各特征作为独立数组处理,来应对这种差异。因此,您无需传入一个大型 DataFrame,而是分别提供每个特征:

single_pf = vbt.Portfolio.from_holding(
    open=p1['open'],
    high=p1['high'],
    low=p1['low'],
    close=p1['close']
)
single_pf.value
2020-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      1
multi_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  False
candle_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   True

Pandas 对象 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.signal
macd_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  1
full_arrays['size']
   a  b
0  1  1
1 -1 -1
2  1  1
3 -1 -1
full_arrays['direction']
          a          b
0  longonly  shortonly
1  longonly  shortonly
2  longonly  shortonly
3  longonly  shortonly
full_arrays['fees']
      a     b
0  0.01  0.01
1  0.01  0.01
2  0.01  0.01
3  0.01  0.01

可以遵循以下经验法则:

  1. 只要有一个数组是 Pandas 对象,输出就始终是 Pandas 对象。
  2. 只要有一个数组是二维的,输出就始终是二维数组。
  3. 如果所有数组都是常数或一维数组,输出就始终是一维数组。
  4. 如果数组是一维 NumPy 数组或 Series,就始终按行指定。
  5. 列表和其他序列会在广播之前转换为 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.ma
fast_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.0
slow_ma.ma
slow_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.5
fast_ma.ma > slow_ma.ma  
ValueError: Can only compare identically-labeled DataFrame objects
fast_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  1
df1.values + df2.values
array([[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 objects
fast_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_upper
lower           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)
1
vbt.flex_select_1d_nb(a, 1)
1
vbt.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)  
0
vbt.flex_select_nb(b, 2, 1)
2
vbt.flex_select_nb(c, 2, 1)
6

这种方法的一个优点是,几乎不会增加额外的内存开销,而且无论目标形状多大,都可以沿任意方向广播。这也是 Portfolio.from_signals 能够广播 50 多个参数,同时保持内存效率和性能的关键之一 😉

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

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