fix(HEL-494): 盘中双免费源故障切换并禁止问天假0覆盖

主源东财失败后自动改走腾讯行情,成功结果写入缓存;两源都失败时返回最近真实快照并标明延迟,不再显示假0。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
总工
2026-09-08 17:38:38 +08:00
co-authored by Cursor multica-agent
parent b5d65ecb41
commit ef13d6feb5
13 changed files with 588 additions and 92 deletions
+109 -31
View File
@@ -11,6 +11,7 @@ import time
from datetime import datetime
from typing import Any
from datahub.adapters.base import AdapterError
from datahub.adapters.eastmoney import EastmoneyAdapter
from datahub.adapters.tencent import TencentAdapter
from datahub.codes import resolve_code
@@ -71,24 +72,32 @@ def fetch_market_quotes(db: HubDB) -> dict[str, Any]:
cached = _read_cache(db, cache_key)
if cached is not None:
return cached
adapter = EastmoneyAdapter()
errors: list[str] = []
rows: list[dict[str, Any]] = []
source = ""
try:
rows = adapter.fetch_market_quotes()
rows = EastmoneyAdapter().fetch_market_quotes()
source = "eastmoney:clist"
except Exception as exc:
raise RealtimeApiError("SOURCE_UNAVAILABLE", f"market quotes unavailable: {exc}") from exc
payload = _envelope(
rows,
{
"tier": "provisional",
"trade_date": yyyymmdd(now_shanghai()),
"source": source,
"stale": False,
"staleness_seconds": 0,
"published_at": isoformat(now_shanghai()),
"scope": "market",
},
)
errors.append(f"eastmoney:{exc}")
try:
listed = _listed_ts_codes(db)
if not listed:
raise AdapterError("no local stock master for tencent market snapshot")
rows = TencentAdapter().fetch_quotes(listed)
if len(rows) < 200:
raise AdapterError(f"Tencent market snapshot too small: {len(rows)}")
source = "tencent:qt"
except Exception as backup_exc:
errors.append(f"tencent:{backup_exc}")
recovered = _load_quotes_lkg(db, cache_key)
if recovered is not None:
return recovered
raise RealtimeApiError(
"SOURCE_UNAVAILABLE",
"market quotes unavailable: " + "".join(errors),
) from backup_exc
payload = _quote_payload(rows, source, scope="market")
_write_cache(db, cache_key, payload, QUOTE_TTL, source)
return payload
@@ -110,29 +119,98 @@ def fetch_quotes(db: HubDB, codes: list[str]) -> dict[str, Any]:
cached = _read_cache(db, cache_key)
if cached is not None:
return cached
adapter = EastmoneyAdapter()
minimum = max(1, int(len(resolved) * 0.5))
errors: list[str] = []
rows: list[dict[str, Any]] = []
source = ""
try:
rows: list[dict[str, Any]] = []
for index in range(0, len(resolved), QUOTE_BATCH):
rows.extend(adapter.fetch_quotes(resolved[index:index + QUOTE_BATCH]))
rows = _eastmoney_named_quotes(resolved)
if len(rows) < minimum:
raise AdapterError(f"Eastmoney named quotes too small: {len(rows)}/{len(resolved)}")
source = "eastmoney:ulist"
except Exception as exc:
raise RealtimeApiError("SOURCE_UNAVAILABLE", f"quotes unavailable: {exc}") from exc
payload = _envelope(
rows,
{
"tier": "provisional",
"trade_date": yyyymmdd(now_shanghai()),
"source": source,
"stale": False,
"staleness_seconds": 0,
"published_at": isoformat(now_shanghai()),
},
)
errors.append(f"eastmoney:{exc}")
try:
rows = TencentAdapter().fetch_quotes(resolved)
if len(rows) < minimum:
raise AdapterError(f"Tencent named quotes too small: {len(rows)}/{len(resolved)}")
source = "tencent:qt"
except Exception as backup_exc:
errors.append(f"tencent:{backup_exc}")
recovered = _load_quotes_lkg(db, cache_key)
if recovered is not None:
return recovered
raise RealtimeApiError(
"SOURCE_UNAVAILABLE",
"quotes unavailable: " + "".join(errors),
) from backup_exc
payload = _quote_payload(rows, source)
_write_cache(db, cache_key, payload, QUOTE_TTL, source)
return payload
def _eastmoney_named_quotes(codes: list[str]) -> list[dict[str, Any]]:
adapter = EastmoneyAdapter()
rows: list[dict[str, Any]] = []
for index in range(0, len(codes), QUOTE_BATCH):
rows.extend(adapter.fetch_quotes(codes[index:index + QUOTE_BATCH]))
return rows
def _listed_ts_codes(db: HubDB) -> list[str]:
try:
rows = db.fetchall(
"SELECT ts_code FROM stock_master WHERE list_status = 'L' ORDER BY ts_code"
)
except Exception:
return []
return [str(row.get("ts_code") or "") for row in rows if row.get("ts_code")]
def _quote_payload(
rows: list[dict[str, Any]],
source: str,
scope: str = "",
) -> dict[str, Any]:
meta: dict[str, Any] = {
"tier": "provisional",
"trade_date": yyyymmdd(now_shanghai()),
"source": source,
"stale": False,
"staleness_seconds": 0,
"published_at": isoformat(now_shanghai()),
"failover": source.startswith("tencent"),
"delay_notice": "",
}
if scope:
meta["scope"] = scope
return _envelope(rows, meta)
def _load_quotes_lkg(db: HubDB, cache_key: str) -> dict[str, Any] | None:
store = LastKnownGood(db)
item = store.load(cache_key)
payload = item.get("payload") if item else None
if not isinstance(payload, dict):
return None
data = payload.get("data")
if not isinstance(data, list) or not data:
return None
stamped = dict(payload)
meta = dict(stamped.get("meta") or {})
stored = str((item or {}).get("stored_at") or "")
try:
age = max(0, int(time.time() - datetime.fromisoformat(stored).timestamp()))
except Exception:
age = QUOTE_TTL
meta["stale"] = True
meta["staleness_seconds"] = age
meta["delay_notice"] = f"主备免费行情均暂不可用,显示 {age} 秒前的真实快照"
meta["lkg_source"] = str((item or {}).get("source") or meta.get("source") or "")
stamped["meta"] = meta
return stamped
def fetch_intraday(db: HubDB, code: str, date: str = "") -> dict[str, Any]:
ts_code = resolve_code(db, code) or _guess_ts_code(code)
if not ts_code: