Skip to content

ADR-0003: DataProvider 抽象

  • 状态:Accepted
  • 日期:2026-05-11

背景

v0.2.0 数据层是 core/data/schemas.py 里几个直接调 Tushare / akshare 的函数, 配 24h TTL 的 DuckDB cache。v0.2.0 上线第一周触发 5 起事故(见 tushare-integration-design §1):

  1. 永鼎股份 — 收盘后 24h cache 返回前日 close
  2. 璞泰来 — 从 ETF 持仓表读到了"价格"字段,语义不是 OHLC
  3. 大位科技 — 返回 6 天前的 close
  4. 浙文互联 — akshare 成交额 单位是元,被当成万元
  5. 浙文互联 — akshare 没返回 昨收 列,下游 KeyError

候选方案:

  1. 只缩短 TTL — 修事故 1 / 3
  2. 加一层 SourceRouter,每个 source 出独立函数 — 修 2 / 4 / 5 但容易回潮
  3. Provider ABC + Capability 枚举 + Composer 路由 — 全部 5 起 + 复权 + WarehouseProvider 钩子
  4. 换 LLM 自审 / 重试 — 不解决数据层 bug,把 cost 推给推理

决策

采用方案 3:DataProvider ABC + ProviderRegistry + QuoteComposer

  • 每个数据源实现 DataProvider ABC(base.py),声明 Capability 集合
  • ProviderRegistry 是单例注册表,lifespan 注入
  • QuoteComposer 持有 ROUTES 表(按 (intent, MarketState) 索引),按链顺序尝试 provider
  • 5 种 typed exception (ProviderUnavailable / ProviderQuotaExceeded / CapabilityNotSupported / CanonicalUnitViolation / ProviderTimeout) 决定 Composer 是降级还是直接抛
  • 单位 (yuan / hand / kyuan) 锁在 Quote dataclass,provider 边界负责把上游单位转 canonical
  • 复权 (raw / qfq / hfq) 通过 Quote property 计算,不在 provider 内做

实现见 PR #24(ABC)、#30(MarketStateResolver)、#32(TushareDailyProvider)、 #33(AkshareSpotProvider)、#34(QuoteComposer + ROUTES)。

理由

  • 强类型契约。 Quote 是 frozen dataclass;dict 不能越过 provider 边界。事故 5 自然消失
  • 强 capability。 Composer 只把请求路由到声明该能力的 provider。事故 2 不可能再发生
  • 强 canonical 单位。 Provider 负责单位转换,错则 CanonicalUnitViolation 内部抛。事故 4 不可能再静默通过
  • 强 market-state 感知。 路由 + cache TTL 都看 MarketState,事故 1/3 一并修
  • WarehouseProvider 钩子留好了。 W6 上线一份本地仓库实现,不动 Composer / 不动调用方

取舍

目录从 1 个文件 (schemas.py) 变成 1 个 package + 5 个文件

  • 缓解:每个文件单一职责 ≤200 行;现行 spec / ADR 直接指向文件

Composer 多 1 跳,latency 多 ~5-15ms

  • 缓解:W3 起 Composer 命中本地 QuoteCache,外层延迟仍 < v0.2.0 实测 p95 (~400ms)

Provider 注册写在代码里(lifespan register),不是 YAML 配置

  • 缓解:本来就是单租户 Python monolith;YAML 的 ergonomics 不抵 import-cycle 风险

Composer 默认"首个成功就 return",不主动跨源对账

  • 缓解:跨源对账放进 AuditLog (W7 TDX,见 ADR 未来)。监测告警,不自动选边

为什么不是其他方案

  • 只缩短 TTL:只修 5 起里的 2 起,剩下 3 起是结构性 bug
  • 每个 source 独立函数 + 调用方挑:又把"选哪个 source"的决定推回到 routes/price.py 和 skill 脚本,5 处不一致
  • LLM 自审 / 重试:编造数字 90% 能被代码兜住(见 ADR-0002);事故里没有一起是推理错

影响

  • core/data/schemas.py 现存函数标记为 v0.3.0 W6 删除(替代品:Composer + Cache)
  • /price route W4 切流时改成 app.state.composer.get_quote(...)(见 W4 runbook
  • src/service/cache.py (DailyCache / FundamentalsCache) 在 W6 删除,替换为 core/data/quote_cache.py
  • 所有新数据源必须实现 DataProvider ABC;不能再直接写"调用 X 库"的函数
  • AuditLog (PR #41) / cross-source diff (W7 TDX) 都挂在 Composer 边界,不在 provider 内

团队内部文档