☰
pandas股票分析全流程:从数据清洗到指标计算与性能优化
2026/10/27 3:09:22 网站建设 项目流程

很多人第一次用 pandas 做股票分析,都是从一行df['close'].plot()开始的——画完图觉得挺像回事,真到要算个收益率、做个回测、对比几只标的的时候,立刻卡住。问题不在 pandas 本身,而在于大部分人拿到行情数据后,没有先想清楚"这张表到底该长什么样"。我见过太多人把日期留成字符串、把复权价和原始价混在一列、用iterrows一行行遍历几万条数据,跑一次要等半分钟。这篇就把我用 pandas 处理行情数据的一套完整流程摊开讲:从数据落地、清洗对齐,到指标计算、分组统计,再到性能优化和常见坑。适合已经会写read_csv但还没形成体系的朋友,也适合想把自己那套零散脚本整理成可复用流程的人。全程围绕 pandas 这一个工具,不牵扯别的框架,代码可以直接抄去改。

1. 行情数据进 pandas 之前,先想清楚表结构

1.1 长表和宽表的选择,决定了后面所有代码的写法

拿到一份日线数据,第一件事不是急着read_csv,而是判断它该用长表还是宽表。这两个词听着玄,其实就是"一行一条记录"还是"一行一个日期、多列放不同标的"的区别。

长表长这样:

datesymbolopenhighlowclosevolume
2024-01-02A10.110.510.010.412000
2024-01-02B20.320.820.120.68000

宽表长这样:

dateA_closeB_close
2024-01-0210.420.6
2024-01-0310.620.9

我的经验是:存储用长表,计算用宽表。原因很实在——长表适合追加新标的、适合存进数据库、适合带 symbol 列做分组;但一旦你要算两只标的的相关性、做组合收益、画对比曲线,宽表才是顺手的形态,因为 pandas 的很多向量化操作默认按列对齐。

所以标准流程是:原始数据以长表落地,进入分析环节前用pivot转成宽表。转换代码不复杂:

wide = long_df.pivot(index='date', columns='symbol', values='close')

这里有个细节很多人忽略:pivot之后列会变成 MultiIndex 或者带 symbol 前缀,如果你只取 close 一列,得到的就是干净的宽表。但如果你要同时保留 open/high/low,就得用pivot_table配合aggfunc='first',或者干脆对每个字段各 pivot 一次再concat。我一般选后者,因为字段一多,pivot_table的聚合语义反而容易让人误判。

提示:pivot遇到重复的 (date, symbol) 组合会直接报错,这其实是好事——它帮你发现了脏数据。别急着用pivot_table的mean去"抹平"重复,先查清楚为什么会有两条同一天的记录。

1.2 索引设成日期,是后面一切时间操作的根基

我见过最影响后续效率的习惯,是把日期留成普通字符串列。字符串列不是不能用,但一旦涉及"取某段时间""按月重采样""计算持有天数",你就得反复pd.to_datetime,既慢又容易出错。

正确做法是在读入阶段就把日期转成datetime64并设为索引:

df['date'] = pd.to_datetime(df['date']) df = df.set_index('date').sort_index()

sort_index()这一步千万别省。行情数据从不同来源拼接时,顺序经常是乱的,而 pandas 的大量时间序列方法(比如asfreq、resample、切片df['2024-01':'2024-03'])都隐含"索引有序"的假设。索引无序时切片可能返回空结果,而且不报错——这种静默失败最难查。

设成 DatetimeIndex 之后,你会白捡一堆能力:df.index.year、df.index.month直接取年月,df.loc['2024']取整年,df.between_time('09:30','11:30')取盘中时段。这些在字符串列上都得手写条件筛选,代码量差好几倍。

还有一个容易踩的点:时区。如果你的数据带时区信息(比如从某些接口拿到的 UTC 时间),务必在设索引前统一tz_localize或tz_convert,否则和另一份无时区数据做join时会报 "Cannot join tz-naive with tz-aware"。我一般统一转成无时区的本地日期,因为日线分析根本用不到时分秒。

1.3 复权处理:不复权算出来的收益率全是错的

