"""配置管理 API 路由 - 品种配置上传/批量获取/批量任务创建""" import json import logging from pathlib import Path from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.schemas import CandleItem, SymbolDataResponse, TimeframeData from app.services.cache import ( check_cache_status, create_task, get_cached_data, save_market_data, ) from app.services.collector import fetch_symbol_data from app.services.scheduler import add_job logger = logging.getLogger(__name__) router = APIRouter(prefix="/config", tags=["品种配置"]) # 配置文件存储路径 CONFIG_DIR = Path(__file__).resolve().parent.parent.parent / "config" CONFIG_FILE = CONFIG_DIR / "symbols_config.json" class BatchFetchRequest(BaseModel): """批量获取请求体。""" periods: str | None = None data_type: str = "futures" selected_symbols: str | None = None def _ensure_config_dir() -> None: CONFIG_DIR.mkdir(parents=True, exist_ok=True) 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.get("") async def get_config(): """获取当前品种配置。""" _ensure_config_dir() if not CONFIG_FILE.exists(): return {"futures": {}, "stock": {}} with open(CONFIG_FILE, encoding="utf-8") as f: return json.load(f) @router.post("/upload") async def upload_config( file: UploadFile | None = File(None), request: Request = None, ): """上传品种配置文件(JSON 格式)。""" _ensure_config_dir() try: if file: content = await file.read() data = json.loads(content) else: body = await request.body() if not body: raise HTTPException(status_code=400, detail="请提供配置文件或JSON数据") data = json.loads(body) if not isinstance(data, dict): raise HTTPException(status_code=400, detail="配置文件必须是 JSON 对象") with open(CONFIG_FILE, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=4) futures_count = len(data.get("futures", {})) stock_count = len(data.get("stock", {})) return { "message": "配置文件上传成功", "futures_symbols": futures_count, "stock_symbols": stock_count, "symbols": data, } except json.JSONDecodeError as e: raise HTTPException(status_code=400, detail="无效的 JSON 格式") from e @router.post("/batch-fetch-all") async def batch_fetch_all( request: BatchFetchRequest, db: AsyncSession = Depends(get_db), ): """根据配置文件批量获取所有品种数据(智能缓存)。""" periods = request.periods data_type = request.data_type selected_symbols = request.selected_symbols _ensure_config_dir() if not CONFIG_FILE.exists(): raise HTTPException(status_code=400, detail="请先上传品种配置文件") with open(CONFIG_FILE, encoding="utf-8") as f: config = json.load(f) symbols_dict = config.get(data_type, {}) if not symbols_dict: raise HTTPException(status_code=400, detail=f"配置中没有 {data_type} 类型的品种") if selected_symbols: symbol_list = [s.strip() for s in selected_symbols.split(",") if s.strip()] symbols_dict = { name: code for name, code in symbols_dict.items() if code in symbol_list } if not symbols_dict: raise HTTPException(status_code=400, detail="选定的合约不在配置中") period_list = ( [p.strip() for p in periods.split(",")] if periods else ["5min", "15min", "30min", "60min", "daily"] ) results: dict = { "total": len(symbols_dict), "success": [], "failed": [], "cached": [], "details": {}, } for name, symbol in symbols_dict.items(): logger.info("处理品种: %s (%s)", name, symbol) cache_status = await check_cache_status(db, symbol, data_type, period_list) if cache_status["all_valid"]: results["cached"].append({"name": name, "symbol": symbol}) cached = await get_cached_data(db, symbol, data_type, period_list) timeframes = _build_timeframes( cached["timeframes"], period_list, cached.get("timestamp", "") ) results["details"][symbol] = SymbolDataResponse( symbol=symbol, data_type=data_type, current_price=cached.get("current_price"), timeframes=timeframes, source="cache", ) results["success"].append({"name": name, "symbol": symbol}) continue need_fetch = cache_status["missing_periods"] logger.info("需要采集的周期: %s", need_fetch) result = await fetch_symbol_data(symbol, data_type, need_fetch) if result and result.get("timeframes"): logger.info("采集到 %d 个周期的数据", len(result["timeframes"])) await save_market_data(db, symbol, result) all_timeframes: dict[str, list] = {} if cache_status["valid_periods"]: existing = await get_cached_data( db, symbol, data_type, cache_status["valid_periods"] ) if existing: all_timeframes.update(existing["timeframes"]) all_timeframes.update(result["timeframes"]) timeframes = _build_timeframes( all_timeframes, period_list, result.get("timestamp", "") ) source = "live+cache" if cache_status["valid_periods"] else "live" results["details"][symbol] = SymbolDataResponse( symbol=symbol, data_type=data_type, current_price=result.get("current_price"), timeframes=timeframes, source=source, ) results["success"].append({"name": name, "symbol": symbol}) logger.info("采集成功: %s", symbol) else: error_msg = (result or {}).get("error", "未知错误") logger.error("采集失败: %s, 错误: %s", symbol, error_msg) results["failed"].append({ "name": name, "symbol": symbol, "error": error_msg, }) return results @router.post("/batch-tasks") async def batch_create_tasks( periods: str | None = None, interval_seconds: int = 300, data_type: str = "futures", db: AsyncSession = Depends(get_db), ): """根据配置文件为所有品种批量创建定时任务。""" _ensure_config_dir() if not CONFIG_FILE.exists(): raise HTTPException(status_code=400, detail="请先上传品种配置文件") with open(CONFIG_FILE, encoding="utf-8") as f: config = json.load(f) symbols_dict = config.get(data_type, {}) if not symbols_dict: raise HTTPException(status_code=400, detail=f"配置中没有 {data_type} 类型的品种") period_list = ( [p.strip() for p in periods.split(",")] if periods else ["5min", "15min", "30min", "60min", "daily"] ) periods_str = ",".join(period_list) results: dict = {"total": len(symbols_dict), "created": [], "failed": []} for name, symbol in symbols_dict.items(): try: task = await create_task( db=db, symbol=symbol, data_type=data_type, periods=periods_str, interval_seconds=interval_seconds, ) job_id = await add_job(task.id, task.interval_seconds) task.job_id = job_id await db.flush() results["created"].append({ "name": name, "symbol": symbol, "task_id": task.id, "job_id": job_id, "interval": interval_seconds, }) except Exception as e: results["failed"].append({ "name": name, "symbol": symbol, "error": str(e), }) return results