Python在金融中的应用 · 第二部分

第六节:Pandas与股票表格分析

Pandas 负责把金融数据变成可检查、可筛选、可合并、可汇总的表格。本节沿用第五节的模拟股票 CSV,从原始数据展示开始,逐步完成清洗、收益计算、分组分析、时间窗口和导出,最后把表格交给绘图与数据库继续使用。

开始前:这张表要回答什么问题

同一份数据可以支持不同问题:哪只股票的收益率最高?哪只股票波动更大?哪个行业成交额更高?某一天是否出现异常价格?Pandas 的价值不是替你决定问题,而是让问题变成可重复的表格操作。

读取把 CSV 变成 DataFrame
检查字段、类型、缺失和重复
变换新增收益与振幅
汇总分组、透视、合并
输出表格、文件与图表输入
原始行情date、ticker、open、high、low、close
交易信息volume、turnover_million
分类信息name、sector
规模变量market_cap_billion

案例文件为合成数据,不代表真实市场价格。代码可以直接复制到课程 Notebook 中运行;如果路径报错,先确认 Notebook 当前工作目录和 data/ 文件夹的位置。

1. 读取 CSV:先让数据进入程序

CSV 是按逗号分隔的文本文件,适合保存简单的表格。pd.read_csv 会把它读成 DataFrame。读取时要同时考虑路径、编码、日期列、缺失值表示和证券代码的前导零。

from pathlib import Path
import pandas as pd

file_path = Path("data/stock_demo.csv")
df = pd.read_csv(
    file_path,
    parse_dates=["date"],
    dtype={"ticker": "string"}
)
print("读取成功:", file_path)
print("行数:", len(df))
print("列数:", len(df.columns))
print("列名:", list(df.columns))
读取成功: data/stock_demo.csv 行数: 60 列数: 11 列名: ['date', 'ticker', 'name', 'sector', 'open', 'high', 'low', 'close', 'volume', 'turnover_million', 'market_cap_billion']

dtype={"ticker": "string"} 很重要。股票代码不能被当作普通整数,否则像 000001.SZ 这样的标识可能在其他数据源中丢失前导零。日期则通过 parse_dates 转为时间类型,后面才能做时间排序和窗口计算。

print(df.head(3).to_string(index=False))
print(df.tail(2).to_string(index=False))
date ticker name sector open high low close volume turnover_million market_cap_billion 2024-01-02 000001.SZ 平安银行 银行 12.81 12.89 12.72 12.79 2000000 25.57 250.00 2024-01-02 600519.SH 贵州茅台 食品饮料 1680.91 1695.03 1671.66 1683.25 1126220 1895.71 2100.00 2024-01-02 300750.SZ 宁德时代 电力设备 179.93 182.16 178.85 180.71 757596 136.91 800.00 date ticker name sector open high low close volume turnover_million market_cap_billion 2024-01-16 601318.SH 中国平安 非银金融 42.19 42.62 41.92 42.24 427895 18.07 801.84 2024-01-16 300750.SZ 宁德时代 电力设备 180.88 182.85 179.79 181.39 637876 115.71 822.40

2. 看懂 DataFrame:形状、类型和内存

读取成功不等于数据可靠。先看形状,再看类型、非空数量和基本统计。info() 是排查“日期被当成字符串、数字被当成文字、某列有缺失”的第一工具。

print("shape:", df.shape)
print("index:", type(df.index).__name__)
print("date 类型:", df["date"].dtype)
print("ticker 类型:", df["ticker"].dtype)
print("每列缺失数:")
print(df.isna().sum())
print("不同股票:", df["ticker"].unique())
print("不同日期:", df["date"].nunique())
shape: (60, 11) index: RangeIndex date 类型: datetime64[ns] ticker 类型: string 每列缺失数: 每一列均为 0 不同股票: ['000001.SZ', '600519.SH', '300750.SZ', '601318.SH'] 不同日期: 15
df.info()
print(df[["open", "high", "low", "close", "volume"]].describe().round(2))
RangeIndex: 60 entries, 0 to 59 Data columns (total 11 columns) dtypes: datetime64[ns](1), string(1), float64(8), int64(1) open high low close volume count 60.00 60.00 60.00 60.00 60.00 mean 478.52 482.89 474.13 478.42 1049661.20 min 12.73 12.81 12.63 12.69 425000.00 max 1686.76 1703.74 1671.66 1691.89 2297182.00

