ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

股票月份效应:从统计检验到可交易回测的完整链路

股票月份效应:从统计检验到可交易回测的完整链路

每个月月底做复盘的时候,我都会把那张“按月份分组的收益率表”重新翻出来看一眼。这张表看上去普普通通,一列月份、一列平均收益、一列胜率,但它背后代表的东西,是股票数据里的日历效应中最容易被讨论、也最容易被误读的一类——月份效应。简单说,它研究的就是“某一个自然月份(或者某个月的特定几天)的收益率,是否系统性地偏离其他月份”。它能帮你回答几个很实际的问题:季度考核、长假、财报披露、资金面松紧这些带日历节奏的因素,到底有没有在价格上留下可测的痕迹;这些痕迹是统计上的巧合,还是能在样本外重复出现;即便重复出现,扣掉交易成本之后还剩多少。这篇内容适合两类人看:一类是刚接触量化、手里已经有一份日频行情数据、想动手做第一份因子检验的人;另一类是做了几年投研、见过太多“一月份一定涨”这类说法、想把这件事彻底拆干净的人。我下面写的东西,全部围绕一条完整链路展开:从概念界定,到数据清洗,到统计检验,到代码落地,再到回测与踩坑。中间会给出可以直接抄的参数、公式和脚本片段,也会说明每一步“为什么要这么干”。

1. 先把话说清楚:月份效应是什么、能干什么

日历效应这个词范围很宽,它指的是资产收益与日历时间标签之间存在的统计关联。月份效应是其中一支,剩下的还有星期效应、假日效应、月末效应等等。它们共享同一套方法论:把时间轴切成若干可重复的标签,然后看不同标签下的收益分布是否显著不同。方法论本身非常简单,简单到很多人第一次跑完就兴奋地以为自己发现了印钞机,然后被样本外数据教育一遍。

1.1 三种最常见的月份效应形态

我习惯把它拆成三种形态来看,因为它们的成因、检验方式和可交易性完全不同,混在一起谈必然出错。

第一种是整月效应:把每个自然月的收益率当作一个观测值,比较十二个月份的均值、中位数和胜率。这是最直观的一种,也是最容易被数据挖掘污染的一种。十二个月份、每个月份几十个观测,随便挑一个出来都很容易显得“显著”。

第二种是月内位置效应:不看整个月,而是看月内的第几个交易日。比如每月最后 1 到 3 个交易日、每月前 3 到 5 个交易日,它们的收益特征经常和月中不一样。这类效应在实操上更有意义,因为它的持仓周期短,资金占用少,但同时对交易成本极其敏感。

第三种是跨月衔接效应:就是月末几天加上月初几天连起来看的那一段,常被叫做“转向月效应”。它本质上是前两种的拼接,但单独拿出来研究有好处——很多资金行为(比如结算、申赎、考核)恰好就发生在这个衔接窗口里。

注意:这三种形态的观测值并不独立。月内位置效应的观测值数量是整月效应的二十倍左右,看起来样本很大,但同一只标的相邻交易日的收益高度自相关,有效样本量远小于名义样本量。拿名义样本量去算 t 值,几乎必然高估显著性。

1.2 影响的边界:哪些标的、哪些参与者会被牵动

讲影响范围,我从两个维度切:标的维度和参与者维度。

标的维度上,月份效应在宽基指数层面的强度通常弱于个股和行业组合,原因很朴素——指数是几十上百只成分股的平均,个体噪声被抹掉了,剩下的系统性成分本来就不多。反过来,在小市值标的、低流动性标的上,这类日历性规律往往更明显,但那里同时堆积着更高的交易成本和更差的成交效率,属于典型的“看着有、吃着难”。风格层面也值得看:价值与成长、大盘与小盘,它们的月份分布经常呈现镜像关系,这提示我们背后可能不是“月份本身”在起作用,而是月份所代理的某些资金行为。

参与者维度上,受影响最直接的是做月度频率调仓的资金。如果你的调仓周期本来就是一个季度或者半年,那么月份效应对你几乎无意义——你一年只做四次决策,月份差异会被你的调仓节奏平均掉。真正需要关注的是月度、周度频率的择时或轮动策略,以及做事件驱动的团队,因为他们的持仓窗口经常恰好落在月初月末这些窗口里。

