第18讲 时间序列(上)时间类型与索引

📎 配套代码第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-MONW-SUN 每周某天
ME / MS 月末 / 月初
QE / QS 季末 / 季初
YE / YS 年末 / 年初
h / min / s 小时 / 分钟 / 秒

NOTE: 💡 频率别名在 pandas 2.2 改过
旧的 M(月末)、QYH 已改成 MEQEYEh,旧写法会弹 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 月,和整数切片的左闭右开不同。

✍️ 作业

  1. 把本地行情的 trade_date 转成时间索引,打印转换前后的 memory_usage(deep=True),记录省了多少。
  2. 复现整数日期的坑:把 trade_date 转成整数后 to_datetime,打印结果;再加 format="%Y%m%d" 对比。用第 08 讲的 CSV 类型丢失串起来解释这个坑在什么场景下会真实发生。
  3. 造一个 ["01/02/2024", "01/03/2024"],分别用默认和 format="%d/%m/%Y" 解析,打印两组结果,说明为什么两种都不报错。
  4. 取某一年的真实交易日数量,和 pd.date_range(freq="B") 给出的天数对比,用 difference() 列出被 B 频率算成交易日、实际休市的日子,确认它们是节假日。
  5. 对同一个序列跑 shift(1)shift(1, freq="D"),并排打印前五行的索引和值,说清两者各动了什么。再验证用后者算收益率会产生多少 NaN。
  6. 思考题:本讲说”交易日历从数据里取”。那么如果你要预测未来的交易日(比如生成下个月的调仓日期),这个办法就不管用了。有哪些替代方案?各自的代价是什么?

🔮 下讲预告:第 19 讲——时间序列(下):重采样与移动窗口。有了时间索引,就能做两类新操作:把日频数据按周、按月重采样(日线合成周线的开高低收),以及在时间序列上滑动一个窗口算移动平均、滚动波动率。这两个是量化里用得最多的时间序列工具,也各自有一个容易踩的坑。


← 上一讲  ·  返回课程  ·  下一讲 →