全表的价格均值没有直接金融意义,因为 12 元、42 元、180 元和 1680 元处在不同价格尺度。跨股票比较时优先使用收益率、标准化价格或百分比变化。

3. 选择列与索引:loc、iloc 和布尔条件

列选择可以使用单个列名或列名列表;行选择建议使用 loc,位置选择使用 iloc。对于研究代码,明确写出列名比依赖列的位置更不容易出错。

price_view = df[["date", "ticker", "name", "close"]]
print(price_view.head(4).to_string(index=False))

first_rows = df.iloc[:3, :5]
print(first_rows.to_string(index=False))
date ticker name close 2024-01-02 000001.SZ 平安银行 12.79 2024-01-02 600519.SH 贵州茅台 1683.25 2024-01-02 300750.SZ 宁德时代 180.71 date ticker name open high 2024-01-02 000001.SZ 平安银行 12.81 12.89 2024-01-02 600519.SH 贵州茅台 1680.91 1695.03 2024-01-02 300750.SZ 宁德时代 179.93 182.16
bank = df.loc[df["sector"] == "银行",
              ["date", "ticker", "name", "close"]]
large_trade = df.loc[
    (df["volume"] >= 1_000_000) & (df["turnover_million"] >= 100),
    ["date", "ticker", "close", "volume", "turnover_million"]
]
print("银行记录数:", len(bank))
print("大额交易记录数:", len(large_trade))
print(large_trade.head(3).to_string(index=False))
银行记录数: 15 大额交易记录数: 12 date ticker close volume turnover_million 2024-01-02 600519.SH 1683.25 1126220 1895.71 2024-01-03 600519.SH 1690.43 1136394 1920.99 2024-01-04 600519.SH 1690.49 1021168 1726.28

多个条件使用 &,且每个条件都要加括号。loc 是按标签和条件选择,iloc 是按整数位置选择;两者混用会让代码难以检查。

4. 排序、去重和索引重置

金融表格经常从多个来源拼接而来,行顺序未必可靠,甚至可能有重复记录。每次做收益率、滚动窗口和前后期比较前,都应先按股票和日期排序。

ordered = df.sort_values(["ticker", "date"]).reset_index(drop=True)
print(ordered[["ticker", "date", "close"]].head(6).to_string(index=False))
print("重复的 date-ticker 组合:",
      ordered.duplicated(["date", "ticker"]).sum())
ticker date close 000001.SZ 2024-01-02 12.79 000001.SZ 2024-01-03 12.81 000001.SZ 2024-01-04 12.86 000001.SZ 2024-01-05 12.84 000001.SZ 2024-01-06 12.75 000001.SZ 2024-01-07 12.69 重复的 date-ticker 组合: 0

drop_duplicates 只能在你知道重复记录应该如何处理时使用。保留最后一条、保留第一条或按成交量选择最大值,背后都是数据口径决定的,不是机械清洗。

duplicate_demo = pd.concat([df, df.iloc[[0]]], ignore_index=True)
print("拼接后行数:", len(duplicate_demo))
duplicate_demo = duplicate_demo.drop_duplicates(
    subset=["date", "ticker"], keep="last"
)
print("去重后行数:", len(duplicate_demo))
拼接后行数: 61 去重后行数: 60

5. 新增金融指标:振幅、日内收益和成交额占比

原始字段只是材料。分析通常需要构造指标,并且要给指标写出公式和单位。下面新增日内振幅、开收盘收益和每只股票在当日成交额中的占比。

work = df.copy()
work["daily_range"] = (work["high"] - work["low"]) / work["open"]
work["intraday_return"] = work["close"] / work["open"] - 1
daily_total = work.groupby("date")["turnover_million"].transform("sum")
work["turnover_share"] = work["turnover_million"] / daily_total

print(work[["ticker", "date", "daily_range",
             "intraday_return", "turnover_share"]]
      .head(4).round(4).to_string(index=False))
