Files
stock/backend/app/data/market_overview.py
2026-08-16 21:41:46 +08:00

163 lines
6.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""大盘行情总览(主页展示)。
- A 股指数走 pro.index_daily全球指数走 pro.index_global均为收盘口径晚间更新
本 token 无 rt_idx_k 实时权限,故展示「最近交易日收盘」并标注日期)。
- 两市统计走 pro.daily_info沪市取 SH_A、深市取 SZ_MARKET 汇总出
总市值 / 流通市值 / 成交额(单位亿元,接口原生口径)。
- 整包结果写 Redis 缓存TTL 可配单个指数拉取失败只是跳过errors 里注明),
全部失败才抛 MarketOverviewError —— 主页行情是锦上添花,不拖垮整页。
"""
from __future__ import annotations
import asyncio
import math
from datetime import date, datetime, timedelta
import pandas as pd
from .. import cache
from ..config import settings
# (代码, 名称, 地区) —— 展示顺序即列表顺序
MARKET_INDEXES: list[tuple[str, str, str]] = [
("000001.SH", "上证指数", "cn"),
("399001.SZ", "深证成指", "cn"),
("399006.SZ", "创业板指", "cn"),
("000688.SH", "科创50", "cn"),
("HSI", "恒生指数", "hk"),
("HKTECH", "恒生科技", "hk"),
("DJI", "道琼斯", "us"),
("IXIC", "纳斯达克", "us"),
("SPX", "标普500", "us"),
]
_SPARK_DAYS = 45 # 迷你走势取最近 45 个交易日收盘
_HISTORY_DAYS = 150 # 日历日窗口(约 100 个交易日,够取 spark
_CALL_INTERVAL = 0.12 # 顺序调用间隔(秒),对 tushare 控频
_CACHE_KEY = "market_overview:v1"
class MarketOverviewError(RuntimeError):
"""所有指数都拉不到token/网络故障)——接口层转 503。"""
def _f(v) -> float | None:
"""pandas 值 -> floatNaN/None -> None否则 JSON 里会出现 NaN"""
if v is None:
return None
try:
f = float(v)
except (TypeError, ValueError):
return None
return None if math.isnan(f) else f
def _d(v) -> str | None:
"""YYYYMMDD -> 'YYYY-MM-DD'(字符串便于 JSON 缓存pydantic 响应模型自动 coerce"""
return datetime.strptime(str(v), "%Y%m%d").date().isoformat() if v else None
def _get_pro():
if not settings.tushare_token:
raise MarketOverviewError("未配置 TUSHARE_TOKEN无法获取大盘行情backend/.env")
import tushare as ts
ts.set_token(settings.tushare_token)
return ts.pro_api()
def _fetch_index_sync(pro, ts_code: str) -> pd.DataFrame:
start = (datetime.now() - timedelta(days=_HISTORY_DAYS)).strftime("%Y%m%d")
if "." in ts_code: # A 股指数000001.SH 形式)
return pro.index_daily(ts_code=ts_code, start_date=start)
return pro.index_global(ts_code=ts_code, start_date=start)
def _quote_from_df(df: pd.DataFrame) -> dict | None:
"""DataFrame -> {close, change, pct_chg, trade_date, spark, spark_dates}(旧 -> 新)。"""
if df is None or df.empty:
return None
df = df.sort_values("trade_date")
tail = df.tail(_SPARK_DAYS)
last = df.iloc[-1]
return {
"close": _f(last["close"]),
"change": _f(last.get("change")),
"pct_chg": _f(last.get("pct_chg")),
"trade_date": _d(last["trade_date"]),
"spark": [round(float(c), 4) for c in tail["close"]],
"spark_dates": [str(d) for d in tail["trade_date"]],
}
def _fetch_stats_sync(pro) -> dict | None:
"""两市市值/成交统计:沪 SH_A + 深 SZ_MARKET同一天口径相加亿元"""
start = (datetime.now() - timedelta(days=14)).strftime("%Y%m%d")
sh = pro.daily_info(exchange="SH", start_date=start)
sz = pro.daily_info(exchange="SZ", start_date=start)
if sh is None or sh.empty or sz is None or sz.empty:
return None
def _board(df: pd.DataFrame, code: str):
sub = df[df["ts_code"] == code].sort_values("trade_date") # 接口不保证有序
return sub.iloc[-1] if not sub.empty else None
sh_a, sz_m = _board(sh, "SH_A"), _board(sz, "SZ_MARKET")
if sh_a is None or sz_m is None:
return None
# 两边各自取最新,日期不一致时以较旧一天为准凑齐口径(罕见,通常同日)
d = min(_d(sh_a["trade_date"]), _d(sz_m["trade_date"]))
def _sum(col: str) -> float | None:
a, b = _f(sh_a.get(col)), _f(sz_m.get(col))
return None if a is None or b is None else round(a + b, 2)
return {
"trade_date": d,
"total_mv": _sum("total_mv"),
"float_mv": _sum("float_mv"),
"amount": _sum("amount"),
"turnover": _f(sh_a.get("tr")), # 换手率仅沪市有,展示口径注明沪市
}
async def fetch_overview() -> dict:
"""聚合全部指数 + 两市统计Redis 缓存整包TTL 内直接回)。"""
cached = await cache.cache_get(_CACHE_KEY)
if cached is not None:
return cached
pro = await asyncio.to_thread(_get_pro)
indexes: list[dict] = []
errors: list[str] = []
for ts_code, name, region in MARKET_INDEXES:
try:
df = await asyncio.to_thread(_fetch_index_sync, pro, ts_code)
q = _quote_from_df(df)
if q is None:
raise MarketOverviewError("无数据")
indexes.append({"code": ts_code, "name": name, "region": region, **q})
except Exception as e: # noqa: BLE001 —— 单个指数失败不拖垮整包
errors.append(f"{name}: {str(e)[:60]}")
await asyncio.sleep(_CALL_INTERVAL)
if not indexes:
raise MarketOverviewError("大盘行情全部拉取失败: " + "; ".join(errors)[:200])
stats: dict | None = None
try:
await asyncio.sleep(_CALL_INTERVAL)
stats = await asyncio.to_thread(_fetch_stats_sync, pro)
except Exception as e: # noqa: BLE001 —— 统计缺失时指数照常展示
errors.append(f"两市统计: {str(e)[:60]}")
payload = {
"updated_at": datetime.now().isoformat(), # 字符串cache_set 走 json.dumps
"indexes": indexes,
"stats": stats,
"errors": errors,
}
await cache.cache_set(_CACHE_KEY, payload, ttl=settings.market_overview_ttl)
return payload