☰
从零搭建自托管金融数据服务:架构、采集、存储与接口设计
2026/9/28 23:18:29 网站建设 项目流程

1. 金融数据服务从零搭建的完整思路

1.1 为什么我要自己搭一套金融数据服务

先说清楚这个项目到底在干什么。financial-services这个名字听起来很泛,实际上我做的是一套面向个人开发者和小型团队的自托管金融数据聚合与分发服务。核心功能就三件事:从公开数据源定时抓取行情、财报、宏观经济指标;把原始数据清洗成统一格式存进本地数据库;对外暴露 REST 接口和 WebSocket 推送,供自己的量化脚本、看板或者小工具消费。

为什么不用现成的商业 API?我算过一笔账。主流行情数据接口按调用次数计费,一个中等频率的策略回测跑下来,光数据成本就能吃掉大半利润。而且很多接口对历史数据的深度有限制,分钟级数据往往只给最近几个月。自己搭一套,前期投入大概两三天,之后边际成本几乎为零,数据想存多久存多久,想怎么切怎么切。这套东西适合谁?有一定编程基础、想认真做量化或者金融数据分析、又不愿意被数据费用卡脖子的个人开发者。如果你只是想看看大盘指数,那直接用免费网页就够了,没必要折腾这个。

1.2 整体架构选型与背后的取舍

架构上我走的是极简路线,没有上微服务那一套。原因很直接:个人项目的流量和并发量根本撑不起微服务的复杂度,引入消息队列、服务发现这些组件只会让运维成本飙升。最终定下来的结构是四层:采集层、存储层、计算层、服务层。

采集层用 Python 写定时任务,APScheduler做调度,httpx做异步请求。选httpx而不是requests,是因为它原生支持 async,批量拉取几十个标的的行情时,并发效率比同步请求高一个数量级。存储层用 PostgreSQL,配合 TimescaleDB 扩展做时序数据。这里有个关键决策:为什么不用 InfluxDB 或者 ClickHouse?InfluxDB 在时序写入上确实快,但它的 SQL 支持弱,做关联查询(比如把行情和财报数据 join 起来)非常别扭。ClickHouse 查询性能强悍,但对个人项目来说资源占用偏高,单机跑起来内存吃紧。PostgreSQL + TimescaleDB 的组合兼顾了时序写入效率和标准 SQL 的灵活性,而且生态成熟,遇到问题好查资料。

计算层负责指标计算和数据对齐。不同数据源的时间戳精度不一样,有的给到秒,有的只给到天,必须统一到同一时间轴上。服务层用 FastAPI,自带 OpenAPI 文档,省去写接口文档的功夫。WebSocket 推送用websockets库单独起一个轻量服务,和 REST 接口解耦,避免长连接把主服务的线程池占满。

提示:架构选型的第一原则是匹配自己的实际负载。个人项目上重型组件,后期维护的时间成本远超收益。

2. 数据采集环节的核心细节与实操要点

2.1 数据源的选择与稳定性评估

数据源是整个服务的命脉,选错了后面全是坑。我评估数据源主要看四个维度:数据覆盖面、更新频率、接口稳定性、使用条款的宽松程度。覆盖面决定了你能做什么策略,更新频率决定了策略的时效性,稳定性决定了你要不要写一堆重试逻辑,使用条款则决定了你能不能合法地把数据用于自己的项目。

我实际测试过七八个公开数据源,最后保留了三类。第一类是官方交易所或监管机构提供的公开接口,这类数据最权威,但格式往往不统一,需要写适配器。第二类是聚合型数据服务,覆盖面广、格式统一,但免费额度有限,需要控制调用频率。第三类是财经资讯网站的公开页面,作为补充数据源,但要注意页面结构随时可能变,解析逻辑要写得足够健壮。

评估稳定性时我有个土办法:连续跑一周的采集任务,记录每次请求的成功率和响应时间,画成曲线看波动。如果某个数据源的成功率低于 95%,或者响应时间波动超过三倍,我就会把它降级为备用源。这个测试成本很低,但能避免上线后才发现数据源不可靠的尴尬。

