- New provider_call_log/provider_health tables (additive-only schema), wired via a fail-open observability.observe()/record_call() helper. - Tushare pipeline keeps its existing src_calls record unchanged and now also feeds the unified provider_health/provider_call_log side channel. - Eastmoney/Tencent realtime_serve.py call sites and the iFinD steward call site are wrapped with observability.observe() at the call site only; no adapter internals, routing, fallback order, or return values are touched. - New read-only admin API endpoints: /admin/api/providers/status, /admin/api/source-catalog, /admin/api/lineage, /admin/api/lineage/affected. - New static, read-only source_catalog.py and lineage.py registries documenting existing providers/interfaces/datasets and known main-site consumers (cited against backend/features/screener and backend/features/heaven call sites). - provider_call_log is purged by the existing pipeline.cleanup() job alongside src_calls/job_runs. - 47 new unit/integration tests covering classification, fail-open behavior under DB/log failures, unchanged payloads/exceptions on success and failure paths, and the new HTTP endpoints. Full suite: 173 tests, all green. Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
166 lines
7.9 KiB
Python
166 lines
7.9 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"]},
|
|
{"interface": "stock_basic", "capability": "股票主档", "datasets": ["stocks"]},
|
|
{"interface": "daily", "capability": "个股日K", "datasets": ["daily"]},
|
|
{"interface": "adj_factor", "capability": "复权因子", "datasets": ["daily"]},
|
|
{"interface": "daily_basic", "capability": "估值", "datasets": ["valuation"]},
|
|
{"interface": "index_daily", "capability": "指数日K", "datasets": ["index_daily"]},
|
|
{"interface": "moneyflow", "capability": "资金流", "datasets": ["moneyflow"]},
|
|
{"interface": "stk_auction", "capability": "集合竞价", "datasets": ["auction"]},
|
|
{"interface": "limit_list_d", "capability": "涨跌停池", "datasets": ["limit_events"]},
|
|
{"interface": "ths_hot", "capability": "同花顺人气榜", "datasets": ["popularity"]},
|
|
{"interface": "dc_hot", "capability": "东方财富人气榜", "datasets": ["popularity"]},
|
|
{"interface": "hm_detail", "capability": "龙虎榜游资明细", "datasets": ["dragon_tiger"]},
|
|
{"interface": "ths_daily", "capability": "同花顺概念行情", "datasets": ["sector_daily"]},
|
|
{"interface": "dc_index", "capability": "东方财富板块行情", "datasets": ["sector_daily"]},
|
|
{"interface": "sw_daily", "capability": "申万行业行情", "datasets": ["sector_daily"]},
|
|
],
|
|
},
|
|
{
|
|
"provider": "eastmoney",
|
|
"label": "东方财富",
|
|
"role": "provisional_primary",
|
|
"credential_key": None,
|
|
"status_source": "provider_health (unified, HEL-543)",
|
|
"interfaces": [
|
|
{"interface": "indices", "capability": "指数实时报价", "datasets": ["index_quotes"]},
|
|
{"interface": "market_quotes", "capability": "全市场实时快照", "datasets": ["quotes_latest"]},
|
|
{"interface": "named_quotes", "capability": "指定个股实时报价", "datasets": ["quotes_latest"]},
|
|
{"interface": "sector_quote", "capability": "申万板块实时报价(单个)", "datasets": ["sectors_quote"]},
|
|
{"interface": "sector_quotes_batch", "capability": "申万板块批量报价(预热)", "datasets": ["sectors_quote"]},
|
|
{"interface": "limit_pool", "capability": "涨停/炸板池(盘中)", "datasets": ["limit_pool"]},
|
|
{"interface": "intraday", "capability": "分时走势", "datasets": ["intraday_points"]},
|
|
],
|
|
},
|
|
{
|
|
"provider": "tencent",
|
|
"label": "腾讯行情",
|
|
"role": "provisional_backup",
|
|
"credential_key": None,
|
|
"status_source": "provider_health (unified, HEL-543)",
|
|
"interfaces": [
|
|
{"interface": "indices", "capability": "指数实时报价(东财失败时备用)", "datasets": ["index_quotes"]},
|
|
{
|
|
"interface": "market_quotes_fallback",
|
|
"capability": "全市场快照(备用;按本地股票主档逐只请求拼接)",
|
|
"datasets": ["quotes_latest"],
|
|
},
|
|
{"interface": "named_quotes", "capability": "指定个股实时报价(东财失败时备用)", "datasets": ["quotes_latest"]},
|
|
],
|
|
},
|
|
{
|
|
"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"]},
|
|
{"interface": "snapshots", "capability": "快照", "datasets": ["ifind_snapshots"]},
|
|
{"interface": "history", "capability": "历史行情", "datasets": ["ifind_history"]},
|
|
{"interface": "realtime", "capability": "实时行情", "datasets": ["ifind_realtime"]},
|
|
{"interface": "intraday", "capability": "分时(高频)", "datasets": ["ifind_intraday"]},
|
|
],
|
|
},
|
|
{
|
|
"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
|
|
result.append(item)
|
|
return result
|