这几年做企业基本面分析和跨市场比较研究时,我最头疼的不是算法,而是数据本身。于是就有了这个项目:整理一份覆盖2014到2023年、横跨大陆和港股市场的上市企业数据库。这个名字里的“oriana”不是某个商业软件,而是我给自己这套数据库方案起的代号——它借鉴了商用金融数据库按统一口径组织字段的思路,但数据完全来自公开披露信息,自己建、自己维护,用起来放心,也改得动。今天这篇就把整个项目的完整思路和实操过程摊开来讲,从选型、建表、采集清洗到查询优化、问题排查,尽量做到拿过去就能直接参考。
1. 项目背景与整体设计思路
1.1 典型业务场景:为什么需要自建这么一套数据
先说说我为什么放着现成的数据终端不用,非要自己建库。日常研究里,经常要同时看一家大陆公司和一家港股公司的营收增速、毛利率、负债率,做横向对比。这时候最尴尬的事情出现了:A股财报按国内会计准则披露,港股多按香港会计准则或国际财务报告准则披露,两边对“营业收入”的界定大体相同,但利润表细节、税务口径、递延收益这些科目差异不小。如果直接从两个不同的终端导数据,单位和币种还可能不一样,最后做出来的对比表错误百出。
商业数据库确实省事,但有几类场景它们不一定合适:一是研究需要把历史区间拉得比较长,逐年比对,而终端导出的数据通常会带版本修订,口径变了之后很难追溯;二是做因子筛选时,经常要自己算衍生指标,比如“经调整的自由现金流”“剔除商誉减值后的ROE”,这些指标在商用库里往往没有现成字段;三是团队协作时,希望全组人都能通过同一个数据库统一查询,而不是每人手里一份Excel,互相之间对不上号。基于这些具体需求,我决定自建一个本地数据库,把大陆、港股上市企业2014到2023年的基础信息、财务摘要和市场行情都装进去。
这样做还有一个额外的收获:整个库是我自己维护的,每一个字段的口径、每一次清洗规则的改动都有记录,出问题可以立刻溯源。商用数据库像是一个黑盒,你拿到手的数字往往不知道它经过了多少次调整,而自建库的核心价值在于“可解释、可复盘”,这对做研究的人来说非常重要。
1.2 数据范围与技术选型:先算清楚规模再动手
动手之前,先把数据量估算清楚,这直接决定了技术选型。2014到2023年间,A股上市企业数量从大约2500家增长到5000多家,港股从1600多家增长到2600多家,两边合计去重后大约有7500到8000家。财务摘要表按每家公司每个季度最多一条记录来算,十年下来大约有20万到25万条。行情表最占空间,按每家每年250个交易日计算,十年积累的量级在700万到900万条之间。
| 数据类别 | 预估记录数 | 说明 |
|---|---|---|
| 公司基本信息表 | 7500-8000条 | 含A股、港股,按证券代码去重 |
| 财务摘要表 | 20万-25万条 | 每家每季度一条,含年报与中报 |
| 每日行情表 | 700万-900万条 | 按每家每年250个交易日估算 |
这个体量,用MySQL 8.0的InnoDB引擎完全没压力。我最终选了MySQL而不是PostgreSQL或Oracle,原因很实际:团队里同事对MySQL最熟,出问题找人容易;InnoDB支持事务和行级锁,批量导入时不容易出现诡异的锁表现象;而且这个量级的表,加好索引后单条查询基本都是毫秒级返回,没必要为了“压榨性能”引入更重的维护成本。如果项目有国产化部署要求,这套表结构也能基本平滑迁移到达梦、人大金仓这类数据库——它们大多兼容MySQL或PostgreSQL的语法,前提是一开始就别用太特殊的方言特性。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库建模:表结构与字段设计
2.1 维度表与事实表分开设计,别嫌麻烦
数据库设计最忌讳一开始把所有字段堆在一张大表里。公司基本信息、财务指标、行情数据生命周期完全不同:公司信息一年变不了几次,行情数据每天都有新记录,财报则按季度批量入库。混在一张表里,要么浪费大量存储空间存重复的公司名称,要么更新公司简称时要改动千万级别的行。所以建库时我按维度表和事实表拆开建,维度表存变化慢的基础数据,事实表存高频率新增的指标和行情。
以公司信息表为例,核心字段包括证券代码、公司全称、公司简称、上市地点、上市日期、所属行业分类、注册资本、注册省份、是否当前存续等。这里有一个关键点:证券代码必须作为唯一业务标识,不能依赖公司名称。原因很简单,一家公司可能中途改名,比如“某某科技”改叫“某某智能”,但证券代码一般不会变。
sql复制CREATE TABLE company_info (
id INT AUTO_INCREMENT PRIMARY KEY,
sec_code VARCHAR(20) NOT NULL COMMENT '证券代码',
market VARCHAR(10) NOT NULL COMMENT '市场:A股/港股',
company_name_full VARCHAR(200) NOT NULL COMMENT '公司全称',
company_name_abbr VARCHAR(100) COMMENT '公司简称',
list_date DATE COMMENT '上市日期',
delist_date DATE COMMENT '退市/摘牌日期,NULL表示仍上市',
industry_code VARCHAR(20) COMMENT '行业分类代码',
industry_name VARCHAR(100) COMMENT '行业分类名称',
registered_capital DECIMAL(18,4) COMMENT '注册资本(万元)',
is_active TINYINT DEFAULT 1 COMMENT '是否当前有效,1有效 0失效',
etl_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '入库时间',
UNIQUE KEY uk_sec_market (sec_code, market),
KEY idx_industry (industry_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='上市企业基本信息表';
这样设计的好处,一是A+H股两地上市的公司可以保留两条记录,通过sec_code + market区分,不会互相覆盖;二是公司退市之后仍保留历史记录,不影响之前年份的数据查询。
财务摘要表是事实表的典型例子。这里特别要注意的是用report_period字段标注报告期间,而不是简单地用入库时间。因为财报披露有滞后,2014年的年报可能到2015年4月才披露,如果用披露时间作为期间,后面统计时会乱套。行情表同样属于事实表,设计时把主键定为sec_code + market + trade_date,再单独加自增id作为物理主键,方便后续按时间范围分区。
2.2 字段口径:币种、单位、会计准则的统一策略
如果说表结构是骨架,字段口径就是血液。跨大陆和港股两个市场时,最容易出事的就是币种。A股财报是人民币,港股财报披露时一般是港元,也有极少数公司用人民币记账。我不建议导入时直接把所有数据统一折算成人民币,因为折算汇率本身带有时点属性,提前折算会丢失原始信息。更稳妥的做法是保留原始币种的数值,同时额外建立一张汇率表,按季度记录人民币兑港元的参考汇率,分析时再动态折算。
财务字段的单位也要高度警惕。同样叫“营业收入”,有的公司报表里是元,有的在PDF公告里写的是“千元”或者“万元”,还有的习惯用“百万元”。导入时如果不统一单位就入库,后面做任何聚合计算都是灾难。我在清洗脚本里对所有金额字段做了统一约定:一律转为“万元”,并在字段注释里写清楚,后续任何人看到这个表,不用猜也知道数字是什么量级。
会计期间口径同样不能含糊。A股公司按自然年披露年报,部分港股公司财年不是12月31日截止,可能是3月31日或6月30日。建模时我用fiscal_end_date表示会计期末日期,同时用report_period表示标准化期间,比如“2014-12-31”,方便按年切片。遇到财年特殊的企业,查询时按标准化期间过滤即可,不影响比较。
金额字段的选择也有讲究。有人说财务金额应该用DECIMAL而不用FLOAT,我在实际项目中深有体会。FLOAT是浮点数,存储和计算都可能产生精度误差,几千万的数据看起来差不多,但做精确核对、差额试算时会莫名对不上账。DECIMAL(18,4)虽然占用空间大一点,但对财务数据而言,可追溯性远比节省那一点空间重要。
3. 数据采集与清洗:最花时间的环节
3.1 原始数据从哪里来,怎么拉取效率最高
这个项目的数据来源全部是公开披露渠道。公司基本信息、财务摘要主要来自交易所官网、巨潮资讯网和港交所披露易,行情数据则是通过财经数据接口按批次拉取。采集方式上,我一开始也想过全部用爬虫自动抓取,后来发现性价比不高:静态页面经常改版,解析规则三天两头失效,调试成本远高于数据本身的价值。最终采用的方案是“半自动”:能通过接口拿到的数据用脚本定时拉取,拿不到的PDF公告走人工辅助解析,再把结果统一落成CSV文件,最后由导入脚本合并进MySQL。
用接口批量拉行情数据时,需要注意频率控制。很多免费接口对单IP访问次数有限制,粗暴并发很容易被封。我的做法是每次请求之间间隔0.2到0.5秒,单次任务控制在几分钟内完成,拉完一个市场休息一下再拉另一个,实测下来稳定性好了很多。另外,接口返回的数据一般只包含最近一段时间的记录,历史数据要提前确认好接口是否支持按日期范围回溯,避免拉到一半才发现日期被限制了。
合规性方面也要提一句:所有这些操作都只针对公开披露的上市公司信息,不涉及任何非公开或内部数据,个人研究使用问题不大,但如果要商业化分发,还要再认真核对数据使用条款。这个坑注意一下,能省掉后续非常多的麻烦。
3.2 清洗环节的五个典型坑
数据清洗是整条链路里最耗时、最容易出幺蛾子的部分。我归纳下来,高频问题集中在五个方面。
第一是公司改名的处理。十年前叫“某某重工”,五年前改叫“某某智能装备”,最新又变成“某某科技”。如果只按公司简称做关联,跨年份对比时一定出问题。我的解法是建表时始终保留证券代码作为唯一标识,公司名称只作为展示字段。关联历史数据时,只用代码,不用名称。
第二是退市和数据下架。有些公司2018年就退市了,市场上很多数据接口查不到它的历史财报,直接导致样本出现“幸存者偏差”。如果做的是“2014到2023年所有上市企业”的全量分析,就必须在清洗时把退市企业的历史数据也补回来。我的做法是先整理一份所有曾上市公司清单,再按代码逐个去补数据,宁可慢一点,也不要漏。
第三是金额单位不一致。这是最坑的。同一年的两份财报,一家报表底稿写着“单位:千元”,另一家写“单位:万元”,导入脚本稍不注意就把单位当成注释丢掉了。清洗时必须逐行检查金额字段是否带有“单位”前缀,并统一转成约定的“万元”。
第四是缺失值处理。财务指标出现NULL很常见,原因包括披露不全、数据接口缺记录、合并报表范围调整等。我定的规则是:优先用原始公告补齐;其次用上一期数据做参考,但不自动填充;最后实在补不齐的,保留NULL并在独立字段里标记数据状态。绝对不要为了分析方便就默认填充0,那会把均值、中位数之类的统计指标全部带偏。
第五是财报重述问题。个别公司会因审计调整对已披露的历史数据进行重述,导致同一期间在不同时间点下载到的数据不一样。这个问题没有完美解法,我的策略是在清洗脚本里记录下载时间戳,并在重要分析前核对一次关键指标是否有异常跳变。如果你的项目对数据准确性要求极高,建议建一张“版本表”,每次更新时保留新旧两个版本,分析时明确指定用哪个版本。
3.3 批量导入MySQL的实操流程
清洗完的CSV文件,导入MySQL的方式也要讲究。最开始我用的是逐行INSERT,几万条数据跑下来要很久。后来改成Python的pandas读取,再配合SQLAlchemy一次性写入,速度提升非常明显。对于更大的表,直接使用LOAD DATA INFILE,几百万行数据几分钟就能导完。
python复制import pandas as pd
from sqlalchemy import create_engine
engine = create_engine("mysql+pymysql://user:password@localhost:3306/oriana_db?charset=utf8mb4")
# 分批读取CSV,避免一次性占用过多内存
chunks = pd.read_csv("financial_report_2014_2023.csv", chunksize=50000)
for i, chunk in enumerate(chunks):
# 统一字段类型:数值字段转float,日期字段转datetime
chunk["total_revenue"] = pd.to_numeric(chunk["total_revenue"], errors="coerce")
chunk["report_period"] = pd.to_datetime(chunk["report_period"])
# 写入数据库,take_ownership_after=True表示传给数据库后立即生效
chunk.to_sql("financial_report", engine, if_exists="append", index=False)
print(f"batch {i} done")
导入时有个容易被忽视的点:如果表上有大量索引,每插入一批数据,InnoDB都要同步维护索引,导致导入速度越来越慢。我的做法是先检查表结构,如果索引比较多且数据又是首次全量导入,可以先把非必要的二级索引删掉,等数据导完再一次性重建索引。这样整体耗时能减少一半以上。实际操作中我习惯每批提交一次事务并打印进度,方便中途出问题时定位到具体批次。
bash复制# 如果使用LOAD DATA INFILE,注意字段终止符和行终止符
LOAD DATA INFILE '/tmp/financial_report.csv'
INTO TABLE financial_report
FIELDS TERMINATED BY ','
ENCLOSED BY '"'
LINES TERMINATED BY '\n'
IGNORE 1 LINES
(sec_code, market, report_period, total_revenue, net_profit, ...);
LOAD DATA INFILE的性能比INSERT强很多,但有三个前提要注意:文件编码必须和表结构一致,否则中文会乱码;CSV里的字段顺序必须和表字段顺序一一对应;如果遇到主键冲突,需要先决定是跳过还是覆盖。我一般先导入到临时表,再通过INSERT ... ON DUPLICATE KEY UPDATE合并到正式表,安全又可控。
4. 查询分析与性能优化
4.1 三个高频分析SQL,直接可以抄作业
数据入库后,重点就是怎么高效地查。这里分享三个我几乎每周都会用到的查询。
第一个是按行业和年度统计营收均值。这个查询可以用来快速了解不同行业在十年间的规模变化。比如“2020年A股半导体行业平均营收是多少”,一条SQL就能解决:
sql复制SELECT
f.report_year,
c.industry_name,
COUNT(DISTINCT c.sec_code) AS company_cnt,
ROUND(AVG(f.total_revenue), 2) AS avg_revenue
FROM financial_report f
JOIN company_info c ON f.sec_code = c.sec_code AND f.market = c.market
WHERE f.report_type = '年报'
AND f.report_year BETWEEN 2014 AND 2023
GROUP BY f.report_year, c.industry_name
ORDER BY f.report_year, avg_revenue DESC;
这里report_year是清洗阶段从report_period里提取出来的年份字段,这样比直接用YEAR(report_period)更快,因为可以直接走索引。
第二个是横向比较同一年大陆和港股的估值水平。港股上市公司的市值单位很多是港元,要用汇率表折算成人民币,再和A股比:
sql复制SELECT
f.report_year,
c.market,
ROUND(AVG(f.pe_ratio), 2) AS avg_pe
FROM financial_report f
JOIN company_info c ON f.sec_code = c.sec_code AND f.market = c.market
JOIN fx_rate r ON r.period = f.report_period
WHERE f.pe_ratio IS NOT NULL
AND f.report_year BETWEEN 2014 AND 2023
GROUP BY f.report_year, c.market
ORDER BY f.report_year;
第三个是筛选连续十年存续且营业收入持续增长的公司。这种查询本质上过滤的是“幸存者”,但从研究角度看很有价值。核心用窗口函数LAG对比每个公司相邻两年的营收:
sql复制WITH revenue_with_prev AS (
SELECT
sec_code,
market,
report_year,
total_revenue,
LAG(total_revenue) OVER (PARTITION BY sec_code, market ORDER BY report_year) AS prev_revenue
FROM financial_report
WHERE report_type = '年报' AND report_year BETWEEN 2014 AND 2023
)
SELECT sec_code, market
FROM revenue_with_prev
GROUP BY sec_code, market
HAVING
COUNT(DISTINCT report_year) = 10
AND SUM(CASE WHEN total_revenue < prev_revenue THEN 1 ELSE 0 END) = 0;
这类SQL如果数据量不大,查询时间还在接受范围内,一旦数据表膨胀到几百万行,就非常依赖于索引设计和数据库配置,这就是下一节的话题。
4.2 索引设计、分区表与慢查询排查
性能优化不能等表建好了再临时抱佛脚,建模阶段就要想清楚查询路径。对财务摘要表,最常用的查询条件是sec_code + market + report_period,所以联合索引(sec_code, market, report_period)是必须的。对行情表,高频查询条件是sec_code + trade_date,同样建立联合索引。这样设计索引后,普通点查都能在几十毫秒内完成。
数据量进一步增长时,可以考虑按年份做分区。分区的好处是“修剪”数据,查询2015年的数据时,InnoDB会跳过其他年份的分区,扫描量显著减少。我用的分区语句大致是:
sql复制ALTER TABLE market_daily
PARTITION BY HASH(YEAR(trade_date))
PARTITIONS 10;
按年分区的方案适合按年度做研究的场景,但要注意,分区字段必须包含在主键里,否则MySQL会报错。这个细节很容易踩,先检查表结构再分区。
慢查询排查方面,我习惯先开着MySQL的慢查询日志,设置阈值为1秒,然后定期查看哪些SQL超时了。针对超时SQL,用EXPLAIN看执行计划,重点确认是否走了索引、扫描行数是否异常大。之前遇到过一次让人印象深刻的案例:明明在report_period上建了索引,但查询时因为嵌套到函数里,导致索引失效,全表扫描。改成直接使用字面量日期范围后,速度立刻从秒级降到毫秒级。这个经验很实用——建了索引不等于能用上索引,函数包裹、隐式类型转换都可能让优化器放弃索引。
数据库连接池的设置也要匹配使用场景。如果只是单人研究,连接数设置5到10个就够;如果团队里多人同时查询,建议使用连接池中间件,把最大连接数控制在20到50之间,避免数据库频繁创建和销毁连接造成不必要的开销。我在项目里一直用HikariCP做连接池管理,简单又稳定。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
这些坑都是我在实际项目中踩过或帮同事排查过的,整理成一张速查表,方便遇到问题时快速定位。
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 导入CSV时中文乱码 | 文件编码和表字符集不一致 | 统一使用UTF-8,导入前确认CSV编码,连接参数加charset=utf8mb4 |
插入大文件报max_allowed_packet超限 |
MySQL默认包大小限制 | 临时调大max_allowed_packet,如SET GLOBAL max_allowed_packet=128M |
| 表里有重复数据 | 主键设计不合理或ETL重复执行 | 先查重,再做唯一索引;重复数据用INSERT ... ON DUPLICATE KEY UPDATE合并 |
| 按日期查询特别慢 | 日期字段被函数包裹导致索引失效 | 使用字面量范围,避免DATE_FORMAT等函数;检查执行计划 |
| 批量更新时出现死锁 | 多个事务更新同一批行顺序不一致 | 统一更新顺序,缩短事务时间,必要时减小批量大小 |
| 迁移数据后utf8字段损坏 | 导出导入时字符集不一致 | mysqldump时指定--default-character-set=utf8mb4 |
| 从Excel复制数据导入报“外部表不是预期格式” | 源文件不是真正的CSV或格式不标准 | 先另存为纯CSV或使用数据库客户端工具导入 |
第一行说的中文乱码几乎每个做过数据导入的人都遇到过。解决方案不是事后补救,而是在建表和生产环境连接阶段就统一字符集。MySQL 8.0默认字符集是utf8mb4,比utf8更完整地支持中文和特殊字符。另外,Python连接MySQL时,连接字符串里一定要带上charset=utf8mb4,否则pandas读出来的中文就可能变成问号。
死锁问题在批量写入财务数据时容易触发。原因是多个事务同时操作同一个主键或索引范围时,InnoDB的行锁顺序不一致导致相互等待。我后来在批量更新脚本里强制按sec_code从小到大排序,再分批提交,死锁基本消失。更保险的做法是设置合理的innodb_lock_wait_timeout,默认50秒太长,我改为10秒,一旦有问题能快速暴露出来。
5.2 数据库迁移与备份的一些心得
这个数据库用了一年多以后,我有一次需要把整体数据迁移到另一台服务器上。因为数据量已经有好几个GB,直接用mysqldump导出SQL文件再导入,花费了很长时间。后来改用--tab参数导出为文本格式,再配合LOAD DATA INFILE导入,速度提升明显。迁移过程中还发现一个问题:源服务器MySQL版本是5.7,目标服务器是8.0,部分老版本字符集配置不兼容,导致个别字段导入失败。所以迁移前先查清两边的版本和字符集,最好在测试环境完整跑一遍再动线上数据。
备份策略这块,我坚持每天凌晨做一次逻辑备份,每周做一次全量物理备份。因为数据库变更频率不算高,这种频率已经能保证数据安全。恢复演练也很重要,我至少做过两次恢复测试,确认备份文件可以正常导入。不然备份做了三个月,真到恢复时才发现文件损坏,那才叫欲哭无泪。
5.3 和国产数据库的适配经验
因为部分合作方有国产化要求,我也在这套数据库上做过适配测试。把表结构迁移到达梦或人大金仓数据库时,发现建表语句和查询语句基本兼容,主要改动集中在几处:MySQL的AUTO_INCREMENT要改成对应数据库的自增语法,ON DUPLICATE KEY UPDATE在新环境里需要换成MERGE或先查后插。如果前期设计时就遵循SQL标准,少用MySQL特有语法,迁移成本会非常低。考虑到现在很多团队在评估国产数据库切换,这一点可以作为早期设计的一个加分项。
6. 最后再分享几个小建议
这个项目做到最后,数据量已经稳定在几千万到上亿条的规模,运行时表现一直很稳。如果让我从头再做一次,有几件事我会在第一天就做好:第一,把字段口径文档写清楚再动手建表,避免后期反复返工;第二,每张表都留etl_time字段,哪怕当时觉得用不到,后来做数据回溯时帮了大忙;第三,批量导入时先删掉非必要索引,全部导入完成后再重建,速度可以快好几倍。
另一个很实际的建议是:清洗脚本一定要保留原始版本,不要覆盖式修改。我第一次清洗时,直接把某个字段的错误值覆盖成了新值,后来发现部分公司当年真实披露的数据确实是奇怪的写法,但我已经没有原始文件可以核对了。从那时起,我再也不在原文件上直接改,所有清洗都生成新列新文件,原始数据始终留档。数据研究这个行当,很多功夫不在分析本身,而在数据准备阶段的耐心和严谨。
如果你正在折腾类似的企业数据库项目,希望这篇文章能帮你避开一些我踩过的坑。当然,每个项目的具体情况都不一样,我的方案也未必完全适合你的场景,但整体的设计思路和排查方法应该是通用的。最后再提一句,数据库设计别怕一开始花时间,多花半天想清楚表结构和字段口径,后面能省下来的时间可能是一个月。
