← 返回模块
3.2.2.4beta 可读 · 未来付费内容校验中内容版本 2026-05-27

时间序列与滚动窗口

3.2.2 · Pandas · 编程

下午两点,私募研究台。你拉到一份 510300.SH(沪深300 ETF)近 10 个 SSE 交易日的分钟 bar,存储里的时间戳是 UTC,列只有 price(这一分钟末的成交价)与 volume(这一分钟的成交股数)。任务很短:把它降到日 bar,算每天的简单收益(simple return)与 5 日年化滚动波动(rolling annualized volatility),再切出 '2024-Q1' 那一段做 sanity check。L3 教完之后你能 group、能 merge、能 reshape,但 date 此刻还只是 groupby 的一个键——量化数据天生是时间序列,这一课把时间这条轴本身装进 Pandas 的语义里。

DatetimeIndex 与单调性

第一步是让 Pandas 知道索引是时间戳。CSV 入口在 L2 已经走过(parse_dates=['date'], index_col='date');DataFrame 已经在内存里时,规范化三步:

df['date'] = pd.to_datetime(df['date'])
df = df.set_index('date').sort_index()
assert df.index.is_monotonic_increasing

sort_index() 不是装饰,是 非可选 步骤——.loc 部分字符串切片、resamplerolling 在非单调(non-monotonic)索引上要么悄悄出错、要么直接抛 KeyErroris_monotonic_increasing 这一行 assert 是你在管道入口处的护栏;踩到一次乱序数据就在源头报错,比下游 PnL 出鬼影再倒查省半天。

部分字符串切片

DatetimeIndex.loc 加了一份普通索引没有的能力——按字符串切出整段:

q1 = df.loc['2024-Q1']
jan = df.loc['2024-01']

'2024' 是整年;'2024-Q1' 是一季度(1、2、3 月);'2024-01' 是 1 月;df.loc['2024-01-15':'2024-01-31'] 两端都含——和 L1 的 .loc 行为一致。落到 A 股语境里要补一脚:上面切出来的是日历日,但 SSE 的春节假期(典型为 1 月末或 2 月初连休 7 个交易日,具体随农历)天然是非交易日的空档。L5 跑前向填充(forward fill)时,千万别越过春节假期把节前最后一个 close 灌进节后第一天的格子里。

交易日历一瞥

pd.bdate_range(start, end) 给的是工作日(weekdays),BDay offset 是底层积木:

trading = pd.bdate_range('2024-01-01', '2024-03-31')

BDay 只识别周六周日,不识别交易所节假日。要对齐 SSE / SZSE 的真实日历(春节、清明、劳动节、端午、中秋、国庆),工程实践里用 pandas_market_calendars 这种第三方库,再喂给 pd.tseries.offsets.CustomBusinessDay(calendar=...)

resample:改频率

df.resample('1D') 的心智模型是「按日历日做 groupby」。频率字符串记一把常用:'1min''5min''1H''1D''1W''1M'(calendar-month end)、'BME'(business-month end)、'1Q''1A'

承重的决策不在频率字符串,而在每一列要喂什么聚合器(aggregator)。规则跟着列的语义走:价格列要当天 最后 一笔('last');成交量要 求和'sum');利率或收益率这种噪声列默认 均值'mean');要 OHLC 摘要就 .ohlc()。把分钟 bar 降到日 bar 的标准写法:

daily = df.resample('1D').agg({'price': 'last', 'volume': 'sum'})

反向的 up-sample(resample('1min').asfreq() 把日序列展成每分钟)在量化里是个 警告信号——通常说明建模口径错了,而不是日常操作。两个小细节同样承重:第一,resample('1D') 的桶边界落在午夜(midnight),所以贴时区前后切出来的「这一天」可能完全不是同一段时刻;先 tz-convert 再 resample 是绕开这个坑的标准顺序。第二,closed='right'label='right' 这两个参数决定每个桶包含左端还是右端、用哪一端做标签——对于带 09:30 开盘、15:00 收盘的 SSE 分钟 bar,默认行为通常够用;只有当你把 1 分钟 bar 升到 5 分钟、并要严格对齐分钟开盘价时才需要显式指定。

rolling:滑动统计

df.rolling(window=21) 返回一个 Rolling 对象,惰性持有窗口定义;聚合器落上去才真算。最常见的一组:

ma_21 = daily['close'].rolling(window=21, min_periods=21).mean()
vol_21d = daily['return'].rolling(window=21, min_periods=21).std() * np.sqrt(252)

min_periods 是承重参数。默认 min_periods=window,所以这里写 21 是显式但冗余;写出来是让契约(contract)声音够响——失败模式是 min_periods=1 在前 20 个交易日悄悄用 3 天窗口算出一个看起来像 21 日波动的数。np.sqrt(252) 是年化因子(annualization factor),把日波动放成年化波动;252 是按美式 252 个交易日的惯例,A 股稍微差几天,这一行写明假设。

成对滚动协方差与相关性是横截面工具:df.rolling(window=63).cov().corr() 返回一个 (date, ticker) 双层索引的长格式表,列是 ticker——本质是按日期切片的成对协方差矩阵。expanding(min_periods=...) 是「从序列起点一路扩展到当前」的版本,适合累积口径。rolling(window).apply(my_func, raw=True) 只在没有现成向量化聚合器时才用,raw=Trueraw=False 快很多(一个传 ndarray、一个传 Series)。

shift:滞后与未来

df['close'].shift(1) 把昨天的 close 对齐到今天的索引——做日收益、做滞后特征(lag feature)、把均线穿越信号 T-1 化都靠它:

daily['return'] = daily['price'] / daily['price'].shift(1) - 1

shift(-1) 则把明天的 close 对齐到今天的索引——也就是 未来 信息漏进了今天的特征行,在 back-test 里就是教科书定义的前视偏差(look-ahead bias)。一句你应当背下来的规则:df.shift(-1) aligns tomorrow with today; it is a research tool for realised forward returns, never a signal tool in a back-test because it uses future information.(研究里可以用 shift(-1) 算实现的未来收益给信号打分;信号生成路径里绝不能出现它。)

asfreq(freq) 是补充工具——只改频率不改值,原索引没有的时间槽填 NaNdaily.asfreq('B') 把索引 re-index 到工作日,把你以前看不见的缺日暴露出来。

时区:盘内对齐的承重课

字符串列建出的 DatetimeIndex 默认是 朴素的(naive,无时区)。纯日频数据 naive 没问题;盘内(intraday) 数据 naive 就是 bug——把美东 16:00 收盘和上海 15:00 收盘按「同一日历日」对齐,悄悄拉错 12 个小时。配方:内部一律存 UTC,需要给人看时再 convert 到展示时区。

df.index = df.index.tz_localize('UTC').tz_convert('Asia/Shanghai')

tz_localize 把朴素索引贴上时区标签(在 已知 是 UTC 时这么用是对的);tz_convert 做小时换算。夏令时(DST)切换那两天每年会产出两个含糊的本地时刻,严格写法是 tz_localize('US/Eastern', ambiguous='raise') 在不确定时直接报错——A 股没有 DST 这点比纽约省心,但跨美中 join 时这个坑是你的。

端到端串一遍

把今天的零件按顺序串起来:

df['date'] = pd.to_datetime(df['date'])
df = df.set_index('date').sort_index()
df.index = df.index.tz_localize('UTC').tz_convert('Asia/Shanghai')
daily = df.resample('1D').agg({'price': 'last', 'volume': 'sum'})
daily['return'] = daily['price'] / daily['price'].shift(1) - 1
vol_5d = daily['return'].rolling(window=5, min_periods=5).std() * np.sqrt(252)
q1 = daily.loc['2024-Q1']

七行:拿到时间戳、单调化、贴时区、降日 bar、算简单收益、滚动年化波动、切季度。这就是 L4 的全部承重肌肉。

下一课接力

你现在能 group、能 merge、能 reshape,还能在时间轴上 resample、rolling、shift 与 tz-convert。下一课把这一套零件焊成一个完整的向量化金融数据管道:读 Parquet 篮子的日 close,清掉假日与孤立缺失,算简单与对数收益,算 21 日年化波动与 63 日滚动 Sharpe,算累积净值与最大回撤,接申万行业表算行业平均 Sharpe,最后把 tear-sheet 写回 Parquet——全程零 Python for 循环。

练习

Exercise

给定一个分钟 bar 的 pd.DataFrame df,索引是 UTC 时区下的 DatetimeIndex,列为 price(这一分钟末的成交价)与 volume(这一分钟的成交股数)。请写 Pandas 代码,返回一个日 pd.DataFrame:索引为 本地交易所日期;列为 close(每天最后一笔 price)、volume(当日 volume 之和)、ret_1d(日简单收益 close / close.shift(1) - 1)。要求用上 tz_convertresample('1D')aggshift;约束:no Python for loop(不允许任何 Python for 循环)。

提示
先把索引从 UTC 用 tz_convert('Asia/Shanghai') 转到本地时区,再 resample('1D').agg({'price': 'last', 'volume': 'sum'}) 拿到日 bar。
提示
把上一步结果里的 price 列重命名为 close,再用 daily['close'] / daily['close'].shift(1) - 1 写到 daily['ret_1d']