Compare commits

...
Author SHA1 Message Date
multica-agentandmultica-agent bed6450992 feat(HEL-457): 估值字段级质量门、股票主档每日发布和资金流历史回补
- field_gates 按数据集配置关键字段非空率下限/非有限比例/相对上一批次的塌陷保护,
  字段大面积为空的批次拒发并保留上一正式批次,可读失败原因入 batches.error
- 股票主档交易日 20:00/23:10 自动刷新并发布版本化快照(eod_stocks + publications),
  覆盖新上市/简称变化/N前缀摘除;/v1/stocks 携带 batch_id/published_at,无变化跳过
- moneyflow 历史回补(默认 60 交易日,跳过已发布日期);未发布点查返回
  available_from/available_to 与 history_not_backfilled 标记,缺失不再静默
- eod-refresh 新增 --force --dataset 安全重发(仍走全部质量门,上一批次可回滚)
- 保持 HEL-435 盘后重试机制;新增 22 项测试覆盖字段拒发/正常通过/旧批保留/
  主档新增改名/资金流覆盖/重复执行幂等

Co-authored-by: multica-agent <github@multica.ai>
2026-09-04 21:36:20 +08:00
14 changed files with 1058 additions and 37 deletions
+32
View File
@@ -79,6 +79,38 @@ python -m datahub history-backfill
区间接口在 `meta.coverage` / `meta.incomplete` 标明覆盖是否完整;网站只读接入把不完整区间视为不可用并回旧链路。个股日 K 的 90 天区间查询依赖已核实,本阶段不回补全市场历史。 区间接口在 `meta.coverage` / `meta.incomplete` 标明覆盖是否完整;网站只读接入把不完整区间视为不可用并回旧链路。个股日 K 的 90 天区间查询依赖已核实,本阶段不回补全市场历史。
## 估值字段级质量门
`hub-quality.config.json``field_gates` 按数据集配置关键字段:非空率下限(支持按字段覆盖,如 `dv_ttm` 合法高空值)、非有限值比例上限、以及相对上一已发布批次的非空率塌陷保护。字段大面积为空的批次会被拒绝发布、保留上一份正常正式数据,失败原因逐字段写入 `batches.error` / `quality_json`。被拒后数据集仍视为缺失,盘后自动重试(HEL-435 机制)会继续尝试直到成功或截止。配置对任意数据集生效,不写死单日或单字段。
## 股票主档每日刷新与发布
交易日 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 # 只补缺失数据集
python -m datahub eod-refresh --trade-date 20260904 --force --dataset valuation
# 强制重取重发:仍走全部质量门,生成新批次,上一批次保留可回滚
```
## 备份 ## 备份
每日 00:40 任务把 `datahub.db` 备份到 `data/backups/`(保留 14 份)。也可手动: 每日 00:40 任务把 `datahub.db` 备份到 `data/backups/`(保留 14 份)。也可手动:
+27 -1
View File
@@ -16,5 +16,31 @@
"index_history_trading_days": 260, "index_history_trading_days": 260,
"eod_retry_start": "15:15", "eod_retry_start": "15:15",
"eod_retry_interval_minutes": 30, "eod_retry_interval_minutes": 30,
"eod_retry_cutoff": "23:30" "eod_retry_cutoff": "23:30",
"moneyflow_history_trading_days": 60,
"stocks_refresh_times": [
"20:00",
"23:10"
],
"field_gates": {
"valuation": {
"fields": [
"turnover_rate",
"volume_ratio",
"total_mv",
"circ_mv",
"pe_ttm",
"pb",
"ps_ttm",
"dv_ttm"
],
"min_nonnull_rate": 0.9,
"min_nonnull_rate_by_field": {
"pe_ttm": 0.5,
"dv_ttm": 0.3
},
"max_nonnull_drop_vs_prev": 0.15,
"max_nonfinite_rate": 0.01
}
}
} }
+2
View File
@@ -84,12 +84,14 @@ class AdminAPI:
def jobs(self) -> dict[str, Any]: def jobs(self) -> dict[str, Any]:
runs = self.db.fetchall("SELECT * FROM job_runs ORDER BY id DESC LIMIT 100") runs = self.db.fetchall("SELECT * FROM job_runs ORDER BY id DESC LIMIT 100")
stocks_times = "/".join(self.pipeline.settings.stocks_refresh_times) or "20:00"
return { return {
"jobs": [ "jobs": [
{"id": "precheck", "at": "08:45", "title": "盘前预检"}, {"id": "precheck", "at": "08:45", "title": "盘前预检"},
{"id": "eod_a", "at": "15:05", "title": "盘后批 A daily/valuation/moneyflow/auction"}, {"id": "eod_a", "at": "15:05", "title": "盘后批 A daily/valuation/moneyflow/auction"},
{"id": "eod_b", "at": "15:10", "title": "盘后批 B index_daily"}, {"id": "eod_b", "at": "15:10", "title": "盘后批 B index_daily"},
{"id": "eod_retry", "at": "15:15-23:30", "title": "盘后未出数自动重试(每 30 分钟,成功即停)"}, {"id": "eod_retry", "at": "15:15-23:30", "title": "盘后未出数自动重试(每 30 分钟,成功即停)"},
{"id": "stocks_refresh", "at": stocks_times, "title": "股票主档刷新与正式发布(新上市/更名,无变化跳过)"},
{"id": "history_backfill", "at": "manual", "title": "回补历史日历与指数日 K"}, {"id": "history_backfill", "at": "manual", "title": "回补历史日历与指数日 K"},
{"id": "cleanup", "at": "00:30", "title": "清理 staging / 日志"}, {"id": "cleanup", "at": "00:30", "title": "清理 staging / 日志"},
{"id": "backup", "at": "00:40", "title": "SQLite 备份"}, {"id": "backup", "at": "00:40", "title": "SQLite 备份"},
+59 -1
View File
@@ -7,6 +7,7 @@ import json
import sys import sys
from datahub.hub import build_hub from datahub.hub import build_hub
from datahub.pipeline import OFFICIAL_DATASETS
from datahub.settings import load_settings from datahub.settings import load_settings
from datahub.timeutil import yyyymmdd from datahub.timeutil import yyyymmdd
@@ -20,6 +21,23 @@ def main(argv: list[str] | None = None) -> int:
history.add_argument("--force", action="store_true", help="覆盖已发布的指数日期") history.add_argument("--force", action="store_true", help="覆盖已发布的指数日期")
refresh = sub.add_parser("eod-refresh", help="对指定交易日补跑盘后正式数据(跳过已发布数据集,仍走质量门禁)") refresh = sub.add_parser("eod-refresh", help="对指定交易日补跑盘后正式数据(跳过已发布数据集,仍走质量门禁)")
refresh.add_argument("--trade-date", default=None, help="交易日 YYYYMMDD,默认今天") refresh.add_argument("--trade-date", default=None, help="交易日 YYYYMMDD,默认今天")
refresh.add_argument(
"--force", action="store_true",
help="对 --dataset 指定的数据集强制重取重发(生成新批次,保留上一批次可回滚)",
)
refresh.add_argument(
"--dataset", default=None,
help="配合 --force 使用:只强制重发该数据集(如 valuation)",
)
stocks_refresh = sub.add_parser("stocks-refresh", help="刷新股票主档并发布正式快照(幂等:无变化则跳过)")
stocks_refresh.add_argument("--trade-date", default=None, help="交易日 YYYYMMDD,默认今天")
stocks_refresh.add_argument("--force", action="store_true", help="即使快照无变化也重新发布")
moneyflow_backfill = sub.add_parser(
"moneyflow-backfill", help="回补资金流历史(默认覆盖网站所需的最近 N 个交易日,跳过已发布日期)",
)
moneyflow_backfill.add_argument("--end-date", default=None, help="截止交易日 YYYYMMDD,默认今天")
moneyflow_backfill.add_argument("--trading-days", type=int, default=None, help="回补交易日数量,默认配置 moneyflow_history_trading_days")
moneyflow_backfill.add_argument("--force", action="store_true", help="覆盖已发布的资金流日期")
args = parser.parse_args(argv) args = parser.parse_args(argv)
settings = load_settings() settings = load_settings()
@@ -35,16 +53,56 @@ def main(argv: list[str] | None = None) -> int:
return 0 if result.get("ok") else 1 return 0 if result.get("ok") else 1
if args.command == "eod-refresh": if args.command == "eod-refresh":
day = yyyymmdd(args.trade_date) if args.trade_date else yyyymmdd() day = yyyymmdd(args.trade_date) if args.trade_date else yyyymmdd()
if args.force:
datasets = tuple(sorted({args.dataset} & OFFICIAL_DATASETS)) if args.dataset else ()
if args.dataset and not datasets:
parser.error(f"unknown dataset: {args.dataset}")
if not datasets:
parser.error("--force requires --dataset (e.g. --dataset valuation)")
result = {}
for dataset in datasets:
result[dataset] = hub.pipeline.run_dataset(dataset, day)
else:
result = hub.pipeline.run_eod_missing(day) result = hub.pipeline.run_eod_missing(day)
hub.pipeline.audit("cli", "eod-refresh", f"eod:{day}", json.dumps( hub.pipeline.audit("cli", "eod-refresh", f"eod:{day}", json.dumps(
{name: item.get("state") for name, item in result.items() if isinstance(item, dict)}, {"force": bool(args.force), "dataset": args.dataset,
**{name: item.get("state") for name, item in result.items() if isinstance(item, dict)}},
ensure_ascii=False, ensure_ascii=False,
)) ))
if args.force:
payload = {"trade_date": day, "datasets": result}
json.dump(payload, sys.stdout, ensure_ascii=False, indent=2, default=str)
sys.stdout.write("\n")
return 0
missing = hub.pipeline.missing_official_datasets(day) missing = hub.pipeline.missing_official_datasets(day)
payload = {"trade_date": day, "datasets": result, "missing_after": missing} payload = {"trade_date": day, "datasets": result, "missing_after": missing}
json.dump(payload, sys.stdout, ensure_ascii=False, indent=2, default=str) json.dump(payload, sys.stdout, ensure_ascii=False, indent=2, default=str)
sys.stdout.write("\n") sys.stdout.write("\n")
return 0 if not missing else 1 return 0 if not missing else 1
if args.command == "stocks-refresh":
day = yyyymmdd(args.trade_date) if args.trade_date else yyyymmdd()
result = hub.pipeline.refresh_stocks(day, force=args.force)
hub.pipeline.audit("cli", "stocks-refresh", f"stocks:{day}", json.dumps(
{"force": bool(args.force), "state": result.get("state"), "batch_id": result.get("batch_id")},
ensure_ascii=False,
))
json.dump(result, sys.stdout, ensure_ascii=False, indent=2, default=str)
sys.stdout.write("\n")
return 0 if result.get("state") != "failed" else 1
if args.command == "moneyflow-backfill":
result = hub.pipeline.backfill_moneyflow_history(
end_date=args.end_date,
trading_days=args.trading_days,
force=args.force,
)
hub.pipeline.audit("cli", "moneyflow-backfill", f"moneyflow:{result.get('end')}", json.dumps(
{"published": len(result.get("published") or []), "skipped": len(result.get("skipped") or []),
"failed": len(result.get("failed") or [])},
ensure_ascii=False,
))
json.dump(result, sys.stdout, ensure_ascii=False, indent=2, default=str)
sys.stdout.write("\n")
return 0 if result.get("ok") else 1
parser.error(f"unknown command: {args.command}") parser.error(f"unknown command: {args.command}")
return 2 return 2
+16
View File
@@ -114,6 +114,21 @@ CREATE TABLE IF NOT EXISTS eod_index_bars (
PRIMARY KEY (ts_code, trade_date, batch_id) PRIMARY KEY (ts_code, trade_date, batch_id)
) WITHOUT ROWID; ) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS eod_stocks (
ts_code TEXT NOT NULL, trade_date TEXT NOT NULL,
symbol TEXT, name TEXT, area TEXT, industry TEXT, market TEXT,
list_status TEXT, list_date TEXT,
batch_id TEXT NOT NULL,
PRIMARY KEY (ts_code, trade_date, batch_id)
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS staging_stocks (
ts_code TEXT NOT NULL, trade_date TEXT NOT NULL, batch_id TEXT NOT NULL,
symbol TEXT, name TEXT, area TEXT, industry TEXT, market TEXT,
list_status TEXT, list_date TEXT,
PRIMARY KEY (batch_id, ts_code, trade_date)
);
CREATE TABLE IF NOT EXISTS staging_bars ( CREATE TABLE IF NOT EXISTS staging_bars (
ts_code TEXT NOT NULL, trade_date TEXT NOT NULL, batch_id TEXT NOT NULL, ts_code TEXT NOT NULL, trade_date TEXT NOT NULL, batch_id TEXT NOT NULL,
open REAL, high REAL, low REAL, close REAL, pct_chg REAL, open REAL, high REAL, low REAL, close REAL, pct_chg REAL,
@@ -273,6 +288,7 @@ DATASET_TABLES = {
"moneyflow": ("eod_moneyflow", "staging_moneyflow"), "moneyflow": ("eod_moneyflow", "staging_moneyflow"),
"auction": ("eod_auction", "staging_auction"), "auction": ("eod_auction", "staging_auction"),
"index_daily": ("eod_index_bars", "staging_index_bars"), "index_daily": ("eod_index_bars", "staging_index_bars"),
"stocks": ("eod_stocks", "staging_stocks"),
} }
+265 -7
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import math
import time import time
from collections.abc import Callable from collections.abc import Callable
from datetime import timedelta from datetime import timedelta
@@ -22,11 +23,21 @@ LOGGER = get_logger()
HARD_DATASETS = {"daily", "valuation", "index_daily"} HARD_DATASETS = {"daily", "valuation", "index_daily"}
SOFT_DATASETS = {"moneyflow", "auction"} SOFT_DATASETS = {"moneyflow", "auction"}
OFFICIAL_DATASETS = HARD_DATASETS | SOFT_DATASETS OFFICIAL_DATASETS = HARD_DATASETS | SOFT_DATASETS
STOCKS_DATASET = "stocks"
STOCK_SNAPSHOT_FIELDS = ("ts_code", "symbol", "name", "area", "industry", "market", "list_status", "list_date")
EOD_A_DATASETS = ("daily", "valuation", "moneyflow", "auction") EOD_A_DATASETS = ("daily", "valuation", "moneyflow", "auction")
EOD_B_DATASETS = ("index_daily",) EOD_B_DATASETS = ("index_daily",)
EMPTY_BATCH_ERROR = "empty official batch: 0 valid rows" EMPTY_BATCH_ERROR = "empty official batch: 0 valid rows"
STAGING_INSERT = { STAGING_INSERT = {
"stocks": (
"INSERT INTO staging_stocks(ts_code,trade_date,batch_id,symbol,name,area,industry,market,list_status,list_date) "
"VALUES (?,?,?,?,?,?,?,?,?,?)",
lambda r, b: (
r["ts_code"], r["trade_date"], b, r.get("symbol"), r.get("name"), r.get("area"),
r.get("industry"), r.get("market"), r.get("list_status"), r.get("list_date"),
),
),
"daily": ( "daily": (
"INSERT INTO staging_bars(ts_code,trade_date,batch_id,open,high,low,close,pct_chg,volume,amount,adj_factor) " "INSERT INTO staging_bars(ts_code,trade_date,batch_id,open,high,low,close,pct_chg,volume,amount,adj_factor) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?)", "VALUES (?,?,?,?,?,?,?,?,?,?,?)",
@@ -72,6 +83,11 @@ STAGING_INSERT = {
} }
EOD_COPY = { EOD_COPY = {
"stocks": (
"INSERT OR REPLACE INTO eod_stocks "
"SELECT ts_code,trade_date,symbol,name,area,industry,market,list_status,list_date,batch_id "
"FROM staging_stocks WHERE batch_id = ?"
),
"daily": ( "daily": (
"INSERT OR REPLACE INTO eod_bars " "INSERT OR REPLACE INTO eod_bars "
"SELECT ts_code,trade_date,open,high,low,close,pct_chg,volume,amount,adj_factor,batch_id " "SELECT ts_code,trade_date,open,high,low,close,pct_chg,volume,amount,adj_factor,batch_id "
@@ -101,6 +117,13 @@ EOD_COPY = {
} }
def _finite(value: Any) -> bool:
try:
return math.isfinite(float(value))
except (TypeError, ValueError):
return False
def _staging_row_count(connection: Any, dataset: str, batch_id: str) -> int: def _staging_row_count(connection: Any, dataset: str, batch_id: str) -> int:
table = DATASET_TABLES[dataset][1] table = DATASET_TABLES[dataset][1]
row = connection.execute( row = connection.execute(
@@ -176,6 +199,17 @@ class Pipeline:
""", """,
(row["exchange"], row["cal_date"], row["is_open"], row.get("pretrade_date"), fetched_at), (row["exchange"], row["cal_date"], row["is_open"], row.get("pretrade_date"), fetched_at),
) )
self._upsert_stock_master(connection, stocks, fetched_at)
return {
"calendar": len(calendar),
"stocks": len(stocks),
"trade_date": day,
"calendar_from": start,
"calendar_to": end,
}
@staticmethod
def _upsert_stock_master(connection: Any, stocks: list[dict[str, Any]], fetched_at: str) -> None:
for row in stocks: for row in stocks:
connection.execute( connection.execute(
""" """
@@ -193,12 +227,133 @@ class Pipeline:
row.get("list_date"), fetched_at, row.get("list_date"), fetched_at,
), ),
) )
def latest_stocks_publication(self, on_or_before: str | None = None) -> dict[str, Any] | None:
if on_or_before:
row = self.db.fetchone(
"SELECT * FROM publications WHERE dataset = ? AND trade_date <= ? ORDER BY trade_date DESC LIMIT 1",
(STOCKS_DATASET, yyyymmdd(on_or_before)),
)
else:
row = self.db.fetchone(
"SELECT * FROM publications WHERE dataset = ? ORDER BY trade_date DESC LIMIT 1",
(STOCKS_DATASET,),
)
return row
def published_stock_snapshot(self, on_or_before: str | None = None) -> tuple[str | None, list[dict[str, Any]]]:
pub = self.latest_stocks_publication(on_or_before)
if not pub:
return None, []
rows = self.db.fetchall(
f"SELECT {','.join(STOCK_SNAPSHOT_FIELDS)} FROM eod_stocks WHERE batch_id = ? ORDER BY ts_code",
(pub["active_batch"],),
)
return str(pub["active_batch"]), rows
def refresh_stocks(self, trade_date: str | None = None, force: bool = False) -> dict[str, Any]:
"""Refresh stock master from upstream and publish a versioned snapshot.
Runs on trading days (scheduler) and via CLI. Idempotent: when the
latest published snapshot already matches the upstream list, nothing
is published; ``force`` re-publishes unconditionally. New listings,
renames (incl. N/C prefix removal) and status changes all flow into
the snapshot, which carries batch_id/published_at metadata.
"""
day = yyyymmdd(trade_date or self.clock())
rows = self._fetch_dataset(STOCKS_DATASET, day)
with self.db.write() as connection:
self._upsert_stock_master(connection, rows, isoformat(self.clock()))
if not force:
active, snapshot = self.published_stock_snapshot(day)
if active is not None:
upstream = sorted(
tuple(str(row.get(field)) for field in STOCK_SNAPSHOT_FIELDS) for row in rows
)
published = sorted(tuple(str(row.get(field)) for field in STOCK_SNAPSHOT_FIELDS) for row in snapshot)
if upstream == published:
return { return {
"calendar": len(calendar), "dataset": STOCKS_DATASET,
"stocks": len(stocks),
"trade_date": day, "trade_date": day,
"calendar_from": start, "state": "skipped",
"calendar_to": end, "reason": "unchanged",
"batch_id": active,
"rows": len(snapshot),
}
result = self.run_dataset(STOCKS_DATASET, day, prepared_rows=rows)
self.audit(
"pipeline", "stocks-refresh", f"{STOCKS_DATASET}:{day}",
json.dumps({"batch_id": result["batch_id"], "rows": result["rows"]}, ensure_ascii=False),
)
return result
def backfill_moneyflow_history(
self,
end_date: str | None = None,
trading_days: int | None = None,
force: bool = False,
) -> dict[str, Any]:
"""Incrementally publish official moneyflow history for the website window.
The website queries moneyflow for any navigable trade date (stock
detail point queries) and for the screener's last-5-days window, so
the hub must cover a trailing window of trading days instead of only
days published since go-live. Already published dates are skipped
unless ``force``; per-date failures are recorded without aborting.
"""
end = yyyymmdd(end_date or self.clock())
limit = int(trading_days or self.settings.moneyflow_history_trading_days)
open_dates = self.open_trade_dates(end, limit)
if not open_dates:
return {
"start": None,
"end": end,
"requested_days": 0,
"published": [],
"skipped": [],
"failed": [{"error": "calendar has no open dates on or before end"}],
"ok": False,
}
start = open_dates[0]
published_dates: set[str] = set()
if not force:
pubs = self.db.fetchall(
"SELECT trade_date FROM publications WHERE dataset = 'moneyflow' AND trade_date >= ? AND trade_date <= ?",
(start, end),
)
published_dates = {str(row["trade_date"]) for row in pubs}
targets = [day for day in open_dates if day not in published_dates]
skipped = [day for day in open_dates if day in published_dates]
published: list[dict[str, Any]] = []
failed: list[dict[str, Any]] = []
for day in targets:
try:
raw = retry_call(
lambda day=day: self._guarded_fetch("moneyflow", {"trade_date": day}),
attempts=self.settings.max_publish_attempts,
base_delay=0.05,
sleeper=lambda _d: time.sleep(_d),
)
rows = self.adapter.normalize("moneyflow", raw)
result = self.run_dataset("moneyflow", day, prepared_rows=rows)
published.append(
{
"trade_date": day,
"batch_id": result["batch_id"],
"rows": result["rows"],
"state": result["state"],
}
)
except Exception as exc:
failed.append({"trade_date": day, "error": str(exc)})
return {
"start": start,
"end": end,
"requested_days": len(open_dates),
"published": published,
"skipped": skipped,
"failed": failed,
"ok": not failed,
} }
def open_trade_dates(self, end: str, limit: int) -> list[str]: def open_trade_dates(self, end: str, limit: int) -> list[str]:
@@ -464,13 +619,14 @@ class Pipeline:
if null_rate >= float(quality.get("null_rate_max") or 0.01): if null_rate >= float(quality.get("null_rate_max") or 0.01):
errors.append(f"null rate {null_rate:.4f}") errors.append(f"null rate {null_rate:.4f}")
empty = row_n == 0 empty = row_n == 0
if empty and dataset in OFFICIAL_DATASETS: if empty and (dataset in OFFICIAL_DATASETS or dataset == STOCKS_DATASET):
errors.append(EMPTY_BATCH_ERROR) errors.append(EMPTY_BATCH_ERROR)
field_report = self._field_gate(dataset, trade_date, rows, errors)
if dataset in SOFT_DATASETS: if dataset in SOFT_DATASETS:
hard_fail = bool(dup or bad_date or empty) hard_fail = bool(dup or bad_date or empty)
else: else:
hard_fail = bool(errors) and dataset in HARD_DATASETS hard_fail = bool(errors) and (dataset in HARD_DATASETS or dataset == STOCKS_DATASET)
return { report = {
"rows": row_n, "rows": row_n,
"listed": listed_n, "listed": listed_n,
"ratio": round(ratio, 4), "ratio": round(ratio, 4),
@@ -480,6 +636,103 @@ class Pipeline:
"soft_fail": bool(warnings) and not hard_fail, "soft_fail": bool(warnings) and not hard_fail,
"batch_id": batch_id, "batch_id": batch_id,
} }
if field_report is not None:
report["fields"] = field_report
return report
def _field_gate(
self,
dataset: str,
trade_date: str,
rows: list[dict[str, Any]],
errors: list[str],
) -> dict[str, Any] | None:
"""Config-driven per-field completeness gate.
Catches field-level half-products (rows complete, key columns empty)
that row-count gates miss: non-null rate floors per field, non-finite
share, and a collapse guard against the previous published batch so
legitimately sparse fields (e.g. dv_ttm) are not false-flagged.
Generic for any dataset configured under quality["field_gates"].
"""
gate = dict((self.settings.quality.get("field_gates") or {}).get(dataset) or {})
if not gate or not rows:
return None
fields = [str(item) for item in (gate.get("fields") or []) if str(item)]
if not fields:
return None
min_rate = float(gate.get("min_nonnull_rate") or 0.9)
by_field = {str(k): float(v) for k, v in dict(gate.get("min_nonnull_rate_by_field") or {}).items()}
max_drop = float(gate.get("max_nonnull_drop_vs_prev") or 0.15)
max_nonfinite = float(gate.get("max_nonfinite_rate") or 0.01)
row_n = len(rows)
prev_batch, prev_stats = self._prev_field_stats(dataset, trade_date, fields)
report: dict[str, Any] = {}
for field in fields:
values = [row.get(field) for row in rows]
nulls = sum(1 for value in values if value is None)
nonfinite = sum(1 for value in values if value is not None and not _finite(value))
rate = (row_n - nulls) / row_n
stats = {
"nonnull": row_n - nulls,
"null": nulls,
"nonnull_rate": round(rate, 4),
"nonfinite": nonfinite,
}
floor = by_field.get(field, min_rate)
if rate < floor:
errors.append(
f"field gate: {dataset}.{field} non-null rate {rate:.4f} < {floor}"
)
if nonfinite / row_n > max_nonfinite:
errors.append(
f"field gate: {dataset}.{field} non-finite rate {nonfinite / row_n:.4f} > {max_nonfinite}"
)
prev_rate = prev_stats.get(field) if prev_stats else None
if prev_rate is not None:
stats["prev_nonnull_rate"] = round(prev_rate, 4)
if prev_rate - rate > max_drop:
errors.append(
f"field gate: {dataset}.{field} non-null rate {rate:.4f} dropped > {max_drop:.2f} "
f"vs prev batch {prev_batch} ({prev_rate:.4f})"
)
report[field] = stats
return report
def _prev_field_stats(
self,
dataset: str,
trade_date: str,
fields: list[str],
) -> tuple[str | None, dict[str, float]]:
"""Non-null rates per field from the latest earlier published batch."""
if dataset not in DATASET_TABLES:
return None, {}
table = DATASET_TABLES[dataset][0]
columns = {
str(row["name"])
for row in self.db.fetchall(f"PRAGMA table_info({table})")
}
usable = [field for field in fields if field in columns]
if not usable:
return None, {}
pub = self.db.fetchone(
"""
SELECT active_batch FROM publications
WHERE dataset = ? AND trade_date < ? ORDER BY trade_date DESC LIMIT 1
""",
(dataset, trade_date),
)
if not pub:
return None, {}
batch_id = str(pub["active_batch"])
selects = ",".join(f"AVG({field} IS NOT NULL) AS {field}" for field in usable)
row = self.db.fetchone(
f"SELECT {selects} FROM {table} WHERE batch_id = ?",
(batch_id,),
)
stats = {field: float(row[field]) for field in usable if row.get(field) is not None}
return batch_id, stats
def publish(self, dataset: str, trade_date: str, batch_id: str, state: str = "published") -> None: def publish(self, dataset: str, trade_date: str, batch_id: str, state: str = "published") -> None:
copy_sql = EOD_COPY[dataset] copy_sql = EOD_COPY[dataset]
@@ -636,6 +889,11 @@ class Pipeline:
) )
def _fetch_dataset(self, dataset: str, trade_date: str) -> list[dict[str, Any]]: def _fetch_dataset(self, dataset: str, trade_date: str) -> list[dict[str, Any]]:
if dataset == STOCKS_DATASET:
rows = self.adapter.normalize("stocks", self._guarded_fetch("stocks", {"list_status": "L"}))
for row in rows:
row["trade_date"] = trade_date
return rows
if dataset == "daily": if dataset == "daily":
raw = self._guarded_fetch("daily", {"trade_date": trade_date}) raw = self._guarded_fetch("daily", {"trade_date": trade_date})
factors = { factors = {
+8 -2
View File
@@ -49,6 +49,7 @@ class Scheduler:
"eod_a": self._eod_a, "eod_a": self._eod_a,
"eod_b": self._eod_b, "eod_b": self._eod_b,
"eod_retry": self._eod_retry, "eod_retry": self._eod_retry,
"stocks_refresh": self._stocks_refresh,
"cleanup": self._cleanup, "cleanup": self._cleanup,
"backup": self._backup, "backup": self._backup,
"history_backfill": self._history_backfill, "history_backfill": self._history_backfill,
@@ -89,6 +90,8 @@ class Scheduler:
("cleanup", time(0, 30)), ("cleanup", time(0, 30)),
("backup", time(0, 40)), ("backup", time(0, 40)),
] ]
for refresh_at in self.pipeline.settings.stocks_refresh_times:
plan.append(("stocks_refresh", _hhmm(refresh_at)))
open_day = is_open_day(self.db, day) open_day = is_open_day(self.db, day)
for job_id, at in plan: for job_id, at in plan:
if current < at: if current < at:
@@ -96,7 +99,7 @@ class Scheduler:
key = (job_id, day, at.strftime("%H%M")) key = (job_id, day, at.strftime("%H%M"))
if key in self._fired: if key in self._fired:
continue continue
if job_id in {"eod_a", "eod_b"} and not open_day: if job_id in {"eod_a", "eod_b", "stocks_refresh"} and not open_day:
self._fired.add(key) self._fired.add(key)
continue continue
self._fired.add(key) self._fired.add(key)
@@ -107,7 +110,7 @@ class Scheduler:
try: try:
self.run_job(job_id, day) self.run_job(job_id, day)
except Exception: except Exception:
if job_id not in {"eod_a", "eod_b"}: if job_id not in {"eod_a", "eod_b", "stocks_refresh"}:
raise raise
# Keep the tick alive; evening retries take over. # Keep the tick alive; evening retries take over.
LOGGER.exception("scheduled job %s failed for %s", job_id, day) LOGGER.exception("scheduled job %s failed for %s", job_id, day)
@@ -317,6 +320,9 @@ class Scheduler:
def _eod_retry(self, trade_date: str) -> dict[str, Any]: def _eod_retry(self, trade_date: str) -> dict[str, Any]:
return self.pipeline.run_eod_missing(trade_date) return self.pipeline.run_eod_missing(trade_date)
def _stocks_refresh(self, trade_date: str) -> dict[str, Any]:
return self.pipeline.refresh_stocks(trade_date)
def _history_backfill(self, trade_date: str) -> dict[str, Any]: def _history_backfill(self, trade_date: str) -> dict[str, Any]:
return self.pipeline.backfill_history(trade_date) return self.pipeline.backfill_history(trade_date)
+44 -3
View File
@@ -135,6 +135,29 @@ class V1API:
def stocks(self, updated_since: str, q: dict[str, str]) -> dict[str, Any]: def stocks(self, updated_since: str, q: dict[str, str]) -> dict[str, Any]:
limit, offset = self._page(q) limit, offset = self._page(q)
today = yyyymmdd(now_shanghai())
batch_id, snapshot = self.pipeline.published_stock_snapshot(today)
if batch_id:
# Formal view: the latest published stock snapshot, with batch
# metadata. Filters are applied in-memory on the snapshot.
pub = self.pipeline.latest_stocks_publication(today) or {}
rows = snapshot
if updated_since:
rows = []
rows = rows[offset: offset + limit]
return envelope(
rows,
{
"tier": "official",
"trade_date": pub.get("trade_date"),
"published_at": pub.get("published_at"),
"source": "tushare:stock_basic",
"batch_id": batch_id,
"stale": False,
"staleness_seconds": 0,
"state": pub.get("state"),
},
)
if updated_since: if updated_since:
rows = self.db.fetchall( rows = self.db.fetchall(
"SELECT * FROM stock_master WHERE updated_at >= ? ORDER BY ts_code LIMIT ? OFFSET ?", "SELECT * FROM stock_master WHERE updated_at >= ? ORDER BY ts_code LIMIT ? OFFSET ?",
@@ -176,7 +199,7 @@ class V1API:
def dataset_status(self, date: str) -> dict[str, Any]: def dataset_status(self, date: str) -> dict[str, Any]:
trade_date = yyyymmdd(date or now_shanghai()) trade_date = yyyymmdd(date or now_shanghai())
datasets = ("daily", "valuation", "moneyflow", "auction", "index_daily") datasets = ("daily", "valuation", "moneyflow", "auction", "index_daily", "stocks")
items = [] items = []
for dataset in datasets: for dataset in datasets:
pub = self.db.fetchone( pub = self.db.fetchone(
@@ -251,7 +274,7 @@ class V1API:
raise ApiError( raise ApiError(
"DATASET_NOT_PUBLISHED", "DATASET_NOT_PUBLISHED",
f"{dataset} {start} 尚未发布", f"{dataset} {start} 尚未发布",
extra={"expected_at": "15:05+08:00"}, extra=self._unpublished_extra(dataset, start),
) )
limit, offset = self._page(q) limit, offset = self._page(q)
sql = f"SELECT * FROM {table} WHERE trade_date = ? AND batch_id = ?" sql = f"SELECT * FROM {table} WHERE trade_date = ? AND batch_id = ?"
@@ -281,7 +304,11 @@ class V1API:
(dataset, start, end), (dataset, start, end),
) )
if not pubs: if not pubs:
raise ApiError("DATASET_NOT_PUBLISHED", f"{dataset} {start}-{end} 尚未发布") raise ApiError(
"DATASET_NOT_PUBLISHED",
f"{dataset} {start}-{end} 尚未发布",
extra=self._unpublished_extra(dataset, end),
)
rows: list[dict[str, Any]] = [] rows: list[dict[str, Any]] = []
limit, offset = self._page(q) limit, offset = self._page(q)
for pub in pubs: for pub in pubs:
@@ -350,6 +377,20 @@ class V1API:
offset = max(0, offset) offset = max(0, offset)
return limit, offset return limit, offset
def _unpublished_extra(self, dataset: str, trade_date: str) -> dict[str, Any]:
"""Identifiable coverage info: is this a history gap or today-not-yet?"""
extra: dict[str, Any] = {"expected_at": "15:05+08:00"}
row = self.db.fetchone(
"SELECT MIN(trade_date) AS a, MAX(trade_date) AS b FROM publications WHERE dataset = ?",
(dataset,),
)
if row and row.get("a"):
extra["available_from"] = row["a"]
extra["available_to"] = row["b"]
if str(trade_date) < str(row["a"]):
extra["reason"] = "history_not_backfilled"
return extra
def _official_meta(self, dataset: str, trade_date: str, source: str) -> dict[str, Any]: def _official_meta(self, dataset: str, trade_date: str, source: str) -> dict[str, Any]:
pub = self.db.fetchone( pub = self.db.fetchone(
"SELECT * FROM publications WHERE dataset = ? AND trade_date = ?", "SELECT * FROM publications WHERE dataset = ? AND trade_date = ?",
+11
View File
@@ -56,6 +56,17 @@ class Settings:
def index_history_trading_days(self) -> int: def index_history_trading_days(self) -> int:
return int(self.quality.get("index_history_trading_days") or 260) return int(self.quality.get("index_history_trading_days") or 260)
@property
def moneyflow_history_trading_days(self) -> int:
return int(self.quality.get("moneyflow_history_trading_days") or 60)
@property
def stocks_refresh_times(self) -> tuple[str, ...]:
raw = self.quality.get("stocks_refresh_times") or ["20:00", "23:10"]
if isinstance(raw, str):
raw = [raw]
return tuple(str(item) for item in raw)
@property @property
def eod_retry_start(self) -> str: def eod_retry_start(self) -> str:
return str(self.quality.get("eod_retry_start") or "15:15") return str(self.quality.get("eod_retry_start") or "15:15")
+1 -1
View File
@@ -25,7 +25,7 @@ RAW = {
], ],
"daily_basic": [ "daily_basic": [
{"ts_code": "600000.SH", "trade_date": "20240902", "turnover_rate": 1.2, "volume_ratio": 0.8, "total_mv": 1000.0, "circ_mv": 800.0, "pe_ttm": 5.1, "pb": 0.6, "ps_ttm": 1.1, "dv_ttm": 4.0}, {"ts_code": "600000.SH", "trade_date": "20240902", "turnover_rate": 1.2, "volume_ratio": 0.8, "total_mv": 1000.0, "circ_mv": 800.0, "pe_ttm": 5.1, "pb": 0.6, "ps_ttm": 1.1, "dv_ttm": 4.0},
{"ts_code": "000001.SZ", "trade_date": "20240902", "turnover_rate": 2.2, "volume_ratio": 1.1, "total_mv": 2000.0, "circ_mv": 1500.0, "pe_ttm": 6.2, "pb": 0.7, "ps_ttm": 1.2, "dv_ttm": 3.0}, {"ts_code": "000001.SZ", "trade_date": "20240902", "turnover_rate": 2.2, "volume_ratio": 1.1, "total_mv": 2000.0, "circ_mv": 1500.0, "pe_ttm": 6.2, "pb": 0.7, "ps_ttm": 1.2, "dv_ttm": None},
], ],
"adj_factor": [ "adj_factor": [
{"ts_code": "600000.SH", "trade_date": "20240902", "adj_factor": 1.1}, {"ts_code": "600000.SH", "trade_date": "20240902", "adj_factor": 1.1},
+30 -8
View File
@@ -60,7 +60,11 @@ class EodRetryTests(unittest.TestCase):
return db.fetchall("SELECT * FROM job_runs WHERE job_id = ? ORDER BY id", (job_id,)) return db.fetchall("SELECT * FROM job_runs WHERE job_id = ? ORDER BY id", (job_id,))
def _batches(self, db: HubDB, day: str) -> list[dict]: def _batches(self, db: HubDB, day: str) -> list[dict]:
return db.fetchall("SELECT * FROM batches WHERE trade_date = ?", (day,)) placeholders = ",".join("?" for _ in OFFICIAL)
return db.fetchall(
f"SELECT * FROM batches WHERE trade_date = ? AND dataset IN ({placeholders})",
(day, *sorted(OFFICIAL)),
)
@staticmethod @staticmethod
def _batch_ids(db: HubDB, day: str) -> set[str]: def _batch_ids(db: HubDB, day: str) -> set[str]:
@@ -71,7 +75,11 @@ class EodRetryTests(unittest.TestCase):
return [name for name in transport.calls if name in DelayedTransport.DATE_APIS] return [name for name in transport.calls if name in DelayedTransport.DATE_APIS]
def _published(self, db: HubDB, day: str) -> set[str]: def _published(self, db: HubDB, day: str) -> set[str]:
rows = db.fetchall("SELECT dataset FROM publications WHERE trade_date = ?", (day,)) placeholders = ",".join("?" for _ in OFFICIAL)
rows = db.fetchall(
f"SELECT dataset FROM publications WHERE trade_date = ? AND dataset IN ({placeholders})",
(day, *sorted(OFFICIAL)),
)
return {str(row["dataset"]) for row in rows} return {str(row["dataset"]) for row in rows}
def test_first_empty_then_retry_succeeds(self) -> None: def test_first_empty_then_retry_succeeds(self) -> None:
@@ -105,12 +113,12 @@ class EodRetryTests(unittest.TestCase):
# success stops all further same-day requests # success stops all further same-day requests
batches_before = len(self._batches(db, day)) batches_before = len(self._batches(db, day))
calls_before = len(transport.calls) eod_calls_before = len(self._eod_calls(transport))
sched.tick(clock_at(day, 17, 0)) sched.tick(clock_at(day, 17, 0))
sched.tick(clock_at(day, 23, 0)) sched.tick(clock_at(day, 23, 0))
self.assertEqual(len(self._job_runs(db, "eod_retry")), 2) self.assertEqual(len(self._job_runs(db, "eod_retry")), 2)
self.assertEqual(len(self._batches(db, day)), batches_before) self.assertEqual(len(self._batches(db, day)), batches_before)
self.assertEqual(len(transport.calls), calls_before) self.assertEqual(len(self._eod_calls(transport)), eod_calls_before)
def test_never_ready_marks_cutoff_failed_and_stops(self) -> None: def test_never_ready_marks_cutoff_failed_and_stops(self) -> None:
day = "20240902" day = "20240902"
@@ -144,9 +152,20 @@ class EodRetryTests(unittest.TestCase):
sched.tick(clock_at(day, 15, 5)) # eod_a publishes 4 datasets sched.tick(clock_at(day, 15, 5)) # eod_a publishes 4 datasets
sched.tick(clock_at(day, 15, 10)) # eod_b publishes index sched.tick(clock_at(day, 15, 10)) # eod_b publishes index
self.assertEqual(self._published(db, day), OFFICIAL) self.assertEqual(self._published(db, day), OFFICIAL)
def official_batches() -> list[str]:
placeholders = ",".join("?" for _ in OFFICIAL)
return [
str(row["batch_id"])
for row in db.fetchall(
f"SELECT batch_id FROM batches WHERE trade_date = ? AND dataset IN ({placeholders})",
(day, *sorted(OFFICIAL)),
)
]
active = db.fetchall("SELECT dataset, active_batch FROM publications WHERE trade_date = ?", (day,)) active = db.fetchall("SELECT dataset, active_batch FROM publications WHERE trade_date = ?", (day,))
active_map = {row["dataset"]: row["active_batch"] for row in active} active_map = {row["dataset"]: row["active_batch"] for row in active if row["dataset"] in OFFICIAL}
batches_before = self._batch_ids(db, day) batches_before = set(official_batches())
calls_before = self._eod_calls(transport) calls_before = self._eod_calls(transport)
# container restart: fresh scheduler, missed-time catch-up fires eod_a/eod_b # container restart: fresh scheduler, missed-time catch-up fires eod_a/eod_b
@@ -157,8 +176,11 @@ class EodRetryTests(unittest.TestCase):
self.assertNotIn("eod_retry", ran) self.assertNotIn("eod_retry", ran)
self.assertEqual(self._published(db, day), OFFICIAL) self.assertEqual(self._published(db, day), OFFICIAL)
after = db.fetchall("SELECT dataset, active_batch FROM publications WHERE trade_date = ?", (day,)) after = db.fetchall("SELECT dataset, active_batch FROM publications WHERE trade_date = ?", (day,))
self.assertEqual({row["dataset"]: row["active_batch"] for row in after}, active_map) self.assertEqual(
self.assertEqual(self._batch_ids(db, day), batches_before) # no duplicate batches {row["dataset"]: row["active_batch"] for row in after if row["dataset"] in OFFICIAL},
active_map,
)
self.assertEqual(set(official_batches()), batches_before) # no duplicate batches
self.assertEqual(self._eod_calls(transport), calls_before) # no duplicate upstream EOD calls self.assertEqual(self._eod_calls(transport), calls_before) # no duplicate upstream EOD calls
self.assertEqual(sched2.eod_status(day, clock=clock_at(day, 21, 0))["state"], "done") self.assertEqual(sched2.eod_status(day, clock=clock_at(day, 21, 0))["state"], "done")
@@ -0,0 +1,128 @@
from __future__ import annotations
import unittest
from datetime import date, timedelta
from pathlib import Path
import tempfile
from datahub.adapters.tushare import TushareAdapter
from datahub.crypto import SecretVault
from datahub.db import HubDB
from datahub.pipeline import Pipeline
from datahub.serving import ApiError, V1API
from datahub.settings import Settings
from tests.fixtures import fake_transport
OPEN_DATES = ["20240826", "20240827", "20240828", "20240829", "20240830", "20240902", "20240903"]
EMPTY_UPSTREAM = {"20240828"} # one date the upstream cannot serve
def build_calendar(open_dates: list[str], span_days: int = 16) -> list[dict]:
start = date(int(open_dates[0][:4]), int(open_dates[0][4:6]), int(open_dates[0][6:8]))
rows = []
open_set = set(open_dates)
for offset in range(span_days):
cursor = start + timedelta(days=offset)
compact = cursor.strftime("%Y%m%d")
rows.append(
{"exchange": "SSE", "cal_date": compact, "is_open": 1 if compact in open_set else 0, "pretrade_date": compact}
)
return rows
def moneyflow_rows(day: str) -> list[dict]:
return [
{
"ts_code": "600000.SH", "trade_date": day,
"buy_sm_amount": 10 + int(day[-2:]), "sell_sm_amount": 8, "buy_md_amount": 20, "sell_md_amount": 15,
"buy_lg_amount": 30, "sell_lg_amount": 25, "buy_elg_amount": 40, "sell_elg_amount": 35, "net_mf_amount": 17,
},
{
"ts_code": "000001.SZ", "trade_date": day,
"buy_sm_amount": 11, "sell_sm_amount": 9, "buy_md_amount": 21, "sell_md_amount": 16,
"buy_lg_amount": 31, "sell_lg_amount": 26, "buy_elg_amount": 41, "sell_elg_amount": 36, "net_mf_amount": 18,
},
]
class MoneyflowHistoryTransport:
def __init__(self) -> None:
self.calendar = build_calendar(OPEN_DATES)
self.moneyflow_fetches: list[str] = []
def __call__(self, api_name: str, params: dict, fields: str):
if api_name == "trade_cal":
start = str(params.get("start_date") or "")
end = str(params.get("end_date") or "99999999")
return [row for row in self.calendar if start <= row["cal_date"] <= end]
if api_name == "moneyflow":
day = str(params.get("trade_date") or "")
self.moneyflow_fetches.append(day)
if day in EMPTY_UPSTREAM:
return []
return moneyflow_rows(day)
return fake_transport(api_name, params, fields)
class MoneyflowBackfillTests(unittest.TestCase):
def setUp(self) -> None:
self.transport = MoneyflowHistoryTransport()
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.db = HubDB(Path(tmp.name) / "hub.db")
adapter = TushareAdapter("x", transport=self.transport)
settings = Settings(
encryption_key=SecretVault.generate_key(),
db_path=self.db.path,
backup_dir=Path(tmp.name) / "backups",
quality={"max_publish_attempts": 2, "publication_generations": 3},
)
self.pipe = Pipeline(self.db, adapter, settings)
self.pipe.ingest_reference("20240903")
self.api = V1API(self.db, self.pipe, settings)
def test_backfill_publishes_window_and_reports_failures(self) -> None:
result = self.pipe.backfill_moneyflow_history(end_date="20240903", trading_days=5)
published = [item["trade_date"] for item in result["published"]]
self.assertEqual(published, ["20240829", "20240830", "20240902", "20240903"])
self.assertEqual(result["failed"][0]["trade_date"], "20240828")
self.assertFalse(result["ok"])
rows = self.db.fetchall("SELECT * FROM eod_moneyflow WHERE trade_date='20240830'")
self.assertEqual(len(rows), 2)
def test_backfill_is_idempotent(self) -> None:
self.pipe.backfill_moneyflow_history(end_date="20240903", trading_days=5)
fetches_after_first = list(self.transport.moneyflow_fetches)
second = self.pipe.backfill_moneyflow_history(end_date="20240903", trading_days=5)
# only the still-missing date is re-fetched; published dates are skipped
self.assertEqual(self.transport.moneyflow_fetches[len(fetches_after_first):], ["20240828"])
self.assertEqual(len(second["skipped"]), 4)
def test_point_query_on_backfilled_date_serves_data(self) -> None:
self.pipe.backfill_moneyflow_history(end_date="20240903", trading_days=5)
payload = self.api.handle("/v1/moneyflow", {"date": ["20240830"]})
self.assertEqual(len(payload["data"]), 2)
self.assertEqual(payload["data"][0]["net_mf_amount"], 180000.0)
def test_unpublished_point_below_window_is_identifiable(self) -> None:
self.pipe.backfill_moneyflow_history(end_date="20240903", trading_days=5)
with self.assertRaises(ApiError) as ctx:
self.api.handle("/v1/moneyflow", {"date": ["20240801"]})
extra = ctx.exception.extra
self.assertEqual(extra["available_from"], "20240829") # window starts at the first published date
self.assertEqual(extra["available_to"], "20240903")
self.assertEqual(extra["reason"], "history_not_backfilled")
self.assertEqual(extra["expected_at"], "15:05+08:00")
def test_range_query_flags_missing_dates(self) -> None:
self.pipe.backfill_moneyflow_history(end_date="20240903", trading_days=5)
payload = self.api.handle("/v1/moneyflow", {"from": ["20240828"], "to": ["20240903"]})
coverage = payload["meta"]["coverage"]
self.assertFalse(coverage["complete"])
self.assertEqual(coverage["missing_count"], 1)
self.assertEqual(coverage["missing_sample"], ["20240828"])
self.assertTrue(payload["meta"]["incomplete"])
if __name__ == "__main__":
unittest.main()
+253
View File
@@ -0,0 +1,253 @@
from __future__ import annotations
import unittest
from pathlib import Path
import tempfile
from datahub.adapters.tushare import TushareAdapter
from datahub.crypto import SecretVault
from datahub.db import HubDB
from datahub.pipeline import Pipeline, QualityError
from datahub.scheduler import Scheduler
from datahub.settings import Settings
from tests.fixtures import TRADE_DATE, fake_transport
from tests.test_eod_retry import clock_at
FIELD_GATES = {
"valuation": {
"fields": [
"turnover_rate", "volume_ratio", "total_mv", "circ_mv",
"pe_ttm", "pb", "ps_ttm", "dv_ttm",
],
"min_nonnull_rate": 0.9,
"min_nonnull_rate_by_field": {"pe_ttm": 0.5, "dv_ttm": 0.3},
"max_nonnull_drop_vs_prev": 0.15,
"max_nonfinite_rate": 0.01,
},
}
class ValuationTransport:
"""fake_transport with switchable daily_basic degradation modes."""
def __init__(self) -> None:
self.mode = "ok"
def __call__(self, api_name: str, params: dict, fields: str):
rows = fake_transport(api_name, params, fields)
if api_name != "daily_basic":
return rows
trade_date = str(params.get("trade_date") or "")
if trade_date:
rows = [{**row, "trade_date": trade_date} for row in rows]
if self.mode == "ok":
return rows
patched = []
for row in rows:
item = dict(row)
if self.mode == "fields_all_null":
item["volume_ratio"] = None
item["dv_ttm"] = None
elif self.mode == "vr_all_null":
item["volume_ratio"] = None
elif self.mode == "dv_all_null":
item["dv_ttm"] = None
elif self.mode == "nonfinite":
item["volume_ratio"] = float("inf")
patched.append(item)
return patched
def make_pipe(transport, quality_extra=None, clock=None):
tmp = tempfile.TemporaryDirectory()
db = HubDB(Path(tmp.name) / "hub.db")
adapter = TushareAdapter("test-token", transport=transport)
quality = {
"daily_row_ratio": 0.98,
"null_rate_max": 0.01,
"max_publish_attempts": 3,
"publication_generations": 3,
"job_run_retain_days": 90,
"staging_retain_days": 14,
"field_gates": FIELD_GATES,
}
if quality_extra:
quality.update(quality_extra)
settings = Settings(
encryption_key=SecretVault.generate_key(),
api_token="t" * 32,
admin_password="admin-pass",
tushare_token="test-token",
db_path=db.path,
quality=quality,
scheduler_enabled=False,
)
pipe = Pipeline(db, adapter, settings, clock=clock)
pipe._tmp = tmp
return pipe, db
class ValuationFieldGateTests(unittest.TestCase):
def setUp(self) -> None:
self.transport = ValuationTransport()
self.pipe, self.db = make_pipe(self.transport)
self.pipe.ingest_reference(TRADE_DATE)
def _active(self) -> str | None:
row = self.db.fetchone(
"SELECT active_batch FROM publications WHERE dataset='valuation' AND trade_date=?",
(TRADE_DATE,),
)
return str(row["active_batch"]) if row else None
def test_normal_batch_with_legit_dv_nulls_passes(self) -> None:
result = self.pipe.run_dataset("valuation", TRADE_DATE)
self.assertEqual(result["state"], "published")
fields = result["quality"]["fields"]
# fixture: 1 of 2 stocks has null dv_ttm → 0.5 non-null ≥ 0.3 floor
self.assertEqual(fields["dv_ttm"]["nonnull_rate"], 0.5)
self.assertEqual(fields["volume_ratio"]["nonnull_rate"], 1.0)
self.assertFalse(result["quality"]["errors"])
def test_all_null_fields_rejected_and_prev_batch_kept(self) -> None:
first = self.pipe.run_dataset("valuation", TRADE_DATE)
self.transport.mode = "fields_all_null"
with self.assertRaises(QualityError) as ctx:
self.pipe.run_dataset("valuation", TRADE_DATE)
errors = "; ".join(ctx.exception.report["errors"])
self.assertIn("field gate: valuation.volume_ratio non-null rate 0.0000 < 0.9", errors)
self.assertIn("field gate: valuation.dv_ttm non-null rate 0.0000 < 0.3", errors)
# previous good publication stays active
self.assertEqual(self._active(), first["batch_id"])
# rejected batch left staged with readable error + field stats
rejected = self.db.fetchone(
"SELECT * FROM batches WHERE state='staged' AND dataset='valuation' ORDER BY started_at DESC",
)
self.assertIsNotNone(rejected)
self.assertIn("field gate: valuation.volume_ratio", str(rejected["error"]))
import json
quality = json.loads(rejected["quality_json"])
self.assertEqual(quality["fields"]["volume_ratio"]["nonnull"], 0)
self.assertEqual(quality["fields"]["dv_ttm"]["nonnull"], 0)
def test_volume_ratio_all_null_alone_rejected(self) -> None:
self.pipe.run_dataset("valuation", TRADE_DATE)
self.transport.mode = "vr_all_null"
with self.assertRaises(QualityError):
self.pipe.run_dataset("valuation", TRADE_DATE)
self.assertEqual(
self.db.fetchone(
"SELECT active_batch FROM publications WHERE dataset='valuation' AND trade_date=?",
(TRADE_DATE,),
)["active_batch"],
"20240902-valuation-001",
)
def test_dv_ttm_all_null_rejected_by_floor_and_collapse(self) -> None:
prev_day = "20240830"
prev = self.pipe.run_dataset("valuation", prev_day) # prev dv nonnull 0.5
self.transport.mode = "dv_all_null"
with self.assertRaises(QualityError) as ctx:
self.pipe.run_dataset("valuation", TRADE_DATE)
errors = "; ".join(ctx.exception.report["errors"])
self.assertIn("field gate: valuation.dv_ttm non-null rate 0.0000 < 0.3", errors)
self.assertIn(f"dropped > 0.15 vs prev batch {prev['batch_id']}", errors)
def test_nonfinite_values_rejected(self) -> None:
self.pipe.run_dataset("valuation", TRADE_DATE)
rows = self.pipe.adapter.normalize(
"valuation", self.pipe._guarded_fetch("valuation", {"trade_date": TRADE_DATE})
)
for row in rows:
row["volume_ratio"] = float("inf")
with self.assertRaises(QualityError) as ctx:
self.pipe.run_dataset("valuation", TRADE_DATE, prepared_rows=rows)
errors = "; ".join(ctx.exception.report["errors"])
self.assertIn("field gate: valuation.volume_ratio non-finite rate 1.0000 > 0.01", errors)
def test_gate_off_when_not_configured(self) -> None:
pipe, _db = make_pipe(ValuationTransport(), quality_extra={"field_gates": {}})
pipe.ingest_reference(TRADE_DATE)
pipe.adapter._transport.mode = "fields_all_null"
result = pipe.run_dataset("valuation", TRADE_DATE)
self.assertEqual(result["state"], "published") # legacy behavior when unconfigured
def test_gate_applies_to_any_configured_dataset(self) -> None:
gates = {"daily": {"fields": ["volume"], "min_nonnull_rate": 0.9, "max_nonfinite_rate": 0.01}}
pipe, _db = make_pipe(ValuationTransport(), quality_extra={"field_gates": gates})
pipe.ingest_reference(TRADE_DATE)
def null_volume(api_name, params, fields):
if api_name != "daily":
return fake_transport(api_name, params, fields)
rows = fake_transport(api_name, params, fields)
for row in rows:
row["vol"] = None
return rows
pipe.adapter._transport = null_volume
with self.assertRaises(QualityError) as ctx:
pipe.run_dataset("daily", TRADE_DATE)
errors = "; ".join(ctx.exception.report["errors"])
self.assertIn("field gate: daily.volume non-null rate 0.0000 < 0.9", errors)
class GateRetryInterplayTests(unittest.TestCase):
def test_rejected_valuation_stays_missing_and_retry_publishes_later(self) -> None:
transport = ValuationTransport()
transport.mode = "fields_all_null"
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
db = HubDB(Path(tmp.name) / "hub.db")
adapter = TushareAdapter("x", transport=transport)
settings = Settings(
encryption_key=SecretVault.generate_key(),
db_path=db.path,
backup_dir=Path(tmp.name) / "backups",
quality={"field_gates": FIELD_GATES, "max_publish_attempts": 2},
)
pipe = Pipeline(db, adapter, settings)
pipe.ingest_reference("20240902")
sched = Scheduler(db, pipe)
sched.tick(clock_at("20240902", 15, 5)) # valuation rejected by field gate
sched.tick(clock_at("20240902", 15, 10))
self.assertIn("valuation", pipe.missing_official_datasets("20240902"))
self.assertEqual(
pipe.active_batch("valuation", "20240902"),
None,
)
transport.mode = "ok"
sched.tick(clock_at("20240902", 15, 45)) # retry passes the gate
self.assertNotIn("valuation", pipe.missing_official_datasets("20240902"))
rows = db.fetchall("SELECT * FROM eod_valuation WHERE trade_date='20240902'")
self.assertTrue(rows)
self.assertTrue(all(row["volume_ratio"] is not None for row in rows))
class ForceRepublishTests(unittest.TestCase):
def test_run_dataset_over_published_keeps_prev_for_rollback(self) -> None:
transport = ValuationTransport()
pipe, db = make_pipe(transport)
pipe.ingest_reference(TRADE_DATE)
first = pipe.run_dataset("valuation", TRADE_DATE)
transport.mode = "vr_all_null"
with self.assertRaises(QualityError):
pipe.run_dataset("valuation", TRADE_DATE) # gate holds: bad re-publish refused
transport.mode = "ok"
second = pipe.run_dataset("valuation", TRADE_DATE) # CLI --force path
self.assertNotEqual(first["batch_id"], second["batch_id"])
pub = db.fetchone(
"SELECT * FROM publications WHERE dataset='valuation' AND trade_date=?",
(TRADE_DATE,),
)
self.assertEqual(pub["active_batch"], second["batch_id"])
self.assertEqual(pub["prev_batch"], first["batch_id"])
rolled = pipe.rollback("valuation", TRADE_DATE, actor="cli")
self.assertEqual(rolled["active_batch"], first["batch_id"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,168 @@
from __future__ import annotations
import unittest
from pathlib import Path
import tempfile
from datahub.adapters.tushare import TushareAdapter
from datahub.crypto import SecretVault
from datahub.db import HubDB
from datahub.pipeline import Pipeline
from datahub.scheduler import Scheduler
from datahub.serving import V1API
from datahub.settings import Settings
from tests.fixtures import TRADE_DATE, fake_transport
from tests.test_eod_retry import clock_at
class StockMasterTransport:
"""fake_transport with a mutable stock_basic list (new listings / renames)."""
def __init__(self) -> None:
self.stocks = [
{"ts_code": "600000.SH", "symbol": "600000", "name": "浦发银行", "area": "上海", "industry": "银行", "market": "主板", "list_status": "L", "list_date": "19991110"},
{"ts_code": "920071.BJ", "symbol": "920071", "name": "N金钛", "area": "辽宁", "industry": "小金属", "market": "北交所", "list_status": "L", "list_date": "20240901"},
]
def __call__(self, api_name: str, params: dict, fields: str):
if api_name == "stock_basic":
return [dict(row) for row in self.stocks]
return fake_transport(api_name, params, fields)
def rename_and_add(self) -> None:
for row in self.stocks:
if row["ts_code"] == "920071.BJ":
row["name"] = "金钛股份" # N-prefix removed the day after listing
self.stocks.append(
{"ts_code": "920289.BJ", "symbol": "920289", "name": "N华汇", "area": "广东", "industry": "专用机械", "market": "北交所", "list_status": "L", "list_date": "20240902"}
)
def make_pipe(transport):
tmp = tempfile.TemporaryDirectory()
db = HubDB(Path(tmp.name) / "hub.db")
adapter = TushareAdapter("test-token", transport=transport)
settings = Settings(
encryption_key=SecretVault.generate_key(),
api_token="t" * 32,
admin_password="admin-pass",
tushare_token="test-token",
db_path=db.path,
quality={"max_publish_attempts": 3, "publication_generations": 3},
scheduler_enabled=False,
)
pipe = Pipeline(db, adapter, settings)
pipe._tmp = tmp
return pipe, db
class StocksRefreshTests(unittest.TestCase):
def setUp(self) -> None:
self.transport = StockMasterTransport()
self.pipe, self.db = make_pipe(self.transport)
self.pipe.ingest_reference(TRADE_DATE)
def _stocks_api(self) -> dict:
return V1API(self.db, self.pipe, self.pipe.settings).handle("/v1/stocks", {})
def test_first_refresh_publishes_snapshot_with_meta(self) -> None:
result = self.pipe.refresh_stocks(TRADE_DATE)
self.assertEqual(result["state"], "published")
self.assertEqual(result["rows"], 2)
self.assertTrue(result["batch_id"].startswith("20240902-stocks-"))
payload = self._stocks_api()
self.assertEqual(payload["meta"]["batch_id"], result["batch_id"])
self.assertIsNotNone(payload["meta"]["published_at"])
self.assertEqual(len(payload["data"]), 2)
names = {row["ts_code"]: row["name"] for row in payload["data"]}
self.assertEqual(names["920071.BJ"], "N金钛")
self.assertNotIn("batch_id", payload["data"][0])
def test_new_listing_and_rename_publish_new_batch(self) -> None:
first = self.pipe.refresh_stocks(TRADE_DATE)
self.transport.rename_and_add()
second = self.pipe.refresh_stocks(TRADE_DATE)
self.assertEqual(second["state"], "published")
self.assertNotEqual(second["batch_id"], first["batch_id"])
payload = self._stocks_api()
names = {row["ts_code"]: row["name"] for row in payload["data"]}
self.assertEqual(names["920071.BJ"], "金钛股份")
self.assertIn("920289.BJ", names)
self.assertEqual(names["920289.BJ"], "N华汇")
# stock_master is refreshed too (code resolution stays current)
master = self.db.fetchone("SELECT name FROM stock_master WHERE ts_code='920289.BJ'")
self.assertEqual(master["name"], "N华汇")
def test_unchanged_refresh_is_idempotent(self) -> None:
first = self.pipe.refresh_stocks(TRADE_DATE)
again = self.pipe.refresh_stocks(TRADE_DATE)
self.assertEqual(again["state"], "skipped")
self.assertEqual(again["reason"], "unchanged")
self.assertEqual(again["batch_id"], first["batch_id"])
count = self.db.fetchone(
"SELECT COUNT(*) AS n FROM batches WHERE dataset='stocks' AND trade_date=?",
(TRADE_DATE,),
)["n"]
self.assertEqual(count, 1)
def test_force_republishes_even_unchanged(self) -> None:
first = self.pipe.refresh_stocks(TRADE_DATE)
forced = self.pipe.refresh_stocks(TRADE_DATE, force=True)
self.assertEqual(forced["state"], "published")
self.assertNotEqual(forced["batch_id"], first["batch_id"])
def test_snapshot_pinned_until_next_publish(self) -> None:
first = self.pipe.refresh_stocks(TRADE_DATE)
self.transport.rename_and_add()
# upstream changed but no refresh ran: published snapshot is untouched
_, snapshot = self.pipe.published_stock_snapshot(TRADE_DATE)
names = {row["ts_code"]: row["name"] for row in snapshot}
self.assertEqual(names["920071.BJ"], "N金钛")
self.assertNotIn("920289.BJ", names)
self.assertEqual(len(snapshot), 2)
def test_dataset_status_includes_stocks(self) -> None:
result = self.pipe.refresh_stocks(TRADE_DATE)
payload = V1API(self.db, self.pipe, self.pipe.settings).handle(
"/v1/datasets/status", {"date": [TRADE_DATE]}
)
by_name = {item["dataset"]: item for item in payload["data"]}
self.assertIn("stocks", by_name)
self.assertEqual(by_name["stocks"]["batch_id"], result["batch_id"])
self.assertIsNotNone(by_name["stocks"]["published_at"])
class StocksRefreshSchedulingTests(unittest.TestCase):
def _make(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
db = HubDB(Path(tmp.name) / "hub.db")
transport = StockMasterTransport()
adapter = TushareAdapter("x", transport=transport)
settings = Settings(
encryption_key=SecretVault.generate_key(),
db_path=db.path,
backup_dir=Path(tmp.name) / "backups",
quality={"stocks_refresh_times": ["20:00", "23:10"]},
)
pipe = Pipeline(db, adapter, settings)
pipe.ingest_reference(TRADE_DATE)
return db, pipe, Scheduler(db, pipe)
def test_scheduled_refresh_runs_on_open_day(self) -> None:
db, pipe, sched = self._make()
ran = sched.tick(clock_at(TRADE_DATE, 20, 0))
self.assertIn("stocks_refresh", ran)
ran = sched.tick(clock_at(TRADE_DATE, 23, 10))
self.assertIn("stocks_refresh", ran) # second slot catches late renames
self.assertIsNotNone(pipe.active_batch("stocks", TRADE_DATE))
def test_no_refresh_on_closed_day(self) -> None:
db, _pipe, sched = self._make()
sched.tick(clock_at("20240907", 20, 30)) # fixture: Saturday closed
runs = db.fetchall("SELECT * FROM job_runs WHERE job_id='stocks_refresh'")
self.assertEqual(runs, [])
if __name__ == "__main__":
unittest.main()