"""行情专题路由:大盘总览 / 打板 / 概念板块 / 指数(上证 K 线、国际指数、指数详情与权重)。""" from __future__ import annotations from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Response from pydantic import TypeAdapter from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from .. import cache from ..data import index_global as index_mod from ..data import limit_board as limit_board_mod from ..data import ths_board as ths_board_mod from ..data.index_series import SH_INDEX, get_index_daily from ..data.aggregation import resample_bars from ..data.limit_board import LimitBoardError from ..data.market_overview import MarketOverviewError, fetch_overview from ..data.ths_board import ThsBoardError from ..db import async_session, get_session from ..models import StockBasic, TradeCalendar from ..schemas import ( CandleOut, GlobalIndexListResponse, IndexBasicOut, IndexDetailResponse, IndexQuoteBriefOut, IndexValuationPointOut, IndexWeightItemOut, IndexWeightsResponse, LimitBoardResponse, MarketOverviewResponse, ThsBoardListResponse, ThsBoardMembersResponse, ) from ._deps import INDEX_TIMEFRAMES, cached_json_response router = APIRouter() async def _is_trading_day_today() -> bool | None: """今日是否 A 股交易日(trade_date 为 String(8) unique 索引,等值查亚毫秒级); DB 不可用时返回 None,调用方回退 weekday 启发式(只影响盘中 TTL 精度)。""" today8 = datetime.now().strftime("%Y%m%d") try: async with async_session() as s: return bool(await s.scalar( select(TradeCalendar.id).where(TradeCalendar.trade_date == today8).limit(1) )) except Exception: # noqa: BLE001 return None @router.get("/market/overview", response_model=MarketOverviewResponse) async def get_market_overview(session: AsyncSession = Depends(get_session)) -> MarketOverviewResponse: """主页大盘总览:A 股 + 港美指数实时价(腾讯)叠加收盘历史走势(tushare), 沪深两市市值/成交统计 + 成交额历史。部分来源失败不影响其余。 """ is_trading_day: bool | None = None try: is_trading_day = bool(await session.scalar( select(TradeCalendar.id).where( TradeCalendar.trade_date == datetime.now().strftime("%Y%m%d")).limit(1) )) except Exception: # noqa: BLE001 —— 判定失败只影响「今日盘中 bar」是否追加 pass try: data = await fetch_overview(is_trading_day=is_trading_day) except MarketOverviewError as e: raise HTTPException(status_code=503, detail=str(e)) from e return MarketOverviewResponse(**data) @router.get("/market/limit-board", response_model=LimitBoardResponse) async def get_limit_board(session: AsyncSession = Depends(get_session)) -> LimitBoardResponse: """首页打板专题(同花顺口径):涨停/炸板/跌停三池 + 连板天梯 + 涨停最强板块, 当日快照(盘中 5 分钟 / 盘后 4 小时,整包 SWR 缓存)。部分池失败不影响其余。""" is_trading_day: bool | None = None try: is_trading_day = bool(await session.scalar( select(TradeCalendar.id).where( TradeCalendar.trade_date == datetime.now().strftime("%Y%m%d")).limit(1) )) except Exception: # noqa: BLE001 —— 判定失败回退 weekday 启发式(影响盘中 TTL 精度而已) pass try: data = await limit_board_mod.fetch_limit_board(is_trading_day) except LimitBoardError as e: raise HTTPException(status_code=503, detail=str(e)) from e return LimitBoardResponse(**data) @router.get("/market/boards", response_model=ThsBoardListResponse) async def list_ths_boards() -> ThsBoardListResponse: """概念/行业板块列表(同花顺口径,全部类型一次给全,前端本地过滤): ths_index 列表直缓存 24h + ths_daily 当日快照 SWR(盘中 5 分钟 / 盘后 4 小时)。""" try: data = await ths_board_mod.fetch_boards(await _is_trading_day_today()) except ThsBoardError as e: raise HTTPException(status_code=503, detail=str(e)) from e return ThsBoardListResponse(**data) @router.get("/market/boards/{code}/members", response_model=ThsBoardMembersResponse) async def list_ths_board_members(code: str, session: AsyncSession = Depends(get_session)) -> ThsBoardMembersResponse: """板块成分股(ths_member 懒加载缓存 24h)+ 最新现价/涨跌幅(candles LATERAL 现算)。""" bc = code.strip().upper() try: boards = await ths_board_mod.get_board_list() except ThsBoardError as e: raise HTTPException(status_code=503, detail=str(e)) from e board = next((b for b in boards if b["ts_code"] == bc), None) if board is None: raise HTTPException(status_code=404, detail=f"未知板块: {bc}") try: members = await ths_board_mod.get_members(session, bc) except Exception: raise HTTPException(status_code=503, detail="板块成分拉取失败,请稍后重试") return ThsBoardMembersResponse(code=bc, name=board.get("name"), members=members) @router.get("/market/index-candles", response_model=list[CandleOut]) async def get_index_candles(timeframe: str = "1d") -> Response: """上证指数全量 K 线:日线为基底(tushare index_daily,进程内+Redis 缓存), 聚合到 1d/1w/1M/1y。收盘口径(数据随 EOD 更新,与总览 spark 一致)。""" if timeframe not in INDEX_TIMEFRAMES: raise HTTPException(status_code=400, detail=f"timeframe 仅支持 {'/'.join(INDEX_TIMEFRAMES)}") key = f"idxkj:{cache.digest('idxc', SH_INDEX, timeframe)}" cached = await cached_json_response(key) if cached is not None: return cached try: bars = resample_bars(await get_index_daily(), timeframe) except Exception as e: # noqa: BLE001 raise HTTPException(status_code=502, detail=f"指数数据获取失败: {e}") outs = [ CandleOut(ts=b.ts, open=b.open, high=b.high, low=b.low, close=b.close, volume=b.volume, amount=b.amount, turnover=None) for b in bars ] # pydantic-core 序列化(与 raw_json 同款),历史不可变、TTL 兜到当日更新 raw = TypeAdapter(list[CandleOut]).dump_json(outs).decode() cache.local_set(key, raw, ttl=300) await cache.cache_set(key, raw, ttl=7200) return Response(content=raw, media_type="application/json") @router.get("/market/global-indexes", response_model=GlobalIndexListResponse) async def get_global_indexes() -> GlobalIndexListResponse: """国际指数卡片列表(index_global 21 个指数最新收盘 + 45 日 spark,SWR 缓存)。""" try: data = await index_mod.fetch_global_list() except index_mod.GlobalIndexError as e: raise HTTPException(status_code=503, detail=str(e)) from e payload = dict(data) payload["updated_at"] = payload.pop("fetched_at") return GlobalIndexListResponse(**payload) def _index_or_404(code: str) -> str: """详情/K线/权重接口只放行白名单内的指数 code。""" if not index_mod.ensure_known(code): raise HTTPException(status_code=404, detail=f"不支持的指数代码: {code}") return code @router.get("/market/indexes/{code}", response_model=IndexDetailResponse) async def get_index_detail(code: str) -> IndexDetailResponse: """指数详情聚合:最新行情(收盘口径)+ 基本信息(国内 index_basic / 国际静态表) + 估值指标(index_dailybasic,仅部分国内指数)。各层自带缓存,直接组装。""" code = _index_or_404(code) if index_mod.is_cn_index(code): name, region = index_mod.CN_INDEXES.get(code, code), "cn" else: g = index_mod.GLOBAL_META[code] name, region = g["name"], g["region"] try: quote = await index_mod.fetch_index_quote(code) except index_mod.GlobalIndexError as e: raise HTTPException(status_code=502, detail=f"指数行情获取失败: {e}") from e basic_raw = await index_mod.get_index_basic(code) basic = IndexBasicOut(**basic_raw) if basic_raw else None valuation_rows = await index_mod.get_index_valuation(code) valuation = IndexValuationPointOut(**valuation_rows[-1]) if valuation_rows else None history = [IndexValuationPointOut(**r) for r in valuation_rows] return IndexDetailResponse( code=code, name=name, region=region, quote=IndexQuoteBriefOut(**quote), basic=basic, valuation=valuation, valuation_history=history, ) @router.get("/market/indexes/{code}/candles", response_model=list[CandleOut]) async def get_any_index_candles(code: str, timeframe: str = "1d") -> Response: """白名单指数全量 K 线(国内 index_daily / 国际 index_global),1d/1w/1M/1y 聚合。 收盘口径,历史不可变、TTL 兜到当日更新(与首页上证 K 线同款缓存策略)。""" code = _index_or_404(code) if timeframe not in INDEX_TIMEFRAMES: raise HTTPException(status_code=400, detail=f"timeframe 仅支持 {'/'.join(INDEX_TIMEFRAMES)}") key = f"idxck:{cache.digest('idxk2', code, timeframe)}" cached = await cached_json_response(key) if cached is not None: return cached try: bars = resample_bars(await index_mod.get_index_bars(code), timeframe) except Exception as e: # noqa: BLE001 raise HTTPException(status_code=502, detail=f"指数数据获取失败: {e}") outs = [ CandleOut(ts=b.ts, open=b.open, high=b.high, low=b.low, close=b.close, volume=b.volume, amount=b.amount, turnover=None) for b in bars ] raw = TypeAdapter(list[CandleOut]).dump_json(outs).decode() cache.local_set(key, raw, ttl=300) await cache.cache_set(key, raw, ttl=7200) return Response(content=raw, media_type="application/json") @router.get("/market/indexes/{code}/weights", response_model=IndexWeightsResponse) async def get_index_weights( code: str, limit: int = 50, session: AsyncSession = Depends(get_session), ) -> IndexWeightsResponse: """指数成分股权重(index_weight 最近月度快照,按权重降序取前 limit)。 仅国内指数有数据;成分股名称从本地 stock_basic 回填。""" code = _index_or_404(code) limit = max(1, min(limit, 300)) data = await index_mod.get_index_weights(code) if data is None: raise HTTPException(status_code=404, detail=f"该指数暂无成分权重数据: {code}") items = data["items"][:limit] codes = [it["con_code"] for it in items] names: dict[str, str] = {} if codes: try: rows = await session.execute( select(StockBasic.ts_code, StockBasic.name).where(StockBasic.ts_code.in_(codes)) ) names = {r[0]: r[1] for r in rows.all()} except Exception: # noqa: BLE001 —— 名称缺失不阻塞权重展示 pass return IndexWeightsResponse( trade_date=data["trade_date"], total=data["total"], items=[IndexWeightItemOut(con_code=c, name=names.get(c), weight=w) for c, w in ((it["con_code"], it["weight"]) for it in items)], )