2.2 采集任务的调度设计与频率控制

调度设计最容易犯的错误是把所有任务塞进一个定时器里。我一开始就是这么干的,结果每次采集高峰期数据库连接池直接被打满,其他任务全部超时。后来改成按数据源分组调度,每组独立控制并发数,问题就解决了。

具体做法是用APScheduler的BlockingScheduler,为每个数据源创建一个独立的 job,每个 job 内部用asyncio.Semaphore限制并发请求数。比如行情数据更新频繁,我设置每 5 分钟跑一次,并发数限制在 10;财报数据更新慢,每天凌晨跑一次,并发数限制在 3。这样即使某个数据源响应变慢,也不会拖垮整个系统。

频率控制还有个细节:很多数据源对请求频率有隐性限制,超过就会返回 429 或者直接封 IP。我的做法是在采集器里内置一个令牌桶限流器,每个数据源配置独立的速率参数。这个参数不是拍脑袋定的,而是根据数据源文档的说明,再留出 30% 的余量。比如文档说每分钟最多 60 次请求,我就设置成每分钟 40 次。实测下来,这个余量能有效避免触发限流。

import asyncio from aiolimiter import AsyncLimiter class DataCollector: def __init__(self, rate_limit: int, time_period: float = 60.0): self.limiter = AsyncLimiter(rate_limit, time_period) self.semaphore = asyncio.Semaphore(10) async def fetch(self, url: str): async with self.limiter: async with self.semaphore: # 实际的请求逻辑 pass

2.3 数据清洗与格式统一的实操方法

原始数据拿到手只是第一步,清洗才是真正花时间的活。不同数据源返回的字段名、时间格式、数值单位都不一样。比如有的用timestamp表示时间,有的用date;有的时间戳是秒级,有的是毫秒级;有的价格是字符串,有的是浮点数。如果不统一,后面查询和计算会痛苦不堪。

我的清洗流程分三步。第一步是字段映射,为每个数据源写一个映射配置,把原始字段名映射到统一的内部字段名。这个配置用 YAML 文件管理,改起来不用动代码。第二步是类型转换,所有时间字段统一转成 UTC 时区的datetime对象,所有数值字段统一转成Decimal类型。为什么用Decimal而不是float?因为金融计算对精度极其敏感,float的浮点误差在累加计算时会放大,Decimal能保证精确。第三步是异常值处理,比如价格出现负数或者单日涨跌幅超过 50%,这些数据要么是错误,要么是特殊事件,我会打上标记存起来,但不参与后续计算。

注意:清洗逻辑一定要写单元测试。我踩过的坑是,某个数据源悄悄改了字段格式,清洗代码没报错但数据全错了,直到一周后才发现。现在每个数据源的清洗函数都有对应的测试用例,每天采集完自动跑一遍。

3. 存储层设计与数据模型落地

3.1 时序数据表结构设计的关键决策

存储层的核心是表结构设计。金融数据本质上是时序数据,但又不是纯粹的时序数据,因为它还涉及标的的元信息、财报的关联关系等。我最终设计了三类表:标的元信息表、时序数据表、事件数据表。

标的元信息表存股票代码、名称、所属行业、上市日期这些不常变的信息。时序数据表存行情、指标这类按时间排列的数据。事件数据表存分红、拆股、财报发布这类离散事件。为什么要把事件数据单独拆出来?因为事件的查询模式和时序数据完全不同,混在一起会导致索引效率下降。

时序数据表用 TimescaleDB 的 hypertable 特性,按时间自动分区。分区间隔我设置成一个月,这个粒度是权衡的结果:太细会导致分区数量爆炸,太粗则查询时扫描的数据量太大。对于个人项目的数据量级,按月分区是比较舒服的选择。主键用(symbol, timestamp)复合主键,这样按标的和时间范围查询时能直接命中索引。