还有一个容易被忽略的影响面:风险模型与业绩归因。如果某个月份系统性地偏向某类风格,而你的归因模型没有控制这个时间维度,你会把一部分日历性收益归因到选股能力上,导致评价失真。我自己在早期就吃过这个亏,某个策略连续两个月的超额收益看起来很漂亮,拆开之后发现八成来自那两个月小盘风格的自然走强,和策略逻辑没半点关系。

2. 为什么会有月份效应:五类解释与我的取舍

这一节是我认为最需要认真写的地方。绝大多数讲日历效应的内容,只告诉你“几月平均收益高”,不告诉你“为什么”,结果读者拿着一张均值表完全无法判断这个规律还能不能继续存在。逻辑上讲,一个统计规律要持续成立,背后必须有一个持续存在的机制;机制消失了,规律也就该失效了。我把常见的解释归成五类。

2.1 资金面与制度性节律

最硬的一类解释来自资金面。市场里的钱不是均匀流动的,它有明显的季节性起伏:年初的信贷投放节奏、季末的考核时点、长假前后的现金备付需求、财政收支的集中期,这些都会让某几个时间窗口的流动性系统性偏紧或偏松。流动性一紧,折价就出来了;一松,估值就抬起来了。

这类解释的好处是可观测、可验证。你不需要相信“市场心理”,你只需要去拉同业拆借利率、回购利率、信用利差的月度序列,看看它们是不是真有季节性,再看看它们和股指月度收益的相关性方向对不对。我自己做验证时最看重这一步,因为如果资金面指标根本不存在季节性,那这条解释就是编出来的。

制度性节律还有一层:财报披露的时间表。定期报告有明确的披露截止日,于是信息释放被压缩到几个固定窗口里。窗口期内信息密度高、分歧大、波动高,窗口期外相对平静。这不必然导致方向性的月份效应,但会导致波动率的月份效应,而波动率效应在期权和波动率相关策略里的价值,往往比方向性效应更实在。

2.2 行为与考核因素

第二类是行为层面的。机构的考核周期、排名压力、申赎节奏,会让资金在特定时点做出非完全理性的选择。比如年初的“重新布局”倾向、年末的“锁定收益”倾向,这些行为叠加起来,可能形成方向性的月份偏差。

这类解释听起来很有说服力,但它的可证伪性很差。你很难找到一个干净的指标去度量“布局倾向”。所以我处理这类解释的方式是:不把它当作独立假设,而是当作前一类机制的行为放大器——先证明资金面确实有季节性,再看价格的反应幅度是否超出资金面本身能解释的部分,超出的部分才归给行为因素。

2.3 宏观数据与企业行为的日历节律

第三类是宏观数据的发布节奏。价格指数、景气调查、就业数据,这些都有固定的发布日历,市场会围绕这些时点重新定价。加上企业的分红除权、限售解禁、指数成分调整,这些事件都有自己的时间表。

这一类的特点是可枚举。你可以把所有已知的日历事件列成一张表,标注发生月份,然后看这些月份是不是恰好就是“效应显著”的月份。如果重合度很高,那你的“月份效应”很可能只是一个事件效应的聚合,本质上是事件在起作用,月份只是个马甲。这个区分非常重要,因为它直接决定了你的策略该怎么做——事件驱动需要精确到日,月份代理只能粗颗粒。

2.4 数据挖掘与幸存者偏差:必须优先排除的解释

第四类,也是最不讨喜但最该先排除的:你看到的效应,可能只是数据挖掘的产物。

十二个月份里挑一个最显著的,这个操作本身就会制造出“显著”的结果。假设十二个月份的真实效应全部为零,你在 5% 显著性水平下做十二次独立检验,至少有一次误判的概率是 1 - 0.95^12 ≈ 46%。接近一半。这还没算上你换了不同样本区间、不同标的池、不同收益计算方式之后又试了多少次。

我的做法是:在跑任何一次月份效应检验之前,先记录下我要检验的假设数量。一次性把十二个月全跑完,而不是跑一个月看一眼、不行再换一个月。前者是检验,后者是拟合。这个习惯养成之后,我“发现”的效应少了九成,但剩下的那一成,质量完全不一样。

2.5 我的取舍:先证伪,再证真

综合下来,我给自己定的判断顺序是这样的:

