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
+120 -1
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
import time
import urllib.error
import urllib.request
from datetime import datetime
from typing import Any
@@ -10,6 +9,8 @@ from datahub.adapters.base import AdapterError, MarketAdapter
from datahub.numbers import finite_number, round4
TENCENT_INDEX_URL = "https://qt.gtimg.cn/q=sh000001,sz399001,sz399006"
TENCENT_QUOTE_URL = "https://qt.gtimg.cn/q="
TENCENT_QUOTE_BATCH = 80
BROWSER_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36"
@@ -45,6 +46,16 @@ class TencentAdapter(MarketAdapter):
def fetch(self, dataset: str, params: dict[str, Any]) -> list[dict[str, Any]]:
if dataset in {"indexes_quotes", "index_quotes"}:
return self.fetch_indices()
if dataset in {"quotes", "quotes_latest"}:
codes = params.get("codes") or []
if isinstance(codes, str):
codes = [item.strip() for item in codes.split(",") if item.strip()]
return self.fetch_quotes(list(codes))
if dataset in {"quotes_market", "market_quotes"}:
codes = params.get("codes") or []
if isinstance(codes, str):
codes = [item.strip() for item in codes.split(",") if item.strip()]
return self.fetch_quotes(list(codes))
raise AdapterError(f"{self.name} unsupported dataset: {dataset}")
def normalize(self, dataset: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
@@ -97,3 +108,111 @@ class TencentAdapter(MarketAdapter):
if len(result) != 3:
raise AdapterError(f"Tencent returned {len(result)}/3 indices")
return result
def fetch_quotes(self, codes: list[str]) -> list[dict[str, Any]]:
symbols: list[str] = []
seen: set[str] = set()
for raw in codes:
symbol = _tencent_symbol(str(raw or ""))
if not symbol or symbol in seen:
continue
seen.add(symbol)
symbols.append(symbol)
if not symbols:
return []
result: list[dict[str, Any]] = []
errors: list[str] = []
for index in range(0, len(symbols), TENCENT_QUOTE_BATCH):
batch = symbols[index:index + TENCENT_QUOTE_BATCH]
try:
raw = self._get_text(f"{TENCENT_QUOTE_URL}{','.join(batch)}")
except AdapterError as exc:
errors.append(str(exc))
continue
for line in raw.splitlines():
quote = _parse_tencent_stock_quote(line)
if quote:
result.append(quote)
if not result:
detail = f"{'; '.join(errors[:3])}" if errors else ""
raise AdapterError(f"Tencent quotes empty{detail}")
return result
def _get_text(self, url: str) -> str:
request = urllib.request.Request(
url,
headers={"User-Agent": BROWSER_UA, "Referer": "https://gu.qq.com/"},
method="GET",
)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
return response.read().decode("gb18030", errors="ignore")
except Exception as exc:
raise AdapterError(f"tencent request failed: {exc}") from exc
def _tencent_symbol(code: str) -> str:
raw = str(code or "").strip().upper()
if not raw:
return ""
symbol = raw.split(".")[0]
if not symbol.isdigit() or len(symbol) != 6:
return ""
if raw.endswith(".SH") or symbol.startswith(("5", "6", "9")):
return f"sh{symbol}"
if raw.endswith(".BJ") or symbol.startswith(("4", "8")):
return f"bj{symbol}"
return f"sz{symbol}"
def _parse_tencent_stock_quote(line: str) -> dict[str, Any] | None:
if '="' not in line:
return None
prefix, payload = line.split('="', 1)
fields = payload.rsplit('";', 1)[0].split("~")
if len(fields) < 38:
return None
symbol = str(fields[2] or "")
if not symbol.isdigit() or len(symbol) != 6:
return None
close = round4(finite_number(fields[3]))
previous = round4(finite_number(fields[4]))
if not close or not previous or close <= 0 or previous <= 0:
return None
marker = prefix.lower()
if "sh" in marker:
ts_code = f"{symbol}.SH"
elif "bj" in marker:
ts_code = f"{symbol}.BJ"
else:
ts_code = f"{symbol}.SZ"
quote_stamp = ""
quote_date = ""
epoch = 0
try:
parsed = datetime.strptime(fields[30], "%Y%m%d%H%M%S")
quote_date = parsed.strftime("%Y%m%d")
epoch = int(parsed.timestamp())
quote_stamp = parsed.astimezone().isoformat(timespec="seconds")
except ValueError:
pass
return {
"ts_code": ts_code,
"name": fields[1] or symbol,
"price": close,
"close": close,
"pct_chg": round4(finite_number(fields[32])),
"change_amount": round4(finite_number(fields[31])),
"open": round4(finite_number(fields[5])),
"high": round4(finite_number(fields[33])),
"low": round4(finite_number(fields[34])),
"pre_close": previous,
"previous_close": previous,
"volume": round4(finite_number(fields[6]) * 100),
"vol": round4(finite_number(fields[6]) * 100),
"amount": round4(finite_number(fields[37]) * 10000),
"quote_date": quote_date,
"quote_time_epoch": epoch,
"quote_time": quote_stamp,
"source": "tencent_qt",
}
+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: