开场:一个被遗忘的经典策略
2023年底,我认识一位做量化的朋友。
他当时很兴奋地告诉我,发现了一个动量策略,回测年化收益60%。但几个月后,他告诉我策略失效了,连续亏损。
我问:「你有没有试过均值回归策略?」
他愣了一下:「那不是老掉牙的策略吗?」
很多人觉得均值回归太简单,不值得研究。但恰恰是这种「简单」的策略,往往最稳健。
因为均值回归的核心逻辑——价格围绕价值波动——永远不会过时。
今天我们就用Rust+Polars,完整实现一个布林带+ZScore的均值回归策略,看看它到底有多强的实战价值。
先问一个问题:为什么价格会回归均值?
答案藏在三个层面:
用一句话总结:市场不是永远理性的,但也不会永远疯狂。
布林带就是利用这个原理设计的:
当价格触及上轨,意味着「高了,可能回调」;触及下轨,意味着「低了,可能反弹」。
布林带有个问题:不同股票的价格波动率不同。
A股票波动大,布林带宽度可能是10元;B股票波动小,宽度可能只有1元。这样很难做横向比较。
于是我们引入ZScore标准化:
1
ZScore = (当前价格 - 均值) / 标准差
ZScore把所有股票「拉到同一条起跑线」:
有了ZScore,我们就可以在全市场范围内寻找超跌股票了。
首先定义股票数据结构:
1
2
3
4
5
6
7
8
9
10
11
12
13
use polars::prelude::*;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct StockDaily {
ts_code: String,
trade_date: String,
open: f64,
high: f64,
low: f64,
close: f64,
vol: f64,
}
布林带的核心是移动均值和移动标准差:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
fn calculate_bollinger(df: &DataFrame, period: usize, std_dev: f64) -> DataFrame {
df.lazy()
.with_columns([
// 中轨:N日移动均值
col("close")
.rolling_mean(RollingOptions {
window_size: Period::Days(period),
min_periods: period,
..Default::default()
})
.alias("mid"),
// 移动标准差
col("close")
.rolling_std(RollingOptions {
window_size: Period::Days(period),
min_periods: period,
..Default::default()
})
.alias("std"),
])
.with_columns([
// 上轨
(col("mid") + lit(std_dev) * col("std")).alias("upper"),
// 下轨
(col("mid") - lit(std_dev) * col("std")).alias("lower"),
])
.collect()
.unwrap()
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
fn calculate_zscore(df: &DataFrame, period: usize) -> DataFrame {
df.lazy()
.with_columns([
col("close")
.rolling_mean(RollingOptions {
window_size: Period::Days(period),
..Default::default()
})
.alias("mean"),
col("close")
.rolling_std(RollingOptions {
window_size: Period::Days(period),
..Default::default()
})
.alias("std"),
])
.with_columns([
((col("close") - col("mean")) / col("std")).alias("zscore")
])
.collect()
.unwrap()
}
交易逻辑:
1
2
3
4
5
6
7
8
9
10
11
fn generate_signals(df: &DataFrame) -> DataFrame {
df.lazy()
.with_columns([
// 买入信号
col("zscore").lt(-2.0).alias("buy_signal"),
// 卖出信号
col("zscore").gt(2.0).alias("sell_signal"),
])
.collect()
.unwrap()
}
就这么简单!
布林带有个关键参数:N(窗口期)。
不同的N值,效果差异巨大。我们来做一个参数遍历:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
use rayon::prelude::*;
fn optimize_period(df: &DataFrame, periods: &[usize]) -> Vec<(usize, f64)> {
periods.par_iter()
.map(|&period| {
let result = calculate_zscore(df, period);
let signals = generate_signals(&result);
// 计算夏普比率
let sharpe = calculate_sharpe(&signals);
(period, sharpe)
})
.collect()
}
// 测试不同窗口期
let periods = vec![10, 15, 20, 30, 60];
let results = optimize_period(&df, &periods);
for (period, sharpe) in results {
println!("窗口期={}天, 夏普比率={:.3}", period, sharpe);
}
在我的测试数据上,结果如下:
窗口期 | 夏普比率 | 最大回撤 |
|---|---|---|
10天 | 0.82 | -15.3% |
15天 | 1.12 | -12.7% |
20天 | 1.34 | -11.2% |
30天 | 1.08 | -13.8% |
60天 | 0.76 | -18.4% |
20天窗口期的效果最好,这也符合布林带的传统参数设定。
我用同样的逻辑写了一个Python版本,做性能对比:
1
2
3
4
5
6
7
8
9
10
# Python版本
import pandas as pd
import numpy as np
def calculate_zscore_py(df, period=20):
df = df.copy()
df['mean'] = df['close'].rolling(period).mean()
df['std'] = df['close'].rolling(period).std()
df['zscore'] = (df['close'] - df['mean']) / df['std']
return df
测试数据:100只股票,每只10年日线数据(约25万行)
实现 | 耗时 | 内存占用 |
|---|---|---|
Python + Pandas | 3.2秒 | 420MB |
Rust + Polars | 0.15秒 | 85MB |
Rust版本快了20倍,内存省了5倍。
这意味着什么?
如果你要做参数优化,测试100组参数:
等待时间的差距,直接影响开发效率。
均值回归策略有个致命弱点:趋势市场中会被打脸。
想象一下,一只股票持续上涨,你不断「卖高」,结果越卖越高,一直亏损。
解决方案:加一个趋势过滤器。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
fn add_trend_filter(df: &DataFrame) -> DataFrame {
df.lazy()
.with_columns([
// 60日均线作为趋势判断
col("close")
.rolling_mean(RollingOptions {
window_size: Period::Days(60),
..Default::default()
})
.alias("trend_ma"),
])
.with_columns([
// 只在上升趋势中做均值回归
(col("close").gt(col("trend_ma"))).alias("is_uptrend"),
])
.with_columns([
// 结合趋势的买入信号
col("buy_signal").and(col("is_uptrend")).alias("final_buy_signal"),
])
.collect()
.unwrap()
}
核心思想:只在上升趋势中做均值回归,避免逆势抄底。
加上趋势过滤后,夏普比率从1.34提升到1.68。
⚠️ 均值回归策略实盘时要注意几点:
我用2018-2024年的A股全市场数据做了回测:
指标 | 数值 |
|---|---|
年化收益 | 18.6% |
夏普比率 | 1.68 |
最大回撤 | -11.2% |
胜率 | 56.3% |
盈亏比 | 1.42 |
结论:均值回归是一个稳健的「吃饭策略」,不会暴富,但活得久。
写这篇文章的时候,我想起了一句话:
「简单的策略往往最有效,因为它不容易过拟合。」
均值回归就是这样——逻辑简单,实现简单,效果却出奇地好。
但「简单」不等于「容易」。真正把这个策略做好,需要:
技术实现只是第一步,策略思维才是核心。
下一篇,我们将聊聊动量策略的Rust实现:
敬请期待。
(全文完)