Files
stock/backend/app/api/market.py
2026-09-09 15:07:58 +08:00

253 lines
11 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.
"""行情专题路由:大盘总览 / 打板 / 概念板块 / 指数(上证 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 日 sparkSWR 缓存)。"""
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_global1d/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)],
)