顺序检查项目的不通过的处置
1该月份效应在多个不重叠子样本中是否同向排除单区间偶然直接放弃
2是否存在可观测的机制指标(资金面、事件表)排除纯数据挖掘降级为观察项
3换成不同标的池、不同收益口径是否稳健排除口径依赖记录但不用
4扣成本后是否还有剩余排除不可交易只做研究不做交易
5滚动窗口下是否持续排除时间不稳定降低仓位权重

这个顺序的关键在于把证伪放在最前面。绝大多数被广泛传播的月份效应说法,卡在第 1 步或第 4 步就过不去了。

3. 数据工程:把日历对齐到交易日

概念想清楚了,接下来是最耗时间也最容易出错的环节:数据。我可以说一句实话,月份效应研究里八成的错误来自数据处理,不是统计方法。统计方法翻来覆去就那几个,数据处理的花样能让你怀疑人生。

3.1 数据源与字段的最小集合

做这件事,你需要的最小字段集合其实很少:标的代码、交易日、当日收盘价(必须是复权后的)、成交量。如果想做更细的分析,再加开盘价、最高最低价、成交额、换手率。

数据来源上,我一般用两类:一类是本地已经把行情落库的数据库,查询快、可以反复回溯;另一类是公开的财经数据接口,胜在起步快。前者适合做长周期、多标的的批量研究,后者适合快速验证想法。不管你用哪一种,有两件事必须确认:交易日历是不是完整的(缺了某几天,月度收益就会算错),复权方式是不是一致的(前复权、后复权、等比复权算出来的月度收益差别很大)。

提示:我强烈建议在本地维护一张独立的交易日历表,字段就是日期 + 是否交易日 + 所属年月 + 该月第几个交易日 + 该月倒数第几个交易日。这张表建一次能用很多年,后面所有和“月内位置”相关的计算都靠它,比每次现算靠谱得多。

3.2 复权、停牌、退市与生存者偏差

这四个词,每一个都能单独毁掉一次研究。

复权:算月度收益时,如果用不复权的价格,遇到分红送股,价格会跳空下跌,你会把它当成真实的负收益。这会系统性地污染某些月份的结果——因为分红除权在时间上是有集中性的。我的做法是统一用后复权价算收益率,这样历史区间内的收益率序列不会因为新的分红而发生变化,便于复现。

停牌:停牌期间没有价格。如果你简单地用“月末收盘价 / 上月末收盘价 - 1”,停牌跨越月末时就会拿到一个跨越多个交易日的收益,和别的观测不可比。处理方式有两种:一是标记并剔除这类观测,二是在标的池层面就排除掉长期停牌的标的。我倾向于前者,因为后者会引入选择偏差。

退市:这是生存者偏差的源头。如果你的标的池只包含“现在还在交易”的标的,那你等于提前知道了谁会活下来,回测结果会系统性偏好。正确做法是使用历史成分——在每个时点,标的池只包含当时真实存在的标的。这件事做起来麻烦,但如果你不做,后面所有的显著性检验都是在错误的样本上做的。

样本区间:还有一个小细节,样本区间不能随便截。如果区间起点恰好是一轮极端行情的底部,那么区间前几个月的平均收益会异常高,很容易造出一个假的“月份效应”。我的习惯是至少跨过两轮完整的市场周期,并且在报告结果时把区间边界写清楚。

3.3 月份标签的构造细节

标签构造看起来是小事,但这里的选择直接决定了你检验的是什么。

整月标签:直接用自然月编号 1 到 12。这里有个坑——如果某个月因为数据缺失只有十几个交易日,你照样算出了一个“月度收益”,但它和别的月份不可比。我的做法是给每个月加一个交易日数量的字段,把交易日数量明显偏少的观测标记出来,做敏感性分析时剔除掉看结果是否变化。

月内位置标签:用“该月第 N 个交易日”。我一般会构造三个窗口:月初窗口(第 1 到第 5 个交易日)、月中窗口(第 6 个到倒数第 6 个)、月末窗口(倒数第 5 到倒数第 1)。窗口长度的选择有讲究:太短,噪声大;太长,窗口之间会重叠,效应被稀释。5 个交易日大约对应一周,是一个比较自然的颗粒度。

跨月窗口标签:把“上个月最后 2 个交易日 + 本月前 3 个交易日”定义为一个连续的窗口。注意这个窗口跨越了月份边界,所以在按自然月分组的时候会被切开,必须单独构造标记列,不能靠月份字段推。

