10  数据管理与组织

拿到数据之后,很多人的第一反应是立刻开始清洗、画图、跑模型。但在真实项目中,真正反复拖慢进度的,往往不是模型本身,而是数据组织混乱:文件太多、命名随意;同一张表保存出多个版本;有的表按公司-年,有的表按公司-日,却直接拿来 merge;上周还能运行的代码,这周已经找不到对应的数据文件。

因此,在「金融数据获取」和「数据清洗」之间,通常还需要一个中间环节:先把数据组织起来,形成一套可复用、可查询、可扩展的数据底座。只有这样,后续的清洗、建模和复现才会更稳健。

本章主线可以概括为:

\[ \text{source files / APIs} \rightarrow \text{storage and organization} \rightarrow \text{cleaning} \rightarrow \text{analysis} \]

本章不讨论服务器部署,也不系统讲授数据库理论,而是聚焦于分析师最常遇到的问题:多来源数据如何存放,多个数据表如何关联,什么时候继续使用 pandas 即可,什么时候应当引入 SQLiteDuckDBParquet 这类工具。

10.1 本章导读

本章的目标不是教你记住所有数据库术语,而是帮助你建立三种意识:

  • 粒度意识:每张表的每一行到底代表什么?
  • 主键意识:什么变量或变量组合可以唯一识别一行数据?
  • 复用意识:如何把零散文件整理成可长期维护的数据项目?

在金融场景中,数据通常同时具有三个特点:

  • 来源多:财务报表、市场行情、宏观指标、公告文本、评级信息等往往来自不同平台;
  • 频率多:既有年频、季频,也有月频、日频,甚至分钟级数据;
  • 结构多:既有标准表格,也有半结构化文本,需要先提取字段才能进入后续分析。

因此,这一章讨论的不是「如何获取某一份数据」,而是「拿到多来源数据之后,如何把它们组织成分析底表」。

10.2 为什么数据管理是数据分析的一部分

先看一个常见场景。你要研究上市公司的融资成本,手头有如下数据:

  • 年度财务数据
  • 日度股票收益率
  • 宏观利率数据
  • 公司公告文本
  • 行业分类表
  • 公司基本信息表

最直接的做法是:每次分析都把这些文件读入内存,再用 merge()concat() 拼成一张大表。这种做法在项目初期似乎没有问题,但随着项目持续推进,问题会迅速暴露出来:

  • 文件越来越多,却没有统一命名规则;
  • 同一份数据被复制出多个版本,不知道哪个是最新的;
  • 原始数据和清洗后的数据混在一起,代码可复现性变差;
  • 不同频率的数据被粗暴拼接,样本量莫名其妙地膨胀;
  • 每次都从头读取和合并,耗时长,内存占用也越来越高。

这些问题说明,数据分析不仅是「变量处理」和「模型估计」,还包括对数据表、文件和目录的组织。可以将这种关系写成:

\[ \text{analysis quality} = f(\text{data quality}, \text{data organization}) \]

也就是说,分析质量不仅取决于数据本身是否干净,还取决于数据是否被正确组织。


# 一个最小示例:构造三张不同粒度的数据表
# 目的:后文用它们演示「主键」「粒度」「聚合」「SQLite 管理」等概念

import pandas as pd

# 公司基本信息表:粒度为 firm
basic_info = pd.DataFrame({
    'stock_code': ['000001', '000002', '000003'],
    'firm_name': ['平安银行', '万科A', '国农科技'],
    'industry_name': ['银行', '房地产', '医药生物']
})

# 年度财务指标表:粒度为 firm-year
fin_ratio = pd.DataFrame({
    'stock_code': ['000001', '000001', '000002', '000002', '000003', '000003'],
    'year': [2022, 2023, 2022, 2023, 2022, 2023],
    'roe': [0.098, 0.105, 0.072, 0.068, 0.041, 0.055],
    'leverage': [0.915, 0.918, 0.772, 0.781, 0.432, 0.446]
})

# 日度收益率表:粒度为 firm-date
# 为了方便演示,这里每个公司只保留少量交易日
trade_date = pd.to_datetime([
    '2023-01-03', '2023-01-04', '2023-01-05',
    '2023-01-03', '2023-01-04', '2023-01-05',
    '2023-01-03', '2023-01-04', '2023-01-05'
])

daily_ret = pd.DataFrame({
    'stock_code': ['000001'] * 3 + ['000002'] * 3 + ['000003'] * 3,
    'trade_date': trade_date,
    'ret': [0.010, -0.004, 0.006, 0.008, -0.002, 0.003, -0.005, 0.011, 0.002],
    'turnover': [0.021, 0.018, 0.025, 0.016, 0.015, 0.017, 0.010, 0.012, 0.014]
})

