ARTICLE DETAIL

资讯详情

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

从零搭建金融数据服务:统一行情接入与指标计算实战

从零搭建金融数据服务:统一行情接入与指标计算实战 1. 为什么我会从零搭一套 financial-services 数据服务手头项目一多最怕的不是业务复杂而是同一套数据大家各算各的。我去年维护的两个项目一个是基金组合净值分析另一个是策略回测报表系统两边都要算日频收益、最大回撤、年化波动率。本来以为这类指标都是调个函数的事结果真去对报表的时候发现完全对不上A 项目的年化收益率用的是简单年化区间收益直接除以年份B 项目用的是复利年化两边算出来的数字差了一倍。用户拿着两份报告来质疑我只能反复解释口径不同但解释多了自己也觉得这事不能继续这么干。被折腾几轮之后我决定把散落在各处的行情接入、指标计算、缓存策略全部收敛成一个独立的服务也就是标题里的financial-services。它不做任何投资决策不预测涨跌只把三件事管清楚行情数据从哪来、指标怎么算、接口怎么给。所有业务方都通过这一套 API 拿数据拿到手的收益率、回撤、波动率都是同一个口径。这篇文章我会把这个服务的落地过程、关键技术选型、计算细节和踩过的坑完整记录下来给正在做类似数据服务或想统一团队技术口径的人一个参考。1.1 两个项目对不上的指标问题从哪来先说一个具体的场景。项目 A 是给内部投研做基金组合净值监控的项目 B 是给客户出策略回测报告的。两个项目各自对接行情接口各自写了独立的收益率计算逻辑。A 项目里的代码是这样写的def annual_return(total_return, years): return total_return / yearsB 项目里则是def annual_return(total_return, years): return (1 total_return) ** (1 / years) - 1这两个函数在区间收益 20%、时间 2 年的场景下算出来的结果分别是 10% 和 9.54%。看上去差得不算离谱但一旦把时间拉长到 5 年、收益改成 80%差距就到 16% 对 12.5%完全不是一个量级。更要命的是两个项目对区间收益的定义也不同一个用期末净值减期初净值再除以期初净值另一个用每日收益率的连乘累加。平时没人专门去核对一到出合并报告就露馅。我一开始的解决办法是建一个公共函数库让大家 import 同一个实现。但很快发现这也不行函数库只解决了算法一致的问题没有解决数据一致的问题。A 项目接的是不复权的日线B 项目接的是后复权的日线同一只股票同一段区间算出来的净值曲线就是不一样。函数库解决不了数据源差异、复权差异、停牌缺失这些更底层的问题。到这一步我才意识到真正需要的不是一个公共函数而是一个带数据接入和指标口径管理的独立数据服务。1.2 financial-services 到底该管什么事我给自己定的边界是三层数据接入层、指标计算层、服务输出层。数据接入层负责从上游拿行情做清洗、对齐、落库最终形成一套干净、连续的日频序列。指标计算层只依赖我们自己的数据库不直接接触上游原始接口所有业务指标都从这里输出。服务输出层对业务方暴露 REST API业务方不需要知道数据是哪个源接的、复权怎么处理的只要传 symbol 和时间区间就能拿到口径一致的指标。这样分层以后最大的好处是问题可以被定位到具体一层。以前两个团队对指标数字有疑问要先争论你的数据是不是有问题你的算法是不是写错了现在只需要问这一层的口径是不是符合约定。接入层数据不对查接入计算层口径不对查指标两边都对但结果不一样再查是不是缓存或时区问题。排查范围一下子缩小了很多。1.3 这套方案适合谁参考如果你只是写一个分析脚本自己用不需要搞这么重。但如果你是下面这几种情况这个架构思路会很有价值团队里有多个项目都在算行情类指标口径统一困难你需要给外部系统提供标准化的行情或绩效数据接口你正在搭一个数据中台或内部数据服务但不确定行情接入这块该怎么做抽象你想搞清楚复权、收益率、回撤、年化波动率这些指标背后的计算细节和潜在坑。接下来我按实际搭建顺序讲从数据接入开始到指标计算再到服务封装最后是上线后遇到的问题。2. 数据接入层行情源选型与统一数据模型2.1 为什么我选公开行情接口加本地落库的方案数据接入层的第一件事是选数据源。商业行情源当然好数据全、稳定、有技术支持但对个人项目和中小团队来说成本不低而且很多商业源的 License 对二次分发、缓存都有严格限制。所以我一开始就决定先用公开行情接口加本地落库的方案把源头数据是否有授权和内部服务是否可缓存这两个问题分开看。我实际用的是国内公开的行情接口主要是东方财富的公开接口和腾讯的行情接口。这类接口的特点是不需要复杂的鉴权流程、直接 HTTP 请求就能拿数据、覆盖 A 股和主要基金品种。缺点也很明显接口是网页端用的没有正式文档字段含义要靠自己试请求频率限制不透明高频请求容易被封历史数据深度不稳定回补太久远的日线偶尔会缺数据。所以我的结论是公开接口可以当数据入口但不能当数据服务。每一次拉到数据后都落到自己的 PostgreSQL 数据库里后续所有计算和对外服务全部基于本地库绝不实时请求上游。这样即使上游偶尔抖动我的服务已经拿到的历史数据不会受影响最多影响增量更新那一段。2.2 统一数据模型设计落库之前先要设计表结构。我没有用很复杂的范式核心就两张表一张是品种表instrument一张是日线表price_daily。instrument表用来描述一个可交易的标的我设计了这些字段字段类型说明idbigint自增主键symbolvarchar统一编码如600000.SHnamevarchar展示名称asset_typevarcharstock / fund / indexexchangevarcharSH / SZ / plainlist_datedate上市日期delist_datedate退市日期空表示仍在交易source_codejsonb各数据源对应的原始代码price_daily表是日频行情字段类型说明idbigint自增主键instrument_idbigint关联 instrument 表trade_datedate交易日opennumeric开盘价highnumeric最高价lownumeric最低价closenumeric收盘价volumebigint成交量amountnumeric成交额adjust_typevarcharnone / qfq / hfqsourcevarchar数据来源标识instrument_id trade_date adjust_type建唯一索引。这样设计的好处是同一个品种可以同时存复权和不复权的数据计算的时候按场景取用。后文会详细说为什么必须这样。2.3 统一 Symbol 映射第一个绕不开的坑不同数据源对同一只股票的编码几乎都不一样这是我接入过程中遇到的第一个实际问题。同一个平安银行东方财富的代码可能是001开头的一串内部 ID腾讯接口用的是sz000001CSV 导入的历史数据里又可能写成000001.SZ。如果不在接入层统一掉后面所有表关联都会变成灾难。我解决的方式是加一张独立的映射关系放在instrument.source_code这个 JSONB 字段里。接入模块拿到上游数据后第一件事不是入库而是先通过映射表找到统一的symbol。我封装了一个简单的 lookup 函数def resolve_symbol(source, raw_code): key f{source}:{raw_code} return instrument_cache[key]instrument_cache启动时从instrument表加载全量映射内存中维护source:raw_code - symbol的字典。新品种第一次出现时会走人工确认流程确认后写回数据库。这个设计让我不用每个数据源各写一套代码逻辑上游编码再怎么变我只改映射不动业务。3. 指标计算核心收益率、回撤与年化风险的实现细节3.1 复权方式所有收益指标绕不开的前提指标计算第一个要决策的问题是复权。A 股股票分红送股之后股价会出现一个向下的跳空缺口。如果你拿不复权的数据算收益分红当天会看到一个莫名的大跌最大回撤会被严重高估反之如果拿前复权数据算历史净值又会因为复权基准不断变化导致历史价格天天漂移。我最终的做法是存储层同时保留不复权和后复权数据计算层默认使用后复权数据展示层按需转前复权。为什么是后复权因为后复权价格是以上市首日为基准把历史价格统一向上调整所以整个序列是完整的、不随最新价格变化而变化的。净值曲线、收益率、回撤这类相对变化指标用后复权数据算最稳定。前复权方便看图因为它让最新价格贴近真实价格但缺点是每次有新分红送股历史上的所有价格都会被重新计算一遍缓存会失效。所以计算用后复权、展示用前复权是实践下来最舒服的搭配。3.2 净值曲线与最大回撤的落地实现有了日频后复权序列以后计算净值曲线其实很简单。给定一段区间把每日收盘价除以上市首日或区间首日的收盘价就得到归一化的净值序列。最大回撤的定义是在选定区间内从任意一个净值高点到之后任意一个净值低点的最大跌幅。注意这里的关键是高点必须在低点之前所以不能简单地用全局最大值减全局最小值。我的实现是从左到右维护一个滚动最高值每个时点计算当前值相对滚动最高值的回撤然后取最大值def max_drawdown(nav_series): peak nav_series.iloc[0] max_dd 0.0 peak_idx nav_series.index[0] trough_idx nav_series.index[0] for date, value in nav_series.items(): if value peak: peak value peak_idx date dd (peak - value) / peak if dd max_dd: max_dd dd trough_idx date return max_dd, peak_idx, trough_idx这段逻辑看起来简单但我在实现时踩过一个细节问题如果只用收盘价计算回撤日内回撤是看不到的。对低频策略或基金组合来说用日频收盘价算回撤是可以接受的但如果你做的是高频或日内交易就必须用分钟级数据否则回撤会明显偏小。除了最大回撤本身我还额外输出peak_idx和trough_idx也就是回撤起点和最低点日期。这个信息对业务方特别有用因为报告里除了最大回撤 15%通常还想知道是从什么时候开始回撤的、持续了多久。我顺便在这里提一句如果还要算回撤恢复天数最自然的做法是先找回到达回撤前高点的下一个日期再减掉peak_idx。3.3 年化收益与年化波动率参数细节比公式更容易错年化收益我统一用复利口径days (end_date - start_date).days annual_return (end_nav / start_nav) ** (365.0 / days) - 1这里使用了自然日 365 天来年化因为它跟年的时间跨度更一致。也有人用 252 个交易日年化那得到的是交易年年化收益两者在统计口径上本来就不同。我的建议是在一个服务里固定用同一个基准不要混用。年化波动率同样需要先确定基准。我用的是日频收益率的标准差乘以根号下 252daily_returns nav_series.pct_change().dropna() daily_std daily_returns.std(ddof1) annual_vol daily_std * math.sqrt(252)这里 252 是 A 股一年的近似交易日数量。之所以用 252 而不是 365是因为波动率的实质是价格在交易时间内的波动非交易日没有价格变化不应该被计入。用 365 会系统性低估真实的交易波动。还有一个容易错的地方是ddof。pandas默认的std()用的ddof1也就是样本标准差。如果你对全量历史数据做统计用总体标准差ddof0理论上更合适。但在金融序列里历史数据永远只是真实随机过程的一个样本所以ddof1是更稳妥的选择。这个细节平时不影响大局但真到了和别人对齐指标的时候就会暴露。4. 服务化封装REST API 设计与任务编排4.1 API 只暴露业务能听懂的口径指标计算层写完之后下一步是把它包装成服务。我在设计 API 时坚持一个原则接口不要暴露底层数据表的字段名要暴露业务语义。举个例子业务方要拿某只基金的绩效指标他不会关心你数据库里有没有price_daily表也不会关心adjust_type是hfq还是qfq。他只需要知道传一个symbol和一个时间区间返回区间内的收益、年化收益、波动率、最大回撤。所以我的接口设计如下GET /api/v1/performance?symbol600000.SHstart_date2023-01-01end_date2023-12-31返回{ symbol: 600000.SH, start_date: 2023-01-01, end_date: 2023-12-31, nav_series_end: 1.1523, total_return: 0.1523, annual_return: 0.1523, annual_volatility: 0.1842, max_drawdown: 0.0931, drawdown_start: 2023-05-16, drawdown_end: 2023-08-25 }另外还有一个批量接口接受symbols列表用并发方式并行计算多只标的的指标。批量接口一开始我没设计后来发现业务方总是循环调用单只接口不仅慢还会把自己打成限流对象。批量接口一次传几十个symbol服务端内部控制并发数效率和稳定性都好了很多。4.2 缓存策略不要让每次请求都穿透到数据库指标计算本身不贵但如果一个指标被十个业务接口同时依赖每次都重新从数据库读序列再算一遍数据库压力会很大。我的策略是分两层缓存。第一层是进程内缓存用lru_cache或本地字典存最近计算过的指标结果适用于热数据。缺点是多个服务实例之间缓存不一致但因为我们内部服务实例不多可接受。第二层是 Redis 缓存存的是序列级数据比如某个标的、某个复权类型的完整日频序列。指标计算逻辑先从 Redis 取序列取不到再查数据库查到后回填 Redis。设计完缓存后我在压测中发现10 个并发请求、每个请求计算 10 只标的一年日频数据Redis 命中率不到 60%。排查后发现是缓存 key 设计得太粗symbol没有任何日期维度导致不同区间的请求全部落到同一个 key 上一次大规模请求就把热 key 冲掉了。后来我把序列级缓存 key 改成按年拆分symbol:2023:hfq、symbol:2024:hfq命中率直接升到 95% 以上。4.3 增量更新与失败重试服务能对外稳定输出指标的前提是数据得持续更新。我设计了一个定时任务每天收盘后跑一次增量更新流程。增量更新的核心是一个交易日历表。交易日的判断不能简单用周一至周五因为法定节假日和调休日太多。我维护了一张trading_calendar表每年初始化下一整年的交易日数据来源可以从行情接口拉取也可以人工维护。增量更新时任务先读取日历表找到当前最新交易日和今天是否是交易日再对每个标的拉取缺失日期的数据。失败重试是我在接入层做得最多的工作。原因是公开行情接口偶尔会超时或返回 5xx。我使用的策略是单个标的拉取失败不中断整体任务记录失败列表整个任务跑完后对失败列表按指数退避重试第一次 30 秒后重试第二次 2 分钟第三次 10 分钟重试三次仍失败的写入告警表并通知值班群。幂等性也通过price_daily表的唯一索引保证同一天的数据插入时如果冲突就直接更新不会产生重复行。这套机制跑了大半年唯一一次真正需要人工介入的是一个连续停牌超过 20 天的标的增量拉取时数据源连续多天返回空数组重试逻辑一直按没有新数据处理后来我加了一个连续空响应次数的兜底才解决。5. 上线后绕不开的三个大坑完整排查链路这章我单独拉出来讲因为这三个坑不是模型设计问题也不是代码写得不对而是只有放到真实环境中才会暴露的边界问题。5.1 时区与交易日历判断今天是否开盘为什么不准第一个坑来自时区。我们的服务部署在国内服务器上默认是北京时间按说不会有时区问题。但我在做今日是否开盘判断时最初直接用datetime.now().weekday() 5判断周一至周五然后默认开盘。这个逻辑在普通日子没问题遇到五一、国庆、春节假期就完全错了。一开始我还以为是交易日历没同步好排查了很久最后发现问题的根源是我写的判断逻辑里根本没有查交易日历表用的只是星期几。这是个非常低级的错误但也很容易犯。后来我统一改成任何涉及是否是交易日的判断一律查trading_calendar表而不是依赖日期函数。为了当天数据能及时入库我还加了一个这样的小逻辑只在当前时间晚于 15:30 且今天是交易日时才触发收盘后的增量任务避免在交易中途拉取不完整的日线。5.2 停牌导致的 NaN 与缺口数据为什么突然少了一段第二个坑来自停牌。某只股票因为重大事项停牌十天这十天行情接口直接不返回任何数据而不是返回价格为 0 的记录。我的深度序列因此出现缺口后复权序列在停牌后恢复交易那天看起来像跳空高开或低开很影响回撤计算。排查链路是这样的业务方反馈某只基金的净值曲线在 6 月中旬出现了一段明显跳变我先是怀疑复权计算有误检查了除权除息事件对不上然后怀疑行情源漏数拉出原始接口数据对比发现接口本身就没有这十天最后才意识到这是停牌。处理方案是对停牌区间做推进法填充即用停牌前最后一天的收盘价填充缺失日期保证日频序列是连续的。这样做的好处是不会在净值曲线上制造虚假的涨跌。需要注意推进法填充只应该用于非交易导致的连续缺失如果是真正的数据漏拉填充会掩盖问题。我的做法是同时保留一个data_quality表记录每个标的每天的数据状态是真实交易、停牌填充还是缺失重试失败。这样后续排查时能区分计算问题和数据问题。5.3 前复权基准漂移历史指标为什么每天都在变第三个坑是最隐蔽的也是我花了最多时间才定位的。某天业务方反馈同一个标的、同一个区间昨天调用指标接口算出来的total_return是 0.1523今天再调用变成了 0.1518。没有改过代码没有改过数据库为什么结果变了我第一反应是数据被重复更新了去翻了price_daily表发现确实有新的数据落库但只是新增了今天的行情历史数据没有变化。然后我怀疑是 Redis 缓存失效导致重算但重算用的数据和昨天完全一样结果不该变。最后我把计算链路彻底走了一遍才发现问题出在取数逻辑上。我最初的设计是指标计算默认取前复权序列。而前复权是以最新价格为基准对历史价格进行调整所以今天新增了行情之后最新价格变了前两天新增了行情所有的历史前复权价格都会重新计算一遍指标自然就变了。定位后我调整了取数逻辑计算层一律使用后复权序列只有对外展示需要跟随最新价格的可视化接口才转前复权。改完这个逻辑后同一个区间的指标再也没出现过隔天变值的问题。6. 这套服务目前的边界以及如果要继续演进我会先做什么6.1 当前已经实现的、明确没实现的到这里这套 financial-services 已经稳定跑了大半年。数据接入层覆盖了 A 股个股、主要宽基指数和场内基金指标计算层支持区间收益、年化收益、年化波动率、最大回撤、回撤区间、日频净值序列服务层提供了单标的和批量指标接口缓存和更新任务也都按上面说的逻辑运转。但也得说清楚边界。它目前没有做分钟级行情、实时行情推送、基本面财务数据、持仓穿透分析、组合优化或风险归因。这些不是不需要而是它们各自是独立的大块头如果全塞进来这个服务又会变成一个大泥球。合理的演进方向是把它拆成多个子服务每个子服务只负责一个领域但在数据模型和接口风格上保持统一。6.2 如果让我重来一次会先改什么如果现在重新搭一遍我会优先把数据质量监控前置到架构里而不是后期补。现在的数据质量表是踩了停牌、复权、漏数这些坑之后才加的早期很多时间花在相信数据没错然后疯狂调试代码上。如果在接入层落库的那一刻就记录每个数据点的质量状态后期排查任何指标异常都会快很多。其次我会在一开始就把缓存 key 按时间维度切分做好。这个之前的教训已经写过序列级缓存不按年拆分热点数据一旦上来命中率立刻下降浪费大量内存还拖慢接口。6.3 给后来者的几条实操建议最后整理几条我实际用下来觉得最有价值的建议。第一不要在业务代码里散落指标计算逻辑。哪怕你只需要算一个收益率也把它收到一个统一模块里因为口径一致这件事靠约定是守不住的只有代码结构上强制收敛才行。第二数据源接口再简单也要包一层适配。不要直接在业务代码里裸调第三方行情接口否则上游换了字段名或接口地址你要改的地方可能有一百处。第三日频数据的存储不要嫌浪费大不了多存几个复权类型。空间永远比排查一个诡异 bug 便宜尤其是在金融数据这种算错一次影响很大的领域。第四任何缓存都要考虑失效场景。数据行情这种新增一天历史价格变一次的场景非常特殊你之前学的缓存直到过期的经验在这里不够用一定要想清楚数据更新的语义对缓存 key 的影响。这套服务现在的状态对我来说更像一个平台的地基有了统一的数据层、稳定的指标口径和可扩展的接口风格后面要接任何新的业务分析需求都不用再从万恶的 Excel 和散落的脚本开始。做完这件事最直接的体会是好的数据服务不是功能有多花哨而是让团队里每个人拿到的数字都经得起互相核对。
返回列表