跳到主要内容

数据契约(Data Contract)— 数据处理层规范

目的:把"清洗与结构转换"的规范收敛为单一事实源,明确每一层由哪个引擎负责、 字段/单位/日期口径是什么。处理引擎可以分层混用(Polars/pandas/dict),但 列契约与单位口径必须以本文档为准。 版本:2026-09-06(数据处理层审计后建立,见文末"审计结论"); 2026-09-22 增补 §十一「分层不变量」(写入侧规则),§八 原「成交额派生修复」 小节随之迁入该节(§八 只留指针);同日增补 §十二「快照段级出处标签」 (读取侧可溯源规则,配套 tests/test_market_snapshot_labels.py 绊线); 2026-10-07 §十一 增补规则 6「数据完整性在写入路径保证」(读路径不重复请求外部 API, 配套 _supplement_fund_flow / _supplement_distribution_bins 调用点收敛优化)。


一、数据分层与引擎归属​

API 源响应(腾讯/东财/新浪/巨潮/THS/TDX)
│ 清洗+结构转换:Polars(canonical.py normalize_daily/minute/adj_factor)
▼
KlineStore 支路:Parquet + DuckDB(列式,kline_store.py)── 读路径:stock_service.get_kline 第 1.5 层
│
MySQL 主链路(dict 行式 + SQLAlchemy,upsert_rows / _save_multirow)
├─ RAW 表(em_*/sina_*/ths_*/cninfo_*/tencent_*/tdx_*/jrj_*/szse_*/sw_*)
│ │ 转换:stg_loader(dict 逐行校验/归一化,_normalize_date_str/_safe_float)
│ ▼
├─ STG 表(stg_*,标准化中转)
│ │ 融合:fox_engine/writer/fusers(dict 收集 → upsert_rows 批量 UPSERT)
│ ▼
└─ DWD 融合表(fox_*,19 类;权威注册表 fox_engine/writer/registry.py::FOX_FUSERS)
│ 读取:fox 引擎 / API(dict 行式返回,面向 JSON 序列化)
▼
量化/回测层(pandas:quant/engine、backtest、factor_analysis;输入 list[dict]/DataFrame)

引擎分工原则(不强行统一为单一引擎):

  • 列式批量清洗/转换(K 线等高一致结构)→ Polars(canonical + KlineStore)
  • 行式采集/融合/落库(多源异构、逐行校验)→ dict + SQLAlchemy(批量 UPSERT)
  • 分析计算(指标/回测/因子)→ pandas/逐行(受口径约束,见 §四)

写入侧规则(每层的值从哪来、谁能写哪一层、多源表谁说了算)见 §十一 分层不变量—— 它由代码落地(downloaders/base.py 共享写入口 + tests/test_layer_boundaries.py 绊线), 不是口号。

二、canonical 列契约(单一事实源)​

定义于 backend/app/services/data_sources/canonical.py,是 K 线类数据的唯一列契约:

数据类型canonical 列
日K CANONICAL_DAILY_COLSsymbol, dt, open, high, low, close, volume, amount
分钟 CANONICAL_MINUTE_COLSsymbol, datetime, open, high, low, close, volume, amount(period 不在 canonical 常量内,由 KlineStore 写入路径 _write_minute_kline_store 补列,供 DuckDB 按周期过滤)
复权因子 CANONICAL_ADJ_COLSsymbol, trade_date, ex_factor

消费方:

  • KlineStore(downloaders/base._write_kline_store/_write_minute_kline_store 写入前归一化)
  • 新浪复权因子源(sina_source.get_sina_adjust_factor 输出按 adj_factor 契约对齐,经 normalize_adj_factor 归一为 ex_factor)

规则:新增 K 线类数据源/写入路径时,必须先过 canonical.normalize_* 再落库; 禁止在消费方各自改名。fox 融合层(stg→dwd)的字段映射以 fox_models DDL + fuser 映射为准(属 DB 行契约,不经 canonical),两套契约的交界是"API 源响应 → DB 行"。

期货主连日K行契约(fox_futures_kline_daily,2026-10-06):不经 canonical.py (该模块是个股 K 线链路的归一契约,期货不在其消费方内),行契约由数据源解析 单点定义(data_sources/commodity_spot_sources.fetch_futures_kline_bars → data_manager/commodity.py::download_futures_kline 落库,读路径 get_futures_kline 共用同一解析):

  • 列:{symbol, dt, open, high, low, close, volume, settle}(+ variety 归属列)。 PK=(symbol,dt);symbol 是主连代码(CU0/RB0 形,大写,正则 [A-Z]{1,2}0); 源侧 d/o/h/l/c/v/s 原样映射(不做归一/复权),volume 单位手。
  • 源:新浪 InnerFuturesNewService.getDailyKLine,一次请求返回该品种全历史 (无日期参数、无分页)→ 采集与自愈都是"整段重拉 + 逐行变更检测"(值未变不触库, 幂等自收敛)。行级清洗:日期须为真实日历日且落在 [1990-01-01, 明天],脏行丢弃。
  • 🔴 主连在换月日有价格跳变(新旧合约拼接),跨换月的涨跌幅/收益不可直接计算, 消费方按「同合约段内比较」或由前端标注换月点,不做前复权臆造。
  • 写入侧登记见 §十一 规则 5(NO_ODS 直写豁免,futures_kline)。

三、单位与格式口径​