这是股票分析里最容易被新手忽略、后果又最严重的一环。原始行情里的价格是"不复权"的,遇到分红送股会跳空。如果你直接拿不复权收盘价算收益率,除权那天会出现一个巨大的假跌幅,回测结果直接失真。

处理方式有两种:一是数据源直接给复权价(前复权或后复权),二是自己用复权因子换算。前复权适合看历史走势(当前价和最新价对齐),后复权适合算长期收益(历史价不变,后续按比例放大)。做收益率计算和回测,我强烈建议用后复权,因为它保证任意两点之间的比值就是真实持有收益。

如果你手上只有复权因子列,换算很简单:

df['adj_close'] = df['close'] * df['adj_factor']

如果没有因子列,只有除权除息事件,那就得自己按事件逐条调整,这部分逻辑建议单独写个函数并加单元测试,因为一旦算错,后面所有指标都是错的,而且错得很隐蔽。

注意:不同标的的复权基准日不同,跨标的比较时一定要确认它们用的是同一套复权口径,否则相关性、相对强弱这些指标都会失真。

2. 清洗与对齐:脏数据不处理,指标全是幻觉

2.1 缺失值的三种来源,处理方式完全不同

行情数据里的 NaN 不是一种东西,至少分三类,混为一谈就会出问题。

第一类是停牌。某标的某天没交易,数据源可能给一行全 NaN,也可能干脆不出现这一天。前者你要保留日期但标记停牌,后者要在对齐时补出来。第二类是数据源缺漏,比如某天接口抽风没返回。第三类是计算产生的 NaN,比如你算 20 日均线,前 19 天天然是 NaN,这是正常的,不能填。

处理策略我一般这样定:

来源识别方式处理方式
停牌成交量为 0 或整行 NaN 且前后有数据前值填充价格,成交量填 0,加停牌标记列
数据缺漏交易日历里有、数据里没有先补日期再判断,少量可前填,大量要查源
计算 NaN滚动窗口未满保留,不填充

关键点是先补交易日历再判断缺失。做法是用一个基准交易日历reindex:

full_idx = pd.date_range(start, end, freq='B') # 工作日近似 df = df.reindex(full_idx)

freq='B'只是工作日近似,真实交易日还得用交易所日历,但很多场景下够用了。reindex 之后,原本"消失"的停牌日会变成整行 NaN,你就能统一处理了。

填充价格时用ffill()而不是fillna(method='ffill')(后者在新版本已弃用),并且只对价格列前填,成交量填 0。千万别对整张表无脑ffill,否则成交量也会被前值污染,算出来的量能指标全是错的。

2.2 多标的对齐:outer join 之后的那一步才是重点

做多标的分析,第一步是把各自的数据对齐到同一时间轴。这里最常见的错误是用默认的concat或merge,结果时间轴取交集,某些标的停牌的日子被悄悄丢掉,导致后面算组合收益时权重对不上。

正确做法是用 outer join 保留所有日期:

panel = pd.concat([df_a['adj_close'], df_b['adj_close']], axis=1, join='outer') panel.columns = ['A', 'B']

但 outer join 之后会产生新的 NaN——某标的停牌那天,它的价格是空的。这时候如果直接算组合收益,NaN 会传染,整行结果都变 NaN。所以对齐之后必须做一步前向填充:

panel = panel.ffill()

前填的语义是"停牌期间按停牌前最后价格计",这对收益计算是合理的近似。但要注意,如果某标的在区间起点就是 NaN(比如它上市晚),前填填不了,这时候要么截断区间到所有标的都有数据的日期,要么在组合计算时按可用权重归一化。我一般选前者,简单且不容易出错。

2.3 异常值检测:涨跌幅过滤比 3σ 更实用

行情数据的异常值,用统计学的 3σ 法则往往抓不准,因为股价分布本身厚尾。更实用的办法是按日收益率过滤:

ret = panel.pct_change() mask = (ret.abs() > 0.21) # 超过 21% 视为可疑

为什么是 21%?因为常规标的单日涨跌幅限制在 10% 或 20% 一带,超过这个范围要么是数据错误(比如小数点错位、复权没处理),要么是特殊事件。把可疑点打出来人工核对,比盲目clip安全得多。

