你有没有想过,你在炒股软件上看到的价格,和量化机构看到的价格,可能差了好几秒甚至几百毫秒?这中间差的,不只是钱,还有一套复杂的数据中台。
量化公司最头疼的事不是策略写不出来,而是不同部门用的行情数据源不一样------交易组用Polygon,风控组用yfinance,报表组手动导CSV。结果就是:同一个收盘价,三个部门算出三个数。
从零搭了一套统一的行情数据中台,需要花费不少时间精力。今天这篇文章,把里面的架构设计、代码实现、踩坑经验全部分享出来。无论你是量化从业者、后端工程师,还是好奇机构怎么玩数据的散户,都能从中找到有价值的东西。
本文核心内容:
- 三层解耦架构:采集层 → 缓存层 → 服务层
- 适配器模式统一8种数据源(含代码)
- 两级缓存(Caffeine+Redis)实战配置
- 限流熔断保护数据源不被封
- 避坑指南:缓存穿透/雪崩/击穿、本地缓存不一致、时区混乱
这篇文章能带给你什么?
如果你是普通投资者
- 看懂数据差异:为什么同花顺和雪球显示的价格不一样?背后可能用了不同数据源,且没有统一中台。
- 评估平台质量:如果一个App数据更新慢、经常卡顿,大概率是缓存和限流没做好。
- 自己动手:你可以用几十行代码,聚合多个免费数据源(如yfinance+akshare)做交叉验证,避免单一数据源出错。
- 理解机构优势:量化团队花大精力搭中台,就是为了抢那几毫秒的延迟和数据一致性,这是散户难以做到的。
如果你是量化团队的一员
| 角色 | 你关心什么 | 这篇文章能给你什么 |
|---|---|---|
| 后端/平台工程师 | 架构怎么搭?代码怎么写? | 适配器模式实现、两级缓存配置、限流熔断代码、一键切换数据源 |
| 量化策略师 | 数据准不准?回测与实盘对得上吗? | 理解数据源差异对策略的影响,知道如何通过中台保证口径一致 |
| 数据工程师 | 清洗逻辑、数据质量怎么保障? | 采集层如何统一字段、时区、复权,缓存策略如何影响数据新鲜度 |
一、整体架构:三层各干各的活
| 层级 | 名字 | 负责什么 | 生活比喻 |
|---|---|---|---|
| 第一层 | 采集层 | 连接不同数据源,把乱七八糟的格式统一成标准样子 | 不同品牌的充电头,统一转成Type-C |
| 第二层 | 缓存层 | 把热数据暂存起来,下次请求秒回 | 冰箱:提前把菜做好,饿了一热就能吃 |
| 第三层 | 服务层 | 对外提供统一的API,管流量、防崩溃 | 餐厅前台:你点菜,后厨做,前台端给你 |
架构流向:
业务线(策略/风控/报表)
↓
【服务层】REST API + 限流熔断
↓
【缓存层】本地缓存(Caffeine) + Redis
↓
【采集层】适配器:Polygon / yfinance / TickDB / ...
↓
外部数据源
二、采集层:用"转换头"统一所有数据源
2.1 为什么要适配器?
不同数据源的"接头"形状不一样:
| 数据源 | 返回格式 | 字段名示例 | 时间戳 | 清洗工作量 |
|---|---|---|---|---|
| yfinance | DataFrame | Open, High, Adj Close |
美东时间datetime | 中(时区、复权) |
| Polygon | JSON | o, h, l, c, v |
UTC毫秒 | 低(字段映射) |
| TickDB | JSON | open, high, close |
UTC毫秒 | 极低(已标准化) |
| 东方财富 | JSON/CSV | f2, f3, f4 |
字符串 | 高(需逆向) |
适配器的任务:把这些都转成公司内部的标准格式。
2.2 定义统一接口(Java示例)
java
public interface MarketDataProvider {
Quote getQuote(String symbol);
List<Kline> getDailyKlines(String symbol, LocalDate start, LocalDate end);
Map<String, Quote> getQuotes(List<String> symbols);
}
2.3 各数据源适配器工作量对比(行业通用估算)
| 数据源 | 接入复杂度 | 主要处理工作 | 适配器代码行数 |
|---|---|---|---|
| yfinance | 低 | 时区转换、复权、列名映射 | ~50 |
| TickDB | 极低 | 几乎无需额外处理 | ~40 |
| Polygon | 中 | 字段映射(o→open) | ~80 |
| 东方财富 | 高 | 解析HTML、反爬、字段猜测 | ~200+ |
2.4 一键切换数据源
通过配置文件,业务层无感知切换:
yaml
market:
data:
provider: tickdb # 改成 polygon 即可换源
java
@Configuration
public class MarketDataConfig {
@Bean
@ConditionalOnProperty(name = "market.data.provider", havingValue = "tickdb")
public MarketDataProvider tickdbProvider() { return new TickDBAdapter(); }
@Bean
@ConditionalOnProperty(name = "market.data.provider", havingValue = "polygon")
public MarketDataProvider polygonProvider() { return new PolygonAdapter(); }
}
💡 架构师笔记:适配器模式的核心是"依赖倒置"------业务层依赖抽象接口,不依赖具体数据源。这样,更换数据源只需修改配置文件,无需改动一行业务代码。
三、缓存层:让数据"秒回"的秘密
3.1 为什么需要缓存?
假设你的策略每秒请求1000次实时报价。如果每次都去调用数据源API:
- 慢:网络延迟+数据源处理,50-100ms
- 贵:很多数据源按调用次数收费
- 可能被封:超过限流阈值直接429
缓存:把最近请求的数据暂存起来,下次直接返回,延迟降到1ms以内。
3.2 两级缓存策略(行业通用设计)
| 缓存级别 | 存储位置 | 过期时间 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|---|---|
| L1 本地缓存 | JVM内存(Caffeine) | 1-5秒 | 极高频访问的热点股 | 超快(微秒级) | 多实例间不一致 |
| L2 分布式缓存 | Redis | 30-60秒 | 全量缓存,多实例共享 | 一致性好 | 稍慢(毫秒级) |
查询流程:
请求 → 查L1本地 → 命中返回
↓ 未命中
查L2 Redis → 命中返回并回填L1
↓ 未命中
查数据源 → 返回并回填L2和L1
3.3 三大缓存坑及解法
| 坑 | 描述 | 解决方案 |
|---|---|---|
| 缓存穿透 | 请求不存在的数据(如退市股票),每次都穿透到数据源 | 缓存空对象,过期时间短(如30秒) |
| 缓存雪崩 | 大量缓存同时过期,瞬间流量打爆数据源 | 过期时间加随机偏移(如30~60秒) |
| 缓存击穿 | 热点数据过期瞬间,大量请求同时打到数据源 | 使用互斥锁,只让一个线程去加载 |
代码示例(Caffeine + Redis 两级缓存):
java
@Service
public class CachedMarketDataService {
private final MarketDataProvider provider;
private final RedisTemplate<String, Quote> redisTemplate;
private final Cache<String, Quote> localCache;
public CachedMarketDataService(MarketDataProvider provider) {
this.provider = provider;
this.localCache = Caffeine.newBuilder()
.expireAfterWrite(5, TimeUnit.SECONDS)
.maximumSize(10000)
.build();
}
public Quote getQuote(String symbol) {
// 1. 本地缓存
Quote quote = localCache.getIfPresent(symbol);
if (quote != null) return quote;
// 2. Redis 缓存
quote = redisTemplate.opsForValue().get("quote:" + symbol);
if (quote != null) {
localCache.put(symbol, quote);
return quote;
}
// 3. 回填数据源
quote = provider.getQuote(symbol);
redisTemplate.opsForValue().set("quote:" + symbol, quote, 30, TimeUnit.SECONDS);
localCache.put(symbol, quote);
return quote;
}
}
💡 架构师笔记:本地缓存大小建议设置为活跃股票数的1.5倍(例如监控2000只股票,设置3000条)。Redis缓存建议开启持久化,防止重启后缓存雪崩。
四、服务层:对外统一API,对内保护数据源
4.1 统一API设计
| 端点 | 方法 | 说明 | 示例 |
|---|---|---|---|
/v1/market/quote/{symbol} |
GET | 单只股票实时报价 | /quote/AAPL.US |
/v1/market/quotes |
POST | 批量获取报价(最多100只) | body: ["AAPL","MSFT"] |
/v1/market/kline/{symbol} |
GET | 历史K线 | /kline/AAPL?from=2025-01-01 |
| 字段筛选 | 参数 | 只返回需要的字段 | ?fields=open,high,close |
4.2 限流与熔断(Resilience4j配置示例)
限流:限制每秒最大请求数,防止超过数据源配额。
熔断:当数据源连续失败(如超时、5xx),自动打开断路器,直接返回缓存或降级数据。
java
@Bean
public CircuitBreaker circuitBreaker() {
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.failureRateThreshold(50) // 失败率超过50%触发熔断
.waitDurationInOpenState(Duration.ofSeconds(60)) // 熔断60秒后尝试半开
.build();
return CircuitBreaker.of("marketData", config);
}
@Bean
public RateLimiter rateLimiter() {
RateLimiterConfig config = RateLimiterConfig.custom()
.limitForPeriod(100) // 每秒最多100个请求
.limitRefreshPeriod(Duration.ofSeconds(1))
.build();
return RateLimiter.of("marketData", config);
}
| 场景 | 动作 | 恢复条件 |
|---|---|---|
| 限流触发(超过100 req/s) | 返回429,等待重试 | 下一秒自动恢复 |
| 熔断触发(失败率>50%) | 直接返回缓存或错误,不再调用数据源 | 60秒后尝试半开,成功则关闭 |
💡 架构师笔记:限流阈值应设置为数据源允许QPS的80%,预留缓冲。熔断器的失败率阈值不宜过低(如10%容易误触发),50%是较合理的起点。
五、实战要点与避坑指南
| 问题 | 现象 | 解决方案 |
|---|---|---|
| 数据源切换后字段不匹配 | 代码报错,字段找不到 | 适配器必须做字段映射,不能假设字段名一致 |
| 时区混乱 | 同一时刻不同源时间戳差几小时 | 统一转为UTC毫秒,所有比较基于UTC |
| 缓存击穿导致数据源过载 | 开盘瞬间API返回429 | 开盘前预热缓存,或使用互斥锁 |
| 熔断后无法自动恢复 | 数据源恢复后服务仍不可用 | 配置合理的半开状态试探间隔 |
| 多实例本地缓存不一致 | 同一股票不同实例返回不同价格 | 使用Redis Pub/Sub广播失效消息;或对一致性要求高的场景降级为Redis单层缓存 |
💡 扩展阅读:WebSocket 实时流的处理
本文示例以 REST API 拉取为主,适用于轮询场景。对于极低延迟的实盘交易,通常需要 WebSocket 网关(如 Netty)处理实时推送。此时,统一适配器模式仍然适用,只需将底层数据源从 REST 切换为 WebSocket 即可,上层业务代码完全无感知。
六、总结:一张表看懂行情数据中台
| 层级 | 核心职责 | 关键技术 | 对普通投资者的启发 |
|---|---|---|---|
| 采集层 | 统一数据源格式 | 适配器模式 | 不同数据源返回格式不同,需要自己清洗 |
| 缓存层 | 加速访问、降低成本 | Caffeine + Redis | 自己写脚本时可以用本地缓存减少重复请求 |
| 服务层 | 统一API、限流熔断 | Spring Boot + Resilience4j | 理解为什么有些API会返回429 |
如果你自己搭建类似的中台,选择一个接口规范、文档清晰的数据源能省很多事。例如 TickDB 提供统一的REST和WebSocket接口,字段、时间戳、单位都已标准化,接入成本很低。免费注册即可体验。
本文纯技术分享,提到的数据源均为公开服务,不构成任何投资建议。市场有风险,投资需谨慎。