ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

股票数据库搭建全指南:从建表到数据质量校验

股票数据库搭建全指南:从建表到数据质量校验

做量化研究这些年,被问得最多的问题不是策略怎么写,而是“我拿到的行情数据到底能不能信”。很多朋友拿着免费数据源直接跑回测,结果收益曲线漂亮得不像话,一上实盘就拉胯,十有八九是数据没处理好。说到底,一个严谨、可复现、能支撑持续研究的股票数据库,才是整个量化体系的底座。这篇文章就是围绕“股票数据库建立”这件事,把从设计到落地的完整路径讲清楚,包括存什么、怎么存、怎么更新、怎么校验。如果你是刚开始搭建数据基础设施,或者正在被数据质量问题折磨,这篇文章应该能帮你少走不少弯路。

1. 先把需求盘清楚:股票数据库到底要存什么

1.1 三类核心数据:行情、财务与日历

动手建表之前,一定要先想明白数据库里需要放什么内容。很多人的第一反应是“存股价”,但实际操作下来,只有股价远远不够。一个能真正支撑回测和策略研究的最小数据集,至少要包含行情数据、财务数据和交易日历三块。

行情数据是最基础的部分,包括日线、分钟线、tick数据。日线包含开高低收、成交量、成交额这些常见字段,分钟线用于日内策略,tick数据则是最高频的逐笔成交记录。财务数据则是另一个维度,包括利润表、资产负债表、现金流量表的关键指标。为什么需要财务数据?因为很多策略依赖估值因子、成长因子,你要通过数据库快速筛选出“市盈率低于某个阈值且净利润增速超过20%”的股票,没有结构化的财务数据根本做不了。还有一块特别容易被忽略:交易日历。A股有节假日、调休、临时休市,不同市场的交易日不一样,用自然日历去对齐交易数据,必然出错。

这三类数据的更新频率和用途完全不同,我整理了一个表格供参考。

数据类别典型内容更新频率核心用途
行情数据日线、分钟线、tick日线每日更新,分钟线盘中积累计算收益率、K线形态、回测主数据
财务数据营收、净利润、ROE、资产负债率季报/年报发布时更新基本面因子、股票筛选、排雷
日历与事件交易日历、复权因子、停复牌、涨跌停每日更新,随交易所公告变化对齐交易日期、处理价格突变

这里最核心的思想是“数据为策略服务”。不要一上来就追求大而全,先想想自己手里现有的策略需要哪些字段,再决定建什么表。建库的成本不只是存储,更重要的是后续数据清洗和维护成本,贪多嚼不烂。

1.2 数据粒度怎么选:日线起步,分钟线按需

数据粒度决定数据库的量级,也直接影响查询性能。很多新手一上来就想存tick级别数据,其实完全没必要。我见过一个很典型的例子:A同学刚开始研究动量策略,非要全市场tick数据,结果光是数据抓取就跑了几个星期,数据库体积暴涨,查询慢得怀疑人生,而他的策略其实只需要日线收盘价。

先算一笔账。目前沪深两市合计超过5000只股票,一个交易日大约242天。如果只存日线,全市场一天产生5000条记录,一年约121万条,十年也就1200多万条,对单机数据库来说毫无压力。但如果是分钟线,一天240根,全市场一天就是120万条,一年2.9亿条,五年接近15亿条,这就需要考虑分区和专门的存储设计。再往上的tick数据,一天就可能有几千万甚至上亿条,个人研究者基本不建议碰。

数据粒度的选择可以用照片来类比:像素越高,细节越丰富,但占用的存储空间和加载成本也越高。日常看一张手机照片只要几十KB就够清晰了,没必要每张都存RAW原图。先想清楚策略是否需要日内数据,如果不是,日线就是最好的起点。即使后来需要分钟线,也可以用“按需补数据”的方式增量添加,而不是第一天就全量铺开。

2. 存储引擎选型:别一上来就上大数据组件