长假标签:长假前后的窗口值得单独标记。因为长假的长度和位置每年都在变,所以不能简单地用“几月”来代理,必须依赖交易日历。我会在日历表里加一列“距下一个长假的天数”和“距上一个长假的天数”,这样能精确切出长假前 N 日和长假后 N 日的窗口。

4. 统计检验:分清真效应与噪声

数据准备好了,接下来是检验。这一节我把“能算出数”和“能下结论”分开讲,因为这两件事之间的距离,比很多人以为的要远得多。

4.1 描述性统计:先把分布看清楚

在做任何检验之前,先看分布。这一步不能省。我至少要拿到这几个数:每个月的观测数、均值、中位数、标准差、胜率(收益为正的比例)、最大值、最小值。

为什么均值和中位数都要看?因为月度收益的分布通常不是对称的。如果某个月的均值很高但中位数接近零,那说明这个均值是被一两个极端月份拉起来的,这类“效应”在实盘里基本没法用——你没法保证自己恰好赶上那一两次。

为什么胜率也要看?均值回答“平均赚多少”,胜率回答“多大概率赚钱”。这两者对持有体验的影响完全不同。一个均值 1%、胜率 45% 的月份,意味着多数时候在亏,靠少数几次大赚把平均拉起来,这种规律对心理和资金管理的要求极高。

我给自己定的一个粗糙的筛选门槛是:均值方向和中位数方向一致,且胜率偏离 50% 至少 5 个百分点,且观测数不少于 15。三个条件同时满足,才值得进入下一步检验。这个门槛不严谨,但它能过滤掉大部分“看起来很厉害”的噪声。

4.2 显著性检验与那个绕不开的多重比较问题

单个均值的检验,用单样本 t 检验就行:t = 均值 / (标准差 / √观测数)。这个公式没什么可讲的,但有一个地方必须提醒——月度收益存在自相关,尤其当标的池里有大量同涨同跌的标的时,独立同分布的假设不成立,普通 t 检验会高估显著性。

我一般会同时报三个数:普通 t 值、Newey-West 调整后的 t 值(滞后阶数取 6 到 12)、以及一个非参数检验(比如 Wilcoxon 符号秩检验)的 p 值。三个数方向一致,我才认为这个月份值得继续看。方向打架的时候,以调整后的结果为准,并且把它当成“证据不足”处理。

举个具体的算例。假设某个月的月度收益均值是 0.8%,月收益标准差 7%,样本 240 个月。普通 t 值 = 0.008 / (0.07 / √240) = 0.008 / 0.004518 ≈ 1.77。这个值在双侧 5% 显著性水平下(临界值约 1.96)不显著。要让 t 值到 1.96,均值需要达到 1.96 × 0.004518 ≈ 0.885%。也就是说,0.8% 和 0.885% 这么小的差距,就决定了“显著”和“不显著”。这说明在这个样本量下,结论对参数极其敏感,任何一点数据口径的调整都可能翻转结论。这一点在做月份效应时尤其重要,因为月度数据本来就少。

接下来是多重比较。前面说过,一次跑十二个月,误判概率接近一半。修正方法我用两种:

第一种是Bonferroni 修正,把显著性阈值除以检验次数,即 0.05 / 12 ≈ 0.0042。这个方法保守,会牺牲统计功效,但简单可靠。

第二种是FDR 修正(Benjamini-Hochberg),控制错误发现率而不是族错误率。它比 Bonferroni 温和,在检验数量多的时候更实用。

我的实际做法是:主结论用 FDR,稳健性检查用 Bonferroni。如果某个效应只在普通 5% 水平上显著,两种修正都过不了,那它只配写在“观察项”里,不配写在“结论”里。

4.3 回归框架:把混淆变量控制住

分组统计是单变量的,它有一个致命弱点:没有控制其他变量。你看到的“某月效应”,可能是因为那个月的市场状态恰好都偏向某个风格。用回归可以把这个混淆剥开。

最基本的框架是把月度收益对十一个月份哑变量(留一个月做基准)回归:

r_t = α + Σ β_i · D_i + ε_t

这里的 α 是基准月份的平均收益,β_i 是第 i 个月相对基准月的平均差异。这个设定比“各自算均值”好在哪?好在你能直接得到差异的显著性,而且方便往里面加控制变量。

加什么控制变量?我一般加三类:上一期收益(控制动量或反转)、当期波动率(控制风险补偿的时变)、当期市场整体收益(控制 beta)。加了这些之后如果月份哑变量仍然显著,那这个效应的独立性就强得多。

