做美股实证研究这行当,我一直有个体会:越不起眼的底层数据,越容易在后面某个深夜变成拦路虎。前阵子帮一个做并购事件研究的团队复核结果,他们用自然日去对齐公告日和收益数据,算出来的异常收益曲线怎么看怎么别扭。问题最后定位到“日期基准”上:整个研究用的是日历日,但股票市场只按交易日运行。就这么一个看似基础的点,能让之前所有统计结果的可信度大打折扣。CnOpenData的美股上市公司交易日历数据,解决的就是这种“研究地基”层面的问题。它记录的是美股市场每天真实的交易状态,包括休市、提前收盘等安排,面向做量化回测、事件研究、面板数据清洗的金融研究者和数据分析师。这篇内容我就结合自己倒腾美股的实战经验,把这套数据的用法、坑点和基础设施建设一次说透。
1. 日子不对,一切白跑:为什么交易日历是你最容易忽略的主表
1.1 自然日、工作日、交易日:三种时间坐标用混会怎样
绝大多数人在处理时间序列时,大脑默认的时间轴是一页日历,从1号到31号,从周一到周日。但股票数据不是这么运行的。市场在周末不产生交易,节假日不产生交易,台风来了可能也不产生交易。于是同一段研究区间内,自然日的数量、工作日的数量、交易日的数量是三个完全不同的数字。
我见过很多次类似代码:直接用pd.date_range生成一个自然日序列,然后去merge行情数据,结果合并完发现大量NaN。这就是因为自然日序列里有太多“市场根本没开门”的日子。更隐蔽的问题是,如果用自然日做滚动窗口,比如计算过去30天的累计收益,一个跨越长周末的窗口和一个正常交易周的窗口,真实信息含量完全不一样,但模型不这么认为。
在金融计量里,“交易日”才是一条独立的时间索引。日收益率是相邻两个交易日收盘价之间的对数差,波动率按交易日次数年化,事件研究里的“事件前第10天”指的是T-10这个交易日,而不是10个自然日前的某个日期。这些计算一旦用了自然日,结果的性质就变了,这不是精度问题,是定义问题。
1.2 CnOpenData这套日历数据的真实定位
很多人第一反应是:交易日历不就是把节假日列出来吗?我自己写一个也行。这话对了一半。常规的元旦、圣诞节、感恩节当然可以手工维护,但交易日历的真实价值在于它是一张“市场运行状态表”,而不是简单的放假列表。
以我实际拿到过的美股日历类数据为例,通常包含几个核心维度:具体日期、当天是否开盘交易、是否提前收盘、所属交易所标识、休市或特殊安排的说明。这样一张表端出来后,它就不是给你查“哪天放假”用的,而是给你做数据对齐、窗口切分、频率转换、策略调度用的基准主表。
CnOpenData这套美股上市公司交易日历数据的价值就在这里:它把纽交所、纳斯达克这些主流交易所一个年度周期内的所有交易状态变化都整理成结构化数据,省去了到处翻交易所公告、逐条确认历史特殊休市的工作。对做学术研究的人来说,手里有一份可以被引用的、权威的日历数据,意味着你的结果可复现、可核查,这在论文审稿和答辩时特别重要。对做量化的人来说,它是回测系统里最底层的那张“交易日主表”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 拿到日历数据后的三个“先手检查”
2.1 覆盖年份和频率:小心新联邦假日的漏网之鱼
数据到手后,不要急着去写合并代码,先花十分钟做三个检查,能省下后面两天排查时间。
第一就是年份覆盖和频率。美股市场有一个让很多人措手不及的变化:从2022年开始,纽交所正式把六月节定为休市日。2021年六月节因为落在周六,纽交所选择在6月18日(周五)休市,但那时候它还不是一个常态化假日;2022年开始,每年6月19日都闭市。如果你的交易日历是2021年之前手工维护的,大概率没有这个节日,结果就是这几年每年的六月会多出一个“你以为在交易、实际市场关门”的日子,回测里凭空多出一根不存在的收益线。
所以我拿到日历数据后,第一件事就是画一个每年交易日数量的直方图,看有没有异常年份。美股正常年份大概有251到252个交易日,如果某年出现260以上或者240以下,我就要去核对这一年发生了什么。靠数字本身就能筛出一大批数据质量问题的先兆。
2.2 提前收盘日标记:日收益和分钟波动率的分水岭
第二是看数据里有没有提前收盘日的标记。美股市场不仅是“全开”和“全关”两种状态,还有“提前收盘”这种中间态。典型的半天交易日包括感恩节次日和圣诞前夕,这两天的市场通常只交易到美东时间下午1点。
如果你做的是日频收益分析,提前收盘日可能影响不大,收盘价还是那个收盘价。但如果你做的是分钟级数据、交易量分布、高频波动率统计,半天交易日就是完全不同的市场环境:交易时长从6.5小时缩短到4小时,成交量和波动率形态都会出现结构性变化。这种情况下,研究样本里必须对半天交易日做出标记,要么单独建模,要么剔除,绝不能让它和普通交易日混在一起。
我习惯的做法是,在所有后续计算里都保留这一列标记,哪怕当时用不到。因为研究设计后期一旦切入高频或者日内结构,这列标记就是救命稻草,不用再回去翻历史公告。
2.3 一张日历还是分交易所日历:合并前先搞清楚
第三是确认数据是按“全市场统一日历”给的,还是按交易所分开给标的。纽交所和纳斯达克在绝大多数情况下保持一致,节假日休市安排完全同步,但极端年份会有差异,比如自然灾害导致某交易所临时关闭而另一个还在运行,或者个别交易所因为技术系统切换暂停部分交易时段。
如果你的研究标的包括跨市场上市公司,或者你后面想扩展到其他美股交易所,就得确认日历数据的粒度。如果数据是按交易所维度组织的,合并行情时就需要把交易所ID一并带出来,作为关联键的一部分;如果数据是全市场统一的,但你的样本里有ADR、优先股等特殊证券,也要多想一层:它们的“交易日”是否完全跟随主板市场。搞清楚数据结构再动手写代码,永远比写完全部代码再回去返工省时间。
3. 交易日历在实证研究里的高频用法拆解
3.1 事件研究法:用交易日序号代替日期字符串
事件研究法(Event Study)是金融实证里最经典的场景之一,用来度量某个事件对股票收益的影响。它要求把样本中的每一只股票按事件发生日对齐,然后计算事件窗口内的异常收益。这个“对齐”操作如果只用日期字符串来做,会遇到一个麻烦:不同年份、不同月份之间,节假日的分布是错位的,事件前第5天到底是哪一天,没法从普通日期直接推算出来。
正确做法是先把交易日历转换成一个“交易日序号”字段。比如用日期排序后,给每个交易日分配一个从1开始递增的序号。事件日标成T=0,事件前一个交易日就是T-1,事件后第三个交易日就是T+3。所有的窗口选择、估计窗口长度、事件窗口长度,全部基于这个序号字段进行运算,而不是用pd.Timedelta(days=N)去日期上加天数。
我实际写事件研究代码时,会先把日历表变成一个字典或查找表:日期到交易日序号的映射、交易日序号到日期的映射。两条映射关系在手,任何事件日都能在O(1)时间内定位到它在交易日时间轴上的位置。然后估计窗口、事件窗口、剔除停牌期、计算异常收益,这些逻辑全部变得非常干净。
python复制# 假设 cal_df 是交易日历,含 date 和 is_open 列
open_days = cal_df[cal_df["is_open"] == 1].copy()
open_days["td_idx"] = range(1, len(open_days) + 1)
date_to_idx = dict(zip(open_days["date"], open_days["td_idx"]))
idx_to_date = dict(zip(open_days["td_idx"], open_days["date"]))
后面所有的窗口计算都通过这张映射完成,不需要再碰自然日加减法。
3.2 收益率、年化波动率与252的来历
第二个高频用法是收益率和波动率的计算。做美股日频研究的人对252这个数字不会陌生——一年大约有252个交易日,年化波动率就是日波动率乘以根号252。但很少有人意识到,这个252不是拍脑袋定的,而是精确地来源于交易日历。
如果你用的日历数据某一年只有250个交易日,那这一年年化波动率就应该用根号250去乘,因为从数学上讲,年化因子的作用是估算一年内收益波动的累加次数,它应该匹配这一年内实际交易的天数。很多研究在一头一尾跨年时会出现年化波动率的微小跳变,原因就在这。
再往细处说,如果你研究日收益率,处理停牌日和多日间隔时也要靠交易日序号。周五到下周一之间只隔一个交易日,周三到周四也是隔一个交易日,虽然自然日跨度不同,但在日收益序列里它们都是相邻观测。而遇到长假期时,某个“日收益率”可能是跨越了好几个自然日才形成的,在收益的分布上,它和正常隔夜收益的性质不完全一样。想严格一点,可以在收益率计算时同时生成一个“间隔交易日数”字段,标记间隔天数大于1的观测,做稳健性检验时单独走一遍。
3.3 定期调仓策略中“每月第一个交易日”的实现
第三个场景是策略日程调度。很多资产配置策略以月度调仓,但“每月调仓”不等于“每月1号调仓”。遇到月初是周末或者节假日,调仓动作会顺延到下一个交易日。如果直接用MonthBegin这类日期工具生成调仓日,然后去行情数据里找当天的价格,轻则拿到NaN,重则在回测里引入了未来数据或空窗期。
用交易日历做这件事非常简单:生成目标月份的1号之后,在日历表里寻找第一个is_open == 1的日期,那就是真正的调仓交易日。更通用的做法是建立一个函数:给定任意日期,返回下一个非交易日的交易日。
python复制def next_trading_day(d, open_days_series):
# 从给定日期开始,找到第一个交易日
return open_days_series[open_days_series >= d].iloc[0]
这个逻辑在周度调仓、季度调仓、逢假顺延的场景里完全通用。我在自己的回测框架里,所有日程节点都走同一个函数,不再在策略代码里散落着各种硬编码的节假日判断,回测的可维护性提升非常明显。
4. 真实数据环境下避不开的四个硬坑
4.1 时区错位:周五晚上买入属于哪个交易日
做美股研究,最容易被坑的其实是时区问题。美股交易时段对应北京时间是晚上到凌晨:夏令时是晚上21:30到次日凌晨4:00,冬令时是晚上22:30到次日凌晨5:00。这意味着一个美股交易日,在自然日维度上跨越了两个日期,比如北京时间的周五晚上,其实是美股时间周五的交易时段。
如果你把数据的时间戳统一存成北京时间,然后按自然日分组,周五的美股行情会被分为两半:一部分在周五,一部分在周六凌晨。这是一个极其常见的坑,我见过不止一个人在这个问题上得出“周五有极大异常波动”的错误结论。
我的建议是,凡是处理美股日内或日频数据,统一以America/New_York时区为准保存时间戳,到展示和报告环节再转换成本地时间。交易日历数据本身通常以纽约当地日期为基准,这正好和行情数据的时区基准对上。如果你拿到的行情数据时间戳是UTC或北京时间,先转换,再做任何分组和聚合操作。
4.2 特殊休市事件:2020年以后日历不再只是“放假表”
大家习惯性认为交易日历就是“节假日 + 周末”,但在真实历史里,美股市场出现过多次非常规休市。2020年疫情冲击初期,市场出现剧烈波动,但那是正常交易;反而是2012年10月底飓风桑迪袭击纽约,纽交所连续休市两天。更早的2001年“911”事件之后,美股连续休市将近一周才重新开盘。2018年12月5日,为了悼念老布什逝世,纽交所和纳斯达克全天才休市一天,措手不及的人还以为是数据缺失。
这种特殊事件不会出现在任何一张固定的节假日表里,必须靠权威的交易日历数据来记录。这也是为什么我强调要使用像CnOpenData这种系统整理过的交易日历,而不是自己常年维护一个“节假日列表”。一张只包含固定节假日的表,本质上是对市场运行状态的简化模型,遇到非常规事件就失灵了。研究区间如果拉得长,回到2000年初,这类特殊休市至少会碰到几次,漏掉其中任何一次,都等于在你的回测时间轴里插入了一个不存在的“幽灵自然日”。
4.3 个股停牌不是交易日历能解决的问题
另一个容易被混淆的概念是“市场交易日”和“个股可交易日”。股市开门,不代表每一只股票都在交易。公司可能因为重大事项停牌,可能因为股价异动被临时暂停交易,可能在退市整理期,也可能因为代码变更导致某天没有行情记录。
我在处理面板数据时,长期遵守一条原则:市场级别的日历对齐一次性做完,证券级别的缺失另开一套处理逻辑。先用交易日历生成一张完整的市场交易日主表,再往表上挂行情数据,挂不上的位置就是证券级缺失,这些缺失里有停牌、有暂停上市、也可能有数据商漏采。如果这一步不分开,把所有缺失都当成“当天没交易”,后面计算组合收益、计算指数暴露时就会系统性低估收益频次。
4.4 第三方日历库推算结果与官方数据的偏差
现在很多Python库能自动推算美股交易日历,比如exchange_calendars、pandas_market_calendars这些工具包。用它们生成交易日序列很方便,但我提醒一句:第三方库的底层数据也是由人维护的,不是交易所的官方口径,它有可能在特殊休市事件、提前收盘调整这些细节上滞后或出错。
我在生产环境里有一条铁律:第三方库只用来做快看和原型验证,正式研究必须过一遍权威交易日的明细数据,手工随机抽几个日期和交易所历史公告核对。尤其是跨年、跨十年这种长区间研究,第三方库一旦有一个特殊日子没更新,错误会沿着时间索引传导到所有下游计算。CnOpenData这类结构化数据库的价值就在于它有明确的整理出处和更新周期,能在源头减少第三方推算带来的偏差。
5. 把交易日历变成可复用的研究基础设施:我的Pandas流水线
5.1 从原始表到规范交易日表的一次性清洗
日历数据如果每年都手工处理一遍,不仅低效,而且容易每次清洗逻辑不一致。我建议第一次拿到数据时,就把它清洗成一张标准的“宽表”,之后所有研究项目都复用同一份结果。
我的清洗流程大概是这几步:先把日期字段统一成datetime64类型;再按日期排序、去重;然后生成交易日序号;接着把提前收盘标记、休市原因等附加字段归并进来;最后给数据加上版本号。这样后面每一个项目都用同一份底稿,无论是做沪深还是美股,流程都按统一标准来,逻辑链不会断。
下面是我经常用的一个简化版清洗逻辑:
python复制import pandas as pd
cal = pd.read_csv("cnopendata_us_trading_calendar.csv", parse_dates=["date"])
cal = cal.sort_values("date").drop_duplicates(subset="date")
# 生成交易日序号,只在 is_open 为真的行上递增
cal["td_idx"] = cal.groupby((cal["is_open"] != cal["is_open"].shift()).cumsum()).cumcount() + 1
cal.loc[cal["is_open"] == 0, "td_idx"] = None
注意上面这个写法只适合快速生成序号,严谨做法是先取出is_open==1的子集再独立编号,然后合并回去,核心思想是用“交易日序号”表示市场时间轴,而不是用日期本身的先后关系硬凑。
5.2 行情数据对齐的推荐顺序
行情数据对齐时,我见过的错误操作是先resample('D')到自然日,再做前向填充,生成一张巨大的稀疏日期表,把所有非交易日都填充出来。这种方法在数据量小的时候看不出问题,一旦标的多了、年份长了,表体积膨胀得厉害,而且大量填充出来的“重复交易日”会让后续统计产生偏差。
我推荐的做法是反向操作:以交易日历为基准,过滤行情表,只保留交易日当天的观测。具体来说,先把行情数据的日期列转换成和日历一致的时区基准,然后inner join交易日表,一张干净的市场级行情面板就出来了。如果行情数据有少量日期在日历里找不到,这是正常的证券级缺失或数据商问题,再单独排查。
用merge_asof做不等连接也是一个高效方案,它能按时间先后把最近的日历日期匹配到行情记录上。但要注意顺序:先按时间排序,再指定direction="backward",这样才能保证每条行情记录匹配到“最接近且不晚于它”的那个交易日节点。
5.3 存储与版本管理建议
日历数据本身很小,一年不到300行,但它的版本管理意义大于数据量。我今天拿到的日历和半年前拿到的可能就差几个特殊休市日,但这种差别会影响所有下游结果。所以我会把日历数据的来源、下载日期、下载时覆盖的年份范围、是否有手工修正,全部记在一个README或数据配置文件里。
存储格式上,我建议用Parquet保存清洗后的标准表,既保留数据类型又压缩体积,读取速度也快。如果你是在团队里协作,把这张表放在共享的数据目录里,所有成员统一读取同一版本,能避免各人电脑里维护着不同的节假日列表导致结果对不上。这看上去是很小的一件事,但我在实际协作中踩过太多次“两人结果对不上,最后发现是日历版本不同”的坑。
我个人这两年养成了一个习惯:开任何新研究前,先把日历数据版本写进项目配置里。然后随机抽三个历史日期,一个选在感恩节附近,一个选在年末提前收盘日,一个选在普通周一,手动核对这些日期在行情数据和日历数据里的状态是否一致。三项检查都过了,我才敢让下游数据计算放心跑。这套动作看着笨,但在多个项目里替我拦截过至少五六次数据错配问题。数据研究这行,慢就是快,地基打牢了,上面的楼才敢往上盖。