2.1 主力方案:开源关系型数据库为什么够用

“股票数据库建立”这个需求,很多人容易走向两个极端:要么直接用Excel和CSV文件凑合,要么一上来就上Hadoop、Spark。这两种我都见过,前者数据多了根本查不动,后者维护成本高到直接劝退。我的建议非常明确:个人量化和中小团队的研究场景,用开源关系型数据库就够了。

以PostgreSQL和MySQL为代表的关系型数据库,在股票数据场景下有天然优势。第一,支持事务和约束,可以保证数据一致性——比如同一只股票同一天的记录不会重复写入,这在数据更新时太重要了。第二,SQL查询极其灵活,回测时写一句SQL就能筛选出满足条件的股票组合,不用写一堆自定义代码。第三,生态成熟,各种语言的驱动、ORM、可视化工具都是现成的。我个人的主力选择是PostgreSQL,因为它在数值精度和索引性能上更均衡,NUMERIC类型处理金融数据也靠谱。

也许有人会问,现在时序数据库不是很流行吗?像InfluxDB、TDengine这类产品,在存储高频时间序列上确实有优势,压缩率高、写入性能好。但对于以日线为主的数据集,时序数据库的收益并不明显,反而会增加技术栈复杂度,而且很多策略研究还是要用SQL做关联查询,时序数据库的查询方言和关系型数据库不完全一样,迁移有成本。结论是:先不碰,除非你确实需要海量分钟级或tick级数据。

2.2 为什么我建议单机数据库而不是分布式体系

这几年大数据概念普及,动不动就听到“数据量大必须上集群”的说法。但在股票数据库这个场景下,单机关系型数据库完全能扛住。原因很简单:日线十年才1000多万行,加上财务数据、交易日历,总量最多几GB,单机SSD和内存完全可以覆盖,查询性能瓶颈根本不在存储节点。

更重要的是,回测的瓶颈往往不在数据库,而在策略逻辑本身。我见过有人把数据库部署在4节点集群上,每次查数据还要走分布式调度,一个简单的日线查询延迟比单机还高。这就是典型的“为了架构而架构”。分布式系统带来的网络开销、节点同步、运维复杂度,对个人研究者而言是纯负债,不是资产。

当然,单机方案也有自己的边界,比如并发写入能力有限、磁盘空间上限固定,但对绝大多数个人和中小团队来说,这些都不是问题。如果有一天你真的需要在一个晚上跑完全市场5000只股票、每一只都做一遍高频因子计算,那时候再考虑分库分表也不迟。起步阶段,一个靠谱的单机PostgreSQL,顶得上所有花哨的大数据组件。

3. 建表设计与字段细节:这步省了,后面全是坑

3.1 行情表设计:主键、索引与字段类型

建表是整个数据库的地基,字段设计得好不好,决定了以后查询痛不痛苦、数据会不会悄悄出错。先看日线表的设计,这是最重要的表。我的标准建表语句大概长这样。

CREATE TABLE daily_bar ( stock_code VARCHAR(12) NOT NULL, trade_date DATE NOT NULL, open NUMERIC(12,4) NOT NULL, high NUMERIC(12,4) NOT NULL, low NUMERIC(12,4) NOT NULL, close NUMERIC(12,4) NOT NULL, volume BIGINT NOT NULL, amount NUMERIC(18,4) NOT NULL, adj_factor NUMERIC(16,8) NOT NULL DEFAULT 1, turnover NUMERIC(12,8), PRIMARY KEY (stock_code, trade_date) );

这里有几个关键设计决策需要解释。主键用的是(stock_code, trade_date)联合主键,而不是自增id。为什么?因为这个组合天然唯一,一只股票一天只有一条日线,用业务主键可以直接避免重复数据。如果你用一个无意义的自增id,那还需要额外加唯一索引去防重,纯属浪费空间和心智。字段类型方面,价格和金额用NUMERIC而不是FLOAT,原因很实际:金融计算中对精度极其敏感,FLOAT在大量乘除之后会产生微小误差,累积起来可能导致信号误判。NUMERIC是定点数,可以精确控制小数位数,适合存储价格。成交量用BIGINT,因为A股成交量在一些股票上可以到几亿手,普通INT会溢出。