更严格的版本是做跨标的的面板回归,用固定效应控制标的层面的差异,并聚类到时间维度计算标准误。这个版本计算量大一些,但对“效应是不是只存在于某几只标的”这个问题回答得更干脆。

4.4 滚动窗口与样本外:检验能不能活到明天

这是整节最重要的一条。全样本显著,不代表未来显著。我见过太多策略,全样本 t 值 3 以上,一上实盘就失效,原因就是效应在样本后期已经衰减甚至反向了。

我的做法是做滚动窗口统计:用 60 个月或者 120 个月的窗口,逐月滚动,每次都重新算一遍各个月份的平均收益,然后画成时间序列。看什么?看方向和强度是不是稳定。如果一个效应在前半段很强、后半段接近零,那它大概率是某个特定历史时期的产物。

再进一步是严格的样本外:把数据按时间切成前后两段,前段用来“发现”效应、确定参数,后段完全不看,直到最后才拿来验证一次。这里的关键是只验证一次。如果你在样本外看了结果之后又回去调参数、再验证,那样本外就变成了样本内,你只是把过拟合的战场往前推了一段。

5. 代码实现:一份可以直接跑的脚本

前面讲的都是思路,这一节把它落成代码。我选用的是最常见的 Python 技术栈,逻辑上分成五步:加载数据、算月度收益、打标签、做统计、做检验。

5.1 环境与依赖

需要的包不多:

pip install pandas numpy statsmodels matplotlib scipy

版本上没太多讲究,pandas 建议 1.5 以上(因为to_period("M")的行为在旧版本里有差异),statsmodels 用 0.13 以上,主要是为了 HAC 标准误的接口稳定。我用pandas做数据处理,numpy做数值计算,statsmodels做回归和多重比较修正,scipy做非参数检验,matplotlib出图。

5.2 数据加载与月度收益计算

假设手里有一张日频表,字段是code、trade_date、adj_close、volume,其中adj_close是后复权收盘价。

import pandas as pd import numpy as np # 1. 读取日频数据 df = pd.read_csv("daily_adj.csv", parse_dates=["trade_date"]) df = df.sort_values(["code", "trade_date"]).reset_index(drop=True) # 2. 打上年月标签 df["ym"] = df["trade_date"].dt.to_period("M") # 3. 每月取最后一个交易日的复权收盘价 monthly = ( df.groupby(["code", "ym"], as_index=False) .agg(close_last=("adj_close", "last"), close_first=("adj_close", "first"), n_days=("adj_close", "size"), vol_sum=("volume", "sum")) ) # 4. 计算月度收益率(按标的分别做 pct_change) monthly = monthly.sort_values(["code", "ym"]) monthly["ret"] = monthly.groupby("code")["close_last"].pct_change() # 5. 打上月份标签 monthly["month"] = monthly["ym"].dt.month # 6. 剔除样本不足的观测 monthly = monthly[monthly["n_days"] >= 10].dropna(subset=["ret"])

这里有两个细节值得说明。第一,为什么用“每月最后一个交易日的收盘价”而不是“每月最后一天的收盘价”?因为最后一天可能是周末或者假日,没有价格。取当月最后一个交易日的价格,是月度收益的标准算法。第二,为什么加n_days >= 10这个过滤?因为新股上市首月、长期停牌复牌首月,交易日数量会异常少,算出来的“月度收益”其实是几天甚至一天的收益,混进来会污染统计。

5.3 分组统计与可视化

def month_stats(s: pd.Series) -> pd.Series: s = s.dropna() n = s.count() if n < 5: return pd.Series(dtype="float64") mean = s.mean() std = s.std(ddof=1) t_naive = mean / (std / np.sqrt(n)) if std > 0 else np.nan return pd.Series({ "n": n, "mean": mean, "median": s.median(), "std": std, "win_rate": (s > 0).mean(), "t_naive": t_naive, }) stats = monthly.groupby("month")["ret"].apply(month_stats).unstack() print(stats.round(4))

apply里返回 Series 再unstack这个写法,比一个个字段单独groupby().agg()更整齐,尤其是你后面想加更多指标的时候。

画图我一般画两张:一张是各月份平均收益的柱状图(正负用不同颜色区分更直观,但不用花哨的配色),另一张是各月份收益分布的箱线图。箱线图能看到均值看不到的东西——离散程度和异常值。有些月份的均值看着漂亮,箱体却特别长,说明那个均值没什么代表性。

