4. 向量化思维基础

在传统回测框架中,我们习惯用 for 循环逐根 K 线推进:判断信号、计算仓位、更新资金曲线。这种写法直观,但在参数扫描场景下会成为性能瓶颈——当你要测试 10 个参数组合、每个组合跑 5000 根 K 线时,循环次数会迅速膨胀到数千万次。vectorbt 的核心价值,正是把这种"逐行推进"改造成"整段数组同时运算"。理解向量化思维,是真正用好 vectorbt 的前提。

一、核心原理:从"时间步"到"整条序列"

循环回测的本质是状态依赖:当前资金取决于上一根 K 线的资金,因此必须按时间顺序一步步算。向量化的突破口在于:很多回测逻辑其实可以拆成"信号生成"和"收益累加"两个阶段,前者只依赖价格序列,后者可以用累积乘积或累积求和一次性完成。

举个最简单的例子。循环写法如下:

equity = [10000]
for i in range(1, len(close)):
    ret = close[i] / close[i-1] - 1
    equity.append(equity[-1] * (1 + ret * position[i]))

向量化写法只需一行:

ret = close.pct_change().fillna(0)
equity = 10000 * (1 + ret * position).cumprod()

关键差异在于:循环把"上一期结果"作为输入,而向量化把整条 ret * position 序列先算出来,再用 cumprod() 一次性完成累积。这就是向量化思维的第一层——把递推关系转化为累积运算。

二、广播机制:让不同形状的数组自动对齐

广播(broadcasting)是 NumPy 和 pandas 的底层能力,也是 vectorbt 参数扫描的基石。它的规则是:当两个数组形状不同但满足"从右向左逐维兼容"时,较小数组会自动扩展以匹配较大数组。

假设你有 3 个参数组合的均线周期 [5, 10, 20],想对同一条价格序列分别计算均线。循环写法要跑三次,而广播写法是:

windows = np.array([5, 10, 20])[:, None]   # 形状 (3, 1)
prices = close.values[None, :]              # 形状 (1, N)
ma = pd.DataFrame(prices).rolling(...)      # 实际中常用 rolling 逐列

更典型的广播场景是"信号矩阵 × 收益向量"。当你用 vbt.MA.run(close, window=[5,10,20]) 时,vectorbt 内部会生成形状为 (3, N) 的均线矩阵,再与形状为 (N,) 的收益序列做比较,广播机制自动把收益复制到每一行。理解这一点,你就能预判内存占用:参数组合数 × K 线数,就是矩阵规模。

广播不会真正复制数据,它只是逻辑上扩展。但一旦触发实际运算(如矩阵乘法),内存仍会按扩展后的形状分配。参数扫描前先估算 组合数 × 数据长度 × 8 字节,避免内存溢出。

三、布尔索引:用条件数组替代 if 判断

循环回测里最常见的 if signal == 1: buy(),在向量化中变成布尔索引。布尔索引的本质是:用一个同形状的 True/False 数组去"筛选"另一个数组的元素。

signal = ma_fast > ma_slow          # 布尔数组
entries = signal & ~signal.shift(1) # 金叉:今天 True 昨天 False
exits = ~signal & signal.shift(1)   # 死叉

这里 &、~、| 是逐元素逻辑运算,配合 shift() 就能表达"状态变化"这类时序逻辑,完全不需要循环。vectorbt 的 Portfolio.from_signals() 接收的正是这样的布尔数组(或入场/出场价格数组),内部再统一处理仓位与资金。

下表对比了循环思维与向量化思维的常见映射关系:

循环写法向量化写法说明
for i: if sig[i]: ...entries = sig布尔索引直接筛选
equity[-1] * (1+ret)(1+ret).cumprod()递推转累积
逐参数 for 循环参数数组 + 广播一次算完所有组合
if cross_up:sig & ~sig.shift(1)用 shift 表达时序变化
手动累计最大回撤(equity/equity.cummax()-1).min()累积极值运算

四、案例:双均线参数扫描的向量化改造

假设我们要扫描 fast 周期 [5,10,15] 与 slow 周期 [20,30,40],共 9 个组合。循环思路是双重 for 嵌套,每个组合单独回测。向量化思路则分三步:

  1. 生成参数网格:用 np.meshgrid 或列表推导得到 9 组 (fast, slow)。
  2. 批量计算指标:vbt.MA.run(close, window=[5,10,15]) 得到形状 (3, N) 的矩阵。
  3. 批量回测:把 fast/slow 矩阵按组合配对,交给 Portfolio.from_signals 一次性得到 9 条资金曲线。
fast_ma = vbt.MA.run(close, window=[5,10,15])
slow_ma = vbt.MA.run(close, window=[20,30,40])
entries = fast_ma.ma_crossed_above(slow_ma)
exits   = fast_ma.ma_crossed_below(slow_ma)
pf = vbt.Portfolio.from_signals(close, entries, exits)
pf.total_return()   # 返回 9 个组合的收益

注意 ma_crossed_above 会自动处理不同 window 组合的广播,最终 entries 的形状是 (3, 3, N)——前两维对应参数组合。这就是 vectorbt 把"参数扫描"变成"数组维度扩展"的典型手法。

五、误区与复盘:向量化不是万能药

向量化最大的陷阱是路径依赖逻辑。如果策略包含"止损后当天不再入场""仓位随浮盈动态调整"这类依赖中间状态的规则,纯向量化会非常困难甚至无法表达。此时应优先用 vectorbt 的 from_signals 内置止损/止盈参数,或退回到 from_order_func 的逐行回调。

常见误区还包括:

  • 忽视 NaN 传播:均线前 N 根是 NaN,布尔比较后仍是 False,但若直接参与乘法会污染整条曲线,需 fillna(0)。
  • 混淆 shift 方向:shift(1) 是把昨天移到今天,用于判断"状态变化";写反会导致未来函数。
  • 内存爆炸:参数组合过多时,(组合数, N) 矩阵可能达到 GB 级,应分批扫描或使用 vbt.Portfolio.from_signals 的 chunk 机制。
  • 过度追求一行代码:可读性同样重要,必要时拆成多个中间变量,便于调试和复盘。

复盘时建议养成一个习惯:每写一段循环逻辑,先问自己"这段逻辑依赖上一期的结果吗?"如果不依赖,就一定能向量化;如果依赖,就考虑用 cumprod、cummax、shift、where 等累积或条件函数改写。向量化思维的本质,是把"时间上的递推"翻译成"数组上的整体运算",这正是 vectorbt 高性能参数扫描的根基。