再说索引。联合主键本身就是 (stock_code, trade_date) 的索引,所以按股票和日期查询已经很快了。但如果经常要按日期范围扫描全市场数据,比如写一个“某一天全市场所有股票”的查询,那这个主键索引就不够用,因为主键索引先按代码排、再按日期排,日期维度并不是前缀。这种情况下,可以额外建立一个(trade_date, stock_code)的联合索引,专门加速按日期维度的访问。这张表平时还会按复权因子做更新,因为除权除息日会批量更新,所以adj_factor字段单独放在表里,方便回测时直接计算后复权价格。

3.2 交易日历与复权因子表:容易被忽略的两个关键表

日线表建好后,新手往往会漏了两张表:交易日历和复权因子。先说交易日历,这是解决“停牌日被当成正常交易日”这一类问题的唯一正确工具。

CREATE TABLE trade_calendar ( trade_date DATE PRIMARY KEY, is_open BOOLEAN NOT NULL, market VARCHAR(8) NOT NULL );

这张表记录了每个市场的真实交易日。为什么要单独建表,而不是直接按自然日过滤?因为A股的交易日受到法定节假日、调休安排、临时休市的影响,无法用简单的周日判断。比如某年国庆长假后的周末调休日,交易所可能休市,但日历上是工作日;再比如有些年份的春节前后,周中会有临时休市。只有以交易所公布的交易日历为准,才能保证回测时每一根K线都对应真实的交易环境。

复权因子表更是重中之重。一只股票在分红、送股、配股后,价格会发生跳变,如果不做处理,择时策略会在除权日出现假信号。复权因子正是用来修正这个问题的:

CREATE TABLE adj_factor ( stock_code VARCHAR(12) NOT NULL, trade_date DATE NOT NULL, adj_factor NUMERIC(16,8) NOT NULL, PRIMARY KEY (stock_code, trade_date) );

具体来说,后复权价格 = 原始价格 × 复权因子,前复权价格 = 原始价格 × 复权因子 / 最新因子。回测中我强烈推荐用后复权价来计算收益率,因为它的历史价格是连续的,不会因为未来发生除权导致历史数据被反复调整。这里有一个经典陷阱:如果数据库里的日线价格是前复权价,那么每天发生新的除权事件时,全部历史价格都会被改写,这意味着同一段历史区间的收益率在不同时间计算会得到不同结果,这会让回测无法复现。而使用“原始价格 + 复权因子”的结构,价格永远不变,只有因子追加,历史计算就稳定得多。

3.3 财务数据与事件表:必须避开未来函数

财务数据表的设计比较容易踩坑,核心原则是:用公告日期对齐,而不是报告期。比如一家公司在4月30日发布一季报,报告中包含了截至3月31日的财务数据,但一个策略只能在4月30日开盘前知道这些数据。如果直接按报告期(3月31日)关联财务数据,那么在4月1日到4月29日之间,策略就会提前使用未来才知道的数据,这就是典型的未来函数,回测收益会严重虚高。

我建议财务表至少包含两个日期字段:report_date(报告期,即财务报表覆盖的截止日期)和announce_date(公告日期,即数据真正公开的时间)。查询时用公告日期作为时间维度,才能保证策略在信息可得性上贴近真实场景。

事件表也是容易被忽略的一块。涨跌停、停复牌、大股东增减持、分红送转这些事件,都可以单独建表记录。比如很多策略要求“剔除涨停的股票”,因为涨停时买不进去,如果你不做事件过滤,回测里就会买入实际上无法成交的股票,收益自然虚高。事件表的结构通常很简单:stock_code、event_date、event_type、event_desc,查询时用事件类型过滤即可。