import matplotlib.pyplot as plt fig, axes = plt.subplots(1, 2, figsize=(12, 4)) stats["mean"].plot(kind="bar", ax=axes[0], color="#4c72b0") axes[0].set_title("Mean monthly return by month") axes[0].set_xlabel("Month") axes[0].set_ylabel("Return") monthly.boxplot(column="ret", by="month", ax=axes[1]) axes[1].set_title("Distribution by month") plt.suptitle("") plt.tight_layout() plt.show()

提示:boxplot(by=...)会自动加一个难看的总标题,用plt.suptitle("")清掉。这种小细节不影响结论,但影响你把图给同事看时的心情。

5.4 回归与显著性检验

分组统计跑完之后,用回归拿更严格的结果。注意哑变量编码时的基准选择:

import statsmodels.api as sm from statsmodels.stats.multitest import multipletests # 用 1 月作基准(drop_first 会丢掉第一个类别) dummies = pd.get_dummies(monthly["month"], prefix="m", drop_first=True).astype(float) X = sm.add_constant(dummies) y = monthly["ret"] model = sm.OLS(y, X, missing="drop").fit( cov_type="HAC", cov_kwds={"maxlags": 12} ) print(model.summary())

这里cov_type="HAC"就是 Newey-West 调整,maxlags=12对应一年,是月度数据常用的取值。系数含义要记清楚:截距项是 1 月的平均收益,其余每个系数是“该月相对 1 月的平均差异”。如果你不想让 1 月当基准,可以在pd.get_dummies之前把月份标签重排,让基准变成你更关心的那个月。

多重比较修正:

pvals = model.pvalues.drop("const") reject_fdr, pval_fdr, _, _ = multipletests(pvals, alpha=0.05, method="fdr_bh") reject_bonf, pval_bonf, _, _ = multipletests(pvals, alpha=0.05, method="bonferroni") result = pd.DataFrame({ "coef": model.params.drop("const"), "p_raw": pvals, "p_fdr": pval_fdr, "p_bonf": pval_bonf, "sig_fdr": reject_fdr, "sig_bonf": reject_bonf, }) print(result.round(4))

跑完之后,我建议把sig_fdr和sig_bonf同时摆出来看。两个都是 True 的,才进“候选”;只有一个 True 的,进“观察”;两个都是 False 的,直接扔。

5.5 结果落表与可复现性

最后一步,把结果存下来。这一步很多人省略,然后过两个月想复现的时候发现完全不记得当时用了什么参数。

meta = { "sample_start": str(monthly["ym"].min()), "sample_end": str(monthly["ym"].max()), "n_obs": int(len(monthly)), "n_codes": int(monthly["code"].nunique()), "ret_def": "monthly_last_adj_close_pct_change", "min_days_filter": 10, "hac_lags": 12, } with pd.ExcelWriter("month_effect_report.xlsx") as writer: stats.to_excel(writer, sheet_name="desc") result.to_excel(writer, sheet_name="regression") pd.Series(meta).to_excel(writer, sheet_name="meta")

meta这张表看着不起眼,但它救过我很多次。有一次我做两个不同标的池的对比,结果差得离谱,翻了半天代码没找到原因,最后是meta里那行ret_def提醒我——两次用的复权口径不一样。

6. 从统计显著到可交易:回测与现实摩擦

写到这里,我得说一句可能让人不太舒服的话:统计显著和能赚钱,是两件几乎独立的事。我做过不少“显著但不可交易”的检验,也见过不少“不显著但能赚钱”的规则。它们之间的桥梁,是成本、容量和执行。

6.1 成本、滑点与容量:先把这三座山搬开

月度频率的策略,听起来换手很低,但真实成本往往被严重低估。原因有三:

第一,单边费用是复合的。佣金、印花、过户费加起来,一次完整买卖的成本可能在万分之几到千分之一不等。看起来不多,但如果你的月度收益差异只有 0.3%,而一次调仓成本是 0.15%,那你需要至少一半以上的月份方向判断正确,才可能覆盖成本。这个门槛比大部分人想的高。

第二,滑点在低频策略里更容易被忽略。因为调仓次数少,很多人就不做滑点建模了。但如果你的调仓恰好集中在月末最后一天、或者长假前最后一天,那几天的流动性往往是一整个月里最差的,滑点会显著放大。这是一个很隐蔽的坑:你的策略逻辑要求你在流动性最差的时点交易。

