用 pandas 处理行情数据:量化的第一项基本功
- 会用 pandas 读取与清洗行情数据(日期索引、缺失值、排序)
- 会算日收益率、累计收益、移动均线等基础指标
- 建立「数据干净是量化第一步」的意识
很多人一上来学量化,第一件事就是打开教程找「策略」:双均线金叉买、死叉卖,回测一跑,曲线漂亮,激动得不行。可真到自己写,往往第一步就崩了——拿到一份行情数据,日期是乱的、中间缺了几天、有一行价格莫名其妙是 0,算出来的收益率全是 NaN 和 inf。策略逻辑没错,错的是喂给它的数据。
量化圈有句老话:垃圾进,垃圾出(garbage in, garbage out)。 你的策略再精妙,喂进去的是脏数据,出来的就是脏结果。所以量化真正的第一项基本功,不是写策略,是把行情数据洗干净、算明白。这篇就用 pandas 把这套流程从头跑一遍。
本文是数据处理教学,所有数据都是用程序生成的模拟序列,不是任何真实标的的真实行情。文中算的收益率、均线只为演示 pandas 用法,不构成任何买卖信号、不预测涨跌、不导向实盘操作。投资有风险,详见 /disclaimer/。
先说清楚 pandas 到底帮你干什么
pandas 是 Python 里处理表格数据的标准工具。你可以把它想成「能写代码操控的 Excel」:行情数据本质就是一张表,每一行是某一天,每一列是开盘价、收盘价、成交量这些字段。Excel 拖鼠标做的事——排序、筛选、算新列、求平均——pandas 用一两行代码就能做,而且能处理几十万行不卡。
行情数据有个最常见的格式叫 OHLCV:开盘价(Open)、最高价(High)、最低价(Low)、收盘价(Close)、成交量(Volume)。后面我们要造的、要洗的、要算的,都围绕这张 OHLCV 表。
环境准备很简单,命令行里装两个库就行:
pip install pandas numpy
第一步:造一份模拟行情(为了可复现)
教学最怕的是「我这跑得通,你那跑不通」。如果让你去下载真实行情,每个人拿到的数据不一样、还可能有版权和接口问题。所以这里用 numpy 生成一段模拟价格序列——固定随机种子,保证你照抄代码跑出来的数字和我的一模一样。
import numpy as np
import pandas as pd
# 固定随机种子:保证每次运行、每个人跑出来的随机数都一样,结果可复现
np.random.seed(42)
# 造 100 个交易日的日期。freq='B' 表示 business day(只取工作日,跳过周末)
dates = pd.date_range(start='2024-01-01', periods=100, freq='B')
# 用「随机游走」模拟收盘价:每天的涨跌幅是一个均值 0、标准差 1% 的随机数
# 真实股价的走势近似随机游走,这样造出来的曲线比纯随机数像样得多
daily_returns = np.random.normal(loc=0.0, scale=0.01, size=100)
close = 100 * (1 + daily_returns).cumprod() # 从 100 元起步,逐日累乘涨跌幅
# 用收盘价反推出一份完整的 OHLCV 表(纯演示,不追求严谨)
df = pd.DataFrame({
'date': dates,
'open': close * (1 + np.random.normal(0, 0.003, 100)), # 开盘价在收盘价附近抖动
'high': close * (1 + np.abs(np.random.normal(0, 0.005, 100))), # 最高价略高于收盘
'low': close * (1 - np.abs(np.random.normal(0, 0.005, 100))), # 最低价略低于收盘
'close': close,
'volume': np.random.randint(1_000_000, 5_000_000, 100), # 成交量随机整数
})
print(df.head()) # 看前 5 行长什么样
这段在干嘛:先用 cumprod(累乘)把每天的涨跌幅滚成一条价格曲线,再围绕收盘价造出开高低和成交量,最后塞进一个 DataFrame(pandas 的表格对象)。df.head() 打印前 5 行,是你拿到任何数据后该做的第一个动作——先看一眼它长什么样,别急着算。
跑出来大概是这样(截取):
date open high low close volume
0 2024-01-01 99.86... 100.71... 99.43... 100.49... 3267441
1 2024-01-02 100.30... 100.55... 99.81... 100.35... 1411319
...
第二步:把日期变成索引,并排好序
原始数据里,日期只是普通一列。但行情分析几乎所有操作都按时间来——取某段时间、按日对齐、算移动窗口——所以第一件事是把日期设成索引(index),相当于给这张表换了个「按时间查」的目录。
# 把 date 列设为索引。inplace=True 表示直接改原表,不返回新表
df.set_index('date', inplace=True)
# 按时间从早到晚排序。真实数据里日期顺序经常是乱的,必须先排好
df.sort_index(inplace=True)
print(df.index) # 确认索引现在是 DatetimeIndex(pandas 专门的时间索引类型)
为什么排序这么重要?因为后面算收益率、算均线,都默认「上一行就是前一个交易日」。如果日期是乱的,你算的「今天比昨天」其实是「今天比某个随机的别的日子」,结果全错,而且程序不会报错——它照算不误,只是算的是垃圾。这是新手最容易踩、又最难发现的坑。
坑:拿到数据不排序就直接算。 很多数据源(尤其是从接口拿的、多文件拼起来的)日期顺序是乱的或倒序的。你不 sort_index 就算 pct_change,程序照跑不报错,但每一个收益率都是错的。为什么坑:错误是「静默」的,曲线照样画得出来,你根本看不出哪里不对,等拿去做决策才发现全盘皆错。怎么避:养成习惯,设完时间索引立刻 sort_index(),把它当成肌肉记忆。
第三步:处理缺失值
真实行情里缺数据是常态:停牌的日子没价格、接口偶尔吐回一个空值、不同来源拼表时对不齐。pandas 用 NaN(Not a Number)表示缺失。我们先手动制造几个缺失值,再演示怎么查、怎么补。
# 故意把第 10、20、30 行的 close 改成缺失值,模拟真实数据的窟窿
df.loc[df.index[[10, 20, 30]], 'close'] = np.nan
# 第一步永远是「先看有多少缺失」,别上来就补
print(df.isna().sum()) # 每一列各有多少个 NaN
# 对行情这类时间序列,常用「前向填充」:用前一个交易日的值补上
# 逻辑是:停牌期间价格视为不变,沿用上一个有效价格
df['close'] = df['close'].ffill()
print(df['close'].isna().sum()) # 再查一次,应该是 0 了
这段的关键不是「怎么补」,而是先搞清楚缺了多少、为什么缺,再决定怎么处理。这里用 ffill(前向填充)是因为停牌期间「价格视为不变」在金融上说得通。但请注意,填充方式必须想清楚业务含义,不是随手填个 0 或填个平均值就完事。
坑:缺失值直接 dropna 一删了之,或者随手填 0。 直接删行,会让时间序列出现「断点」——原本连续的交易日中间被抽掉几天,你后面算的「日收益率」就跨过了那段空洞,变成了「隔好几天的收益率」,数值被严重高估。填 0 更糟,价格突然从 100 跳到 0 再跳回来,会算出 -100% 和 +∞ 这种荒谬的收益率。为什么坑:这两种做法都会在你看不见的地方污染整条序列。怎么避:先 isna().sum() 搞清缺失规模和位置,再按业务含义选填充方式(行情常用 ffill),删除是最后才考虑的手段。
第四步:算日收益率和累计收益
数据干净了,终于可以算指标。最基础的就是日收益率——今天相对昨天涨跌了百分之几。pandas 有现成的 pct_change(),一行搞定。
# pct_change():自动用「(今天 - 昨天) / 昨天」算每日涨跌幅
df['daily_return'] = df['close'].pct_change()
# 第一行没有「昨天」,所以是 NaN,这是正常的,去掉它再继续
df['daily_return'] = df['daily_return'].fillna(0)
# 累计收益:把每天的涨跌幅连乘起来,看从头到现在总共涨跌了多少
# (1 + 每日收益) 连乘 - 1,得到累计收益率
df['cum_return'] = (1 + df['daily_return']).cumprod() - 1
print(df[['close', 'daily_return', 'cum_return']].tail()) # 看最后 5 行
这段在干嘛:pct_change 把价格序列转成涨跌幅序列;cumprod(累乘)再把每天的涨跌幅滚成「从第一天到今天总共赚/亏了多少」。注意这里绝对不能用累加——涨 10% 再跌 10% 不是回到原点,是亏了 1%,必须用 (1+r) 连乘才对。这是金融数据处理里一个真实的、新手常错的细节。
坑:用「未来的数据」算「过去的信号」(未来函数)。 这是量化里最致命、最隐蔽的错误。比如你想做一个「今天的信号决定明天买不明天买」的研究,正确做法是用 df['signal'].shift(1) 把信号往后挪一天,表示「拿昨天收盘后才知道的信号,指导今天的动作」。如果你不 shift,直接拿今天的收盘价算出今天的信号、又用它「决定」今天的买卖,相当于偷看了今天的答案再下注。为什么坑:回测曲线会美得不真实,一拿到真实环境立刻原形毕露,因为现实里你不可能在收盘前就知道收盘价。怎么避:凡是「信号 → 动作」之间,老老实实 shift(1),并反复问自己「这个数,在我做决定的那一刻真的已经知道了吗?」
第五步:算移动均线
移动均线(Moving Average)就是「最近 N 天收盘价的平均」,是行情分析里最常见的派生指标。pandas 用 rolling(滚动窗口)一行算出来。
# rolling(window=5):开一个 5 天的滑动窗口;.mean() 算窗口内平均
df['ma5'] = df['close'].rolling(window=5).mean() # 5 日均线
df['ma20'] = df['close'].rolling(window=20).mean() # 20 日均线
# 前几天因为凑不够窗口长度,均线会是 NaN,这是正常的
print(df[['close', 'ma5', 'ma20']].head(25))
这段在干嘛:rolling(window=5) 像一个在数据上从左往右滑动的「取景框」,每滑一格就框住最近 5 天,.mean() 算出框内平均。20 日均线同理,只是框更大。注意头部那些 NaN:算 5 日均线,第 1 到第 4 天凑不齐 5 个数,自然没有结果,这不是 bug。
第六步:把结果看出来
数字看着累,画出来就直观了。matplotlib 是 Python 最常用的绘图库。
import matplotlib.pyplot as plt
# 装一下:pip install matplotlib
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(11, 7), sharex=True)
# 上图:收盘价 + 两条均线
ax1.plot(df.index, df['close'], label='Close', linewidth=1)
ax1.plot(df.index, df['ma5'], label='MA5', linewidth=1)
ax1.plot(df.index, df['ma20'], label='MA20', linewidth=1)
ax1.set_title('Simulated price with moving averages (DEMO DATA ONLY)')
ax1.legend()
# 下图:累计收益曲线
ax2.plot(df.index, df['cum_return'], label='Cumulative return')
ax2.axhline(0, color='gray', linewidth=0.8) # 画一条 0 基准线
ax2.set_title('Cumulative return')
ax2.legend()
plt.tight_layout()
plt.show()
跑完你会看到两张图:上面是模拟收盘价配两条均线,下面是累计收益曲线。再强调一次——这是用随机数造出来的模拟价格,图形不代表任何真实标的,更不代表任何策略能赚钱。 它只是让你确认「数据流通顺了、指标算对了」。
不想画图、只想要个数字汇总也行,pandas 一行给你统计:
# describe() 给出每列的计数、均值、标准差、最大最小值等
print(df[['daily_return', 'cum_return']].describe())
一张表,记住这套清洗流程的关键动作
| 环节 | pandas 动作 | 为什么这一步不能省 |
|---|---|---|
| 看数据 | df.head() / df.info() |
先认清字段和类型,别盲算 |
| 设时间索引 | set_index('date') |
让所有按时间的操作能对齐 |
| 排序 | sort_index() |
日期乱序会让收益率/均线全错且不报错 |
| 查缺失 | isna().sum() |
先知道缺多少,再决定怎么补 |
| 补缺失 | ffill()(按业务选) |
删行/填 0 会污染序列 |
| 算日收益 | pct_change() |
把价格转成可比较的涨跌幅 |
| 算累计收益 | (1+r).cumprod()-1 |
必须连乘,不能累加 |
| 算均线 | rolling(n).mean() |
滚动窗口派生指标 |
| 防未来函数 | shift(1) |
信号指导动作必须延后一天 |
还有几个真实世界的坑,比代码更重要
上面讲的是「代码层面」的干净。但行情数据还有几个「数据来源层面」的坑,是再熟练的 pandas 也补不了的,必须在拿数据时就警惕。
第一个是复权问题。股票分红、送股、拆股之后,价格会「跳一下」,但这不是真涨真跌。如果你拿的是「不复权」价格,分红除权那天会凭空算出一个大跌,污染收益率。做历史分析通常要用后复权价格。这不是代码能修的,是你取数时就得选对的字段。
第二个是幸存者偏差(survivorship bias)。如果你的数据集里只剩下「现在还活着」的公司——退市的、破产的、被并购的都被剔除了——那你研究出来的任何结论都会偏乐观,因为你天然只统计了「活下来的赢家」。这就像只调查中了彩票的人,得出「买彩票能发财」的结论。
坑:以为「跑通了代码」就等于「数据是对的」。 复权、幸存者偏差这类问题,pandas 不会报任何错,代码跑得飞快、曲线漂漂亮亮,但底层数据本身就是歪的。为什么坑:技术上的干净(没有 NaN、排好序)和数据质量上的干净(口径正确、样本无偏)是两码事,后者更隐蔽、更致命。怎么避:拿到任何数据集,先问三句——价格复权了吗?退市的公司在不在里面?时间段有没有断档?想不清楚来源口径,再漂亮的回测都不能信。关于这些数学和认知层面的陷阱,可以去 原理库 系统补一补。
自检清单:开算之前先过一遍
- 我看过
head()和info(),确认每一列的含义和数据类型了吗? - 时间设成索引并
sort_index()了吗? - 我查过缺失值的数量和位置,并按业务含义(而非偷懒)选了填充方式吗?
- 累计收益我用的是连乘
(1+r).cumprod(),没有错用累加吧? - 凡是「信号决定动作」的地方,我
shift(1)了吗?有没有偷看未来? - 这份价格是复权的吗?数据集有没有幸存者偏差?
这六条全过了,你的数据才算真正「干净」,后面再谈策略才有意义。
小结
- 量化的第一步不是写策略,是把行情数据洗干净:设时间索引、排序、查补缺失值,每一步都有它非做不可的理由。
- 收益率用
pct_change、累计收益用(1+r).cumprod()-1(连乘不是累加)、均线用rolling().mean(),这三件是行情数据的基本派生指标。 - 比代码更要命的是数据来源层面的坑:未来函数、复权、幸存者偏差、缺失值乱处理——它们不报错,却能让你的所有结论悄悄变成垃圾。
- 本文全程是模拟数据的教学演示,不构成任何买卖信号。把基本功练扎实,再往后走。
继续往下,可以回到 L5阶梯 看量化工具链的整体路线;想搞懂收益率、波动率背后的数学和那些认知陷阱,去 原理库 补地基;任何涉及实盘的念头出现前,先重读一遍 /disclaimer/。