4. 数据采集与增量更新:从零到一搭数据管道

4.1 数据从哪来:免费接口与第三方数据源

数据源是整个数据库的上游,质量直接决定下游一切结果。常见的渠道有几类:免费的数据接口、第三方数据服务商、券商提供的量化接口。免费接口的优势是零成本、上手快,历史数据也能覆盖十几年,但频率限制比较严格,大量抓取时容易被限流。第三方数据服务商的数据质量更好、字段更全,但需要付费,适合已经有初步策略、需要精细化数据的阶段。券商量化接口则适合做盘中实时数据,一般只提供给开户用户使用。

这里想强调一个观点:不管用哪个渠道,都要通过实际校验来验证数据质量,而不是默认对方数据是可信的。我见过某免费接口在早期一段历史行情中返回了错误成交量,导致因子计算偏差。数据源不是越大牌越好,而是越校验越可靠。

4.2 全量初始化:第一轮数据怎么拉

数据库是空的,第一步要做全量历史数据初始化。这一步如果写代码,流程大概是这样的。

import time import requests import pandas as pd def fetch_stock_list(): # 从数据源获取全部股票列表 resp = requests.get("https://api.example.com/stock/list", timeout=10) return [item["code"] for item in resp.json()] def fetch_daily(code, start, end): # 拉取单只股票的历史日线 params = {"code": code, "start": start, "end": end} resp = requests.get("https://api.example.com/daily", params=params, timeout=10) return pd.DataFrame(resp.json()) def init_database(conn): symbols = fetch_stock_list() for code in symbols: data = fetch_daily(code, "2000-01-01", "2025-12-31") # 写入数据库, upsert 模式 upsert_daily(conn, data) time.sleep(0.2) # 限流控制, 避免被接口封禁

这段代码的核心逻辑就是:先拉股票列表,再逐只拉历史日线,写入数据库。看起来简单,实际操作中有几个细节要特别注意。

第一,限流控制必不可少。免费接口通常会限制每秒请求次数,如果不加sleep,几分钟内就会被封IP。第二,一定要分批拉取,不要一次性全量请求,否则网络超时和内存压力都会很大。第三,写入数据库时优先用INSERT ... ON CONFLICT DO UPDATE的upsert方式,这样即使重复拉取、接口返回重复数据,也不会产生脏数据。

全量初始化完成后,必须做一次数据校验,这一点放在后面单独讲。校验通过,才意味着数据库可以正式投入使用。

4.3 增量更新:每天收盘后的固定流程

全量历史数据初始化完成不代表一劳永逸,更关键的是增量更新机制。A股每个交易日收盘后,数据源才会更新当天的数据,所以数据库也需要每天固定时间增量拉取。增量更新的核心思路很简单:每只股票从数据库中已有的最大交易日期开始,往后拉取缺失的数据。

def sync_daily_incremental(conn): symbols = fetch_stock_list() for code in symbols: last_date = get_last_trade_date(conn, code) start = last_date + timedelta(days=1) end = date.today().strftime("%Y-%m-%d") data = fetch_daily(code, start, end) upsert_daily(conn, data)

这里有一个容易被忽略的小细节:last_date是从daily_bar表中查出来的,但如果一只股票已经停牌了很久,它的最后交易日期可能已经落后很多天,增量拉取的起点会比较早,这没有问题。但如果数据源接口对单次查询的最大时间跨度有限制,比如一次最多只能拉500天,超出了一定要自动按区间分段拉取,否则会漏数据。

增量更新还有一个常见问题是当天数据口径变化。比如某些数据源在收盘后先出一个“临时数据”,晚上再修正成“正式数据”。这种情况下,增量更新不能只拉一次,最好在收盘后跑一次,晚间或次日凌晨再跑一次修正任务,用upsert覆盖之前的口径。这就是为什么要用upsert而不是先删除再插入:先删后插在并发查询时会有窗口期,查询会读到半截数据,而upsert是原子操作,始终保证查询结果是完整的。