第三,容量限制。小市值标的上的月份效应往往更明显,但那些标的的成交额本来就小。你的资金量一大,买入就把价格推上去了,超额收益在成交的瞬间就被自己吃掉了。我一般会做一个粗略的容量估算:单标的单日成交金额的 10% 作为可交易上限,再看这个上限能支撑多大的组合规模。

成本项常见量级在月度策略中的影响我的处理方式
佣金万分之几直接扣减收益按实际费率硬编码
印花及规费千分之一级别(卖出)单边不对称,影响调仓方向分开建模买卖
滑点万几到千几,流动性差时更大月末月末窗口被放大按成交额分层设滑点
冲击成本与规模非线性相关大资金致命容量上限约束
机会成本难以量化涨停、停牌无法成交回测中禁止成交

6.2 规则设计的几个硬约束

从检验结果到交易规则,中间有几个约束必须提前想清楚,否则回测出来的曲线毫无意义。

约束一:规则必须在事前可知。你不能用“本月收益为正”来定义信号,因为月初你不知道。所有信号只能基于上一个月的已知信息构造,比如“上个月是几月”“上个月最后几天的表现”。这个约束听起来是废话,但我在别人的代码里见过不少前视偏差,尤其是做月内位置效应的时候,很容易不小心把当天的收益算进信号里。

约束二:持仓数量要有限制。如果你做的是一个“某月买入全市场”的规则,那回测里根本不需要考虑选股,直接用指数收益就行。但如果你的标的池是几百只股票,全买不现实,必须限制持仓数量,这时候选哪几只就成了新的自由度——又是一个过拟合的入口。

约束三:空仓也是一种仓位。很多月份效应的规则是“某月满仓、其余月份空仓”。这种规则在回测里看着很美,因为它天然带了一个择时。但你要意识到,空仓期间你的资金是有机会成本的,而且如果市场在那段时间大涨,你的心理压力会非常大。我个人更倾向于不做全空仓的规则,而是做仓位的相对调整,比如某月 80% 仓位、其余月份 50% 仓位,这样曲线的可接受度高得多。

6.3 一个可复现的月度轮动样例

下面这段是框架示意,不是可以直接上实盘的策略。假设前面的检验已经确认某两个月份显著偏强,我想看看扣成本之后还剩多少。

# 说明:这是框架示意,参数需要你自己用前面的流程检验后填入 # 严禁把这里的示例参数直接当策略使用 TARGET_MONTHS = [2, 11] # 举例:假设检验后选出的月份 COST_ONE_WAY = 0.0008 # 单边成本假设 SLIPPAGE = 0.0005 # 滑点假设 monthly = monthly.sort_values(["code", "ym"]).copy() # 信号:基于上个月的已知信息,判断本月是否进入目标月份 monthly["signal"] = monthly["month"].isin(TARGET_MONTHS).astype(int) # 实际持仓应该滞后一期:本月末才知道本月是什么月,所以用本月信号决定下个月持仓 monthly["pos"] = monthly.groupby("code")["signal"].shift(1).fillna(0) # 组合收益:简单等权,假设标的池内所有标的按信号等权持有 port = (monthly[monthly["pos"] == 1] .groupby("ym")["ret"] .mean() .rename("gross")) # 换手与成本:每次仓位发生变化时计一次换手 turnover = (monthly.groupby("ym")["pos"].mean().diff().abs().fillna(0)) port = port.to_frame() port["turnover"] = turnover port["net"] = port["gross"] - port["turnover"] * (COST_ONE_WAY * 2 + SLIPPAGE * 2) # 累计净值 port["nav"] = (1 + port["net"].fillna(0)).cumprod() print(port.round(4).tail(12))

这段代码里有几个地方值得单独说。

shift(1)那一行是关键。很多人写回测的时候会写成“本月是目标月份就在本月建仓”,这就引入了前视偏差——你在月初并不知道这个月会表现如何,但你的代码已经用上了。正确的做法是:用已经结束的上个月的信息,决定本月的仓位。这里的shift(1)做的就是这件事。

turnover的计算也简化了。真实换手应该按标的逐个计算,我这里用的是“仓位比例的绝对变化”,好处是简单直观,坏处是低估了标的层面的换手。做正式回测的时候,我会改成逐标的计算买入量和卖出量,分别套用不同的成本。

