KeyError
列名拼写或空格不一致。先运行 print(df.columns.tolist()),不要猜列名。
Python在金融中的应用 · 第二部分
Pandas 负责把金融数据变成可检查、可筛选、可合并、可汇总的表格。本节沿用第五节的模拟股票 CSV,从原始数据展示开始,逐步完成清洗、收益计算、分组分析、时间窗口和导出,最后把表格交给绘图与数据库继续使用。
同一份数据可以支持不同问题:哪只股票的收益率最高?哪只股票波动更大?哪个行业成交额更高?某一天是否出现异常价格?Pandas 的价值不是替你决定问题,而是让问题变成可重复的表格操作。
案例文件为合成数据,不代表真实市场价格。代码可以直接复制到课程 Notebook 中运行;如果路径报错,先确认 Notebook 当前工作目录和 data/ 文件夹的位置。
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))dtype={"ticker": "string"} 很重要。股票代码不能被当作普通整数,否则像 000001.SZ 这样的标识可能在其他数据源中丢失前导零。日期则通过 parse_dates 转为时间类型,后面才能做时间排序和窗口计算。
print(df.head(3).to_string(index=False))
print(df.tail(2).to_string(index=False))读取成功不等于数据可靠。先看形状,再看类型、非空数量和基本统计。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())df.info()
print(df[["open", "high", "low", "close", "volume"]].describe().round(2))全表的价格均值没有直接金融意义,因为 12 元、42 元、180 元和 1680 元处在不同价格尺度。跨股票比较时优先使用收益率、标准化价格或百分比变化。
列选择可以使用单个列名或列名列表;行选择建议使用 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))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))多个条件使用 &,且每个条件都要加括号。loc 是按标签和条件选择,iloc 是按整数位置选择;两者混用会让代码难以检查。
金融表格经常从多个来源拼接而来,行顺序未必可靠,甚至可能有重复记录。每次做收益率、滚动窗口和前后期比较前,都应先按股票和日期排序。
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())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))原始字段只是材料。分析通常需要构造指标,并且要给指标写出公式和单位。下面新增日内振幅、开收盘收益和每只股票在当日成交额中的占比。
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))transform("sum") 会把每个日期的成交额总和广播回原表,因此每一行都能计算自己的占比。它和 groupby().sum() 不同:后者会把表压缩成分组结果,transform 保留原来的行数。
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))平均收益高不等于风险调整后表现好。至少要同时看波动率、成交量和样本长度;若要形成投资判断,还需要交易成本和样本外检验。
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)收益率必须在每只股票内部按照日期排序后计算。若把不同股票混在一起直接 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))第一条收益率没有前一天收盘价,所以是 NaN。不要为了让表格没有空白就立即填 0;先决定这条缺失代表“没有定义”还是“确实为零”。
金融数据缺失可能来自停牌、交易日不一致、字段不可用或文件损坏。处理方法取决于问题:删除、前值填充、插值或保留缺失,各有含义。
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))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))前值填充适合某些“状态变量”,不一定适合价格或收益率。每次填充都要在代码和研究说明中记录方法、理由和可能造成的偏差。
长表适合追加新观测,宽表适合比较多个资产。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)宽表很适合交给第五节的 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])真实金融项目往往有行情表、资产信息表、行业表、宏观变量表。连接前先决定主键,再检查键是否唯一。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))如果资产信息表每个代码出现两次,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))concat 是把结构相同的表上下拼接,merge 是根据键把不同信息左右连接。两者目的不同,不能互换。
日期列转换为时间类型后,可以设置为索引,并使用 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))时间聚合的单位必须说清楚。把日数据加总成月成交额合理;把价格加总通常没有意义,价格更常用最后值、均值或区间高低值。
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())滚动窗口把“最近几天”的信息用于当前观测。移动平均可以平滑短期噪声,但它不是预测保证,也会因为窗口前几天没有足够观测而产生缺失。
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))窗口长度为 3 表示当前日和前两日。min_periods=3 强制至少有 3 个观测才计算,避免用两个数字冒充三日平均。
分析结果不仅要在屏幕上打印,还应整理成读者可以快速比较的表。下面把平均收益、收益波动和总成交额合并成一张报告表。
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))报告表的列顺序应该服务于读者:先放识别股票的字段,再放核心指标,最后放辅助指标。小数位数也要统一,避免一列出现过多没有意义的数字。
Notebook 中直接输入 DataFrame 会显示漂亮的表格;在网页课程中可以用 HTML 表格展示前几行。先用 head 控制展示数量,避免把 60 行全部挤在屏幕上。
| date | ticker | name | close | volume | turnover_million |
|---|---|---|---|---|---|
| 2024-01-02 | 000001.SZ | 平安银行 | 12.79 | 2,000,000 | 25.57 |
| 2024-01-02 | 600519.SH | 贵州茅台 | 1683.25 | 1,126,220 | 1895.71 |
| 2024-01-02 | 300750.SZ | 宁德时代 | 180.71 | 757,596 | 136.91 |
| 2024-01-02 | 601318.SH | 中国平安 | 42.19 | 510,584 | 21.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)展示表和分析表要区分:展示表帮助读者认识数据,分析表用于汇总和结论。不要把所有中间变量都放进最终表格,也不要为了好看删除必要的单位说明。
表格分析的结果通常要交给绘图、数据库、报告或其他同学。导出时要明确文件名、编码、是否保留索引和是否只导出需要的列。
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")))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)列名拼写或空格不一致。先运行 print(df.columns.tolist()),不要猜列名。
可能正在修改筛选结果的视图。使用 .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())保留列名、日期和分类,适合读取、筛选、分组、合并和展示。
处理整齐的数值数组,适合向量化、广播、矩阵、统计和线性代数。
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))先用 Pandas 把表格整理成正确的形状,再用 NumPy 做批量数值计算,是本课程最常见的工作流。
读取 CSV,报告行数、股票数、日期数、缺失数、重复键数,并说明每个检查为什么重要。
按行业汇总平均成交额、平均日内振幅和收益波动率,写出一段带单位的解释。
自己构造一个含有股票代码和风格标签的小表,使用 merge(validate="many_to_one") 连接,并验证行数。
计算 3 日移动平均和 3 日平均成交量,说明前两行为什么是缺失。
导出标准化收盘价宽表,交给第七节绘制折线图;比较长表和宽表各自适合什么任务。
用 100 字说明这份 15 日合成数据可以训练 Pandas 操作,但不能支持真实投资结论。
完成本节后,应能从一个 CSV 文件出发,留下可检查的中间表、清楚的指标定义和可复用的输出文件,而不是只得到一张无法解释的图。