字段口径说明
volume(日K/分钟K)手新浪源为股须 ÷100;腾讯/东财原生手(契约统一于 stock_service/limit_board 写入前)
amount(K 线)元⚠️ 腾讯 fqkline 只返回 6 列(date/open/close/high/low/volume),本就没有成交额 → fox_kline_daily/fox_kline_bar/fox_index_daily 的 amount 是上游固有的空列(fox_kline_bar 238 万行、fox_index_daily 同为 0),不是写入 bug。fox_kline_daily.amount 自 2026-09-17 起由融合阶段 reconciler 按日落当日真实值(见 §十一「融合阶段 reconciler」;历史日期仍为 0,已知且下游判空降级);fox_kline_bar/fox_index_daily 无修复源,用成交额的高位判定须判空降级
change_pct / pct%(10.00 = 10%)人气/换手/涨跌幅一致
龙虎榜 net_buy_wan 等万元东财原始元 ÷10000
dtISO YYYY-MM-DD(字符串或 date 对象)分钟为 datetime;跨源比较前先归一
复权价不复权价 + 因子分离前复权价 = 不复权 ÷ qfq_factor;禁止存储混算后的负价格序列(腾讯 qfq 深度历史可为负)

四、已裁决的口径约束(不要"优化"掉)​

  1. 指标计算的每步 round:indicators.py 的 KDJ/RSI 等在循环内逐步 round(), 属复利式舍入语义——不可向量化替代(无法逐位复现),这是删除 indicators_vector.py 的历史原因(口径漂移)。有 test_indicator_formulas parity 测试保护。
  2. _save_multirow 先删后插:全量覆盖语义,勿改 UPSERT(stale 行清理依赖删除); 已批量化(bulk_insert_mappings,2026-09-06 P2)。
  3. upsert_rows 幂等 UPSERT:fox 融合层唯一写入口,勿回退先删后插。
  4. 回填口径(2026-09-06):market_phase_sync/industry_rank_sync/dragon_score_sync 支持按历史日期回填(/schedule/{key}/run body 传 trade_date)。回填模式下 依赖当日分钟线的子因子(drive 带动板块/anti_drop)按历史日期不可得,取中性/跳过 并在 dims.details 标注 degraded;absorption 用 THS 5 分K 按日期过滤(深度约 5 个交易日);其余子因子(封板时间/连板/换手/封单/5日涨幅/板块共鸣)日期精确。
  5. 东财涨停池四类的失败语义与日期归属(2026-09-27,与参考对象对拍后裁决): limit_board 四个池函数契约是 None=请求失败、[]=该日池子确实为空,两者在 下游含义相反([] 会被写成「今天一家涨停都没有」)。消费者不得 or [] 洗白, 只允许覆盖自己真正拿到的键。两处不可"统一优化"的例外: ① fox_engine 门面(get_limit_up_pool 等)对约 100 个接口一致地把异常收敛成 []——面向页面的端点因此必须直连 data_sources.limit_board 取池(datamgr._pool_payload 在 None 时抛 502);② push2ex 对没有当日池的日期(休市/未来)会把最近交易日的池 顶回来、且 data.qdate 恒为最新池日 → 载荷不能自证请求日,只能由 _no_pool_reason 在发请求前按日历拦(交易日盘前返回 None,绝不给昨日的池冒充今天)。 超出保留期的老日期回 rc=0 + pool: [],与真空池不可区分 → 深历史不能靠 push2ex 回补,只能每日落 em_limit_board 累积。

五、读路径优先级(K 线 API 实测口径)​

stock_service.get_kline(2026-09-06 P1 起含 KlineStore 层):

  1. SQLite 快路径(新鲜即返回)
  2. KlineStore(DuckDB/Parquet):last_date ≥ 最近交易日 且 行数 ≥ min(days, 30) ——覆盖度守卫:行情刷新会把当日快照写成单行日K,仅看新鲜度会让 120 天请求只返回 1 行
  3. MySQL fox_kline_daily(新鲜度校验)
  4. 实时降级链(腾讯 → TDX → 东财;北交所为 新浪 → 东财)→ 写回 SQLite + MySQL —— 实现见 data_sources/real_data_source.py::get_kline;Baostock 已不参与该链路

六、全市场涨幅榜读路径(2026-09-10)​

get_market_data("hot_stocks")(首页/市场看板「涨幅榜」)口径统一为全市场涨幅榜 TOP N, 响应契约:{stocks[], total, source, source_label, as_of, timestamp},行字段 {rank, code, name, price, change, change_percent, volume, amount, turnover_rate, high, low, open, prev_close}。

层级数据源门槛说明
L1fox_stock_wideupdated_at >= 最近交易日 且有效行数 ≥ 1000一次 SQL(实测 0.2s);不新鲜/残缺即降级,避免把 16 天前数据当"今日涨幅榜"
L2腾讯全市场实时批量有效行数 ≥ 1tencent_market_top_gainers:fox_stock_master 枚举 → 约 10 批次(实测 3.0s);进程内缓存盘中 5min / 休市 30min
L3fox_quote_snapshot≥ 5 行样本榜(自选/被查看股票),非全市场
L4东财涨幅榜—source_label=涨幅榜(东财)
L5雪球人气榜—source_label=人气榜(雪球)——口径与涨幅榜不同,必须靠 source_label 区分

单位口径:L2 腾讯 amount 原生为万元 → 输出前 ×1e4 归一为元(与宽表/东财一致); change_percent 为 %;停牌(price<=0)与无涨跌幅行在排序前剔除。

七、两市成交额读路径与累积(2026-09-10)​

GET /api/v1/market-turnover/history(前端「两市成交金额」面板,单位亿元)走多源降级链, 并把每次取到的结果 UPSERT 落库 fox_market_turnover_daily(dt 主键,逐日累积):

