本地量化系列教程 · 第二篇。配套笔记本:
双均线策略.ipynb;数据:data/BTCUSDT_15m_20170817_20250707.csv(本地,约 27.6 万根 15 分钟 K 线)。
双均线策略是量化世界的 "Hello World":两条移动平均线一交叉就交易,逻辑一句话讲完。但在这个最简单的策略里,藏着回测的全部核心概念——信号、仓位、收益率对齐、年化、最大回撤、参数寻优。这一篇我们用向量化的方式实现它:不写逐日循环,只用 pandas 的列运算,10 行核心代码跑完 10 万根 K 线的回测。
1. 策略原理
用两条不同周期的简单移动平均线(SMA)刻画趋势:
- 短均线:跟随近期价格,反应快、噪声大;
- 长均线:刻画中期趋势,反应慢、更平滑。
交易规则:
$$ \text{position}_t = \begin{cases} +1, & ma_{short,t} > ma_{long,t} \quad \text{(短均线在上方 → 多头趋势 → 持多)} \\ -1, & ma_{short,t} \le ma_{long,t} \quad \text{(反之 → 空头趋势 → 做空/空仓)} \end{cases} $$
短均线上穿长均线(金叉)转多,下穿(死叉)转空——本质是趋势跟踪:不预测顶底,只承认已经发生的趋势。
本篇演示区间取 2022-07 至 2025-07 的 BTC 15 分钟数据(约 10.5 万根),参数 short=96(约 1 天)、long=672(约 7 天)。
2. 数据准备
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
df = pd.read_csv('./data/BTCUSDT_15m_20170817_20250707.csv')
df['datetime'] = pd.to_datetime(df['datetime'])
df = df.set_index('datetime').sort_index()
demo = df.loc['2022-07-01':, 'close'].copy()
先看全貌——八年半的 BTC 价格(对数坐标,早年的几十倍涨幅会压扁线性坐标):

3. 十行核心代码
d = pd.DataFrame({'close': demo})
d['ma_s'] = d['close'].rolling(96).mean() # 短均线:96 根 ≈ 1 天
d['ma_l'] = d['close'].rolling(672).mean() # 长均线:672 根 ≈ 7 天
d['returns'] = np.log(d['close'] / d['close'].shift(1)) # 对数收益率
d['position'] = np.where(d['ma_s'] > d['ma_l'], 1, -1) # 信号
d = d.dropna()
d['strategy'] = d['position'].shift(1) * d['returns'] # 策略收益
逐行拆解这 6 行里的四个关键动作:
① rolling().mean():滑动窗口均值,前 window-1 个位置为 NaN,所以必须 dropna()。
② 对数收益率 ln(P_t / P_{t-1}):可跨期直接相加(cumsum() 即累计对数收益),exp 一次还原成净值。简单收益率不行——它们只能连乘。
③ np.where 生成信号:整列一次性判断,没有 Python 循环。这是"向量化"的本体。
④ shift(1) 是回测的灵魂。position 由当天收盘后算出的均线关系决定,但当天收益 returns 用的是"昨天收盘到今天收盘"的价格变化——今天收盘才知道的信号,最早只能吃到明天的收益。所以策略收益必须是 position.shift(1) * returns。
漏掉 shift(1) 是回测第一大作弊:相当于用"今天的信号"吃"今天的收益",即"收盘前就知道收盘后的事"。它能把任何平庸策略粉饰成圣杯。回测结果好得离谱时,第一件事就是检查信号与收益的时间对齐。
4. 可视化:信号长什么样

绿色底色是持多头的区间。可以直观看到趋势策略的典型行为:2023 上半年与 2024-2025 的上行段大部分被捕捉到;而震荡段(如 2023 年中)均线反复缠绕,持仓状态频繁切换,这是趋势策略最大的亏损来源。
5. 绩效评估
5.1 累计收益对比
(d[['returns', 'strategy']].cumsum().apply(np.exp)).plot(figsize=(10, 4))

