从零搭建你的 A 股量化系统(十五):Pandas 量化必备 30 操作(上)——索引、时间序列、重采样
系列名:《从零搭建你的 A 股量化系统》| 专栏 S2 Python 工具链 | 第 3 篇 / 共 14 篇
标签:#量化投资 #Pandas #时间序列 #重采样 #Python #数据分析 #零基础 #实战 #A股 #散户
NumPy 是发动机,pandas 是装了发动机的整车。发动机再猛,你也不能天天手搓数组去算"某年某月某只股票涨了多少"“把日线压成月线看趋势”。这些脏活累活,pandas 一脚油门就帮你办了。
这一篇开始,我们用30 个高频操作把 pandas 在量化里最常用、也最容易被新手卡住的本事拆干净。因为一次塞 30 个太干,我拆成上下两篇:
- 本篇(上):聚焦索引、时间序列、重采样三大块,共15 个操作;
- 下篇(S2-016):补齐groupby / rolling / shift 与未来函数陷阱,又是15 个。
两篇合起来正好30 个量化必备操作。配套脚本src/s2_015_pandas_essentials.py,全程用你仓库里现成的data/510300_daily.csv(沪深300ETF 真实日线)跑通。
📌 你将学到
- 为什么量化里索引(Index)是第一公民,以及
set_index/.loc/.iloc/ 布尔索引到底怎么选; - DatetimeIndex这套"把日期当索引"的思维,怎么让你用
df['2024']一行就榨出全年数据; - 时间序列四大高频动作:按年/月取数、时间段切片、
date_range造日期、DateOffset 偏移; - 重采样(resample)怎么把日线"压"成周线/月线/季线,并顺手聚合成交量、算月收益;
- 新手最容易踩的6 个坑(最阴的是
df['2024']报错、resample最后一桶不全、read_csv把日期读成字符串),附避雷代码。
📖 前置知识
- 已按第 1 篇装好
ashare环境(参考src/s2_013_detect_env.py确认),并已pip install pandas; - 看过 S2-014《NumPy 核心》最好——本篇默认你懂"向量化",pandas 底层就是它;
- 会最基础的 Python:变量、列表、函数。pandas 的 API 名字很直白,不懂统计学也能跟。
操作地图
| 篇 | 块 | 操作 |
|---|---|---|
| 上(本篇) | 索引 | ① 读 CSV 吃 BOM ② 设 DatetimeIndex ③ 选列 ④.loc标签取行 ⑤.iloc位置取行 ⑥ 布尔索引 + 索引对齐 |
| 上(本篇) | 时间序列 | ⑦ 按年/月取数 ⑧ 时间段切片 ⑨date_range⑩ 时间差 ⑪ DateOffset 偏移 |
| 上(本篇) | 重采样 | ⑫ 月线收盘 ⑬ 周线 OHLC ⑭ 季度聚合 ⑮ 月收益聚合 |
| 下(S2-016) | groupby/rolling/shift | ⑯~⑳groupby全家桶 ㉑rolling滚动 ㉒expanding㉓shift错位 ㉔pct_change㉕apply㉖ 金叉信号 ㉗ 横截面rank㉘merge㉙concat㉚ 缺失值处理 |
💡 建议:把这张表当书签。上篇学完索引/时间序列/重采样,你就能独立做出"按月看收益、按周看波动"的最基础研究;下篇的
rolling/shift才是因子和回测的命门。
一、为什么是 pandas
NumPy 的ndarray只有"行号",没有"名字"。可量化数据天然带日期和字段名:2024-09-30 的收盘价、开盘价……你用行号arr[1234]去取,三天后就忘了 1234 是哪天。
pandas 的DataFrame=带标签的二维表:行有索引(通常是日期),列有字段名(open/high/low/close/volume)。好处是——
- 你不用记行号,直接写
df.loc['2024-09-30']; - 两列相减会自动按日期对齐,不用担心"第一行对第一行"的错配;
- 时间相关的活(
resample重采样、DateOffset偏移)开箱即用。
一句话:NumPy 让你算得快,pandas 让你找得准、读得懂。下面 15 个操作,全是围绕"索引"和"日期"转。
二、索引篇(操作 ① ~ ⑥)
操作① 读 CSV,先吃掉 BOM(量化数据最常见的第一个坑)
A 股数据源(AkShare、Tushare 导出的 CSV)经常带BOM(字节顺序标记)。如果你不处理,第一列名会变成'\ufeffdate',后面set_index('date')直接KeyError。
importpandasaspd# encoding='utf-8-sig' 自动吃掉 BOM,量化数据几乎必加df=pd.read_csv("data/510300_daily.csv",encoding="utf-8-sig")print(list(df.columns))# ['date', 'open', 'high', 'low', 'close', 'volume'] ✓ 干净配套脚本跑出来:
标的: 510300 沪深300ETF 共 1,594 个交易日 区间: 2020-01-02 ~ 2026-07-31 列名: ['open', 'high', 'low', 'close', 'volume'] open high low close volume date 2020-01-02 1.673 1.693 1.672 1.683 627623614.0 2020-01-03 1.686 1.688 1.677 1.680 469673776.0 2020-01-06 1.675 1.692 1.663 1.674 666504783.0 索引类型: DatetimeIndex -> datetime64[ns]操作② 把日期列设为索引(DatetimeIndex)——时间序列的基石
read_csv读进来时,date只是普通一列字符串。要把它变成"会思考的日期索引",两步:先to_datetime解析,再set_index。
df["date"]=pd.to_datetime(df["date"])# 字符串 -> 真正的 Timestampdf=df.set_index("date").sort_index()# 设为索引,并升序排好print(type(df.index).__name__,df.index.dtype)# DatetimeIndex datetime64[ns]⚠️ 忘记
to_datetime就set_index,索引会是"字符串"而不是"日期"——后面df['2024']按年取数会直接失效。这是坑⑤,详见第五节。
操作③ 选列:单列 → Series,多列 → DataFrame
close_series=df["close"]# 单列 -> Series(一维)oc_df=df[["open","close"]]# 多列 -> DataFrame(二维),注意两层方括号df['close'] 类型: Series 长度: 1594 df[['open','close']] 类型: DataFrame 形状: (1594, 2)💡 经验法则:单层
[]给列名(结果可能是 Series 或 DataFrame),双层[[]]永远返回 DataFrame。写因子时尽量用双层,下游.pipe()、.assign()才不会因维度突变翻车。
操作④ 按标签取行:.loc['2024-09-30']
索引是日期后,直接用日期字符串当"行号"。比如取出著名的"924 行情"次日:
print(df.loc["2024-09-30"])open 1.735000e+00 high 1.821000e+00 low 1.686000e+00 close 1.818000e+00 volume 6.370234e+09操作⑤ 按位置取行:.iloc[0]
想要"第几行"而不是"哪天",用.iloc(按整数位置,从 0 开始):
print(df.iloc[0])# 最早一天 2020-01-02open 1.673000e+00 high 1.693000e+00 low 1.672000e+00 close 1.683000e+00 volume 6.276236e+08📌
.loc用"标签"(日期)、.iloc用"位置"(第几行)。混用.iloc配日期字符串会报错,新手常栽在这。
操作⑥ 布尔索引 + 索引自动对齐(pandas 最香的特性)
想筛"收盘价大于 2 元的所有交易日",直接上布尔条件:
high=df[df["close"]>2.0]print(f"收盘价 > 2.0 元:{len(high):,}/{len(df):,}天")收盘价 > 2.0 元的交易日: 432 / 1,594 天 (27.1%)更妙的是索引自动对齐:两列做运算时,pandas 按"日期"对齐而不是按"行号"。下面这行算"当天开盘到收盘的涨跌幅",完全不用管顺序对不对:
df["intraday_chg"]=(df["close"]-df["open"])/df["open"]open close intraday_chg date 2020-01-02 1.673 1.683 0.005977 2020-01-03 1.686 1.680 -0.003559 2020-01-06 1.675 1.674 -0.000597💡 这个"按索引对齐"是 pandas 防错的核心机制。等你下篇做多只股票拼接(
concat/merge)时,它会替你挡掉 90% 的"对错行"事故。
三、时间序列篇(操作 ⑦ ~ ⑪)
DatetimeIndex 设好后,时间相关的活变得像说话一样自然。
操作⑦ 按年 / 按月取数(局部字符串索引)
y2024=df.loc["2024"]# 全年m2024_01=df.loc["2024-01"]# 某月print(f"2024 全年交易日:{len(y2024)}天")print(f"2024-01 交易日:{len(m2024_01)}天")2024 全年交易日: 242 天 2024-01 交易日: 22 天,区间 2024-01-02~2024-01-31 2024-01 收盘: [1.504, 1.5, 1.489, 1.482, 1.465, 1.468, 1.462, 1.469, 1.465, 1.463, 1.472, 1.444, 1.46, 1.463, 1.437, 1.446, 1.465, 1.485, 1.483, 1.472, 1.451, 1.443]⚠️必须用
.loc,不能写df["2024"]——后者会被当成"取名为 2024 的列"而KeyError。这是新手第一坑,第五节展开。
操作⑧ 时间段切片(含两端)
要一段区间,直接'起':'止',两端都包含(和 Python 普通切片不同,这里不"左闭右开"):
q1=df.loc["2024-01-01":"2024-03-31"]print(f"2024 年一季度交易日:{len(q1)}天,区间收盘{q1['close'].min():.3f}~{q1['close'].max():.3f}")2024 年一季度交易日: 58 天 区间收盘: 1.431 ~ 1.581操作⑨pd.date_range造连续日期序列
做回测框架、对齐多标的日历时常要自己造日期轴:
r=pd.date_range("2026-01-01","2026-01-10",freq="D")# 连续自然日rb=pd.bdate_range("2026-01-01","2026-01-10")# 仅工作日print([d.date().isoformat()fordinr])print([d.date().isoformat()fordinrb])freq='D' 连续 10 天: ['2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04', '2026-01-05', '2026-01-06', '2026-01-07', '2026-01-08', '2026-01-09', '2026-01-10'] freq='B'(工作日) 同区间: ['2026-01-01', '2026-01-02', '2026-01-05', '2026-01-06', '2026-01-07', '2026-01-08', '2026-01-09']📌
freq是灵魂:D日、B工作日、W周、ME月末、QE季末、QE-DEC等。它和下面的resample共用同一套频率字符串。
操作⑩ 时间差:自然日 vs 交易日
两个日期相减,得到Timedelta,.days拿到天数:
first,last=df.index.min(),df.index.max()print(f"自然日跨度:{(last-first).days}天,实际交易日:{len(df)}天")首末交易日: 2020-01-02 -> 2026-07-31 自然日跨度: 2402 天 实际交易日: 1594 天 -> 约 242 个交易日/年💡 记住A 股一年约 242 个交易日(不是 365)。所有"年化"都要乘 242 或 252,别用 365——这是 S1-010 讲年化波动率的底层约定。
操作⑪ DateOffset 偏移:取"N 个自然日/交易日前"
target=pd.Timestamp("2024-09-30")prev_5=target-pd.DateOffset(days=5)# 往前 5 个自然日last_b=df.index[df.index<=target][-1]# 不晚于 target 的最近交易日print("往前5自然日:",prev_5.date(),"| 最近交易日:",last_b.date())2024-09-30 往前 5 个自然日: 2024-09-25 不晚于 2024-09-30 的最近交易日: 2024-09-30📌
DateOffset(days=5)是"日历偏移";若要"往前 5 个交易日"要用BDay(5)(需from pandas.tseries.offsets import BDay)。做"N 日前的信号"时务必分清,否则会混入周末/节假日。
四、重采样篇(操作 ⑫ ~ ⑮)
resample= 把高频数据按时间频率"降采样"成低频,并指定每个桶怎么聚合。它是把日线变成周/月/季线的唯一正解。
操作⑫ 月线收盘价:resample('ME').last()
monthly_close=df["close"].resample("ME").last()# ME = 月末(Month End)print(monthly_close.tail(6).round(3))date 2026-02-28 2.079 2026-03-31 1.982 2026-04-30 2.116 2026-05-31 2.153 2026-06-30 2.188 2026-07-31 2.053 Freq: ME⚠️ 老教程写
resample('M'),新版 pandas(≥2.2)会报警告,改用'ME'(月末)/'MS'(月初)。本文全程用新写法。
操作⑬ 周线 OHLC:resample('W').ohlc()
一根 K 线要开高低收,ohlc()一行给齐:
weekly_ohlc=df["close"].resample("W").ohlc()print(weekly_ohlc.tail(4).round(3))open high low close date 2026-07-12 2.136 2.150 2.106 2.118 2026-07-19 2.086 2.121 2.029 2.029 2026-07-26 2.051 2.102 2.051 2.070 2026-08-02 2.090 2.090 2.035 2.053操作⑭ 季度聚合多指标:resample('QE').agg({...})
不同列用不同聚合函数,传字典即可:
quarterly=df.resample("QE").agg({"close":"last","volume":"sum"})quarterly["volume"]=(quarterly["volume"]/1e8).round(2)# 成交量转「亿股」quarterly.columns=["季末收盘","季度成交量(亿股)"]print(quarterly.tail(4))季末收盘 季度成交量(亿股) date 2025-12-31 2.044 459.41 2026-03-31 1.982 943.85 2026-06-30 2.188 630.04 2026-09-30 2.053 336.84📌 注意最后一行
2026-09-30:样本只到 7-31,所以这个"季度"其实只含 7 月数据,是个不完整桶。真实研究里常.iloc[:-1]砍掉最后一桶,或显式判断桶内天数。坑②会讲。
操作⑮ 由日收益聚合月收益:resample('ME').last().pct_change()
把日线收盘价先压成月线,再.pct_change()算"月涨跌幅"——这就是 S5 因子、S8 组合里"月度收益表"的源头:
monthly_ret=df["close"].resample("ME").last().pct_change().dropna()up=(monthly_ret>0).sum()print(f"上涨月:{up}下跌月:{len(monthly_ret)-up}")print((monthly_ret.tail(6)*100).round(2).astype(str).add("%"))月度收益率序列(共 78 个月,去掉首月) 上涨月: 42 下跌月: 36 date 2026-02-28 0.24% 2026-03-31 -4.67% 2026-04-30 6.76% 2026-05-31 1.75% 2026-06-30 1.63% 2026-07-31 -6.17%💡 顺便看一眼:近 6 个月 4 涨 2 跌,但单月波动能到 ±6%——这就是为什么要做组合、要控回撤。就这么一行
resample,你已经摸到了"收益分布"的门槛。
五、新手最常踩的坑(避雷)
| # | 现象 | 原因 | 解决办法 |
|---|---|---|---|
| 坑① | df['2024']报KeyError: '2024' | 按年/月取数是索引能力,必须走.loc | 写df.loc['2024'],别写df['2024'] |
| 坑② | resample最后一个月/季数据"缺一半" | resample 会补齐到周期边界,末桶只有部分数据 | 展示前.iloc[:-1]砍掉末桶,或过滤桶内天数 |
| 坑③ | df.close取不到列,或取到奇怪东西 | 列名含空格/与 DataFrame 方法重名(如df.count)时属性访问失效 | 永远用df['列名']双层写法,别用df.列名 |
| 坑④ | 合并两张表后日期对不上、出现大量NaT | 一张是"naive"(无时区)、一张是"tz-aware"(有时区) | 统一用tz_localize(None)去掉时区,或都加同一时区 |
| 坑⑤ | df['2024']取数失效、排序像字符串 | read_csv把日期读成字符串,set_index前忘了to_datetime | 先df['date'] = pd.to_datetime(df['date'])再set_index |
| 坑⑥ | resample('M')跑出FutureWarning | 旧频率别名'M'在新版 pandas 已废弃 | 改用'ME'(月末)/'MS'(月初)/'QE'(季末) |
坑① 现场演示(最阴的一个,90% 新手第一天就撞):
df=pd.read_csv("data/510300_daily.csv",encoding="utf-8-sig").set_index("date")df.loc["2024"]# ✅ 正确:242 天df["2024"]# ❌ KeyError: '2024'(它去找"叫 2024 的列"了)坑⑤ 现场演示(为什么必须to_datetime):
raw=pd.read_csv("data/510300_daily.csv",encoding="utf-8-sig").set_index("date")print(raw.index.dtype)# object(字符串!)raw.loc["2024"]# ❌ 字符串索引不支持按年切片clean=raw.reset_index()clean["date"]=pd.to_datetime(clean["date"])clean=clean.set_index("date")print(clean.index.dtype)# datetime64[ns] ✅clean.loc["2024"]# ✅ 242 天📌保命三连:① 读 CSV 永远
encoding='utf-8-sig';②set_index前永远先to_datetime;③ 按年/月取数永远.loc不是[]。把这三条刻进肌肉记忆,pandas 入门的坑你直接跳过一半。
六、本篇小结 & 下篇预告
小结:本篇(上)用真实 A 股日线跑通了15 个量化必备操作,全在"索引 + 时间序列 + 重采样"三块:
- 索引让你"找得准"——
set_index把日期变成索引,.loc按日期取、.iloc按位置取、布尔索引按条件筛,且两列运算按索引自动对齐,天然防错; - 时间序列让你"切得动"——
df.loc['2024']一行榨全年、'起':'止'含端切片、date_range造日历、DateOffset做偏移; - 重采样让你"看得远"——
resample('ME'/'W'/'QE')把日线压成月/周/季线,agg({...})多指标同聚合,.pct_change()直接出月度收益。
记住三条保命准则:读 CSV 加utf-8-sig、设索引前先to_datetime、按年取数用.loc。
下一篇预告:专栏 S2 的第四篇《Pandas 量化必备 30 操作(下):groupby、rolling、shift 与未来函数陷阱》。你会学到:怎么按"年/股票"分组算指标、rolling滚动窗口算均线、shift错位算收益率——以及为什么shift用错一步,你的回测会"偷看未来"直接变造假。这 15 个操作(⑯~⑳)直接决定你后面因子和回测的真假。准备好ashare环境,我们下篇见。
免责声明:本文所有内容仅用于量化投资技术教学与知识分享,文中涉及的代码示例、跑分数据与实证结果均不构成任何投资建议或个股推荐。投资有风险,入市需谨慎;实际交易前请务必用自己的真实数据充分回测,并充分了解相关风险。
