第19讲 时间序列(下)重采样与移动窗口

📎 配套代码第19讲_重采样与移动窗口.py
📊 配套数据data/quote/ —— 交易数据(沪深300 × 2024 年以来,仓库自带,开箱即跑)

🎬 开场:一条 20 日均线,混进了别人的价格

样本行情 17 万行、300 只股票,算 20 日均线:

df["ma20"] = df["close"].rolling(20).mean()
df["ma20"].isna().sum()      # 19

只有 19 个 NaN。看起来数据很完整——一条均线要 20 个数才能算出第一个值,开头缺 19 个,合情合理。

但正确的结果应该是这样:

df.groupby("ts_code")["close"].rolling(20).mean().isna().sum()      # 5,700

5700 个 NaN。 因为 300 只股票 × 每只开头 19 个 = 5700。

第一种写法的问题在于:rolling 只认行的先后,不认股票的边界。当窗口滑到某只股票的第一行时,它会把上一只股票最后 19 行的价格拿来一起平均。

["ts_code", "trade_date"] 排好序的面板数据里,茅台的第一条 20 日均线,用的是五粮液最后 19 天的收盘价加上茅台第一天的。

代码不报错。均线画出来也是一条平滑的曲线。只有每只股票开头那 19 行是错的——300 只股票就是 5700 行。

这一讲讲两个时间序列工具:改变数据频率resample,和在序列上滑动窗口rolling。它们都很常用,也都各自有一个容易踩空的地方。


🎯 这一讲结束时,你能

  • resample 把日线合成周线、月线,包括 OHLC 这种多字段聚合
  • 说清 MEMS 的区别,以及重采样产生空桶的原因
  • 解释 rolling 默认包含当前行,以及这在做交易信号时为什么是未来函数
  • min_periods 控制开头那几行的行为
  • 在面板数据上正确地做滚动计算——先分组,再滚动

一、🧰 resample:改变数据频率

resample 按时间把数据分桶,再对每个桶聚合。用法和 groupby 很像,区别是分组键是时间区间

日线合成周线

w = d.resample("W-FRI").agg({
    "open":  "first",     # 一周的第一个开盘价
    "high":  "max",       # 一周的最高价
    "low":   "min",
    "close": "last",      # 一周的最后一个收盘价
    "vol":   "sum",       # 成交量累加
}).dropna()

真实数据(茅台 574 个交易日):

574 个交易日 → 123 周

              open    high      low    close        vol
dt
2023-01-06  1731.2  1811.9  1706.01  1803.77  119296.15
2023-01-13  1835.0  1888.0  1807.82  1887.00  125562.09
2023-01-20  1886.0  1935.0  1858.00  1860.01  128258.06

first/max/min/last 这组搭配就是 OHLC 的定义,把它们写进一个 agg 字典是合成 K 线的标准写法。

pandas 还有个专门的简写:

d["close"].resample("W-FRI").ohlc()      # 直接产出 open/high/low/close 四列

但它只对单列做,而且用的是这一列自己的首末最值——只有当你手上只有收盘价时才用它。有完整的开高低收就用上面那个 agg 字典,那才是真实的周 K 线。

常用频率

频率 含义
W-FRI 每周,以周五为桶的右端
ME / MS 月末 / 月初
QE / QS 季末 / 季初
YE 年末
5min / h 分钟 / 小时(分钟线用)

二、🧰 MEMS:桶用哪个边界命名

s.resample("ME").last()      # 索引 2023-01-31, 2023-02-28, 2023-03-31
s.resample("MS").last()      # 索引 2023-01-01, 2023-02-01, 2023-03-01

两者的值完全相同,只是索引标签不同——一个用月末命名,一个用月初命名。

两者值是否相同: True

选哪个取决于你后面怎么用:

  • 要和别的月末数据(月度财务、月末持仓)对齐,用 ME
  • 要表达”这是 1 月的数据”,MS2023-01-01 更直观
  • 画图时 ME 的点落在月末,和实际数据的时间位置一致

改边界还有两个参数:closed(区间哪端闭合)和 label(用哪端命名)。默认值对大多数频率是”右闭右标”,日内数据上要特别小心——resample("5min") 的桶到底包不包含 09:35:00 这个点,直接影响你的分钟线对不对。

