第二章 财务数据获取与清洗:用Python搞定上市公司财报

做量化投资,数据就是命根子。这话我常跟团队里的小朋友说。

你想想看,模型再漂亮,策略再精巧,拿到的财务数据是脏的、错的、缺的,那结果就是垃圾进垃圾出。我见过太多人花几个月搭模型,最后发现是数据源出了问题——那种感觉,就像你辛辛苦苦盖了栋楼,结果地基是豆腐渣。

所以这一章,咱们就踏踏实实把地基打好。我会带你走一遍完整的流程:怎么拿数据、怎么洗数据、怎么处理那些烦人的缺失值和异常值。

2.1 数据源选择:从哪里拿财报数据?

国内能拿上市公司财报数据的渠道,我这些年基本都试过。说几个靠谱的:

  • Tushare Pro:我最常用的。数据全,更新快,但需要积分。新用户注册送100分,够用一阵子。
  • AKShare:开源免费,接口丰富。适合预算有限的朋友,但偶尔会有接口变动。
  • 东方财富 Choice:机构级数据,但需要付费,个人用户门槛高。
  • Wind:金融圈标配,贵。我早期在机构时用得多,个人做研究就算了。
我的建议:个人做量化研究,Tushare Pro 是最平衡的选择。数据质量高,文档清晰,社区活跃。我目前大部分项目都在用。

2.2 用Python获取利润表数据

先装好必要的库。我习惯用 tushare 配合 pandas,这两个是标配。

import tushare as ts
import pandas as pd
import numpy as np
from datetime import datetime

# 设置token(去tushare官网注册获取)
ts.set_token('你的token')
pro = ts.pro_api()

获取利润表,核心是调用 income 接口。我一般会指定股票代码、开始日期和结束日期。

# 获取贵州茅台近5年的利润表
df_income = pro.income(
    ts_code='600519.SH',
    start_date='20190101',
    end_date='20231231',
    fields='ts_code,end_date,revenue,operate_profit,net_profit'
)

print(df_income.head())

嗯,这里要注意一点:end_date 字段是报告期截止日,不是数据发布日期。我刚开始做的时候搞混过,拿到的数据对不上号。

2.3 资产负债表与现金流量表

资产负债表用 balancesheet 接口,现金流量表用 cashflow 接口。写法大同小异。

# 资产负债表
df_balance = pro.balancesheet(
    ts_code='600519.SH',
    start_date='20190101',
    end_date='20231231',
    fields='ts_code,end_date,total_assets,total_liab,equity'
)

# 现金流量表
df_cashflow = pro.cashflow(
    ts_code='600519.SH',
    start_date='20190101',
    end_date='20231231',
    fields='ts_code,end_date,operate_cash_flow,invest_cash_flow,finan_cash_flow'
)

拿到三张表之后,我通常会把它们合并成一个宽表。这样后续做因子计算时方便很多。

# 合并三张表
df_merged = df_income.merge(
    df_balance, on=['ts_code', 'end_date'], how='left'
).merge(
    df_cashflow, on=['ts_code', 'end_date'], how='left'
)

print(f"合并后数据维度:{df_merged.shape}")
关键点:合并时用 how='left',以利润表为主表。因为利润表数据通常最完整,其他表可能有缺失。

2.4 数据清洗:标准化处理

数据拿到手,第一件事就是标准化。我总结了一套标准流程:

  1. 统一日期格式:把 end_date 转成 datetime 类型
  2. 处理单位:Tushare 返回的数据单位是元,我习惯转成亿元
  3. 排序:按股票代码和日期升序排列
  4. 去重:同一报告期可能有多个版本(比如修正后的数据)
# 日期标准化
df_merged['end_date'] = pd.to_datetime(df_merged['end_date'])

# 单位转换(元 → 亿元)
for col in ['revenue', 'operate_profit', 'net_profit', 
            'total_assets', 'total_liab', 'equity',
            'operate_cash_flow']:
    df_merged[col] = df_merged[col] / 1e8

# 排序
df_merged = df_merged.sort_values(['ts_code', 'end_date'])

# 去重(保留最新版本)
df_merged = df_merged.drop_duplicates(
    subset=['ts_code', 'end_date'], 
    keep='last'
)
避坑指南:我曾经在去重这一步吃过亏。有些公司会在同一报告期发布修正后的财报,如果不做去重,后面计算同比、环比时会出现重复数据,导致指标计算错误。所以一定要用 keep='last' 保留最新版本。

2.5 处理缺失值

财务数据缺失是常态。原因很多:

  • 新上市公司历史数据不全
  • 某些指标只在特定报告期披露
  • 数据源本身就有遗漏

我的处理策略分三步:

# 第一步:查看缺失情况
missing_ratio = df_merged.isnull().mean()
print("各字段缺失比例:")
print(missing_ratio[missing_ratio > 0])

# 第二步:根据缺失比例决定处理方式
# 缺失率 < 5%:用前向填充
# 缺失率 5%-20%:用行业均值填充
# 缺失率 > 20%:删除该字段

threshold_low = 0.05
threshold_high = 0.20

for col in df_merged.columns:
    if col in ['ts_code', 'end_date']:
        continue
    ratio = df_merged[col].isnull().mean()
    if ratio == 0:
        continue
    elif ratio < threshold_low:
        # 前向填充:用上一期数据填充
        df_merged[col] = df_merged.groupby('ts_code')[col].ffill()
    elif ratio < threshold_high:
        # 行业均值填充(这里简单用整体均值)
        df_merged[col] = df_merged[col].fillna(df_merged[col].median())
    else:
        # 删除该字段
        df_merged.drop(columns=[col], inplace=True)
        print(f"删除字段:{col},缺失率 {ratio:.2%}")
个人经验:前向填充(ffill)在财务数据上效果很好。因为财报是时序数据,上一期的值往往是最合理的估计。但要注意,如果连续缺失超过两期,建议用行业均值,避免误差累积。

2.6 异常值检测与处理

异常值比缺失值更隐蔽,也更危险。我常用的方法是 Z-score 法IQR 法

对于财务数据,我偏好 IQR 法,因为它对极端值更稳健。

def detect_outliers_iqr(data, column, factor=3):
    """
    用 IQR 法检测异常值
    factor=3 是比较宽松的标准,财务数据波动大
    """
    Q1 = data[column].quantile(0.25)
    Q3 = data[column].quantile(0.75)
    IQR = Q3 - Q1
    
    lower_bound = Q1 - factor * IQR
    upper_bound = Q3 + factor * IQR
    
    outliers = data[(data[column] < lower_bound) | 
                    (data[column] > upper_bound)]
    return outliers, lower_bound, upper_bound

# 检测净利润的异常值
outliers, lb, ub = detect_outliers_iqr(df_merged, 'net_profit')
print(f"净利润异常值数量:{len(outliers)}")
print(f"正常范围:{lb:.2f} 亿 ~ {ub:.2f} 亿")

发现异常值后怎么处理?我的原则是:

  • 核实数据源:先确认是不是数据抓取错误
  • 业务判断:比如某公司突然亏损几十亿,可能是计提了大额减值,这种是合理的
  • 截尾处理:对于明显不合理的数据,用上下限替换
# 截尾处理:将异常值替换为边界值
df_merged['net_profit'] = df_merged['net_profit'].clip(lb, ub)
注意:不要机械地删除所有异常值。财务数据中,真正的异常往往蕴含着重要信息——比如财务造假、业绩暴雷。我建议保留一份原始数据副本,处理后的数据用于建模,原始数据用于排查。

2.7 本章知识体系

下面这张图是我自己梳理的流程,你可以照着这个框架来操作:

财务数据获取与清洗流程 数据获取 Tushare / AKShare 利润表 资产负债表 现金流量表 合并宽表 数据标准化 缺失值处理 异常值检测与处理 清洗后的财务数据 关键处理细节 • 日期统一为datetime • 单位转亿元 • 按代码+日期排序 • 去重保留最新版本 • 缺失<5%:前向填充 • 缺失5-20%:行业均值 • 缺失>20%:删除字段 • 异常值:IQR法+截尾

2.8 完整代码封装

最后,我把整个流程封装成一个函数。这样每次处理新数据时,一行代码就能搞定。

def get_financial_data(ts_code, start_date, end_date):
    """
    一站式获取并清洗财务数据
    """
    pro = ts.pro_api()
    
    # 获取三张表
    df_income = pro.income(ts_code=ts_code, start_date=start_date, 
                          end_date=end_date)
    df_balance = pro.balancesheet(ts_code=ts_code, start_date=start_date,
                                 end_date=end_date)
    df_cashflow = pro.cashflow(ts_code=ts_code, start_date=start_date,
                              end_date=end_date)
    
    # 合并
    df = df_income.merge(df_balance, on=['ts_code','end_date'], how='left')\
                  .merge(df_cashflow, on=['ts_code','end_date'], how='left')
    
    # 清洗
    df['end_date'] = pd.to_datetime(df['end_date'])
    numeric_cols = df.select_dtypes(include=[np.number]).columns
    df[numeric_cols] = df[numeric_cols] / 1e8
    df = df.sort_values(['ts_code', 'end_date'])
    df = df.drop_duplicates(subset=['ts_code', 'end_date'], keep='last')
    
    # 处理缺失值
    for col in numeric_cols:
        ratio = df[col].isnull().mean()
        if ratio < 0.05:
            df[col] = df.groupby('ts_code')[col].ffill()
        elif ratio < 0.20:
            df[col] = df[col].fillna(df[col].median())
        else:
            df.drop(columns=[col], inplace=True)
    
    # 处理异常值(IQR法)
    for col in df.select_dtypes(include=[np.number]).columns:
        Q1, Q3 = df[col].quantile(0.25), df[col].quantile(0.75)
        IQR = Q3 - Q1
        lb, ub = Q1 - 3*IQR, Q3 + 3*IQR
        df[col] = df[col].clip(lb, ub)
    
    return df

# 使用示例
df_clean = get_financial_data('600519.SH', '20190101', '20231231')
print(f"清洗完成,数据维度:{df_clean.shape}")

好了,这一章的内容就到这儿。数据清洗看着琐碎,但绝对是值得花功夫的环节。我见过太多人在这上面翻车,所以宁可多花点时间把基础打牢。

下一章我们会用这些干净的数据,开始构建真正的估值模型。到时候你就知道,今天这些功夫没白费。


专注资料整理