From 63f8ed8b69fd8eeb567eb765895568dbeb4932b0 Mon Sep 17 00:00:00 2001 From: cirry <812852553@qq.com> Date: Sun, 16 Aug 2026 00:47:19 +0800 Subject: [PATCH] =?UTF-8?q?=E7=9C=8B=E8=82=A1=E5=8A=9F=E8=83=BD=E6=9B=B4?= =?UTF-8?q?=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/data/market_overview.py | 161 +++++++++++++ backend_run.log | 37 +++ frontend/src/components/MarketOverview.vue | 252 +++++++++++++++++++++ 3 files changed, 450 insertions(+) create mode 100644 backend/app/data/market_overview.py create mode 100644 frontend/src/components/MarketOverview.vue diff --git a/backend/app/data/market_overview.py b/backend/app/data/market_overview.py new file mode 100644 index 0000000..cbdb151 --- /dev/null +++ b/backend/app/data/market_overview.py @@ -0,0 +1,161 @@ +"""大盘行情总览(主页展示)。 + +- 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 值 -> float;NaN/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) -> date | None: + return datetime.strptime(str(v), "%Y%m%d").date() 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(), + "indexes": indexes, + "stats": stats, + "errors": errors, + } + await cache.cache_set(_CACHE_KEY, payload, ttl=settings.market_overview_ttl) + return payload diff --git a/backend_run.log b/backend_run.log index f8fae8e..f5b3360 100644 --- a/backend_run.log +++ b/backend_run.log @@ -694,3 +694,40 @@ INFO: 127.0.0.1:52465 - "GET /api/screener/preview/000001.SZ?limit=800&adjus WARNING: WatchFiles detected changes in 'app\data\market_overview.py'. Reloading... INFO: 127.0.0.1:53348 - "GET /api/auth/me HTTP/1.1" 200 OK INFO: 127.0.0.1:53466 - "GET /api/preferences HTTP/1.1" 200 OK +INFO: 127.0.0.1:53593 - "GET /api/auth/me HTTP/1.1" 200 OK +INFO: 127.0.0.1:53600 - "GET /api/preferences HTTP/1.1" 200 OK +INFO: 127.0.0.1:53614 - "GET /api/auth/me HTTP/1.1" 200 OK +INFO: 127.0.0.1:53616 - "GET /api/preferences HTTP/1.1" 200 OK +INFO: 127.0.0.1:53772 - "GET /api/market/overview HTTP/1.1" 404 Not Found +INFO: 127.0.0.1:54143 - "GET /api/auth/me HTTP/1.1" 200 OK +INFO: 127.0.0.1:54156 - "GET /api/market/overview HTTP/1.1" 404 Not Found +INFO: 127.0.0.1:54155 - "GET /api/preferences HTTP/1.1" 200 OK +INFO: 127.0.0.1:54160 - "GET /api/market/overview HTTP/1.1" 404 Not Found +INFO: 127.0.0.1:54166 - "GET /api/market/overview HTTP/1.1" 404 Not Found +INFO: 127.0.0.1:54952 - "GET /api/market/overview HTTP/1.1" 404 Not Found +INFO: 127.0.0.1:55237 - "GET /api/auth/me HTTP/1.1" 200 OK +INFO: 127.0.0.1:55254 - "GET /api/market/overview HTTP/1.1" 404 Not Found +INFO: 127.0.0.1:55252 - "GET /api/preferences HTTP/1.1" 200 OK +INFO: 127.0.0.1:55259 - "GET /api/market/overview HTTP/1.1" 404 Not Found +INFO: 127.0.0.1:55269 - "GET /api/screener/sync/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:55280 - "GET /api/market/overview HTTP/1.1" 404 Not Found +INFO: 127.0.0.1:55282 - "GET /api/stocks/facets HTTP/1.1" 200 OK +INFO: 127.0.0.1:55281 - "GET /api/stocks?sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK +INFO: 127.0.0.1:55286 - "GET /api/stocks?sort=circ_mv&order=desc&limit=100&offset=0 HTTP/1.1" 200 OK +INFO: 127.0.0.1:55294 - "GET /api/stocks?sort=total_mv&order=desc&limit=100&offset=0 HTTP/1.1" 200 OK +INFO: 127.0.0.1:55318 - "GET /api/trades?ts_code=603259.SH HTTP/1.1" 200 OK +INFO: 127.0.0.1:55320 - "GET /api/watchlist HTTP/1.1" 200 OK +INFO: 127.0.0.1:55319 - "GET /api/screener/preview/603259.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK +INFO: 127.0.0.1:55330 - "GET /api/screener/preview/603259.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK +INFO: 127.0.0.1:55349 - "GET /api/screener/preview/603259.SH?limit=800&adjust=qfq&timeframe=1d&end=2021-04-07 HTTP/1.1" 200 OK +INFO: 127.0.0.1:55433 - "GET /api/stocks?sort=total_mv&order=desc&limit=100&offset=100 HTTP/1.1" 200 OK +INFO: 127.0.0.1:56175 - "GET /api/market/overview HTTP/1.1" 404 Not Found +INFO: 127.0.0.1:56189 - "GET /api/auth/me HTTP/1.1" 200 OK +INFO: 127.0.0.1:56208 - "GET /api/market/overview HTTP/1.1" 404 Not Found +INFO: 127.0.0.1:56204 - "GET /api/preferences HTTP/1.1" 200 OK +INFO: 127.0.0.1:56279 - "GET /api/market/overview HTTP/1.1" 404 Not Found +INFO: 127.0.0.1:56281 - "GET /api/market/overview HTTP/1.1" 404 Not Found +INFO: 127.0.0.1:56621 - "GET /api/auth/me HTTP/1.1" 200 OK +INFO: 127.0.0.1:56638 - "GET /api/market/overview HTTP/1.1" 404 Not Found +INFO: 127.0.0.1:56637 - "GET /api/preferences HTTP/1.1" 200 OK +INFO: 127.0.0.1:56644 - "GET /api/market/overview HTTP/1.1" 404 Not Found diff --git a/frontend/src/components/MarketOverview.vue b/frontend/src/components/MarketOverview.vue new file mode 100644 index 0000000..cedc15f --- /dev/null +++ b/frontend/src/components/MarketOverview.vue @@ -0,0 +1,252 @@ + + +