diff --git a/backend/main.py b/backend/main.py index f5b8262..f3e42d4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -7,7 +7,7 @@ from contextlib import asynccontextmanager from dotenv import load_dotenv from database import init_db -from routes import stock, collections, shares, themes, core_stocks, fuyao +from routes import stock, collections, shares, themes, core_stocks, fuyao, market_dashboard from services.daily_collector import collector_loop, cache_cleanup_loop load_dotenv() @@ -46,6 +46,7 @@ app.include_router(shares.router, prefix="/api/share") app.include_router(themes.router, prefix="/api/themes") app.include_router(core_stocks.router, prefix="/api/core-stocks") app.include_router(fuyao.router, prefix="/api/v2") +app.include_router(market_dashboard.router, prefix="/api") # 生产模式:后端同时托管前端静态文件 # catch-all 路由在 API 路由之后注册,所以 API 优先级更高 diff --git a/backend/routes/market_dashboard.py b/backend/routes/market_dashboard.py new file mode 100644 index 0000000..ec234a9 --- /dev/null +++ b/backend/routes/market_dashboard.py @@ -0,0 +1,511 @@ +"""市场看板数据聚合路由(/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}") diff --git a/backend/services/fuyao_client.py b/backend/services/fuyao_client.py index d4d6c4f..d68898d 100644 --- a/backend/services/fuyao_client.py +++ b/backend/services/fuyao_client.py @@ -200,3 +200,88 @@ async def index_prices_snapshot(thscodes: str) -> list[dict]: async def index_prices_historical(thscode: str, start_ms: int, end_ms: int) -> list[dict]: items = await _run(_sdk.index_prices_historical, thscode, start_ms, end_ms) return _item_list(items) + + +# --------------------------------------------------------------- +# 特殊数据(涨停/跌停池) +# --------------------------------------------------------------- + +async def limit_up_pool(date_ms=None, page=1, size=50, + sort_field="seal_money", sort_dir="desc") -> dict: + data = await _run(_sdk.special_data_limit_up_pool, date_ms=date_ms, + page=page, size=size, sort_field=sort_field, sort_dir=sort_dir) + return data or {} + + +async def limit_down_pool(date_ms=None, page=1, size=50, + sort_field="last_limit_time", sort_dir="desc") -> dict: + data = await _run(_sdk.special_data_limit_down_pool, date_ms=date_ms, + page=page, size=size, sort_field=sort_field, sort_dir=sort_dir) + return data or {} + + +# --------------------------------------------------------------- +# 全市场行情快照 +# --------------------------------------------------------------- + +async def prices_snapshot_all(limit: int = 5000) -> list[dict]: + """拉取全市场 A 股行情快照(自动分页)""" + items = await _run(_sdk.prices_snapshot, None, fetch_all_market=True, limit=limit) + return _item_list(items) + + +# --------------------------------------------------------------- +# 特殊数据(热门股/龙虎榜/异动/连板/飙升) +# --------------------------------------------------------------- + +async def hot_stock_list(period: str = "day") -> dict: + data = await _run(_sdk.special_data_hot_stock_list, period=period) + return data or {} + + +async def dragon_tiger_list(board_type: str = "all", date: str = None) -> dict: + data = await _run(_sdk.special_data_dragon_tiger_list, board_type=board_type, date=date) + return data or {} + + +async def anomaly_analysis_list(tag_codes=None) -> dict: + data = await _run(_sdk.special_data_anomaly_analysis_list, tag_codes=tag_codes) + return data or {} + + +async def skyrocket_list(period: str = "day") -> dict: + data = await _run(_sdk.special_data_skyrocket_list, period=period) + return data or {} + + +async def limit_up_ladder() -> dict: + data = await _run(_sdk.special_data_limit_up_ladder) + return data or {} + + +# --------------------------------------------------------------- +# 集合竞价 / 估值 / 炸板 / 概念板块 +# --------------------------------------------------------------- + +async def auction_snapshot(thscodes: str, stage: str = "final") -> dict: + codes = [c.strip() for c in thscodes.split(",") if c.strip()] + data = await _run(_sdk.a_share_auction_snapshot, codes, stage=stage) + return data or {} + + +async def auction_short_term_benchmark(date: str = None) -> dict: + data = await _run(_sdk.a_share_auction_short_term_benchmark, date=date) + return data or {} + + +async def valuations_snapshot(thscodes: str) -> dict: + codes = [c.strip() for c in thscodes.split(",") if c.strip()] + data = await _run(_sdk.a_share_valuations_snapshot, codes) + return data or {} + + +async def limit_break_pool(date_ms=None, page=1, size=50, + sort_field="price_change_ratio_pct", sort_dir="desc") -> dict: + data = await _run(_sdk.special_data_limit_break_pool, date_ms=date_ms, + page=page, size=size, sort_field=sort_field, sort_dir=sort_dir) + return data or {} diff --git a/src/lib/market-dashboard-api.ts b/src/lib/market-dashboard-api.ts new file mode 100644 index 0000000..5b70119 --- /dev/null +++ b/src/lib/market-dashboard-api.ts @@ -0,0 +1,104 @@ +// 市场看板数据 API 客户端 +import { getApiBaseUrl } from "@/lib/api-client"; + +export interface MarketIndex { + code: string; + name: string; + price: number; + change: number; + changePct: number; + prevClose: number; + turnover: number; +} + +export interface MarketTemperature { + score: number; + label: string; + factors: { + advanceScore: number; + medianScore: number; + strongScore: number; + limitScore: number; + breakPenalty: number; + auctionBonus: number; + }; +} + +export interface AuctionData { + score: number; + label: string; + date: string; + benchmark: Record; +} + +export interface MarketStats { + upCount: number; + downCount: number; + flatCount: number; + total: number; + marketBreadth: number; + medianChange: number; + strongCount: number; + weakCount: number; + limitUp: number; + limitDown: number; + limitBreak: number; + breakRate: number; + totalTurnover: number; + temperature: MarketTemperature; + auction: AuctionData; + auctionSignal: string; +} + +export interface SectorStrengthItem { + code: string; + name: string; + price: number; + change: number; + changePct: number; + strength: number; + breadthPct: number; + strongCount: number; +} + +export interface ConceptStrengthItem { + code: string; + name: string; + changePct: number; +} + +export interface DashboardEvent { + type: string; + label: string; + name: string; + code: string; + detail: string; +} + +export interface MarketDashboardData { + indices: MarketIndex[]; + marketStats: MarketStats; + sectorStrength: SectorStrengthItem[]; + conceptStrength: ConceptStrengthItem[]; + events: DashboardEvent[]; + updateTime: string; +} + +export async function fetchMarketDashboard(): Promise { + const baseUrl = getApiBaseUrl(); + const resp = await fetch(`${baseUrl}/api/market-dashboard`, { + method: "GET", + cache: "no-store", + }); + if (!resp.ok) { + try { + const err = await resp.json(); + throw new Error(err.detail || `请求失败 (${resp.status})`); + } catch (e) { + if (e instanceof Error) throw e; + throw new Error(`请求失败 (${resp.status})`); + } + } + const result = await resp.json(); + return result.data; +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 069b5f0..f3900f0 100755 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as ThemesRouteImport } from './routes/themes' import { Route as ThemeHistoryRouteImport } from './routes/theme-history' import { Route as HotMapRouteImport } from './routes/hot-map' +import { Route as DashboardRouteImport } from './routes/dashboard' import { Route as CoreStocksRouteImport } from './routes/core-stocks' import { Route as IndexRouteImport } from './routes/index' import { Route as ThemeCodeRouteImport } from './routes/theme.$code' @@ -33,6 +34,11 @@ const HotMapRoute = HotMapRouteImport.update({ path: '/hot-map', getParentRoute: () => rootRouteImport, } as any) +const DashboardRoute = DashboardRouteImport.update({ + id: '/dashboard', + path: '/dashboard', + getParentRoute: () => rootRouteImport, +} as any) const CoreStocksRoute = CoreStocksRouteImport.update({ id: '/core-stocks', path: '/core-stocks', @@ -62,6 +68,7 @@ const ShareCodeRoute = ShareCodeRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/core-stocks': typeof CoreStocksRoute + '/dashboard': typeof DashboardRoute '/hot-map': typeof HotMapRoute '/theme-history': typeof ThemeHistoryRoute '/themes': typeof ThemesRoute @@ -72,6 +79,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/core-stocks': typeof CoreStocksRoute + '/dashboard': typeof DashboardRoute '/hot-map': typeof HotMapRoute '/theme-history': typeof ThemeHistoryRoute '/themes': typeof ThemesRoute @@ -83,6 +91,7 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/core-stocks': typeof CoreStocksRoute + '/dashboard': typeof DashboardRoute '/hot-map': typeof HotMapRoute '/theme-history': typeof ThemeHistoryRoute '/themes': typeof ThemesRoute @@ -95,6 +104,7 @@ export interface FileRouteTypes { fullPaths: | '/' | '/core-stocks' + | '/dashboard' | '/hot-map' | '/theme-history' | '/themes' @@ -105,6 +115,7 @@ export interface FileRouteTypes { to: | '/' | '/core-stocks' + | '/dashboard' | '/hot-map' | '/theme-history' | '/themes' @@ -115,6 +126,7 @@ export interface FileRouteTypes { | '__root__' | '/' | '/core-stocks' + | '/dashboard' | '/hot-map' | '/theme-history' | '/themes' @@ -126,6 +138,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute CoreStocksRoute: typeof CoreStocksRoute + DashboardRoute: typeof DashboardRoute HotMapRoute: typeof HotMapRoute ThemeHistoryRoute: typeof ThemeHistoryRoute ThemesRoute: typeof ThemesRoute @@ -157,6 +170,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof HotMapRouteImport parentRoute: typeof rootRouteImport } + '/dashboard': { + id: '/dashboard' + path: '/dashboard' + fullPath: '/dashboard' + preLoaderRoute: typeof DashboardRouteImport + parentRoute: typeof rootRouteImport + } '/core-stocks': { id: '/core-stocks' path: '/core-stocks' @@ -198,6 +218,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, CoreStocksRoute: CoreStocksRoute, + DashboardRoute: DashboardRoute, HotMapRoute: HotMapRoute, ThemeHistoryRoute: ThemeHistoryRoute, ThemesRoute: ThemesRoute, diff --git a/src/routes/dashboard.tsx b/src/routes/dashboard.tsx new file mode 100644 index 0000000..f53e69a --- /dev/null +++ b/src/routes/dashboard.tsx @@ -0,0 +1,485 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + fetchMarketDashboard, + type MarketDashboardData, + type MarketIndex, + type SectorStrengthItem, +} from "@/lib/market-dashboard-api"; +import { formatMoney } from "@/lib/utils"; +import { + ArrowLeft, + RefreshCw, + BarChart3, + Thermometer, + Zap, + Newspaper, + TrendingUp, + Target, +} from "lucide-react"; + +export const Route = createFileRoute("/dashboard")({ + component: DashboardPage, +}); + +function DashboardPage() { + const [autoRefresh, setAutoRefresh] = useState(false); + + const { data, isLoading, isFetching, isError, refetch } = useQuery({ + queryKey: ["market-dashboard"], + queryFn: fetchMarketDashboard, + staleTime: 15_000, + retry: false, + }); + + useEffect(() => { + if (!autoRefresh) return; + const timer = setInterval(() => refetch(), 30_000); + return () => clearInterval(timer); + }, [autoRefresh, refetch]); + + return ( +
+ {/* ── 顶栏 ── */} +
+
+
+ + + +

