shift、rolling、resample 与时间对齐
前置知识:缺失、重复与类型清洗 · 聚合、groupby 与 transform
本章目录
本章学习目标
- 先分组排序再计算滞后和窗口
- 区别观测窗口与时间窗口
- 检查信号信息时点与缺失交易日
时间计算先保证顺序
时间序列算法通常沿当前行顺序工作,不会自动知道哪一行是昨天。股票面板需要先解析日期、处理重复股票日期,再按股票与日期排序。shift(1) 在同一股票组中取前一条观测的数值,而不是自动取前一个日历日;跨股票直接 shift 会把前一只股票的末值当成后一只股票的前值。
收益率用当前收盘价除以前一条有效定义的价格再减一。若中间交易日缺失,结果覆盖的是两条观测之间的整个区间,不能标成单日收益率;本章列名因此写成 return_observed。要形成日收益序列,应先确定交易日历、补齐索引并决定缺失价格策略,不能把周末和停牌一律当成零收益。
小数据:两种窗口
日期一、二、五、六的价格依次十、十二、十五、二十。第三个观测的 rolling(3) 取十、十二、十五,均值为三十七除以三;按三日时间 rolling('3D') 并采用右闭区间,日期五的窗口是大于日期二且小于等于日期五,只包含十五,均值为十五。两种窗口回答不同问题,结果不相等不是错误。
min_periods 决定至少有多少有效观测才计算结果。rolling(3,min_periods=3) 在前两条和包含缺失价格的不足三值窗口返回缺失。时间窗口的默认最小有效数可能不同,应主动写出要求。closed 控制边界包含,特别是恰好落在左边界的日期,必须用小数据检查。
信号与未来信息
若在当天开盘前交易,当天收盘价还未知,用包含当天收盘的均线生成当天开盘信号会泄漏未来信息。示例先计算含当日收盘的三观测均线,再按股票 shift(1),得到在下一条观测开始前已经可用的信号。是否足够仍取决于真实发布时间、复权处理和执行价格,本课程仅演示基本时点约束。
负向 shift 可以构造未来标签,但标签列不能进入特征。merge_asof 可以将已有历史指标匹配到决策时点,通常使用 direction='backward'、容忍时间差和实体 by;输入必须满足要求的排序。仅按日期精确连接公告数据可能既丢失非交易日公告,也忽视同一天盘后发布的信息。
重采样与对齐
resample 把时间索引按日、周或月划分桶,聚合成交量可求和,价格可取首末值,返回值应说明桶边界和标签时点。股票组内单独重采样,不能把不同股票混成一条价格序列。OHLC 含义是开、高、低、收,不是四个同义的均值。月份频率标记与结束边界在版本间有别,教学代码和文档应核对实际版本。
reindex 到明确日历可以暴露缺失日期,ffill 只能从过去传播值,bfill 可能使用未来值。前向填充不产生真实交易,成交量不应沿用前日;缺少观测的状态需要单独一列。在汇总前,先判断缺失意味着市场休市、无成交还是日志丢失,三者的指标口径不同。
验证与解释窗口结果
脚本同时输出观测收益、三观测均线和滞后信号,断言每只股票第一条前值为空。交互演示切换窗口及滞后,学习者先猜哪些行会变缺失,再看结果。清洗规则只是教学副本保留输入最后一条,并不替代生产的行情版本规则。
检查边界:每组只有一行、日期间隔很大、价格缺失、零价格及同一天重复。窗口的有效样本数应该随结果一起检查,避免用少量观测和完整窗口混比。面试中讲 rolling,必须说清按几条记录还是几天、是否包含当日、怎样处理缺失,以及信号是在交易前还是交易后可用。
版本与复现约定
本课程 pandas 示例以 2.3.3 为验证基线,页面运行记录给出实际版本。pandas 3.x 默认使用 Copy-on-Write:从表中取出的对象发生修改时,不据此假定原表同步变化。跨版本都应使用一次完整的 loc 赋值表达式;需要独立工作表时显式 copy。阅读网上的旧示例时,先核对版本、输入类型和索引,再判断结果能否复现。
交互演示:观察机制
完整示例与实际输出
from pathlib import Path
import pandas as pd
import numpy as np
from _shared import load
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "outputs"
OUT.mkdir(exist_ok=True)
raw=load("panel")
d=raw.drop_duplicates(["symbol","date"],keep="last").copy()
d["date"]=pd.to_datetime(d["date"])
d=d.sort_values(["symbol","date"]).reset_index(drop=True)
d["previous_close"]=d.groupby("symbol")["close"].shift(1)
d["return_observed"]=d["close"]/d["previous_close"]-1
d["mean_3_observations"]=d.groupby("symbol")["close"].transform(lambda s:s.rolling(3,min_periods=3).mean())
d["signal_available_before_today"]=d.groupby("symbol")["mean_3_observations"].shift(1)
first=d.groupby("symbol").head(1)
assert first["previous_close"].isna().all()
assert not d.duplicated(["symbol","date"]).any()
small=pd.Series([10.,12.,15.,20.],index=pd.to_datetime(["2026-01-01","2026-01-02","2026-01-05","2026-01-06"]))
assert small.rolling(3).mean().iloc[2]==37/3
assert small.rolling("3D",closed="right").mean().iloc[2]==15
print("三个观测 / 三个日历日:",small.rolling(3).mean().iloc[2],small.rolling("3D").mean().iloc[2])
print(d.head(8).to_string(index=False))
d.to_csv(OUT / "pd_time_panel.csv",index=False)
实际运行通过CPU Python execution.
三个观测 / 三个日历日: 12.333333333333334 15.0 symbol date open close volume industry previous_close return_observed mean_3_observations signal_available_before_today AAA 2026-01-01 26.1033 26.3837 6372 科技 NaN NaN NaN NaN AAA 2026-01-02 26.3837 27.1463 8759 科技 26.3837 0.028904 NaN NaN AAA 2026-01-05 27.1463 27.6126 1874 科技 27.1463 0.017177 27.047533 NaN AAA 2026-01-06 27.6126 27.9436 4883 科技 27.6126 0.011987 27.567500 27.047533 AAA 2026-01-07 27.9436 28.0324 12345 科技 27.9436 0.003178 27.862867 27.567500 AAA 2026-01-08 28.0324 27.8942 2711 科技 28.0324 -0.004930 27.956733 27.862867 AAA 2026-01-09 27.8942 28.4592 4291 科技 27.8942 0.020255 28.128600 27.956733 AAA 2026-01-12 28.4592 28.4195 8402 科技 28.4592 -0.001395 28.257633 28.128600
五道自测:先回答,再核对
01 · shift(1) 一定是昨天吗?
查看提示
记录可能缺日期。
查看参考答案
不是,是当前顺序前一条观测;要先分组排序。
02 · rolling(3) 与 rolling('3D') 差别?
查看提示
条数和时间。
查看参考答案
前者最近三观测,后者指定日历时间范围。
03 · 为何开盘信号需要滞后收盘均线?
查看提示
收盘何时可知。
查看参考答案
当日开盘看不到当日收盘,必须使用已知历史值。
04 · bfill 有什么风险?
查看提示
从哪一侧补。
查看参考答案
会从未来记录填到过去,可能造成特征泄漏。
05 · 跨股票直接 rolling 为什么危险?
查看提示
组边界。
查看参考答案
窗口会混入其他股票的记录,需 groupby 后计算。
两项动手任务
任务 1 · 生成按股票排序的观测收益与滞后均线。
提示
先去重,明确观测间隔。
完整参考解答与检查方式
from pathlib import Path
import pandas as pd
import numpy as np
from _shared import load
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "outputs"
OUT.mkdir(exist_ok=True)
raw=load("panel")
d=raw.drop_duplicates(["symbol","date"],keep="last").copy()
d["date"]=pd.to_datetime(d["date"])
d=d.sort_values(["symbol","date"]).reset_index(drop=True)
d["previous_close"]=d.groupby("symbol")["close"].shift(1)
d["return_observed"]=d["close"]/d["previous_close"]-1
d["mean_3_observations"]=d.groupby("symbol")["close"].transform(lambda s:s.rolling(3,min_periods=3).mean())
d["signal_available_before_today"]=d.groupby("symbol")["mean_3_observations"].shift(1)
first=d.groupby("symbol").head(1)
assert first["previous_close"].isna().all()
assert not d.duplicated(["symbol","date"]).any()
small=pd.Series([10.,12.,15.,20.],index=pd.to_datetime(["2026-01-01","2026-01-02","2026-01-05","2026-01-06"]))
assert small.rolling(3).mean().iloc[2]==37/3
assert small.rolling("3D",closed="right").mean().iloc[2]==15
print("三个观测 / 三个日历日:",small.rolling(3).mean().iloc[2],small.rolling("3D").mean().iloc[2])
print(d.head(8).to_string(index=False))
d.to_csv(OUT / "pd_time_panel.csv",index=False)
任务 2 · 把三日记录补成完整日历,保留原始观测标记。
提示
reindex 后不要丢掉 missing 标记。
完整参考解答与检查方式
from pathlib import Path
import pandas as pd
import numpy as np
from _shared import load
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "outputs"
OUT.mkdir(exist_ok=True)
s=pd.Series([10.,12.],index=pd.to_datetime(["2026-01-01","2026-01-03"]))
result=s.reindex(pd.date_range("2026-01-01","2026-01-03",freq="D")).to_frame("close")
result["observed"]=result["close"].notna()
result["history_close"]=result["close"].ffill()
assert result["observed"].tolist()==[True,False,True]
assert result["history_close"].tolist()==[10.,10.,12.]
print(result.to_string())
官方资料与掌握状态
完成两项任务后,按独立完成程度记录。状态会尽量保存在当前浏览器,可导出备份。
动手任务记录
掌握状态与复习
关掉资料独立完成后记 A;提示后完成记 B;需要重学记 C。
记录尽量保存在当前浏览器,建议定期导出备份。