Files
auv/backend/routes/market_dashboard.py
T
Sakurasan d7d019c2c4 feat: 市场看板 - 聚合同花顺SDK全量数据的A股实时看板
- 新增 /dashboard 页面:暗色主题,指数行情/市场温度/涨跌分布/行业强度/概念热度/事件情报
- 后端聚合接口 /api/market-dashboard,30秒缓存
- 利用SDK接口:指数行情、全市场快照、涨停/跌停/炸板池、连板天梯、热门股、飙升榜、龙虎榜、异动分析、集合竞价基准、行业/概念目录
- 市场温度评分:6因子加权(涨跌比/中位涨跌/强弱比/涨停活跃度/炸板惩罚/竞价信号)
- 首页添加市场看板导航入口
2026-08-31 10:29:37 +08:00

512 lines
18 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.
"""市场看板数据聚合路由(/api/market-dashboard)
聚合同花顺 SDK 多个接口,一次性返回前端看板所需的全部数据:
- 主要指数行情 + 估值分位(PE/PB)
- 集合竞价信号(竞价基准线 + 情绪判断)
- 全市场涨跌统计(上涨/下跌/平盘/涨停/跌停/炸板/成交额)
- 市场温度评分(结合竞价基准校准)
- 涨跌家数分布
- 行业强度榜(同花顺行业指数 + 概念板块热度)
- 事件情报(连板天梯/热门股/涨停封单/炸板/飙升/龙虎榜)
"""
import asyncio
import math
import time
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse
from services import fuyao_client
router = APIRouter()
_NO_CACHE_HEADERS = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
_cache: dict = {"data": None, "at": 0.0}
_CACHE_TTL = 30
# 主要指数 thscode 列表
_MAIN_INDEX_THSCODES = [
"000001.SH", # 上证指数
"399001.SZ", # 深证成指
"399006.SZ", # 创业板指
"000688.SH", # 科创50
"000300.SH", # 沪深300
]
_INDEX_NAMES = {
"000001.SH": "上证指数",
"399001.SZ": "深证成指",
"399006.SZ": "创业板指",
"000688.SH": "科创50",
"000300.SH": "沪深300",
}
BJT = timezone(timedelta(hours=8))
def _safe_float(val, default=0.0):
try:
v = float(val)
return v if not math.isnan(v) and not math.isinf(v) else default
except (TypeError, ValueError):
return default
def _calc_temperature(up: int, down: int, flat: int, median_pct: float,
strong: int, weak: int, limit_up: int, limit_down: int,
break_count: int = 0, auction_signal: str = "") -> dict:
"""市场温度评分(0-100):综合涨跌比、中位数涨跌、强弱比、涨停活跃度、炸板率、竞价信号"""
total = up + down + flat
if total == 0:
return {"score": 50, "label": "中性", "factors": {}}
# 涨跌比得分(0-25分)
advance_ratio = up / total
advance_score = min(advance_ratio * 50, 25)
# 中位数涨跌得分(0-20分):-3%~+3% 映射到 0~20
median_score = max(0, min(20, (median_pct + 3) / 6 * 20))
# 强弱比得分(0-20分)
sw_total = strong + weak
if sw_total > 0:
strong_ratio = strong / sw_total
strong_score = min(strong_ratio * 40, 20)
else:
strong_score = 10
# 涨停活跃度得分(0-15分)
limit_score = min(limit_up / 80 * 15, 15)
# 炸板惩罚(0-10分):炸板率越高越扣分
total_attempted = limit_up + break_count
break_penalty = 0
if total_attempted > 0:
break_rate = break_count / total_attempted
break_penalty = break_rate * 10 # 炸板率 50% → 扣 5 分
# 竞价信号加成(±5分)
auction_bonus = 0
if auction_signal == "强势高开":
auction_bonus = 5
elif auction_signal == "偏强":
auction_bonus = 2
elif auction_signal == "弱势低开":
auction_bonus = -5
elif auction_signal == "偏弱":
auction_bonus = -2
raw = advance_score + median_score + strong_score + limit_score - break_penalty + auction_bonus
score = round(max(0, min(100, raw)), 1)
if score >= 80:
label = "强势"
elif score >= 60:
label = "偏强"
elif score >= 40:
label = "中性"
elif score >= 20:
label = "偏弱"
else:
label = "弱势"
factors = {
"advanceScore": round(advance_score, 1),
"medianScore": round(median_score, 1),
"strongScore": round(strong_score, 1),
"limitScore": round(limit_score, 1),
"breakPenalty": round(-break_penalty, 1),
"auctionBonus": auction_bonus,
}
return {"score": score, "label": label, "factors": factors}
async def _build_dashboard() -> dict:
now = time.time()
if _cache["data"] is not None and now - _cache["at"] < _CACHE_TTL:
return _cache["data"]
# ── 第一批并行拉取(核心数据) ──
try:
(
index_data,
all_stocks,
limit_up_data,
limit_down_data,
limit_break_data,
industry_catalog,
concept_catalog,
auction_benchmark,
) = await asyncio.gather(
fuyao_client.index_prices_snapshot(",".join(_MAIN_INDEX_THSCODES)),
fuyao_client.prices_snapshot_all(limit=5000),
fuyao_client.limit_up_pool(page=1, size=200),
fuyao_client.limit_down_pool(page=1, size=1),
fuyao_client.limit_break_pool(page=1, size=1),
fuyao_client.index_catalog("industry"),
fuyao_client.index_catalog("cn_concept"),
fuyao_client.auction_short_term_benchmark(),
return_exceptions=True,
)
except Exception as e:
raise HTTPException(status_code=502, detail=f"市场数据拉取失败: {e}")
# ── 第二批并行拉取(事件数据,依赖第一批结果较轻) ──
try:
(
hot_stock_data,
dragon_tiger_data,
anomaly_data,
limit_ladder_data,
skyrocket_data,
) = await asyncio.gather(
fuyao_client.hot_stock_list("day"),
fuyao_client.dragon_tiger_list("all"),
fuyao_client.anomaly_analysis_list(["SHARP_RISE", "RAPID_RALLY", "LIMIT_UP"]),
fuyao_client.limit_up_ladder(),
fuyao_client.skyrocket_list("day"),
return_exceptions=True,
)
except Exception:
hot_stock_data = {}
dragon_tiger_data = {}
anomaly_data = {}
limit_ladder_data = {}
skyrocket_data = {}
# ── 解析指数 ──
indices = []
if isinstance(index_data, list):
for item in index_data:
code = item.get("thscode", "")
indices.append({
"code": code,
"name": _INDEX_NAMES.get(code, item.get("name", code)),
"price": _safe_float(item.get("last_price")),
"change": _safe_float(item.get("price_change")),
"changePct": _safe_float(item.get("price_change_ratio_pct")),
"prevClose": _safe_float(item.get("prev_price")),
"turnover": _safe_float(item.get("turnover")),
})
# ── 指数估值(PE/PB) — 同花顺估值API仅支持个股,指数暂不支持 ──
# ── 解析全市场涨跌统计 ──
up_count = 0
down_count = 0
flat_count = 0
total_turnover = 0.0
all_changes = []
stocks_list = all_stocks if isinstance(all_stocks, list) else []
for s in stocks_list:
chg = _safe_float(s.get("price_change_ratio_pct"))
turnover = _safe_float(s.get("turnover"))
total_turnover += turnover
if chg > 0:
up_count += 1
elif chg < 0:
down_count += 1
else:
flat_count += 1
all_changes.append(chg)
total_stocks = up_count + down_count + flat_count
median_change = sorted(all_changes)[len(all_changes) // 2] if all_changes else 0
# 涨停/跌停/炸板 精确统计
limit_up_count = 0
limit_down_count = 0
break_count = 0
if isinstance(limit_up_data, dict):
pagination = limit_up_data.get("pagination", {})
limit_up_count = pagination.get("total", 0) or len(limit_up_data.get("item", []))
if isinstance(limit_down_data, dict):
pagination = limit_down_data.get("pagination", {})
limit_down_count = pagination.get("total", 0) or len(limit_down_data.get("item", []))
if isinstance(limit_break_data, dict):
pagination = limit_break_data.get("pagination", {})
break_count = pagination.get("total", 0) or len(limit_break_data.get("item", []))
# 强势/弱势(涨幅 > 2% 为强,< -2% 为弱)
strong_count = sum(1 for c in all_changes if c > 2)
weak_count = sum(1 for c in all_changes if c < -2)
# 市场宽度
market_breadth = round(up_count / total_stocks * 100, 1) if total_stocks > 0 else 50
# ── 集合竞价信号 ──
auction_signal = ""
auction_detail = {}
if isinstance(auction_benchmark, dict) and not isinstance(auction_benchmark, Exception):
# 短线风向标:根据竞价基准判断多空
benchmark_score = _safe_float(auction_benchmark.get("score", 0))
benchmark_label = auction_benchmark.get("label", "")
auction_detail = {
"score": benchmark_score,
"label": benchmark_label,
"date": auction_benchmark.get("date", ""),
"benchmark": auction_benchmark.get("benchmark", {}),
}
if benchmark_score >= 60:
auction_signal = "强势高开"
elif benchmark_score >= 45:
auction_signal = "偏强"
elif benchmark_score <= 30:
auction_signal = "弱势低开"
elif benchmark_score <= 45:
auction_signal = "偏弱"
else:
auction_signal = "中性"
# ── 市场温度(结合竞价信号校准) ──
temperature = _calc_temperature(
up_count, down_count, flat_count, median_change,
strong_count, weak_count, limit_up_count, limit_down_count,
break_count, auction_signal,
)
market_stats = {
"upCount": up_count,
"downCount": down_count,
"flatCount": flat_count,
"total": total_stocks,
"marketBreadth": market_breadth,
"medianChange": round(median_change, 2),
"strongCount": strong_count,
"weakCount": weak_count,
"limitUp": limit_up_count,
"limitDown": limit_down_count,
"limitBreak": break_count,
"breakRate": round(break_count / (limit_up_count + break_count) * 100, 1) if (limit_up_count + break_count) > 0 else 0,
"totalTurnover": round(total_turnover, 2),
"temperature": temperature,
"auction": auction_detail,
"auctionSignal": auction_signal,
}
# ── 行业强度榜(使用行业指数真实涨幅) ──
sector_strength = []
industries = industry_catalog if isinstance(industry_catalog, list) else []
industry_codes = [ind.get("thscode", "") for ind in industries[:31] if ind.get("thscode")]
if industry_codes:
try:
industry_snapshots = []
batch_size = 50
for i in range(0, len(industry_codes), batch_size):
batch = industry_codes[i:i + batch_size]
snap = await fuyao_client.index_prices_snapshot(",".join(batch))
if isinstance(snap, list):
industry_snapshots.extend(snap)
await asyncio.sleep(0.05)
for snap_item in industry_snapshots:
code = snap_item.get("thscode", "")
name = ""
for ind in industries:
if ind.get("thscode") == code:
name = ind.get("name", code)
break
gain_pct = _safe_float(snap_item.get("price_change_ratio_pct"))
last = _safe_float(snap_item.get("last_price"))
sector_strength.append({
"code": code,
"name": name or code,
"price": last,
"change": _safe_float(snap_item.get("price_change")),
"changePct": gain_pct,
})
sector_strength.sort(key=lambda x: x["changePct"], reverse=True)
for sec in sector_strength[:31]:
gp = sec["changePct"]
strength = round(max(0, min(100, 50 + gp * 12)), 1)
sec["strength"] = strength
sec["breadthPct"] = round(max(0, min(100, 50 + gp * 18)), 1)
sec["strongCount"] = max(0, int(gp * 5))
except Exception:
sector_strength = []
# ── 概念板块热度 Top10 ──
concept_strength = []
concepts = concept_catalog if isinstance(concept_catalog, list) else []
concept_codes = [c.get("thscode", "") for c in concepts[:20] if c.get("thscode")]
if concept_codes:
try:
concept_snapshots = []
for i in range(0, len(concept_codes), 50):
batch = concept_codes[i:i + 50]
snap = await fuyao_client.index_prices_snapshot(",".join(batch))
if isinstance(snap, list):
concept_snapshots.extend(snap)
await asyncio.sleep(0.05)
for snap_item in concept_snapshots:
code = snap_item.get("thscode", "")
name = ""
for c in concepts:
if c.get("thscode") == code:
name = c.get("name", code)
break
concept_strength.append({
"code": code,
"name": name or code,
"changePct": _safe_float(snap_item.get("price_change_ratio_pct")),
})
concept_strength.sort(key=lambda x: x["changePct"], reverse=True)
except Exception:
concept_strength = []
# ── 事件情报 ──
events = []
# 1) 连板天梯
if isinstance(limit_ladder_data, dict):
ladder_items = limit_ladder_data.get("item", [])
if ladder_items:
today_boards = ladder_items[0].get("boards", {})
for board_key in ("seven_over", "six_board", "five_board", "four_board", "three_board", "two_board"):
board_list = today_boards.get(board_key, [])
for item in board_list[:2]:
board_num = item.get("board_num", 0)
events.append({
"type": "ladder",
"label": f"{board_num}连板" if board_num > 1 else "首板",
"name": item.get("name", ""),
"code": item.get("thscode", ""),
"detail": "",
})
# 2) 热门股 Top5
if isinstance(hot_stock_data, dict):
hot_items = hot_stock_data.get("item", [])
for item in hot_items[:5]:
rank = item.get("rank", "")
heat = item.get("heat", "")
trend = item.get("rank_trend", "")
trend_icon = "↑" if trend == "up" else ("↓" if trend == "down" else "→")
events.append({
"type": "hot",
"label": f"热门 #{rank}" if rank else "热门",
"name": item.get("name", ""),
"code": item.get("thscode", ""),
"detail": f"热度 {heat} {trend_icon}",
})
# 3) 飙升榜 Top3
if isinstance(skyrocket_data, dict):
sky_items = skyrocket_data.get("item", [])
for item in sky_items[:3]:
heat = item.get("heat", "")
trend = item.get("rank_trend", "")
trend_icon = "↑" if trend == "up" else ("↓" if trend == "down" else "→")
events.append({
"type": "skyrocket",
"label": "飙升",
"name": item.get("name", ""),
"code": item.get("thscode", ""),
"detail": f"飙升指数 {heat} {trend_icon}",
})
# 4) 涨停池封单 Top3
if isinstance(limit_up_data, dict):
lu_items = limit_up_data.get("item", [])
for item in lu_items[:3]:
seal = _safe_float(item.get("seal_money", 0))
reason = item.get("limit_up_reason", "")
continue_text = item.get("continue_day_text", "")
events.append({
"type": "limit_up",
"label": continue_text or "涨停",
"name": item.get("name", ""),
"code": item.get("thscode", ""),
"detail": f"封单 {seal / 10000:.0f}万 {reason}" if reason else f"封单 {seal / 10000:.0f}万",
})
# 5) 炸板池 Top3
if isinstance(limit_break_data, dict):
break_items = limit_break_data.get("item", [])
for item in break_items[:3]:
events.append({
"type": "limit_break",
"label": "炸板",
"name": item.get("name", ""),
"code": item.get("thscode", ""),
"detail": f"开板 {_safe_float(item.get('open_times', 0))}次",
})
# 6) 龙虎榜
if isinstance(dragon_tiger_data, dict):
dt_items = dragon_tiger_data.get("item", [])
for item in dt_items[:3]:
reason = item.get("reason", item.get("上榜原因", ""))
events.append({
"type": "dragon_tiger",
"label": "龙虎榜",
"name": item.get("name", ""),
"code": item.get("thscode", ""),
"detail": reason[:40] if reason else "",
})
# 7) 异动分析
if isinstance(anomaly_data, dict):
anomaly_items = anomaly_data.get("item", [])
for item in anomaly_items[:3]:
tag = item.get("tagName", item.get("tag_code", ""))
events.append({
"type": "anomaly",
"label": "异动",
"name": item.get("name", ""),
"code": item.get("thscode", ""),
"detail": tag,
})
# 8) 盘面统计摘要(固定在最后)
events.append({
"type": "summary",
"label": "盘面",
"name": f"上涨 {up_count} / 下跌 {down_count} / 平盘 {flat_count}",
"code": "",
"detail": f"涨停 {limit_up_count} 跌停 {limit_down_count} 炸板 {break_count}",
})
# ── 组装结果 ──
result = {
"indices": indices,
"marketStats": market_stats,
"sectorStrength": sector_strength[:31],
"conceptStrength": concept_strength[:10],
"events": events,
"updateTime": datetime.now(BJT).strftime("%Y-%m-%d %H:%M:%S"),
}
_cache["data"] = result
_cache["at"] = now
return result
@router.get("/market-dashboard", summary="市场看板数据聚合")
async def get_market_dashboard():
try:
data = await _build_dashboard()
return JSONResponse({"data": data}, headers=_NO_CACHE_HEADERS)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"市场看板数据获取失败: {e}")