Revert the full revert6f99ee9so main's tree content is byte-identical to3bafd30, 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>
197 lines
10 KiB
Python
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
|