You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
buffer_platform/buffer_platform_v2/app/api/data.py

247 lines
7.7 KiB

"""数据 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}