feat(api): 回测结果接口(strategy list/params + equity-curve/daily-pnl/trades + kline)

- strategy_registry 枚举 vnpy_ctastrategy 策略(兜底 STRATEGY_NAMES)
- /strategy/list、/strategy/{name}/params
- /task/{id}/equity-curve、/daily-pnl、/trades(BacktestResult JSON 化)
- /kline(read_db_daily 历史 K 线)
- 9 tests passed(4 strategy_registry + 5 routes)
This commit is contained in:
2026-07-07 06:08:53 +08:00
parent 510f77e6ea
commit 3a0e75fdc1
5 changed files with 288 additions and 1 deletions
+32
View File
@@ -0,0 +1,32 @@
"""Historical K-line loader for the backtest result chart.
Reads daily bars from the A-share DB via sanguo_data.datareader.read_db_daily
and returns plain dicts for the frontend candlestick chart. Task S1.5.
"""
from __future__ import annotations
def load_kline(symbol: str, start: str, end: str, cfg=None) -> list[dict]:
"""Return [{datetime, open, high, low, close, volume, vt_symbol}, ...].
Args:
symbol: Bare symbol e.g. "600000" (DB stores without exchange suffix).
start: Start date YYYY-MM-DD.
end: End date YYYY-MM-DD.
cfg: Optional data config; None uses default data_platform.yaml.
"""
from sanguo_data.datareader import read_db_daily
bars = read_db_daily(symbol, start, end, cfg)
return [
{
"datetime": str(b.datetime),
"open": b.open_price,
"high": b.high_price,
"low": b.low_price,
"close": b.close_price,
"volume": getattr(b, "volume", 0),
"vt_symbol": getattr(b, "vt_symbol", symbol),
}
for b in bars
]
+70 -1
View File
@@ -6,6 +6,8 @@ from pydantic import BaseModel
from .schemas import CtaBacktestRequest, OptimizeRequest, FactorAnalysisRequest
from .auth import verify_token as verify_token_impl, verify_password, create_token
from .ws import manager
from .strategy_registry import list_strategies, strategy_params
from .kline import load_kline
router = APIRouter()
@@ -138,4 +140,71 @@ async def task_ws(websocket: WebSocket, task_id: str, token: str = Query(...)):
except Exception:
pass
finally:
manager.disconnect(task_id, websocket)
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}")
+56
View File
@@ -0,0 +1,56 @@
"""Enumerate vnpy_ctastrategy CTA strategies + their parameters.
Used by the backtest UI dropdown and dynamic parameter form. Falls back to a
static name list when vnpy_ctastrategy is not importable (e.g. local dev).
Task S1.3.
"""
from __future__ import annotations
import importlib
import pkgutil
# Fallback strategy names (when vnpy_ctastrategy import fails).
STRATEGY_NAMES: list[str] = ["DoubleMaStrategy", "BollChannelStrategy", "AtrRsiStrategy"]
def _load_strategy_classes() -> dict[str, type]:
"""Import all Strategy classes from vnpy_ctastrategy.strategies."""
classes: dict[str, type] = {}
try:
mod = importlib.import_module("vnpy_ctastrategy.strategies")
for _, name, _ in pkgutil.iter_modules(mod.__path__):
try:
m = importlib.import_module(f"vnpy_ctastrategy.strategies.{name}")
for attr in dir(m):
obj = getattr(m, attr)
if isinstance(obj, type) and attr.endswith("Strategy") and hasattr(obj, "parameters"):
classes[attr] = obj
except Exception:
continue
except Exception:
pass
return classes
def list_strategies() -> list[dict]:
"""Return [{name, class_name}, ...] for the UI dropdown."""
classes = _load_strategy_classes()
if classes:
return [{"name": n, "class_name": n} for n in sorted(classes)]
return [{"name": n, "class_name": n} for n in STRATEGY_NAMES]
def strategy_params(name: str) -> dict:
"""Return {parameters: [...], defaults: {...}} for a strategy's dynamic form."""
classes = _load_strategy_classes()
cls = classes.get(name)
if cls is None:
return {"parameters": [], "defaults": {}}
params = list(getattr(cls, "parameters", []))
defaults = {p: getattr(cls, p, None) for p in params}
return {"parameters": params, "defaults": defaults}
def get_strategy_class(name: str) -> type | None:
"""Return the strategy class by name (None if unavailable)."""
return _load_strategy_classes().get(name)