📎 配套代码:
第18讲_时间类型与索引.py
📊 配套数据:data/quote/—— 交易数据(沪深300 × 2024 年以来,仓库自带,开箱即跑)
🎬 开场:字符串日期能走多远
第 17 讲要按月分组,用的是从字符串切一段:
df["年月"] = df["trade_date"].str[:6] # "20240102" → "202401"
能用。但换几个需求就走不动了:
| 需求 | 字符串日期 |
|---|---|
| 2024 年 1 月的数据 | 可以,str[:6] == "202401"
|
| 最近 20 个交易日 | 做不到,只能靠位置 |
| 所有周五的收盘价 | 做不到 |
| 按周、按月重采样 | 做不到 |
| 距离上次财报公告过了几天 | 做不到 |
把它转成真正的时间类型之后:
s = px.set_index(pd.to_datetime(px["trade_date"], format="%Y%m%d"))["close"].sort_index() s["2024"] # 整年 242 条 s["2024-01"] # 整月 22 条 s["2024-01":"2024-03"] # 区间 58 条 s[s.index.dayofweek == 4] # 所有周五 112 条
日期一旦是时间类型,pandas 就知道”1 月 31 日的下一天是 2 月 1 日”、”这天是周几”、”这个月有几个交易日”。字符串永远不知道这些——它只是一串字符。
这一讲讲怎么把日期转成时间类型、转的时候有哪些坑,以及时间索引能做哪些字符串做不到的事。
🎯 这一讲结束时,你能
- 用
to_datetime把各种格式的日期转成时间戳,并说清format参数的真正作用 - 避开整数日期被当成纳秒时间戳这个坑(它接着第 08 讲那个 CSV 陷阱)
- 用部分字符串索引按年/月/区间取数
- 用时间属性做”所有周五””每月最后一天”这类筛选
- 说清 pandas 的工作日频率为什么不等于 A 股交易日,以及该怎么办
- 分清
shift(1)和shift(1, freq="D")——它们是两回事
一、🧰 从字符串到时间戳
pd.to_datetime("2024-01-02") # 单个 pd.to_datetime(df["trade_date"], format="%Y%m%d") # 整列 df["dt"] = pd.to_datetime(df["trade_date"], format="%Y%m%d")
转完之后 dtype 是 datetime64[ns]。顺带一个好处:
object 10 MB datetime64 1 MB ← 省 7 倍
时间戳在内存里是一个 int64(纳秒数),比存字符串省得多。
format 参数的真正作用不是速度
常见的说法是”指定 format 会快很多”。在当前版本实测:
| 格式 | 指定 format
|
不指定 | 倍数 |
|---|---|---|---|
20240102 |
24.5 ms | 24.3 ms | 1.0× |
2024-01-02 |
27.7 ms | 28.1 ms | 1.0× |
2024/01/02 |
28.5 ms | 28.5 ms | 1.0× |
没有差别。 pandas 2.x 会从第一个元素推断出格式,然后整列走同一条快路径——你自己指定和它推断出来的是同一个东西。
指定 format 的真正理由是正确性:
s = pd.Series(["01/02/2024", "01/03/2024"]) pd.to_datetime(s) # ['2024-01-02', '2024-01-03'] 按美式 月/日/年 pd.to_datetime(s, format="%d/%m/%Y") # ['2024-02-01', '2024-03-01'] 按欧式 日/月/年
同一份数据,两种解读,都不报错。 数据来自国外交易所或第三方接口时,这个歧义会静默地把你的日期整体挪掉几个月。
第二个理由是它会在格式不一致时报错:
s = pd.Series(["2024-01-02", "2024-01-03", "01/04/2024"]) pd.to_datetime(s, format="%Y-%m-%d") # ValueError: time data "01/04/2024" doesn't match format "%Y-%m-%d"
推断模式只看第一个元素,混进异类时同样会报错,但报错信息指向的是”推断出来的格式”,不如你显式声明的清楚。
需要容错时用 errors="coerce",不匹配的变成 NaT(时间版的 NaN):
pd.to_datetime(s, format="%Y-%m-%d", errors="coerce") # [2024-01-02, 2024-01-03, NaT]
🐛 整数日期会被当成纳秒时间戳
pd.to_datetime(pd.Series(["20240102", "20240103"])) # ['2024-01-02', '2024-01-03'] ✅ pd.to_datetime(pd.Series([20240102, 20240103])) # ['1970-01-01', '1970-01-01'] ❌
字符串 "20240102" 被正确解析,整数 20240102 被当成从 1970 年起的纳秒数——20240102 纳秒还不到 1 秒,所以全部落在 1970-01-01。
这个坑接着第 08 讲那个:CSV 不存类型,trade_date 读回来会从 object 变成 int64。然后你在这里 to_datetime,整列日期就全变成 1970 年了。
pd.to_datetime(int_series, format="%Y%m%d") # 加上 format 就对了 pd.read_csv(f, dtype={"trade_date": str}) # 或者一开始就别让它变成整数
IMPORTANT: 🔑 什么时候必须写
format
- 日期里有
/或.分隔,日和月都可能小于 13(歧义)- 数据是整数而不是字符串
- 你希望格式不一致时立刻报错,而不是被推断蒙混过去
不为速度写,为这三件事写。
二、🧰 部分字符串索引
时间索引可以直接用字符串取一段:
s["2024"] # 整年 s["2024-01"] # 整月 s["2024-01-15"] # 单日 s["2024-01":"2024-03"] # 区间(含首含尾)
这是 DatetimeIndex 独有的能力——pandas 把你给的字符串解析成一个时间范围,再取落在范围内的行。
注意区间切片是闭区间,s["2024-01":"2024-03"] 包含 3 月。这和整数切片的左闭右开不一样。
WARNING: ⚠️ 前提是索引排好序
和第 12 讲的层次索引一样,时间索引的范围切片要求单调有序。没排序时轻则变慢,重则报KeyError。
set_index之后跟一个sort_index(),这个习惯在时间索引上同样适用。
三、🧰 时间属性:字符串做不到的那些筛选
idx = s.index idx.year idx.month idx.day idx.quarter idx.dayofweek idx.dayofyear idx.is_month_end idx.is_quarter_end idx.weekday_name # 旧版;新版用 idx.day_name()
列是普通列而不是索引时,走 .dt 访问器:
df["dt"].dt.year df["dt"].dt.dayofweek
和第 10 讲的 .str、第 11 讲的 .cat 是同一种设计。
实际用法:
s[s.index.dayofweek == 4] # 所有周五 s[s.index.is_month_end] # 每月最后一个交易日 s[s.index.quarter == 1] # 所有一季度
真实数据上(茅台 574 个交易日):
星期分布 周一 112 周二 117 周三 117 周四 116 周五 112 每月最后交易日 18 条 一季度 171 条
周一和周五比周中少——因为春节、国庆这类长假常常从周一开始,而且元旦、清明、劳动节调休也多落在周一。这个分布本身就是交易日历的一个侧影。
四、🐛 pandas 的工作日 ≠ A 股交易日
pandas 有个”工作日”频率 freq="B":
pd.date_range("2024-01-01", "2024-12-31", freq="B")
拿它和真实交易日对比:
2024 年 A 股实际交易日 242 天 pandas freq="B"(工作日) 262 天 差 20 天
差的这 20 天是节假日。B 只排除周六周日,它不知道春节、国庆、清明:
被 freq="B" 算成交易日、实际休市的日子(前 8 个): 2024-01-01 2024-02-09 2024-02-12 2024-02-13 2024-02-14 2024-02-15 2024-02-16 2024-04-04
2 月 9 日到 16 日整整一周是春节休市,B 频率全都算成了交易日。
后果很实际:
- 用
date_range(freq="B")造一个”完整交易日历”去reindex,会凭空多出 20 天的 NaN - 算”20 个交易日动量”时如果按自然日或工作日推算,跨春节的那段会算错
- 按
freq="B"重采样,节假日那些空桶会混进结果
IMPORTANT: 🔑 交易日历要从数据里取,别自己造
trade_days = pd.Index(sorted(px["trade_date"].unique())) # 从行情数据里取行情数据里出现过的日期,就是真实交易日。这是最可靠也最省事的来源。
tushare 也有专门的trade_cal接口。总之别用freq="B"当交易日历。pandas 支持自定义假期日历(
CustomBusinessDay+AbstractHolidayCalendar),但维护一份中国假期表比直接从数据里取交易日麻烦得多,除非你需要预测未来的交易日。
五、🧰 shift:按行还是按时间
shift 有两种完全不同的用法,混淆的后果很严重。
shift(n):按行移动
s.shift(1) # 值整体往下挪一格,索引不变,首行变 NaN
这是算收益率、算动量用的那个:
ret = s / s.shift(1) - 1
它移动的是值在行之间的位置,不关心日期是什么。跨节假日也照样是”上一个交易日”——正是你要的。
shift(n, freq="D"):平移时间轴
s.shift(1, freq="D") # 索引整体加 1 天,值不动
原始首日期 2024-01-02 shift(1) 值挪一格,索引还是 2024-01-02,首值 NaN shift(1,"D") 值不动,索引变成 2024-01-03
它移动的是索引,用来做时间轴对齐(比如把日频数据整体推后一天再和别的表合并)。
WARNING: ⚠️ 算收益率一定用
shift(1),不带freq
同一段 40 个交易日的数据,两种写法算出来的收益率条数不同:(d / d.shift(1) - 1).dropna() → 39 条 (d / d.shift(1, freq="D") - 1).dropna() → 31 条 ← 索引挪到非交易日,对不上带
freq="D"之后索引整体加了一天,周末和节假日那些位置在原序列里根本不存在,相除得到 NaN。
判断方法:你想动的是”上一行”还是”上一天”。收益率、动量、滞后因子都是”上一行”。
六、🧰 date_range 与频率
pd.date_range("2024-01-01", "2024-12-31", freq="D") # 每天 pd.date_range("2024-01-01", periods=12, freq="ME") # 每月最后一天 pd.date_range("2024-01-01", periods=52, freq="W-FRI") # 每周五
常用频率别名:
| 别名 | 含义 |
|---|---|
D / B
|
自然日 / 工作日 |
W-MON…W-SUN
|
每周某天 |
ME / MS
|
月末 / 月初 |
QE / QS
|
季末 / 季初 |
YE / YS
|
年末 / 年初 |
h / min / s
|
小时 / 分钟 / 秒 |
NOTE: 💡 频率别名在 pandas 2.2 改过
旧的M(月末)、Q、Y、H已改成ME、QE、YE、h,旧写法会弹FutureWarning。
看到别人的代码里写freq="M"不要照抄,改成ME。
date_range 主要用来造一个规整的时间轴,然后 reindex 上去补齐缺失的日期。但在 A 股上要小心——上一节说过,规整的工作日轴和真实交易日不是一回事。
🏋️ 训练营
QUESTION: 🟢 训练 1:一份行情表的
trade_date列是形如20240102的字符串。写出:① 转成时间索引并排序 ② 取 2024 年 3 月的数据 ③ 取所有周五
TIP: 👉 参考s = (px.assign(dt=pd.to_datetime(px["trade_date"], format="%Y%m%d")) .set_index("dt")["close"].sort_index()) # ① s["2024-03"] # ② s[s.index.dayofweek == 4] # ③
sort_index()不能省——部分字符串索引要求索引单调有序。
format="%Y%m%d"在这里其实可省(字符串没有歧义),但写上更稳妥:万一这列在某次读取时变成了整数,不写 format 就会全变 1970 年。
QUESTION: 🟡 训练 2:下面这段代码想给行情表补齐”缺失的交易日”,结果多出来一堆 NaN。找出问题。
cal = pd.date_range(s.index.min(), s.index.max(), freq="B") s2 = s.reindex(cal) print(s2.isna().sum())TIP: 👉 参考
问题:freq="B"只排除周末,不认识节假日。2024 年它给出 262 天,而 A 股实际只有 242 个交易日——多出的 20 天全是节假日,reindex之后就是 20 个 NaN。
更麻烦的是这些 NaN 看起来像”数据缺失”,如果你接着ffill()填充,就会给休市日凭空造出行情。
正确做法:交易日历从数据本身取。cal = pd.Index(sorted(px["trade_date"].unique())) # 行情里出现过的日期就是交易日如果是要补齐多只股票的面板(某些股票停牌导致缺行),用全市场出现过的交易日做并集,而不是造一个规整的工作日轴。
QUESTION: 🔴 训练 3:你要算”每只股票距离最近一次财报公告过了多少天”,作为一个信息新鲜度因子。数据是行情表(
ts_code/trade_date)和财报表(ts_code/ann_date)。写出实现,并指出三处会出错的地方。
TIP: 👉 参考px["dt"] = pd.to_datetime(px["trade_date"], format="%Y%m%d") # ① fi["ann"] = pd.to_datetime(fi["ann_date"], format="%Y%m%d", errors="coerce") fi = fi.dropna(subset=["ann"]) # ② px = px.sort_values("dt") fi = fi.sort_values("ann") r = pd.merge_asof(px, fi"ann", "ts_code", # ③ left_on="dt", right_on="ann", by="ts_code", direction="backward") r["days_since"] = (r["dt"] - r["ann"]).dt.days三处会出错的地方:
① 忘了format——如果trade_date因为经过 CSV 变成了整数,不写 format 会整列变成 1970-01-01,算出来的天数是两万多天。
②ann_date有缺失——第 13 讲实测那份财务数据有 27 行ann_date为空,to_datetime会给NaT,相减得到NaT,.dt.days变成 NaN。用errors="coerce"加dropna显式处理。
③ 用end_date而不是ann_date——这是第 13 讲那个未来函数:报告期结束日平均比公告日早 51 天,用它算”信息新鲜度”会让所有股票看起来都比实际更早知道消息。
相减得到的是Timedelta,要.dt.days才变成整数天。直接拿Timedelta去做数值比较也可以,但可读性差。
🐛 常见坑
- ⚠️ 整数日期被当成纳秒时间戳:
to_datetime(20240102)得到 1970-01-01。加format="%Y%m%d",或者一开始就用dtype={"date": str}读。 - ⚠️ 歧义格式被静默解读:
01/02/2024默认按美式月/日/年。数据来自国外源时必须写format。 - ⚠️ 以为指定
format是为了快:实测没有差别,理由是正确性。 - ⚠️ 时间索引没排序:部分字符串索引和范围切片要求单调有序。
set_index后跟sort_index()。 - ⚠️ 用
freq="B"当交易日历:2024 年会多出 20 天节假日。交易日历从数据里取。 - ⚠️
shift(1)和shift(1, freq="D")混用:前者动值,后者动索引。算收益率用前者。 - ⚠️ 旧频率别名:
M/Q/Y/H在 2.2 起改成ME/QE/YE/h,旧写法会弹警告。 - ⚠️ 时间区间切片是闭区间:
s["2024-01":"2024-03"]包含 3 月,和整数切片的左闭右开不同。
✍️ 作业
- 把本地行情的
trade_date转成时间索引,打印转换前后的memory_usage(deep=True),记录省了多少。 - 复现整数日期的坑:把
trade_date转成整数后to_datetime,打印结果;再加format="%Y%m%d"对比。用第 08 讲的 CSV 类型丢失串起来解释这个坑在什么场景下会真实发生。 - 造一个
["01/02/2024", "01/03/2024"],分别用默认和format="%d/%m/%Y"解析,打印两组结果,说明为什么两种都不报错。 - 取某一年的真实交易日数量,和
pd.date_range(freq="B")给出的天数对比,用difference()列出被B频率算成交易日、实际休市的日子,确认它们是节假日。 - 对同一个序列跑
shift(1)和shift(1, freq="D"),并排打印前五行的索引和值,说清两者各动了什么。再验证用后者算收益率会产生多少 NaN。 - 思考题:本讲说”交易日历从数据里取”。那么如果你要预测未来的交易日(比如生成下个月的调仓日期),这个办法就不管用了。有哪些替代方案?各自的代价是什么?
🔮 下讲预告:第 19 讲——时间序列(下):重采样与移动窗口。有了时间索引,就能做两类新操作:把日频数据按周、按月重采样(日线合成周线的开高低收),以及在时间序列上滑动一个窗口算移动平均、滚动波动率。这两个是量化里用得最多的时间序列工具,也各自有一个容易踩的坑。