提交
This commit is contained in:
252
backend/app/api/market.py
Normal file
252
backend/app/api/market.py
Normal file
@@ -0,0 +1,252 @@
|
||||
"""行情专题路由:大盘总览 / 打板 / 概念板块 / 指数(上证 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)],
|
||||
)
|
||||
Reference in New Issue
Block a user