CREATE TABLE market_data ( symbol VARCHAR(20) NOT NULL, timestamp TIMESTAMPTZ NOT NULL, open DECIMAL(18, 6), high DECIMAL(18, 6), low DECIMAL(18, 6), close DECIMAL(18, 6), volume BIGINT, PRIMARY KEY (symbol, timestamp) ); SELECT create_hypertable('market_data', 'timestamp', chunk_time_interval => INTERVAL '1 month');

3.2 数据写入的批量优化与去重策略

写入性能是存储层的另一个关键点。逐条插入在数据量小的时候没问题,但当天数据积累到百万行级别,逐条插入会慢到无法接受。我的做法是批量插入,每批 1000 行,用execute_values或者COPY命令。实测下来,批量插入比逐条插入快 20 倍以上。

去重是必须处理的。采集任务可能因为重试或者调度重叠导致重复数据。我在表上建了唯一约束,插入时用ON CONFLICT DO NOTHING或者ON CONFLICT DO UPDATE。前者用于行情数据,因为同一时间点的数据应该是一样的;后者用于财报数据,因为财报可能修正,需要更新为最新值。

还有个细节是写入时间戳的处理。我额外加了一个created_at字段记录数据入库时间,和业务时间戳分开。这样排查问题时能清楚知道数据是什么时候进来的,而不是只知道数据对应的时间点。这个字段在调试采集延迟问题时特别有用。

3.3 数据保留与归档的自动化方案

数据不能无限存下去,尤其是分钟级数据,一年下来就是几千万行。我设计了一套分级保留策略:分钟级数据保留最近 3 个月,小时级数据保留最近 2 年,日级数据永久保留。这个策略是根据实际使用场景定的——回测高频策略用最近几个月的数据就够了,长期分析用日级数据。

归档用 TimescaleDB 的原生压缩功能,把 3 个月前的分钟级数据压缩存储,压缩率大概能到 90% 以上。压缩后的数据仍然可以查询,只是写入会被禁止。自动化用定时任务实现,每周日凌晨跑一次归档脚本,把符合条件的数据块压缩掉。这个操作对在线查询几乎没有影响,因为 TimescaleDB 的压缩是在 chunk 级别进行的。

4. 服务层接口设计与性能调优

4.1 REST 接口的路径规划与参数设计

服务层是这套系统对外的门面,接口设计得好不好直接决定了用起来顺不顺手。我遵循的原则是:路径表达资源,参数表达过滤条件。比如获取行情数据的接口是GET /api/v1/market/{symbol},查询参数用start、end、interval来控制时间范围和粒度。

参数设计上有个容易忽略的点:默认值的选择。start默认值我设成当天零点,end默认值设成当前时间,interval默认值设成1d。这样即使调用方什么参数都不传,也能拿到一份合理的默认数据,降低了使用门槛。另外所有时间参数都接受 ISO 8601 格式,也接受 Unix 时间戳,内部统一转换处理。

分页是必须的。行情数据动辄几千条,一次性返回会拖慢响应。我用limit和offset做分页,默认limit是 500,最大允许 5000。超过最大值的请求会被拒绝并返回明确的错误信息,而不是默默截断。这样调用方能清楚知道自己的请求是否被完整处理。

4.2 查询性能优化的具体手段

查询性能优化我做了三件事。第一是索引优化,除了主键索引,还在symbol和timestamp上分别建了索引,因为查询模式既有按标的查全部历史,也有按时间查所有标的。第二是查询缓存,对于不常变的数据(比如日级行情),用 Redis 缓存查询结果,缓存有效期设成 1 小时。第三是慢查询监控,所有执行时间超过 500ms 的查询都会被记录到日志,定期 review 并优化。

这里重点说下缓存策略。缓存 key 的设计很关键,我用md:{symbol}:{interval}:{start}:{end}作为 key,这样不同参数组合的查询结果互不干扰。缓存失效用主动失效加被动过期结合的方式:数据更新时主动删除相关 key,同时设置过期时间兜底。实测下来,加了缓存之后,重复查询的响应时间从 200ms 降到了 5ms 以内。

4.3 WebSocket 实时推送的实现细节