我自己的习惯是每天收盘后15:30跑第一轮增量更新,晚上21:00再跑一轮修正更新,同时把交易日历和复权因子也一并更新。这个流程固定下来之后,数据库就真正做到了“跟得上市场节奏”。

5. 数据质量与常见问题排查

5.1 脏数据从哪来:五种常见表现

数据库跑起来之后,真正让人的头疼的是数据质量问题。我总结了几种最常见的脏数据表现,每一种都曾经坑过我,贴上经验分享。

第一种是除权除息日价格突变。如果复权因子更新不及时,某只股票在除权日会出现价格瞬间下跌20%甚至更多,这在回测中会被当成巨大亏损信号。第二种是停牌日数据缺失。有些接口在停牌日会返回一个空记录或者用前一天的收盘价填充,如果不做处理,策略会误以为停牌日有成交量,实际上根本交易不了。第三种是财务数据错位。前面说的未来函数就是典型,再比如财务数据表里同一报告期有多个版本(业绩预告、正式财报、修订稿),如果不区分版本,数据就会混乱。第四种是分钟数据时区问题。有些数据源的时间戳用的是UTC,有些是本地时间,跨市场策略尤其容易踩坑。第五种是接口偶发返回空值。网络波动时接口会返回部分为空的数据,如果不做校验就直接入库,会出现某一天的close为NULL,直接导致后续收益率计算全部出错。

5.2 质量校验脚本怎么设计

数据质量不能靠肉眼观察,必须写自动化校验脚本,而且最好每天跑增量更新时顺带执行。我的校验脚本大致包含以下几项。

def verify_data(conn): # 1. 检查每个交易日全市场记录数是否在合理范围 # 某天记录数异常稀少, 可能漏拉数据 sql = """ SELECT trade_date, COUNT(*) AS cnt FROM daily_bar GROUP BY trade_date ORDER BY cnt ASC LIMIT 20 """ # 2. 检查单日涨跌幅是否超过阈值 # 剔除上市首日后, 正常涨跌幅应在±20%以内 sql = """ SELECT stock_code, trade_date, close FROM daily_bar WHERE (close / LAG(close) OVER (PARTITION BY stock_code ORDER BY trade_date) - 1) > 0.2 """ # 3. 检查成交量/成交额是否异常 # 价格正常但成交量为0的记录, 大概率是停牌未标记 sql = """ SELECT stock_code, trade_date, volume, amount FROM daily_bar WHERE volume = 0 AND amount = 0 """

检查单日涨跌幅超过20%这个阈值很关键。A股主板正常涨跌幅限制是10%,创业板和科创板是20%,超过这个幅度大概率是复权因子没更新或者数据源返回了错误值。成交量异常这个检查同样有效,如果一只股票有价格但成交量、成交额都是0,问题多半出在复权或停牌标记上。这些校验脚本并不复杂,但对于守住数据库的底线非常有效。

还有一个思路是多数据源交叉验证。如果条件允许,用两个不同的数据源对同一只股票同一日的数据做比对,价格差异超过某个阈值就告警。这一步不一定每天做,但每隔一段时间抽几只股票做一次交叉验证,可以帮助发现单一数据源的系统性偏差。

5.3 实战踩坑记录与问题速查表

分享几个真实的踩坑片段。第一次是模拟项目X的日线回测,跑出来策略年化收益40%,看着很高兴,结果发现复权因子表已经三个月没更新,除权日的价格跳变全被当成真实亏损,可想而知这个收益数字有多失真。第二次是某次增量更新时,接口返回数据有一半是NaN,我当时偷懒没做空值校验,结果那几天的close全是NULL,机器学习模型直接训练报错。第三次是分钟线策略,发现每天上午9:35的第一根K线总是异常,后来查出来是时区问题,接口返回的本地时间被默认当成了UTC,等于所有时间戳往前偏了8个小时,整个日内策略的逻辑全部错位。