我踩过的一个坑:某次数据源把某天的收盘价少写了一位小数,10.5 变成 1.05,收益率瞬间 -90%。如果当时直接dropna或clip,这个错误就被"处理"掉了,但根因没解决,后面还会反复出现。所以异常值检测的目的不是自动修复,而是暴露问题。

3. 指标计算:向量化是底线,rolling 是核心

3.1 均线、波动率、RSI 的 pandas 写法

技术指标看着多,落到 pandas 上其实就三类操作:滚动窗口(rolling)、差分(diff/pct_change)、累积(cumsum/cumprod)。掌握这三类,八成指标都能自己写。

均线最简单:

ma20 = panel['A'].rolling(20).mean()

波动率用滚动标准差,注意要年化:

vol = panel['A'].pct_change().rolling(20).std() * np.sqrt(252)

这里的 252 是一年交易日数,sqrt(252)是把日波动放大到年化。很多人忘了开方,直接把日标准差乘 252,结果差了一个数量级。

RSI 稍微绕一点,但本质还是 rolling:

delta = panel['A'].diff() gain = delta.clip(lower=0).rolling(14).mean() loss = (-delta.clip(upper=0)).rolling(14).mean() rs = gain / loss rsi = 100 - 100 / (1 + rs)

注意loss那里用了-delta.clip(upper=0),因为下跌时 delta 是负数,取反才得到正的"跌幅"。这个符号处理是 RSI 手写时最容易错的地方,写反了指标会完全颠倒。

3.2 为什么 rolling 比 for 循环快几十倍

有人习惯这样算均线:

for i in range(len(df)): ma[i] = df['close'][i-19:i+1].mean()

几万条数据跑下来要好几秒,而rolling(20).mean()是毫秒级。差距的根源在于:pandas 的 rolling 底层是 C 实现的滑动窗口,每次移动只做"加新减旧"的增量更新,复杂度是 O(n);而 Python 循环每次都要重新切片求和,复杂度是 O(n×window),再加上解释器开销,慢几十倍很正常。

理解这一点很重要,因为它告诉你优化的方向:凡是能用 rolling、shift、cumsum 表达的,绝不用循环。我整理了一张常用操作的对照:

需求慢写法快写法
20 日均线for 循环切片求均值rolling(20).mean()
昨日收盘df['close'][i-1]df['close'].shift(1)
累计收益循环累乘(1+ret).cumprod()
分组排名循环 groupbygroupby().rank()

3.3 分组计算:groupby 的正确打开方式

长表形态下,按标的算指标是家常便饭。最直接的写法是groupby('symbol')然后apply,但apply往往慢,因为它对每个组都走一遍 Python 函数。

更快的做法是优先用内置的 groupby 聚合:

# 慢:apply 自定义函数 long_df.groupby('symbol')['close'].apply(lambda s: s.pct_change().std()) # 快:先算再分组 long_df['ret'] = long_df.groupby('symbol')['close'].pct_change() long_df.groupby('symbol')['ret'].std()

区别在于:后者把pct_change放在 groupby 内部但用的是向量化实现,避免了 Python 层的逐组调用。经验法则是——能在 groupby 外面用向量化算的,就别塞进 apply。

如果确实需要复杂逻辑,比如每组内做一次回归,那apply躲不掉,但可以先用groupby(...).ngroups估算组数,组数少(几十个)时 apply 完全可接受,组数上万时才需要另想办法。

4. 性能与内存:数据一大,写法就得变

4.1 dtype 优化:float64 换成 float32 能省一半内存

pandas 默认把数值读成 float64,8 字节一个数。行情数据动辄几百万行、几十列,内存很快就上 G。如果精度要求没那么高(价格保留两位小数足够),转成 float32 能直接砍掉一半内存:

for col in ['open','high','low','close']: df[col] = df[col].astype('float32')

成交量用int32或int64视量级而定。symbol 这种低基数列(取值种类少)转成category类型,内存能降一个数量级:

df['symbol'] = df['symbol'].astype('category')

我实测过一份 500 万行的日线数据,全 float64 占约 1.2G,优化 dtype 后降到 500M 出头。这个优化几乎零成本,值得养成习惯。

4.2 避免链式赋值和 SettingWithCopyWarning

新手常写:

df[df['close'] > 10]['signal'] = 1

