mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 14:34:18 +08:00
board-mac/hotspot-correlation 的入阵板块来自 set 的并集, sorted(days_in, key=-days_in) 在上榜次数并列时保持 set 迭代序, 而字符串哈希随机化(PYTHONHASHSEED)使该顺序跨进程不稳定—— 服务每次重启矩阵的行/列都可能互换(全量单测中按哈希种子复现)。 排序加 code 兜底使其确定性;热点矩阵 rows_out 的多级排序同样 补 code 兜底(全并列时此前也受 set 序影响)。
598 lines
25 KiB
Python
598 lines
25 KiB
Python
"""板块分析路由:板块列表、成分、归属、摘要、涨幅排名、N日涨幅、热点滚动。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import logging
|
||
import time
|
||
from datetime import datetime
|
||
from typing import Any
|
||
|
||
import pandas as pd
|
||
from fastapi import APIRouter, Depends, Query
|
||
|
||
from easy_tdx.mac.enums import Adjust, Period
|
||
from easy_tdx.web.convert import (
|
||
board_sort_from_str,
|
||
board_type_from_str,
|
||
market_value_from_str,
|
||
sort_order_from_str,
|
||
sort_type_from_str,
|
||
)
|
||
from easy_tdx.web.deps import get_mac_client
|
||
from easy_tdx.web.schemas import DataFrameResponse, DictResponse
|
||
|
||
_logger = logging.getLogger(__name__)
|
||
|
||
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)
|
||
|
||
|
||
@router.get("/board-mac/list", response_model=DataFrameResponse)
|
||
async def board_list(
|
||
board_type: str = Query("ALL", description="板块类型: ALL/HY/HY2/GN/FG/DQ"),
|
||
count: int = Query(500, ge=1, le=50000),
|
||
sort_column: str = Query(
|
||
"CHANGE_PCT",
|
||
description=(
|
||
"排序键: CHANGE_PCT/SPEED/CHANGE_3D/CHANGE_5D/CHANGE_10D/"
|
||
"CHANGE_20D/CHANGE_60D/YTD;sort_value 列即该指标值"
|
||
"(CHANGE_PCT 时恒 0,涨跌幅=price/pre_close-1)"
|
||
),
|
||
),
|
||
client: Any = Depends(get_mac_client),
|
||
) -> DataFrameResponse:
|
||
"""获取板块列表(默认按涨跌幅降序;要取涨速传 sort_column=SPEED)。"""
|
||
df = await client.get_board_list(
|
||
board_type=board_type_from_str(board_type),
|
||
count=count,
|
||
sort_column=board_sort_from_str(sort_column),
|
||
)
|
||
return _df_resp(df)
|
||
|
||
|
||
@router.get("/board-mac/members", response_model=DataFrameResponse)
|
||
async def board_members(
|
||
board_symbol: str = Query(..., description="板块代码,如 881001"),
|
||
count: int = Query(100, ge=1, le=100000),
|
||
sort_type: str = Query("CHANGE_PCT", description="排序字段"),
|
||
sort_order: str = Query("DESC", description="排序方向: ASC/DESC"),
|
||
client: Any = Depends(get_mac_client),
|
||
) -> DataFrameResponse:
|
||
"""获取板块成分股。"""
|
||
df = await client.get_board_members(
|
||
board_symbol=board_symbol,
|
||
count=count,
|
||
sort_type=sort_type_from_str(sort_type),
|
||
sort_order=sort_order_from_str(sort_order),
|
||
)
|
||
return _df_resp(df)
|
||
|
||
|
||
@router.get("/board-mac/belong", response_model=DataFrameResponse)
|
||
async def board_belong(
|
||
market: str = Query(..., description="市场: SZ, SH"),
|
||
code: str = Query(..., min_length=6, max_length=6, description="6位股票代码"),
|
||
client: Any = Depends(get_mac_client),
|
||
) -> DataFrameResponse:
|
||
"""获取股票所属板块列表。"""
|
||
df = await client.get_belong_board(market=market_value_from_str(market), code=code)
|
||
return _df_resp(df)
|
||
|
||
|
||
@router.get("/board-mac/summary", response_model=DictResponse)
|
||
async def board_summary(
|
||
board_symbol: str = Query(..., description="板块代码,如 881001"),
|
||
sort_type: str = Query("CHANGE_PCT", description="排序字段"),
|
||
sort_order: str = Query("DESC", description="排序方向: ASC/DESC"),
|
||
client: Any = Depends(get_mac_client),
|
||
) -> DictResponse:
|
||
"""获取板块摘要信息(含成分股资金流向)。"""
|
||
result = await client.get_board_summary(
|
||
board_symbol=board_symbol,
|
||
sort_type=sort_type_from_str(sort_type),
|
||
sort_order=sort_order_from_str(sort_order),
|
||
)
|
||
return DictResponse.from_dict(result)
|
||
|
||
|
||
@router.get("/board-mac/ranking", response_model=DataFrameResponse)
|
||
async def board_ranking(
|
||
board_type: str = Query("HY", description="板块类型: HY/HY2/GN/FG/DQ"),
|
||
top_n: int = Query(10, ge=1, le=200),
|
||
sort_by: str = Query("change_pct", description="排序字段名"),
|
||
ascending: bool = Query(False, description="是否升序"),
|
||
client: Any = Depends(get_mac_client),
|
||
) -> DataFrameResponse:
|
||
"""获取板块涨幅排名。"""
|
||
df = await client.get_board_ranking(
|
||
board_type=board_type_from_str(board_type),
|
||
top_n=top_n,
|
||
sort_by=sort_by,
|
||
ascending=ascending,
|
||
)
|
||
return _df_resp(df)
|
||
|
||
|
||
@router.get("/board-mac/change-ranking", response_model=DataFrameResponse)
|
||
async def board_change_ranking(
|
||
board_type: str = Query("HY", description="板块类型: HY/HY2/GN/FG/DQ"),
|
||
days: int = Query(20, ge=1, le=250, description="统计天数"),
|
||
top_n: int = Query(10, ge=1, le=200),
|
||
target_date: int | None = Query(None, description="目标日期,如 20250101"),
|
||
ascending: bool = Query(False, description="是否升序"),
|
||
client: Any = Depends(get_mac_client),
|
||
) -> DataFrameResponse:
|
||
"""获取板块 N 日涨幅排名。"""
|
||
df = await client.get_board_change_ranking(
|
||
board_type=board_type_from_str(board_type),
|
||
target_date=target_date,
|
||
days=days,
|
||
top_n=top_n,
|
||
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)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 热点滚动(/board-mac/hotspot):交易日 × 板块 每日涨跌矩阵 + 每日排名
|
||
#
|
||
# 两段式数据合成:
|
||
# - 历史矩阵:逐板块拉板块指数日K(get_stock_kline),close 逐日环比得涨跌幅,
|
||
# 收盘后不可变 → 按日历日缓存全天有效;days 参数只做切片,不进缓存键。
|
||
# - 今日列:实时报价 price/pre_close-1(与 overview 同口径);全市场无一移动
|
||
# (盘前/休市/节假日)则不追加今日列,避免出现全 0 的假列。
|
||
#
|
||
# AsyncMacClient 是单连接串行,概念板块(~500 个)首次构建需数十秒:
|
||
# 构建放 asyncio 后台任务 + 进度轮询,避免占住请求线程并拖死同连接的其他页面。
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# 历史矩阵最大窗口(days 参数在其内切片)与多拉的缓冲 bar(窗口首日前收 + 节假日)
|
||
_HOTSPOT_MAX_DAYS = 60
|
||
_HOTSPOT_FETCH_BUFFER = 12
|
||
_HOTSPOT_KLINE_COUNT = _HOTSPOT_MAX_DAYS + _HOTSPOT_FETCH_BUFFER
|
||
_HOTSPOT_MAX_ROWS = 60 # 返回行数上限(行集合按上榜次数截断)
|
||
_HOTSPOT_KLINE_CONCURRENCY = 8 # 单连接实际串行,信号量只做秩序与背压
|
||
|
||
# board_type 名 -> (日历日, {axis: 日期轴, pct: {code: {日期: 涨跌幅}}, names: {code: 名称}})
|
||
_hotspot_history_cache: dict[str, tuple[str, dict[str, Any]]] = {}
|
||
# board_type 名 -> 构建状态 {"status": "building"|"ready"|"error", "progress", "task", "error"}
|
||
_hotspot_builds: dict[str, dict[str, Any]] = {}
|
||
|
||
|
||
def _today_str() -> str:
|
||
"""当日日历日(缓存失效键;单测可 monkeypatch)。"""
|
||
return datetime.now().strftime("%Y-%m-%d")
|
||
|
||
|
||
async def _hotspot_build(board_key: str, bt: Any, client: Any) -> None:
|
||
"""后台构建板块历史日度涨跌矩阵,结果写入 _hotspot_history_cache。"""
|
||
state = _hotspot_builds[board_key]
|
||
try:
|
||
boards_df = await client.get_board_list(board_type=bt, count=5000)
|
||
if boards_df is None or boards_df.empty:
|
||
raise ValueError("板块列表为空,无法构建热点矩阵")
|
||
entries = [
|
||
(str(rec["code"]), int(rec.get("market") or 1))
|
||
for rec in boards_df.to_dict(orient="records")
|
||
]
|
||
names = {
|
||
str(rec["code"]): str(rec.get("name") or rec["code"])
|
||
for rec in boards_df.to_dict(orient="records")
|
||
}
|
||
total = len(entries)
|
||
sem = asyncio.Semaphore(_HOTSPOT_KLINE_CONCURRENCY)
|
||
done = 0
|
||
|
||
async def fetch_one(code: str, market: int) -> tuple[str, pd.DataFrame | None]:
|
||
nonlocal done
|
||
async with sem:
|
||
try:
|
||
df = await client.get_stock_kline(
|
||
market=market,
|
||
code=code,
|
||
period=Period.DAILY,
|
||
count=_HOTSPOT_KLINE_COUNT,
|
||
adjust=Adjust.NONE,
|
||
)
|
||
except Exception: # noqa: BLE001 — 单板块缺K线不阻塞整体
|
||
df = None
|
||
done += 1
|
||
state["progress"] = round(done / total, 4)
|
||
return code, df
|
||
|
||
fetched = await asyncio.gather(*(fetch_one(code, market) for code, market in entries))
|
||
|
||
pct_map: dict[str, dict[str, float]] = {}
|
||
for code, df in fetched:
|
||
if df is None or df.empty or len(df) < 2 or "datetime" not in df.columns:
|
||
continue
|
||
kline = df.sort_values("datetime")
|
||
dates = pd.to_datetime(kline["datetime"]).dt.strftime("%Y-%m-%d").reset_index(drop=True)
|
||
close = pd.to_numeric(kline["close"], errors="coerce").reset_index(drop=True)
|
||
pct = (close / close.shift(1) - 1.0) * 100.0
|
||
series: dict[str, float] = {}
|
||
for d, p in zip(dates.iloc[1:], pct.iloc[1:]): # 首根无前收,跳过
|
||
if pd.notna(p):
|
||
series[str(d)] = round(float(p), 3)
|
||
if series:
|
||
pct_map[code] = series
|
||
if not pct_map:
|
||
raise ValueError("全部板块日K获取失败,无法构建热点矩阵")
|
||
|
||
# 交易日轴 = 数据最全板块的日期序列(全市场板块共享交易日历)
|
||
axis = sorted(max(pct_map.values(), key=len).keys())
|
||
_hotspot_history_cache[board_key] = (
|
||
_today_str(),
|
||
{"axis": axis, "pct": pct_map, "names": names},
|
||
)
|
||
state["status"] = "ready"
|
||
state["progress"] = 1.0
|
||
except Exception as exc: # noqa: BLE001 — 构建失败转可轮询的 error 状态,不抛出
|
||
state["status"] = "error"
|
||
state["error"] = str(exc)
|
||
_logger.warning("热点矩阵构建失败 (%s): %s", board_key, exc)
|
||
|
||
|
||
def _hotspot_history_or_build(
|
||
key: str,
|
||
bt: Any,
|
||
client: Any,
|
||
*,
|
||
retry: bool = False,
|
||
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
||
"""热点历史缓存的公共入口。
|
||
|
||
缓存就绪返回 ``(history, None)``;否则触发/汇报后台构建,返回
|
||
``(None, building_or_error_payload)``。error 状态保持稳定不自动重建,
|
||
保证失败原因能被前端读到(``retry=1`` 才重建)。
|
||
"""
|
||
cached = _hotspot_history_cache.get(key)
|
||
if cached is not None and cached[0] == _today_str():
|
||
return cached[1], None
|
||
state = _hotspot_builds.get(key)
|
||
running = state is not None and state.get("task") is not None and not state["task"].done()
|
||
# 需要新建:无状态 / 上次成功但缓存已过期 / 显式重试
|
||
if not running and (retry or state is None or state.get("status") == "ready"):
|
||
state = {"status": "building", "progress": 0.0, "task": None, "error": ""}
|
||
_hotspot_builds[key] = state
|
||
state["task"] = asyncio.create_task(_hotspot_build(key, bt, client))
|
||
running = True
|
||
if running:
|
||
return None, {"status": "building", "progress": state.get("progress", 0.0)}
|
||
return None, {
|
||
"status": "error",
|
||
"error": state.get("error") or "热点矩阵构建失败",
|
||
"progress": 1.0,
|
||
}
|
||
|
||
|
||
@router.get("/board-mac/hotspot", response_model=DictResponse)
|
||
async def board_hotspot(
|
||
board_type: str = Query("HY", description="板块类型: HY/HY2/GN/FG/DQ"),
|
||
days: int = Query(20, ge=1, le=_HOTSPOT_MAX_DAYS, description="窗口交易日数(1=仅今日)"),
|
||
mode: str = Query("top", description="top=领涨(每日最强入选) / bottom=领跌(每日最弱入选)"),
|
||
per_day: int = Query(5, ge=2, le=10, description="每日入选名次阈值"),
|
||
retry: bool = Query(False, description="上次构建失败后强制重建"),
|
||
client: Any = Depends(get_mac_client),
|
||
) -> DictResponse:
|
||
"""市场热点滚动:交易日 × 板块 每日涨跌矩阵 + 当日排名。
|
||
|
||
首次请求某板块类型时启动后台构建,返回 ``{"status": "building", "progress": 0~1}``,
|
||
前端 ~1s 轮询直至 ``ready``。构建失败返回 ``{"status": "error", "error": ...}``
|
||
并保持稳定(轮询不会自动重建,避免错误被冲掉);带 ``retry=1`` 再次请求即重建。
|
||
``session`` 为 ``live`` 表示最后一列是盘中实时值。
|
||
|
||
行集合 = 窗口内「每日 mode 方向前 per_day 名」板块的并集(按上榜次数截断至
|
||
``_HOTSPOT_MAX_ROWS`` 行)。``rank`` 为当日全类型排名:mode=top 时 1=涨幅最大,
|
||
mode=bottom 时 1=跌幅最大。``sum_pct`` 为窗口内逐日复利累计。
|
||
"""
|
||
mode_norm = mode.strip().lower()
|
||
if mode_norm not in ("top", "bottom"):
|
||
raise ValueError(f"mode 仅支持 top/bottom,got {mode}")
|
||
|
||
bt = board_type_from_str(board_type)
|
||
key = bt.name
|
||
|
||
history, build_payload = _hotspot_history_or_build(key, bt, client, retry=retry)
|
||
if build_payload is not None:
|
||
return DictResponse.from_dict(build_payload)
|
||
axis_all: list[str] = history["axis"]
|
||
pct_map: dict[str, dict[str, float]] = history["pct"]
|
||
names: dict[str, str] = dict(history["names"])
|
||
|
||
# 窗口切片:剔除今日(今日列一律来自实时报价,避免日K盘中未完成 bar 混入)
|
||
today = _today_str()
|
||
window = [d for d in axis_all if d != today][-days:]
|
||
|
||
# 今日列:实时报价(1–2 页,廉价)。全市场无一移动(盘前/休市)则不追加
|
||
live_df = await client.get_board_list(board_type=bt, count=5000)
|
||
live_change: dict[str, float] = {}
|
||
any_moved = False
|
||
if live_df is not None and not live_df.empty:
|
||
for rec in live_df.to_dict(orient="records"):
|
||
code = str(rec["code"])
|
||
if rec.get("name"):
|
||
names[code] = str(rec["name"])
|
||
price = float(rec.get("price") or 0.0)
|
||
pre = float(rec.get("pre_close") or 0.0)
|
||
if price > 0 and pre > 0:
|
||
chg = round((price / pre - 1.0) * 100.0, 3)
|
||
live_change[code] = chg
|
||
if abs(chg) > 1e-9:
|
||
any_moved = True
|
||
col_pct: list[dict[str, float]] = [
|
||
{code: m[d] for code, m in pct_map.items() if d in m} for d in window
|
||
]
|
||
# 周末/节假日隔夜:TDX 的 pre_close 尚未滚动,实时涨跌会与历史末列几乎完全
|
||
# 重合(都是上一交易日的涨幅)——重合度过高则不追加,避免出现重复的假今日列。
|
||
# 交易日盘中实时值与昨日收盘涨幅必然大面积偏离,不受此判定影响。
|
||
if any_moved and window:
|
||
last_col = col_pct[-1]
|
||
same = diff = 0
|
||
for code, chg in live_change.items():
|
||
prev = last_col.get(code)
|
||
if prev is None:
|
||
continue
|
||
if abs(chg - prev) <= 0.05:
|
||
same += 1
|
||
else:
|
||
diff += 1
|
||
append_live = (same + diff) > 0 and diff / (same + diff) >= 0.5
|
||
else:
|
||
append_live = False
|
||
if append_live:
|
||
col_pct.append(live_change)
|
||
|
||
dates = window + ([today] if append_live else [])
|
||
|
||
# 每列全类型排名(mode 方向;1 = 最强/最弱)
|
||
col_rank: list[dict[str, int]] = []
|
||
for col in col_pct:
|
||
ordered = sorted(col.items(), key=lambda kv: kv[1], reverse=(mode_norm == "top"))
|
||
col_rank.append({code: i + 1 for i, (code, _) in enumerate(ordered)})
|
||
|
||
# 行集合 = 每日前 per_day 名的并集;行内元数据在完整窗口(含今日列)上统计
|
||
in_top: list[set[str]] = [{c for c, r in rank.items() if r <= per_day} for rank in col_rank]
|
||
candidates: set[str] = set().union(*in_top) if in_top else set()
|
||
|
||
rows_out: list[dict[str, Any]] = []
|
||
for code in candidates:
|
||
pct_arr = [col.get(code) for col in col_pct]
|
||
rank_arr = [rank.get(code) for rank in col_rank]
|
||
top_flags = [r is not None and r <= per_day for r in rank_arr]
|
||
best: int | None = None
|
||
comp = 1.0
|
||
has_data = False
|
||
for p, r in zip(pct_arr, rank_arr):
|
||
if p is not None:
|
||
has_data = True
|
||
comp *= 1.0 + p / 100.0
|
||
if r is not None and (best is None or r < best):
|
||
best = r
|
||
first_date = next((dates[i] for i, f in enumerate(top_flags) if f), None)
|
||
rows_out.append(
|
||
{
|
||
"code": code,
|
||
"name": names.get(code, code),
|
||
"pct": pct_arr,
|
||
"rank": rank_arr,
|
||
"days_in": sum(top_flags),
|
||
"streak": _trailing_streak(top_flags),
|
||
"best_rank": best,
|
||
"sum_pct": round((comp - 1.0) * 100.0, 2) if has_data else None,
|
||
"first_date": first_date,
|
||
}
|
||
)
|
||
rows_out.sort(
|
||
key=lambda r: (
|
||
-r["days_in"],
|
||
-(r["sum_pct"] or 0.0),
|
||
r["best_rank"] if r["best_rank"] else 9999,
|
||
r["code"], # 全并列时按代码兜底:candidates 来自 set,迭代序跨进程不稳定
|
||
)
|
||
)
|
||
rows_out = rows_out[:_HOTSPOT_MAX_ROWS]
|
||
|
||
from easy_tdx.realtime.session import is_trading_time
|
||
|
||
payload: dict[str, Any] = {
|
||
"status": "ready",
|
||
"board_type": bt.name,
|
||
"days": days,
|
||
"mode": mode_norm,
|
||
"per_day": per_day,
|
||
"generated_at": int(time.time()),
|
||
"session": "live" if (append_live and is_trading_time()) else "closed",
|
||
"dates": dates,
|
||
"today_index": (len(dates) - 1) if append_live else None,
|
||
"total_boards": len(pct_map),
|
||
"rows": rows_out,
|
||
}
|
||
return DictResponse.from_dict(payload)
|
||
|
||
|
||
@router.get("/board-mac/hotspot-correlation", response_model=DictResponse)
|
||
async def board_hotspot_correlation(
|
||
board_type: str = Query("HY", description="板块类型: HY/HY2/GN/FG/DQ"),
|
||
days: int = Query(20, ge=5, le=_HOTSPOT_MAX_DAYS, description="相关性窗口交易日数"),
|
||
per_day: int = Query(5, ge=2, le=10, description="每日入选名次阈值(行集合口径)"),
|
||
top: int = Query(15, ge=5, le=25, description="入阵板块数上限(按上榜次数取前 N)"),
|
||
client: Any = Depends(get_mac_client),
|
||
) -> DictResponse:
|
||
"""热点板块相关性矩阵:窗口内活跃板块两两日涨跌幅的 Pearson 相关系数。
|
||
|
||
行集合与 ``/board-mac/hotspot`` 同口径(每日 mode=top 前 per_day 名的并集,
|
||
不含今日实时列),按上榜次数取前 ``top`` 个板块入阵。复用热点历史矩阵缓存
|
||
(无缓存时返回与 hotspot 相同的 building/error 状态,前端先拉 hotspot 即可)。
|
||
相关系数 >0(红)= 同涨同跌,<0(绿)= 跷跷板。
|
||
"""
|
||
bt = board_type_from_str(board_type)
|
||
key = bt.name
|
||
|
||
history, build_payload = _hotspot_history_or_build(key, bt, client)
|
||
if build_payload is not None:
|
||
return DictResponse.from_dict(build_payload)
|
||
|
||
axis_all: list[str] = history["axis"]
|
||
pct_map: dict[str, dict[str, float]] = history["pct"]
|
||
names: dict[str, str] = dict(history["names"])
|
||
|
||
# 仅用已完成交易日(不含今日),与热点矩阵的历史段对齐
|
||
window = [d for d in axis_all if d != _today_str()][-days:]
|
||
col_pct: list[dict[str, float]] = [
|
||
{c: m[d] for c, m in pct_map.items() if d in m} for d in window
|
||
]
|
||
in_top: list[set[str]] = [
|
||
set(sorted(col, key=lambda c: col[c], reverse=True)[:per_day]) for col in col_pct
|
||
]
|
||
days_in: dict[str, int] = {}
|
||
for s in in_top:
|
||
for c in s:
|
||
days_in[c] = days_in.get(c, 0) + 1
|
||
|
||
# 排序必须确定性:days_in 并列时按代码兜底——set 迭代序受哈希随机化影响,
|
||
# 跨进程不稳定,会导致相关矩阵行列顺序在服务重启后随机互换
|
||
chosen = sorted(days_in, key=lambda c: (-days_in[c], c))[:top]
|
||
if len(chosen) < 2:
|
||
return DictResponse.from_dict(
|
||
{"status": "ready", "boards": [], "matrix": [], "days": len(window)}
|
||
)
|
||
|
||
frame = pd.DataFrame({c: pct_map[c] for c in chosen}).T # 板块 × 交易日,缺失为 NaN
|
||
corr = frame.T.corr(min_periods=max(3, len(window) // 2))
|
||
|
||
boards = [
|
||
{"code": c, "name": names.get(c, c), "days_in": days_in[c]} for c in chosen
|
||
]
|
||
matrix: list[list[float | None]] = [
|
||
[
|
||
None if pd.isna(corr.loc[a, b]) else round(float(corr.loc[a, b]), 2)
|
||
for b in chosen
|
||
]
|
||
for a in chosen
|
||
]
|
||
return DictResponse.from_dict(
|
||
{
|
||
"status": "ready",
|
||
"board_type": bt.name,
|
||
"days": days,
|
||
"boards": boards,
|
||
"matrix": matrix,
|
||
}
|
||
)
|
||
|
||
|
||
def _trailing_streak(flags: list[bool]) -> int:
|
||
"""从末尾向前数连续 True(末位为 False 时对齐"当前连榜"语义返 0)。"""
|
||
if not flags or not flags[-1]:
|
||
return 0
|
||
n = 0
|
||
for f in reversed(flags):
|
||
if not f:
|
||
break
|
||
n += 1
|
||
return n
|