diff --git a/docs/design/architecture/provider-tet-design.md b/docs/design/architecture/provider-tet-design.md new file mode 100644 index 0000000..fe1f939 --- /dev/null +++ b/docs/design/architecture/provider-tet-design.md @@ -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`