Files
sanguo_vnpy_v2/docs/design/architecture/provider-tet-design.md
T
claude_dev 58e9a3b053 docs(design): Provider Fetcher TET 化设计笔记——治兜底会乱
承接 OpenBB 调研的可落地设计:
- 实证本项目兜底坑(NaN当停牌/dbbardata双行/裸查询误报)
- TET三段式契约+fail-fast治兜底映射
- 本项目落地方案:extract读本地(绕过网络)+transform pydantic校验
- 明确不照搬OpenBB网络层/Registry/Router(KISS)
2026-07-30 07:47:41 +08:00

8.7 KiB

Provider Fetcher 化设计笔记:用 TET 三段式治「兜底会乱」

设计日期 2026-07-30。来源:OpenBB Fetcher TET 三段式 + 本项目数据层历史踩坑。配套调研见 docs/research/openbb-platform-research.md

一句话定位:把数据层的容错「兜底」范式,改成 TET 三段式「严格校验、不行就报错」范式,让数据质量问题在取数时暴露,而不是被掩盖后在策略下单时记成大祸。


一、动机:本项目踩过的「兜底会乱」

本项目 provider 层(取数层)历史上为了「让数据能跑下去」做过各种容错/补默认/兜底。这些兜底掩盖了数据层的真问题,且兜底逻辑之间互相干扰、行为不可预测。三个真实实证:

实证 1:NaN 被当停牌 → 订单全取消

  • 数据层某些字段缺失,provider 兜底补了 NaN
  • 下游 bool(NaN) == True(Python 坑:NaN 布尔值为 True)
  • bullet_trade 策略判断 if paused: 取消订单全部订单被取消,0 交易
  • 兜底的 NaN 本意「没数据」,被误解成「停牌」

实证 2:dbbardata 双行

  • 数据层同一交易日存两行(日期格式:datetime 带时间 vs 纯日期)
  • 若在 provider 层「兜底去重」→ 掩盖「数据层为何有双行」这个真 bug
  • 正解是数据层根治(统一纯日期),不在 provider 掩盖

实证 3:裸查询误报

  • 调研时手搓 SQL symbol='000534' 查 dbbardata(实为双列 symbol+exchange)
  • 误报「19 只股票无日线」;实际调 provider.get_price 数据完整
  • 兜底/手搓查询的口径 ≠ provider 真实查询口径

「乱」的本质

  • 兜底逻辑散落在取数路径各处(补默认、转格式、去重、try-except 吞错)
  • 让「本该报错的脏数据」被悄悄修正 → 数据质量问题被掩盖,直到酿成大祸
  • 出了问题难定位:是数据脏?还是兜底错?还是两者叠加?

一句话:兜底 =「尽量让数据能用」(容错导向),代价是掩盖问题 + 行为不可预测。


二、TET 三段式设计契约

TET = Transform-Extract-Transform。取数拆成三个职责单一的步骤(源自 OpenBB Fetcher[Q, R]):

调用 get_price(symbol, start, end)
    │
    ▼
① transform_query(参数)          校验 + 补默认 + 翻译
    │  · 参数合法性(日期格式/symbol)
    │  · 补默认值
    │  · 通用参数 → 数据源特定参数
    ▼ 产出:QueryParams 对象
② extract_data(query)             ★唯一 IO 入口
    │  · 读本地 dbbardata/parquet(本项目已落库,不打网络)
    │  · 返回:原始数据(可能脏)
    ▼ 产出:raw data
③ transform_data(query, raw)      把脏数据洗成标准 + 校验
    │  · 字段映射(vendor 字段名 → 标准名)
    │  · 类型转换
    │  · pydantic 严格校验:不符合 schema 直接报错
    ▼ 产出:list[标准 Data 模型]

关键约束

  • ②是唯一 IO 点:所有读盘/网络调用只能在这里。好处:易测试(mock 掉②单测①③)、易缓存(只缓存②)
  • ①③是纯函数:给定输入确定输出,可缓存可重放
  • 职责单一:改「取数」不动「清洗」,改「清洗」不动「取数」
  • ③用 pydantic 校验 fail-fast:不符合就抛 ValidationError,不静默兜底

三、TET 如何治「兜底会乱」(问题 → 对策映射)

兜底会乱的问题 TET 对策
清洗逻辑散落各处 集中到 transform_data 一个钩子
脏数据静默补默认/吞错 pydantic 校验,报错 fail-fast
字段映射过程式 if-else 易错 声明式 __alias_dict__,一眼看全
取数与清洗混在一起 严格分离(extract 只取,transform 只洗)
难测试(要真打网络) extract 是唯一 IO,mock 它即可单测 transform
问题暴露晚(策略下单时才爆) 问题暴露早(取数校验时就报)

治本原理:把「尽量让数据能用」(容错)改成「严格校验、不行就报错」(fail-fast)。数据脏在取数时就暴露,而不是被兜底掩盖后在下游(下单、回测)记成大祸。


