行情数据有一种别的数据没有的「方向感」:它沿着时间一去不回头。你问「这只股票最近 20 天平均多少钱」,其实是在时间轴上开一扇 20 天宽的窗;你问「从 4 月到 6 月涨了多少」,是在轴上切一段。上一篇我们用 DataFrame 装下了 OHLCV,但当时的日期还只是一列普通标签,没发挥出时间的威力。pandas 真正的看家本领恰恰在时间序列上:把日期变成真正的「时间索引」之后,按周按月聚合、滚动窗口计算、拿昨天比今天,全都变成一行代码。
本篇我们围绕一段模拟日行情,把五件武器逐一上手:DatetimeIndex(时间索引)、resample(重采样)、rolling(滚动窗口)、shift(错位对齐)和 pct_change(百分比变化)。它们是第 9 篇算收益率、第 10 篇算均线、第 18 篇写回测的直接地基——那几篇的核心代码,就是本篇这几个函数换了个名字的组合。
本篇目标
学完本篇,你能够做到:
- 把 CSV 里的日期列解析成 DatetimeIndex,并用「按年月切片」的语法直接取某个月的数据。
- 用
resample把日线聚合成周线和月线,并说清 OHLCV 各列分别该用什么聚合方式。 - 用
rolling计算滚动均值与滚动波动率,解释「窗口」与min_periods的含义。 - 用
shift取昨天的数据、用pct_change算日收益率,并能手写出两者的等价关系。 - 写任何窗口计算时,自觉检查有没有把「未来的数据」混进当期的计算。
核心概念
DatetimeIndex:把日期变成一等公民
从 CSV 读进来的日期默认是字符串,字符串只能「长得像日期」,没法按月份切片、没法算间隔。两步把它变成 DatetimeIndex(时间索引):读文件时 pd.read_csv("quotes.csv", parse_dates=["date"], index_col="date"),解析和设索引一步完成;如果 DataFrame 已经在手,用 pd.to_datetime(df["date"]) 加 df.set_index("date") 补救。变成时间索引后,立刻解锁几个语法糖:
df.loc["2024-03"] # 直接取 2024 年 3 月整月
df.loc["2024-04":"2024-06"] # 取一个日期区间,两端都是标签
划重点:行情数据请养成「日期设为索引」的肌肉记忆。它不只是好看——resample、时间切片、与其他数据按日期对齐,全都建立在 DatetimeIndex 之上。索引里躺着日期却用
df[df["date"] == ...]这种写法,是后面所有麻烦的源头。
resample:给数据换时间粒度
resample(重采样)把数据从一个时间粒度搬到另一个粒度。日线降成周线月线叫降采样,规则是「先切窗、再聚合」:"W-FRI" 表示以每周五为界切一周,"ME" 表示以月末为界切一月(老版本 pandas 写 "M")。关键在于 OHLCV 每列的聚合方式不一样:开盘价取期内第一个(first),最高价取最大(max),最低价取最小(min),收盘价取最后(last),成交量取求和(sum)——这正是真实周线、月线 K 线的画法。用 .agg(字典) 一行写全:
weekly = df.resample("W-FRI").agg(
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}
)
rolling:滚动窗口,均线与波动率的共同底座
rolling(滚动窗口)让一个固定大小的窗口沿时间轴滑动,每滑一步算一次。df["close"].rolling(5).mean() 是 5 日滚动均值——它正是第 10 篇要讲的 MA5 的本体;rolling(20).std() 是 20 日滚动标准差,作用在日收益率上就是常说的滚动波动率(rolling volatility)。两个细节:窗口没攒满前(前 n−1 行)结果是 NaN,这不是 bug,而是诚实的「数据不够不硬算」;想让不满的窗口也给值,用 min_periods,比如 rolling(5, min_periods=3) 表示最少攒够 3 个样本就算。
shift 与 pct_change:拿昨天对比今天
shift(1) 把整列数据向下挪一格:挪完之后,第 t 行里躺着的变成了第 t−1 行的值——也就是「昨天」。pct_change() 算「今天相对昨天变了百分之几」,即 close / close.shift(1) - 1,这正是日收益率的定义(第 9 篇的主角)。两者的等价关系值得手写一遍,因为它揭示了 pct_change 的本质:一个除法 + 一个 shift。
划重点:
shift是防未来函数(Look-ahead Bias)的第一道防线。未来函数指「用 t 日之后才有的信息参与 t 日的计算」——比如拿今天收盘价算出的信号在今天就成交,这在回测里等于作弊。凡是「拿过去比现在」的需求,先想 shift;凡是「今天算出的信号」,默认明天才能执行。这个纪律从本篇开始养成。
滚动波动率:一行代码的「温度计」
把 pct_change() 和 rolling(20).std() 接起来,就得到「最近 20 天收益率的波动有多剧烈」,这是衡量风险最朴素的温度计。日度波动率乘上 √252(一年约 252 个交易日)可以粗略年化,年化的来龙去脉第 9 篇细讲,这里先混个脸熟。
完整代码
依赖同上一篇:uv pip install pandas numpy。代码先在本地造一份 120 个交易日的模拟日线并存成 CSV,再读回来做全套演示,零外部依赖、复制就能跑。四块代码连起来依次运行。
import numpy as np
import pandas as pd
# 1) 造一段模拟日行情并保存为 CSV(真实数据怎么拉,第 7 篇专门讲)
rng = np.random.default_rng(7) # 固定种子,结果可复现
dates = pd.bdate_range("2024-01-01", periods=120) # 120 个交易日
ret = rng.normal(0.0005, 0.015, len(dates)) # 每日收益率
close = pd.Series(100 * np.exp(np.cumsum(ret)), index=dates).round(2) # 随机游走收盘价
open_ = (close * (1 + rng.normal(0, 0.003, len(dates)))).round(2) # 开盘价在收盘附近
high = (np.maximum(open_, close) * (1 + rng.uniform(0, 0.008, len(dates)))).round(2)
low = (np.minimum(open_, close) * (1 - rng.uniform(0, 0.008, len(dates)))).round(2)
volume = rng.integers(60_000, 180_000, len(dates))
daily = pd.DataFrame({"open": open_, "high": high, "low": low,
"close": close, "volume": volume})
daily.index.name = "date"
daily.to_csv("quotes.csv") # 落盘成 CSV,模拟真实文件
print(daily.head(3))
print(daily.shape)
weekend = (daily.index.dayofweek >= 5).sum() # 校验:日历里不该有周末
print(f"周末数量:{weekend}") # 应为 0
print(f"日收益标准差(应接近 0.015):{close.pct_change().std():.4f}") # 事后验证造数参数
print(f"全表缺失值:{int(daily.isna().sum().sum())}") # 应为 0
CSV 里的日期如今是字符串,接下来的重头戏是把它变成时间索引,并做重采样。
import pandas as pd
# 2) 读回 CSV:parse_dates 解析日期,index_col 设为索引
df = pd.read_csv("quotes.csv", parse_dates=["date"], index_col="date")
print(df.index.dtype) # datetime64[ns],不再是 object 字符串
print(df.index.min(), "→", df.index.max())
print(df.loc["2024-03"].head(3)) # 按月切片:直接取 2024 年 3 月
print(f"2 月交易日数:{len(df.loc['2024-02'])}") # 2 月有多少个交易日
jun_avg_vol = df.loc["2024-06", "volume"].mean() # 6 月(截至结束日)日均量
print(f"6 月日均成交量:{jun_avg_vol:,.0f}")
print(df.index.day_name().unique()) # 索引里出现过的星期名,应只有周一到周五
# 3) resample 降采样:日线 → 周线 / 月线
agg_dict = {"open": "first", "high": "max", "low": "min",
"close": "last", "volume": "sum"} # OHLCV 各自的聚合方式
weekly = df.resample("W-FRI").agg(agg_dict).dropna()
monthly = df.resample("ME").agg(agg_dict).dropna() # 老版 pandas 报错就换成 "M"
print(weekly.tail(3))
print(monthly[["close", "volume"]].head(3))
print(f"日线 {len(df)} 根 → 周线 {len(weekly)} 根 → 月线 {len(monthly)} 根")
最后是滚动窗口与错位对齐,顺手验证 pct_change 的等价写法。
import numpy as np
import pandas as pd
df = pd.read_csv("quotes.csv", parse_dates=["date"], index_col="date")
# 4) rolling:滚动窗口——均线与波动率的共同底座
df["ma5"] = df["close"].rolling(5).mean() # 5 日滚动均值,就是 MA5
df["win_min"] = df["close"].rolling(5, min_periods=3).min() # 攒够 3 个样本就算窗口最小值
df["vol20"] = df["close"].pct_change().rolling(20).std() # 20 日滚动波动率(日度)
df["vol20_ann"] = df["vol20"] * np.sqrt(252) # 乘 √252 粗略年化(第 9 篇细讲)
print(df[["close", "ma5", "vol20_ann"]].tail(3))
print(df[["close", "ma5"]].head(6)) # 开头几行:ma5 前 4 行应为 NaN
# 手算验证:第 5 天的 MA5 就是前 5 行收盘价的平均
manual = df["close"].iloc[:5].mean()
print(f"手算第 5 天 MA5:{manual:.2f}")
print(f"rolling 第 5 天 MA5:{df['ma5'].iloc[4]:.2f}")
# rolling + agg:一个窗口同时算多个统计量
multi = df["close"].rolling(10).agg(["mean", "std", "max"])
print(multi.tail(2))
# 5) shift 与 pct_change:拿昨天对比今天
df["prev_close"] = df["close"].shift(1) # 整列下移一行 = 昨日收盘
df["ret"] = df["close"].pct_change() # 日收益率
df["ret_manual"] = df["close"] / df["close"].shift(1) - 1 # 手写版,与上面等价
same = np.allclose(df["ret"].dropna(), df["ret_manual"].dropna())
print(f"pct_change 与手写公式结果一致:{same}")
print(df[["close", "prev_close", "ret"]].tail(3))
print(df["ret"].describe().round(4)) # 日收益率的分布概览
把本篇工具串成一个小流程:周线、周收益、量能均线一次到位。
import numpy as np
import pandas as pd
# 6) 组合小流程:周线 + 周收益 + 4 周均量 + 周方向
df = pd.read_csv("quotes.csv", parse_dates=["date"], index_col="date")
weekly = df.resample("W-FRI").agg({"close": "last", "volume": "sum"}).dropna()
weekly["ret"] = weekly["close"].pct_change() # 周收益率
weekly["vol4"] = weekly["volume"].rolling(4).mean() # 4 周平均量,观察放量缩量
weekly["trend"] = np.sign(weekly["close"] - weekly["close"].shift(1)) # 周涨跌方向
print(weekly["trend"].value_counts()) # 涨跌周各多少
print(weekly.tail(6))
# 顺带两个快照检查
df["ma5"] = df["close"].rolling(5).mean()
print(df.loc["2024-06-14", ["close", "ma5"]]) # 单日快照,方便手算核对
busy = (weekly["volume"] > weekly["vol4"]).sum()
print(f"放量周数(周量 > 4 周均量):{busy}")
代码逐段解释
第 1 块:造数据落盘。 用 np.random.default_rng(7) 固定种子;每日收益率从正态分布抽样,np.exp(np.cumsum(ret)) 累乘成随机游走的收盘价(起点 100 元);开盘价围绕收盘价小幅抖动,最高最低价用 np.maximum/np.minimum 保证包住开收盘;最后 to_csv("quotes.csv") 模拟「从文件读数据」的真实场景。head(3) 打出(示意):
open high low close volume
date
2024-01-01 99.87 100.42 99.55 100.21 143021
2024-01-02 100.15 100.66 99.78 100.53 121455
2024-01-03 100.42 101.05 99.96 100.88 156733
(120, 5)
周末数量:0
bdate_range 生成的是跳过周末的交易日历,所以周末数量校验为 0(它不含 A 股节假日,这是教学简化,第 7 篇用真实日历时会处理)。末尾两行是自检:日收益率标准差应接近造数据时设的 0.015,全表缺失值应为 0。
第 2 块:时间索引与重采样。 parse_dates=["date"] 让 pandas 把日期列解析成 datetime64[ns] 类型,index_col="date" 顺手设为索引,df.index.dtype 打印验证。df.loc["2024-03"] 直接取 3 月整月——字符串切片只有对 DatetimeIndex 才有效。df.loc["2024-02"] 的长度告诉我们 2 月有多少个交易日(示意:21 天);day_name().unique() 列出索引里出现过的星期名,应当只有周一到周五五个。重采样部分:agg_dict 规定每列的聚合方式,W-FRI 以周五为界切周、ME 以月末为界切月,dropna() 丢掉没有数据的空窗口。打印周线尾部(示意):
open high low close volume
date
2024-05-31 103.12 104.05 102.44 103.87 604321
2024-06-07 103.90 105.11 103.02 104.65 655102
2024-06-14 104.60 105.30 103.77 105.02 587433
日线 120 根 → 周线 24 根 → 月线 6 根
120 个交易日从 1 月 1 日(周一)跑到 6 月 14 日(周五),正好装满 24 个周五窗口、覆盖 6 个月份。周线的 open 是当周第一个交易日的开盘、close 是当周最后一天的收盘——和行情软件里的周 K 画法一致。月线头部(示意):
close volume
date
2024-01-31 101.16 2760543
2024-02-29 102.24 2488132
2024-03-31 103.30 2571889
第 3 块:滚动与错位。 ma5 是 5 日滚动均值,前 4 行为 NaN;win_min 演示了 min_periods=3 的用法——窗口不满 5 天、攒够 3 个样本也算最小值。vol20 对日收益率取 20 日标准差,vol20_ann 乘 √252 粗略年化(示意:0.22 上下,随行情起伏)。shift(1) 造出 prev_close;ret 与 ret_manual 一列用 pct_change、一列手写除法,np.allclose 验证两者逐行相等(输出 True)。手算验证的两次打印应当完全一致(示意:100.61 / 100.61)——这一步是为了确认你真的理解了滚动窗口在算什么。开头几行打印里 ma5 的前 4 行是 NaN,正是「窗口不满不硬算」的诚实表现;rolling(10).agg([...]) 则展示一个窗口可以同时输出均值、标准差和最大值。尾部打印(示意):
close prev_close ret
date
2024-06-12 104.31 104.02 0.0028
2024-06-13 104.72 104.31 0.0039
2024-06-14 105.02 104.72 0.0029
prev_close 恰好是上一行的 close,ret 等于两者之比减一——三列互相咬合,这就是「拿昨天对比今天」的全部含义。ret.describe() 还会给出日收益率的均值、标准差和分位数(示意:标准差约 0.015,与我们造数据时设的参数一致)。
第 4 块:组合小流程。 周线取收盘与成交量,pct_change 得周收益,rolling(4).mean() 得 4 周平均量用来观察放量缩量,np.sign 给出每周涨跌方向(+1 涨、−1 跌),value_counts() 顺手数出涨跌周各多少(示意:涨 14 周、跌 9 周,首周因 shift 缺方向不计入);最后统计「周成交量高于 4 周均量」的周数,这是最朴素的「放量周」计数(示意输出):
close ret vol4 trend
date
2024-05-10 103.44 0.0021 611234 1.0
2024-05-17 102.87 -0.0055 608876 -1.0
2024-05-24 103.29 0.0041 610254 1.0
2024-05-31 103.87 0.0056 612001 1.0
2024-06-07 104.65 0.0075 615332 1.0
2024-06-14 105.02 0.0035 617108 1.0
放量周数(周量 > 4 周均量):12
注意 5 月 17 日那行:周收益为负、方向为 −1,其余各周为正——和第 2 块里周线收盘价逐周抬高的走势吻合。24 周里有 12 周放量,说明量能围绕均量上下波动,符合模拟数据的随机设定。
常见坑与小练习
坑 1:忘了解析日期。 现象:resample 一跑就报 TypeError: Only valid with DatetimeIndex...。原因:CSV 读进来的日期是字符串,索引类型是 object。避免:读的时候带上 parse_dates 与 index_col,或事后 to_datetime + set_index 补救。
坑 2:resample("W") 与 resample("W-FRI") 混淆。 W 默认以周日为一周结束,而 A 股周六周日不交易,导致每周数据被切进「周五到下周日」的窗口,末尾多出空行。避免:A 股日线一律用 W-FRI。
坑 3:rolling 开头一串 NaN 当成 bug。 窗口没攒满前输出 NaN 是设计如此。要么接受它,要么用 min_periods 显式声明最少样本数;千万不要用 fillna(0) 把它糊成 0,那会污染后续所有计算。
坑 4:pct_change 的第一行 NaN 与中间缺失。 第一行没有「昨天」,NaN 正常;但若中间某天停牌缺数,除法会放大异常。养成习惯:算收益前先 df.isna().sum() 检查缺失(第 8 篇专讲清洗)。
坑 5:当日信号当日成交。 用今天收盘算出的均线金叉,今天收盘「定稿」前就不可得,当天就成交属于偷跑。回测里至少 shift(1) 到次日执行——这是回测篇(第 17 篇起)反复敲打的头号陷阱,本篇的 shift 就是它的解药。
小练习 1: 把周线改成双周:resample("2W-FRI"),数一数 120 个交易日能聚出多少根双周线,并解释为什么不是 24 的一半。
思路提示:本例起点恰逢周一,24 周正好被对半切成 12 根;把起始日改成周中再跑一遍,观察第一根不满双周的「半截窗口」。打印
len()对照。
小练习 2: 新增一列 diff = ma5 - ma20,找出 diff 由负转正的第一个交易日——这正是第 10 篇金叉的雏形。
思路提示:条件是
(df["diff"] > 0) & (df["diff"].shift(1) <= 0),再用布尔索引取第一行。
小练习 3: 用 rolling(20).apply(lambda x: x[-1] / x[0] - 1) 手写 20 日动量,与 pct_change(20) 对比是否一致,想一想两者窗口含义的异同。
思路提示:两者都是「今天除以 20 个交易日前减 1」,但
apply版能顺手改成任意自定义函数,比如窗口内的最大回撤。
本文示例均基于模拟数据,仅用于教学,不构成投资建议,实盘前请充分测试。