docs(design): Provider Fetcher TET 化设计笔记——治兜底会乱
承接 OpenBB 调研的可落地设计: - 实证本项目兜底坑(NaN当停牌/dbbardata双行/裸查询误报) - TET三段式契约+fail-fast治兜底映射 - 本项目落地方案:extract读本地(绕过网络)+transform pydantic校验 - 明确不照搬OpenBB网络层/Registry/Router(KISS)
This commit is contained in:
@@ -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`
|
||||
Reference in New Issue
Block a user