"""数据 API 路由 - 批量获取/最新数据/缓存状态""" import logging from datetime import datetime from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database import get_db from app.models.market import SymbolTimestamp from app.schemas import ( BatchFetchRequest, BatchFetchResponse, CandleItem, SymbolDataResponse, TimeframeData, ) from app.services.cache import ( check_cache_status, get_cached_data, get_latest_cached, save_market_data, ) from app.services.collector import fetch_symbol_data logger = logging.getLogger(__name__) router = APIRouter(prefix="/data", tags=["数据"]) def _normalize_candles(candles: list[dict]) -> list[dict]: """将K线数据中的 time 字段统一为 datetime。""" normalized = [] for c in candles: candle_dict = dict(c) if "time" in candle_dict and "datetime" not in candle_dict: candle_dict["datetime"] = candle_dict.pop("time") normalized.append(candle_dict) return normalized def _build_timeframes( timeframes_dict: dict[str, list], periods: list[str], fetched_at: str, ) -> list[TimeframeData]: """从 timeframes 字典构建 TimeframeData 列表。""" result = [] for p in periods: candles = timeframes_dict.get(p, []) if candles: normalized = _normalize_candles(candles) result.append( TimeframeData( period=p, candles=[CandleItem(**c) for c in normalized], candle_count=len(normalized), fetched_at=fetched_at, ) ) return result @router.post("/batch-fetch", response_model=BatchFetchResponse) async def batch_fetch( req: BatchFetchRequest, db: AsyncSession = Depends(get_db), ): """批量获取指定品种、指定周期的数据(智能缓存)。""" symbols = req.symbols periods = req.periods data_type = req.data_type success: list[str] = [] failed: list[str] = [] details: dict = {} for sym in symbols: cache_status = await check_cache_status(db, sym, data_type, periods) if cache_status["all_valid"]: logger.info("[%s] 缓存全部命中,跳过采集", sym) cached = await get_cached_data(db, sym, data_type, periods) timeframes = _build_timeframes( cached["timeframes"], periods, cached.get("timestamp", "") ) details[sym] = SymbolDataResponse( symbol=sym, data_type=data_type, current_price=cached.get("current_price"), timeframes=timeframes, source="cache", ) success.append(sym) continue need_fetch = cache_status["missing_periods"] logger.info("[%s] 缓存部分缺失,需要采集: %s", sym, need_fetch) result = await fetch_symbol_data(sym, data_type, need_fetch) if result and result.get("timeframes"): await save_market_data(db, sym, result) success.append(sym) all_timeframes: dict[str, list] = {} if cache_status["valid_periods"]: existing = await get_cached_data( db, sym, data_type, cache_status["valid_periods"] ) if existing: all_timeframes.update(existing["timeframes"]) all_timeframes.update(result["timeframes"]) timeframes = _build_timeframes( all_timeframes, periods, result.get("timestamp", "") ) details[sym] = SymbolDataResponse( symbol=sym, data_type=data_type, current_price=result.get("current_price"), timeframes=timeframes, source="live+cache", ) else: failed.append(sym) details[sym] = {"error": (result or {}).get("error", "未知错误")} return BatchFetchResponse(success=success, failed=failed, details=details) @router.get("/latest/{symbol}", response_model=SymbolDataResponse) async def get_latest( symbol: str, data_type: str = "futures", period: str | None = None, end_time: str | None = None, db: AsyncSession = Depends(get_db), ): """从缓存获取最新数据。""" end_dt = None if end_time: try: end_dt = datetime.fromisoformat(end_time) except Exception as e: raise HTTPException(status_code=400, detail=f"end_time 格式错误: {e}") from e try: cached = await get_cached_data( db, symbol, data_type, [period] if period else None, end_time=end_dt ) if not cached: raise HTTPException(status_code=404, detail=f"未找到 {symbol} 的缓存数据") period_keys = list(cached["timeframes"].keys()) timeframes = _build_timeframes( cached["timeframes"], period_keys, cached.get("timestamp", "") ) return SymbolDataResponse( symbol=symbol, data_type=data_type, current_price=cached.get("current_price"), timeframes=timeframes, source="cache" if cached.get("is_fresh", False) else "cache_stale", ) except HTTPException: raise except Exception as e: logger.error("获取数据失败: symbol=%s, error=%s", symbol, e) raise HTTPException(status_code=500, detail=f"服务器内部错误: {e}") from e @router.get("/latest/{symbol}/{period}") async def get_latest_by_period( symbol: str, period: str, data_type: str = "futures", db: AsyncSession = Depends(get_db), ): """获取缓存中指定品种+周期的最新数据。""" cached = await get_cached_data(db, symbol, data_type, [period]) if not cached: raise HTTPException(status_code=404, detail=f"未找到 {symbol} {period} 的缓存") candles = cached["timeframes"].get(period, []) return { "symbol": symbol, "period": period, "data_type": data_type, "candles": candles, "candle_count": len(candles), "current_price": cached.get("current_price"), "fetched_at": cached.get("timestamp"), "is_fresh": cached.get("is_fresh", False), } @router.get("/cache-status/{symbol}") async def cache_status( symbol: str, db: AsyncSession = Depends(get_db), ): """查看品种的缓存状态。""" records = await get_latest_cached(db, symbol) if not records: return {"symbol": symbol, "cached_periods": [], "status": "no_data"} now = datetime.now() periods_info = [] for r in records: age_seconds = (now - r.fetched_at).total_seconds() periods_info.append({ "period": r.period, "candle_count": r.candle_count, "fetched_at": r.fetched_at.isoformat(), "age_seconds": round(age_seconds, 0), "is_fresh": age_seconds < settings.cache_ttl_seconds, }) return { "symbol": symbol, "cached_periods": periods_info, "status": "ok", } @router.get("/latest-timestamps") async def get_latest_timestamps( db: AsyncSession = Depends(get_db), ): """获取所有品种的最新数据时间戳。""" stmt = select(SymbolTimestamp) result = await db.execute(stmt) timestamps = list(result.scalars().all()) data = [] for ts in timestamps: data.append({ "symbol": ts.symbol, "data_type": ts.data_type, "last_refresh_at": ts.last_refresh_at.isoformat() if ts.last_refresh_at else None, }) return {"success": True, "data": data}