ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从零搭建金融数据服务:自托管聚合与分发实战

从零搭建金融数据服务:自托管聚合与分发实战 1. 金融数据服务从零搭建的完整思路1.1 为什么我要自己动手做一套金融数据服务先说清楚这个项目到底在干什么。financial-services这个名字听起来很泛实际上我把它定位成一个面向个人开发者和小型团队的自托管金融数据聚合与分发服务。它做的事情不复杂把分散在不同渠道的行情数据、财报数据、汇率数据抓取回来做统一的清洗、存储、计算最后通过一套标准的 HTTP 接口吐给前端或者策略脚本用。你可能会问市面上现成的数据接口那么多为什么还要自己搭我踩过的坑是这样的免费接口有频率限制付费接口按调用次数计费一旦你的策略回测跑得频繁一点账单就失控了。更麻烦的是不同来源的数据字段命名、时间戳格式、复权方式全都不一样每次换个数据源就要重写一遍解析逻辑。所以这套服务的核心价值就三点——数据源可插拔、存储格式统一、查询接口标准化。适合谁来参考如果你写过 Python 脚本拉行情但每次都要手动处理缺失值和时区问题那这套东西能帮你省下大量重复劳动。如果你是小团队里负责数据基建的那个人这套架构也能直接拿去改。我不假设你有分布式系统的经验所有组件都是单机可跑的一台 2 核 4G 的云主机足够撑起日级别的数据服务。1.2 整体架构选型与背后的取舍逻辑架构上我走的是最朴素的采集层 → 存储层 → 服务层三段式。采集层用 Python 写因为金融数据的解析库生态最全pandas、akshare 这类工具能省掉大量造轮子的时间。存储层我选了 PostgreSQL 而不是 MongoDB原因很直接金融数据本质上是强结构化的时间序列字段固定、类型明确关系型数据库的约束和索引反而能帮我提前发现数据质量问题。时序数据库比如 InfluxDB 我也试过写入确实快但做多表关联查询比如把行情和财报按股票代码 join 起来的时候非常别扭最后还是回到了 PG。服务层用 FastAPI这个选择几乎没犹豫。它的异步特性在处理大量并发查询时表现稳定自动生成的 OpenAPI 文档省去了手写接口说明的功夫Pydantic 做数据校验也让返回格式不会乱。整个服务打包成 Docker 镜像用 docker-compose 编排一条命令就能拉起数据库和服务迁移到别的机器上也不用重新配环境。这里有个关键取舍我要说明白我没有做实时流式推送。原因是我服务的主要场景是日频和中频的策略研究对毫秒级延迟没有需求。如果你要做高频这套架构需要把采集层换成 WebSocket 长连接存储层换成专门的时序库那是另一个量级的工程。我选择把复杂度控制在“一个人能维护”的范围内这是小团队做基建最重要的原则——能跑起来、能看懂、能改得动比技术先进更重要。1.3 数据模型设计统一字段是整套服务的基石数据模型这块我花了最多时间。金融数据最烦人的地方在于同一个概念在不同数据源里叫法完全不同比如收盘价有的叫close有的叫closing_price有的叫last。我的做法是定义一套内部标准字段所有采集器在入库前必须把原始字段映射到标准字段上。核心表结构大致是这样一张instruments表存标的的基础信息代码、名称、市场、类型一张daily_quotes表存日频行情开高低收、成交量、成交额、复权因子一张fundamentals表存财务指标。三张表通过instrument_id关联。时间字段统一用 UTC 存储展示的时候再按需转换时区这样能避免夏令时切换带来的各种诡异问题。提示复权因子一定要单独存一列不要直接把复权后的价格写死。因为回测时你可能需要原始价格来计算真实成交也可能需要前复权价格来看趋势两种需求都存在。存因子、用时再算灵活性最高。字段类型上价格类用NUMERIC(18,6)而不是FLOAT这是血泪教训。浮点数在做累加和比较的时候会出现精度漂移比如 0.1 0.2 不等于 0.3 这种问题在计算收益率和持仓市值的时候会累积成肉眼可见的误差。用定点数虽然写入稍慢但数据准确性有保障。2. 采集层的核心细节与实操要点2.1 采集器的插件化设计采集层我设计成插件模式每个数据源对应一个独立的采集器类继承同一个基类。基类定义了fetch()、parse()、normalize()三个必须实现的方法。这样做的好处是新增数据源时只需要写一个文件不用动主流程代码。class BaseCollector: def fetch(self, **kwargs): raise NotImplementedError def parse(self, raw): raise NotImplementedError def normalize(self, parsed): raise NotImplementedErrorfetch负责网络请求parse负责把原始响应转成字典列表normalize负责字段映射和类型转换。主调度器只认这三个方法完全不关心底层是 HTTP 还是文件读取。我实测下来加一个新数据源的平均时间从原来的半天缩短到了一个小时以内。采集频率上日频数据我设定在每天收盘后两小时触发给数据源留出更新时间。调度用 APScheduler配置在数据库里而不是硬编码这样改时间不用重启服务。这里有个细节调度任务要加随机延迟比如设定 18:00 触发实际在 18:00 到 18:05 之间随机启动。原因是很多数据源在整点会有大量请求涌入错峰能显著降低超时概率。2.2 网络请求的稳定性处理金融数据接口的稳定性普遍一般超时、限流、返回格式突变都是家常便饭。我的处理策略是三层防护。第一层是重试机制用 tenacity 库做指数退避重试最多重试 5 次间隔从 1 秒开始翻倍。第二层是请求头伪装设置合理的 User-Agent 和 Referer模拟正常浏览器行为这不是为了绕过什么限制而是很多接口对默认的 Python 请求头直接返回 403。第三层是响应校验每次拿到数据先检查关键字段是否存在、行数是否在合理范围内如果某天返回的数据量突然只有平时的十分之一大概率是接口出了问题这时候要触发告警而不是直接入库。from tenacity import retry, stop_after_attempt, wait_exponential retry(stopstop_after_attempt(5), waitwait_exponential(multiplier1, min1, max30)) def safe_request(url, headers, params): resp requests.get(url, headersheaders, paramsparams, timeout15) resp.raise_for_status() return resp.json()超时时间我设的是 15 秒这个值是根据实测调的。太短了容易误杀正常请求太长了会拖慢整个采集批次。15 秒能覆盖 95% 以上的正常响应剩下的确实该重试。注意重试的时候要区分错误类型。网络超时和 5xx 错误值得重试但 4xx 错误比如参数错误、权限不足重试再多次也没用反而可能触发风控。我在重试装饰器里加了异常类型判断只对特定异常重试。2.3 数据清洗的实操细节采集回来的原始数据不能直接用清洗环节我固定做四件事。第一是去重按标的代码加日期做唯一性检查重复的直接跳过。第二是缺失值处理行情数据里如果某天没有记录我选择不填充保持缺失状态因为填充会引入虚假信息让回测结果失真。第三是异常值检测单日涨跌幅超过 30% 的记录会被标记出来人工复核虽然大部分是正常的比如新股上市首日但偶尔能抓到数据源本身的错误。第四是时区统一所有时间戳转成 UTC 再入库。清洗逻辑我写成了独立的管道函数每个函数只做一件事通过链式调用组合起来。这样调试的时候可以单独测试每个环节出问题也容易定位是哪个步骤的锅。def clean_pipeline(records): records deduplicate(records) records detect_outliers(records) records convert_timezone(records) return records3. 存储层与服务层的落地实现3.1 数据库表结构与索引优化PostgreSQL 的表结构我反复调整过几版最终定下来的方案是行情表按年份做分区。金融数据的特点是越老的数据查询频率越低分区之后查询近期数据时扫描的数据量大幅减少。分区键用交易日期每年一个分区跨年查询的时候 PG 会自动做分区裁剪。索引方面daily_quotes表上建了(instrument_id, trade_date)的复合唯一索引这个索引同时承担了去重和加速查询两个职责。另外单独建了trade_date的索引用于按日期批量拉取全市场数据的场景。我实测过不加索引的时候查一只股票一年的数据要 800 毫秒左右加了复合索引之后降到 15 毫秒以内差距非常明显。CREATE TABLE daily_quotes ( id BIGSERIAL, instrument_id INT NOT NULL, trade_date DATE NOT NULL, open NUMERIC(18,6), high NUMERIC(18,6), low NUMERIC(18,6), close NUMERIC(18,6), volume BIGINT, amount NUMERIC(20,2), adj_factor NUMERIC(12,6), PRIMARY KEY (id, trade_date) ) PARTITION BY RANGE (trade_date);写入的时候用INSERT ... ON CONFLICT DO UPDATE这样重复采集同一天的数据不会报错而是覆盖更新。这个策略在数据源修正历史数据的时候特别有用我只需要重新跑一遍采集任务数据就自动修正了。3.2 FastAPI 接口设计与参数校验服务层的接口设计遵循 RESTful 风格核心接口就三个/quotes/{symbol}查行情/fundamentals/{symbol}查财务/instruments查标的列表。每个接口都支持日期范围、字段筛选、分页这些通用参数。参数校验用 Pydantic 模型来做比如日期参数必须是合法的 ISO 格式symbol 必须匹配预设的正则模式。校验不通过直接返回 422 和具体的错误信息不会让脏参数进到查询逻辑里。这样做的好处是接口的健壮性大幅提升我压测的时候故意传各种畸形参数服务都能优雅地返回错误而不是崩溃。from pydantic import BaseModel, Field, validator from datetime import date class QuoteQuery(BaseModel): symbol: str Field(..., regexr^[A-Z0-9.]{1,12}$) start: date end: date limit: int Field(100, le5000) validator(end) def end_after_start(cls, v, values): if start in values and v values[start]: raise ValueError(end must be after start) return v分页我设了上限 5000 条防止有人一次性拉全量数据把数据库拖垮。如果确实需要大批量数据我另外提供了一个导出接口走后台任务生成 CSV 文件生成完给下载链接。这个设计把在线查询和批量导出分开互不影响。3.3 缓存策略与性能实测查询接口前面加了一层 Redis 缓存缓存键是查询参数的哈希值过期时间设 5 分钟。为什么是 5 分钟因为日频数据一天只更新一次5 分钟的缓存足以覆盖绝大部分重复查询同时保证数据更新后最多 5 分钟就能被查到新值。缓存命中率我监控了一段时间稳定在 70% 左右数据库的查询压力下降非常明显。性能实测数据单机 2 核 4G 环境下不带缓存查询单只股票一年日线数据约 250 条平均耗时 18 毫秒带缓存 3 毫秒。并发 50 个请求的情况下P99 延迟在 120 毫秒左右没有出现超时。这个性能对于个人研究和小团队内部使用完全够用。提示缓存键的生成要注意参数顺序。我一开始直接用字典的字符串表示做键结果{a:1, b:2}和{b:2, a:1}生成了两个不同的键缓存命中率上不去。后来改成对参数排序后再哈希问题解决。4. 常见问题与排查技巧实录4.1 采集任务失败的排查路径采集失败是最常见的问题我整理了一套排查顺序。第一步看日志里的异常类型如果是ConnectionError或Timeout基本是网络问题检查目标站点是否可达。第二步看 HTTP 状态码403 通常是请求头问题429 是触发了限流需要降低采集频率。第三步看返回内容如果状态码是 200 但数据为空可能是接口参数变了或者数据源本身当天没更新。我遇到过最诡异的一次是接口返回 200数据格式也正常但所有价格字段都是 0。排查了半天才发现是数据源在做维护返回了占位数据。从那以后我在清洗环节加了“全零检测”如果一批数据里超过 80% 的记录价格为零直接判定为异常批次不入库并触发告警。问题现象可能原因处理方式连接超时网络不通或目标站点故障检查网络稍后重试403 错误请求头被识别更新 User-Agent 和 Referer429 错误请求频率过高增加请求间隔降低并发数据为空接口变更或数据未更新核对接口文档确认更新时间价格全为零数据源维护中跳过该批次次日重试4.2 数据质量问题的发现与修复数据质量问题往往不会立刻暴露而是在你用它做计算的时候才显现。我养成了一个习惯每次采集完成后跑一遍质量检查脚本输出几个关键指标——当日记录数、缺失字段比例、价格范围分布。这些指标存到一张监控表里用 Grafana 画成趋势图。一旦某个指标偏离历史区间就能第一时间发现。有一次我发现某只股票连续三天的收盘价完全一样这在实际交易中几乎不可能。查下来是数据源那边把停牌期间的价格重复填充了。修复方式是在清洗环节加一条规则如果连续多日价格完全相同且成交量为零标记为停牌期间价格置空而不是保留重复值。另一个常见问题是复权因子突变。正常情况下复权因子是平滑变化的如果某天突然跳变要么是发生了拆股分红要么是数据源算错了。我的处理是设置一个阈值单日因子变化超过 20% 就触发人工复核确认是真实事件后才放行。4.3 服务部署与日常维护的经验部署这块我用 docker-compose 把 PG、Redis、FastAPI 三个服务编排在一起数据卷挂载到宿主机这样容器重建数据不丢。环境变量统一放在.env文件里数据库密码、Redis 地址这些敏感信息不写进代码。日常维护我做了两件事。一是每日备份用 pg_dump 导出数据库保留最近 30 天的备份文件脚本挂在 cron 里自动跑。二是日志轮转用 logrotate 配置日志文件按天切割、保留 14 天防止日志把磁盘写满。这两件事看起来简单但真出问题的时候能救命。我有一次误操作删了一张表靠前一天的备份十分钟就恢复了。注意备份文件一定要验证可恢复性。我见过太多人备份跑了大半年真要用的时候发现备份文件是空的或者损坏的。我的做法是每周抽一个备份文件在测试环境恢复一次确认流程走得通。4.4 扩展新数据源的实操清单当你需要接入一个新数据源时按这个清单走能少踩很多坑。先写一个最小的采集脚本只拉一只标的的一天数据确认能跑通。然后检查返回数据的字段和内部标准字段做映射缺的字段想清楚是留空还是用其他字段推算。接着写解析逻辑注意处理各种边界情况比如空值、字符串类型的数字、带单位的数值。最后把采集器注册到调度器先手动触发一次全量采集观察日志确认没有异常再开启定时任务。整个过程我建议在测试环境先跑一遍确认数据质量没问题再上生产。新数据源刚接入的第一周要重点监控因为很多问题只有在数据量上来之后才会暴露。我接入过一个数据源单只股票测试完全正常但全市场采集的时候发现它对超过 5000 条请求的响应会截断这个坑只有跑全量才能发现。5. 这套服务后续可以怎么扩展这套financial-services目前覆盖的是日频数据如果你需要分钟级数据采集层换成 WebSocket 订阅模式即可存储层把分区粒度从年改成月其他部分基本不用动。如果你需要做实时监控告警可以在服务层加一个 WebSocket 推送接口把价格突破阈值的事件实时推给客户端。我个人在实际操作中的体会是金融数据服务最难的不是技术实现而是数据质量的持续保障。接口会变、数据源会挂、格式会调整这些都是常态。所以整套系统的设计重心应该放在“出问题能快速发现、快速定位、快速恢复”上而不是追求一开始就完美。先把采集和存储跑通再逐步加监控和告警最后优化查询性能这个顺序比反过来做要顺畅得多。最后分享一个小技巧给每个采集批次打一个唯一的批次号从采集到入库全链路透传。这样一旦发现某天的数据有问题可以直接按批次号定位到是哪次采集、哪个数据源、哪个环节出的错排查效率能提升好几倍。这个习惯是我在处理一次跨月数据错乱时养成的当时如果没有批次号根本无从下手。
返回列表