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