# xiaobai-datahub

独立行情数据中枢（HEL-382 / P0）。与 `xiaobai-review` 同仓库、不同容器、不共享数据库文件。
本阶段不部署现网；只提供可本地运行、可自测的底座和盘后正式数据链路。

## 做什么

- SQLite WAL `datahub.db`，容器名 `xiaobai-datahub`，端口 `8766`
- Tushare 盘后正式数据：交易日历、股票主档、daily、daily_basic、adj_factor、index_daily、moneyflow、stk_auction
- 暂存 → 校验 → 整批原子发布 → 可回滚
- `/v1` 稳定接口（`X-Datahub-Token`）
- `/admin/` 最小管理后台（总览 / 数据源 / 调度 / 发布 / 数据集 / 审计）
- 东财/腾讯/同花顺/选股宝/AKShare/iFinD 适配器位已预留，本阶段不拉实时源

## 单位口径（相对现站）

现站 `xiaobai-review` 按 Tushare 原始单位入库、展示时再换算。中枢在归一化层一次换算：

| 字段 | Tushare / 现站 | 中枢 canonical |
|---|---|---|
| `daily.amount` / `index_daily.amount` | 千元 | 元（×1000） |
| `daily.vol` / `index_daily.vol` | 手 | 股（×100） |
| `moneyflow.*_amount` | 万元 | 元（×1e4） |
| `daily_basic.total_mv` / `circ_mv` | 万元 | 元（×1e4） |
| `stk_auction.amount` | 元 | 元 |

差异为口径升级，golden 测试按上表对照，不为 0 的字段都有说明。

## 本地启动（不走 Docker）

```bash
cd xiaobai-datahub
python -m venv .venv && .venv/bin/pip install -r requirements.txt
cp .env.example .env
# 填入 DATAHUB_ENCRYPTION_KEY / DATAHUB_TOKEN / DATAHUB_ADMIN_PASSWORD / TUSHARE_TOKEN
# 生成 Fernet 密钥：
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
.venv/bin/python server.py --host 127.0.0.1 --port 8766
```

- 管理后台：http://127.0.0.1:8766/admin/
- 存活检查：http://127.0.0.1:8766/livez （无需 token）
- `/v1/*` 必须带请求头 `X-Datahub-Token`

## Docker（独立 compose，不改现网 review 服务）

```bash
cd xiaobai-datahub
cp .env.example .env   # 填密钥
mkdir -p data
docker compose build
docker compose up -d
```

仓库根目录另有 `compose.datahub.yaml`，供总工以后与现有 `compose.yaml` 叠加部署，本卡不执行现网 `up`。

## 自测

```bash
cd xiaobai-datahub
python -m unittest discover -s tests -v
```

不调用真实 Tushare；用内存/临时库和假适配器。

## 历史回补

交易日历默认从 `20160101` 拉到今天后 30 天；盘前 `precheck` 与手动回补都走同一 UPSERT，可重复执行。

网站实际使用的指数（上证、深成、创业板、沪深300）按交易日增量发布，默认覆盖 260 个交易日（大于现有 90 天窗口，并覆盖智能选股基准回看）。已发布日期默认跳过。

```bash
cd xiaobai-datahub
python -m datahub history-backfill
# 可选：--calendar-start 20160101 --index-days 260 --force
```

管理后台也可手动跑 `history_backfill` 任务，或 `POST /admin/api/backfill` 且 `dataset=history`、确认词 `history:full`。

区间接口在 `meta.coverage` / `meta.incomplete` 标明覆盖是否完整；网站只读接入把不完整区间视为不可用并回旧链路。个股日 K 的 90 天区间查询依赖已核实，本阶段不回补全市场历史。

## 估值字段级质量门

`hub-quality.config.json` 的 `field_gates` 按数据集配置关键字段：非空率下限（支持按字段覆盖，如 `dv_ttm` 合法高空值）、非有限值比例上限、以及相对上一已发布批次的非空率塌陷保护。字段大面积为空的批次会被拒绝发布、保留上一份正常正式数据，失败原因逐字段写入 `batches.error` / `quality_json`。被拒后数据集仍视为缺失，盘后自动重试（HEL-435 机制）会继续尝试直到成功或截止。配置对任意数据集生效，不写死单日或单字段。

## 整批原子发布（release group）

盘后发布/重发（eod_a、eod_retry、`eod-refresh`、跨数据集重发）不再逐数据集各自切换，而是走整批原子可见机制：