这行代码不报错,但赋值根本没生效,因为df[...]返回的是副本。正确写法是用.loc:

df.loc[df['close'] > 10, 'signal'] = 1

SettingWithCopyWarning这个警告不是烦人的噪音,它在提醒你"你以为改了原表,其实改的是副本"。养成用.loc一次性定位行和列的习惯,能避开一大类静默 bug。

4.3 大文件读取:chunksize 和 usecols 的组合拳

数据文件超过内存时,read_csv直接读会爆。两个参数能救场:usecols只读需要的列,chunksize分块读。

chunks = pd.read_csv('big.csv', usecols=['date','symbol','close'], chunksize=100000) result = pd.concat([c.groupby('symbol')['close'].mean() for c in chunks])

分块之后每块单独聚合,最后合并。注意分块聚合只适用于可结合的运算(求和、计数、最值),均值这种要先求 sum 和 count 再相除,不能直接对每块的均值再求均值——那是加权问题,直接平均会错。

5. 几个我踩过的坑和对应解法

5.1 pct_change 的第一个值是 NaN,别拿它算收益

pct_change()默认第一个位置是 NaN,因为没有前值。很多人算累计收益时直接(1+ret).cumprod(),结果整列第一个是 NaN,后面全被污染。解法是先fillna(0)或者dropna():

ret = panel['A'].pct_change().fillna(0) cum = (1 + ret).cumprod()

填 0 的语义是"第一天收益为 0",对累计曲线没有影响,比 dropna 更安全(dropna 会改变索引长度,后续对齐容易错位)。

5.2 resample 的 label 和 closed 参数,决定你按月聚合对不对

按月重采样算月收益,很多人写df.resample('M').last(),但'M'在新版 pandas 里已弃用,应该用'ME'。更重要的是label和closed参数:默认label='right',意味着 1 月的数据会被标成 1 月 31 日;如果你想要"1 月的数据标成 1 月 1 日",得设label='left'。

这个细节在做月度对比时很致命——标签错位一个月,图表看起来完全对不上。我的习惯是显式写全:

monthly = df.resample('ME', label='left', closed='left').last()

5.3 用 query 代替布尔索引,可读性和速度都更好

复杂条件筛选,df[(df.a>1)&(df.b<2)]写起来括号一堆,容易漏。query更清爽:

df.query('close > 10 and volume > 1000000')

而且query底层用了 numexpr,在大表上通常比布尔索引快一些。唯一要注意的是列名含特殊字符时要用反引号包起来。

5.4 别在循环里反复 df.loc 赋值

有人写回测信号时这样干:

for i in range(1, len(df)): df.loc[df.index[i], 'position'] = ...

每次.loc赋值都有索引查找开销,几万次下来非常慢。更好的做法是先用 numpy 数组或列表算好整列,再一次性赋给 DataFrame:

positions = compute_positions(df['close'].values) # 纯 numpy df['position'] = positions

把计算逻辑下沉到 numpy 层,pandas 只负责承载结果,这是处理大表时的通用思路。

6. 把零散脚本整理成可复用流程

写到这儿,前面那些操作如果每次都手敲,迟早会乱。我的做法是固定一个处理顺序,写成几个小函数串起来:读入并转日期索引 → 复权换算 → 补交易日历 → 前填价格 → 转宽表 → 算指标。每一步单独可测,出问题能快速定位是哪一环。

举个最小骨架:

def load_prices(path): df = pd.read_csv(path, parse_dates=['date']) return df.set_index('date').sort_index() def to_wide(long_df, field='adj_close'): return long_df.pivot(index='date', columns='symbol', values=field).ffill() def add_ma(wide, window=20): return wide.rolling(window).mean()

这种拆法的好处是,换数据源时只改load_prices,指标逻辑完全不动。我见过太多人把所有逻辑塞进一个两百行的脚本,换个数据格式就得从头改,维护成本极高。

最后分享一个我自己的习惯:每次拿到新数据,先跑三行体检——df.info()看 dtype 和内存,df.index.is_monotonic_increasing看索引是否有序,df.isna().sum()看缺失分布。这三行能在写任何分析代码之前,把八成的问题提前暴露出来。数据干净了,后面的指标和回测才有意义;数据不干净,算得再花哨也是自欺欺人。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询