四、伪代码对比(直观)

Before:当前兜底模式(取数 + 兜底混在一起)

def get_price(symbol, start, end):
    df = read_dbbardata(symbol, start, end)   # 取数
    if 'close' not in df: df['close'] = np.nan # 兜底1:补 NaN ← 掩盖缺失
    df = df.drop_duplicates()                  # 兜底2:去重 ← 掩盖双行 bug
    try: df['date'] = pd.to_datetime(df['date'])  # 兜底3:吞错
    except: pass
    return df

问题:NaN 被下游当停牌、去重掩盖数据层 bug、except 吞掉真错误。

After:TET 化(职责分离 + fail-fast)

class PriceQueryParams(QueryParams):
    symbol: str
    start: date
    end: date

class PriceData(Data):
    date: date
    open: float | None
    close: float        # 必填 → 缺失直接 ValidationError,不补 NaN
    volume: float

class PriceFetcher(Fetcher[PriceQueryParams, list[PriceData]]):
    @staticmethod
    def extract_data(query) -> list[dict]:
        # 唯一 IO:读本地 dbbardata/parquet
        return read_dbbardata(query.symbol, query.start, query.end)

    @staticmethod
    def transform_data(query, raw) -> list[PriceData]:
        # 校验 + 映射,不符合就抛错
        return [PriceData(**row) for row in raw]
  • close 缺失 → pydantic 报错 → 数据层 bug 立刻暴露,不补 NaN 掩盖
  • extract 只读盘,transform 只校验,职责清晰可单测

五、本项目落地方案

现状

  • LocalUnifiedProvider 直读本地(dbbardata/parquet),符合 provider-local-data-only 铁律
  • 但取数与兜底/清洗逻辑混在 get_price/get_fundamentals 等方法里,散落容错

目标

每个数据源封装成一个 Fetcher,取数走三段式:

  • extract_data:读本地 dbbardata/parquet(绕过网络——这是本项目对 TET 的关键改造)
  • transform_data:pydantic 校验 + __alias_dict__ 字段归一,取代散落兜底

关键认知:本项目用 TET 但 extract 读本地

OpenBB 的 extract_data 打网络 API(实时点菜);本项目已落库,extract_data 内部读 sqlite/parquet(提前囤货)。TET 的精髓是「IO 集中」,不是「必须打网络」。所以本项目:

  • 借鉴:TET 三段式结构 + transform 的严格校验(治兜底)
  • 不照搬:OpenBB 的网络层、Registry/entry_points、Router、FastAPI(单仓库过度工程,违反 KISS/YAGNI)

不照搬清单(明确划界)

OpenBB 有 本项目是否需要 理由
Fetcher TET 三段式 借鉴 治兜底会乱,核心价值
__alias_dict__ 字段归一 借鉴 多源(baostock/akshare/miniQMT)字段统一
Registry / entry_points 不需要 单仓库单开发者,字典注册即可
Router 命令树 / FastAPI 不需要 策略层直调 provider,不给第三方 REST
MCP 多出口代码生成 不需要 工具 <30 不必,可单独按需暴露
网络实时取数 不照搬 本项目落库导向,extract 读本地

六、设计原则(可复用)

  1. fail-fast > 容错兜底:数据不符合 schema 就报错,不静默补默认。掩盖问题比报错危险得多。
  2. 数据质量问题在取数时暴露,不下沉:transform_data 是数据质量的「海关」,脏数据在这里被拦,不让它流到策略层。
  3. 数据层瑕疵报数据层根治,不在 provider 掩盖(呼应 feedback-no-provider-workaround):双行、格式不一、缺失——这些是数据层的 bug,治在数据层(如统一纯日期、补数),不在 provider 层兜底掩盖。
  4. IO 集中:所有读盘只在一个地方(extract_data),其余纯函数。便于测试、缓存、替换数据源。

七、改造优先级建议

  1. 先在数据质量最痛的入口试点:选一个出过坑的(如曾被 NaN 当停牌的行情取数),重构成 Fetcher 三段式,验证 fail-fast 能否抓住数据问题
  2. 定义本项目标准 Data 模型:参考 OpenBB standard_models,定义 PriceData/FundamentalsData/ConstituentData 等,字段必填/可选明确
  3. 逐步迁移:baostock/akshare/miniQMT/parquet 各源封装 Fetcher,统一走 transform_data 校验
  4. 保留现有 LocalUnifiedProvider 作为门面:内部委托给各 Fetcher,对外 API 不变(策略层无感)

相关

  • OpenBB 调研报告:docs/research/openbb-platform-research.md / wiki references/openbb-platform-research
  • 本项目实证坑:unified-provider-paused-nan-bug(NaN 当停牌)、dbbardata-dedup-pending(双行根治)、feedback-no-provider-workaround(数据层根治不在 provider 兜底)、feedback-verify-via-provider-exact-query(验证复刻 provider 确切查询)
  • provider 本地只读铁律:provider-local-data-only