ticker date daily_range intraday_return turnover_share 000001.SZ 2024-01-02 0.0133 -0.0016 0.0119 600519.SH 2024-01-02 0.0144 0.0014 0.8823 300750.SZ 2024-01-02 0.0123 0.0043 0.0637 601318.SH 2024-01-02 0.0209 0.0055 0.0100

transform("sum") 会把每个日期的成交额总和广播回原表,因此每一行都能计算自己的占比。它和 groupby().sum() 不同:后者会把表压缩成分组结果,transform 保留原来的行数。

6. 分组聚合:把“每只股票怎样”算出来

groupby 的思路是“先分组,再对每组执行同样的函数”。可以一次计算多个指标,也可以把输出列命名得更接近金融问题。

summary = (
    work.groupby(["ticker", "name", "sector"], as_index=False)
        .agg(
            avg_close=("close", "mean"),
            avg_return=("intraday_return", "mean"),
            return_std=("intraday_return", "std"),
            avg_volume=("volume", "mean"),
            total_turnover=("turnover_million", "sum"),
            avg_range=("daily_range", "mean")
        )
        .round(4)
)
print(summary.to_string(index=False))
ticker name sector avg_close avg_return return_std avg_volume total_turnover avg_range 000001.SZ 平安银行 银行 12.7790 0.0003 0.0038 2025707.6 378.0 0.0133 300750.SZ 宁德时代 电力设备 180.4900 0.0004 0.0042 672041.7 1695.0 0.0167 600519.SH 贵州茅台 食品饮料 1681.2650 0.0004 0.0045 1019356.4 25521.0 0.0147 601318.SH 中国平安 非银金融 42.1810 0.0001 0.0049 494677.5 322.0 0.0181

平均收益高不等于风险调整后表现好。至少要同时看波动率、成交量和样本长度;若要形成投资判断,还需要交易成本和样本外检验。

sector_summary = (
    work.groupby("sector")
        .agg(
            stocks=("ticker", "nunique"),
            avg_turnover=("turnover_million", "mean"),
            avg_range=("daily_range", "mean")
        )
        .sort_values("avg_turnover", ascending=False)
        .round(4)
)
print(sector_summary)
stocks avg_turnover avg_range sector 食品饮料 1 1701.4 0.0147 电力设备 1 113.0 0.0167 非银金融 1 21.5 0.0181 银行 1 25.2 0.0133

7. 收益率:组内排序后再 pct_change

收益率必须在每只股票内部按照日期排序后计算。若把不同股票混在一起直接 pct_change(),上一行可能属于另一只股票,结果就会错位。

work = work.sort_values(["ticker", "date"]).reset_index(drop=True)
work["return"] = work.groupby("ticker")["close"].pct_change()
work["log_return"] = work.groupby("ticker")["close"].transform(
    lambda s: __import__("numpy").log(s / s.shift(1))
)
print(work[["date", "ticker", "close", "return", "log_return"]]
      .head(6).round(6).to_string(index=False))
date ticker close return log_return 2024-01-02 000001.SZ 12.79 NaN NaN 2024-01-03 000001.SZ 12.81 0.001564 0.001563 2024-01-04 000001.SZ 12.86 0.003903 0.003895 2024-01-05 000001.SZ 12.84 -0.001555 -0.001556 2024-01-06 000001.SZ 12.75 -0.007009 -0.007034 2024-01-07 000001.SZ 12.69 -0.004706 -0.004717

第一条收益率没有前一天收盘价,所以是 NaN。不要为了让表格没有空白就立即填 0;先决定这条缺失代表“没有定义”还是“确实为零”。

8. 缺失值与异常值:先诊断再处理

金融数据缺失可能来自停牌、交易日不一致、字段不可用或文件损坏。处理方法取决于问题:删除、前值填充、插值或保留缺失,各有含义。

print(work.isna().sum())
print("收盘价非正记录:",
      (work["close"] <= 0).sum())
print("成交量为负记录:",
      (work["volume"] < 0).sum())

missing_return = work[work["return"].isna()]
print(missing_return[["date", "ticker", "close"]]
      .head().to_string(index=False))