市场看板

+
+
+ {data?.updateTime && ( + 行情时间 {data.updateTime} + )} + + +
+
+
+ + {/* ── 主内容 ── */} +
+ {isLoading ? ( + + ) : isError ? ( +
+

数据加载失败

+ +
+ ) : data ? ( +
+ {/* 左侧主区域 */} +
+ {/* 指数行情 */} + + + {/* 集合竞价信号 */} + {data.marketStats.auctionSignal && ( + + )} + + {/* 市场温度 */} + + + {/* 涨跌家数分布 */} + + + {/* 行业强度榜 */} + + + {/* 概念板块热度 */} + {data.conceptStrength.length > 0 && ( + + )} +
+ + {/* 右侧事件栏 */} +
+ +
+
+ ) : null} +
+
+ ); +} + +/* ============================================================ + 指数行情卡片行(含估值) + ============================================================ */ +function IndicesRow({ indices }: { indices: MarketIndex[] }) { + return ( +
+ {indices.map((idx) => ( + + ))} +
+ ); +} + +function IndexCard({ index }: { index: MarketIndex }) { + const isUp = index.changePct >= 0; + return ( +
+
+ {index.name} + {index.code.replace(/\.(SH|SZ)$/, "")} +
+
+ {index.price.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +
+
+ + {isUp ? "+" : ""}{index.change.toFixed(2)} + + + {isUp ? "+" : ""}{index.changePct.toFixed(2)}% + +
+
+ ); +} + +/* ============================================================ + 集合竞价信号条 + ============================================================ */ +function AuctionSignalBar({ stats }: { stats: MarketDashboardData["marketStats"] }) { + const signal = stats.auctionSignal; + const color = + signal === "强势高开" ? "#f85149" : + signal === "偏强" ? "#f0883e" : + signal === "弱势低开" ? "#3fb950" : + signal === "偏弱" ? "#238636" : + "#d29922"; + + return ( +
+ + 竞价信号 + {signal} + {stats.auction?.date && ( + {stats.auction.date} + )} +
+ ); +} + +/* ============================================================ + 市场温度 + ============================================================ */ +function MarketTemperatureSection({ stats }: { stats: MarketDashboardData["marketStats"] }) { + const temp = stats.temperature; + const tempColor = + temp.score >= 80 ? "#f85149" : + temp.score >= 60 ? "#f0883e" : + temp.score >= 40 ? "#d29922" : + temp.score >= 20 ? "#3fb950" : "#58a6ff"; + + return ( +
+
+
+ + 市场温度 +
+
+ + {temp.score} + + {temp.label} +
+
+ +
+ + + = 0 ? "+" : ""}${stats.medianChange.toFixed(2)}%`} + valueColor={stats.medianChange >= 0 ? "#f85149" : "#3fb950"} /> + + 0 ? "#f85149" : "#8b949e"} /> + 30 ? "#f0883e" : "#8b949e"} /> +
+ + {/* 温度因子明细 */} + {temp.factors && ( +
+ 涨跌 {temp.factors.advanceScore > 0 ? "+" : ""}{temp.factors.advanceScore} + 中位 {temp.factors.medianScore > 0 ? "+" : ""}{temp.factors.medianScore} + 强弱 {temp.factors.strongScore > 0 ? "+" : ""}{temp.factors.strongScore} + 涨停 {temp.factors.limitScore > 0 ? "+" : ""}{temp.factors.limitScore} + 炸板 {temp.factors.breakPenalty} + 竞价 {temp.factors.auctionBonus > 0 ? "+" : ""}{temp.factors.auctionBonus} +
+ )} +
+ ); +} + +function StatItem({ label, value, valueColor }: { label: string; value: string; valueColor?: string }) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + +/* ============================================================ + 涨跌家数分布 + ============================================================ */ +function AdvanceDeclineBar({ stats }: { stats: MarketDashboardData["marketStats"] }) { + const total = stats.upCount + stats.flatCount + stats.downCount; + if (total === 0) return null; + + const upPct = (stats.upCount / total) * 100; + const flatPct = (stats.flatCount / total) * 100; + const downPct = (stats.downCount / total) * 100; + + return ( +
+
+
+ + 涨跌家数分布 +
+ + 涨停 {stats.limitUp} 炸板 {stats.limitBreak} 跌停 {stats.limitDown} 共 {total} 只 + +
+ +
+
+
+
+
+ +
+
+ + 上涨 + {stats.upCount} +
+
+ + 平盘 + {stats.flatCount} +
+
+ + 下跌 + {stats.downCount} +
+
+
+ ); +} + +/* ============================================================ + 行业强度榜 + ============================================================ */ +function SectorStrengthList({ sectors }: { sectors: SectorStrengthItem[] }) { + if (sectors.length === 0) return null; + + return ( +
+
+
+ + 行业强度榜 TOP{sectors.length} +
+
+ +
+ {sectors.slice(0, 15).map((sec) => ( + + ))} +
+
+ ); +} + +function SectorRow({ sector }: { sector: SectorStrengthItem }) { + const barWidth = Math.max(0, Math.min(100, sector.strength)); + const isUp = sector.changePct >= 0; + + return ( +
+ {sector.name} +
+
+
+
+ 强度 {sector.strength} + + {isUp ? "+" : ""}{sector.changePct.toFixed(2)}% + + 宽度 {sector.breadthPct}% + 强 {sector.strongCount} +
+
+ ); +} + +/* ============================================================ + 概念板块热度 + ============================================================ */ +function ConceptStrengthList({ concepts }: { concepts: MarketDashboardData["conceptStrength"] }) { + if (concepts.length === 0) return null; + + return ( +
+
+
+ + 概念板块热度 TOP{concepts.length} +
+
+ +
+ {concepts.map((c) => { + const isUp = c.changePct >= 0; + return ( + + {c.name} + + {isUp ? "+" : ""}{c.changePct.toFixed(2)}% + + + ); + })} +
+
+ ); +} + +/* ============================================================ + 事件情报侧栏 + ============================================================ */ +function EventSidebar({ events }: { events: MarketDashboardData["events"] }) { + return ( +
+
+ + 事件情报 +
+ +
+ {events.length === 0 ? ( +

暂无事件

+ ) : ( + events.map((evt, i) => ( + + )) + )} +
+
+ ); +} + +function EventItem({ event }: { event: MarketDashboardData["events"][0] }) { + const labelColor = + event.type === "ladder" ? "text-[#f85149] bg-[#f85149]/10 border-[#f85149]/30" : + event.type === "limit_up" ? "text-[#f0883e] bg-[#f0883e]/10 border-[#f0883e]/30" : + event.type === "hot" ? "text-[#d29922] bg-[#d29922]/10 border-[#d29922]/30" : + event.type === "skyrocket" ? "text-[#f778ba] bg-[#f778ba]/10 border-[#f778ba]/30" : + event.type === "dragon_tiger" ? "text-[#a371f7] bg-[#a371f7]/10 border-[#a371f7]/30" : + event.type === "limit_break" ? "text-[#8b949e] bg-[#8b949e]/10 border-[#8b949e]/30" : + event.type === "anomaly" ? "text-[#58a6ff] bg-[#58a6ff]/10 border-[#58a6ff]/30" : + "text-[#8b949e] bg-[#8b949e]/10 border-[#8b949e]/30"; + + return ( +
+
+ + {event.label} + +
+

{event.name}

+ {event.detail && ( +

{event.detail}

+ )} +
+ ); +} + +/* ============================================================ + 骨架屏 + ============================================================ */ +function DashboardSkeleton() { + return ( +
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+
+
+
+ ))} +
+
+
+
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+ ))} +
+
+
+
+
+
+
+
+ {Array.from({ length: 8 }).map((_, i) => ( +
+
+
+
+
+ ))} +
+
+ ); +} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 5488668..ce6c1d2 100755 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -10,7 +10,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; -import { Search, Plus, Share2, Trash2, TrendingUp, Loader2, Flame, Network } from "lucide-react"; +import { Search, Plus, Share2, Trash2, TrendingUp, Loader2, Flame, Network, BarChart3 } from "lucide-react"; import { toast } from "sonner"; export const Route = createFileRoute("/")({ @@ -212,6 +212,12 @@ function Index() {

创建股票集合,分享历史走势

+ + +