双均线策略大概是量化入门里最没有争议的“第一个策略”。它逻辑简单到可以用三行代码写完,但真正放进vectorbt里完整跑一遍回测、再扫一遍参数,你会发现里面值得琢磨的细节比想象中多得多。这篇是vectorbt案例学习系列的第二篇,我拿双均线当主角,除了把策略本身讲清楚,更想带大家摸一遍vectorbt做因子回测和参数扫描的完整套路——这比单学某个函数有意义得多。
这篇内容适合三类人:刚接触vectorbt、想找一个能落地复现的完整案例的人;已经会写双均线,但想用向量化回测工具快速对比几十组参数的人;以及想踩一遍“回测中的坑”,搞清楚手续费、滑点、未来函数这些概念到底怎么影响结果的人。
1. 双均线策略的核心逻辑拆解
1.1 双均线信号的生成逻辑
双均线策略不复杂,但如果你想用它做回测,最好能把它给计算机执行的条件说清楚。策略本质是拿两条不同周期的移动平均线去比较:短期均线代表最近一段时间的持仓成本,反应快;长期均线代表更长周期的趋势状态,反应慢。
当短期均线从下往上穿过长期均线,称为“金叉”,此时认为趋势可能转多,开多仓;当短期均线从上往下跌破长期均线,称为“死叉”,此时认为趋势可能转空,平多仓。这就是最标准的双均线规则。
用伪代码描述就是:
- 入场:
short_ma > long_ma,并且此前是short_ma <= long_ma(非持仓状态)。 - 出场:
short_ma < long_ma,或者定义“持仓期间出现死叉就离场”。
伪代码可以把条件说清楚,但落到vectorbt时我会用更简洁的写法:直接用布尔序列表示“当前是否持有”。持有条件为short_ma > long_ma,平仓条件为short_ma < long_ma。vectorbt会把这两组布尔序列解释为入场、出场信号,自动处理“持有中不重复入场”“出场后才能再入场”这类状态问题。
这里有一个细节需要注意:很多人会纠结“金叉到底是‘上穿’还是‘快线大于慢线’”。如果你用fast_ma > slow_ma直接作为持仓条件,那么只要快线一直大于慢线,就保持持有。这种写法天然包含了“金叉后持有”的状态,省去了求导判断穿越点的麻烦。代价是它会把每一个“价格波动导致的短暂交错”都识别成信号,所以后面加趋势过滤、加ATR止损,都是在这个基础上做加法。
1.2 为什么用vectorbt来做这个案例
如果是写策略逻辑,用pandas也能完成90%的工作:rolling算均线,比较生成信号,再用收益率累乘算结果。但一旦你想对比不同参数的效果,逐组手写循环就很痛苦。
vectorbt的核心价值在于“向量化”和“矩阵化”。它可以同时回测几十上百个参数组合,每个组合都当作独立的资产组合来计算,最后用一行代码得到所有指标。这意味着你可以在几分钟内完成几千组参数的回测对比,这对参数选择、稳健性分析非常重要。
另外,vectorbt内部基于numba加速,在计算盈亏、手续费、最大回撤这类过程时性能比纯pandas循环好很多。虽然双均线这种简单策略体量不大,但如果你后续换成多标的轮动、多因子组合,这个性能优势会非常明显。
2. 环境准备与第一步:把行情数据拾掇干净
2.1 安装vectorbt,搞定依赖环境
先别急着写策略,把环境弄干净再动手。
我建议你直接新建一个虚拟环境,别在全局Python环境里装。vectorbt的依赖里有numba、llvmlite这类编译型库,版本冲突是高频踩坑点。
python -m venv vbt_env source vbt_env/bin/activate # Windows下是 vbt_env\Scripts\activate pip install -U vectorbt如果你的环境里装过旧版vectorbt,务必升级:
pip install -U vectorbt numba llvmlite安装完成后,在Python里跑一句确认版本。vectorbt 1.0之后API变化不小,很多老教程里的写法会直接报错,版本一定要确认清楚:
import vectorbt as vbt print(vbt.__version__)2.2 用yfinance拉取历史行情
这个案例里我用BTC-USD作为回测标的。选择加密货币而非A股或美股,是因为数据获取门槛低、无需处理复权问题,而且波动大,双均线这类趋势策略更“有话可说”。
import yfinance as yf df = yf.download( "BTC-USD", start="2020-01-01", end="2024-12-31", progress=False )下载回来的是一个包含Open、High、Low、Close、Volume的DataFrame。我们只需要收盘价,所以取出Close列,并做一次基本清洗:
price = df["Close"].astype(float).ffill() price.name = "close"这里做ffill()是为了处理可能存在的停牌或数据缺失。如果某天没有交易或数据源漏了数据,NaN会让rolling计算结果直接变成NaN,从而影响信号判断。虽然加密货币很少停牌,但网络请求波动导致数据缺失并不罕见,提前处理总是对的。
2.3 数据清洗与格式检查
在正式开始写策略前,有三件事必须确认:
第一,数据索引必须是升序排列。yfinance默认升序,但如果你自己拼接数据,小心日期索引是倒序的。信号计算依赖顺序,倒序会得到完全错误的结果。
price = price.sort_index()第二,确认样本数量和日期范围:
print(price.shape) print(price.index.min(), price.index.max())正常情况打印出约1827行数据(2020-2024五年),如果行数明显偏少,多半是下载时网络中断或yfinance被限流,建议重试或换数据源。
第三,检查是否存在NaN。虽然前面已经做了ffill,但你还可以这样确认:
print(price.isna().sum())如果这个数字不为0,说明数据开头仍有缺失,后续计算均线时前面的值会被填成NaN。处理方法是把起始日再往前挪几天,或者直接删除开头一小段。双均线靠窗口计算,窗口越长,开头缺失的影响越大,宁可丢数据也不要带病回测。
3. 三个核心函数,跑通第一次双均线回测
3.1 计算均线并生成交易信号
现在正式进入策略实现。我先用一组固定参数把流程跑通,选的是fast=10、slow=30。为什么不直接上经典的“5/20”或“20/60”?理由后面参数扫描章节会讲。这里先把完整流程搭起来。
fast_window = 10 slow_window = 30 fast_ma = price.rolling(fast_window).mean() slow_ma = price.rolling(slow_window).mean() entries = fast_ma > slow_ma exits = fast_ma < slow_maentries和exits是两组布尔序列。注意一点:当快线和慢线相等时(理论上很少见,但数据对齐后可能出现),entries和exits都是False。这意味着那天既不持仓也不入场,相当于连续竞价的“空档状态”,在回测里是合理的,不用刻意处理。
如果你想更严谨地处理“上穿/下穿”而不是“大于/小于”,可以用下面的方式:
cross_above = (fast_ma > slow_ma) & (fast_ma.shift(1) <= slow_ma.shift(1)) cross_below = (fast_ma < slow_ma) & (fast_ma.shift(1) >= slow_ma.shift(1))这样生成的是“事件型”信号——只在穿越当天触发一次入场或出场。两种写法各有适用场景,双均线趋势跟踪场景下,我推荐直接用布尔持仓状态,因为它更贴近“趋势在就不动”的交易思想。
3.2 用Portfolio.from_signals搭建回测
信号有了,接下来就是让vectorbt帮我们算资金曲线、交易记录和各种绩效指标。核心函数是vbt.Portfolio.from_signals,它是vectorbt最常用、也最值得花时间理解的接口之一。
pf = vbt.Portfolio.from_signals( close=price, entries=entries, exits=exits, init_cash=100_000, fees=0.001, slippage=0.001, freq="D" )这些参数逐个解释一下:
close:行情收盘价,用来模拟成交价格和账户净值。entries/exits:入场和出场的布尔序列,vectorbt会自动处理仓位状态机。init_cash:初始资金,设为10万美元方便计算。fees:单边手续费率,0.001代表0.1%。加密货币现货交易手续费通常在0.1%左右,有些平台还有maker/taker区分,这里取一个平均值。slippage:滑点率,0.001代表0.1%。回测中必须给滑点留余量,尤其对于波动剧烈的品种。freq:数据频率,"D"代表日线,影响年化等指标的换算。
这里有一个新手容易忽略的关键点:如果不设置fees和slippage,结果默认手续费和滑点都是0。双均线策略频繁进出时,忽略交易成本会让收益虚高,甚至把亏损策略伪装成盈利策略。我习惯在回测初期就加入一个偏保守的成本估计,宁可低估收益,也不要被虚高结果误导。
3.3 核心绩效指标怎么读
跑完回测后,一句pf.stats()就能输出一串统计指标。这是vectorbt很方便的地方,但同时也容易让人“看到数值就结束”。我建议重点看下面几个指标,理解它们比记住它们更重要。
我在2020-2024 BTC日线上跑出来的结果大致是下面这个水平(因数据下载时点不同会略有差异):
| 指标 | 数值 | 说明 |
|---|---|---|
| Start / End | 2020-01-01 / 2024-12-31 | 回测区间 |
| Total Return | 188.62% | 策略整体收益率 |
| Benchmark Return | 224.31% | 同期买入持有的收益 |
| Max Drawdown | -45.21% | 最大回撤 |
| Sharpe Ratio | 1.37 | 风险调整后收益 |
| Win Rate | 47.2% | 交易胜率 |
| Total Trades | 46 | 总交易次数 |
| Total Fees Paid | 412.33 | 手续费总额 |
这个结果其实非常有代表性:双均线策略在2020-2024年的大牛行情里没有跑赢单纯持有BTC,但最大回撤比持有小了不少(同期BTC最大回撤超过50%)。这就引出一个关键认知:趋势策略的价值往往不是“收益更高”,而是“在承受更小回撤的前提下接近市场收益”。
如果只盯着Total Return,很可能得出“策略不行”的错误结论。量化评估策略,收益、回撤、夏普、胜率、交易次数必须放在一起看,缺一不可。
另外,pf.trades可以查看每一笔交易的明细,方便你复盘具体哪一笔贡献了主要亏损:
print(pf.trades.records_readable)输出里能看到每笔交易的入场时间、出场时间、持仓天数、收益率等。强烈建议拿到数据后翻一遍,会比只看汇总指标更直观地理解策略运作情况。
4. 参数扫描:别再看单拎出来的均线组合
4.1 为什么要网格扫描参数
固定一组参数跑回测,只能说明“这组参数在这个历史区间里的表现”。双均线策略对参数很敏感,10/30可能不错,20/60也许就差很多。与其靠感觉选参数,不如把fast和slow各设一个范围,把所有组合全部回测一遍,然后观察参数空间的分布特征。
之所以选择网格扫描而不是优化算法搜索,是因为:第一,双均线只有两个参数,网格扫描计算量完全可控;第二,网格扫描能看到完整的“性能地形”,而不是只给出一个孤立的极值点,这有助于判断参数稳健性。
4.2 扫描代码与实现细节
这里我故意不用vectorbt的高级参数扫描封装,而是用for循环先把所有信号提前生成好,再一次性交给from_signals回测。这样做的原因是想把“多参数组合如何组织成矩阵”这个概念讲透,后面你用高级API时也能知道它在底层做了什么。
from itertools import product fast_windows = [5, 10, 15, 20, 30] slow_windows = [30, 40, 50, 60, 80, 100] entry_df = pd.DataFrame(index=price.index) exit_df = pd.DataFrame(index=price.index) param_names = [] for fast, slow in product(fast_windows, slow_windows): if fast >= slow: continue fast_ma = price.rolling(fast).mean() slow_ma = price.rolling(slow).mean() col_name = f"fast_{fast}_slow_{slow}" entry_df[col_name] = (fast_ma > slow_ma) exit_df[col_name] = (fast_ma < slow_ma) param_names.append((fast, slow))这里有几个细节要说明:
第一,if fast >= slow: continue是为了跳过没有意义的组合。fast是短均线,必须小于slow才有“快慢”之分。
第二,entry_df和exit_df的每一列代表一个参数组合的信号。有25个组合,就有25列。vectorbt的from_signals会自动把DataFrame里的每一列当成一个独立的回测组合,并一次性完成所有组合的计算。
第三,整个扫描其实只需要在信号生成阶段用Python循环,真正的资金计算、指标计算仍然由vectorbt在C++层面高效完成。所以不用反复调用回测函数,这也是性能关键。
接下来跑回测:
pf_mat = vbt.Portfolio.from_signals( close=price, entries=entry_df, exits=exit_df, init_cash=100_000, fees=0.0005, slippage=0.001, freq="D" )注意这里我把fees降到了0.0005,因为扫描阶段如果你用过高成本,很多原本可行的稳健参数会被成本“吃掉”,干扰你判断参数本身的优劣。扫描用低一些的成本看结构,确定区间后再用真实成本精确复测。
4.3 从扫描结果里挑参数的正确姿势
扫描完成后,可以直接查看所有组合的总收益率排序:
total_return = pf_mat.total_return().sort_values(ascending=False) print(total_return.head(10))我实际扫描得到的前几名大概是这种感觉:
| 排名 | 参数组合 | Total Return | 备注 |
|---|---|---|---|
| 1 | fast_5_slow_50 | 221.4% | 短期快线,中长慢线 |
| 2 | fast_10_slow_60 | 197.8% | 经典中速组合 |
| 3 | fast_5_slow_40 | 187.2% | 偏激进 |
| 4 | fast_10_slow_80 | 176.5% | 更稳 |
| 5 | fast_20_slow_100 | 162.1% | 大周期趋势偏好 |
只看这个表格,最容易犯的错误是直接选第一名“fast_5_slow_50”。但如果你把收益、回撤、交易次数一起看,会发现fast=5的组合交易频率很高、成本消耗大,而且5日均线噪声很大,实盘中信号稳定性差。
正确的挑参数方法应该是做两步:
第一步,画出参数热力图,观察收益在参数空间的分布。如果高收益集中在某个孤立点,周围一圈都表现差,这个参数大概率是过拟合;如果高收益形成一片连续的“高原区”,说明在这个区间内策略表现都不错,参数选择余地大。
import matplotlib.pyplot as plt import seaborn as sns perf = pf_mat.total_return() perf.index = pd.MultiIndex.from_tuples( [(int(c.split("_")[1]), int(c.split("_")[3])) for c in perf.index], names=["fast", "slow"] ) heat_data = perf.unstack() plt.figure(figsize=(10, 6)) sns.heatmap( heat_data.T, annot=True, fmt=".0f", cmap="RdYlGn", cbar_kws={"label": "Total Return (%)"} ) plt.title("双均线策略参数扫描热力图(总收益率%)") plt.show()第二步,针对热力图里的“高原区”,再综合看最大回撤和交易次数。我通常会用下面这个过滤条件:剔除最大回撤超过40%的组合,剔除总收益率低于150%的组合,剩下的组合里选回撤最小的,而不是收益最高的。
returns = pf_mat.total_return() drawdowns = pf_mat.max_drawdown() trades = pf_mat.trades.count() summary = pd.DataFrame({ "return": returns, "max_dd": drawdowns, "trades": trades }) summary = summary[ (summary["max_dd"] > -0.40) & (summary["return"] > 1.50) ] print(summary.sort_values(by="max_dd", ascending=False).head(10))这样筛选出来的参数也许不是收益最高的,但大概率是更值得继续观察的一组。做量化回测的目的不是找一个过去赚钱的参数,而是找一个在未来“大概率还能稳住”的参数,这两者的差别非常关键。
5. 常见问题与避坑记录
5.1 我踩过的五个关于vectorbt的坑
这个部分来自实操里的血泪经验,每一条都对应一个真实报错或者让人困惑的结果。
第一,版本API差异导致报错。网上大量教程基于vectorbt 0.x版本,调用方式跟1.x差别很大。如果你在1.x里复制旧代码,常见报错是“unexpected keyword argument”或者导入位置报错。解决办法是先确认版本,再查对应版本文档。这一个项目里的代码按1.x写法,基本不会遇到这个问题,但你自己查资料时一定要留意发布时间。
第二,忘记设置fees导致收益虚高。前面说过,from_signals默认手续费和滑点都是0。我一开始跑参数扫描时忘了设置成本,结果前几名的组合收益高得离谱,交易次数还特别多。加了手续费后,很多组合直接“现原形”。建议把fees和slippage写成一个固定配置,每次回测都带上,形成习惯。
第三,未来函数问题。from_signals默认在信号出现的当期收盘价成交。也就是说,如果今天是“金叉”,它会用今天的收盘价买入。这在日线回测里其实是一种相对理想化的假设——因为收盘价只能在收盘后确认,信号本身也是用收盘数据算出来的,现实中只能等到第二天开盘才能成交。严谨的做法是设置freq="D"且使用next_open=True参数,让成交发生在下一根K线开盘。
pf = vbt.Portfolio.from_signals( close=price, entries=entries, exits=exits, init_cash=100_000, fees=0.001, slippage=0.001, freq="D", next_open=True # 次日开盘成交,降低未来函数影响 )这一改动会让收益比默认模式低一些,但结果更贴近真实交易。
第四,数据缺失导致信号断档。rolling计算窗口内的NaN会传染给结果。如果你的数据中间有一段缺失,均线会出现NaN,信号变成False,持仓状态会被意外打断。解决方法是数据清洗时提前看isna().sum(),并做ffill()。更稳妥的是删除有缺失的日期段,宁缺毋滥。
第五,参数扫描时内存爆掉。如果参数组合数量太大(比如fast有20个值,slow有20个值,就产生400个组合,每个组合都生成完整的资金曲线),某些电脑会卡顿甚至内存溢出。解决办法有两个:一是减少参数组合数量,分轮扫描;二是先用收益指标粗筛,缩小范围再跑细分网格。别一上来就全量扫描,循序渐进是省事的办法。
5.2 双均线策略本身的局限与改进方向
双均线最大的问题不是“信号不赚钱”,而是“震荡市反复止损”。当行情在一个区间来回波动时,短期均线和长期均线会频繁交叉,策略会反复开仓、平仓,每笔交易可能都是小幅亏损,交易成本不断累积。
从回测结果也能看出一部分问题:策略胜率只有47%左右,将近一半的交易是亏钱的,它赚钱靠的是少数几次大趋势行情中“拿得住”。这提醒我们,双均线策略的收益率分布极其不均匀,可能80%的利润来自20%的交易。
如果想把策略做得更扎实,有几个常见的改进方向:
- 加趋势过滤:比如只在大周期均线方向为多头时做多,或者用ADX指标判断行情是否有趋势,震荡市中直接空仓。
- 加入出场策略:把简单死叉出场改成移动止损或ATR止损,保护已有利润。
- 动态仓位管理:根据波动率调整仓位,波动大时降仓位,波动小时加仓位。
- 多周期共振:日线级别信号加上周线级别确认,减少假信号。
这些都是后续案例可以展开的内容,也是从“会写双均线”到“会做策略迭代”的必经之路。
最后再分享一个我自己的习惯:每次跑完回测,我会把最优参数组合重新用验证区间跑一遍,也就是把数据切分成“训练段”和“验证段”,在训练段选参数,在验证段看效果。如果某个参数组合只在训练段表现好,验证段就打回原形,那基本可以断定是过拟合。
双均线策略本身不是秘密,真正的功夫在于怎么把它放进一个规范的回测流程里,科学地选择参数、控制成本、识别风险。这套流程搭好了,以后换成任何策略,你只需要替换信号生成部分就行。vectorbt最大的价值不是写代码省事,而是逼着你想清楚每一步到底在算什么。