1. 项目缘起与整体设计思路
1.1 为什么选股价数据作为数据分析练手项目
做数据分析练手,选数据集这件事本身就挺讲究的。我见过太多人一上来就拿经典的鸢尾花数据集或者泰坦尼克号幸存者数据,跑完一遍分类模型就觉得自己会数据分析。说实话,这类数据集太干净了,干净到跟真实业务场景脱节。你拿它练手,练的是API调用,不是分析思维。
股价数据就不一样。它有几个特别适合练手的特性:第一,数据获取门槛低,很多公开渠道都能拿到历史行情数据,不需要申请什么特殊权限;第二,数据本身有明确的业务含义,开盘价、收盘价、最高价、最低价、成交量,每一个字段都能对应到真实的交易行为;第三,数据质量足够“脏”,有停牌导致的缺失、有除权除息造成的价格跳变、有极端行情下的异常值,这些在真实工作中都会遇到。
我这次拿一家知名科技公司(后文统一用“A公司”代称)的股价数据来做完整分析,时间跨度覆盖了多个完整财年。选它不是因为有什么内幕消息,纯粹是因为它的数据记录完整、交易活跃、市场关注度高,作为教学案例非常合适。
1.2 分析框架的搭建逻辑
一个完整的股价数据分析项目,我把它拆成四个层次:
- 描述性分析:先搞清楚数据长什么样,价格在什么区间波动,成交量是什么量级
- 趋势分析:识别价格的长期走向和中期波动,用移动平均线来平滑噪声
- 关联性分析:看价格和成交量之间有没有联动关系,不同时间窗口的收益率有没有自相关性
- 可视化呈现:把上面三步的结论用图表讲清楚,让别人一眼就能看懂
这个框架不是拍脑袋定的。描述性分析解决“是什么”的问题,趋势分析解决“往哪走”的问题,关联性分析解决“为什么”的问题,可视化解决“怎么讲”的问题。四步走下来,一个分析项目的逻辑链条就完整了。
注意:很多人做数据分析喜欢直接跳到建模预测,跳过描述性分析这一步。我的经验是,这一步绝对不能省。你对数据的分布、量级、缺失情况没有直观感受,后面建出来的模型大概率是空中楼阁。
1.3 工具选型与版本说明
整个项目用Python生态里最成熟的几个库来完成:
| 工具 | 用途 | 选它的理由 |
|---|---|---|
| pandas | 数据清洗与处理 | 时间序列处理能力最强,resample和rolling方法开箱即用 |
| numpy | 数值计算 | 底层数组运算效率高,收益率计算离不开它 |
| matplotlib | 基础绘图 | 灵活度最高,适合定制化图表 |
| seaborn | 统计可视化 | 默认样式好看,画分布图和热力图省事 |
版本方面,我用的pandas是2.x版本,numpy是1.26以上。这里提醒一句,pandas 2.x在数据类型和性能上有不少改进,特别是对Arrow后端的支持,处理大文件时速度提升明显。如果你还在用1.x版本,建议升级,但要注意append方法已经被移除了,得换成concat。
2. 数据获取与清洗的核心细节
2.1 数据字段解读与获取途径
一份标准的日线行情数据,通常包含以下字段:
- Date:交易日期,注意是交易日不是自然日
- Open:当日开盘价
- High:当日最高价
- Low:当日最低价
- Close:当日收盘价
- Adj Close:复权收盘价,这个字段最容易被忽略但最重要
- Volume:当日成交量
这里重点说Adj Close。股票在分红、拆股之后,价格会出现跳变。比如某天收盘100块,第二天拆股变成50块,如果你直接用Close字段算收益率,会得到一个-50%的荒谬结果。Adj Close就是把这些公司行为的影响剔除了,让价格序列在时间上可比。做收益率计算和趋势分析,一定要用Adj Close。
获取途径上,公开渠道能拿到历史数据的方式不少,但各有各的限制。有的接口有频率限制,有的只提供最近几年的数据,有的需要注册账号。我建议在练手阶段,先把数据下载成CSV文件存在本地,这样每次跑代码不用重新请求,调试效率高很多。
2.2 数据加载与初步体检
拿到CSV文件之后,第一步不是急着画图,而是做数据体检。我通常按这个顺序来:
import pandas as pd import numpy as np # 加载数据,把日期列直接解析成datetime类型 df = pd.read_csv('stock_data.csv', parse_dates=['Date'], index_col='Date') # 看前五行,确认字段和格式 print(df.head()) # 看数据类型和缺失情况 print(df.info()) # 看描述性统计,快速了解数值分布 print(df.describe())parse_dates这个参数很关键。如果不加,Date列就是字符串类型,后面做时间序列重采样的时候会报错。index_col='Date'把日期设为索引,这样就能直接用df['2023']这种方式切片,非常方便。
df.info()会告诉你每个字段有多少非空值。如果发现某个字段的计数明显少于总行数,说明有缺失。df.describe()给出均值、标准差、四分位数这些统计量,能帮你快速判断有没有异常值。比如成交量如果最大值和75分位数差了好几个数量级,那大概率有极端交易日。
2.3 缺失值与异常值处理
股价数据的缺失通常有两种情况:一种是停牌导致的整行缺失,另一种是某个字段单独缺失。处理方式不一样。
停牌导致的缺失,我一般用前向填充(forward fill),因为停牌期间价格没有变化,用停牌前最后一个交易日的价格填充是合理的:
df.fillna(method='ffill', inplace=True)但要注意,如果数据开头就有缺失,前向填充填不了,这时候得用后向填充兜底。另外,pandas 2.x里fillna(method='ffill')的写法已经过时了,推荐用df.ffill()。
异常值方面,股价数据里最常见的是涨跌停和乌龙指。涨跌停是真实交易行为,不能删;乌龙指是错误数据,得处理。判断方法是用收益率的标准差,超过5倍标准差的点标记出来人工核查。
实操心得:我一般会保留一个原始数据的副本,所有清洗操作在副本上进行。这样万一清洗逻辑有问题,还能回溯到原始状态重新来。这个习惯帮我省过好几次事。
3. 核心分析环节的完整实现
3.1 描述性统计与价格分布分析
数据清洗完之后,先做描述性分析。这一步的目标是回答几个问题:价格中枢在哪,波动范围多大,分布是对称的还是偏斜的。
# 计算关键统计量 stats = df['Adj Close'].describe() print(stats) # 计算偏度和峰度 skewness = df['Adj Close'].skew() kurtosis = df['Adj Close'].kurtosis() print(f"偏度: {skewness:.3f}, 峰度: {kurtosis:.3f}")偏度衡量分布的对称性。如果偏度为正,说明分布右偏,也就是有少数极高的价格拉高了均值。峰度衡量分布的尖峭程度,正态分布的峰度是0(pandas默认减去3),如果峰度为正,说明极端值比正态分布更多。
股价的Adj Close通常是右偏的,因为价格下跌有下限(最多跌到0),上涨没有上限。这个特性对后续建模有影响,比如用线性回归的时候可能需要对价格取对数。
除了整体统计,我还习惯按年份分组看统计量:
yearly_stats = df['Adj Close'].resample('Y').agg(['mean', 'std', 'min', 'max']) print(yearly_stats)resample('Y')按年重采样,agg传入多个聚合函数。这样一眼就能看出哪些年份波动大,哪些年份相对平稳。
3.2 移动平均线与趋势识别
移动平均线是趋势分析里最基础也最实用的工具。它的逻辑很简单:把最近N天的价格取平均,消掉短期波动,留下长期趋势。
# 计算不同窗口的移动平均线 df['MA20'] = df['Adj Close'].rolling(window=20).mean() df['MA60'] = df['Adj Close'].rolling(window=60).mean() df['MA120'] = df['Adj Close'].rolling(window=120).mean()窗口选20、60、120是有讲究的。20个交易日大约是一个月,60个交易日是一个季度,120个交易日是半年。这三个窗口分别对应短期、中期、长期趋势。
移动平均线有个特性叫滞后性。因为它是用历史数据算的,所以趋势反转的时候,均线会晚于价格变化。窗口越大,滞后越明显。这不是缺陷,是特性。做趋势跟踪的时候,滞后反而能过滤掉假信号。
判断趋势方向,我常用两个规则:
- 均线排列:短期均线在长期均线之上,且都在向上走,说明上升趋势明确
- 价格与均线的关系:价格持续在均线之上运行,说明买盘强劲
# 判断金叉和死叉 df['Signal'] = 0 df.loc[df['MA20'] > df['MA60'], 'Signal'] = 1 df.loc[df['MA20'] < df['MA60'], 'Signal'] = -1 # 找出信号变化的点 df['Signal_Change'] = df['Signal'].diff() cross_points = df[df['Signal_Change'] != 0] print(cross_points[['Adj Close', 'MA20', 'MA60', 'Signal']])金叉(短期均线上穿长期均线)通常被视为买入信号,死叉反之。但我要泼一盆冷水:单纯靠均线交叉做交易决策,胜率并不高。均线交叉在震荡市里会频繁发出假信号,来回打脸。它更适合作为趋势确认的辅助工具,而不是唯一的决策依据。
3.3 日收益率计算与波动率分析
收益率是股价分析里最核心的衍生指标。有两种算法:
- 简单收益率:
(P_t - P_{t-1}) / P_{t-1} - 对数收益率:
ln(P_t / P_{t-1})
我一般用对数收益率,因为它有两个好处:第一,多期收益率可以直接相加;第二,对数收益率更接近正态分布,方便做统计推断。
# 计算对数收益率 df['Log_Return'] = np.log(df['Adj Close'] / df['Adj Close'].shift(1)) # 年化波动率(假设一年252个交易日) annual_volatility = df['Log_Return'].std() * np.sqrt(252) print(f"年化波动率: {annual_volatility:.2%}")年化波动率的计算逻辑是:日波动率乘以根号252。为什么是根号252而不是252?因为波动率是标准差,标准差不可加,但方差可加。日方差乘以252得到年方差,再开根号就是年标准差。
这个指标的含义是:如果收益率服从正态分布,那么大约68%的情况下,一年的收益率会落在正负一个年化波动率的范围内。比如年化波动率是30%,那一年赚30%或亏30%都是正常波动。
我还习惯算滚动波动率,看波动率随时间的变化:
# 20日滚动波动率 df['Rolling_Vol'] = df['Log_Return'].rolling(window=20).std() * np.sqrt(252)滚动波动率能帮你识别市场情绪的变化。波动率突然放大,往往意味着有重大事件发生,或者市场进入恐慌阶段。
3.4 成交量与价格的关联分析
成交量是价格的“燃料”。价格上涨如果伴随成交量放大,说明上涨有资金支撑,持续性更强;如果价格上涨但成交量萎缩,说明追高意愿不足,可能是强弩之末。
# 计算价量相关系数 correlation = df['Adj Close'].corr(df['Volume']) print(f"价格与成交量的相关系数: {correlation:.3f}") # 按价格涨跌分组看成交量 df['Price_Change'] = df['Adj Close'].pct_change() df['Direction'] = np.where(df['Price_Change'] > 0, 'Up', 'Down') volume_by_direction = df.groupby('Direction')['Volume'].mean() print(volume_by_direction)相关系数只能看线性关系,而且股价和成交量的关系往往是非线性的。所以我还会画散点图,用seaborn的regplot加上拟合线,直观感受一下。
另外,我会构造一个量价背离的指标:价格创20日新高,但成交量没有创20日新高,这种情况标记出来重点关注。
df['Price_High_20'] = df['Adj Close'].rolling(20).max() df['Volume_High_20'] = df['Volume'].rolling(20).max() df['Divergence'] = (df['Adj Close'] == df['Price_High_20']) & (df['Volume'] < df['Volume_High_20']) print(f"量价背离天数: {df['Divergence'].sum()}")3.5 可视化图表的定制与优化
图表是分析结论的载体。一张好的图表,不用看文字说明就能传递核心信息。我通常会把下面几张图作为标配:
第一张:价格走势与均线叠加图
import matplotlib.pyplot as plt import matplotlib.dates as mdates fig, ax = plt.subplots(figsize=(14, 7)) ax.plot(df.index, df['Adj Close'], label='收盘价', linewidth=1, alpha=0.7) ax.plot(df.index, df['MA20'], label='MA20', linewidth=1.5) ax.plot(df.index, df['MA60'], label='MA60', linewidth=1.5) ax.plot(df.index, df['MA120'], label='MA120', linewidth=1.5) ax.set_title('A公司股价走势与移动平均线', fontsize=14) ax.set_xlabel('日期') ax.set_ylabel('价格') ax.legend() ax.xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m')) plt.tight_layout() plt.show()第二张:收益率分布直方图
fig, ax = plt.subplots(figsize=(10, 6)) ax.hist(df['Log_Return'].dropna(), bins=100, edgecolor='black', alpha=0.7) ax.axvline(df['Log_Return'].mean(), color='red', linestyle='--', label='均值') ax.set_title('日对数收益率分布', fontsize=14) ax.set_xlabel('对数收益率') ax.set_ylabel('频次') ax.legend() plt.tight_layout() plt.show()第三张:成交量柱状图
fig, ax = plt.subplots(figsize=(14, 5)) ax.bar(df.index, df['Volume'], width=1, alpha=0.6) ax.set_title('日成交量', fontsize=14) ax.set_xlabel('日期') ax.set_ylabel('成交量') plt.tight_layout() plt.show()画图有几个细节要注意:figsize控制图片大小,14x7适合宽屏展示;alpha控制透明度,叠加多条线的时候很有用;tight_layout自动调整子图间距,避免标签被截断。
避坑提醒:matplotlib默认不支持中文显示,会显示成方框。需要在代码开头加两行配置:
plt.rcParams['font.sans-serif'] = ['SimHei']和plt.rcParams['axes.unicode_minus'] = False。前者设置中文字体,后者解决负号显示问题。
4. 常见问题排查与实操避坑指南
4.1 数据层面的典型问题
问题一:日期解析失败
报错信息通常是ValueError: time data 'xxx' does not match format。原因一般是日期格式不统一,比如有的行是2023-01-01,有的是01/01/2023。解决办法是用pd.to_datetime的format参数指定格式,或者用errors='coerce'把解析失败的变成NaT再处理。
问题二:复权价格与原始价格混用
这个坑很隐蔽。如果你用Close算收益率,用Adj Close画价格图,两边的数值对不上,分析结论就会自相矛盾。我的做法是:统一用Adj Close,除非有特殊需求要看真实交易价格。
问题三:时区问题
如果数据来源涉及不同时区,日期索引可能会错位。建议统一转换成UTC或者交易所所在时区,再做后续分析。
4.2 计算层面的典型问题
问题四:滚动窗口计算出现NaN
rolling(window=20).mean()的前19个值是NaN,这是正常的,因为数据不够。但如果NaN出现在中间,说明原始数据有缺失。处理方式是先填充缺失值,再做滚动计算。
问题五:收益率计算的首日NaN
pct_change()和shift(1)都会让第一个值变成NaN,因为第一天没有前一天的数据。这个NaN不用管,后续分析自动忽略即可。
问题六:年化系数用错
日收益率年化用252,周收益率年化用52,月收益率年化用12。用错系数会导致波动率高估或低估。252是交易日数量,不是自然日数量,这个要记清楚。
4.3 可视化层面的典型问题
问题七:中文显示为方框
前面提过了,加字体配置就行。如果SimHei不可用,可以试试Microsoft YaHei或者PingFang SC。
问题八:日期标签重叠
数据点多的时候,x轴的日期标签会挤在一起。解决办法是用mdates.AutoDateLocator()自动调整标签密度,或者用plt.xticks(rotation=45)旋转标签。
问题九:图表保存后分辨率不够
plt.savefig的时候加上dpi=300参数,输出高清图。如果要在网页上展示,bbox_inches='tight'能去掉多余的白边。
4.4 分析层面的常见误区
误区一:把相关性当因果性
价格和成交量有相关性,但不能说成交量放大导致了价格上涨。可能是某个消息同时影响了两者。分析的时候要谨慎表述。
误区二:过度拟合历史数据
用复杂模型去拟合历史价格,回测收益很漂亮,实盘一塌糊涂。股价数据信噪比很低,简单模型往往比复杂模型更稳健。
误区三:忽略交易成本
回测的时候如果不考虑手续费和滑点,高频策略的收益会被严重高估。做任何策略评估,都要把交易成本算进去。
误区四:用未来数据做决策
这个错误在写代码的时候很容易犯。比如用当天的最高价决定是否买入,但实际交易中你不可能提前知道最高价。所有信号必须基于当前时刻之前的数据。
下面这张表是我整理的问题速查表,遇到问题可以快速定位:
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 日期解析报错 | 格式不统一 | 打印前几行看格式 | 指定format或coerce |
| 收益率异常大 | 用了未复权价格 | 检查除权日附近数据 | 改用Adj Close |
| 滚动计算中间有NaN | 原始数据缺失 | df.isnull().sum() | 先填充再滚动 |
| 中文显示方框 | 字体未配置 | 看报错信息 | 设置rcParams |
| 图表标签重叠 | 数据点太密 | 观察x轴 | 旋转标签或自动定位 |
| 相关系数很低 | 关系是非线性的 | 画散点图 | 用斯皮尔曼相关系数 |
5. 分析结论的呈现与后续扩展方向
5.1 如何把分析结果讲成一个完整的故事
数据分析的最终产出不是代码,是结论。代码只是手段,结论才是目的。我见过不少人跑了一堆图,但问他“所以呢”,答不上来。这就是缺少结论提炼的环节。
我的做法是,每张图下面写三句话:第一句说图里看到了什么,第二句说这意味着什么,第三句说所以应该关注什么。比如价格走势图,第一句“过去三年价格整体上行,但中间有两次超过20%的回撤”,第二句“说明长期趋势向好但短期波动剧烈”,第三句“需要关注回撤期间的成交量变化,判断是恐慌抛售还是正常调整”。
这样一套下来,分析报告就有了逻辑主线,读者跟着你的思路走,不会迷失在图表里。
5.2 这个分析框架还能怎么扩展
这套框架不只适用于单只股票。把数据源换成指数,就是大盘分析;换成多只股票,就是板块轮动分析;换成加密货币,就是数字资产分析。核心方法完全一样。
再往深了做,可以加入这些维度:
- 技术指标扩展:MACD、RSI、布林带,这些指标的计算逻辑都不复杂,pandas几行代码就能实现
- 多资产对比:把几只同行业公司的股价放在一起,看相对强弱
- 事件驱动分析:标记财报发布日、产品发布会等事件,看事件前后的价格和成交量变化
- 简单预测模型:用ARIMA或者LSTM做价格预测,但要注意,预测精度通常很低,别抱太大期望
5.3 我个人的几条实操建议
第一,数据质量永远比模型复杂度重要。清洗干净的数据加上简单模型,效果远好于脏数据加上复杂模型。
第二,可视化不是装饰,是分析工具。画图的过程中你往往能发现统计量看不出来的模式。
第三,保留分析过程的中间结果。每一步的DataFrame都存一份,后面想换个角度分析的时候不用从头跑。
第四,代码要模块化。把数据加载、清洗、计算、绘图拆成独立函数,改一个环节不影响其他环节。
第五,结论要留有余地。股价受太多因素影响,数据分析能告诉你“发生了什么”,但很难告诉你“为什么发生”和“接下来会怎样”。保持谦逊,别过度解读。
最后分享一个我踩过的坑:有一次我算年化波动率,忘了用根号252,直接乘以252,结果算出来年化波动率超过500%,自己看了半天觉得不对劲。后来检查代码才发现是年化系数用错了。这个错误很隐蔽,因为代码不报错,结果看起来也像个数字,但量级完全不对。所以每次算完指标,都要用常识判断一下:这个数值合理吗?股价年化波动率通常在20%到60%之间,超过100%就要警惕了。