资讯动态

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

发布时间:2026/10/27 22:37:25 来源:尧图企业网站定制
每个月月底做复盘的时候我都会把那张“按月份分组的收益率表”重新翻出来看一眼。这张表看上去普普通通一列月份、一列平均收益、一列胜率但它背后代表的东西是股票数据里的日历效应中最容易被讨论、也最容易被误读的一类——月份效应。简单说它研究的就是“某一个自然月份或者某个月的特定几天的收益率是否系统性地偏离其他月份”。它能帮你回答几个很实际的问题季度考核、长假、财报披露、资金面松紧这些带日历节奏的因素到底有没有在价格上留下可测的痕迹这些痕迹是统计上的巧合还是能在样本外重复出现即便重复出现扣掉交易成本之后还剩多少。这篇内容适合两类人看一类是刚接触量化、手里已经有一份日频行情数据、想动手做第一份因子检验的人另一类是做了几年投研、见过太多“一月份一定涨”这类说法、想把这件事彻底拆干净的人。我下面写的东西全部围绕一条完整链路展开从概念界定到数据清洗到统计检验到代码落地再到回测与踩坑。中间会给出可以直接抄的参数、公式和脚本片段也会说明每一步“为什么要这么干”。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(dropTrue) # 2. 打上年月标签 df[ym] df[trade_date].dt.to_period(M) # 3. 每月取最后一个交易日的复权收盘价 monthly ( df.groupby([code, ym], as_indexFalse) .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(dtypefloat64) mean s.mean() std s.std(ddof1) 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(kindbar, axaxes[0], color#4c72b0) axes[0].set_title(Mean monthly return by month) axes[0].set_xlabel(Month) axes[0].set_ylabel(Return) monthly.boxplot(columnret, bymonth, axaxes[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], prefixm, drop_firstTrue).astype(float) X sm.add_constant(dummies) y monthly[ret] model sm.OLS(y, X, missingdrop).fit( cov_typeHAC, cov_kwds{maxlags: 12} ) print(model.summary())这里cov_typeHAC就是 Newey-West 调整maxlags12对应一年是月度数据常用的取值。系数含义要记清楚截距项是 1 月的平均收益其余每个系数是“该月相对 1 月的平均差异”。如果你不想让 1 月当基准可以在pd.get_dummies之前把月份标签重排让基准变成你更关心的那个月。多重比较修正pvals model.pvalues.drop(const) reject_fdr, pval_fdr, _, _ multipletests(pvals, alpha0.05, methodfdr_bh) reject_bonf, pval_bonf, _, _ multipletests(pvals, alpha0.05, methodbonferroni) 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_namedesc) result.to_excel(writer, sheet_nameregression) pd.Series(meta).to_excel(writer, sheet_namemeta)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会取该月最后一个有数据的日子但如果有停牌那天的数据可能是空的于是这个月的“收盘价”其实来自更早的日期。后来我改成先建交易日历表按“该月最后一个交易日”精确取价问题才消失。坑二忽略标的池的历史变动。我做过一次检验某个效应的显著性非常漂亮。后来同事问我一句“你这三百只标的是从哪一年开始凑齐的”我才发现标的池是按现在的成分回溯的早年根本不存在那么多标的。修正之后显著性掉了一大半。这个坑很经典也很致命。坑三多重比较没做被自己的好奇心坑了。一开始我做检验的流程是跑一个月不显著换一个月再不显著换成另一个标的池试试。这种“探索式”的路径下我最终一定会找到一个显著的结果但那个结果的真实含义是零。现在我强制自己一次性写完全部十二个月的检验脚本跑一次出结果绝不中途看单个结果然后再调整。坑四交易成本建模太乐观。我最早的回测里成本是一个固定值不区分标的、不区分时点。后来把滑点按成交额分了三档结果发现目标月份恰好集中在月末而月末那几天的成交额是整月最低的那一档滑点直接翻了三倍。成本模型一改原本年化超额十来个点的策略变成了六七个点。这个教训让我后来所有的低频回测都会专门检查“调仓日是否落在流动性低谷”。坑五把有效性当成永久属性。我有一段时间很迷信某几个月份觉得这个规律会一直在。后来的滚动窗口分析显示这个效应在样本后半段已经基本消失了剩下的那点微弱差异扣完成本之后是负的。市场不是静态的一个被广泛知晓的规律会被资金交易掉。你现在看到的显著很可能只是它过去显著留下的残影。最后分享一个我自己一直在用的小习惯每做完一次月份效应检验无论结果好坏我都会在笔记本上写三行——这次检验了什么、用了什么口径、结论是什么包括“无结论”。这个习惯看起来笨但半年后你回头看会发现最有价值的信息往往不是那些“显著”的结果而是你曾经在多少个不同的口径下反复试过同一件事。那些反复试探留下的记录恰好是判断自己有没有在过拟合的最好证据。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