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

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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