
用 Python 揭秘均值回归策略:你的收益从何而来?
量化交易的生死线:用 Python 算出你的最优下注比例
2026年重磅升级已全面落地!欢迎加入专注财经数据与量化投研的【数据科学实战】知识星球!您将获取持续更新的《财经数据宝典》与《量化投研宝典》,双典协同提供系统化指引;星球内含 500 篇以上独有高质量文章,深度覆盖策略开发、因子分析、风险管理等核心领域,内容基本每日更新;同步推出的「量化因子专题教程」系列(含完整可运行代码与实战案例),系统详解因子构建、回测与优化全流程,并实现日更迭代。我们持续扩充独家内容资源,全方位赋能您的投研效率与专业成长。无论您是量化新手还是资深研究者,这里都是助您少走弯路、事半功倍的理想伙伴,携手共探数据驱动的投资未来!
很多刚接触量化交易的 Python 学习者,第一次跑出漂亮的回测曲线时都会心跳加速:胜率 73%、盈利因子 2.14、最大回撤只有 13%,看起来简直是「印钞机」。但真相往往藏在代码的细节里——一个参数写错,整份报告的结论就可能被推翻。
本文以一个经典的「RSI(2) 超卖回归 + 200 日均线趋势过滤」策略为案例,完整复盘它在 SPY(标普 500 ETF)上跨越 33.5 年的回测过程。我们不只看它赚了多少,更要拆解那些新手极易踩坑的地方:年化口径写错、闲置现金收益被忽略、成交价与估值价错位、交易成本假设失真……这些正是把一个「看似能打」的策略打回原形的关键。读完你会明白:在量化里,能正确地怀疑自己的回测,比跑出高收益更重要。
这是一个「顺大势、抄小底」的均值回归系统:只在长期上涨趋势里,抓住短期超卖的瞬间买入,等价格一反弹就卖出。它只有三条规则,没有任何拟合参数。
背后的经济学假设很朴素:在一个已经确立的上涨趋势中,短期的超卖极值往往是「流动性事件」而非「信息事件」——也就是恐慌性抛售,而不是基本面变坏。当被迫卖出的筹码清理完毕,价格就会回归。而 200 日均线这个趋势过滤器的作用,是防止系统在真正熊市的初期就急着抄底。
核心信号代码如下:
# 趋势过滤:收盘价站上 200 日均线,视为多头环境df["Bull_Regime"] = df["Close"] > df["SMA200"]# 超卖信号:2 周期 RSI 跌破 15df["Oversold"] = df["RSI"] < 15.0# Qs 出场:今日收盘价突破昨日最高价,视为「转强」df["Qs_Exit"] = df["Close"] > df["High"].shift(1)# 入场需同时满足「多头环境」与「超卖」df["entry_signal"] = df[["Bull_Regime", "Oversold"]].all(axis=1)# 出场只看是否转强df["exit_signal"] = df[["Qs_Exit"]].all(axis=1)知识补充:RSI(2) 是 Larry Connors 提出的短周期均值回归经典参数。周期越短,RSI 越敏感,对「短期极端」的捕捉越强。传统 RSI 常用 14 周期,而这里用 2 是刻意为之。
一份回测报告是否可信,往往不取决于策略逻辑,而取决于工程实现。这里有三个关键控制点,每一个都是新手高发的坑。
1. 信号滞后(避免未来函数)。 所有条件都在第 t 根 K 线上计算,然后向后平移一根,再交给引擎。这样,t 收盘时才确认的信号,最早只能在 t+1 执行:
def shift_bool(s): # 信号整体后移一格,首值填 False,避免用到「当下还不知道」的信息 return s.shift(1).fillna(False).astype(bool).to_numpy()entries = shift_bool(df["entry_signal"])exits = shift_bool(df["exit_signal"])2. 成交价的选择。 引擎被传入了 close=df["Open"],意味着 t 收盘确认的信号,会在 t+1 的开盘价成交。这一点对本策略至关重要——因为它的平均持仓只有 3.6 根 K 线。如果贪图省事,用「同一根 K 线的收盘价」去成交 2 周期 RSI 信号,就会机械地「吃掉」大部分反弹收益,制造出根本无法真实交易的虚假利润。
3. 交易摩擦。 单边 1bp 手续费 + 2bp 滑点,往返共 6bp。
4. RSI 的算法。 使用 Wilder 平滑(通过 ewm(alpha=1/period, adjust=False) 实现),并对「平均亏损为 0」的情况做了返回 100 的保护。这一步很关键:如果错用简单滚动均值,在周期为 2 时算出来的 RSI 会与正确值有实质差异。
易错点提醒:这里有一个隐藏的口径不一致——因为开盘价序列被当成
close传进引擎,所以整个组合的估值和回撤衡量都变成了「开盘价对开盘价」。也就是说,估值时点和信号评估时点在盘中不是同一个位置。这是一种「为了避免未来函数」而引入的次要瑕疵。
跑完 8438 根 K 线(约 33.5 年)后,主要指标如下:
单看数字很漂亮:胜率 73.75%,盈利因子超过 2,最大回撤只有 13%。但请记住这一句——它的总回报 443%,远远跑输了大盘的 3095%。 接下来的分析,就是要搞清楚这个差距到底意味着什么。
这是全文最值得每个 Python 量化新手记住的教训。
引擎在初始化时传入了 freq="1D",却用了默认的 365 天/年。于是它把 8438 个交易日当成了 8438 个自然日,硬生生把 33.5 年的测试压缩成了 23.1 年来复利计算——所有年化指标全部被高估。
这个错误甚至可以从报告自身反推出来:Calmar 0.5652 × 最大回撤 13.436% ≈ 7.59% 的 CAGR;而 443.15% 复利到 7.59%,需要 23.1 年;8438 ÷ 23.1 ≈ 365,正好对上「365 天/年」的错误假设。
修复只需一行:
# 关键修正:明确告诉引擎一年有 252 个交易日pf = vbt.Portfolio.from_signals(..., freq="1D", year_freq="252 days")修正前后对比,差距触目惊心:
一个参数,年化收益虚高了近 2.4 个百分点,夏普比率虚高了 0.15。所有后续对比,都必须用修正后的数字。 同期 SPY 买入持有的真实年化约为 10.89%。
知识补充:
year_freq决定了「一年有多少个 bar」,是所有年化指标(CAGR、Sharpe、Sortino、Calmar)的分母基础。日线策略务必显式设置为 252(美股交易日),A 股约为 242,切勿使用默认的 365。这是回测框架里最常见、也最隐蔽的错误之一。
表面看,5.18% 对阵 10.89%,策略被完败。但这个比较并不公平——因为两者承担的风险量根本不在一个量级。
这个策略在 8438 根 K 线里,只有 1387 根持有仓位,也就是说它有 83.56% 的时间是空仓的。如果我们按「单位市场暴露的回报」来归一化:
按单位「资金-时间」暴露算,策略的效率大约是被动持有的 3 倍;按单位回撤算,大约是 2 倍。
它的产品是「风险特征」,而不是「绝对收益」。 用四分之一的回撤,换取了同一趋势里更高的资金使用效率。
不过要注意:这种效率并不能简单地通过加杠杆「变现」。回撤会随杠杆近似线性放大——3 倍杠杆下最大回撤约 40%,而那笔 −9.40% 的最差交易会变成 −28%(记住它没有止损)。在 2 倍杠杆、扣除融资成本后,收益趋近于买入持有,却要承受约 27% 的回撤和 762 次额外的交易执行。效率是真实的,但并不容易被直接收割。
这是整个模型里最大的单项遗漏,也是新手最容易忽视的一环。
模型假设闲置资金利息为零。由于仓位是二元的(要么 100% 满仓,要么 100% 现金),总回报可以拆解成两条腿相乘:
# 总回报 = 权益腿 × 现金腿# 当前模型:现金腿 = 1.00(空仓不产生任何收益)Total = Equity_leg × Cash_leg = 5.43x × 1.00x前面说过,策略有 83.56% 的时间空仓,相当于有 28 年的资金躺着吃 0 利息。如果给这条现金腿补上一个短期国债收益:
考虑到 1993—2026 年间 3 个月期国债平均收益率约为 3%,在这个水平下,策略实际复利约 7.8%,对阵大盘的 10.9%,但峰值回撤只有 13.4%,而大盘曾超过 50%。
这意味着真正公平的基准,不该是 100% 股票,而应该是「现金 + 股票」的混合配置。 空仓时的机会成本,必须被计入模型。
交易记录是自洽的。 每笔交易的期望值为 +0.4566%,理论复利 381 × 0.4566% ≈ 5.69 倍,实际观测到 5.43 倍,差额恰好与手续费和滑点的损耗吻合。
这个分布是短周期均值回归的教科书式特征:高胜率(73.75%)、负偏度(平均亏损是平均盈利的 1.22 倍)、亏损单持有时间几乎是盈利单的两倍。为什么亏损单拿得更久?因为出场条件是「收盘突破前高」——当仓位处于浮亏时,这个条件在机制上就更难满足。
但它有一个结构性风险:趋势过滤只管入场,不管出场。 一旦开仓,系统会一直持有到「转强」为止,哪怕 200 日趋势早已破位。如果在趋势破位前夕的 RSI 洗盘中入场,就会被一路持有到深跌之中,直到某根强势 K 线出现——而那时价格可能已经低了很多。那笔 −9.40% 的最差交易和 870 天的最大回撤持续期,都与这个暴露一致。
最小可行的修复:
# 在原出场条件上,叠加「趋势破位则离场」# ~df["Bull_Regime"] 表示收盘价跌破 200 日均线df["exit_signal"] = df["Qs_Exit"] | (~df["Bull_Regime"])考虑到盈利单的中位持仓只有 2.9 根 K 线,「持有 N 根 K 线后强制离场」的时间止损,也是一个自然的替代方案。
成本会随时代变化。 6bp 的往返成本对 2010 年后的 SPY 是合理的,但对测试的前三分之一完全站不住脚。SPY 在 2001 年小数化改革前是以 1/16 报价的:45 美元股价上的 1/16 价差,单边约 14bp。也就是说,2001 年前的真实摩擦,可能是模型假设的 4—8 倍。
这一点影响巨大,因为平均盈利单只有 +1.093%,多出 20bp 的往返成本就吃掉了它的约 18%。对约 150 笔 2001 年前的交易施加真实成本后,期末净值会向 $420,000 靠拢(20%—25% 的折损)——而偏偏,权益曲线最陡峭的上涨正好发生在 1995—2000 年,恰恰是成本模型最不可靠的区间。
建议采用分时段的成本表:2001 年前 25bp 往返,2001—2010 年 10bp,之后 6bp。
统计上还有三条硬约束需要牢记:
第一,单一标的,N = 1。全部 381 个观测都来自同一条价格序列,宏观状态高度重叠,真正「独立」的市场事件其实只有几十个。
第二,规则来源。RSI(2) + 200 日过滤 + 转强出场是广为流传的公开逻辑,源自对 1993—2007 窗口的研究。因此测试的前半段对规则的「发现过程」而言其实是样本内的——哪怕这里没有拟合任何参数。真正的样本外大约从 2008 年开始,这段期间净值从约 21 万涨到 54.3 万,年化约 4.9%,仍不到基准的一半。
第三,暴露指标的口径。报告里的 16.44% 是由一个独立循环算出的,它对「同一根 K 线上入场与出场冲突」的处理方式和引擎并不一致,数值大致正确,但并非 P&L 所依据的那条序列。更严谨的做法是直接从持仓记录里推导。
下面是可运行的完整实现,方便对照学习:
import numpy as npimport pandas as pdimport vectorbt as vbtimport yfinance as yf# ========== 参数配置 ==========SYMBOL = "SPY"START, END = "1993-01-01", "2030-01-01"SMA_PERIOD = 200 # 趋势过滤均线周期RSI_PERIOD = 2 # RSI 周期(短周期,捕捉极端超卖)RSI_ENTRY = 15.0 # RSI 入场阈值INIT_CASH = 100_000 # 初始资金FEES, SLIPPAGE = 0.0001, 0.0002 # 单边 1bp 手续费 + 2bp 滑点# 下载日线数据(多级列压平)df = yf.download(SYMBOL, start=START, end=END, interval="1d", multi_level_index=False)def wilder_rsi(close: pd.Series, period: int) -> pd.Series: """Wilder 平滑版 RSI,短周期下与简单均值差异显著""" delta = close.diff() gain = delta.clip(lower=0.0) # 上涨幅度 loss = -delta.clip(upper=0.0) # 下跌幅度(取正) # 用指数加权实现 Wilder 平滑,adjust=False 才是标准 Wilder 递推 avg_gain = gain.ewm(alpha=1 / period, min_periods=period, adjust=False).mean() avg_loss = loss.ewm(alpha=1 / period, min_periods=period, adjust=False).mean() rsi = 100.0 - 100.0 / (1.0 + avg_gain / avg_loss) # 平均亏损为 0 时,RSI 定义为 100,避免除零 return rsi.where(avg_loss != 0, 100.0)# ========== 计算指标与条件 ==========df["SMA200"] = df["Close"].rolling(SMA_PERIOD).mean()df["RSI"] = wilder_rsi(df["Close"], RSI_PERIOD)df["Bull_Regime"] = df["Close"] > df["SMA200"] # 多头环境df["Oversold"] = df["RSI"] < RSI_ENTRY # 超卖df["Qs_Exit"] = df["Close"] > df["High"].shift(1) # 转强出场entry_conditions = ["Bull_Regime", "Oversold"]exit_conditions = ["Qs_Exit"]df["entry_signal"] = df[entry_conditions].all(axis=1)df["exit_signal"] = df[exit_conditions].all(axis=1)def shift_bool(s: pd.Series) -> np.ndarray: """信号后移一格,杜绝未来函数""" return s.shift(1).fillna(False).astype(bool).to_numpy()entries = shift_bool(df["entry_signal"])exits = shift_bool(df["exit_signal"])# ========== 构建组合 ==========# 注意:这里把 Open 传入 close,使信号在次日开盘成交# 更严谨的写法见文末「修订建议」:close=df["Close"], price=df["Open"]pf = vbt.Portfolio.from_signals( close=df["Open"], entries=entries, exits=exits, init_cash=INIT_CASH, fees=FEES, slippage=SLIPPAGE, freq="1D", # 生产环境务必补上:year_freq="252 days")# ========== 统计市场暴露时间 ==========mask = np.zeros(len(df), dtype=bool)state = Falsefor i in range(len(df)): if entries[i]: state = True # 入场,持仓 if exits[i]: state = False # 出场,空仓 mask[i] = stateprint(f"\n=== {SYMBOL} | Pullback + SMA{SMA_PERIOD} filter ===")print(f"Time in market: {mask.mean() * 100:.2f}% ({mask.sum()} of {len(df)} bars)\n")print(pf.stats())# ========== 可视化 ==========fig = pf.plot( subplots=["value", "underwater", "orders", "trade_pnl"], title=f"{SYMBOL}",)fig.update_layout(height=1200)fig.show()这个 RSI(2) + 200 日趋势过滤策略,确实如设计般运作:一个高胜率、短持仓的均值回归系统,用四分之一的回撤,换来了约 3 倍于被动持有的资金使用效率。但在原始复利口径下,它大幅跑输买入持有;即便正确计入现金腿收益后,仍然落后——只是差距小了很多。它的产品是风险特征,而不是绝对收益。
对正在学 Python 量化的你,本文更大的价值在于那份「回测踩坑清单」:
year_freq="252 days",否则所有年化指标都是错的;close=df["Close"], price=df["Open"]),而不是把开盘价当收盘价塞进引擎;记住一句话:在量化世界里,一个能被自己怀疑并逐条修正的回测,远比一条陡峭上扬的净值曲线更值钱。 本文所有内容仅用于学习交流,不构成任何投资建议,实盘务必独立研究、谨慎决策。
2026年全面升级已落地!【数据科学实战】知识星球核心权益如下:
星球已沉淀丰富内容生态——涵盖量化文章专题教程库、因子日更系列、高频数据集、PyBroker实战课程、专家深度分享与实时答疑服务。无论您是初探量化的学习者,还是深耕领域的从业者,这里都是助您少走弯路、高效成长的理想平台。诚邀加入,共探数据驱动的投资未来!
好文推荐
1. 用 Python 打造股票预测系统:Transformer 模型教程(一)
2. 用 Python 打造股票预测系统:Transformer 模型教程(二)
3. 用 Python 打造股票预测系统:Transformer 模型教程(三)
4. 用 Python 打造股票预测系统:Transformer 模型教程(完结)
6. YOLO 也能预测股市涨跌?计算机视觉在股票市场预测中的应用
9. Python 量化投资利器:Ridge、Lasso 和 Elastic Net 回归详解
好书推荐