空桶

按周重采样 125 周,其中空桶(NaN)2 个

那 2 个空桶是整周休市(春节、国庆长假)。resample 按日历切桶,不管这周有没有交易日,所以会产生空桶。

.dropna() 可以去掉,但要先想清楚:空桶代表”这段时间没有数据”,不是”数据缺失”。做周频回测时,跳过这些周和把它们当成”收益为 0″是两个完全不同的处理。


三、🐛 rolling 默认包含当前行

这是本讲最要紧的一条。

s.rolling(20).mean()

第 20 行的值,是第 1 到第 20 行(含第 20 行)的均值——包含当天

np.isclose(ma.iloc[19], s.iloc[:20].mean())     # True

这个默认行为对”描述历史”是对的,对”生成交易信号”就是未来函数

一个具体的例子

信号:收盘价上穿 20 日均线时买入。

sig_bad = (s > s.rolling(20).mean()).astype(int)          # ❌
sig_ok  = (s > s.rolling(20).mean().shift(1)).astype(int)  # ✅

第一种写法里,用来比较的均线包含了当天的收盘价。也就是说你在今天收盘前就知道了今天的收盘价——现实里做不到。

实测两者的差异:

两者信号不同的天数: 9 / 574

574 天里有 9 天信号不一样。占比不高,但这 12 天恰恰是价格穿越均线的关键时刻——信号的价值就在这些转折点上。

IMPORTANT: 🔑 判断方法:这个值在什么时候能被算出来
rolling(20).mean() 在 t 日的值,要等 t 日收盘才能算出来。
如果你要用它在 t 日做决策,那就是未来函数;用它在 t+1 日做决策才合法。
表达 t+1 日可用,写法就是 .shift(1)

这条规则适用于所有滚动指标:均线、波动率、相关系数、分位数。只要它用到了当天的数据,就不能在当天用。

相关的一个提醒

第 14 讲那个因子分析用了 shift(-1) 取次日收益——那是评估环节,是允许的(你在事后统计因子的预测能力)。而这里说的是信号生成环节,不能用未来。

两者的区别是:评估时你已经知道结果了,只是在做统计;生成信号时你站在当下,只能用已经发生的信息。


四、🧰 min_periods:开头那几行怎么办

s.rolling(20).mean()                    # 前 19 个是 NaN
s.rolling(20, min_periods=1).mean()     # 前 0 个是 NaN
s.rolling(20, min_periods=5).mean()     # 前 4 个是 NaN

min_periods 规定”窗口里至少要有几个有效值才算”。默认等于窗口长度,所以前 19 行都是 NaN。

设成 1 意味着第一行就出结果——但那个”20 日均线”其实只用了 1 个数。

WARNING: ⚠️ min_periods=1 会造出不可比的值
前几行的”20 日均线”实际上是 1 日、2 日、3 日均线,它们和后面真正的 20 日均线不是同一个东西,但看起来一模一样。
拿去做截面比较时,新上市的股票会因为窗口不足而系统性地偏离。
建议:要么保持默认(让不够的地方明确缺失),要么设一个有意义的下限(比如 min_periods=15),并且知道自己在做什么


五、🧰 量化里常用的滚动指标

ret = s.pct_change(fill_method=None)               # 第 14 讲:不能省这个参数

ret.rolling(20).std() * np.sqrt(252)               # 20 日年化波动率
ret.rolling(60).corr(other_ret)                    # 60 日滚动相关
ret.rolling(60).cov(mkt_ret) / mkt_ret.rolling(60).var()   # 滚动 beta
s.rolling(20).max()                                # 20 日最高价
s.expanding().mean()                               # 从头到当前的累计均值

真实数据上:

茅台 20 日年化波动率     均值 21.1%   区间 [8.4%, 70.6%]
茅台 vs 五粮液 60 日滚动相关   均值 0.810   区间 [0.545, 0.940]

两个数字都值得看一眼:

  • 波动率在 8.4% 到 70.6% 之间变动,用一个静态的历史波动率去估计风险会差很远
  • 两只白酒股的相关系数在 0.545 到 0.940 之间波动——相关性不是常数,这正是相关矩阵要滚动估计的原因