实时推送是这套服务的亮点功能。实现上用websockets库起一个独立服务,客户端连接后可以订阅特定标的的行情更新。推送逻辑是:采集层写入新数据后,通过 Redis 的 pub/sub 发一条消息,WebSocket 服务订阅这个消息并推送给对应的客户端。

连接管理上有个坑要注意:客户端可能因为网络问题断开但服务端不知道,导致连接泄漏。我的做法是加心跳机制,服务端每 30 秒发一次 ping,客户端必须回 pong,连续三次没回应就主动断开。同时限制单个 IP 的最大连接数,防止恶意连接耗尽资源。这些细节在文档里通常不会写,但不做的话服务跑几天就会出问题。

5. 常见问题排查与避坑经验实录

5.1 数据采集失败的排查思路

采集失败是最常见的问题,排查时我按这个顺序走:先看网络连通性,再看数据源是否改了接口,最后看自己的代码逻辑。网络问题好判断,直接 curl 一下目标地址就知道。接口变更比较隐蔽,通常表现为返回 200 但数据结构变了,或者返回 404。我的做法是每次采集后校验数据条数和字段完整性,异常时发告警。

有个坑我踩过两次:数据源在特定时间段(比如收盘后)会返回空数据,但 HTTP 状态码是 200。如果代码只判断状态码,就会把空数据当成正常数据存进去,导致后续计算出现缺口。现在的做法是加一层数据质量检查,如果某次采集的数据量比历史平均值低 50% 以上,就标记为可疑并告警。

5.2 数据库连接池耗尽的解决方案

连接池耗尽通常发生在采集高峰期。表现是请求全部卡住,日志里全是连接超时。根本原因是并发任务数超过了连接池大小。解决方案有两个方向:一是调大连接池,二是控制并发数。我倾向于后者,因为连接池不是越大越好,PostgreSQL 每个连接都有内存开销,连接太多反而会拖慢整体性能。

具体做法是给每个采集任务组分配独立的连接池,池大小根据任务的实际并发需求设置。比如行情采集并发高,给 20 个连接;财报采集并发低,给 5 个连接。同时设置连接的最大存活时间,避免长时间空闲的连接占用资源。这个调整之后,连接池耗尽的问题再没出现过。

5.3 时间时区处理引发的数据错乱

时区问题是金融数据里最隐蔽的坑。不同数据源用的时区不一样,有的用 UTC,有的用交易所所在地时区,有的用北京时间。如果不统一,跨数据源关联时就会出现时间对不上的情况。我的原则是:存储层一律用 UTC,展示层再根据用户需求转换。

转换过程中有个细节:夏令时。有些市场有夏令时切换,切换当天的时间处理特别容易出错。我的做法是用pytz或者zoneinfo库处理时区转换,不要自己手动加减小时数。另外所有涉及时间的比较和计算,都先转成 UTC 再操作,避免混用不同时区的时间对象。

常见问题典型表现排查方向解决方案
采集失败数据缺失、告警触发网络、接口变更、代码逻辑加数据质量校验,异常告警
连接池耗尽请求卡住、连接超时并发数超过池大小分组连接池,控制并发
时区错乱跨源数据时间对不上时区未统一存储统一 UTC,用库转换
写入缓慢采集任务积压逐条插入、索引过多批量插入,精简索引
缓存不一致查询结果过期失效策略不完善主动失效加被动过期

5.4 服务上线的检查清单与个人体会

上线前我一定会过一遍检查清单:数据库索引是否齐全、连接池配置是否合理、限流参数是否设置、告警是否配置、备份是否正常。这份清单是踩坑踩出来的,每一条背后都有一次事故。比如有一次忘了配告警,采集任务挂了三天才发现,数据缺口补起来非常麻烦。

最后分享一个个人体会:这套系统最大的价值不在于技术多先进,而在于它完全受我控制。数据怎么存、接口怎么设计、什么时候更新,全由自己决定。这种掌控感是使用商业服务给不了的。后续我打算加一个简单的回测框架,直接消费这套服务的数据,把从数据到策略的链路彻底打通。如果你也在做类似的事情,建议先把采集和存储做扎实,这两块稳了,上层应用怎么折腾都不会出大问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询