这些问题的共性是:如果构建数据库的时候就把校验和约束嵌进去,完全可以避免。我把常见问题整理成了速查表。

问题现象常见原因处理方式
回测收益普遍偏高未来函数,财务数据按报告期关联改用公告日期关联财务数据
某只股票价格异常跳变除权除息未处理或复权因子缺失更新复权因子,改用后复权价
数据总是缺最近几天接口更新延迟,或增量拉取起点有误增加重试机制,校验最大日期
分钟线时间偏移数小时时区处理错误统一使用北京时间,记录时区信息
入库报数值错误接口返回NaN或无效字符串入库前先清洗,过滤无效值
同一股票同日有多条记录重复拉取,缺少唯一约束使用联合主键或upsert

6. 数据库的性能优化与日常维护

6.1 查询优化:别让回测卡死在SQL上

数据库建立之后,随着数据量增长,查询性能会逐渐成为瓶颈。最常见的问题是:全市场所有股票某一段时间的日线数据,查询非常慢。优化的第一原则就是善用索引。如果查询条件经常是“某一天的很多股票”,那前面提到的(trade_date, stock_code)联合索引就派上了用场;如果查询条件是“某只股票的一段历史”,主键索引就足够了。

优化第二原则是合理使用分区。对于日线表,可以按年份做原生分区,查询时如果条件里带有年份过滤,数据库会自动只扫描对应分区,大幅减少IO。对于分钟线表,分区几乎是必须的,否则表和索引会非常大。分区表建好后,对查询SQL完全透明,不需要改代码,这是投入产出比很高的优化手段。

优化第三原则是物化视图。回测中经常要计算“每日收益率”“20日均线”这类衍生指标,如果每次回测都现算一遍,浪费大量时间。可以建立物化视图,在增量更新完成后统一刷新。比如:

CREATE MATERIALIZED VIEW daily_return AS SELECT stock_code, trade_date, close / LAG(close) OVER (PARTITION BY stock_code ORDER BY trade_date) - 1 AS return_rate FROM daily_bar WHERE close IS NOT NULL; REFRESH MATERIALIZED VIEW daily_return;

物化视图相当于把计算结果提前缓存下来,回测时直接查询结果,速度能提升好几倍。代价是占用额外的磁盘空间,但对于日线级别数据量来说完全可接受。

6.2 备份与恢复策略:数据库万一坏了怎么办

股票数据库是研究资产,丢数据几乎等于丢研究基础,备份一定不能省。最简单的方案是用数据库自带的逻辑备份工具,比如PostgreSQL的pg_dump,每天凌晨跑一次全量备份,保留最近30天的备份文件。

pg_dump -h localhost -U user -F c stock_db > /backup/stock_$(date +%Y%m%d).dump

除了全量备份,更稳妥的做法是结合WAL归档做增量备份,这样即使某天的全量备份损坏,也能恢复到最近任意时间点。当然,个人研究者不一定需要这么复杂,但至少做到“每天一次全量备份,备份文件定期拷贝到另外一台机器或对象存储”。

我在实际维护中还发现一个细节:备份要测试恢复。只备份不恢复等于没备份,我见过有人备份文件生成了半年,真正需要恢复时发现备份文件已经损坏。每隔一段时间,把最新备份恢复到一个临时库,验证数据完整性和可用性,这才是闭环。

最后再分享一个实用建议:把数据校验脚本和备份任务一起纳入定时调度,比如用系统自带的cron,每个交易日收盘后自动完成“增量更新 -> 质量校验 -> 物化视图刷新 -> 备份”这一整条流水线。这样一来,数据库就从一个需要手动维护的工具,真正变成了能自行运转的研究基础设施。数据库搭得好不好,不看你写了多少代码,而是看时间和精力是花在数据上,还是花在策略上。把数据底座打牢,后面才能腾出手来做真正有价值的研究。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进