expanding 是窗口从头一直扩展到当前行,用来算累计收益、累计最大回撤这类”从开始到现在”的指标。


六、🐛 面板数据:先分组,再滚动

回到开场那个问题。多只股票的面板数据上,必须先按股票分组:

# ❌ 窗口跨越股票边界
df["close"].rolling(20).mean()

# ✅ 两种正确写法
df.groupby("ts_code")["close"].rolling(20).mean().reset_index(level=0, drop=True)
df.groupby("ts_code")["close"].transform(lambda x: x.rolling(20).mean())

实测(17 万行,300 只):

groupby.rolling().reset_index    18 ms
groupby.transform(rolling)       17 ms      两者结果一致

transform 写法稍快,而且返回的索引直接和原表对齐,不用再 reset_index——第 16 讲说过 transform 的返回值和原表等长。

groupby(...).rolling(...) 的结果会多出一层股票代码的索引,所以要 reset_index(level=0, drop=True) 把它去掉才能赋值回原表。忘了这一步会因为索引对不上而产生大量 NaN。

IMPORTANT: 🔑 面板上做任何滚动计算之前,先问两件事
① 排序了吗——sort_values(["ts_code", "trade_date"])(第 09 讲)。
② 分组了吗——groupby("ts_code")
两个都做了,rolling 才是”这只股票的过去 20 个交易日”。
少一个都不报错,结果都是错的。


七、🤔 resample / rolling / groupby 怎么分

三者都在”按某种规则把数据聚成一组再算”,区别在分组方式:

分组依据 结果长度 典型用途
resample 时间区间,桶之间不重叠 变短(日→周) 改变频率,合成 K 线
rolling 滑动窗口,窗口之间大量重叠 和原来一样 移动平均、滚动波动率
groupby 某一列的值 每组一行(agg)或不变(transform 按股票、按行业汇总

一个记法:resample 是把时间轴切段,rolling 是在时间轴上滑动,groupby 和时间无关。

resample 本质上是时间版的 groupby——它甚至支持同样的 agg 写法。区别只在于分组键是自动从时间索引切出来的。


🏋️ 训练营

QUESTION: 🟢 训练 1:把一只股票的日线合成月线 K 线(开高低收 + 成交量),并统计有多少个月。
TIP: 👉 参考

m = d.resample("ME").agg({
    "open": "first", "high": "max", "low": "min",
    "close": "last", "vol": "sum",
}).dropna()

first/max/min/last 对应 OHLC 的定义。dropna() 去掉整月没有交易的空桶(A 股不会出现,但停牌股或数据缺失时会)。
别用 resample("ME").ohlc()——它只对单列做,用的是那一列自己的首末最值,不是真实的月 K 线。

QUESTION: 🟡 训练 2:下面这个信号有未来函数。指出来并改对。

ma = df.groupby("ts_code")["close"].transform(lambda x: x.rolling(20).mean())
df["signal"] = (df["close"] > ma).astype(int)
df["ret"] = df.groupby("ts_code")["close"].pct_change(fill_method=None)
df["strategy_ret"] = df["signal"] * df["ret"]

TIP: 👉 参考
两处未来函数
ma 包含当天收盘价——rolling(20).mean() 在 t 日的值要等 t 日收盘才算得出来,用它在 t 日生成信号是不可能的。
signalret 同期相乘——signal 是 t 日的信号,ret 是 t 日的收益。用 t 日的信号赚 t 日的钱,等于收盘前就知道了当天涨跌。
改对:

ma = df.groupby("ts_code")["close"].transform(lambda x: x.rolling(20).mean().shift(1))
df["signal"] = (df["close"].groupby(df["ts_code"]).shift(1) > ma).astype(int)
df["strategy_ret"] = df["signal"] * df["ret"]

更清楚的写法是把信号整体滞后一期:先按正常逻辑算出信号,再 groupby("ts_code")["signal"].shift(1),表达”用昨天收盘后确定的信号,赚今天的钱”。
一个通用检查:策略收益 = 信号 × 收益时,信号必须比收益早至少一期

QUESTION: 🔴 训练 3:你要给全市场每只股票算”过去 60 个交易日相对沪深300 的 beta”。数据是长表行情和指数行情。写出实现,指出三处会出错的地方。
TIP: 👉 参考

df = df.sort_values(["ts_code", "trade_date"])                      # ①
df["ret"] = df.groupby("ts_code")["close"].pct_change(fill_method=None)   # ②

idx_ret = (index_px.set_index("trade_date")["close"]
                   .pct_change(fill_method=None).rename("mkt"))
df = df.merge(idx_ret, left_on="trade_date", right_index=True, how="left")

g = df.groupby("ts_code")                                            # ③
cov = g.apply(lambda x: x["ret"].rolling(60).cov(x["mkt"]), include_groups=False)
var = df.groupby("ts_code")["mkt"].transform(lambda x: x.rolling(60).var())
df["beta"] = cov.reset_index(level=0, drop=True) / var
df["beta"] = df.groupby("ts_code")["beta"].shift(1)                  # 用作信号要滞后

三处会出错的地方
① 不排序——rolling 按行顺序滑动,顺序不对窗口里就不是”过去 60 天”(第 09 讲)。
② 不分组直接 rolling——窗口跨越股票边界,每只股票开头 59 行混进上一只的数据。这就是开场那个坑。
③ 忘了滞后——rolling(60) 包含当天,直接拿来当因子就是未来函数。
另外两个容易忽略的:pct_changefill_method=None(第 14 讲);以及 beta 分母 var 为 0 时会得到 inf,实务里要么加 min_periods 保证样本量,要么事后过滤异常值。


🐛 常见坑

  • ⚠️ 面板数据不分组直接 rolling:窗口跨越股票边界,每只股票开头 N−1 行是错的。这份样本上是 5700 行。
  • ⚠️ rolling 包含当天:用作交易信号是未来函数。要 .shift(1)
  • ⚠️ 信号和收益同期相乘:策略收益里信号必须比收益早至少一期。
  • ⚠️ min_periods=1 造出不可比的值:前几行的”20 日均线”其实只用了 1~2 个数。
  • ⚠️ groupby(...).rolling(...) 忘了 reset_index:结果多一层索引,赋值回原表会因索引对不上产生大量 NaN。
  • ⚠️ resample 的空桶当成缺失:空桶是”这段时间没有数据”,不是”数据丢了”。
  • ⚠️ 日内重采样不看 closed/label:分钟线的桶边界直接影响 K 线对不对。
  • ⚠️ resample("ME").ohlc() 当成真 K 线:它只对单列做,不是开高低收四个字段的聚合。

✍️ 作业

  1. 把一只股票的日线分别合成周线和月线 K 线,打印前三行,手工验证周线的 high 确实等于那一周日线 high 的最大值。
  2. 对同一个序列跑 resample("ME").last()resample("MS").last(),打印两者的索引和值,确认值相同、标签不同。
  3. 复现未来函数:分别用 s > mas > ma.shift(1) 生成信号,统计两者不同的天数,并把这些天的日期打印出来,观察它们是不是都发生在价格穿越均线的位置。
  4. 在全市场面板上分别跑”不分组 rolling“和”groupbyrolling“,比较两者的 NaN 数量。验证差额约等于 股票数 × (窗口长度 − 1),并解释为什么不完全相等。
  5. 算一只股票的 20 日年化波动率序列,打印均值、最大值、最小值。用这个区间说明”为什么风险模型要滚动估计而不是用一个静态数字”。
  6. 思考题:rolling(20) 是按行数滑动的。如果一只股票中间停牌了 30 天,这个”20 日均线”实际覆盖了多长的日历时间?如果你想要的是”过去 20 个自然日”而不是”过去 20 行”,该怎么做?(提示:rolling 支持传时间偏移字符串作为窗口。)

🔮 下讲预告:第 20 讲——可视化。前面十九讲一直在用数字看数据,但有些结构数字看不出来:因子的分档收益是不是单调、滚动相关是怎么变化的、收益分布有多厚的尾。下一讲讲怎么用 pandas 内置的绘图接口快速把这些画出来,以及什么时候该跳过 pandas 直接用 matplotlib。


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