docs(design): Provider Fetcher TET 化设计笔记——治兜底会乱

承接 OpenBB 调研的可落地设计:
- 实证本项目兜底坑(NaN当停牌/dbbardata双行/裸查询误报)
- TET三段式契约+fail-fast治兜底映射
- 本项目落地方案:extract读本地(绕过网络)+transform pydantic校验
- 明确不照搬OpenBB网络层/Registry/Router(KISS)
This commit is contained in:
2026-07-30 07:47:41 +08:00
parent 8862816557
commit 58e9a3b053
@@ -0,0 +1,178 @@
# 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 = **T**ransform-**E**xtract-**T**ransform。取数拆成三个职责单一的步骤(源自 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:当前兜底模式(取数 + 兜底混在一起)
```python
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)
```python
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`