层级数据源说明
S1TDX 指数日K(上证 000001 / 深证综指 399106)原生带 amount,历史最全;当前网络环境实测不可用
S2腾讯实时指数行情 qt.gtimg.cn(sh000001 / sz399106)不封 IP;字段37=成交额(万元),仅当日值 → 每日采集即积累一天
S3交易所 RAW(sse/szse_market_overview)⚠️ 上交所 dt 列被回填污染(241 行同值)→ 加"同值即整源跳过"门禁;深交所可用
S4fox_market_daily.total_amount(TDX/东财权威两市合计)本环境唯一有量级的历史合计(实测 239 日);按实测占比中位数拆分沪/深(source='split',quality='estimate')
S5fox_quote_snapshot 样本按换手率外推库内兜底,quality='estimate'
S6联网搜索(东财/新浪/同花顺收盘综述)仅在以上全空且当日未尝试过时触发一次;解析"沪市/深市/两市成交额 X 万亿"

单位口径:输出统一亿元;腾讯字段为万元(÷1e4);交易所 raw amount 实测已是亿元。 合理性区间 500 < 值 < 60000(亿元),越界拒绝落库。 (S1-S6 链路不含 JRJ:jrj_* 表只服务涨跌停温度计/市场历史,无成交额换算。)

定时任务:market_turnover_sync(15:35 交易日)先补最近 30 天缺口再采当日, UPSERT 幂等;API POST /market-turnover/collect|backfill 可手工补采。

八、融合读源契约:优先读当日落库、缺失再自愈直连(2026-09-17)​

