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/config.py

284 lines
9.2 KiB

"""配置管理 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