在讲怎么用 vectorbt 做“基于信号的定制策略”之前,我先说我自己的判断:如果你还在用 for 循环写回测,大概率会被数据量、调参次数和迭代速度卡死。vectorbt 把整个回测流程向量化之后,信号策略从“写一段能跑的代码”变成了“搭一套能反复拆解的流水线”,尤其适合做多参数组合扫描、信号热力图分析和策略健壮性验证。这篇文章就围绕“基于信号”这个核心,把我实际用 vectorbt 搭建定制策略的过程、设计和踩坑记录完整梳理一遍,代码部分也是我可跑的版本,你可以直接复现。
1. 在讲策略之前,先搞清楚vectorbt解决什么问题
1.1 为什么我选vectorbt做信号回测
先说个背景。我做量化回测大概有四五年,早期用 pandas 手写循环事件撮合,后来换到 backtrader,再后来转到 vectorbt,这里面的转折点其实是“痛点驱动的”。
- backtrader 逻辑清晰,但单次回测速度在面对 3000 只股票、5 年 tick 级数据时非常难受,跑一次要几分钟,做 1000 组参数组合基本要通宵。
- 手写 pandas 循环的问题在于撮合逻辑极易出错,尤其容易引入未来函数,而且在多标的、多周期扩展时代码会越写越乱。
- vectorbt 的核心算子是 numpy 向量化,Portfolio 模块底层用 numba 编译加速,同样的回测任务,秒级完成是常态,跑上千组参数也就十几秒的事。
如果只是“能跑回测”,那 vectorbt 的优势还不算致命。它真正的价值在于把所有回测步骤都抽象成了数组和矩阵运算:价格是数组、信号是布尔数组、持仓是数组,收益是数组,于是你可以直接用 numpy 的逻辑去组合、切片、扫描。这对“信号策略”特别友好,因为信号策略本质上就是在处理一组布尔条件:什么时候进场、什么时候离场、什么时候做空,组合方式天然适合用向量化表达。
1.2 信号驱动策略的核心逻辑
所谓“基于信号的定制策略”,我理解的核心就一句话:把交易逻辑拆成“信号生成层”和“信号执行层”。
信号生成层解决的是“什么时候有交易想法”,比如均线金叉、RSI 超卖、布林带突破、MACD 零轴穿越,甚至是你自己写的稀奇古怪的条件。信号执行层解决的是“拿到信号之后怎么下单”,比如进场后多少根 K 线内执行、止损是什么逻辑、仓位怎么分配、手续费怎么扣。
很多新手做策略最容易犯的错,是把交易逻辑全塞在信号生成函数里,比如写了金叉就立刻买,条件一复杂就各种 if/else。实际上,更好的设计是:信号只是信号,执行逻辑单独管,两者解耦之后,调优才能模块化。vectorbt 的 from_signals 接口设计逻辑和这个思路高度一致,它接收 entries 和 exits 两个布尔数组,其余的交易规则通过 Portfolio 的参数来控制。这样,你可以任意更换信号函数,而执行层的资金管理、止损止盈逻辑完全不用动。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 基于信号的策略设计:从想法到买卖条件
2.1 把“信号”拆成进、出、做空三个独立事件
实际写策略时,我习惯把所有信号拆成三个布尔序列:买入信号、卖出信号、做空信号(如果有做空需求)。这样拆的好处是,后续不管是用 vectorbt 还是用其他框架,信号都能直接映射成参数,不需要额外写复杂的转换逻辑。
以我自己的一个案例来说明:我在做螺纹钢期货的日内策略时,核心逻辑是“长周期趋势方向过滤 + 短周期动量入场”,信号拆出来是这样的:
- 入场信号:短周期动量突破布林带中轨,且长周期均线斜率向上。
- 离场信号:短周期动量跌破前一个入场K线的最低价,也就是跟踪止损。
- 反向信号:如果长周期趋势转为向下,则允许做空入场。
拆成三个数组之后,vectorbt 的用法就变得非常直接了:
python复制import vectorbt as vbt
import pandas as pd
import numpy as np
price = pd.read_csv("rb_main_5min.csv", index_col=0, parse_dates=True)["close"]
fast_ma = price.rolling(20).mean()
slow_ma = price.rolling(60).mean()
# 简单示例:快线上穿慢线为买入信号,下穿为卖出信号
entries = (fast_ma > slow_ma) & (fast_ma.shift(1) <= slow_ma.shift(1))
exits = (fast_ma < slow_ma) & (fast_ma.shift(1) >= slow_ma.shift(1))
# 做空信号单独列出来,后续和 entries 合并
short_entries = (fast_ma < slow_ma) & (fast_ma.shift(1) >= slow_ma.shift(1))
short_exits = (fast_ma > slow_ma) & (fast_ma.shift(1) <= slow_ma.shift(1))
这里有个细节需要注意:信号序列的索引必须和价格完全对齐,否则 vectorbt 在内部对齐时会静默产生错位。我在二三节会详细聊索引错位的问题,这里先提个醒。
2.2 定制信号组合:多源叠加与去冲突
实际策略很少只用单一信号源,比如“只靠金叉”赚钱的时代早就过去了。我现在更倾向于把多个信号源做“叠加组合”:趋势过滤信号负责判断方向,动量信号负责捕捉入场点,波动率信号负责控制仓位。
但在叠加多个信号时,会遇到第一个经典问题:信号冲突。比如趋势过滤器说做多,但动量信号给了做空信号,这时候怎么办?我的处理原则是:方向过滤器的优先级高于入场触发器。具体做法是在组合阶段用 and 操作做方向强校验:
python复制trend_long = slow_ma > slow_ma.shift(20)
momentum_signal = price > price.rolling(10).max().shift(1)
entries = trend_long & momentum_signal
这样设计的好处是逻辑上不会产生多空矛盾,避免了信号混叠失真问题。所谓信号混叠失真,放在交易里讲就是:多个指标各自给出的方向性判断互相抵消,最终生成的信号看起来混乱不堪。如果你有做技术的背景,这个可以类比信号处理里的采样混叠:采样频率不够、信号叠加太多,原始特征就被掩盖了,输出自然不稳定。
另一个常见问题是“顶底信号指标源码”这类东西。说句实在话,我见过很多把自己的指标包装成“98%胜率”的所谓顶底信号源码,实际上大多是针对历史行情做过拟合的骗局。真正拿来用的信号,应该逻辑透明、参数可解释、且能在样本外保持一定稳定性。所以在定制策略阶段,与其找一个“神奇指标”,不如把信号本身的逻辑想清楚。
2.3 一套实用的信号生成示例代码
我一次写一套相对完整的信号生成代码,用双手指出清楚实际怎么落地。假设数据是日线级别的 BTC-USDT 永续合约,信号逻辑为:
- 趋势过滤:50 日均线高于 200 日均线做多,反之做空。
- 入场触发:价格向上突破 20 日最高价时做多;向下突破 20 日最低价时做空。
- 离场:移动止损用 Chandelier Exit(基于 ATR)。
代码:
python复制import vectorbt as vbt
import pandas as pd
import numpy as np
df = pd.read_csv("btc_usdt_daily.csv", index_col=0, parse_dates=True)
close = df["close"]
# 趋势过滤
ma_fast = close.rolling(50).mean()
ma_slow = close.rolling(200).mean()
trend_long = ma_fast > ma_slow
trend_short = ~trend_long
# 唐奇安通道突破
donchian_high = close.rolling(20).max().shift(1)
donchian_low = close.rolling(20).min().shift(1)
entry_long = (close > donchian_high) & trend_long
entry_short = (close < donchian_low) & trend_short
# Chandelier Exit 作为离场信号
atr = vbt.IndicatorFactory.run(
lambda x: x,
lambda x, period: x.rolling(period).apply(...),
...
).run(close, period=14)
# 简化离场:跌破 10 日均线
exit_long = close < close.rolling(10).mean()
exit_short = close > close.rolling(10).mean()
pf = vbt.Portfolio.from_signals(
close,
entries=entry_long,
exits=exit_long,
short_entries=entry_short,
short_exits=exit_short,
fees=0.001,
slippage=0.001,
freq="1D",
)
实际情况下,Chandelier Exit 计算比较复杂,我通常单独拆函数出来,这里用 10 日均线代替是为了保持示例简洁。你要完整代码我可以放到代码仓库。这一段的核心是展示“信号拆解+条件组合”的写法。
3. 信号策略在vectorbt中的完整落地步骤
3.1 用from_signals搭建主回测框架
from_signals 是 vectorbt 的 Portfolio 类里最常用的构造方法之一。它接收你生成好的 entries、exits 布尔序列,内部分别完成持仓构建、撮合、手续费扣减、收益率计算。
一个完整回测框架,我会至少包含以下参数:
| 参数名 | 作用 | 我常用的设置 |
|---|---|---|
entries |
多头入场信号 | 布尔数组 |
exits |
多头离场信号 | 布尔数组 |
short_entries |
空头入场信号 | 布尔数组或 None |
short_exits |
空头离场信号 | 布尔数组或 None |
fees |
手续费率 | 根据交易所/券商定,一般万几到千几 |
slippage |
滑点 | 期货设 0.02%,币圈设 0.05%~0.1% |
freq |
K线频率字符串 | 用于计算年化收益等指标 |
init_cash |
初始资金 | 默认 10000 |
size |
单笔交易数量 | 可以是固定值、百分比或按资金管理公式动态计算 |
一个值得注意的点:size 参数如果指定为 100,表示每次入场固定买入 100 股/张/币;如果指定为 0.1,配合 size_type="percent",表示每次用 10% 资金入场。后者在组合策略里更常用。
关于滑点,我建议实盘回测一定不要设成 0,币圈尤甚。你哪怕用限价单,市价冲击、盘口深度不够都会带来实际成交滑点,回测里不设滑点,结果往往偏乐观,参数优化出来的“最优参数”到实盘大概率拉胯。
运行完回测之后,核心结果都挂在 pf 上,比如:
python复制print(pf.stats())
这会输出从总收益率、年化收益率、夏普比率、最大回撤到交易次数、胜率、盈亏比等一整套指标。这些指标在信号策略的调参阶段就是你的标尺。
3.2 用参数扫描和信号热力图找最优组合
信号策略定制经常要面对一个问题:参数选多少。比如均线是 20/60 还是 10/30,唐奇安通道是 20 周期还是 55 周期。
在多参数组合的可能性下,逐一手动回测是低效的。vectorbt 原生支持参数扫描,写法简单到超乎你想象,核心就是将参数变成数组,然后调用 vbt.Portfolio.from_signals 时传一个 param_product=True,就会自动做笛卡尔积扫描。
python复制import vectorbt as vbt
import numpy as np
import pandas as pd
close = pd.read_csv("btc_usdt_daily.csv", index_col=0, parse_dates=True)["close"]
fast_windows = np.arange(10, 50, 5)
slow_windows = np.arange(50, 200, 20)
fast_ma = vbt.MA.run(close, window=fast_windows)
slow_ma = vbt.MA.run(close, window=slow_windows)
entries = fast_ma.ma_crossed_above(slow_ma)
exits = fast_ma.ma_crossed_below(slow_ma)
pf = vbt.Portfolio.from_signals(
close,
entries=entries,
exits=exits,
fees=0.001,
freq="1D",
param_product=True,
)
扫描完成之后,我最喜欢做的事情就是画“信号热力图”。把每组参数的收益率或夏普比率,按照两个参数维度做成热力图,一眼就能看出哪些区域是“稳健的高收益区”,哪些区域只是“孤峰”。
python复制import plotly.graph_objects as go
heatmap_data = pf.total_return().unstack()
fig = go.Figure(data=go.Heatmap(
z=heatmap_data.values,
x=heatmap_data.columns,
y=heatmap_data.index,
colorscale="RdYlGn",
zmid=0,
))
fig.show()
热力图的实际价值不是让你去挑那个颜色最红的小格子,而是观察红色区域的整体形态。如果最优参数孤零零地出现在某个角落,周围全是绿色,这种参数大概率是过拟合产物。如果是大片红色连贯区域,说明策略对参数不敏感,更鲁棒。这个观察习惯是我踩过多次坑之后才养成的。
参数扫描时还有一个隐藏问题:不同参数组合下交易次数差异巨大,单纯看总收益率会有幸存者偏差。比如某个参数只交易了 2 次,收益率 50%,另一个参数交易 500 次,收益率 30%,前者不能简单认为更优。所以我在看热力图时,一般同时扫描 total_trades 或 trade_count,把交易次数低于某个阈值的区域在热力图上标白或直接剔除。
3.3 实盘前必须加的信号防护层
信号策略从回测走向实盘,中间还隔着很多坑,其中最关键的是信号防护层。这里的“防护”主要指以下几件事:
第一,去抖和消抖。真实行情里,价格经常在关键点位附近反复穿越,信号会被频繁触发。比如价格刚上穿唐奇安通道,下一根K线又跌破,然后又上穿,这种抖动会让交易成本高到离谱。我一般在信号生成后加“最小持仓K线数”约束:比如入场后至少持有 3 根K线才允许离场,或者在离场信号触发后等待一根K线确认。
第二,信号偏移修正。使用 shift(1) 是标准动作,因为在当前K线收盘时计算的信号,最早只能用于下一根K线开盘,否则就是前视偏差。这个我在第 4 节会展开。
第三,极端行情熔断。如果单根K线波动率超过历史 99.5% 分位,我会强制且立即清仓。这种行情下正常信号会失效,甚至成为反向指标。用一个布尔数组做强制平仓逻辑,并把它接到 exits 上就行:
python复制vol = close.pct_change().rolling(20).std()
extreme = vol > vol.quantile(0.995)
exits = exits | extreme
这种“叠加离场条件”的方式非常灵活,也再次体现了信号拆解成独立布尔数组的好处:每个信号源独立生成、独立验证,最后再用逻辑运算组合。
4. 我踩过的坑:信号错位、前视偏差与性能问题
4.1 信号错位与时区索引问题
我第一次用 vectorbt 跑信号策略的时候,遇到过一个诡异现象:回测结果看起来很好,但交易记录里的入场价格和信号触发价格对不上。查了半天发现,问题出在索引对齐上。
我的 csv 数据里时间列是字符串格式,pandas 默认解析后带上了 UTC 时区,但行情数据的收盘时间其实是北京时间。信号生成是用北京时间,而 vectorbt 内部对齐用的索引是 UTC 时间,导致信号序列和价格序列错开了 8 小时(如果涉及夏令时,误差更恶心)。对日线数据来说,这根错位直接导致入场点的那根K线变成了前一根。
排查思路其实简单:
python复制print(close.index[:5])
print(entries.index[:5])
一旦发现两边索引不一致,第一时间检查时区。我现在的处理方法是在读取数据后统一 tz_localize(None),把时间索引统一成不带时区的本地时间。
另一个索引问题是去重。因为数据源偶尔会有重复时间戳,如果没有 drop_duplicates,信号计算时 rolling 函数会重复取值,最终生成的信号完全错误。这个坑特别隐蔽,因为 pandas 计算不报错,只有对比成交记录才会发现端倪。
4.2 前视偏差的隐藏来源
前视偏差是信号策略回测里最致命又最难发现的错误类型。很多人以为自己用了 shift(1) 就安全了,实际上前视偏差的来源远不止一种:
- 指标计算本身使用了未来数据。例如
rolling(20).max()默认包含当前K线,如果你打算在收盘后判断“今天是否突破”,那当前K线数据确实是可以拿到的,但如果你的实盘信号是在这段K线开盘时生成,就会用到当天不可得的收盘信息。我习惯把这类突破信号整体shift(1)。 - 使用了未来信息做止损。比如回测里用
exit_long = close < close.rolling(10).mean().shift(-1),这种负向 shift 就是直接把未来价格拉进来,资金曲线会完美得不像话,但实盘根本做不到。 - 多信号交叉时隐式错位。比如一个信号用了今天收盘数据,另一个信号用了明天开盘数据,两者在代码里看似对齐,实际已经引入不同时间语义。
我的排查办法是:每次修改信号逻辑后,都随机抽 5 笔交易,用“人工推演”的方式从原始K线数据里手算一遍入场、离场价格和回测结果是否一致。这个办法笨,但非常有效。另外,在对比不同信号逻辑时,务必用相同的执行参数,比如一样的滑点和手续费,否则对比的是撮合规则上的差异,而不是信号本身的差异。
4.3 内存与性能优化技巧
vectorbt 做非参数扫描时性能尚可,但一旦做 param_product=True 的大规模组合扫描,内存可能暴涨。我遇到过 200x200 参数组合,直接吃掉 16G 内存的案例。
几个亲测有效的优化思路:
- 尽量减小数据精度。把 float64 转成 float32,价格类数据用
astype(np.float32),这个操作在向量化计算里可以省近一半内存。 - 用
vbt.IndicatorFactory的run(..., column_wise=True)在单核上逐列跑不如并行快,但内存更可控。如果内存受限,可以在run中直接关闭参数展开,先算出信号,再分块回测。 - 分块回测比一次性大矩阵回测效率更高。比如把 3000 根K线分成三段,分别生成
Portfolio,再合并结果,性能损失很小,但内存峰值大幅下降。 - 如果不需要逐K线净值,可以设置
pf.total_return()叫批量计算时只保留聚合指标,不保留完整持仓曲线。Portfolio.from_signals有一个recalc参数,但更直接的是在构建时用cash_sharing=False减少持仓矩阵的列数。
我还习惯在参数扫描之前先用小样本数据(比如最近 500 根K线)跑一遍全参数组合,用来验证代码逻辑和大致参数范围。确认没有问题后,再用全量数据跑最终扫描。这个“先小后大”的策略能节省大量试错时间,同时避免因为代码错误而全量跑完才发现失败。
4.4 vectorbt的信号热力图,到底图的是啥
参数扫描完成以后,信号热力图是让我看得最直观的图。前面提过的 pf.total_return().unstack() 画出来的热力图,x 轴是快线周期,y 轴是慢线周期,颜色深浅代表收益率。多数时候,图像上会出现一条“山脊线”,那条山脊就是胜率较高的参数组合。
我个人的经验是,真正值得关注的不是那条山脊,而是山脊旁边的“高原区”——参数变化但收益变化很小的区域。这说明策略结构是稳定的,信号逻辑对这个参数不敏感。山脊本身越窄越危险,说明趋优性越强,真实盘面稍有不同,策略表现可能断崖式下跌。
如果你更进一步,可以在热力图基础上叠加最大回撤或夏普比率的热力图,然后挑选“收益率不低且回撤不大”的区域组合作为最终参数集。这个思路其实就是信号的“多目标寻优”,比单看收益率稳健得多。
5. 关于信号定制策略的几点体会
做信号定制策略这么久,最大的体会是:回测框架再快、工具再完善,策略的核心还是信号逻辑本身。vectorbt 解决的是你“验证想法”的速度问题,而不是帮你创造 alpha。
如果你用的是现成指标,比如均线、布林带、MACD,那大概率你想到的思路别人也都想到了,拼的其实是执行层面:止损怎么设、仓位怎么分、信号怎么去抖。但如果你能基于自己的交易经验,把一些主观规则转成明确的信号条件,比如识别特定盘口特征、事件驱动逻辑,再用 vectorbt 快速验证,那这套流程会给你带来远超单个指标的竞争力。
我自己日常的流程基本固定:先手工生成一批候选信号逻辑,用小样本数据快速验证,淘汰明显不行的;再用参数扫描+热力图挑选参数区间;最后在样本外数据上做滚动前测,确认信号稳定性。这一整套流程,vectorbt 是全流程唯一的主力工具。还是要提醒一句:信号策略的复杂度要克制,不要盲目堆指标。每加一个条件,就多一个过拟合的来源,保持每个信号要素透明可解释,是我们这一行最该重视的事。
