Files
xiaobai-review/xiaobai-datahub/datahub/admin_api.py
T
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

173 lines
7.4 KiB
Python

from __future__ import annotations
import json
from typing import Any
from datahub.adapters import RESERVED
from datahub.auth import AuthService
from datahub.db import HubDB
from datahub.pipeline import Pipeline
from datahub.scheduler import Scheduler
from datahub.serving import ApiError
from datahub.timeutil import isoformat, now_shanghai, session_phase, yyyymmdd
class AdminAPI:
def __init__(self, db: HubDB, pipeline: Pipeline, scheduler: Scheduler, auth: AuthService) -> None:
self.db = db
self.pipeline = pipeline
self.scheduler = scheduler
self.auth = auth
def overview(self) -> dict[str, Any]:
today = yyyymmdd(now_shanghai())
cal = self.db.fetchone(
"SELECT is_open FROM trade_calendar WHERE exchange = 'SSE' AND cal_date = ?",
(today,),
)
is_open = bool(cal and int(cal["is_open"]) == 1)
pubs = self.db.fetchall("SELECT * FROM publications WHERE trade_date = ?", (today,))
failed = self.db.fetchall(
"SELECT * FROM batches WHERE trade_date = ? AND state IN ('failed','staged')",
(today,),
)
calls = self.db.fetchall(
"SELECT * FROM src_calls ORDER BY id DESC LIMIT 20",
)
return {
"trade_date": today,
"session_phase": session_phase(now_shanghai(), is_open),
"is_open_day": is_open,
"eod_status": self.scheduler.eod_status(today),
"publications": pubs,
"anomalies": failed,
"recent_calls": _public_calls(calls),
"source_count": len(self.db.fetchall("SELECT provider FROM src_health")),
}
def sources(self) -> dict[str, Any]:
health = {f"{row['provider']}:{row['endpoint_class']}": row for row in self.db.fetchall("SELECT * FROM src_health")}
items = [
{
"provider": "tushare",
"role": "official",
"health": health.get("tushare:pro") or {"state": "unknown"},
"credential": self.auth.credential_status("tushare_token") or {"configured": bool(self.pipeline.adapter.token)},
}
]
for name, adapter in RESERVED.items():
items.append(
{
"provider": name,
"role": "reserved",
"health": adapter.probe(),
"credential": {"configured": False, "last4": "", "updated_at": ""},
}
)
# Prefer encrypted last4 if stored
cred = self.auth.credential_status("tushare_token")
if cred.get("configured"):
items[0]["credential"] = cred
elif self.pipeline.adapter.token:
from datahub.crypto import mask_secret
items[0]["credential"] = {"configured": True, "last4": mask_secret(self.pipeline.adapter.token), "updated_at": ""}
return {"items": items}
def probe(self, provider: str) -> dict[str, Any]:
if provider == "tushare":
return self.pipeline.adapter.probe()
adapter = RESERVED.get(provider)
if adapter is None:
raise ApiError("INVALID_ARGUMENT", f"unknown provider: {provider}")
return adapter.probe()
def jobs(self) -> dict[str, Any]:
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 {
"jobs": [
{"id": "precheck", "at": "08:45", "title": "盘前预检"},
{"id": "eod_a", "at": "15:05", "title": "盘后批 A daily/valuation/moneyflow/auction"},
{"id": "eod_b", "at": "15:10", "title": "盘后批 B index_daily"},
{"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": "cleanup", "at": "00:30", "title": "清理 staging / 日志"},
{"id": "backup", "at": "00:40", "title": "SQLite 备份"},
],
"runs": runs,
}
def run_job(self, job_id: str, trade_date: str) -> dict[str, Any]:
return self.scheduler.run_job(job_id, yyyymmdd(trade_date or now_shanghai()))
def batches(self, date: str, dataset: str = "") -> dict[str, Any]:
trade_date = yyyymmdd(date or now_shanghai())
if dataset:
rows = self.db.fetchall(
"SELECT * FROM batches WHERE trade_date = ? AND dataset = ? ORDER BY started_at",
(trade_date, dataset),
)
else:
rows = self.db.fetchall(
"SELECT * FROM batches WHERE trade_date = ? ORDER BY started_at",
(trade_date,),
)
pubs = self.db.fetchall("SELECT * FROM publications WHERE trade_date = ?", (trade_date,))
return {"trade_date": trade_date, "batches": rows, "publications": pubs}
def datasets(self, date: str) -> dict[str, Any]:
trade_date = yyyymmdd(date or now_shanghai())
pubs = self.db.fetchall("SELECT * FROM publications WHERE trade_date = ?", (trade_date,))
diffs = self.db.fetchall(
"SELECT * FROM diff_reports WHERE trade_date = ? ORDER BY id",
(trade_date,),
)
return {"trade_date": trade_date, "publications": pubs, "diff_reports": diffs}
def audit(self) -> dict[str, Any]:
return {"items": self.db.fetchall("SELECT * FROM audit_log ORDER BY id DESC LIMIT 200")}
def rollback(self, dataset: str, trade_date: str, password: str, confirm: str, actor: str) -> dict[str, Any]:
self._dangerous(password, confirm, f"{dataset}:{trade_date}")
result = self.pipeline.rollback(dataset, trade_date, actor=actor)
return result
def backfill(self, dataset: str, trade_date: str, password: str, confirm: str, actor: str) -> dict[str, Any]:
day = yyyymmdd(trade_date or now_shanghai())
if dataset == "history":
self._dangerous(password, confirm, "history:full")
result = self.pipeline.backfill_history(day)
else:
self._dangerous(password, confirm, f"{dataset}:{day}")
if dataset == "reference":
result = self.pipeline.ingest_reference(day)
else:
result = self.pipeline.run_dataset(dataset, day)
self.pipeline.audit(actor, "backfill", f"{dataset}:{day}", json.dumps({"ok": True}))
return result
def _dangerous(self, password: str, confirm: str, expected: str) -> None:
if not self.auth.confirm_password(password):
raise ApiError("UNAUTHORIZED", "二次确认密码错误")
if confirm.strip() != expected:
raise ApiError("INVALID_ARGUMENT", f"确认词必须为 {expected}")
def _public_calls(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
out = []
for row in rows:
out.append(
{
"id": row["id"],
"provider": row["provider"],
"endpoint": row["endpoint"],
"ok": bool(row["ok"]),
"latency_ms": row["latency_ms"],
"error": row["error"],
"created_at": row["created_at"],
}
)
return out