Files
xiaobai-review/xiaobai-datahub/datahub/source_catalog.py
T
78931f9c42 restore main tree to deployed 3bafd30 (HEL-529 校准)
Revert the full revert 6f99ee9 so main's tree content is byte-identical
to 3bafd30, which is the commit currently deployed and healthy on the
8766 xiaobai-datahub container (image hel531-3bafd30). No history
rewrite, no force push; this is a normal forward commit on top of main.

Co-authored-by: multica-agent <github@multica.ai>
2026-09-15 15:03:14 +08:00

197 lines
10 KiB
Python

"""Minimal, read-only source directory (HEL-543).
Registers what already exists: providers, their concrete interfaces, what
capability/dataset each interface serves, and whether the provider plays a
primary or backup role. This module only *describes* the current adapters
and datasets already wired in `datahub/hub.py`, `datahub/serving.py`, and
`datahub/realtime_serve.py`; it does not add a way to configure or add a new
source without code, and it never changes routing, retries, or fallback
order.
Every ``interfaces`` entry below is a docs-as-code mirror of a real call
site, cross-referenced in comments so a reviewer can verify each row is
accurate rather than aspirational:
- tushare interfaces mirror ``datahub/serving.py``'s ``_official_meta``/``source=``
strings and ``datahub/steward.py``'s live/published dataset table.
- eastmoney/tencent interfaces mirror the ``observability.observe(...)``
call sites added in ``datahub/realtime_serve.py`` for HEL-543.
- ifind interfaces mirror ``datahub/steward.py``'s ``IFIND_APIS`` table.
"""
from __future__ import annotations
from typing import Any
CATALOG: list[dict[str, Any]] = [
{
"provider": "tushare",
"label": "Tushare",
"role": "official_primary",
"credential_key": "tushare_token",
"status_source": "src_health (legacy, kept) + provider_health (unified, HEL-543)",
"interfaces": [
{"interface": "trade_cal", "capability": "交易日历", "datasets": ["calendar"], "group": "日历 / 主档"},
{"interface": "stock_basic", "capability": "股票主档", "datasets": ["stocks"], "group": "日历 / 主档"},
{"interface": "daily", "capability": "个股日K", "datasets": ["daily"], "group": "盘后 A 批"},
{"interface": "adj_factor", "capability": "复权因子", "datasets": ["daily"], "group": "盘后 A 批"},
{"interface": "daily_basic", "capability": "估值", "datasets": ["valuation"], "group": "盘后 A 批"},
{"interface": "index_daily", "capability": "指数日K", "datasets": ["index_daily"], "group": "指数 B 批"},
{"interface": "moneyflow", "capability": "资金流", "datasets": ["moneyflow"], "group": "盘后 A 批"},
{"interface": "stk_auction", "capability": "集合竞价", "datasets": ["auction"], "group": "盘后 A 批"},
{"interface": "limit_list_d", "capability": "涨跌停池", "datasets": ["limit_events"], "group": "扩展软批"},
{"interface": "ths_hot", "capability": "同花顺人气榜", "datasets": ["popularity"], "group": "扩展软批"},
{"interface": "dc_hot", "capability": "东方财富人气榜", "datasets": ["popularity"], "group": "扩展软批"},
{"interface": "hm_detail", "capability": "龙虎榜游资明细", "datasets": ["dragon_tiger"], "group": "扩展软批"},
{"interface": "ths_daily", "capability": "同花顺概念行情", "datasets": ["sector_daily"], "group": "扩展软批"},
{"interface": "dc_index", "capability": "东方财富板块行情", "datasets": ["sector_daily"], "group": "扩展软批"},
{"interface": "sw_daily", "capability": "申万行业行情", "datasets": ["sector_daily"], "group": "扩展软批"},
],
},
{
"provider": "eastmoney",
"label": "东方财富",
"role": "provisional_primary",
"credential_key": None,
"status_source": "provider_health (unified, HEL-543)",
"interfaces": [
{"interface": "indices", "capability": "指数实时报价", "datasets": ["index_quotes"], "group": "实时快照"},
{"interface": "market_quotes", "capability": "全市场实时快照", "datasets": ["quotes_latest"], "group": "实时快照"},
{"interface": "named_quotes", "capability": "指定个股实时报价", "datasets": ["quotes_latest"], "group": "实时快照"},
{"interface": "sector_quote", "capability": "申万板块实时报价(单个)", "datasets": ["sectors_quote"], "group": "实时快照"},
{"interface": "sector_quotes_batch", "capability": "申万板块批量报价(预热)", "datasets": ["sectors_quote"], "group": "实时快照"},
{"interface": "limit_pool", "capability": "涨停/炸板池(盘中)", "datasets": ["limit_pool"], "group": "实时快照"},
{"interface": "intraday", "capability": "分时走势", "datasets": ["intraday_points"], "group": "实时快照"},
],
},
{
"provider": "tencent",
"label": "腾讯行情",
"role": "provisional_backup",
"credential_key": None,
"status_source": "provider_health (unified, HEL-543)",
"interfaces": [
{"interface": "indices", "capability": "指数实时报价(东财失败时备用)", "datasets": ["index_quotes"], "group": "实时备援"},
{
"interface": "market_quotes_fallback",
"capability": "全市场快照(备用;按本地股票主档逐只请求拼接)",
"datasets": ["quotes_latest"],
"group": "实时备援",
},
{"interface": "named_quotes", "capability": "指定个股实时报价(东财失败时备用)", "datasets": ["quotes_latest"], "group": "实时备援"},
],
},
{
"provider": "ifind",
"label": "同花顺 iFinD",
"role": "licensed_optional",
"credential_key": "ifind_refresh_token",
"status_source": "provider_health (unified, HEL-543) + adapter.status()",
"interfaces": [
{"interface": "wencai", "capability": "问财自然语言选股", "datasets": ["ifind_wencai"], "group": "预留接口"},
{"interface": "snapshots", "capability": "快照", "datasets": ["ifind_snapshots"], "group": "预留接口"},
{"interface": "history", "capability": "历史行情", "datasets": ["ifind_history"], "group": "预留接口"},
{"interface": "realtime", "capability": "实时行情", "datasets": ["ifind_realtime"], "group": "预留接口"},
{"interface": "intraday", "capability": "分时(高频)", "datasets": ["ifind_intraday"], "group": "预留接口"},
],
},
{
"provider": "ths",
"label": "同花顺(预留)",
"role": "reserved",
"credential_key": None,
"status_source": "adapter.probe()(占位,本阶段未接入真实数据)",
"interfaces": [],
},
{
"provider": "xgb",
"label": "选股宝(预留)",
"role": "reserved",
"credential_key": None,
"status_source": "adapter.probe()(占位,本阶段未接入真实数据)",
"interfaces": [],
},
{
"provider": "akshare",
"label": "AKShare(预留)",
"role": "reserved",
"credential_key": None,
"status_source": "adapter.probe()(占位,本阶段未接入真实数据)",
"interfaces": [],
},
]
def snapshot(db: Any, auth: Any = None) -> list[dict[str, Any]]:
"""Merge the static catalog with live credential/health facts.
Purely read-only: never touches routing, credentials, or adapters. Any
failure while enriching one entry only degrades that entry's live data;
it never drops the entry or raises, so a directory read can never break
on a partially-unhealthy database.
"""
result: list[dict[str, Any]] = []
for entry in CATALOG:
item: dict[str, Any] = {
"provider": entry["provider"],
"label": entry.get("label", entry["provider"]),
"role": entry["role"],
"status_source": entry["status_source"],
"interfaces": [dict(i) for i in entry.get("interfaces", [])],
}
cred_key = entry.get("credential_key")
if cred_key:
cred = None
try:
if auth is not None:
cred = auth.credential_status(cred_key)
except Exception:
cred = None
item["credential"] = cred or {"configured": False, "last4": "", "updated_at": ""}
else:
item["credential"] = {"configured": True, "last4": "", "updated_at": "", "note": "无需凭证"}
health_rows: list[dict[str, Any]] = []
try:
if db is not None:
health_rows = db.fetchall(
"SELECT interface, state, last_ok_at, last_error, last_fallback_reason, "
"consec_failures, last_latency_ms, last_data_age_seconds, updated_at "
"FROM provider_health WHERE provider = ? ORDER BY interface",
(entry["provider"],),
)
except Exception:
health_rows = []
item["live_interfaces"] = health_rows
# HEL-529 fix 3: 已登记 ≠ 已观测 ≠ 健康。观测记录有两种真实写法:
# eastmoney/tencent 的 observe() 直接写接口名;tushare 官方管线
# _log_call() 写的是数据集名(如 daily_basic 接口对应的数据集
# valuation)。这里按「接口名 或 该接口声明的 datasets 之一」双向
# 匹配,并为每个接口标注观测依据,禁止把"暂无观测"显示成"未配置"。
by_interface = {str(row["interface"]): row for row in health_rows}
for iface in item["interfaces"]:
row = by_interface.get(str(iface["interface"]))
basis = "interface" if row is not None else ""
if row is None:
for dataset in iface.get("datasets", []):
candidate = by_interface.get(str(dataset))
if candidate is not None:
row = candidate
basis = "dataset"
break
if row is None:
iface["observed"] = False
iface["observed_basis"] = ""
iface["observed_state"] = ""
iface["observed_at"] = ""
iface["observed_latency_ms"] = None
iface["observed_note"] = ""
else:
iface["observed"] = True
iface["observed_basis"] = basis
iface["observed_state"] = str(row["state"] or "")
iface["observed_at"] = str(row["last_ok_at"] or row["updated_at"] or "")
iface["observed_latency_ms"] = row["last_latency_ms"]
iface["observed_note"] = str(row["last_error"] or row["last_fallback_reason"] or "")
result.append(item)
return result