- 一致性边界：日 K、估值、资金流、竞价同属 A 组整批；指数日 K 为 B 组；当日股票主档快照随 A 组一同切换（主档 `stock_master` 的 UPSERT 与快照发布同一事务，不会出现主档先行/滞后）。
- 流程：组内全部成员先在暂存表完成拉取、字段质量门、覆盖检查和跨数据集交叉校验（`cross_gates` 配置 ts_code 覆盖重叠率下限），全部达标后才在**一个 SQLite 事务**里复制正式表并翻转全部 `publications` 指针。
- 任一成员失败（拉取失败、质量门拒绝、交叉校验不过、切换事务中断）→ 整批不切换，对外继续提供上一份完整正式版本，失败原因写入 `batches.error` 与 `audit_log`（`action=release-group`），等待晚间自动重试。
- 读取侧任何时刻只会看到"旧完整版本"或"新完整版本"：发布指针在单事务内统一翻转，容器重启/事务中断自动回滚，不暴露字段残缺或跨数据集混合版本。
- 幂等：仅当一致性边界内全部成员都已发布时才整组跳过；边界内任有缺失则整组重暂存后统一切换，避免旧批次与新批次混在同一次重发中。重复执行、并发重试不会在完整边界已就绪时生成重复批次（调度器另有 EOD 互斥锁）。

## 股票主档每日刷新与发布

交易日 20:00 与 23:10（`stocks_refresh_times` 可配）自动刷新股票主档并发布版本化快照（`eod_stocks` + `publications.dataset='stocks'`），覆盖当日新上市、证券简称变化和上市首日 N/C 前缀摘除；无变化则跳过，重复执行幂等。`/v1/stocks` 从最新已发布快照提供数据并带 `batch_id` / `published_at`；`/v1/datasets/status` 同步展示 stocks 状态。

```bash
cd xiaobai-datahub
python -m datahub stocks-refresh            # 手动触发；--force 无变化也重发
```

## 资金流历史回补

网站会沿真实调用链查最近若干交易日的 moneyflow（个股详情任意日期点查 + 智能选股最近 5 个交易日），默认回补最近 60 个交易日（`moneyflow_history_trading_days` 可配，已发布日期自动跳过）。点查未覆盖的历史日期返回 `DATASET_NOT_PUBLISHED` 并附 `available_from` / `available_to`（低于下界时 `reason=history_not_backfilled`），网站据此明确回退旧链路，不会静默拿到半截数据。

```bash
cd xiaobai-datahub
python -m datahub moneyflow-backfill        # --trading-days 60 --end-date --force 可选
```

## 盘后补跑与强制重发

```bash
cd xiaobai-datahub
python -m datahub eod-refresh --trade-date 20260904                  # 补不完整的 A/B 边界
python -m datahub eod-refresh --trade-date 20260904 --force --dataset valuation
# --force 按一致性边界整组重发：valuation/daily/moneyflow/auction/stocks → A 组；
# index_daily → B 组。不可再单独切换某一个正式数据集。
```

管理后台「补数」对盘后正式数据集同样走 `force_republish_boundary`，不会绕过 A/B 整批边界。

## 估值发布后复核与自动追补

Tushare `daily_basic` 会在盘后继续改当日字段。HEL-423 在 2026-09-07 观察到：中枢 17:10 发布 `003021.SZ turnover_rate=1.3565`，21:05 上游/旧链路已是 `1.3572`；其余 7 类观察对象当日一致。日 K、资金流、竞价、指数没有同类晚间修订证据，股票主档已有 20:00/23:10 刷新，因此默认只复核估值，不盲目全量重拉。

窗口（可配）：交易日 **20:00–23:20**，每 30 分钟一次轻量比对（对齐网站 21:00 / 23:30 观察）。只拉取 `daily_basic`，按网站真实请求字段精确比较，无误差豁免。

- 无变化：不产生新批次，状态「已追平」。
- 发现修订：重新走字段质量门、覆盖检查和 A 组整批原子发布；读者全程只能看到上一完整版本或新完整版本。
- 上游空 / 接口失败 / 不完整 / 质量门拒绝：保留上一完整版本，状态「复核失败」。
- 23:20 截止后停止当晚复核；下一自然日盘前对上一交易日再做一次安全追赶。
- 与 `eod_a` / `eod_retry` 共用互斥锁；容器重启会在窗口内立即补一次。

## 备份

每日 00:40 任务把 `datahub.db` 备份到 `data/backups/`（保留 14 份）。也可手动：

```bash
python -c "from pathlib import Path; from datahub.db import HubDB; HubDB(Path('data/datahub.db')).backup_to(Path('data/backups/manual.db'))"
```

## 安全

- 密钥只以 `configured / 末4位 / 更新时间` 出现在后台，不进日志、不进 `/v1`
- HTTP 解析失败只记录“请求不是合法 JSON”，不把请求正文、密码或 Token 写入容器日志
- 回滚、补数需重新输入密码 + 确认词
- 容器非 root（uid 10002）、read_only、cap_drop ALL
