Files
xiaobai-review/xiaobai-datahub/datahub/revision.py
T
d175bb65d4 feat(HEL-478): 估值发布后晚间复核并原子追补上游修订
盘后成功发布后继续轻量比对 daily_basic 网站字段,发现修订才走质量门与整组原子切换,避免 17:10 快照落后于晚间上游改写。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-07 21:19:35 +08:00

112 lines
4.1 KiB
Python

"""Post-publish revision review for datasets whose upstream may rewrite T-day fields.
HEL-423 field evidence, not a whitelist of tolerated diffs:
- 2026-09-07 valuation/daily_basic: hub published 003021.SZ turnover_rate=1.3565
at 17:10; website legacy and a direct Tushare read at 21:05 both showed 1.3572.
The other seven observed objects (daily, moneyflow, auction, stocks, status,
index_daily, calendar) matched. Hub had already stopped the day after the
first successful publish, so the revision never self-healed.
- 2026-09-02: same dataset, opposite direction (hub already held the later
value). Confirms daily_basic is rewritten after the first complete dump.
Daily bars, moneyflow, auction and index_daily have no same-evening field
revision evidence. Stocks already refreshes at 20:00/23:10. Review therefore
fetches only configured revision-risk datasets (default: valuation) and
compares the website-requested field set. No numeric tolerance.
"""
from __future__ import annotations
from typing import Any
from datahub.db import DATASET_TABLES
from datahub.normalize import VALUATION_FIELDS
from datahub.numbers import finite_number, round4
# Datasets with proven same-evening upstream rewrites. Config may replace this
# list; it must not silently expand to a full EOD re-pull.
DEFAULT_REVISION_DATASETS = ("valuation",)
# Website daily_basic request (HEL-423): ts_code/trade_date plus the eight
# value fields used by the old link and field_gates.
WEBSITE_COMPARE_FIELDS: dict[str, tuple[str, ...]] = {
"valuation": VALUATION_FIELDS,
}
REVISION_STATES = ("waiting_review", "review_failed", "aligned", "cutoff")
def revision_datasets(quality: dict[str, Any] | None) -> tuple[str, ...]:
raw = (quality or {}).get("revision_review_datasets")
if isinstance(raw, (list, tuple)) and raw:
names = tuple(str(item) for item in raw if str(item))
if names:
return names
return DEFAULT_REVISION_DATASETS
def compare_fields(dataset: str) -> tuple[str, ...]:
fields = WEBSITE_COMPARE_FIELDS.get(dataset)
if fields:
return fields
gate = {}
return tuple(str(item) for item in (gate.get("fields") or []) if str(item))
def _norm_value(field: str, value: Any) -> Any:
if field in {"ts_code", "trade_date"}:
return str(value or "")
number = round4(finite_number(value))
return number
def row_signature(row: dict[str, Any], fields: tuple[str, ...]) -> tuple[Any, ...]:
return tuple(_norm_value(field, row.get(field)) for field in fields)
def diff_published_vs_upstream(
dataset: str,
published: list[dict[str, Any]],
upstream: list[dict[str, Any]],
*,
max_diffs: int = 20,
) -> dict[str, Any]:
"""Exact compare on website-requested fields. No tolerance / exemption."""
fields = compare_fields(dataset)
if not fields:
fields = tuple(sorted({key for row in published + upstream for key in row if key != "batch_id"}))
pub_map = {str(row.get("ts_code") or "").upper(): row for row in published}
up_map = {str(row.get("ts_code") or "").upper(): row for row in upstream}
missing = sorted(code for code in pub_map if code not in up_map)
extra = sorted(code for code in up_map if code not in pub_map)
diffs: list[dict[str, Any]] = []
for code in sorted(set(pub_map) & set(up_map)):
left = row_signature(pub_map[code], fields)
right = row_signature(up_map[code], fields)
if left == right:
continue
for field, old, new in zip(fields, left, right):
if old == new:
continue
diffs.append({"ts_code": code, "field": field, "published": old, "upstream": new})
if len(diffs) >= max_diffs:
break
if len(diffs) >= max_diffs:
break
changed = bool(diffs or missing or extra)
return {
"changed": changed,
"dataset": dataset,
"fields": list(fields),
"published_rows": len(published),
"upstream_rows": len(upstream),
"missing_codes": missing[:max_diffs],
"extra_codes": extra[:max_diffs],
"diffs": diffs,
}
def official_table(dataset: str) -> str:
return DATASET_TABLES[dataset][0]