return 4 log_return 4 其他字段 0 收盘价非正记录: 0 成交量为负记录: 0 date ticker close 2024-01-02 000001.SZ 12.79 2024-01-02 300750.SZ 180.71 2024-01-02 600519.SH 1683.25 2024-01-02 601318.SH 42.19
filled = work.copy()
filled["return_zero_only_for_demo"] = filled["return"].fillna(0)
filled["close_forward_demo"] = (
    filled.groupby("ticker")["close"].ffill()
)
print(filled[["ticker", "return",
               "return_zero_only_for_demo"]].head(4)
      .to_string(index=False))
ticker return return_zero_only_for_demo 000001.SZ NaN 0.000000 000001.SZ 0.001564 0.001564 000001.SZ 0.003903 0.003903 000001.SZ -0.001555 -0.001555

前值填充适合某些“状态变量”,不一定适合价格或收益率。每次填充都要在代码和研究说明中记录方法、理由和可能造成的偏差。

9. 透视表:把长表变成宽表

长表适合追加新观测,宽表适合比较多个资产。pivot 要求每个“日期—股票”组合唯一;pivot_table 可以在重复时指定聚合函数。

close_wide = work.pivot_table(
    index="date", columns="ticker", values="close", aggfunc="last"
)
return_wide = work.pivot_table(
    index="date", columns="ticker", values="return", aggfunc="first"
)
print(close_wide.head(3).round(2).to_string())
print("宽表形状:", close_wide.shape)
ticker 000001.SZ 300750.SZ 600519.SH 601318.SH date 2024-01-02 12.79 180.71 1683.25 42.19 2024-01-03 12.81 181.61 1690.43 42.36 2024-01-04 12.86 180.83 1690.49 42.07 宽表形状: (15, 4)

宽表很适合交给第五节的 NumPy:close_wide.to_numpy() 就可以得到日期 × 股票的二维数组。转换前要明确列的顺序,否则权重数组可能对应错误的股票。

ordered_tickers = ["000001.SZ", "600519.SH", "300750.SZ", "601318.SH"]
close_wide = close_wide[ordered_tickers]
close_matrix = close_wide.to_numpy()
print("列顺序:", list(close_wide.columns))
print("矩阵形状:", close_matrix.shape)
print("第一天:", close_matrix[0])
列顺序: ['000001.SZ', '600519.SH', '300750.SZ', '601318.SH'] 矩阵形状: (15, 4) 第一天: [ 12.79 1683.25 180.71 42.19]

10. 连接多张表:merge、concat 与键检查

真实金融项目往往有行情表、资产信息表、行业表、宏观变量表。连接前先决定主键,再检查键是否唯一。validate 可以帮助发现本应一对一、实际却重复的情况。

asset_info = (
    work[["ticker", "name", "sector", "market_cap_billion"]]
    .drop_duplicates("ticker")
)
price_only = work[["date", "ticker", "open", "high",
                   "low", "close", "volume"]]
merged = price_only.merge(
    asset_info, on="ticker", how="left", validate="many_to_one"
)
print("合并后行数:", len(merged))
print(merged.head(2).to_string(index=False))
合并后行数: 60 合并后仍然每个日期—股票一行;validate='many_to_one' 检查左表多行对应右表一行。

如果资产信息表每个代码出现两次,many_to_one 会报错。这个错误很有价值,因为它阻止数据悄悄膨胀。

part_a = work.iloc[:30].copy()
part_b = work.iloc[30:].copy()
stacked = pd.concat([part_a, part_b], ignore_index=True)
print("part_a:", len(part_a), "part_b:", len(part_b))
print("concat 后:", len(stacked))
print("是否与原表行数相同:", len(stacked) == len(work))
part_a: 30 part_b: 30 concat 后: 60 是否与原表行数相同: True

concat 是把结构相同的表上下拼接,merge 是根据键把不同信息左右连接。两者目的不同,不能互换。

11. 时间索引:按日、周和月份观察

日期列转换为时间类型后,可以设置为索引,并使用 resample 做时间聚合。当前案例只有 15 个连续日期,月度结果仅用于演示写法。

