1. 金融数据服务从零搭建的核心思路
1.1 为什么我要自己动手做一套金融数据服务
先说清楚这套东西是干什么的。financial-services这个项目,本质上是一个面向个人开发者和小型团队的自建金融数据聚合与分发服务。它要解决的问题很具体:当你需要做行情监控、量化策略回测、财务数据分析或者投资组合跟踪时,数据来源往往散落在十几个不同的地方——有的要付费、有的接口不稳定、有的返回格式乱七八糟。每次写新策略都要重新对接一遍数据源,这种重复劳动非常消耗精力。
我最初做这个项目的动机很朴素:手里同时跑着几个策略,一个看股票日线,一个盯基金净值,还有一个做宏观经济指标的定期抓取。每个脚本里都塞满了各种请求库、解析逻辑和异常处理,改一个字段名要翻五六个文件。后来我意识到,与其在每个策略里重复造轮子,不如把数据获取这件事抽出来,做成一个独立的服务层,统一对外提供干净的接口。
这套服务适合什么人参考?如果你是有一定编程基础的个人开发者,正在做量化交易、投资分析或者金融数据相关的副业项目,那这套思路可以直接拿去用。如果你是完全不懂代码的纯投资者,这篇文章可能偏技术了一些,但里面关于数据源选型和数据质量控制的思路,对你理解金融数据的来龙去脉也有帮助。
核心关键词financial-services贯穿整个项目,它不是一个具体的库或者框架,而是一种架构模式——把金融数据的获取、清洗、缓存、分发这几个环节解耦,让上层应用只关心“我要什么数据”,而不需要关心“数据从哪来、怎么拿”。
1.2 整体架构设计的取舍逻辑
在动手之前,我花了大概两周时间对比了几种方案。最省事的做法是直接用现成的金融数据 API 聚合平台,注册一个账号,调用统一的接口。但实测下来有几个问题:免费额度太少,稍微频繁一点的请求就会被限流;数据覆盖不全,一些小众市场或者另类数据根本没有;还有就是数据格式虽然统一了,但字段定义不符合我的使用习惯,每次都要做一层映射。
所以最终我选择了自建服务层。架构上分成四块:数据采集层负责从各个源头拉取原始数据;数据清洗层做格式统一、缺失值处理和异常值修正;缓存层用本地数据库做持久化,减少对上游接口的重复请求;接口层对外暴露统一的 RESTful 接口或者 Python SDK。
这个分层的好处是每一层都可以独立替换。比如某天某个数据源挂了,我只需要改采集层对应的适配器,上层完全不受影响。缓存层用 SQLite 起步,数据量大了可以平滑迁移到 PostgreSQL,接口层的代码一行都不用动。
注意:不要一上来就追求大而全。我见过不少人一开始就设计分布式架构、消息队列、微服务,结果光环境搭建就耗了一周,真正写业务逻辑的时间反而没多少。个人项目,单机跑得动就是好架构。
1.3 技术栈选型的实际考量
语言层面我选了 Python,原因很直接:金融数据处理生态最成熟。pandas 做数据清洗、numpy 做数值计算、requests 做网络请求,这些库的文档和社区支持都非常完善。如果你更熟悉 JavaScript,用 Node.js 也能做,但在数据处理这块会稍微吃力一些。
数据库方面,起步阶段我用的是 SQLite。很多人觉得 SQLite 太简单,不适合生产环境,但对于个人项目来说,它的优势非常明显:零配置、单文件、备份就是复制一个文件。日线级别的行情数据,几万条记录,SQLite 的查询性能完全够用。等到数据量上到千万级别,再考虑换 PostgreSQL 也不迟。
调度方面,我用的是 APScheduler,一个纯 Python 的定时任务库。相比 Celery 这种重量级方案,APScheduler 更轻量,配置简单,适合单机环境。每天收盘后自动拉取当日数据,凌晨做一次全量校验,这些任务用 APScheduler 管理起来很顺手。
接口层我用 FastAPI 搭了一个简单的 HTTP 服务。选 FastAPI 而不是 Flask 的原因有两个:一是它自带数据校验和文档生成,省去了不少样板代码;二是异步支持更好,虽然金融数据服务对并发要求不高,但异步写起来更舒服。
2. 数据采集层的核心细节与实操要点
2.1 数据源分类与适配器模式
金融数据源大致可以分成几类:行情数据(股票、基金、期货的实时和歷史价格)、基本面数据(财务报表、估值指标)、宏观经济数据(GDP、CPI、利率等)、另类数据(新闻情绪、社交媒体热度等)。每一类数据的获取方式、更新频率、数据格式都不一样。
我在采集层用了适配器模式。每个数据源对应一个适配器类,统一继承一个基类,基类定义了fetch()和parse()两个抽象方法。fetch()负责发起请求拿到原始数据,parse()负责把原始数据转换成内部统一的数据结构。这样新增一个数据源只需要写一个新的适配器,不用改动其他任何代码。
from abc import ABC, abstractmethod class DataSourceAdapter(ABC): @abstractmethod def fetch(self, **kwargs): pass @abstractmethod def parse(self, raw_data): pass这个设计看起来简单,但实际用起来非常灵活。比如某天我发现某个数据源的接口变了,只需要修改对应适配器的parse()方法,其他数据源完全不受影响。
2.2 请求频率控制与重试机制
金融数据接口通常都有频率限制,有的是每分钟多少次,有的是每天多少次。如果不做控制,很容易被封禁。我在采集层加了一个简单的令牌桶限流器,每个数据源配置独立的速率参数。
import time class RateLimiter: def __init__(self, max_calls, period): self.max_calls = max_calls self.period = period self.calls = [] def acquire(self): now = time.time() self.calls = [t for t in self.calls if now - t < self.period] if len(self.calls) >= self.max_calls: sleep_time = self.period - (now - self.calls[0]) time.sleep(sleep_time) self.calls.append(time.time())重试机制也很关键。网络请求失败是常态,尤其是免费接口,偶尔超时或者返回 5xx 错误很正常。我的做法是设置三次重试,每次间隔指数增长(1秒、2秒、4秒),如果三次都失败就记录日志并跳过,等下一个调度周期再试。
实操心得:重试的时候一定要区分错误类型。网络超时和连接错误可以重试,但如果是 401 未授权或者 404 资源不存在,重试多少次都没用,直接记录错误信息就好。
2.3 数据格式统一的字段映射
不同数据源返回的字段名千奇百怪。有的用trade_date,有的用date,有的用timestamp;有的价格字段叫close,有的叫closing_price,有的叫last。如果不做统一,上层应用每次都要写一堆映射逻辑。
我的做法是定义一套内部标准字段名,然后在每个适配器的parse()方法里做映射。标准字段包括:symbol(标的代码)、trade_date(交易日期)、open、high、low、close、volume、amount。所有数据源解析后都输出这个格式,上层拿到的数据永远是一致的。
STANDARD_COLUMNS = [ 'symbol', 'trade_date', 'open', 'high', 'low', 'close', 'volume', 'amount' ] def standardize(df, mapping): df = df.rename(columns=mapping) for col in STANDARD_COLUMNS: if col not in df.columns: df[col] = None return df[STANDARD_COLUMNS]这个映射表我建议写在配置文件里,而不是硬编码在代码中。因为数据源的字段名可能会变,写在配置文件里改起来方便,不用重新部署代码。
2.4 增量更新与全量校验的配合
数据采集不能每次都全量拉取,那样太浪费资源。我的策略是:日常调度只做增量更新,拉取最近几天的数据;每周做一次全量校验,检查历史数据是否有缺失或错误。
增量更新的逻辑是:先查本地数据库里该标的最新日期,然后只请求这个日期之后的数据。这样每天只需要拉取少量新数据,速度快、对上游压力小。
全量校验的逻辑是:对比本地数据记录数和理论交易日数量,如果差异超过阈值,就触发一次全量重新拉取。这个机制帮我发现过好几次数据源静默丢数据的问题——接口正常返回,但某些日期的数据就是缺失的,不做校验根本发现不了。
3. 数据清洗与缓存层的实现细节
3.1 缺失值处理的几种策略
金融数据缺失很常见,原因也多种多样:停牌、节假日、数据源本身的问题。处理方式不能一刀切,要根据缺失原因选择不同策略。
如果是非交易日导致的缺失,直接跳过就好,不需要填充。如果是停牌导致的缺失,通常保留空值,但在数据表中标记停牌状态。如果是数据源问题导致的缺失,可以尝试从备用数据源补拉,或者用前后交易日的均值做临时填充。
我在清洗层加了一个缺失值检测模块,每次数据入库前先跑一遍,输出缺失报告。报告里会列出哪些标的、哪些日期缺失,以及可能的缺失原因。这样我就能判断是需要补拉数据,还是正常现象。
def detect_missing(df, expected_dates): actual_dates = set(df['trade_date']) missing = set(expected_dates) - actual_dates if missing: return { 'missing_dates': sorted(missing), 'count': len(missing), 'ratio': len(missing) / len(expected_dates) } return None3.2 异常值识别与修正
异常值比缺失值更隐蔽,也更危险。一个错误的收盘价可能导致整个回测结果失真。常见的异常值包括:价格突然变成 0 或者负数、单日涨跌幅超过合理范围、成交量突然放大几百倍等。
我的做法是设置一套规则引擎,对每条数据做检查。比如收盘价必须大于 0,单日涨跌幅绝对值超过 20% 就标记为可疑(科创板、创业板可以放宽到 20%),成交量超过过去 20 日均值 10 倍就标记为异常。标记为异常的数据不会直接删除,而是写入一个异常表,人工复核后再决定是修正还是剔除。
注意:异常值检测的阈值不要设得太死。不同市场、不同品种的波动特征差异很大,用一套固定阈值很容易误伤。我建议按品种类型分别配置阈值,股票、基金、期货各有一套参数。
3.3 缓存策略与数据库设计
缓存层的核心目标是减少对上游接口的重复请求。我的策略是:所有拉取到的数据都持久化到本地数据库,上层查询时优先查本地,本地没有再回源。
数据库表设计上,我用了三张核心表:daily_price存日线行情,fund_nav存基金净值,macro_data存宏观经济指标。每张表都建了联合索引,比如daily_price上是(symbol, trade_date)的唯一索引,既保证数据不重复,又加速查询。
CREATE TABLE daily_price ( id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT NOT NULL, trade_date DATE NOT NULL, open REAL, high REAL, low REAL, close REAL, volume REAL, amount REAL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(symbol, trade_date) ); CREATE INDEX idx_symbol_date ON daily_price(symbol, trade_date);数据量大了之后,查询性能会下降。我的经验是,单表超过 500 万行就要考虑分区或者分表。SQLite 不支持分区,但可以用多个数据库文件按年份拆分。比如price_2023.db、price_2024.db,查询时根据日期范围路由到对应的文件。
3.4 数据版本管理与回溯
金融数据有个特点:历史数据可能会被修正。比如某公司后来发布了财务重述,之前的财报数据就变了。如果直接覆盖旧数据,就没法做历史回溯了。
我的做法是给每条数据加一个version字段和is_latest标记。新数据入库时,旧版本标记为is_latest = 0,新版本标记为is_latest = 1。查询时默认只查is_latest = 1的数据,需要做历史回溯时再查所有版本。
这个机制在调试策略时特别有用。有时候策略表现异常,查到最后发现是某个日期的数据被修正了,导致回测结果和实盘不一致。有了版本管理,就能清楚地看到数据是什么时候变的、变成了什么。
4. 接口层设计与上层应用对接
4.1 RESTful 接口的路径规划
接口层的设计原则是:路径清晰、参数简单、返回统一。我按数据类型划分路径,比如/api/price/daily查日线行情,/api/fund/nav查基金净值,/api/macro/cpi查 CPI 数据。每个接口都支持symbol、start_date、end_date三个核心参数。
返回格式统一用 JSON,结构固定为{code, message, data}。code为 0 表示成功,非 0 表示各种错误。data里是实际的数据列表。这样上层应用处理起来很省心,不用为每个接口写不同的解析逻辑。
from fastapi import FastAPI, Query from datetime import date app = FastAPI() @app.get("/api/price/daily") def get_daily_price( symbol: str = Query(..., description="标的代码"), start_date: date = Query(..., description="开始日期"), end_date: date = Query(..., description="结束日期") ): data = query_daily_price(symbol, start_date, end_date) return {"code": 0, "message": "success", "data": data}4.2 查询性能优化与分页
数据量大了之后,一次性返回所有数据会非常慢,而且可能撑爆内存。我的做法是强制分页,默认每页 500 条,最大不超过 2000 条。查询参数里加page和page_size,返回结果里带上total和has_next。
分页的实现要注意一点:排序字段必须是唯一的,否则分页结果可能重复或遗漏。我用(trade_date, symbol)作为排序键,保证顺序稳定。
def query_daily_price(symbol, start_date, end_date, page=1, page_size=500): offset = (page - 1) * page_size sql = """ SELECT * FROM daily_price WHERE symbol = ? AND trade_date BETWEEN ? AND ? AND is_latest = 1 ORDER BY trade_date DESC, symbol ASC LIMIT ? OFFSET ? """ return db.execute(sql, (symbol, start_date, end_date, page_size, offset))4.3 Python SDK 封装与使用示例
除了 HTTP 接口,我还封装了一个 Python SDK,方便在策略代码里直接调用。SDK 内部处理了请求重试、分页合并、数据格式转换等细节,用起来就像调用本地函数一样。
class FinancialDataClient: def __init__(self, base_url="http://localhost:8000"): self.base_url = base_url def get_daily_price(self, symbol, start_date, end_date): all_data = [] page = 1 while True: resp = requests.get( f"{self.base_url}/api/price/daily", params={ "symbol": symbol, "start_date": start_date, "end_date": end_date, "page": page, "page_size": 2000 } ) result = resp.json() if result["code"] != 0: raise Exception(result["message"]) all_data.extend(result["data"]) if not result.get("has_next"): break page += 1 return pd.DataFrame(all_data)这个 SDK 我用了大半年,最大的感受是:把复杂性封装在底层,上层用起来就非常清爽。策略代码里只需要一行client.get_daily_price(...)就能拿到干净的 DataFrame,不用关心分页、重试、格式转换这些琐事。
4.4 接口鉴权与访问控制
虽然是个人项目,但接口暴露在网络上就有安全风险。我加了一个简单的 API Key 鉴权机制:每个客户端分配一个 Key,请求时放在 Header 里,服务端校验通过才返回数据。
from fastapi import Header, HTTPException VALID_KEYS = {"your-secret-key-1", "your-secret-key-2"} def verify_key(x_api_key: str = Header(...)): if x_api_key not in VALID_KEYS: raise HTTPException(status_code=401, detail="Invalid API Key") return x_api_key如果服务只在本地局域网使用,可以不加鉴权,但一定要绑定127.0.0.1而不是0.0.0.0,避免被外部扫描到。我见过有人把没鉴权的数据库接口暴露在公网上,结果数据被爬了个精光。
5. 常见问题与排查技巧实录
5.1 数据源接口变更的应对流程
数据源接口变更是最让人头疼的问题,没有之一。表现通常是:之前跑得好好的采集任务突然报错,或者返回的数据字段对不上。我的应对流程分三步:确认现象、定位变更点、快速修复。
确认现象就是看日志,确定是请求失败还是解析失败。请求失败通常是 URL 变了或者参数格式变了;解析失败通常是返回结构变了。定位变更点需要手动调一次接口,对比返回结果和之前的记录。快速修复就是改适配器的fetch()或parse()方法,改完跑一次测试用例验证。
实操心得:给每个数据源写一个简单的冒烟测试,每天采集前先跑一遍。这样接口一变就能立刻发现,而不是等到数据缺失了才后知后觉。
5.2 数据库锁冲突与并发写入
SQLite 在并发写入时容易遇到database is locked错误。原因是 SQLite 默认的锁粒度是数据库级别,同一时间只允许一个写入操作。如果多个采集任务同时往同一个库里写,就会冲突。
解决方案有两个:一是把所有写入操作串行化,用一个队列管理,一个一个来;二是开启 WAL 模式,提高并发性能。
PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;WAL 模式下,读和写可以同时进行,只有写和写之间会互斥。busy_timeout设置成 5000 毫秒,遇到锁冲突时会自动等待重试,而不是立刻报错。这两个参数加上之后,我的采集任务再也没出现过锁冲突。
5.3 内存占用过高的排查与优化
数据量大了之后,Python 进程的内存占用会明显上升。我遇到过一次内存泄漏,跑了一周之后进程占了 4 个 G,最后被系统 OOM 杀掉了。排查下来发现是 pandas 的 DataFrame 没有及时释放,加上缓存层用了全局字典存数据,越积越多。
优化措施有三个:一是用del显式删除不再使用的 DataFrame,并调用gc.collect()强制回收;二是缓存层改用 LRU 策略,设置最大条目数,满了自动淘汰最久未使用的;三是采集任务分批处理,不要一次性把所有数据读进内存。
from functools import lru_cache @lru_cache(maxsize=128) def get_cached_data(symbol, date): return query_from_db(symbol, date)lru_cache是 Python 内置的装饰器,用起来非常简单,效果也很好。设置maxsize=128表示最多缓存 128 个结果,超出后自动淘汰。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 采集任务报错 401 | API Key 过期或无效 | 检查配置文件中的 Key | 更新 Key,重新启动服务 |
| 数据缺失某几天 | 数据源静默丢数据 | 跑全量校验脚本 | 从备用源补拉或手动补录 |
| 查询接口超时 | 数据量太大或索引缺失 | 用 EXPLAIN 分析查询计划 | 加索引或强制分页 |
| 内存持续增长 | 对象未释放或缓存过大 | 用 memory_profiler 分析 | 显式释放 + LRU 缓存 |
| 数据库锁冲突 | 并发写入冲突 | 查看日志中的 locked 错误 | 开启 WAL + busy_timeout |
| 数据格式错乱 | 数据源字段变更 | 对比新旧返回结构 | 更新适配器映射表 |
这张表是我踩坑之后整理的,基本上覆盖了 90% 的日常问题。遇到新问题我会先查表,表里没有的再从头排查,效率高很多。
5.5 日志记录与监控告警
日志是排查问题的第一手资料。我的日志分三个级别:INFO记录正常的采集和查询操作,WARNING记录重试和异常值,ERROR记录采集失败和接口错误。日志按天切割,保留最近 30 天。
监控告警方面,我加了一个简单的健康检查接口/health,返回数据库连接状态、最近一次采集时间、缓存命中率等指标。然后用一个定时脚本每分钟调一次,如果返回异常就发邮件通知。这个机制帮我及时发现过好几次采集任务卡死的问题。
@app.get("/health") def health_check(): db_ok = check_db_connection() last_fetch = get_last_fetch_time() cache_hit = get_cache_hit_rate() return { "status": "ok" if db_ok else "error", "db_connected": db_ok, "last_fetch": last_fetch, "cache_hit_rate": cache_hit }这套监控虽然简陋,但胜在实用。个人项目不需要复杂的监控系统,能及时发现问题、快速定位原因就够了。
5.6 数据备份与恢复策略
金融数据是核心资产,丢了就麻烦了。我的备份策略是:每天凌晨自动备份数据库文件到另一个目录,保留最近 7 天的备份。每周做一次全量备份,上传到云存储。
恢复流程也很简单:停掉服务,把备份文件复制回原位置,重启服务。整个过程不超过 5 分钟。我建议定期做一次恢复演练,确保备份文件真的能用,而不是等到需要的时候才发现备份是坏的。
# 每日备份脚本 cp /data/financial.db /backup/financial_$(date +%Y%m%d).db # 删除 7 天前的备份 find /backup -name "financial_*.db" -mtime +7 -delete这个脚本我放在 crontab 里,每天凌晨 3 点执行。简单粗暴,但非常可靠。
5.7 性能调优的几个实用技巧
最后分享几个性能调优的技巧。第一,批量插入代替逐条插入。SQLite 的逐条插入非常慢,用executemany批量插入能快几十倍。第二,查询时只取需要的列,不要SELECT *,减少数据传输量。第三,用连接池管理数据库连接,避免频繁创建和销毁连接的开销。
# 批量插入示例 data_tuples = [(row['symbol'], row['trade_date'], row['close']) for row in data] cursor.executemany( "INSERT OR REPLACE INTO daily_price (symbol, trade_date, close) VALUES (?, ?, ?)", data_tuples ) conn.commit()INSERT OR REPLACE是个很实用的语法,存在就更新,不存在就插入,省去了先查后写的麻烦。配合唯一索引使用,能保证数据不重复。
这套financial-services我从最初的一个脚本,慢慢迭代成了现在这个分层清晰的服务。中间踩过不少坑,也推翻过好几次设计,但最终的效果是值得的——现在写新策略,数据获取部分基本不用操心,专注在策略逻辑本身就好。如果你也在做类似的事情,希望这些经验能帮你少走一些弯路。