本区间的实际数字:
| 指标 | 买入持有 | 双均线策略 |
|---|---|---|
| 累计收益 | +399.6% | +21.9% |
| 年化收益 | ~70.9% | ~6.8% |
| 年化夏普 | — | 0.13 |
| 最大回撤 | — | -52.5% |
结论直白而重要:这个参数组合下,策略大幅跑输买入持有。 趋势策略牺牲了大部分上行,换来的只是"跌的时候少亏一点",而 BTC 这个样本恰好处在长期大牛市中——任何长期看空的机制都会吃亏。
5.2 回撤:策略的另一面
cum = d['strategy'].cumsum().apply(np.exp)
cummax = cum.cummax()
drawdown = (cum / cummax - 1).min()

最大回撤 -52.5%,意味着策略净值曾从高点腰斩。回撤公式值得背下来:cum / cum.cummax() - 1 的最小值,是任何策略交付前必看的第一风险指标。
5.3 多头与空头的暴露
(d['position'] == 1).sum() # 56516 根 K 线做多(53.7%)
(d['position'] == -1).sum() # 48668 根 K 线做空(46.3%)
多空时间几乎对半,但收益贡献极不均衡——大趋势段贡献了全部利润。
6. 参数寻优:网格搜索
96/672 是拍脑袋参数。向量化回测的好处就是快,可以暴力扫参数:
shorts = [24, 48, 96, 192]
longs = [96, 168, 336, 672]
for s in shorts:
for l in longs:
if s >= l: # 短窗口必须小于长窗口
continue
ma_s, ma_l = demo.rolling(s).mean(), demo.rolling(l).mean()
pos = pd.Series(np.where(ma_s > ma_l, 1, -1), index=demo.index)
strat = (pos.shift(1) * np.log(demo / demo.shift(1))).dropna()
...

网格结果(左:累计收益 %,右:年化夏普):
- 最差组合亏损严重,最好组合 short=192 / long=336 达到 +293% 收益、夏普 0.91——同一个策略,参数不同就是天壤之别;
- 收益和夏普的最优参数不重合,说明"收益最大化"和"风险调整后收益最大化"是两个目标;
- 网格里最优与最差之间的巨大落差,本身就是过拟合警告(见下)。
7. 务必警惕:这个回测的四个坑
- 没有交易成本。 15 分钟级别,10.5 万根 K 线里有 5.6 万次持仓翻转,即便按 0.1% 单边费率也足以把 +21.9% 打成深亏。均线交叉策略在短周期上对成本极其敏感。
- 过拟合。 16 组参数挑最好的那组,等于在历史数据上"抄答案"。规范做法是训练/验证集分离或滚动前推(walk-forward)。
- 做空假设。
position=-1假设可以无限制做空 BTC 且保证金不被强平;现货与多数 A 股账户根本没有这个机制,实盘应改为np.where(cond, 1, 0)只做多。 - 单一标的单一区间。 换一个币种、换一个时段重跑,结论可能完全反转。策略有效性要用多标的、多时段验证(第六篇的批量回测会展开)。
8. 小结与练习
- 向量化回测 = 信号列 +
shift(1)对齐 + 收益列运算,无循环、十万根 K 线秒级完成; shift(1)是防作弊的第一道闸;- 完整绩效至少包含:累计收益、年化收益、夏普、最大回撤、多空暴露;
- 网格搜索暴露的是参数敏感性,而参数敏感就是过拟合的信号;
- 无成本、可做空、单样本——这三个简化在写论文和写实盘系统时必须一个一个还回去。
练习:
- (基础)把
position改成{1, 0}(只做多不做空),重跑并对比收益与回撤; - (基础)加入单边 0.05% 成本:策略收益每次持仓翻转时扣减
2 * 0.0005,结果还剩多少? - (进阶)把 2022-07~2024-06 设为训练集、2024-07~2025-07 设为验证集:训练集选出的最优参数在验证集上还成立吗?
- (挑战)把 A 股版双均线(
stock_data/000063_中兴通讯.csv,日频)按同样流程跑一遍,对比 A 股与 BTC 上同一策略的表现差异。
上一篇:01 · 数据基石 · 下一篇:03 · 均值回归:当价格偏离均值之后