time_df = work.set_index("date").sort_index()
daily_market = time_df.groupby(level=0)["turnover_million"].sum()
monthly_turnover = daily_market.resample("ME").sum()
print("每日成交额前 3 天:")
print(daily_market.head(3).round(2))
print("月度成交额:")
print(monthly_turnover.round(2))
每日成交额前 3 天: date 2024-01-02 2159.76 2024-01-03 2092.25 2024-01-04 1870.40 月度成交额: date 2024-01-31 42500.00

时间聚合的单位必须说清楚。把日数据加总成月成交额合理;把价格加总通常没有意义,价格更常用最后值、均值或区间高低值。

close_by_day = work.pivot_table(
    index="date", columns="ticker", values="close", aggfunc="last"
)
weekly_last = close_by_day.resample("7D").last()
print(weekly_last.round(2).to_string())
以 7 天为窗口取每只股票最后一个收盘价;输出行数少于原始 15 天,具体日期由时间索引决定。

12. 滚动窗口:移动平均与简单信号

滚动窗口把“最近几天”的信息用于当前观测。移动平均可以平滑短期噪声,但它不是预测保证,也会因为窗口前几天没有足够观测而产生缺失。

rolling_df = work.sort_values(["ticker", "date"]).copy()
rolling_df["ma_3"] = (
    rolling_df.groupby("ticker")["close"]
              .transform(lambda s: s.rolling(3, min_periods=3).mean())
)
rolling_df["volume_ma_3"] = (
    rolling_df.groupby("ticker")["volume"]
              .transform(lambda s: s.rolling(3, min_periods=3).mean())
)
print(rolling_df[["date", "ticker", "close", "ma_3",
                   "volume_ma_3"]].head(5)
      .round(2).to_string(index=False))
date ticker close ma_3 volume_ma_3 2024-01-02 000001.SZ 12.79 NaN NaN 2024-01-03 000001.SZ 12.81 NaN NaN 2024-01-04 000001.SZ 12.86 12.82 2175209.0 2024-01-05 000001.SZ 12.84 12.84 2189189.0 2024-01-06 000001.SZ 12.75 12.82 2029361.33

窗口长度为 3 表示当前日和前两日。min_periods=3 强制至少有 3 个观测才计算,避免用两个数字冒充三日平均。

13. 透视与统计:制作一张可读的比较表

分析结果不仅要在屏幕上打印,还应整理成读者可以快速比较的表。下面把平均收益、收益波动和总成交额合并成一张报告表。

report = (
    work.groupby(["ticker", "name", "sector"])
        .agg(
            mean_return=("return", "mean"),
            return_volatility=("return", "std"),
            total_turnover=("turnover_million", "sum"),
            average_market_cap=("market_cap_billion", "mean")
        )
        .reset_index()
)
report["mean_return_pct"] = report["mean_return"] * 100
report["return_volatility_pct"] = report["return_volatility"] * 100
report = report.sort_values("return_volatility_pct", ascending=False)
print(report[["ticker", "name", "sector",
              "mean_return_pct", "return_volatility_pct",
              "total_turnover"]].round(3).to_string(index=False))
按收益波动率从高到低输出四只股票;表中收益和波动率单位为百分比,成交额单位为百万元。

报告表的列顺序应该服务于读者:先放识别股票的字段,再放核心指标,最后放辅助指标。小数位数也要统一,避免一列出现过多没有意义的数字。

14. 数据展示:用 Markdown 和 HTML 看到表格

Notebook 中直接输入 DataFrame 会显示漂亮的表格;在网页课程中可以用 HTML 表格展示前几行。先用 head 控制展示数量,避免把 60 行全部挤在屏幕上。

datetickernameclosevolumeturnover_million
2024-01-02000001.SZ平安银行12.792,000,00025.57
2024-01-02600519.SH贵州茅台1683.251,126,2201895.71
2024-01-02300750.SZ宁德时代180.71757,596136.91
2024-01-02601318.SH中国平安42.19510,58421.54
display_cols = ["date", "ticker", "name", "sector",
                "close", "return", "turnover_million"]
display_df = work[display_cols].head(8).copy()
display_df["return"] = display_df["return"].mul(100).round(3)
display_df["close"] = display_df["close"].round(2)
display(display_df)
Notebook 中显示一个带列名的表格;date 是日期类型,return 已转换为百分比,close 保留两位小数。

