数据契约(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_COLS | symbol, dt, open, high, low, close, volume, amount |
分钟 CANONICAL_MINUTE_COLS | symbol, datetime, open, high, low, close, volume, amount(period 不在 canonical 常量内,由 KlineStore 写入路径 _write_minute_kline_store 补列,供 DuckDB 按周期过滤) |
复权因子 CANONICAL_ADJ_COLS | symbol, 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 |
dt | ISO YYYY-MM-DD(字符串或 date 对象) | 分钟为 datetime;跨源比较前先归一 |
| 复权价 | 不复权价 + 因子分离 | 前复权价 = 不复权 ÷ qfq_factor;禁止存储混算后的负价格序列(腾讯 qfq 深度历史可为负) |
四、已裁决的口径约束(不要"优化"掉)
- 指标计算的每步 round:
indicators.py的 KDJ/RSI 等在循环内逐步round(), 属复利式舍入语义——不可向量化替代(无法逐位复现),这是删除indicators_vector.py的历史原因(口径漂移)。有test_indicator_formulasparity 测试保护。 _save_multirow先删后插:全量覆盖语义,勿改 UPSERT(stale 行清理依赖删除); 已批量化(bulk_insert_mappings,2026-09-06 P2)。upsert_rows幂等 UPSERT:fox 融合层唯一写入口,勿回退先删后插。- 回填口径(2026-09-06):
market_phase_sync/industry_rank_sync/dragon_score_sync支持按历史日期回填(/schedule/{key}/runbody 传trade_date)。回填模式下 依赖当日分钟线的子因子(drive 带动板块/anti_drop)按历史日期不可得,取中性/跳过 并在 dims.details 标注 degraded;absorption 用 THS 5 分K 按日期过滤(深度约 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 层):
- SQLite 快路径(新鲜即返回)
- KlineStore(DuckDB/Parquet):
last_date ≥ 最近交易日且 行数 ≥ min(days, 30) ——覆盖度守卫:行情刷新会把当日快照写成单行日K,仅看新鲜度会让 120 天请求只返回 1 行 - MySQL
fox_kline_daily(新鲜度校验) - 实时降级链(腾讯 → 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}。
| 层级 | 数据源 | 门槛 | 说明 |
|---|---|---|---|
| L1 | fox_stock_wide | updated_at >= 最近交易日 且有效行数 ≥ 1000 | 一次 SQL(实测 0.2s);不新鲜/残缺即降级,避免把 16 天前数据当"今日涨幅榜" |
| L2 | 腾讯全市场实时批量 | 有效行数 ≥ 1 | tencent_market_top_gainers:fox_stock_master 枚举 → 约 10 批次(实测 3.0s);进程内缓存盘中 5min / 休市 30min |
| L3 | fox_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 主键,逐日累积):
| 层级 | 数据源 | 说明 |
|---|---|---|
| S1 | TDX 指数日K(上证 000001 / 深证综指 399106) | 原生带 amount,历史最全;当前网络环境实测不可用 |
| S2 | 腾讯实时指数行情 qt.gtimg.cn(sh000001 / sz399106) | 不封 IP;字段37=成交额(万元),仅当日值 → 每日采集即积累一天 |
| S3 | 交易所 RAW(sse/szse_market_overview) | ⚠️ 上交所 dt 列被回填污染(241 行同值)→ 加"同值即整源跳过"门禁;深交所可用 |
| S4 | fox_market_daily.total_amount(TDX/东财权威两市合计) | 本环境唯一有量级的历史合计(实测 239 日);按实测占比中位数拆分沪/深(source='split',quality='estimate') |
| S5 | fox_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,标明"落库渠道不可用")
▼ 仍失败 → 返回 {}
硬约束:
- 落库表不得成为融合的硬依赖——任何一级失败都必须能降级,融合不允许因落库链路 故障而断供(这是本模式与"把落库表当唯一源"的关键区别)。
- 覆盖率门禁:
read_quote_map(..., min_coverage=0.6)——覆盖率不足时返回{}而非残缺快照;宁可走自愈/直连,也不要用半份数据让融合悄悄丢字段。 - 自愈规模门禁:自愈 = 拉一次全市场(约 28 次 HTTP、~90s),只在请求达到全市场
尺度(
len(codes) >= _HEAL_MIN_CODES = 500)时触发;单只/少量代码的按需下载 直接走直连,避免一次单股请求意外拖出全市场拉取(同_raw_heal的"仅全市场"守卫)。 - 必须按日对齐:跨源 JOIN(如成交额 ÷ 净额、成交额修 K 线 amount)一律
ON 双方各自的日期列相等,禁止"用最新一天的成交额除以前一天的净额"。 - 单位在交界处归一:RAW 落库口径为 元/手;
gtimg口径为 万元/手/亿元,merge_quote_sources(写入方向 ×1e4 / ×1e8)与read_quote_map(读取方向 ÷1e4 / ÷1e8)负责双向换算,调用方永远拿tencent_quote_batch同形状。 - 三级路径都记日志(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 | 代码 801010 | sw_l1=<代码> | 板块中心 / 行业详情 |
| 申万二级 | fox_industry.sw_l2 | 代码 801012 | sw_l2=<代码> | 板块中心 / 行业详情 |
- 🔴 代码列不得写名称:
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 改版),代码稳定。 - 数据源:申万宏源研究官方
index_publish(清单/成分股/指数日线),本地增量由融合器自管 (官方 trend 接口无日期参数,全量返回 1999 起)。 - 分母口径:行业成交额占比 =
该行业当日 amount_yi ÷ Σ31 个申万一级行业当日 amount_yi。 分母不落库、由读路径现算;denominator_complete=false表示当日不足 31 个行业, 占比仅供参考(前端显式提示)。该分母与"全市场成交额"不同源,只用于行业间横向比较。 - 分位输入是占比不是成交额绝对值:绝对值受全市场量能整体放大影响,个股/行业自身 的历史分位必须在"份额"维度上算。样本 = 近 365/730 个自然日窗口内的有效交易日。
- 单位:
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_amount | q.trade_date = k.dt |
fox_fund_flow_daily.main_net_ratio_pct | = main_net / q.total_amount * 100 | q.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:20fox_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_residue | fox_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_residue | fox_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_source | sentiment | tdx_indices / em_indices;被 _backfill_sentiment_indices 校正后记并集 原源+tdx_indices |
_breadth_source | sentiment、distribution | sentiment:tdx_market_breadth / db_market_breadth / tencent_market_breadth_batch / em_ulist_np / em_market_breadth;distribution 见下表 |
_limit_source | distribution、sentiment.breadth、fund_flow | tencent_limit_codes(其炸板/封板率另取 em_push2ex)/ em_push2ex |
_bins_source | distribution | tdx_full / eastmoney / fox_stock_wide(配 _bins_date 记数据日期) |
_sectors_source | industry_rank | tencent_industry_rank / db_sector_performance / em_sector_performance |
_cells_source | sector_treemap | db_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_full | tdx_full | 1 次 TCP 全量 |
| 补位② 东财区间统计(家数+分段同批) | em_market_breadth | eastmoney | 最多 15 请求 |
| 补位③ 宽表收盘快照(家数+分段同批,仅非交易时段) | db_distribution_bins_from_wide | fox_stock_wide | 0 请求 |
主链路(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 段,所以分段仍由补位链负责。
约定(改这几条链路前先看):
- 标签值写取数函数名,不写上游厂商名——函数内部换源时标签跟着函数走,不会悄悄失真
(如
tdx_indices实为"腾讯优先 + TDX 补齐",标签只承诺"出自这条链")。 - 出处随值改:任何后置覆盖(涨跌停家数校正、指数补齐、bins 重建)必须同步改写 对应标签,否则标签会指向已经不存在的那个值。
- 空值不写入(
""/None不落键):让"没标注"与"标注为空串"可区分;不写标签的段 即"该段无独立出处"(其出处就是载荷级_source)。 - hot_stocks 不走段级标签:行级
_source/_as_of已由build_hot_stocks提升为载荷级source/source_label/as_of(见 §六),再打一层标签是重复信息。 - distribution 的家数有独立标签
_breadth_source,分段另有_bins_source,两者可以 不同源:家数走东财ulist.np、分段由补位链给出时就是这种状态。家数优先、 分段补位,补位链每一棒连同家数一起替换,出处标签必须跟着改写,绝不出现 "家数已是 TDX 口径、标签还指着东财"。 - 同一纪律也适用于按行落库的时序表:
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下降) |
由此推出三条约束:
- 写行门槛 =
has_signal(pat) or has_structural_signal(struct):只有度量列非零的行 (处于鸭颈/鸭头回落、箱体成形中)也必须落行,否则选股器读本表永远查不到这些筛选条件。 - 度量列不得混进 signals:
api/quant.py把输出拆成structural(方向)/duck(老鸭头族 度量)/metrics(图形形态族度量)三块,两块都空则为None而非全零占位——否则「回调 8%」 会被下游当成买入信号播报。 - 形态战绩只评测方向列:
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_announcement | wholemarket_events_sync 18:50 + evening_sync 18:30(自选股) | STG stg_announcement → fox_announcement(16:00 事件域,RAW 过期自愈) |
cninfo_stock_base_info | cninfo_market_sync 19:00 | fox_stock_wide 融合的 A 股宇宙过滤;新鲜度体检分母 _a_share_count() |
cninfo_disclosure_schedule | cninfo_market_sync 19:00 | fox_engine/engine.py 个股披露日程直读;catalyst_service 催化剂日历 |
cninfo_periodic_report / cninfo_dividend_detail / cninfo_share_change / cninfo_shareholder_meeting | cninfo_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_lottery | cninfo_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 条目即可,绊线会持续核对完整性;决定永久停采则从同步清单移除该表。