1. 金融数据服务从零搭建的核心思路
1.1 为什么我要自己动手做一套金融数据服务
先说清楚这套东西到底解决什么问题。我做量化策略研究有几年了,最开始用的是各种公开的数据接口,免费额度小、限流严重、字段残缺,最要命的是历史数据经常被悄悄修正,回测跑出来的漂亮曲线一到实盘就原形毕露。后来也试过买商业数据源,一年几万到几十万不等,对小团队和个人开发者来说成本太高,而且很多接口的文档写得跟天书一样,字段含义全靠猜。
所以我的思路很直接:自己搭一套可控的金融数据服务,把行情、基本面、宏观这几类数据抓下来、清洗干净、存进自己的数据库,再对外暴露统一的查询接口。这样数据口径自己说了算,历史快照可以留档,回测和实盘用的是同一份数据,心里踏实。
这套服务适合谁?一是做量化研究但预算有限的个人开发者;二是小团队想快速验证策略想法,不想在数据采购上耗时间;三是对数据管道、定时任务、API 设计感兴趣的后端工程师,拿这个当练手项目也很合适。技术栈上我选的是 Python 做采集和清洗,PostgreSQL 做存储,FastAPI 对外提供接口,调度用 APScheduler,整套东西一台 4 核 8G 的云主机就能跑起来。
1.2 整体架构怎么分层,为什么这么分
我把整个服务拆成四层,从下往上依次是数据采集层、数据清洗与存储层、服务接口层、调度与监控层。这个分层不是为了好看,而是每一层的职责边界清晰之后,出问题的时候能快速定位到底是采集挂了、清洗逻辑错了,还是接口响应慢。
采集层负责跟外部数据源打交道,包括 HTTP 请求、解析、重试、限流。这一层我刻意做成"可插拔"的,每个数据源一个 adapter,统一实现fetch()方法,返回原始 JSON 或 CSV。这样以后换数据源、加数据源,都不用动上层代码。
清洗与存储层拿到原始数据后做标准化:字段重命名、类型转换、缺失值处理、去重、复权计算,然后写进 PostgreSQL。这里有个关键决策——原始数据和处理后数据分开存。原始数据进一张raw_*表,几乎不做加工,保留最完整的信息;清洗后的进业务表。这样万一清洗逻辑有 bug,可以拿原始数据重跑,不用重新抓一遍。
服务接口层用 FastAPI,把数据库查询包装成 REST 接口,加上参数校验、分页、缓存。调度层用 APScheduler 管理各种定时任务,比如每天收盘后拉当日行情、每周更新一次财务数据。监控层最简单,就是日志加一个健康检查接口,任务失败发邮件告警。
提示:分层的时候一定要想清楚"数据流向",我见过不少人把清洗逻辑写在接口层里,结果每次查询都要实时计算,QPS 一高数据库直接跪。清洗是写时做的,查询是读时做的,这两件事必须分开。
1.3 技术选型背后的取舍逻辑
为什么用 PostgreSQL 而不是 MySQL 或者 MongoDB?金融数据有几个特点:强结构化、需要复杂查询、对一致性要求高。行情数据是典型的时间序列,但除了时间序列查询,我还经常要做多表关联,比如把行情和财务数据 join 起来算因子。PostgreSQL 在窗口函数、CTE、JSONB 支持上都很强,而且有 TimescaleDB 这种时序扩展,后期数据量大了可以平滑升级。MongoDB 虽然写入灵活,但做聚合查询和事务的时候会很难受。
为什么用 FastAPI 而不是 Flask 或 Django?FastAPI 原生支持异步、自带 Pydantic 做参数校验、自动生成 OpenAPI 文档,对于数据服务这种"接口多、参数复杂"的场景特别合适。而且它的性能在 Python 框架里属于第一梯队,配合 asyncpg 异步驱动,单机扛几千 QPS 没问题。
调度为什么不用 Celery?Celery 功能强大但太重,需要额外的 broker(Redis 或 RabbitMQ),对于这种"每天跑几个定时任务"的场景属于杀鸡用牛刀。APScheduler 轻量、纯 Python、支持 cron 表达式和持久化任务,够用了。当然如果任务量涨到几百个、需要分布式调度,那还是得上 Celery 或者 Airflow。
2. 数据采集与清洗的关键细节
2.1 采集层怎么设计才能既稳又不容易被封
采集层最容易踩的坑就是"请求太猛被限流"和"网络抖动导致数据缺失"。我的做法是给每个 adapter 配一个令牌桶限流器,控制每秒请求数,同时加上指数退避重试。
import time import requests from functools import wraps class RateLimiter: def __init__(self, rate_per_sec): self.rate = rate_per_sec self.tokens = rate_per_sec self.last = time.time() def acquire(self): now = time.time() self.tokens = min(self.rate, self.tokens + (now - self.last) * self.rate) self.last = now if self.tokens < 1: time.sleep((1 - self.tokens) / self.rate) self.tokens = 0 else: self.tokens -= 1 def retry_with_backoff(max_retries=3, base_delay=1): def decorator(func): @wraps(func) def wrapper(*args, **kwargs): for attempt in range(max_retries): try: return func(*args, **kwargs) except Exception as e: if attempt == max_retries - 1: raise delay = base_delay * (2 ** attempt) time.sleep(delay) return None return wrapper return decorator这段代码看着简单,但有两个细节值得说。第一,令牌桶的acquire()里我先补充令牌再判断,这样连续调用时不会因为时间精度问题误判。第二,重试的退避时间用2 ** attempt,第一次等 1 秒,第二次 2 秒,第三次 4 秒,给对端足够的恢复时间。
采集的时候还要注意请求头伪装。很多数据源会检查 User-Agent 和 Referer,直接用 requests 默认头很容易被识别。我一般会带上一个常见的浏览器 UA,并且把请求间隔随机化,比如限流设成每秒 2 次,但实际发送时在 0.4 到 0.6 秒之间随机,避免形成固定节奏。
注意:采集频率一定要克制。我见过有人为了"数据全"把请求间隔设成 0.1 秒,结果 IP 被封了一周。金融数据大部分是日频或分钟频,日频数据一天抓一次就够了,分钟频数据也要看你的策略需不需要,别为了囤数据把源头得罪了。
2.2 清洗环节最容易出错的几个地方
清洗是整套服务里最考验细心程度的部分。我总结了四个高频出错点,每一个都让我在回测里吃过亏。
第一是复权处理。股票除权除息之后价格会跳空,如果不做复权,算出来的收益率完全是错的。前复权、后复权、不复权各有用途:回测一般用前复权,因为要保持当前价格的真实性;算历史某一天的真实市值要用后复权。我的做法是同时存三份价格,用字段区分,查询的时候按需取。
第二是停牌和缺失值。A 股经常有停牌,停牌期间没有行情数据。如果直接 drop 掉这些行,时间序列就断了,算移动平均会错位。正确做法是保留日期、价格填前值,同时加一个is_trading标记位,让策略自己决定怎么处理。
第三是字段类型。从 JSON 解析出来的数字默认是 float,但价格、成交量这些用 float 存会有精度问题。金额类字段我统一用NUMERIC(20, 4),成交量用BIGINT,日期用DATE而不是字符串。这些在建模的时候就要定好,后期改类型非常痛苦。
第四是时区。金融数据的时间戳一定要明确时区。我统一用 UTC 存储,展示的时候再转成交易所所在时区。曾经因为时区问题,把美股的收盘时间算错了一天,回测结果差了 3 个百分点。
2.3 数据库表结构怎么设计才经得起折腾
表结构设计我遵循一个原则:宽表存快照,窄表存明细。行情数据用窄表,一行一个交易日;财务数据用宽表,一行一个报告期,把所有指标铺开。
-- 日行情表 CREATE TABLE daily_quotes ( id BIGSERIAL PRIMARY KEY, symbol VARCHAR(16) NOT NULL, trade_date DATE NOT NULL, open NUMERIC(20, 4), high NUMERIC(20, 4), low NUMERIC(20, 4), close NUMERIC(20, 4), adj_close NUMERIC(20, 4), volume BIGINT, amount NUMERIC(24, 4), is_trading BOOLEAN DEFAULT TRUE, created_at TIMESTAMP DEFAULT NOW(), UNIQUE(symbol, trade_date) ); CREATE INDEX idx_quotes_symbol_date ON daily_quotes(symbol, trade_date DESC);这里有几个设计考量。UNIQUE(symbol, trade_date)保证同一只股票同一天只有一条记录,配合ON CONFLICT DO UPDATE就能实现幂等写入,重复跑任务不会产生脏数据。索引建在(symbol, trade_date DESC)上,因为最常见的查询是"某只股票最近 N 天的行情",这个复合索引能直接命中。
财务数据表我用 JSONB 存非核心字段,核心字段(营收、净利润、ROE 等)单独建列。这样既保证了常用查询的性能,又能灵活容纳数据源新增的字段,不用每次都改表结构。
3. 服务接口与调度系统的落地实现
3.1 FastAPI 接口怎么设计才好用
接口设计我踩过的最大坑是"参数太随意"。早期我写的接口,日期参数一会儿是20240101,一会儿是2024-01-01,调用方天天来问。后来统一成 ISO 格式YYYY-MM-DD,用 Pydantic 做校验,传错格式直接返回 422 和清晰的错误信息。
from fastapi import FastAPI, Query, HTTPException from pydantic import BaseModel from datetime import date from typing import Optional, List app = FastAPI(title="Financial Data Service") class Quote(BaseModel): symbol: str trade_date: date open: float high: float low: float close: float volume: int @app.get("/api/v1/quotes", response_model=List[Quote]) async def get_quotes( symbol: str = Query(..., min_length=1, max_length=16), start: date = Query(...), end: date = Query(...), limit: int = Query(500, le=5000) ): if start > end: raise HTTPException(status_code=400, detail="start must be <= end") rows = await fetch_quotes(symbol, start, end, limit) return rows接口版本化用/api/v1/前缀,这样以后改接口不会影响老调用方。分页用limit加游标,不用 offset,因为 offset 在数据量大时性能会急剧下降。返回体统一用 Pydantic 模型,FastAPI 会自动生成文档,前端和策略代码都能直接参考。
缓存这块我用 Redis 做了一层。对于"最近 30 天行情"这种高频查询,缓存 5 分钟,命中率能到 80% 以上。缓存 key 用quotes:{symbol}:{start}:{end},失效策略是定时任务写完数据后主动删除相关 key,保证数据新鲜度。
3.2 定时任务怎么编排才不出乱子
定时任务的核心问题是依赖关系和失败重试。比如"计算复权因子"必须在"拉取除权除息数据"之后跑,"生成因子表"又必须在"行情和财务都更新完"之后跑。如果不管依赖,任务并发跑起来数据就乱了。
我的做法是用 APScheduler 的add_job配合max_instances=1防止同一任务并发,然后用一个"任务链"的方式串行编排:
from apscheduler.schedulers.blocking import BlockingScheduler from apscheduler.triggers.cron import CronTrigger scheduler = BlockingScheduler(timezone="UTC") def daily_pipeline(): try: fetch_daily_quotes() fetch_corporate_actions() compute_adj_factors() update_factor_table() clear_cache() except Exception as e: send_alert(f"daily pipeline failed: {e}") scheduler.add_job( daily_pipeline, CronTrigger(day_of_week="mon-fri", hour=22, minute=0), id="daily_pipeline", max_instances=1, misfire_grace_time=3600 )misfire_grace_time=3600这个参数很关键。如果服务器重启导致任务错过了执行时间,只要在 1 小时内,调度器还是会补跑一次。没有这个参数,错过的任务就直接丢了,第二天数据就缺一天。
任务失败告警我用的是邮件加日志。日志用结构化 JSON 格式,方便后期用 ELK 或者 Loki 做检索。告警邮件里带上任务名、失败时间、异常堆栈,收到邮件能直接定位问题。
3.3 数据一致性怎么保证
金融数据服务最怕的就是"数据不一致"——接口返回的行情和数据库里的对不上,或者因子表和行情表的时间范围不匹配。我的解决方案是引入数据版本号。
每次全量更新完数据,我会在data_version表里插一条记录,包含版本号、更新时间、覆盖的数据范围。接口返回数据时带上当前版本号,调用方可以据此判断数据是否更新。因子计算任务启动前先检查行情数据的版本号,如果版本不匹配就拒绝执行,避免用旧数据算新因子。
另一个手段是事务。清洗和写入放在同一个事务里,要么全成功要么全回滚。PostgreSQL 的 MVCC 机制保证了读操作不会被写操作阻塞,接口查询和后台更新可以并行不冲突。
提示:千万不要在业务代码里用
SELECT *,字段顺序一变就出问题。显式列出需要的字段,既安全又能利用覆盖索引提升性能。
4. 常见问题排查与实战避坑经验
4.1 采集环节的高频故障速查
采集出问题一般表现为"数据缺了"或"数据错了"。我整理了一张速查表,覆盖了 90% 的情况。
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 某天数据整体缺失 | 任务未执行或执行失败 | 查调度日志和告警邮件 | 检查 misfire_grace_time,手动补跑 |
| 个别股票数据缺失 | 该股票停牌或数据源未收录 | 对比交易所公告 | 标记 is_trading=false,保留日期 |
| 价格明显异常 | 复权因子计算错误 | 对比不复权价格 | 重算复权因子,检查除权数据 |
| 请求频繁超时 | 被限流或网络问题 | 看响应状态码和耗时 | 降低频率,增加重试和超时时间 |
| 字段解析报错 | 数据源字段变更 | 打印原始响应 | 更新 adapter 解析逻辑 |
这张表是我实际运维中一点点攒出来的。比如"价格明显异常"这一条,有一次我发现某只股票的前复权价格是负数,查了半天才发现是除权数据里有一条记录的分红金额填错了,导致复权因子算出来是负的。后来我在清洗逻辑里加了一个校验:复权因子必须在 0.01 到 100 之间,超出范围直接告警。
4.2 性能问题的定位思路
服务跑起来之后,最常见的性能问题是"接口变慢"。定位思路是从外到内:先看网络和网关,再看应用层,最后看数据库。
应用层我一般用py-spy做火焰图,能直接看出哪个函数耗时最长。数据库层开pg_stat_statements扩展,找出慢查询。我遇到过一次接口 P99 从 50ms 涨到 2s,最后定位到是一个没建索引的LIKE查询,数据量涨到千万级之后全表扫描了。加上索引之后立刻恢复到 30ms。
缓存穿透也是常见问题。如果查询一个不存在的股票代码,缓存里没有,每次都要查数据库。我的做法是缓存空结果,用一个特殊的空值标记,过期时间设短一点比如 1 分钟。这样既能挡住恶意查询,又不会因为数据源后来补录了数据而一直返回空。
4.3 我踩过的三个印象最深的坑
第一个坑是浮点数精度。早期我用 float 存价格,结果算收益率的时候出现0.1 + 0.2 = 0.30000000000000004这种问题,累积到几千次计算之后误差就很明显了。后来全部改成NUMERIC,Python 侧用Decimal,问题彻底解决。这个坑的教训是:涉及金额和价格,永远不要用浮点数。
第二个坑是时区。有一次做美股策略,我把所有时间都当成 UTC 处理,结果发现盘前盘后的数据混进了正常交易时段。美股交易时间是美东时间 9:30 到 16:00,对应 UTC 是 14:30 到 21:00(夏令时),我一开始按 13:30 到 20:00 算,差了一个小时。后来我在数据库里存 UTC,在业务层显式做时区转换,并且写了单元测试覆盖夏令时和冬令时的切换。
第三个坑是任务重复执行。有次服务器网络抖动,调度器以为任务失败了就重试,结果同一个任务跑了两遍,数据被写入了两次。虽然UNIQUE约束挡住了重复行,但复权因子被算了两次,导致数据错误。后来我给每个任务加了幂等设计:任务开始前先检查"今天的数据是否已经处理过",处理过就直接跳过。
4.4 数据质量监控怎么做才有效
数据质量监控不能只靠"任务成功"来判断,任务成功不代表数据正确。我加了三层校验:
第一层是行数校验。每天更新完,检查当天行情数据的行数是否在合理范围内。比如 A 股正常交易日应该有 4000 到 5000 行,如果只有 100 行,那肯定出问题了。
第二层是数值范围校验。价格必须大于 0,涨跌幅必须在 -20% 到 20% 之间(考虑 ST 股和科创板),成交量不能为负。超出范围的记录打上标记,人工复核。
第三层是跨表一致性校验。因子表里的股票代码必须在行情表里存在,财务数据的报告期必须和行情数据的日期范围有交集。这些校验用 SQL 就能做,跑完更新任务后自动执行,有问题就告警。
-- 行数校验示例 SELECT COUNT(*) AS cnt FROM daily_quotes WHERE trade_date = CURRENT_DATE - INTERVAL '1 day'; -- 数值范围校验示例 SELECT symbol, trade_date, close FROM daily_quotes WHERE trade_date = CURRENT_DATE - INTERVAL '1 day' AND (close <= 0 OR volume < 0);这套监控上线之后,我基本能在数据出问题的当天就发现,而不是等到回测结果异常才回头查。省下来的时间,够我多跑好几组策略参数了。
5. 服务扩展与长期维护的几点思考
5.1 数据量涨上来之后怎么扩展
一开始我的数据库只有几十万行,随便查都很快。跑到第三年,日行情表涨到了几百万行,加上分钟级数据,单表上亿行。这时候单机 PostgreSQL 开始吃力,查询延迟明显上升。
我的扩展路径是先分区,再分库,最后上时序数据库。第一步用 PostgreSQL 的原生分区,按年份把daily_quotes拆成多个子表,查询时带上年份条件就能只扫一个分区。第二步如果还不够,把历史数据和热数据分到不同实例,热数据放 SSD,历史数据放普通盘。第三步如果数据量到了十亿级,可以考虑 TimescaleDB 或者 ClickHouse,它们对时序数据的压缩和查询优化做得更好。
分区的时候有个细节:分区键要跟查询模式匹配。我的查询几乎都带日期范围,所以按trade_date的范围分区最合适。如果按symbol做哈希分区,范围查询就要扫所有分区,反而更慢。
5.2 接口安全怎么保障
数据服务虽然在内网用,但安全不能马虎。我做了三件事:API Key 认证、请求频率限制、敏感操作审计。
API Key 用 HMAC 签名,每个调用方一个 key,请求头里带上签名和时间戳,服务端校验签名并检查时间戳是否在 5 分钟内,防止重放攻击。频率限制用 Redis 的滑动窗口,每个 key 每分钟最多 600 次请求,超了返回 429。
审计日志记录所有写操作和敏感查询,包括调用方、时间、参数、返回行数。这些日志保留 90 天,出问题的时候能追溯。
5.3 文档和测试怎么维护
接口文档我用 FastAPI 自动生成的 Swagger UI,但光有自动文档不够,我额外写了一份"使用指南",用实际例子说明每个接口的典型用法、参数含义、返回字段解释。这份指南放在内部 Wiki 上,新同事上手基本不用问我。
测试分三层:单元测试覆盖清洗逻辑和复权计算,用固定的输入输出做断言;集成测试覆盖采集到入库的完整流程,用 mock 数据源;端到端测试覆盖接口调用,用真实数据库但小数据集。CI 里跑单元测试和集成测试,端到端测试每天定时跑一次。
这套东西维护了三年多,中间换过数据源、改过表结构、加过新接口,但因为分层清晰、测试覆盖到位,每次改动都很从容。我最大的体会是:金融数据服务的价值不在于技术多炫,而在于数据准、接口稳、出问题能快速定位。把这三件事做好,比堆一堆花哨的功能有用得多。