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:
co-authored by
Cursor
multica-agent
parent
b5d65ecb41
commit
ef13d6feb5
@@ -240,9 +240,17 @@ class DatahubBridge:
|
||||
rows = dated
|
||||
if len(rows) < minimum:
|
||||
raise DatahubError("EMPTY", f"datahub {dataset} empty")
|
||||
if (response.meta or {}).get("stale"):
|
||||
raise DatahubError("STALE", f"datahub {dataset} stale")
|
||||
self._record_route(dataset, "datahub", str((response.meta or {}).get("source") or "datahub"))
|
||||
stale = bool((response.meta or {}).get("stale"))
|
||||
delay = int((response.meta or {}).get("staleness_seconds") or 0)
|
||||
notice = str((response.meta or {}).get("delay_notice") or "")
|
||||
source = str((response.meta or {}).get("source") or "datahub")
|
||||
if stale:
|
||||
for item in rows:
|
||||
item["delayed"] = True
|
||||
item["delay_seconds"] = delay
|
||||
item["delay_notice"] = notice
|
||||
item["source"] = source
|
||||
self._record_route(dataset, "datahub", source)
|
||||
return rows
|
||||
except Exception as exc:
|
||||
self._log_failure(dataset, exc)
|
||||
@@ -444,7 +452,7 @@ def _native_quote(row: dict[str, Any]) -> dict[str, Any] | None:
|
||||
if not ts_code or close <= 0 or previous <= 0:
|
||||
return None
|
||||
volume = _finite(row.get("vol") if row.get("vol") not in (None, "") else row.get("volume"))
|
||||
return {
|
||||
payload = {
|
||||
"ts_code": ts_code,
|
||||
"name": str(row.get("name") or ts_code).strip(),
|
||||
"pre_close": previous,
|
||||
@@ -458,6 +466,11 @@ def _native_quote(row: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"quote_date": yyyymmdd(row.get("quote_date") or row.get("trade_date")),
|
||||
"source": str(row.get("source") or "datahub"),
|
||||
}
|
||||
if row.get("delayed"):
|
||||
payload["delayed"] = True
|
||||
payload["delay_seconds"] = int(row.get("delay_seconds") or 0)
|
||||
payload["delay_notice"] = str(row.get("delay_notice") or "")
|
||||
return payload
|
||||
|
||||
|
||||
def _chart_bars(rows: list[Any]) -> list[dict[str, Any]]:
|
||||
|
||||
@@ -66,3 +66,5 @@ class TushareClient(
|
||||
_stock_listing_lock: ClassVar[Lock] = Lock()
|
||||
_suspension_cache: ClassVar[dict[str, dict[str, str] | None]] = {}
|
||||
_suspension_lock: ClassVar[Lock] = Lock()
|
||||
_sw_member_cache: ClassVar[dict[str, Any]] = {}
|
||||
_sw_member_lock: ClassVar[Lock] = Lock()
|
||||
|
||||
@@ -293,7 +293,10 @@ class DashboardMixin:
|
||||
last_error = str(exc)
|
||||
code_list = [item for item in str(codes or "").split(",") if item]
|
||||
try:
|
||||
quotes = aggregator.tencent_market_quotes(code_list, expected_date=trade_date)
|
||||
if code_list:
|
||||
quotes = aggregator.tencent_stock_quotes(code_list, expected_date=trade_date)
|
||||
else:
|
||||
quotes = aggregator.tencent_market_quotes(code_list, expected_date=trade_date)
|
||||
except Exception as exc:
|
||||
raise TushareError(
|
||||
f"eastmoney={last_error or 'empty'};tencent={exc}"
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from backend.data.numbers import finite_number as _number
|
||||
from backend.data.providers.tushare_transport import TushareError
|
||||
|
||||
_SW_MEMBER_TTL = timedelta(hours=24)
|
||||
_SW_MEMBER_DIR = Path(__file__).resolve().parents[3] / "data" / "cache" / "sw_members"
|
||||
|
||||
|
||||
class ShenwanIndustryMixin:
|
||||
def sw_stock_industry(self, ts_code: str, trade_date: str) -> dict[str, Any]:
|
||||
@@ -239,23 +244,100 @@ class ShenwanIndustryMixin:
|
||||
sector_code: str,
|
||||
trade_date: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
rows = []
|
||||
for is_new in ("Y", "N"):
|
||||
rows.extend(
|
||||
self.query(
|
||||
"index_member_all",
|
||||
{"l2_code": sector_code, "is_new": is_new},
|
||||
"l2_code,l2_name,ts_code,name,in_date,out_date,is_new",
|
||||
cached_rows = self._read_local_sw_members(sector_code)
|
||||
if cached_rows is not None:
|
||||
return _active_members(cached_rows, trade_date)
|
||||
rows: list[dict[str, Any]] = []
|
||||
try:
|
||||
for is_new in ("Y", "N"):
|
||||
rows.extend(
|
||||
self.query(
|
||||
"index_member_all",
|
||||
{"l2_code": sector_code, "is_new": is_new},
|
||||
"l2_code,l2_name,ts_code,name,in_date,out_date,is_new",
|
||||
)
|
||||
)
|
||||
except TushareError:
|
||||
stale = self._read_local_sw_members(sector_code, allow_stale=True) or []
|
||||
if stale:
|
||||
return _active_members(stale, trade_date)
|
||||
raise
|
||||
reconciled = _reconcile_membership_rows(rows)
|
||||
self._write_local_sw_members(sector_code, reconciled)
|
||||
return _active_members(reconciled, trade_date)
|
||||
|
||||
def _read_local_sw_members(
|
||||
self,
|
||||
sector_code: str,
|
||||
allow_stale: bool = False,
|
||||
) -> list[dict[str, Any]] | None:
|
||||
now = datetime.now().astimezone()
|
||||
cache = getattr(self, "_sw_member_cache", None)
|
||||
lock = getattr(self, "_sw_member_lock", None)
|
||||
if isinstance(cache, dict) and lock is not None:
|
||||
with lock:
|
||||
packed = cache.get(sector_code)
|
||||
if isinstance(packed, dict):
|
||||
loaded_at = packed.get("loaded_at")
|
||||
rows = packed.get("rows")
|
||||
fresh = (
|
||||
isinstance(loaded_at, datetime)
|
||||
and now - loaded_at < _SW_MEMBER_TTL
|
||||
)
|
||||
if isinstance(rows, list) and (fresh or allow_stale):
|
||||
return [dict(item) for item in rows]
|
||||
path = _sw_member_path(sector_code)
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
rows = list(payload.get("rows") or [])
|
||||
updated = str(payload.get("updated_at") or "")
|
||||
fresh = False
|
||||
try:
|
||||
stamped = datetime.fromisoformat(updated)
|
||||
if stamped.tzinfo is None:
|
||||
stamped = stamped.replace(tzinfo=now.tzinfo)
|
||||
fresh = now - stamped.astimezone(now.tzinfo) < _SW_MEMBER_TTL
|
||||
except ValueError:
|
||||
fresh = False
|
||||
if rows and (fresh or allow_stale):
|
||||
self._remember_sw_members(sector_code, rows)
|
||||
return rows
|
||||
return None
|
||||
|
||||
def _write_local_sw_members(self, sector_code: str, rows: list[dict[str, Any]]) -> None:
|
||||
packed = [dict(item) for item in rows]
|
||||
self._remember_sw_members(sector_code, packed)
|
||||
path = _sw_member_path(sector_code)
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"sector_code": sector_code,
|
||||
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||
"rows": packed,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
deduped: dict[str, dict[str, Any]] = {}
|
||||
for row in _reconcile_membership_rows(rows):
|
||||
code = str(row.get("ts_code") or "")
|
||||
if code and _membership_active_on(row, trade_date):
|
||||
current = deduped.get(code)
|
||||
if current is None or str(row.get("in_date") or "") > str(current.get("in_date") or ""):
|
||||
deduped[code] = row
|
||||
return list(deduped.values())
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _remember_sw_members(self, sector_code: str, rows: list[dict[str, Any]]) -> None:
|
||||
cache = getattr(self, "_sw_member_cache", None)
|
||||
lock = getattr(self, "_sw_member_lock", None)
|
||||
if not isinstance(cache, dict) or lock is None:
|
||||
return
|
||||
with lock:
|
||||
cache[sector_code] = {
|
||||
"loaded_at": datetime.now().astimezone(),
|
||||
"rows": [dict(item) for item in rows],
|
||||
}
|
||||
|
||||
def sw_sector_members(self, sector_code: str, trade_date: str) -> list[dict[str, Any]]:
|
||||
"""Return constituents active in a Shenwan L2 industry on the target date."""
|
||||
@@ -394,8 +476,12 @@ class ShenwanIndustryMixin:
|
||||
codes = [
|
||||
str(row.get("ts_code") or "")
|
||||
for row in members
|
||||
if str(row.get("ts_code") or "") in active_codes
|
||||
if str(row.get("ts_code") or "")
|
||||
]
|
||||
if active_codes:
|
||||
listed = [code for code in codes if code in active_codes]
|
||||
if listed:
|
||||
codes = listed
|
||||
quotes, inner_source = self._load_member_realtime_quotes(codes, trade_date)
|
||||
for row in quotes:
|
||||
close = _number(row.get("close"))
|
||||
@@ -439,12 +525,14 @@ class ShenwanIndustryMixin:
|
||||
try:
|
||||
market_rows = self._ensure_realtime_market_cache(trade_date)
|
||||
except TushareError as exc:
|
||||
inner_precise = False
|
||||
message = str(exc)
|
||||
if "rt_k" in message or "权限" in message:
|
||||
inner_error = inner_error or "全市场实时行情暂不可用,已避开无权限接口"
|
||||
market_error = "全市场实时行情暂不可用,已避开无权限接口"
|
||||
else:
|
||||
inner_error = inner_error or message
|
||||
market_error = message
|
||||
if not valid:
|
||||
inner_precise = False
|
||||
inner_error = inner_error or market_error
|
||||
capital_map = {
|
||||
str(item.get("ts_code") or ""): item
|
||||
for item in reference.get("capital_rows") or []
|
||||
@@ -467,9 +555,14 @@ class ShenwanIndustryMixin:
|
||||
average_turnover = sum(sector_turnovers) / len(sector_turnovers) if sector_turnovers else 0
|
||||
market_turnover = sum(market_turnovers) / len(market_turnovers) if market_turnovers else 0
|
||||
relative_turnover = average_turnover / market_turnover if market_turnover else 0
|
||||
if not relative_turnover:
|
||||
inner_precise = False
|
||||
inner_error = inner_error or "Shenwan member relative turnover is unavailable"
|
||||
delayed = "delayed" in str(inner_source) or any(item.get("delayed") for item in valid)
|
||||
delay_seconds = max((int(item.get("delay_seconds") or 0) for item in valid), default=0)
|
||||
delay_notice = ""
|
||||
if delayed:
|
||||
delay_notice = next(
|
||||
(str(item.get("delay_notice") or "") for item in valid if item.get("delay_notice")),
|
||||
"",
|
||||
) or f"主备免费行情均暂不可用,显示最近一次真实快照(延迟 {delay_seconds} 秒)"
|
||||
return {
|
||||
"code": sector_code,
|
||||
"name": str(industry.get("l2_name") or sw_row.get("name") or ""),
|
||||
@@ -511,6 +604,9 @@ class ShenwanIndustryMixin:
|
||||
"precise": inner_precise and outer_precise,
|
||||
"inner_error": inner_error,
|
||||
"outer_error": outer_error,
|
||||
"delayed": delayed,
|
||||
"delay_seconds": delay_seconds,
|
||||
"delay_notice": delay_notice,
|
||||
"schema_version": 6,
|
||||
"methodology": "外显使用已发布 sw_daily 或免费申万实时;内核使用数据中枢/免费实时成分,不调用 rt_sw_k",
|
||||
}
|
||||
@@ -568,7 +664,8 @@ class ShenwanIndustryMixin:
|
||||
if callable(hub_market):
|
||||
filtered = consider(hub_market(trade_date) or [], "datahub")
|
||||
if len(filtered) >= max(1, int(len(wanted) * 0.9)):
|
||||
return filtered, "datahub"
|
||||
delayed = any(item.get("delayed") for item in filtered)
|
||||
return filtered, "datahub_delayed" if delayed else "datahub"
|
||||
|
||||
hub = getattr(self, "try_quotes", None)
|
||||
if callable(hub):
|
||||
@@ -577,25 +674,38 @@ class ShenwanIndustryMixin:
|
||||
collected.extend(hub(wanted[index:index + _QUOTE_BATCH]) or [])
|
||||
filtered = consider(collected, "datahub")
|
||||
if len(filtered) >= max(1, int(len(wanted) * 0.9)):
|
||||
return filtered, "datahub"
|
||||
delayed = any(item.get("delayed") for item in filtered)
|
||||
return filtered, "datahub_delayed" if delayed else "datahub"
|
||||
|
||||
aggregator = getattr(self, "realtime_aggregator", None)
|
||||
loader = getattr(aggregator, "eastmoney_stock_quotes", None) if aggregator else None
|
||||
if callable(loader):
|
||||
eastmoney_loader = getattr(aggregator, "eastmoney_stock_quotes", None) if aggregator else None
|
||||
if callable(eastmoney_loader):
|
||||
try:
|
||||
filtered = consider(loader(wanted, expected_date=trade_date) or [], "eastmoney_ulist")
|
||||
filtered = consider(eastmoney_loader(wanted, expected_date=trade_date) or [], "eastmoney_ulist")
|
||||
if len(filtered) >= max(1, int(len(wanted) * 0.9)):
|
||||
return filtered, "eastmoney_ulist"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
tencent_loader = getattr(aggregator, "tencent_stock_quotes", None) if aggregator else None
|
||||
if callable(tencent_loader):
|
||||
try:
|
||||
filtered = consider(tencent_loader(wanted, expected_date=trade_date) or [], "tencent_qt")
|
||||
if len(filtered) >= max(1, int(len(wanted) * 0.9)):
|
||||
return filtered, "tencent_qt"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
quotes, source = self._free_realtime_quotes(trade_date, "")
|
||||
quotes, source = self._free_realtime_quotes(trade_date, ",".join(wanted))
|
||||
consider(quotes, source)
|
||||
except TushareError:
|
||||
pass
|
||||
|
||||
if best_rows:
|
||||
delayed = any(item.get("delayed") for item in best_rows)
|
||||
if delayed and not str(best_source).endswith("_delayed"):
|
||||
return best_rows, f"{best_source}_delayed"
|
||||
return best_rows, best_source
|
||||
return [], "unavailable"
|
||||
|
||||
@@ -718,6 +828,22 @@ def _reconcile_membership_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any
|
||||
return list(reconciled.values())
|
||||
|
||||
|
||||
def _sw_member_path(sector_code: str) -> Path:
|
||||
safe = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in str(sector_code or ""))
|
||||
return _SW_MEMBER_DIR / f"{safe or 'unknown'}.json"
|
||||
|
||||
|
||||
def _active_members(rows: list[dict[str, Any]], trade_date: str) -> list[dict[str, Any]]:
|
||||
deduped: dict[str, dict[str, Any]] = {}
|
||||
for row in rows:
|
||||
code = str(row.get("ts_code") or "")
|
||||
if code and _membership_active_on(row, trade_date):
|
||||
current = deduped.get(code)
|
||||
if current is None or str(row.get("in_date") or "") > str(current.get("in_date") or ""):
|
||||
deduped[code] = dict(row)
|
||||
return list(deduped.values())
|
||||
|
||||
|
||||
def _match_sector_row(rows: list[dict[str, Any]], identifier: str) -> dict[str, Any] | None:
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
@@ -236,6 +236,15 @@ class WebRealtimeAggregator:
|
||||
self,
|
||||
codes: list[str],
|
||||
expected_date: str = "",
|
||||
) -> list[dict[str, Any]]:
|
||||
quotes = self.tencent_stock_quotes(codes, expected_date="", minimum=200)
|
||||
return self._filter_quotes_by_date(quotes, expected_date)
|
||||
|
||||
def tencent_stock_quotes(
|
||||
self,
|
||||
codes: list[str],
|
||||
expected_date: str = "",
|
||||
minimum: int | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
symbols: list[str] = []
|
||||
seen: set[str] = set()
|
||||
@@ -280,10 +289,11 @@ class WebRealtimeAggregator:
|
||||
quotes.extend(rows)
|
||||
elif not status.get("ok"):
|
||||
errors.append(str(status.get("error") or "batch failed"))
|
||||
if len(quotes) < 200:
|
||||
floor = minimum if minimum is not None else max(1, int(len(symbols) * 0.5))
|
||||
if len(quotes) < floor:
|
||||
detail = f";{'; '.join(errors[:3])}" if errors else ""
|
||||
raise RealtimeAggregateError(
|
||||
f"Tencent market snapshot too small: {len(quotes)}{detail}"
|
||||
f"Tencent quotes too small: {len(quotes)}/{len(symbols)}{detail}"
|
||||
)
|
||||
return self._filter_quotes_by_date(quotes, expected_date)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user