Files
sanguo_vnpy_v2/sanguo_api/routes.py
T

644 lines
23 KiB
Python

"""
FastAPI routes for Sanguo Quant API
"""
import os
from fastapi import APIRouter, HTTPException, Depends, WebSocket, Query, Header
from fastapi.responses import FileResponse
from pydantic import BaseModel
from .schemas import CtaBacktestRequest, OptimizeRequest, FactorAnalysisRequest
from .auth import (
create_token, token_expires_in,
verify_password,
verify_token as verify_token_impl,
)
from .ws import manager
from .strategy_registry import list_strategies, strategy_params, get_strategy_class
from .kline import load_kline
router = APIRouter()
_orchestrator = None
_auth_config = {"username": "admin", "password_hash": "", "jwt_secret": "x", "expire_minutes": 60}
def set_orchestrator(orch):
"""Set the global orchestrator instance"""
global _orchestrator
_orchestrator = orch
def get_orchestrator():
"""Get the global orchestrator instance"""
return _orchestrator
def set_auth_config(cfg):
"""Set authentication configuration"""
_auth_config.update(cfg)
class LoginRequest(BaseModel):
"""Login request schema"""
username: str
password: str
async def verify_token(authorization: str | None = Header(None)):
"""Dependency to verify JWT token from Authorization header"""
if authorization is None:
raise HTTPException(status_code=401, detail="Missing authorization header")
if not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Invalid authorization header format")
token = authorization.split(" ")[1]
return verify_token_impl(token)
@router.post("/auth/login")
def login(req: LoginRequest):
"""Authenticate user and return JWT token"""
if req.username != _auth_config["username"] or not verify_password(req.password, _auth_config["password_hash"]):
raise HTTPException(status_code=401, detail="用户名或密码错误")
return {"token": create_token(req.username), "expires_in": token_expires_in()}
@router.post("/auth/refresh", dependencies=[Depends(verify_token)])
def refresh_token(authorization: str | None = Header(None)):
"""P1.4 静默刷新:仍有效的旧 token 换新 token。
过期 token 401(verify_token 拒)——不放过期续命;前端在剩余<10min 时
主动调本端点,长回测轮询不再因 60min 过期跳登录。单用户无 refresh
token 体系,滑动续期即够。
"""
token = authorization.split(" ", 1)[1]
return {"token": create_token(verify_token_impl(token)), "expires_in": token_expires_in()}
_BENCHMARKS = ("hs300", "zz500", "zz1000", "zz2000")
def _build_fee_cfg(req: CtaBacktestRequest) -> dict:
"""前端费用参数打包成 cfg,走现有 cfg 通道注入 AShareBacktestingEngine。"""
return {
"commission_rate": req.commission_rate,
"min_commission": req.min_commission,
"stamp_duty_rate": req.stamp_duty_rate,
"transfer_fee_rate": req.transfer_fee_rate,
"slippage": req.slippage,
}
@router.post("/backtest/cta", dependencies=[Depends(verify_token)])
async def submit_cta(req: CtaBacktestRequest):
"""Submit CTA backtest task"""
if req.benchmark not in _BENCHMARKS:
raise HTTPException(status_code=422, detail=f"Invalid benchmark: {req.benchmark}. Must be one of {_BENCHMARKS}")
cls = get_strategy_class(req.strategy)
if cls is None:
raise HTTPException(status_code=400, detail=f"未知策略: {req.strategy}")
tid = await get_orchestrator().submit_cta(
strategy_class=cls,
symbol=req.symbol,
params=req.params,
start=req.start,
end=req.end,
cfg=_build_fee_cfg(req),
benchmark=req.benchmark,
capital=req.capital,
position_pct=req.position_pct,
interval=req.interval,
)
return {"task_id": tid}
@router.post("/backtest/optimize", dependencies=[Depends(verify_token)])
async def submit_optimize(req: OptimizeRequest):
"""Submit optimization task"""
cls = get_strategy_class(req.strategy)
if cls is None:
raise HTTPException(status_code=400, detail=f"未知策略: {req.strategy}")
tid = await get_orchestrator().submit_optimize(
strategy_class=cls,
symbol=req.symbol,
grid=req.grid,
start=req.start,
end=req.end,
cfg=None
)
return {"task_id": tid}
@router.post("/factor/analyze", dependencies=[Depends(verify_token)])
async def submit_factor(req: FactorAnalysisRequest):
"""Submit factor analysis task"""
tid = await get_orchestrator().submit_factor(
symbols=req.symbols,
factor_names=req.factor_names,
start=req.start,
end=req.end,
cfg=None,
output_dir="/tmp/factor"
)
return {"task_id": tid}
@router.get("/task/{task_id}", dependencies=[Depends(verify_token)])
def get_status(task_id: str):
"""Get task status"""
orch = get_orchestrator()
s = orch.get_status(task_id)
if s is None:
# 内存池无此 task(容器重启/已完成被清)→查 DB;有持久化结果则返其 status,
# 否则真不存在。治:done task 内存清后 GET /task 404 → 前端轮询死循环卡等待,
# 但结果其实在 DB(历史能查)。CTA/组合回测 done 后内存失均受益。
try:
from sanguo_backtest.result_store import load_result_by_task_id
if orch.db_path:
r = load_result_by_task_id(task_id, orch.db_path)
if r is not None:
return {
"task_id": task_id,
"status": r.status or "done",
"stage": "",
"error_msg": None,
}
except Exception:
pass
raise HTTPException(status_code=404, detail="task not found")
pool = orch.pool
stage = pool.get_stage(task_id)
task = pool.get_task(task_id)
return {
"task_id": task_id,
"status": s.value if hasattr(s, "value") else str(s),
"stage": stage or "",
"error_msg": task.error_msg if (task and isinstance(task.error_msg, str)) else None,
}
@router.get("/task/{task_id}/result", dependencies=[Depends(verify_token)])
def get_result(task_id: str):
"""Get task result"""
r = get_orchestrator().get_result(task_id)
if r is None:
raise HTTPException(status_code=404, detail="result not ready")
# Extract relative metrics from statistics
relative_metrics = {}
relative_fields = [
"total_return", "annual_return", "sharpe_ratio", "max_drawdown",
"alpha", "beta", "sortino_ratio", "information_ratio",
"annual_volatility", "benchmark_return", "benchmark_volatility",
]
for field in relative_fields:
if field in r.statistics:
relative_metrics[field] = r.statistics[field]
return {
"task_id": task_id,
"statistics": r.statistics,
"relative_metrics": relative_metrics,
"symbol": r.symbol,
"start": r.start,
"end": r.end,
"strategy": r.strategy,
"params": r.params,
"status": r.status,
}
@router.websocket("/ws/task/{task_id}")
async def task_ws(websocket: WebSocket, task_id: str, token: str = Query(...)):
"""WebSocket endpoint for task status updates"""
try:
verify_token(token)
except Exception:
await websocket.close(code=4401)
return
await websocket.accept()
manager.connect(task_id, websocket)
try:
while True:
await websocket.receive_text() # Keep connection alive
except Exception:
pass
finally:
manager.disconnect(task_id, websocket)
# ===== Backtest UI support endpoints (S1.4 / S1.5) =====
def _df_to_records(df) -> list[dict]:
"""DataFrame → list[dict] (empty-safe)."""
if df is None:
return []
if hasattr(df, "empty") and df.empty:
return []
if hasattr(df, "to_dict"):
return df.to_dict(orient="records")
return list(df)
@router.get("/strategy/list", dependencies=[Depends(verify_token)])
def strategy_list():
"""List available CTA strategies for the UI dropdown."""
return {"strategies": list_strategies()}
@router.get("/strategy/{name}/params", dependencies=[Depends(verify_token)])
def strategy_params_route(name: str):
"""Strategy parameters + defaults for the dynamic form."""
return strategy_params(name)
@router.get("/task/{task_id}/equity-curve", dependencies=[Depends(verify_token)])
def equity_curve(task_id: str):
r = get_orchestrator().get_result(task_id)
if r is None:
raise HTTPException(status_code=404, detail="result not ready")
return {"task_id": task_id, "equity_curve": _df_to_records(r.equity_curve)}
@router.get("/task/{task_id}/daily-pnl", dependencies=[Depends(verify_token)])
def daily_pnl(task_id: str):
r = get_orchestrator().get_result(task_id)
if r is None:
raise HTTPException(status_code=404, detail="result not ready")
ec = r.equity_curve
if ec is None or (hasattr(ec, "empty") and ec.empty) or "balance" not in ec.columns:
return {"task_id": task_id, "daily_pnl": []}
import pandas as pd
bal = pd.to_numeric(ec["balance"], errors="coerce")
pnl = bal.diff().fillna(0.0)
return {
"task_id": task_id,
"daily_pnl": [{"date": str(d), "pnl": float(p)} for d, p in zip(ec["date"], pnl)],
}
@router.get("/task/{task_id}/trades", dependencies=[Depends(verify_token)])
def trades_route(task_id: str):
r = get_orchestrator().get_result(task_id)
if r is None:
raise HTTPException(status_code=404, detail="result not ready")
return {"task_id": task_id, "trades": _df_to_records(r.trades)}
@router.get("/kline", dependencies=[Depends(verify_token)])
def kline(symbol: str, start: str, end: str):
"""Historical daily K-line for the backtest chart."""
try:
return {"symbol": symbol, "kline": load_kline(symbol, start, end)}
except Exception as e:
raise HTTPException(status_code=500, detail=f"kline load failed: {type(e).__name__}: {e}")
# ===== Factor (投研) endpoints (S2) =====
@router.get("/factor/list", dependencies=[Depends(verify_token)])
def factor_list():
"""List registered factors for the UI dropdown."""
from sanguo_factor.registry import list_factors
items = [{"name": f["name"], "category": f.get("category", "")} for f in list_factors()]
return {"factors": items}
@router.get("/task/{task_id}/ic-summary", dependencies=[Depends(verify_token)])
def ic_summary(task_id: str):
"""Factor IC summary (mean/std/icir/t_stat per period)."""
r = get_orchestrator().get_raw_result(task_id)
if r is None:
raise HTTPException(status_code=404, detail="result not ready")
ic = getattr(r, "ic_summary", None)
if ic is None:
raise HTTPException(status_code=404, detail="no ic_summary (not a factor result?)")
return {"task_id": task_id, "ic_summary": ic}
@router.get("/task/{task_id}/report/{factor}")
def factor_report(task_id: str, factor: str, token: str = Query(...)):
"""Serve the alphalens tears HTML report (token via query for iframe use)."""
try:
verify_token_impl(token)
except Exception:
raise HTTPException(status_code=401, detail="invalid token")
r = get_orchestrator().get_raw_result(task_id)
if r is None:
raise HTTPException(status_code=404, detail="result not ready")
paths = getattr(r, "report_paths", {}) or {}
path = paths.get(factor)
if not path or not os.path.exists(path):
raise HTTPException(status_code=404, detail=f"report for {factor} not found")
return FileResponse(path)
# ===== History + Optimization endpoints (S3) =====
def _to_bj(ts: str | None) -> str:
"""SQLite CURRENT_TIMESTAMP 存的是 UTC → 转北京时间(+8)显示。"""
if not ts:
return ""
try:
from datetime import datetime, timedelta
dt = datetime.strptime(ts, "%Y-%m-%d %H:%M:%S")
return (dt + timedelta(hours=8)).strftime("%Y-%m-%d %H:%M:%S")
except (ValueError, TypeError):
return ts
@router.get("/task", dependencies=[Depends(verify_token)])
def list_tasks(type: str | None = None, status: str | None = None):
"""List tasks: 内存中 running/pending + DB done/failed(历史)。
running/pending 任务还没存 DB(done 才 save_result),光查 DB 看不到刚提交的
→ 任务中心(GitHub Actions 模式)必须合并内存 active + DB 已完成。
时间口径:created_at = 提交时间(task_submissions,真创建时间);
finished_at = 结果落库时间;duration_s = 两者差(秒)。旧任务无提交记录时
created_at 退化为落库时间(即旧行为)、duration_s 为 None。
"""
from datetime import datetime
from sanguo_backtest.result_store import list_results, load_submissions
orch = get_orchestrator()
submissions: dict[str, str] = {}
try:
submissions = load_submissions(orch.db_path)
except Exception:
pass # 旧库无表/读失败 → 退化为旧行为
active: list[dict] = []
seen: set[str] = set()
# 1) 内存 active 任务 (running/pending,未存 DB)
for tid, task in list(getattr(orch.pool, "_tasks", {}).items()):
st = task.status.value if hasattr(task.status, "value") else str(task.status)
if st not in ("running", "pending"):
continue
if status and st != status:
continue
ttype = getattr(task, "task_type", "") or ""
if type and ttype != type:
continue
spec = getattr(orch, "_pending", {}).get(tid, {}) or {}
strat = spec.get("strategy", "")
if not strat and spec.get("strategy_class"):
strat = getattr(spec["strategy_class"], "__name__", str(spec["strategy_class"]))
active.append({
"id": 0,
"task_id": tid,
"type": ttype,
"status": st,
"strategy": strat,
"symbol": spec.get("symbol", "") or spec.get("benchmark", ""),
"start": spec.get("start", ""),
"end": spec.get("end", ""),
"created_at": _to_bj(submissions.get(tid)),
"finished_at": "",
"duration_s": None,
})
seen.add(tid)
# 2) DB 已持久化任务 (done/failed)
def _duration_s(submitted: str | None, finished: str | None) -> int | None:
if not submitted or not finished:
return None
try:
fmt = "%Y-%m-%d %H:%M:%S"
return int((datetime.strptime(finished, fmt) - datetime.strptime(submitted, fmt)).total_seconds())
except (ValueError, TypeError):
return None
db_items: list[dict] = []
for r in list_results(type_filter=type, db_path=orch.db_path):
if r.task_id in seen:
continue
if status and r.status != status:
continue
submitted = submissions.get(r.task_id)
db_items.append({
"id": r.id,
"task_id": r.task_id,
"type": r.type,
"status": r.status,
"strategy": r.strategy,
"symbol": r.symbol,
"start": r.start,
"end": r.end,
"created_at": _to_bj(submitted) or _to_bj(r.created_at),
"finished_at": _to_bj(r.created_at),
"duration_s": _duration_s(submitted, r.created_at),
})
active.reverse() # 最新提交的 running 在最上
db_items.reverse() # DB newest first
return {"tasks": active + db_items}
@router.delete("/task/{task_id}", dependencies=[Depends(verify_token)])
def delete_task(task_id: str):
"""Delete a task's persisted traces: DB rows + equity/trades files +
submission record. Running/pending tasks refuse deletion."""
orch = get_orchestrator()
task = orch.pool.get_task(task_id)
if task is not None:
st = task.status.value if hasattr(task.status, "value") else str(task.status)
if st in ("running", "pending"):
raise HTTPException(status_code=400, detail="任务进行中,完成后才能删除")
orch.pool._tasks.pop(task_id, None) # 终态内存残留一并清
from sanguo_backtest.result_store import delete_result_by_task_id
deleted = delete_result_by_task_id(task_id, orch.db_path)
if deleted == 0 and task is None:
raise HTTPException(status_code=404, detail="task not found")
return {"deleted": deleted}
@router.get("/task/{task_id}/params", dependencies=[Depends(verify_token)])
def task_params(task_id: str):
"""轻量参数回放(点任务ID跳参数页预填用):只回参数元数据,不带曲线。
运行中任务读内存 _pending spec;已完成任务读 DB。都没有 → 404。
"""
orch = get_orchestrator()
# 1) 内存运行中任务
spec = getattr(orch, "_pending", {}).get(task_id)
task = orch.pool.get_task(task_id)
st = task.status.value if task is not None and hasattr(task.status, "value") else ""
if spec is not None and st in ("running", "pending"):
strat = spec.get("strategy", "")
if not strat and spec.get("strategy_class"):
strat = getattr(spec["strategy_class"], "__name__", str(spec["strategy_class"]))
return {
"task_id": task_id,
"type": getattr(task, "task_type", "") or "",
"status": st,
"strategy": strat,
"symbol": spec.get("symbol", "") or spec.get("benchmark", ""),
"params": spec.get("params") or spec.get("grid") or {},
"start": spec.get("start", ""),
"end": spec.get("end", ""),
}
# 2) DB 已完成任务
from sanguo_backtest.result_store import load_result_by_task_id
r = load_result_by_task_id(task_id, orch.db_path)
if r is None:
raise HTTPException(status_code=404, detail="task not found")
return {
"task_id": task_id,
"type": r.type,
"status": r.status,
"strategy": r.strategy,
"symbol": r.symbol,
"params": r.params or {},
"start": r.start,
"end": r.end,
}
@router.get("/task/{task_id}/optimization-results", dependencies=[Depends(verify_token)])
def optimization_results(task_id: str):
"""Optimization results: list of {params, statistics} per parameter combo.
Bug2: Read from DB aggregate record (persistent across restarts).
Falls back to in-memory raw_result for backward compat (in-flight tasks).
"""
orch = get_orchestrator()
# Primary: DB aggregate record (survives API restart)
from sanguo_backtest.result_store import load_result_by_task_id
agg = load_result_by_task_id(task_id, orch.db_path)
if agg and agg.statistics and "combos" in agg.statistics:
return {"task_id": task_id, "results": agg.statistics["combos"]}
# Fallback: in-memory raw_result (backward compat for in-flight tasks)
raw = orch.get_raw_result(task_id)
if raw is None:
raise HTTPException(status_code=404, detail="optimization results not ready")
rows = []
for r in (raw if isinstance(raw, list) else [raw]):
rows.append({
"params": getattr(r, "params", {}),
"statistics": getattr(r, "statistics", {}),
})
return {"task_id": task_id, "results": rows}
# ===== Task 4: Backtest result API extensions =====
def _get_metrics_file_path(task_id: str) -> str | None:
"""Get the metrics file path. Tries the request task_id directly first, then
falls back to the result's internal task_id — run_cta_backtest writes the file
under ITS OWN uuid task_id (result.task_id), which differs from the runner's
task_id when submitted via the API."""
orch = get_orchestrator()
if not (hasattr(orch, 'db_path') and orch.db_path):
return None
file_dir = os.path.dirname(os.path.abspath(orch.db_path))
# 1. direct lookup by request task_id
direct = os.path.join(file_dir, f"{task_id}_metrics.json")
if os.path.exists(direct):
return direct
# 2. resolve via result.task_id (run_cta_backtest's own uuid)
r = orch.get_result(task_id)
if r is not None and getattr(r, "task_id", None):
resolved = os.path.join(file_dir, f"{r.task_id}_metrics.json")
if os.path.exists(resolved):
return resolved
return None
@router.get("/task/{task_id}/benchmark-curve", dependencies=[Depends(verify_token)])
def benchmark_curve(task_id: str):
"""Get benchmark curve data (strategy vs benchmark)."""
metrics_file = _get_metrics_file_path(task_id)
if not metrics_file:
# metrics 文件缺失时返回空 200(图表优雅降级),不再 404 触发前端整页空白
return {"dates": [], "strategy": [], "benchmark": []}
import json
with open(metrics_file, 'r') as f:
data = json.load(f)
series = data.get("series", {})
equity_curve = series.get("equity_curve", {})
benchmark_curve = series.get("benchmark_curve", {})
return {
"dates": equity_curve.get("dates", []),
"strategy": equity_curve.get("values", []),
"benchmark": benchmark_curve.get("values", [])
}
@router.get("/task/{task_id}/risk-series", dependencies=[Depends(verify_token)])
def risk_series(task_id: str):
"""Get risk series data (alpha, beta, drawdown)."""
metrics_file = _get_metrics_file_path(task_id)
if not metrics_file:
# metrics 文件缺失时返回空 200(图表优雅降级),不再 404 触发前端整页空白
return {"dates": [], "alpha": [], "beta": [], "drawdown": [], "strategy_vol": [], "benchmark_vol": []}
import json
with open(metrics_file, 'r') as f:
data = json.load(f)
series = data.get("series", {})
alpha = series.get("alpha", {})
beta = series.get("beta", {})
drawdown = series.get("drawdown", {})
vol_s = series.get("volatility_strategy", {})
vol_b = series.get("volatility_benchmark", {})
return {
"dates": alpha.get("dates", []),
"alpha": alpha.get("values", []),
"beta": beta.get("values", []),
"drawdown": drawdown.get("values", []),
"strategy_vol": vol_s.get("values", []),
"benchmark_vol": vol_b.get("values", [])
}
@router.get("/task/{task_id}/daily-holdings", dependencies=[Depends(verify_token)])
def daily_holdings(task_id: str):
"""Get daily holdings data from equity curve."""
r = get_orchestrator().get_result(task_id)
if r is None:
raise HTTPException(status_code=404, detail="result not ready")
ec = r.equity_curve
if ec is None or (hasattr(ec, "empty") and ec.empty):
return {"task_id": task_id, "daily_holdings": []}
# Return balance and return data as holdings
holdings = []
for _, row in ec.iterrows():
holding = {"date": str(row.get("date", ""))}
if "balance" in row:
holding["balance"] = float(row["balance"])
if "return" in row:
holding["return"] = float(row["return"])
holdings.append(holding)
return {"task_id": task_id, "daily_holdings": holdings}
@router.get("/task/{task_id}/log", dependencies=[Depends(verify_token)])
def log_endpoint(task_id: str):
"""Get backtest log text."""
orch = get_orchestrator()
if hasattr(orch, 'db_path') and orch.db_path:
file_dir = os.path.dirname(os.path.abspath(orch.db_path))
log_file = os.path.join(file_dir, f"{task_id}.log")
if os.path.exists(log_file):
with open(log_file, 'r') as f:
log_content = f.read()
return {"task_id": task_id, "log": log_content}
# Return empty log if file doesn't exist
return {"task_id": task_id, "log": ""}