成本那一行里,COST_ONE_WAY * 2是因为一次完整调仓有买有卖两个方向,SLIPPAGE * 2同理。如果你的规则在两次调仓之间没有持仓变化,turnover就是零,不产生成本;如果有变化,每次都扣。这个建模不完美,但它至少能让你看到成本对曲线的实际侵蚀程度。

跑完之后,我的习惯是把gross和net两条曲线画在一起。如果两条线贴得很近,说明策略的换手很低,成本不敏感;如果net明显矮一截,那你就要认真考虑这个效应到底值不值得做。

7. 常见问题与排查实录

这一节是我这些年攒下来的问题清单,都是我真实遇到过、并且花了时间才搞明白的。

7.1 问题速查表

现象可能原因排查方法处理方式
某个字 t 值高得离谱(5 以上)前视偏差或重叠窗口检查信号是否用了未来数据去掉未来信息后重跑
只有几个月有效应,其余接近零少数极端月份拉动均值看中位数和胜率改为看中位数或截尾均值
换成等权/市值加权后结论翻转效应集中在小市值做分层检验明确效应存在的层级
全样本显著,样本外衰减效应被套利掉了滚动窗口降低权重或放弃
月度收益出现异常大值分红送股未复权比对复权因子统一后复权口径
某几个月观测数明显偏少数据缺失或标的池变动统计每月交易日数加最小交易日过滤
加入成本后完全不赚钱换手高、成本敏感拆解成本贡献降低换手或改频率

这张表里我最想强调的是第二行。“只有几个月显著”这件事本身,往往就是数据挖掘的信号。真实存在的效应,通常在相邻月份上会有一定的溢出,不会像开关一样精确地只在那一个月里生效。如果你发现效应非常“精准”地只落在一个月份上,反而更应该怀疑。

7.2 我踩过的坑

坑一:把月度收益算成了自然月的收益。早期的脚本里,我直接用resample("M")拿到每个自然月最后一天的价格。问题是最后一天经常不是交易日,resample会取该月最后一个有数据的日子,但如果有停牌,那天的数据可能是空的,于是这个月的“收盘价”其实来自更早的日期。后来我改成先建交易日历表,按“该月最后一个交易日”精确取价,问题才消失。

坑二:忽略标的池的历史变动。我做过一次检验,某个效应的显著性非常漂亮。后来同事问我一句:“你这三百只标的,是从哪一年开始凑齐的?”我才发现,标的池是按现在的成分回溯的,早年根本不存在那么多标的。修正之后,显著性掉了一大半。这个坑很经典,也很致命。

坑三:多重比较没做,被自己的好奇心坑了。一开始我做检验的流程是:跑一个月,不显著,换一个月,再不显著,换成另一个标的池试试。这种“探索式”的路径下,我最终一定会找到一个显著的结果,但那个结果的真实含义是零。现在我强制自己一次性写完全部十二个月的检验脚本,跑一次出结果,绝不中途看单个结果然后再调整。

坑四:交易成本建模太乐观。我最早的回测里,成本是一个固定值,不区分标的、不区分时点。后来把滑点按成交额分了三档,结果发现目标月份恰好集中在月末,而月末那几天的成交额是整月最低的那一档,滑点直接翻了三倍。成本模型一改,原本年化超额十来个点的策略变成了六七个点。这个教训让我后来所有的低频回测,都会专门检查“调仓日是否落在流动性低谷”。

坑五:把有效性当成永久属性。我有一段时间很迷信某几个月份,觉得这个规律会一直在。后来的滚动窗口分析显示,这个效应在样本后半段已经基本消失了,剩下的那点微弱差异,扣完成本之后是负的。市场不是静态的,一个被广泛知晓的规律,会被资金交易掉。你现在看到的显著,很可能只是它过去显著留下的残影。

最后分享一个我自己一直在用的小习惯:每做完一次月份效应检验,无论结果好坏,我都会在笔记本上写三行——这次检验了什么、用了什么口径、结论是什么(包括“无结论”)。这个习惯看起来笨,但半年后你回头看,会发现最有价值的信息往往不是那些“显著”的结果,而是你曾经在多少个不同的口径下反复试过同一件事。那些反复试探留下的记录,恰好是判断自己有没有在过拟合的最好证据。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进