This commit is contained in:
2026-09-16 09:09:10 +08:00
parent 71a0f6e404
commit a490fdc110
8 changed files with 487 additions and 196 deletions

View File

@@ -1,6 +1,7 @@
"""大盘行情总览(主页展示)——两层结构。
- live 层腾讯免费实时行情qt.gtimg.cn一次 GET 拿全部指数现价/涨跌幅)
- live 层腾讯免费实时行情qt.gtimg.cn一次 GET 拿 A股/美股指数现价/涨跌幅)+
东财 push2日韩指数腾讯 s_ 前缀不覆盖,实测 s_jp*/s_kr* 均无符号),
进程内缓存 30s成功/ 15s 负缓存(失败,防止接口抖动持续拖慢请求)。
- EOD 层tushare 收盘数据 —— A 股指数 pro.index_daily、全球指数 pro.index_global
45 日 spark 走势)+ 两市统计/成交额历史 pro.daily_info。收盘数据一天一变
@@ -28,7 +29,7 @@ from ..config import settings
from .sync_utils import d8_iso, f_clean
# (tushare代码, 名称, 地区, 腾讯符号) —— 展示顺序即列表顺序
# 首页聚焦中美(港股/国际指数在 /indexes 国际指数页标普500 腾讯符号是 s_usINX不是 s_usSPX
# 首页聚焦中美 + 日韩(其余国际指数在 /indexes 国际指数页标普500 腾讯符号是 s_usINX不是 s_usSPX
MARKET_INDEXES: list[tuple[str, str, str, str]] = [
("000001.SH", "上证指数", "cn", "s_sh000001"),
("399001.SZ", "深证成指", "cn", "s_sz399001"),
@@ -37,20 +38,36 @@ MARKET_INDEXES: list[tuple[str, str, str, str]] = [
("DJI", "道琼斯", "us", "s_usDJI"),
("IXIC", "纳斯达克", "us", "s_usIXIC"),
("SPX", "标普500", "us", "s_usINX"),
("N225", "日经225", "apac", ""), # 日韩实时走东财_APAC_SECIDS腾讯符号留空
("KS11", "韩国KOSPI", "apac", ""),
]
# 深证综指:不展示,仅取其 f[7](深市全市成交额,万元)
_TENCENT_SZ_TOTAL = "s_sz399106"
_TENCENT_MAP = {ts_code: sym for ts_code, _, _, sym in MARKET_INDEXES}
_TENCENT_MAP = {ts_code: sym for ts_code, _, _, sym in MARKET_INDEXES if sym}
_TENCENT_URL = "http://qt.gtimg.cn/q=" + ",".join([*_TENCENT_MAP.values(), _TENCENT_SZ_TOTAL])
# 日韩指数实时源:东财 push2 ulist 一次 GET主站 + 延迟镜像双 host。腾讯 s_ 前缀不
# 覆盖日韩(实测均返回 pv_none_match新浪 int_kospi 为空且 int_* 行情明显滞后
# (实测 DJI 差价 ~6000 点),故不用。
_APAC_SECIDS = {"N225": "100.N225", "KS11": "100.KS11"}
_EM_HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"} # 同 etf_provider
_EM_HOSTS = ["https://push2.eastmoney.com", "https://push2delay.eastmoney.com"]
_EM_PATH = ("/api/qt/ulist.np/get?fltt=2&invt=2&fields=f2,f3,f4,f12,f14&secids="
+ ",".join(_APAC_SECIDS.values()))
# 指数 code -> live 行情键em: 前缀与腾讯符号隔离merge 时查表
_LIVE_SYM_MAP: dict[str, str] = {**_TENCENT_MAP, **{c: f"em:{c}" for c in _APAC_SECIDS}}
_SPARK_DAYS = 45 # 迷你走势取最近 45 个交易日收盘
_HISTORY_DAYS = 150 # 日历日窗口(约 100 个交易日,够取 spark
_CALL_INTERVAL = 0.12 # 顺序调用间隔(秒),对 tushare 控频
_EOD_KEY = "market_overview:eod:v3" # v3首页列表移除港股移入国际指数页),与 v2 隔离
_EOD_KEY = "market_overview:eod:v4" # v4新增日韩指数N225/KS11),与 v3 隔离
_AMOUNT_HIST_CAL_DAYS = 190 # 成交额历史的日历日窗口≈128 交易日)
_AMOUNT_HIST_BARS = 120 # 输出的柱数(取尾部)
_LIVE_FAIL_TTL = 15.0 # live 层失败负缓存(秒)
_LIVE_FAIL_TTL = 15.0 # 腾讯 live 层失败负缓存(秒)
_EM_FAIL_TTL = 90.0 # 东财日韩失败负缓存(秒):长于 live 缓存 30s
# 持续故障时最多每 90s 才有一次超时等待,其余刷新直接跳过
class MarketOverviewError(RuntimeError):
@@ -69,7 +86,9 @@ def _get_pro():
# ======================= live 层:腾讯实时行情 =======================
_live: dict = {"at": 0.0, "quotes": None} # 进程内缓存monotonic 时钟)
_live_error: str | None = None # 最近一次实时拉取失败的原因(成功后清空)
_live_error: str | None = None # 最近一次腾讯实时拉取失败的原因(成功后清空)
_live_em_error: str | None = None # 最近一次东财日韩实时拉取失败的原因(成功后清空)
_em_state: dict = {"at": 0.0, "ok": None, "host": 0} # 东财日韩负缓存 + 最近成功 host 索引
async def _fetch_live_http() -> dict[str, dict]:
@@ -112,23 +131,91 @@ async def _fetch_live_http() -> dict[str, dict]:
return quotes
def _parse_em_ulist(payload: dict) -> dict[str, dict]:
"""ulist JSON -> {em:code: {name, price, change, pct, amount_wan}}。fltt=2 下 f2/f3/f4
直接是浮点;未开盘等场景字段是 "-",解析为 None。"""
diff = (payload.get("data") or {}).get("diff") or []
quotes: dict[str, dict] = {}
for row in diff:
code = str(row.get("f12") or "")
if code not in _APAC_SECIDS:
continue
def _num(v) -> float | None:
try:
return float(v)
except (TypeError, ValueError):
return None
quotes[f"em:{code}"] = {
"name": row.get("f14"),
"price": _num(row.get("f2")),
"change": _num(row.get("f4")),
"pct": _num(row.get("f3")),
"amount_wan": None, # 日韩成交额口径不同且未使用,不取
}
if not quotes:
raise RuntimeError("东财日韩行情响应为空或无法解析")
return quotes
async def _fetch_live_apac_http() -> dict[str, dict]:
"""日韩指数实时(东财 ulist 一次 GET主站/延迟镜像按序重试,粘住最近成功的 host
坑(同 etf_provider 实测push2 主站短连发几次会直接断连push2delay 镜像稳;
本机 v2rayN 系统代理(127.0.0.1:10808)对 push2 的 https CONNECT 隧道断连curl -x
同样失败,腾讯 http:// 却正常)——必须带浏览器 UA 且 trust_env=False 直连。"""
start = _em_state.get("host", 0)
last_err: Exception | None = None
for i in range(len(_EM_HOSTS)):
url = _EM_HOSTS[(start + i) % len(_EM_HOSTS)] + _EM_PATH
try:
async with httpx.AsyncClient(
timeout=settings.tencent_quote_timeout, headers=_EM_HEADERS, trust_env=False,
) as client:
resp = await client.get(url)
resp.raise_for_status()
payload = resp.json()
quotes = _parse_em_ulist(payload)
_em_state["host"] = (start + i) % len(_EM_HOSTS) # 粘住成功 host
return quotes
except Exception as e: # noqa: BLE001 —— 换镜像整重来
last_err = e
raise RuntimeError(f"东财日韩行情双镜像均失败: {str(last_err)[:60]}")
async def _fetch_live() -> dict[str, dict] | None:
"""实时行情(进程内缓存);任何失败返回 None上层降级 EOD。"""
global _live_error
"""实时行情(进程内缓存);腾讯与东财各自独立降级,全失败返回 None上层降级 EOD。
东财有独立负缓存_EM_FAIL_TTL持续故障时后续刷新直接跳过不吃超时等待。"""
global _live_error, _live_em_error
now = time.monotonic()
age = now - _live["at"]
if _live["quotes"] is not None and age < settings.market_live_ttl:
return _live["quotes"]
if _live["quotes"] is None and _live["at"] > 0 and age < _LIVE_FAIL_TTL:
return None # 负缓存:刚失败过,短时间内不再打腾讯
return None # 负缓存:刚失败过,短时间内不再打行情接口
quotes: dict[str, dict] = {}
try:
quotes = await _fetch_live_http()
quotes.update(await _fetch_live_http())
_live_error = None
except Exception as e: # noqa: BLE001 —— 实时层是锦上添花,失败不拖垮整包
_live.update(at=now, quotes=None)
_live_error = f"实时行情: {str(e)[:60]}"
if _em_state["ok"] is False and now - _em_state["at"] < _EM_FAIL_TTL:
pass # 东财刚失败过负缓存期内跳过_live_em_error 保留上次原因)
else:
try:
quotes.update(await _fetch_live_apac_http())
_live_em_error = None
_em_state.update(at=now, ok=True)
except Exception as e: # noqa: BLE001
_live_em_error = f"日韩实时行情: {str(e)[:60]}"
_em_state.update(at=now, ok=False)
if not quotes:
_live.update(at=now, quotes=None)
return None
_live.update(at=now, quotes=quotes)
_live_error = None
return quotes
@@ -315,7 +402,7 @@ async def fetch_overview(is_trading_day: bool | None = None) -> dict:
indexes: list[dict] = []
for it in eod["indexes"]:
out = dict(it)
sym = _TENCENT_MAP.get(it["code"])
sym = _LIVE_SYM_MAP.get(it["code"])
q = live.get(sym) if (live and sym) else None
if q and q.get("price") is not None:
# spark 永远来自 EOD末点是上一收盘点与实时价并存是已知的装饰性差异不改历史序列
@@ -345,6 +432,8 @@ async def fetch_overview(is_trading_day: bool | None = None) -> dict:
errors.append(_eod_refresh_error)
if _live_error:
errors.append(_live_error)
if _live_em_error:
errors.append(_live_em_error)
return {
"updated_at": datetime.now().isoformat(),