print('basic_info')
print(basic_info)
print('
fin_ratio')
print(fin_ratio)
print('
daily_ret')
print(daily_ret)

10.3 数据表、主键与粒度

数据管理中最核心的三个概念是:数据表主键粒度

10.3.1 数据表

一张数据表可以理解为:对某一类观测单位在某一层级上的系统记录。例如:

  • 公司基本信息表:每行是一个公司;
  • 财务报表:每行是一个公司-年;
  • 日度收益率表:每行是一个公司-日;
  • 公告元数据表:每行是一篇公告。

10.3.2 主键

主键可以理解为:唯一标识一行数据的变量或变量组合。例如:

  • 公司基本信息表的主键可能是 stock_code
  • 财务报表的主键可能是 (stock_code, year)
  • 日度收益率表的主键可能是 (stock_code, trade_date)

如果主键不能唯一识别一行数据,后续的 merge 就容易出问题。主键的核心要求是:

\[ K_i \neq K_j \quad \text{for all } i \neq j \]

10.3.3 粒度

粒度可以理解为:数据记录的细致程度。在金融数据中,最常见的粒度包括:

  • firm
  • firm-year
  • firm-quarter
  • firm-month
  • firm-date
  • date
  • document

一个合法的数据关联通常至少需要满足:

\[ \text{valid merge} \Rightarrow \text{key consistency} + \text{grain consistency} \]


# 检查主键是否唯一
# 这是数据管理中的第一步:在 merge 之前,先验证主键

# 1. basic_info 的主键应为 stock_code
print('basic_info 是否存在重复的 stock_code:')
print(basic_info.duplicated(subset=['stock_code']).any())

# 2. fin_ratio 的主键应为 (stock_code, year)
print('
fin_ratio 是否存在重复的 (stock_code, year):')
print(fin_ratio.duplicated(subset=['stock_code', 'year']).any())

# 3. daily_ret 的主键应为 (stock_code, trade_date)
print('
daily_ret 是否存在重复的 (stock_code, trade_date):')
print(daily_ret.duplicated(subset=['stock_code', 'trade_date']).any())

10.3.4 一个典型错误:忽略粒度直接合并

设有两张表:

  • fin_ratio:主键为 (stock_code, year),粒度为 firm-year
  • daily_ret:主键为 (stock_code, trade_date),粒度为 firm-date

如果只按 stock_code 做横向合并,那么财务表中的每个公司-年观测都会与该公司所有日度收益率记录相匹配。得到的结果不再是原本的 firm-year 数据,而是一个被重复展开的表。样本量急剧增加,但这种增加没有经济含义。

这个错误本质上不是代码语法错误,而是粒度错误


# 错误演示:只按 stock_code 直接合并 firm-year 与 firm-date
# 结果会发生「样本膨胀」

wrong_merge = fin_ratio.merge(daily_ret, on='stock_code', how='left')

print('fin_ratio 原始行数:', len(fin_ratio))
print('wrong_merge 合并后行数:', len(wrong_merge))
print('
wrong_merge 的前几行:')
print(wrong_merge.head(10))

正确的思路通常不是直接把高频表并进低频表,而是先把高频表聚合到目标粒度,再进行连接。如果最终分析单位是 firm-year,则 firm-date 层面的收益率表往往需要先聚合成年度特征,例如:

  • 年收益率均值
  • 年波动率
  • 年换手率均值

只有在这一层,高频数据才适合与 firm-year 财务表相连。


# 正确示意:先把日度数据聚合为公司-年层面,再与财务表合并

# 从 trade_date 中提取年份
ret_yearly = daily_ret.copy()
ret_yearly['year'] = ret_yearly['trade_date'].dt.year

# 聚合到 firm-year 层面
ret_yearly = (
    ret_yearly
    .groupby(['stock_code', 'year'], as_index=False)
    .agg(
        ret_mean=('ret', 'mean'),
        ret_sd=('ret', 'std'),
        turnover_mean=('turnover', 'mean')
    )
)

# 与财务表按 (stock_code, year) 合并
analysis_base = fin_ratio.merge(ret_yearly, on=['stock_code', 'year'], how='left')
analysis_base = analysis_base.merge(basic_info, on='stock_code', how='left')

print('ret_yearly:')
print(ret_yearly)
print('
analysis_base:')
print(analysis_base)

10.4 从零散文件到项目结构

理解了表、主键和粒度之后,下一步就是考虑:这些数据应当如何在项目中存放。

一个常见误区是把所有文件都丢在同一个文件夹里,文件名随手命名,例如:

  • data1.csv
  • new_data.xlsx
  • final_data_revised2.csv
  • 市场数据最新.xlsx

这样做在项目初期似乎也能工作,但随着文件数量增加,维护成本会快速上升。更稳妥的做法,是在项目开始时就建立分层明确的目录结构。例如:

project_root/
├── data_raw/
├── data_clean/
├── data_temp/
├── output/
├── code/
└── docs/

各层的含义可以约定如下:

  • data_raw/:原始数据,原则上不随意覆盖;
  • data_clean/:清洗后的标准化数据;
  • data_temp/:中间结果,用于测试、缓存或临时聚合;
  • output/:最终图表、表格和结果文件;
  • code/:脚本、Notebook 和辅助程序;
  • docs/:README、数据字典、变量说明和项目笔记。

# 使用 pathlib 构造一个清晰的项目目录
# 说明:这里以当前工作目录下的 finance_demo_project 为例

from pathlib import Path

project_root = Path('finance_demo_project')
subdirs = ['data_raw', 'data_clean', 'data_temp', 'output', 'code', 'docs']

for folder in subdirs:
    (project_root / folder).mkdir(parents=True, exist_ok=True)

print('已创建如下目录:')
for path in sorted(project_root.iterdir()):
    print('-', path)

10.4.1 文件命名规则

除了目录分层,文件命名也应尽量规范。比较稳妥的做法,是让文件名包含三类信息:

  • 数据主题
  • 粒度
  • 时间范围或版本标记

例如:

  • financial_firm_year_2010_2024.parquet
  • returns_firm_date_2020_2025.csv
  • macro_month_2015_2025.xlsx
  • ann_meta_firm_date_2023.sqlite

这样的命名方式比 new_final_use_this.csv 稳妥得多。文件名本身就能提供足够的上下文信息,便于快速判断其内容和用途。

10.4.2 数据字典与 README

当数据表数量较多时,仅靠文件名仍然不够。建议为关键数据表维护一个简单的数据字典,至少记录:

  • 文件名
  • 主键
  • 粒度
  • 时间范围
  • 核心变量
  • 数据来源
  • 更新日期

同时,在项目根目录写一个简短的 README.md,说明项目目标、目录结构、数据处理流程,以及哪些文件是原始文件,哪些文件由脚本自动生成。

提示词:生成数据字典和 README 文档

可以使用以下提示词生成数据字典和 README 文档:

我有以下数据表:

  • financial_firm_year_2010_2024.parquet:主键为 (stock_code, year),粒度为 firm-year,时间范围为 2010-2024,核心变量包括 ROE、leverage,数据来源于 >Wind,最近更新于 2024-06-01。
  • returns_firm_date_2020_2025.csv:主键为 (stock_code, trade_date),粒度为 firm-date,时间范围为 2020-2025,核心变量包括 pct_chg、volume,数据来源于 >Tushare,最近更新于 2024-06-01。
  • macro_month_2015_2025.xlsx:主键为 trade_month,粒度为 date,时间范围为 2015-2025,核心变量包括 CPI、PPI,数据来源于国家统计局,最近更新于 2024-06-01。
  • ann_meta_firm_date_2023.sqlite:主键为 (stock_code, ann_date),粒度为 firm-date,时间范围为 2023,核心变量包括公告标题、公告类型,数据来源于公司公告,最>近更新于 2024-06-01。

请帮我根据这些信息生成一个数据字典表格,并且写一段简短的 README 文档,说明这个项目的目标、目录结构、数据处理流程,以及这些数据表的用途。

或者,你可以使用 dirtree 之类的命令行工具来生成目录结构的可视化树形图,并把它包含在 README 中。例如:

我有一个项目目录结构如下:
project_root/
├── data_raw/
├── data_clean/
├── data_temp/
├── output/
├── code/
└── docs/
请帮我生成一个目录树形图,并且写一段简短的 README 文档,说明这个项目的目标、目录结构、数据处理流程,以及各个目录的用途。

如果你会使用 Agent,可以定位到具体的文件路径,直接让 Agent 读取文件内容来生成数据字典和 README 文档。例如:

请帮我读取 `data_raw/financial_firm_year_2010_2024.parquet` 文件的元数据,
提取主键、粒度、时间范围、核心变量、数据来源和更新日期等信息,
然后把这些信息整理成一个数据字典表格,并且写一段简短的 README 文档,
说明这个项目的目标、目录结构、数据处理流程,以及这个数据表的用途。

10.5 常见的数据存储方式

在分析任务中,最常见的几类存储方式包括:CSV / ExcelParquetSQLiteDuckDB。它们的区别不在于「谁更高级」,而在于适合的任务不同。

10.5.1 CSV / Excel

优点是直观、易于交换、便于人工查看。适合:

  • 与他人共享小规模数据
  • 手工检查内容
  • 作为初学者练习时的输入输出格式

局限在于:

  • 不适合频繁多表关联
  • 不保留严格的数据类型信息
  • 大文件读写效率较低
  • 容易产生多个版本副本

10.5.2 Parquet

Parquet 是适合分析场景的列式存储格式。可以把它理解为:比 CSV 更现代、更高效的一种表格文件形式。它通常具有以下优点:

  • 读写速度较快
  • 文件体积较小
  • 更好地保留变量类型
  • 适合大表和重复读取

10.5.3 SQLite

SQLite 是一种轻量级本地关系数据库。它与 CSV 的最大区别在于:它不是单纯的数据文件,而是一个可以使用 SQL 进行查询和连接的小型数据库系统。它尤其适合:

  • 数据表数量较多,需要反复查询和关联
  • 数据已经较为结构化,主键和变量定义明确
  • 项目主要在本地单机上运行
  • 不需要复杂的多人并发访问

例如,假设我们有多张表:公司基本信息表、财务报表、日度收益率表、公告元数据表等。每张表的主键和粒度不同,但它们之间存在复杂的关联关系。使用 SQLite 可以把这些表存放在同一个数据库文件中,并且通过 SQL 语句方便地进行查询和连接,而不需要每次都从头读取和合并多个 CSV 文件。典型查询语句如下:

SELECT
    f.stock_code,
    f.company_name,
    r.trade_date,
    r.pct_chg,
    a.ann_title
FROM
    financial_firm_year AS f
JOIN
    returns_firm_date AS r
ON
    f.stock_code = r.stock_code
JOIN
    ann_meta_firm_date AS a
ON
    f.stock_code = a.stock_code AND r.trade_date = a.ann_date
WHERE
    f.year = 2023

上述代码展示了如何使用 SQL 连接三张表,查询特定年份的公司基本信息、日度收益率和公告标题。这种方式比在 pandas 中反复 merge 更高效、更清晰。核心代码解释如下:

  • SELECT:指定要查询的变量;
  • FROM:指定主表,这里是 financial_firm_year (被简称为 f);
  • JOIN:连接不同表格,基于主键进行匹配;此处,先连接了 returns_firm_date (简称 r),再连接了 ann_meta_firm_date (简称 a);
  • ON:指定连接条件,确保连接的正确性;此处,f 表和 r 表通过 stock_code 连接,f 表和 a 表通过 stock_code 和日期连接;
  • WHERE:过滤特定条件,例如年份。

虽然从形式上看起来,上述查询过程像是对数据进行了一次「合并」,但它的效率远高于在 pandas 中进行多次 merge,因为 SQLite 内部会优化查询计划,它主要是在磁盘上进行连接操作,而不是把所有数据都读入内存后再进行连接。因此,SQLite 非常适合处理多个结构化表格之间的复杂关联,尤其是在数据量较大、内存有限的情况下。

有关 SQLite 的更多使用细节和优化技巧,可以参考官方文档和相关教程。

10.5.4 DuckDB

如果说 SQLite 更偏向轻量级关系数据库,那么 DuckDB 更适合分析型任务。它很适合处理多个本地大文件、多次聚合和连接,以及分析型 SQL 查询。

对本课程而言,可以把它理解为:一个非常适合本地分析的 SQL 引擎

10.6 用 SQLite 管理一个小型金融项目

下面用一个极简示例演示如何把若干标准化数据表写入本地 SQLite 数据库,并用 SQL 查询。注意,本节的目标不是系统教授 SQL 语法,而是帮助你理解:

  • 多张表可以被统一放在一个数据库文件中;
  • 表之间的关系可以通过 SQL 显式表达;
  • 查询逻辑与存储逻辑可以分离;
  • 后续可以反复复用同一个数据库,而不是重复读写多个 csv

# 将示例数据写入 SQLite,并执行一个简单查询
# SQLite 是 Python 标准库的一部分,因此这里无需额外安装第三方包

import sqlite3

# 建立 SQLite 连接
# 如果数据库文件不存在,系统会自动创建
sqlite_path = project_root / 'data_clean' / 'finance_project.sqlite'
conn = sqlite3.connect(sqlite_path)

# 将 pandas DataFrame 写入数据库中的表
basic_info.to_sql('basic_info', conn, if_exists='replace', index=False)
fin_ratio.to_sql('fin_ratio', conn, if_exists='replace', index=False)
ret_yearly.to_sql('ret_yearly', conn, if_exists='replace', index=False)

# 编写一个 SQL 查询:取出 2023 年的财务数据,并连上行业与年度收益率信息
query = """
SELECT
    a.stock_code,
    a.year,
    a.roe,
    a.leverage,
    b.industry_name,
    c.ret_mean,
    c.turnover_mean
FROM fin_ratio AS a
LEFT JOIN basic_info AS b
    ON a.stock_code = b.stock_code
LEFT JOIN ret_yearly AS c
    ON a.stock_code = c.stock_code
   AND a.year = c.year
WHERE a.year = 2023
ORDER BY a.stock_code
"""

result = pd.read_sql_query(query, conn)
print(result)

# 关闭连接
conn.close()
print(f'
SQLite 数据库文件已保存到:{sqlite_path}')

10.7 什么时候继续用 pandas,什么时候引入数据库

这并不是一个非此即彼的问题。更现实的做法,是把它看成一个连续谱。

10.7.1 继续使用 pandas 即可的情况

以下情况下,继续使用 pandas 往往已经足够:

  • 数据表不多;
  • 文件体量不大;
  • 数据关联关系比较简单;
  • 项目是一次性的;
  • 最终只需生成一张分析样本表。

10.7.2 可以考虑 SQLite 或 DuckDB 的情况

出现下面这些信号时,可以开始考虑数据库或分析型 SQL 工具:

  • 数据来源越来越多;
  • 多个表需要反复连接;
  • 高频与低频数据并存;
  • 项目需要较长时间维护;
  • 同一张表会被多个 Notebook 或脚本重复使用;
  • 每次从头 merge 都很耗时;
  • 内存占用越来越大。

也就是说,问题不在于「会不会数据库」,而在于是否已经出现了「数据组织复杂度上升」的迹象。

10.8 提示词模式:让 AI 帮你设计数据组织方案

AI 在这类任务中的一个重要用途,不只是生成某段处理代码,而是帮助你梳理数据结构。一个好的提示词,至少应包含以下信息:

  • 项目目标
  • 各数据表的名称
  • 每张表的主键和粒度
  • 最终分析表的目标粒度
  • 希望采用的存储方式
  • 输出文件或数据库表的命名要求

下面给出一个可以直接修改使用的提示词模板。

AI 提示词:设计一个小型金融数据项目的数据组织方案

我正在做一个金融数据分析项目,目标是构造一张 firm-year 层面的分析样本表,用于研究上市公司的融资成本。

我目前有如下几张原始数据表:

  • basic_info:公司基本信息,主键为 stock_code,粒度为 firm
  • fin_ratio:年度财务指标,主键为 (stock_code, year),粒度为 firm-year
  • daily_ret:日度收益率,主键为 (stock_code, trade_date),粒度为 firm-date
  • macro_rate:宏观利率,主键为 date,粒度为 date
  • ann_meta:公告元数据,主键为 (stock_code, ann_date, doc_id),粒度为 firm-date-document

请你帮我完成以下任务:

  1. 判断哪些表不能直接合并,并说明原因
  2. 设计一个合理的项目目录结构
  3. 说明哪些表适合保存为 csvparquetsqlite
  4. 给出从原始表到最终 firm-year 分析底表的处理流程
  5. 用 Python 生成一个简洁、可运行的示例代码框架,必要时使用 sqlite3duckdb
  6. 所有代码都加上清晰的中文注释

10.9 本章小结

本章讨论的不是具体模型,而是数据分析中一个更基础的问题:多来源数据如何被组织起来。

核心结论可以概括为四点:

  • 数据分析不仅是清洗变量和跑模型,也包括对数据表、文件和目录的组织;
  • 在多来源金融数据场景下,主键与粒度比单纯的函数语法更重要,很多 merge 错误本质上都是粒度错误;
  • CSV / Excel 适合交换和查看,Parquet 适合高效存储标准表,SQLite 适合轻量关系数据管理,DuckDB 适合本地分析型查询;
  • 真实项目的目标不应只是「把数据读进来」,而应是构造一套可复用、可查询、可扩展的数据底座。

面对一个新的多来源数据项目,可以先按下面的顺序思考:

  1. 列出所有原始数据表,并写清楚每张表的主键和粒度;
  2. 确定最终分析样本的目标粒度;
  3. 判断哪些高频表需要先聚合后再合并;
  4. 设计目录结构,并区分 rawcleantempoutput
  5. 根据数据规模和查询复杂度,选择 csvparquetsqliteduckdb
  6. 将最终分析底表持久化保存,为后续清洗和建模做好准备。