feat: 行业/概念总览 — WebUI 板块总览双栏目(统计条+热力图/表格+三榜/翻红翻绿+板块弹窗复用)+ /board-mac/overview 聚合端点(多排序归并+15s缓存)

This commit is contained in:
GitHub
2026-09-04 15:59:57 +08:00
parent d6478a1e1c
commit f6560f3c6d
11 changed files with 1587 additions and 0 deletions
+101
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
import asyncio
import time
from typing import Any
from fastapi import APIRouter, Depends, Query
@@ -18,6 +20,24 @@ from easy_tdx.web.schemas import DataFrameResponse, DictResponse
router = APIRouter(tags=["board-mac"])
# overview 端点:metrics 参数名 → 返回行字段名(值来自对应排序键的 sort_value)
_OVERVIEW_METRIC_FIELDS: dict[str, str] = {
"SPEED": "speed",
"CHANGE_3D": "chg_3d",
"CHANGE_5D": "chg_5d",
"CHANGE_10D": "chg_10d",
"CHANGE_20D": "chg_20d",
"CHANGE_60D": "chg_60d",
"YTD": "chg_ytd",
}
_OVERVIEW_TTL = 15.0
# (board_type, metrics) -> (monotonic 截止时间, payload)。无锁:并发重复拉取
# 无害(AsyncMacClient 连接内本就串行),省去跨事件循环的锁生命周期问题。
_overview_cache: dict[tuple[str, tuple[str, ...]], tuple[float, dict[str, Any]]] = {}
# 可在单测中 monkeypatch 以控制 TTL 判定
_now = time.monotonic
def _df_resp(df: Any) -> DataFrameResponse:
return DataFrameResponse.from_dataframe(df)
@@ -127,3 +147,84 @@ async def board_change_ranking(
ascending=ascending,
)
return _df_resp(df)
@router.get("/board-mac/overview", response_model=DictResponse)
async def board_overview(
board_type: str = Query("HY", description="板块类型: HY/HY2/GN/FG/DQ"),
metrics: str = Query(
"SPEED,CHANGE_3D,CHANGE_5D,CHANGE_20D,YTD",
description=(
"附加指标(逗号分隔,取自各排序键的 sort_value: "
"SPEED/CHANGE_3D/CHANGE_5D/CHANGE_10D/CHANGE_20D/CHANGE_60D/YTD"
),
),
count: int = Query(2000, ge=1, le=20000),
client: Any = Depends(get_mac_client),
) -> DictResponse:
"""板块总览:一次返回全部板块的当日涨跌幅 + 领涨股 + 多周期指标。
以默认(涨跌幅)排序的板块列表为基表归并各 metrics 排序列的 sort_value
避免前端直连 N 次 ``/board-mac/list``。结果服务端缓存 15s。
当日涨跌幅按 price/pre_close-1 计算(CHANGE_PCT 的 sort_value 恒 0)。
"""
bt = board_type_from_str(board_type)
sort_names = [m.strip().upper() for m in metrics.split(",") if m.strip()]
invalid = [m for m in sort_names if m not in _OVERVIEW_METRIC_FIELDS]
if invalid:
valid = ", ".join(_OVERVIEW_METRIC_FIELDS)
raise ValueError(f"无效指标 '{','.join(invalid)}',可选值: {valid}")
cache_key = (bt.name, tuple(sort_names))
cached = _overview_cache.get(cache_key)
if cached is not None and _now() < cached[0]:
return DictResponse.from_dict(cached[1])
results = await asyncio.gather(
client.get_board_list(board_type=bt, count=count),
*(
client.get_board_list(board_type=bt, count=count, sort_column=board_sort_from_str(name))
for name in sort_names
),
)
base_df, metric_dfs = results[0], list(results[1:])
metric_values: dict[str, dict[str, float]] = {}
for name, df in zip(sort_names, metric_dfs):
field = _OVERVIEW_METRIC_FIELDS[name]
col: dict[str, float] = {}
if df is not None and not df.empty:
for code, value in zip(df["code"], df["sort_value"]):
col[str(code)] = float(value)
metric_values[field] = col
rows: list[dict[str, Any]] = []
if base_df is not None and not base_df.empty:
for record in base_df.to_dict(orient="records"):
price = float(record["price"])
pre_close = float(record["pre_close"])
sym_price = float(record.get("symbol_price") or 0.0)
sym_pre_close = float(record.get("symbol_pre_close") or 0.0)
row: dict[str, Any] = {
"market": int(record["market"]),
"code": str(record["code"]),
"name": str(record["name"]),
"price": price,
"pre_close": pre_close,
"change_pct": round((price / pre_close - 1) * 100, 3) if pre_close else None,
"leader_code": str(record.get("symbol_code") or ""),
"leader_name": str(record.get("symbol_name") or ""),
"leader_change_pct": (
round((sym_price / sym_pre_close - 1) * 100, 3) if sym_pre_close else None
),
}
for field, values in metric_values.items():
row[field] = values.get(str(record["code"]))
# 未请求的指标字段补 null,保证行结构稳定(前端类型固定)
for field in _OVERVIEW_METRIC_FIELDS.values():
row.setdefault(field, None)
rows.append(row)
payload = {"board_type": bt.name, "ts": int(time.time()), "count": len(rows), "rows": rows}
_overview_cache[cache_key] = (_now() + _OVERVIEW_TTL, payload)
return DictResponse.from_dict(payload)