总原则:融合器(fox_engine/writer/fusers/*)取行情类数据时,第一读必须是当日 已落库的 RAW/时序表;落库缺失或覆盖不足时先自愈落库再读(自愈结果写回表, 后续消费者同样受益);只有落库渠道彻底不可用时才直连 API 兜底。

① 读当日落库(tencent_stock_quote_daily 等,按 trade_date + code)
│ 命中且覆盖 ≥ 阈值 → 返回(与落库快照同口径,可事后对账;融合不依赖外部 HTTP 可用性)
▼ 缺失 / 覆盖不足 / 读取异常
② 自愈落库(就地调用同一 sync 入口写回表)→ 再读一次
│ 成功 → 返回(记 warning,便于统计自愈频率)
▼ 自愈未成功
③ 直连 API(tencent_quote_batch 等)→ 返回(记 warning,标明"落库渠道不可用")
▼ 仍失败 → 返回 {}

硬约束:

  1. 落库表不得成为融合的硬依赖——任何一级失败都必须能降级,融合不允许因落库链路 故障而断供(这是本模式与"把落库表当唯一源"的关键区别)。
  2. 覆盖率门禁:read_quote_map(..., min_coverage=0.6)——覆盖率不足时返回 {} 而非残缺快照;宁可走自愈/直连,也不要用半份数据让融合悄悄丢字段。
  3. 自愈规模门禁:自愈 = 拉一次全市场(约 28 次 HTTP、~90s),只在请求达到全市场 尺度(len(codes) >= _HEAL_MIN_CODES = 500)时触发;单只/少量代码的按需下载 直接走直连,避免一次单股请求意外拖出全市场拉取(同 _raw_heal 的"仅全市场"守卫)。
  4. 必须按日对齐:跨源 JOIN(如成交额 ÷ 净额、成交额修 K 线 amount)一律 ON 双方各自的日期列相等,禁止"用最新一天的成交额除以前一天的净额"。
  5. 单位在交界处归一:RAW 落库口径为 元/手;gtimg 口径为 万元/手/亿元, merge_quote_sources(写入方向 ×1e4 / ×1e8)与 read_quote_map (读取方向 ÷1e4 / ÷1e8)负责双向换算,调用方永远拿 tencent_quote_batch 同形状。
  6. 三级路径都记日志(info=①,warning=②/③),用于事后回答"这次融合走的是哪条路径"。

参考实现:backend/app/services/fox_engine/writer/fusers/stock_wide.py::_load_tencent_quotes (腾讯行情)+ backend/app/services/data_manager/tencent_market.py::load_tencent_quotes (通用三级入口,四个融合器共用:stock_wide / valuation / industry / stock_master)。

成交额派生修复:已迁出本节——它写 fox_* 表,属融合阶段职责,公式与按日对齐 口径见 §十一「融合阶段 reconciler」。这里只保留与本节相关的一条:跨源派生计算 (成交额 ÷ 净额、成交额修 K 线 amount)必须按日对齐,见上"硬约束 4"。

九、审计结论(2026-09-06)​

  • 数据处理层未全面统一为 Polars:dict 行式为主链路,Polars 仅 K 线支路,pandas 在量化层
  • 已实施:KlineStore 读路径变现(原"只写不读")、_save_multirow 批量化、 provider_protocol.py 死代码删除、PostgreSQL 注释漂移修正(duckdb_engine/stock_service/quant engine)
  • 明确不做:主链路整体换 Polars(瓶颈在网络 IO 与逐股查询,非行转换);指标向量化(口径)

十、行业分类口径与成交额占比(2026-09-21)​

三套行业口径互不通用,切换口径 = 切换筛选参数,禁止名称模糊匹配:

口径归属列取值筛选参数用途
腾讯三级fox_stock_wide.industry名称串("电子设备-半导体-集成电路")industry=<名称>行情中心默认展示
申万一级fox_industry.sw_l1代码 801010sw_l1=<代码>板块中心 / 行业详情
申万二级fox_industry.sw_l2代码 801012sw_l2=<代码>板块中心 / 行业详情
  1. 🔴 代码列不得写名称:fox_industry.sw_l1/sw_l2、fox_industry_index_daily.industry_code 只存 801xxx 代码;名称一律经 sw_index_meta.code → name 映射,或由 API 输出独立的 sw_l1_name/sw_l2_name 字段。名称会变(申万 2021 改版),代码稳定。
  2. 数据源:申万宏源研究官方 index_publish(清单/成分股/指数日线),本地增量由融合器自管 (官方 trend 接口无日期参数,全量返回 1999 起)。
  3. 分母口径:行业成交额占比 = 该行业当日 amount_yi ÷ Σ31 个申万一级行业当日 amount_yi。 分母不落库、由读路径现算;denominator_complete=false 表示当日不足 31 个行业, 占比仅供参考(前端显式提示)。该分母与"全市场成交额"不同源,只用于行业间横向比较。
  4. 分位输入是占比不是成交额绝对值:绝对值受全市场量能整体放大影响,个股/行业自身 的历史分位必须在"份额"维度上算。样本 = 近 365/730 个自然日窗口内的有效交易日。
  5. 单位:fox_industry_index_daily.amount_yi 为亿元(列名即口径,DB 列名不是申万原始字段 bargainsum),industry_class.get_class_kline 的 amount_yi 同口径;前端换算为元时 ×1e8。

十一、分层不变量(写入侧规则,2026-09-22)​

本文前半讲"读什么、什么口径",本节讲谁能写哪里、值从哪来。五条规则是 2026-09-22 数据分层收口的结论,全部由代码落地(共享写入口 + 静态绊线),不是口号: tests/test_layer_boundaries.py 在采集层越层写 DWD 时直接失败(CI 已纳入)。

1. 每层只装本层该装的东西(RAW 只装本源值)​

RAW 行的值要么来自该源响应,要么来自同源换算(单位/日期/代码归一化),不得掺入其他源的 值;跨源填充是 STG / DWD 的事。

  • 反例(已禁止):在 RAW 下载器里拿 fox_quote_snapshot 的成交额去补 fox_kline_daily.amount。
  • 派生列要登记:某列不是源字段、而是由本表(或同源)其他列算出来的,必须写清公式与 quality 标记(先例:fox_market_turnover_daily 的 source='split' + quality='estimate')。

2. 跨源必须留痕(标签与值原子更新)​

一行的 source / source_json 就是该行血缘的唯一事实,必须与值在同一次提交内更新。

  • 🔴 违规形态(2026-09-22 已修):stock_service 覆盖 sina_financial_indicator.items_json 却不刷新 source → 值是新浪的、标签写着 eastmoney。现统一走 downloaders/base.py::apply_financial_indicator。
  • STG 的血缘按行内 source 标注(stg_loader/finance_indicator.py::_RAW_BY_SOURCE), 不整表硬编码某个源——否则备源的行会被冒充成主源。

3. 层单向:RAW → STG → DWD(禁止越层写)​

  • 采集层(data_manager/)不得写 DWD;跨源派生计算一律放在融合阶段 (fox_engine/writer/fusers/*,或融合后置 writer/reconcile.py)。
  • 服务层读路径不得回写 ODS 之外的层(回写只允许自己的 ODS 表与 SQLite 快路径)。
  • 静态绊线 tests/test_layer_boundaries.py:规则 A 拦"构造/ORM 改非豁免融合表模型", 规则 B 拦"原生 SQL 写 fox_*"。它拦的正是真实事故:原 data_manager/tencent_market.py::_repair_amount_derived 以内联调用的方式由 RAW 下载器 直接 UPDATE fox_kline_daily(详见本节末 reconciler)。

4. 同一列只有一个写入者​

  • 多源混写的 RAW 表也必须收敛到一个入口:sina_financial_indicator 有 4 条写入路径 (新浪逐股 / 新浪全市场批量 / 东财全市场回填 / 个股详情实时回写),统一走 downloaders/base.py::upsert_financial_indicator,按优先级裁定:新浪 gjzb(主源)可刷新与 夺取,东财回填(备源)只补空缺——备源永远不覆盖主源的值,且值与标签同批落库。
  • DWD 列同理:fox_kline_daily.amount(TDX 成交额 / 腾讯成交额双源)与 fox_fund_flow_daily.main_net_ratio_pct(东财净额 ÷ 腾讯成交额)只由融合层单点写入, 不允许下载器"顺手"补。
  • 多写入者列必须声明「缺失补位」:融合器每日整段重拉(K 线 500 根)时,源里没有的 列会以 NULL/0 覆盖掉别的来源补上的值。判据是"该列的值可能不来自本次拉取的源",这类列在 注册表 FoxFuserSpec.fill_only_cols 里声明,写入口 upsert_rows 据此在更新分支上 COALESCE(NULLIF(新值,0), 旧值)——来值缺省则保留既有值,来值有效才覆盖。 先例(2026-09-22 实测):fox_kline_daily.amount 被腾讯 K 线重拉清洗,09-16 及更早的 成交额整体变 NULL(2026 年 96 万行);根因不是"补值丢了",而是"没声明这条列被别人写"。

5. 直写 DWD 的数据集必须登记理由​

数据源本身是实时 API、没有持久 ODS 表可放的融合类型,必须在 fox_engine/writer/registry.py::NO_ODS_TYPES 逐条登记理由(现 9 类,分三批: 初始 kline_daily_fox / kline_bar / index_daily / stock_wide / market_dpyt; 2026-09-25 审计补齐 industry_index / macro_indicator / macro_release; 2026-10-06 商品域接入 futures_kline——期货主连全历史日K,新浪一次请求返回 全历史、无持久 ODS 价值)。该登记表就是规则 A 的豁免输入: 新增一类直写数据集,不登记就会让绊线失败;同时断言"无 ODS 的类不含 STG loader" (防影子分层)。

6. 数据完整性在写入路径保证(读路径不重复请求外部 API)​

数据补充(_supplement_*)必须在写入路径完成(落库前 / 落缓存前),确保入库数据 已完整。读路径(缓存命中、当日 DB 数据、已获取标记)不得调用补充函数——那会让每次 读取都发起外部 API 请求,违反"数据完整入库、读取零 HTTP"的管道原则。

  • 写入路径(唯三允许调用 _supplement_* 的场景): ① 实时源获取成功后、写入快照/缓存前(fetchers.py 步骤 8 写入前补充) ② 历史回退路径(老旧记录写入时可能尚未具备补充能力,补充后落库) ③ 融合阶段 reconciler(§十一 reconciler 小节,跨源派生补齐)
  • 读路径(禁止调用 _supplement_*): ① _serve_cached() 缓存命中——数据写入缓存时已完整 ② 当日 DB 数据(snap_date == today_str)——写入路径已补充 ③ already_fetched_today() 命中——同上
  • 🔴 先例(2026-10-07 优化):_supplement_fund_flow 从 7 处调用收敛到 4 处, _supplement_distribution_bins 从 6 处收敛到 3 处。读路径的重复补充被移除后, 每次缓存命中不再请求东财 API(此前 distribution 每次命中都发起 1~2 次 HTTP)。
  • 判据:函数调用栈里出现 _supplement_* 时,检查调用方是"写入/持久化前"还是 "读取/返回前"。后者必须删除(数据已在写入时完整,读路径补充是冗余且有害的)。

融合阶段 reconciler(原 §八「成交额派生修复」)​

当日真实成交额落库后(tencent_stock_quote_daily.total_amount),补齐三个此前整列为空 的派生列。实现:fox_engine/writer/reconcile.py::reconcile_amount_derived, 由 fox_wide_sync(16:20)完成后独立调用(scheduler_tasks.py 的 run_reconcile), 与落库任务解耦。

目标列修复公式对齐方式
fox_kline_daily.amount= q.total_amountq.trade_date = k.dt
fox_fund_flow_daily.main_net_ratio_pct= main_net / q.total_amount * 100q.trade_date = f.dt
fox_stock_wide.main_net_ratio_pct同上(快照表无日期列)经 fox_fund_flow_daily 取净额所属日,再 JOIN 该日成交额
  • 只补 NULL/0,幂等;单条失败只记 warning(旁路能力,不影响融合结果)
  • 回看 7 天(lookback_days),覆盖"K线比行情晚落地一天"的常态
  • 🔴 归属历史:它原本由 RAW 下载器 sync_tencent_market_daily(15:50)内联调用, 即 bronze 层直接写 gold 层。后果有二:① 同列两个写入者(15:50 下载器补 → 16:20 fox_wide_sync 整表重建),最终值取决于时序;② 修复能力被绑在一个与它无关的落库任务上 (该任务失败则修复消失)。2026-09-22 迁至融合层,两件事解耦——这是规则 3 的来源。
  • reconciler 只管最近 7 天(超出 tencent_stock_quote_daily 保留窗口即无分母), 存量空窗由融合器自身的补值源填:fox_kline_daily.amount 现由 TDX 日 K 补 (fusers/kline_daily_fox.py::_tdx_amount_map,与腾讯权威值逐日吻合到 0.0001%), 500 根窗口覆盖不到的历史再走一次性回填脚本 (backend/tools/_kline_amount_fill.py:TDX 1600 根 → 临时表 JOIN 只补 NULL/0)。 2026-09-22 实跑结果:补 362.6 万行,在市股票(9 月仍在交易)缺失率 0.0% (当日 5,632 行仅 1 行缺 = 当日上市新股 001246);残下 33.1 万行全部落在 595 只已停更/退市股(TDX 无该标的数据,属已知且不可回填——不是缺口)。

融合阶段清账(残行清理,2026-09-22)​

"对账"补行内缺值,"清账"删整行残值——都是融合层旁路能力,都在融合任务收尾调用, 都只读库内数据、不碰数据源、幂等可重跑。触发条件都是"融合器只 UPSERT 不 DELETE":

能力残行形态判据调用方
purge_period_residuefox_kline_bar 期内残行:腾讯给进行中周期按"最新交易日"作 key,每日重拉写新行 → 月桶 2026-09 出现 5 行(09-16/17/18/21/22),读路径不去重 → 月 K 连出 5 根同一 (code, period, 周期桶) 只留 MAX(dt);周桶用 ISO 周(跨年周不拆桶)fox_kline_sync(15:40 后置)
prune_snapshot_residuefox_stock_wide 快照残留:宽表是"1 行/股的最新快照"但只增不删 → 114 行 B 股 + 589 行退市/老三板永久滞留(退市股带着涨跌幅出现在 A 股榜单里)段外(not _is_a_share,与枚举器同源)∪(不在最新快照 且 120 天内无 K 线);快照 < 3000 只视为落库失败,只清段外不判退市fox_wide_sync(16:20 后置)
  • 两条判据都保守优先:_is_a_share 是融合器枚举时的同一个函数(单一事实来源), "无近期 K 线"窗口覆盖停牌数月后复牌,避免误删在市停牌股。
  • 删错可重建:两张表都在融合器的重拉窗口内(K 线 240 根 / 宽表全覆盖),下一次全量融合即恢复。
  • 周期 K 线的残行不会自愈(每天照旧多写一行),必须靠 fox_kline_sync 后置清账兜住; 清账挂在产生残行的那个域(K 线残行归 K 线域),不借别的任务的手(同 reconciler 的教训)。

已知例外与不覆盖范围(读本节时先看这段,避免把规则理解成"绝对禁止"):

  • fox_ 前缀不是层标记:fox_quote_snapshot / fox_stock_f10_info / fox_data_batch 是 ODS 角色,采集层写它们是其本分,规则只认 FOX_FUSERS 里那 19 张融合表。
  • 采集层的按需下载写 DWD 属已登记豁免:downloaders/market.py::download_kline(自选股 实时新鲜度)直写 fox_kline_daily,与融合器同用 canonical 列契约——豁免理由是 §5 的 kline_daily_fox,不是"忘了迁"。
  • 绊线是静态扫描,拦不到赋值中转(q = db.query(FoxX); q.delete())与动态拼接的 SQL, 仍需人工审查兜底。

十二、快照段级出处标签(读取侧可溯源,2026-09-22)​

get_market_data(data_type) 的载荷有两个去处:返回给前端,以及整体落 fox_market_snapshot.data_json。但载荷级 _source 只记"这次请求的主源" (tdx / tencent / eastmoney / sina_backup / db_history),而一次组装常由多源拼成: sentiment 的指数与家数各走一条链、industry_rank 在分支内还有一级降级、涨跌停家数会被 _apply_limit_pool_counts 后置覆盖。所以取数时用 _tag_segments 给各组成段补出处标签, 随 data_json 一起持久化——事后对账("这块数出自谁")无须重跑链路:

标签载荷取值(= 取数函数名,可 grep 核对)
_indices_sourcesentimenttdx_indices / em_indices;被 _backfill_sentiment_indices 校正后记并集 原源+tdx_indices
_breadth_sourcesentiment、distributionsentiment:tdx_market_breadth / db_market_breadth / tencent_market_breadth_batch / em_ulist_np / em_market_breadth;distribution 见下表
_limit_sourcedistribution、sentiment.breadth、fund_flowtencent_limit_codes(其炸板/封板率另取 em_push2ex)/ em_push2ex
_bins_sourcedistributiontdx_full / eastmoney / fox_stock_wide(配 _bins_date 记数据日期)
_sectors_sourceindustry_ranktencent_industry_rank / db_sector_performance / em_sector_performance
_cells_sourcesector_treemapdb_sector_performance / tencent_industry_rank_full:hy2 / em_sector_performance

涨跌分布(distribution)的家数与分段可以出自两批数据,两支各自标注:

分支家数 _breadth_source分段 _bins_source调用压力
东财 ulist.np 三腿(家数优先)em_ulist_np无分段(交由补位链)1 请求
东财 stock/bk/get + filter(待恢复)em_market_breadth无分段(交由补位链)2 探针 + 最多 15 请求
补位① TDX 全市场快照(家数+分段同批)tdx_market_breadth_fulltdx_full1 次 TCP 全量
补位② 东财区间统计(家数+分段同批)em_market_breadtheastmoney最多 15 请求
补位③ 宽表收盘快照(家数+分段同批,仅非交易时段)db_distribution_bins_from_widefox_stock_wide0 请求

主链路(TDX / 腾讯实时)的家数与分段本就同批产出,只标 _bins_source。 补位链的前两棒 DB 分支(读 db_market_breadth)是 2026-08-22 起的死钩子 (该函数因 stock_base_info 移除而恒返回 None),故不参与标注。

家数之所以要多一个 ulist.np 分支:filter 参数停用后东财这一档只剩"整市场 一个数"的探针可用,而探针恒被判不可用 → 东财档事实上断供。ulist.np 按市场 (沪 A / 深 A / 北证)聚合涨跌平,1 次请求即得家数,且不依赖已被忽略的 filter,因此把它排在 filter 之前——先把请求数降下来,而不是加大重试 (无效请求同样计入主机冷却,会牵连龙虎榜 / 涨停池等东财独有数据)。 它拿不到 13 段,所以分段仍由补位链负责。

约定(改这几条链路前先看):

  1. 标签值写取数函数名,不写上游厂商名——函数内部换源时标签跟着函数走,不会悄悄失真 (如 tdx_indices 实为"腾讯优先 + TDX 补齐",标签只承诺"出自这条链")。
  2. 出处随值改:任何后置覆盖(涨跌停家数校正、指数补齐、bins 重建)必须同步改写 对应标签,否则标签会指向已经不存在的那个值。
  3. 空值不写入(""/None 不落键):让"没标注"与"标注为空串"可区分;不写标签的段 即"该段无独立出处"(其出处就是载荷级 _source)。
  4. hot_stocks 不走段级标签:行级 _source/_as_of 已由 build_hot_stocks 提升为载荷级 source/source_label/as_of(见 §六),再打一层标签是重复信息。
  5. distribution 的家数有独立标签 _breadth_source,分段另有 _bins_source,两者可以 不同源:家数走东财 ulist.np、分段由补位链给出时就是这种状态。家数优先、 分段补位,补位链每一棒连同家数一起替换,出处标签必须跟着改写,绝不出现 "家数已是 TDX 口径、标签还指着东财"。
  6. 同一纪律也适用于按行落库的时序表:app_market_breadth_intraday 把出处写进列而非 JSON,除家数/涨停的 breadth_source / limit_source 外,2026-09-27 起同一行还捎带 一次腾讯批量拿到的指数点位与沪深成交额,出处记 pulse_source(tencent_pulse_quotes)。 🔴 深市两列不同源指数:点位 = 深证成指 399001,成交额 = 深证综指 399106(全深市 口径,与 fox_market_turnover_daily 一致;成分指数无全市场成交额语义)。这块是捎带的: 取不到只让自家列留空(且同一 (交易日, 时点) 重采时不洗掉上一次的成功值), 绝不阻断家数落库;合计只在沪深两侧都取到时才算。绊线 tests/test_market_breadth_intraday.py(CI 已纳入)。

绊线:tests/test_market_snapshot_labels.py + tests/test_em_breadth_ulist.py (CI 已纳入)——三个带段级标签的构建器 (build_sentiment / build_industry_rank / build_sector_treemap)若被裸 return (即漏包 _tag_segments)直接失败;另有"AST 层摘掉一层包装"的反向用例,证明绊线盯的是 真实源码而非空转。涨跌分布两支(家数 / 分段)由 tests/test_em_breadth_ulist.py 盯住:三腿必须全是 A 股口径(深市 399107,不是 399001)、家数自洽 + 量级门禁、180s 内只发一次请求(失败同样缓存,风控期不反复敲门)、 补位链替换家数时标签同步改写。

十三、形态字段契约(fox_stock_pattern_daily,2026-09-22)​

同一张表由两个识别器写:patterns.py(单根/组合 K 线形态)与 quant/structural_patterns.py(跨周结构形态 + 图形形态)。后者分三层,依赖单向:

quant/chart_geometry.py 基元:分形枢轴 / 边界线 / 缺口 / 收敛与平行判定(无形态知识)
↓
quant/chart_patterns.py 判据 + 中文名:双顶底 / 三重顶底 / 头肩 / 矩形 / 三角 / 楔形 / 旗形 / 岛形
↓
quant/structural_patterns.py 字段注册表 + 参数容器(SysConfig 覆盖)+ 统一入口
+ 自有状态机(老鸭头 / 圆弧顶底 / 杯柄)

🔴 列分两类,语义不通用(这是本表最容易误用的地方):

类别列语义写值时机
方向列duck_buy / duck_sell / arc_bottom / arc_top / cup_handle / double_bottom / double_top / triple_top / triple_bottom / hs_top / hs_bottom / rectangle / triangle / wedge / flag / island信号,±100(看涨 + / 看跌 −)枢轴类图形形态(双顶底/三重顶底/头肩/矩形/三角/楔形/旗形/岛形)仅在突破首日(今日破线且昨日仍在线内,chart_patterns._first_breakout);arc_bottom/arc_top/cup_handle 是状态判定(右端回到肩线 / 放量站上左杯沿期间持续给值,不是单日事件);duck_buy/duck_sell 由老鸭头状态机给出(买点①②、卖点死叉)
度量列duck_stage / duck_neck_days / duck_pullback_pct / duck_vol_shrink_pct / duck_breakout_pct / rectangle_height_pct / triangle_kind / wedge_kind / flag_kind筛选条件,不是信号判定成立期间持续给值(如 triangle_kind 1对称/2上升/3下降)

由此推出三条约束:

  1. 写行门槛 = has_signal(pat) or has_structural_signal(struct):只有度量列非零的行 (处于鸭颈/鸭头回落、箱体成形中)也必须落行,否则选股器读本表永远查不到这些筛选条件。
  2. 度量列不得混进 signals:api/quant.py 把输出拆成 structural(方向)/ duck(老鸭头族 度量)/ metrics(图形形态族度量)三块,两块都空则为 None 而非全零占位——否则「回调 8%」 会被下游当成买入信号播报。
  3. 形态战绩只评测方向列:quant/pattern_review.py 一律与方向列取交集,显式传 duck_stage 这类度量列也被拒(当信号回测 = 把「处于鸭颈期的票」当买点)。

其他口径:

  • 无未来函数:fractal_* 只返回右侧已有 k 根确认的枢轴;鸭头取「当日之前」的最高点。
  • 边界线取「被最多枢轴触及」的那条(chart_geometry.best_line,平局取跨度更长), 而不是穿过首尾枢轴拟合——后者会被边缘平台枢轴带偏(实测把矩形斜率顶到 4.17%,越过门槛漏报)。
  • 缺口一律用前复权价判:岛形依赖两个反向跳空,一字板(开=高=低=收)与除权造成的假缺口 必须在基元层过滤,否则复权日全市场批量误报。
  • 突破放量门槛按方向分档(SwingParams):上破 breakout_vol_mult=1.5,下破 breakout_vol_mult_down=1.0(「当日不得缩量」)。vma 是含形态本段的滚动均量,顶部区间的 均量被自身峰值抬高,同一倍数在下破一侧系统性更难达成 —— 实测 295 只 × 20 个交易日:下破候选日 vol/vma 中位 0.86、上破候选日中位 1.42;两向共用一个 1.5 会让双顶/三重顶/头肩顶/圆弧顶等 全部顶部形态恒为 0 条信号(判据在 _first_breakout,签名收 SwingParams 而非单个倍数, 正是为了让「漏了方向区分」在类型上写不出来)。
  • 形态名册(CHART_DIRECTION_LABELS)只登记 run_detectors 真会产出的形态:该字典同时是 run_detectors 的零初始化名单,列进去却没有对应 calls 项的字段会被恒定写 0,并在 detect_structural 的 out.update(fields) 里覆盖掉别处算出的同名字段(圆弧顶实库恒 0: 单形态入口正常报 −100,批量入口被盖回 0)。属于 structural 自有状态机的字段(arc_*)只登记在 STRUCTURAL_FIELDS;它们的标注名同理由 structural_patterns.make_geo 查 STRUCTURAL_LABELS (chart 的 make_geo 只认图形形态那张表,直接用会让图例显示 "arc_bottom" 裸串)。
  • 前向收益口径唯一实现:services/forward_return.py(次日开盘买入、持有 5 个交易日收盘卖, 零 HTTP),builtin_track(榜单)与 quant/pattern_review(形态)共用;两处各写一份会让 同一批信号出现两套无法互相解释的胜率。
  • 命中率按信号方向对齐:aligned = ret_5d × sign,aligned > 0 即走对;刻意不报浮亏—— 同一份 max_drawdown_pct 在 −100 的桶里含义翻转(成了最大浮盈)。
  • 字段三处注册同改:STRUCTURAL_FIELDS(方向)/ STRUCTURAL_METRICS(度量)→ screener/registry.py 的 pattern_* → api/quant.py 的输出分块。

绊线:tests/test_structural_patterns.py、tests/test_chart_patterns.py(含"字段注册表 ↔ ORM 列 ↔ 选股器 spec 三处一致"用例)、tests/test_pattern_review.py(CI 已纳入)。

十四、巨潮资讯(cninfo)RAW 消费登记(2026-09-25)​

cninfo 市场级 RAW 由 cninfo_market_sync(19:00,交易日)与 wholemarket_events_sync (18:50,公告走逐股 + 全市场双路径)落库。§十一 的铁律是"建表必须有定时写入者"; 本节补齐另一侧——每张 RAW 表必须登记消费者,没有消费者的落库同样是静默死数据 (读到的是过期快照且不报错)。逐表现状(2026-09-25 数据源审计实测核对):

RAW 表定时写入者消费者(融合/读路径)
cninfo_announcementwholemarket_events_sync 18:50 + evening_sync 18:30(自选股)STG stg_announcement → fox_announcement(16:00 事件域,RAW 过期自愈)
cninfo_stock_base_infocninfo_market_sync 19:00fox_stock_wide 融合的 A 股宇宙过滤;新鲜度体检分母 _a_share_count()
cninfo_disclosure_schedulecninfo_market_sync 19:00fox_engine/engine.py 个股披露日程直读;catalyst_service 催化剂日历
cninfo_periodic_report / cninfo_dividend_detail / cninfo_share_change / cninfo_shareholder_meetingcninfo_market_sync 19:00仅落库(datamgr 管理端浏览 + 新鲜度/清理登记),无融合链
cninfo_block_trade_stat / cninfo_block_trade_detail / cninfo_margin_trading / cninfo_stock_connect / cninfo_stock_connect_active / cninfo_bond / cninfo_lotterycninfo_market_sync 19:00仅落库(同上);大宗/两融的业务读走东财主源链(em_block_trade/em_margin_trading → STG → fox),cninfo 侧留作交叉核对底账

约定:"仅落库"不是终态。后续任何消费 cninfo 表的功能(交叉验证、独立指标)落地时 必须回填本表;反之若决定永久不消费,应从同步清单移除该表(省请求量),而不是留着 "看起来有数据"。新增 cninfo 表时同样先写本表再建表。

十五、RAW / STG 落库表消费登记(全源族,2026-09-26)​

§十四 只手工登记了 cninfo 一族;本节把同一铁律推广到全部源族 (em_ / tdx_ / ths_ / cninfo_ / tencent_ / jrj_ / sina_ / sw_ / szse_ / sse_ / ppi100_ / stg_,共 119 张落库表,2026-10-06 计数)。

唯一权威来源是代码登记表 services/data_quality/raw_consumers.py(不再是本文的 散文表——百余行的 markdown 表会与模型漂移且无强制力)。每张表归入三类之一:

类别含义判据来源
FUSION_TABLES(50)经 STG loader / fuser / exchange DWD 融合进入 fox_ 层STG_LOADERS[*].raw_tables 声明 + data_manager/exchange_fusion.py + 融合器实读
READ_PATH_TABLES(43)被 api/ 端点或 services/ 读路径直读(不经融合)双口径(表名 + ORM 类名)源码扫描
OFFLINE_TABLES(26)只有写入者,没有业务消费者——§十四 要暴露的静默死数据候选每表在 OFFLINE_REASONS 写明留库原因/建议动作

绊线:backend/tests/test_raw_consumers.py(CI 已纳入)强制登记完整性——所有 模型 RAW/STG 表必须恰好落进某一类(新增表不登记即红)、三类互斥、无陈旧条目、 仅落库表必须写原因。绊线刻意不静态判定死/活:消费关系既可能藏在 ORM 类名里、 也可能藏在 data_manager 下的融合(如 exchange_fusion 读 sse_/szse_),静态判活 会假阳性——"仅落库"是按 tools/_raw_scan.py 证据人工登记的结论。

OFFLINE_TABLES 是待办清单,其中值得点名的真实缺口:

  • 🔴 tdx_kline_minute —— intraday_minute_kline 每 5 分钟采集,却无任何 API/服务 读路径:数据一直在写、从来没人看。要么接线(分钟K线端点/图表),要么停采省请求量。
  • tencent_block_kline_daily / tencent_block_stock_snapshot —— block_sync 落的板块 K线/成分,当前仅管理端浏览,无对外板块读路径。
  • em_northbound_daily —— 源已冻结(见 §口径谎言),刻意只留底账。
  • 其余 sse_* / szse_* 底账 —— 交易所权威数据,进入 DWD 的只有 overview/industry/list 三链,其它按日全字段表留作交叉核对(与供应商口径分表,不顶替主源)。

后续为任一 OFFLINE 表接线消费时:把它从 OFFLINE_TABLES 移到对应类别、删其 OFFLINE_REASONS 条目即可,绊线会持续核对完整性;决定永久停采则从同步清单移除该表。

相关文档​

  • 数据流转 — 采集 → RAW → STG → DWD 的时序与任务编排
  • 数据模型总览 — 本文各层对应的表与列
  • 缓存策略 — 读路径 TTL 分层
  • 端点路由速查 — 契约在 HTTP 层的落地端点
  • 量化打分与形态体系 — §十三 方向列/度量列的下游消费者
  • docs/reports/data-coverage-backfill-2026-10-07.md(数据覆盖与回补报告) — 全量表最新数据覆盖日期、缺口分类与追赶计划