展示表和分析表要区分:展示表帮助读者认识数据,分析表用于汇总和结论。不要把所有中间变量都放进最终表格,也不要为了好看删除必要的单位说明。

15. 导出:把结果交给下一步

表格分析的结果通常要交给绘图、数据库、报告或其他同学。导出时要明确文件名、编码、是否保留索引和是否只导出需要的列。

output_dir = Path("outputs")
output_dir.mkdir(exist_ok=True)

report.to_csv(
    output_dir / "stock_summary.csv",
    index=False,
    encoding="utf-8-sig"
)
work.to_csv(
    output_dir / "stock_with_metrics.csv",
    index=False,
    encoding="utf-8-sig"
)
print("已导出:", list(output_dir.glob("stock_*.csv")))
已导出: [outputs/stock_summary.csv, outputs/stock_with_metrics.csv]

index=False 避免把 Pandas 的行号作为额外字段写出。utf-8-sig 让部分中文表格软件打开 CSV 时更不容易乱码。导出前先检查行数和关键列,避免输出空表。

check = pd.read_csv(
    output_dir / "stock_summary.csv"
)
assert len(check) == 4
assert check["ticker"].is_unique
assert check["mean_return_pct"].notna().all()
print("导出文件检查通过:", check.shape)
导出文件检查通过: (4, 8)

16. 常见错误:从报错中学习

KeyError

列名拼写或空格不一致。先运行 print(df.columns.tolist()),不要猜列名。

SettingWithCopyWarning

可能正在修改筛选结果的视图。使用 .loc[...] 或显式 .copy(),让修改对象明确。

合并后行数增加

连接键不唯一。检查右表是否一只股票多条资产信息,并使用 validate。

收益率异常

先检查排序、股票分组、价格是否复权、是否把不同股票的前后行误连在一起。

print("列名:", df.columns.tolist())
print("重复键:", df.duplicated(["date", "ticker"]).sum())
print("日期范围:", df["date"].min(), "至", df["date"].max())
print("收盘价是否非正:", (df["close"] <= 0).any())
列名、重复键、日期范围和价格合法性都可以在几秒内检查;把这些检查放进 Notebook,后续数据更新时仍能重复执行。

17. Pandas 与 NumPy 的分工

Pandas

保留列名、日期和分类,适合读取、筛选、分组、合并和展示。

NumPy

处理整齐的数值数组,适合向量化、广播、矩阵、统计和线性代数。

两者连接

df[列].to_numpy() 把列变为数组;pd.DataFrame(array) 可以重新加上列名。

numeric_matrix = close_wide.to_numpy()
np_result = numeric_matrix.mean(axis=0)
pandas_result = pd.Series(
    np_result,
    index=close_wide.columns,
    name="平均收盘价"
)
print(pandas_result.round(2))
000001.SZ 12.78 600519.SH 1681.27 300750.SZ 180.49 601318.SH 42.18 Name: 平均收盘价, dtype: float64

先用 Pandas 把表格整理成正确的形状,再用 NumPy 做批量数值计算,是本课程最常见的工作流。

18. 综合练习:完成一份小型股票数据报告

练习 A:数据审计

读取 CSV,报告行数、股票数、日期数、缺失数、重复键数,并说明每个检查为什么重要。

练习 B:行业比较

按行业汇总平均成交额、平均日内振幅和收益波动率,写出一段带单位的解释。

练习 C:合并两张表

自己构造一个含有股票代码和风格标签的小表,使用 merge(validate="many_to_one") 连接,并验证行数。

练习 D:窗口指标

计算 3 日移动平均和 3 日平均成交量,说明前两行为什么是缺失。

练习 E:交给绘图

导出标准化收盘价宽表,交给第七节绘制折线图;比较长表和宽表各自适合什么任务。

练习 F:写出边界

用 100 字说明这份 15 日合成数据可以训练 Pandas 操作,但不能支持真实投资结论。

完成本节后,应能从一个 CSV 文件出发,留下可检查的中间表、清楚的指标定义和可复用的输出文件,而不是只得到一张无法解释的图。