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.
247 lines
7.7 KiB
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}
|