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:
@@ -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
@@ -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}")
|
||||
@@ -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)
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Tests for backtest UI support endpoints (S1.4).
|
||||
|
||||
Uses a FakeOrch returning a BacktestResult with equity_curve/trades so we can
|
||||
assert the strategy/equity-curve/daily-pnl/trades endpoints without a real
|
||||
orchestrator or DB.
|
||||
"""
|
||||
import pytest
|
||||
import pandas as pd
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from sanguo_api.app import create_app
|
||||
from sanguo_api.routes import set_orchestrator
|
||||
from sanguo_api.auth import hash_password
|
||||
from sanguo_backtest.result_store import BacktestResult
|
||||
|
||||
|
||||
class FakeOrch:
|
||||
def __init__(self, result):
|
||||
self._r = result
|
||||
|
||||
def get_result(self, task_id):
|
||||
return self._r
|
||||
|
||||
|
||||
def _result() -> BacktestResult:
|
||||
return BacktestResult(
|
||||
task_id="cta_t", type="cta", status="done", strategy="DoubleMaStrategy",
|
||||
symbol="600000", params={"fast_window": 10}, start="2024-01-01", end="2024-06-30",
|
||||
statistics={"total_return": 0.1, "sharpe_ratio": 1.2},
|
||||
equity_curve=pd.DataFrame([
|
||||
{"date": "2024-01-01", "balance": 1_000_000.0},
|
||||
{"date": "2024-01-02", "balance": 1_010_000.0},
|
||||
{"date": "2024-01-03", "balance": 1_005_000.0},
|
||||
]),
|
||||
trades=pd.DataFrame([
|
||||
{"datetime": "2024-01-02", "direction": "多", "offset": "开",
|
||||
"price": 10.5, "volume": 100, "vt_symbol": "600000.SSE"},
|
||||
]),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client() -> TestClient:
|
||||
app = create_app(
|
||||
db_path="/tmp/test_bt_routes.db",
|
||||
auth_config={
|
||||
"username": "admin",
|
||||
"password_hash": hash_password("admin"),
|
||||
"jwt_secret": "test-secret",
|
||||
"expire_minutes": 60,
|
||||
},
|
||||
max_workers=1,
|
||||
)
|
||||
set_orchestrator(FakeOrch(_result()))
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def token(client) -> str:
|
||||
r = client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
|
||||
assert r.status_code == 200
|
||||
return r.json()["token"]
|
||||
|
||||
|
||||
def test_endpoints_require_auth(client):
|
||||
assert client.get("/api/v1/task/t/equity-curve").status_code == 401
|
||||
assert client.get("/api/v1/strategy/list").status_code == 401
|
||||
|
||||
|
||||
def test_strategy_list_and_params(client, token):
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
r = client.get("/api/v1/strategy/list", headers=h)
|
||||
assert r.status_code == 200
|
||||
assert "strategies" in r.json()
|
||||
r2 = client.get("/api/v1/strategy/DoubleMaStrategy/params", headers=h)
|
||||
assert r2.status_code == 200
|
||||
assert "parameters" in r2.json()
|
||||
|
||||
|
||||
def test_equity_curve(client, token):
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
eq = client.get("/api/v1/task/t/equity-curve", headers=h).json()
|
||||
assert len(eq["equity_curve"]) == 3
|
||||
assert eq["equity_curve"][1]["balance"] == 1_010_000.0
|
||||
|
||||
|
||||
def test_daily_pnl(client, token):
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
pnl = client.get("/api/v1/task/t/daily-pnl", headers=h).json()
|
||||
assert len(pnl["daily_pnl"]) == 3
|
||||
# day 0: no prior → 0.0; day 1: +10000; day 2: -5000
|
||||
assert pnl["daily_pnl"][0]["pnl"] == 0.0
|
||||
assert pnl["daily_pnl"][1]["pnl"] == 10_000.0
|
||||
assert pnl["daily_pnl"][2]["pnl"] == -5_000.0
|
||||
|
||||
|
||||
def test_trades(client, token):
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
tr = client.get("/api/v1/task/t/trades", headers=h).json()
|
||||
assert len(tr["trades"]) == 1
|
||||
assert tr["trades"][0]["price"] == 10.5
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Tests for sanguo_api.strategy_registry (Task S1.3)."""
|
||||
from sanguo_api.strategy_registry import list_strategies, strategy_params, STRATEGY_NAMES
|
||||
|
||||
|
||||
def test_list_strategies_shape():
|
||||
items = list_strategies()
|
||||
assert isinstance(items, list)
|
||||
assert len(items) > 0
|
||||
for item in items:
|
||||
assert "name" in item and "class_name" in item
|
||||
|
||||
|
||||
def test_list_strategies_fallback_when_unimportable():
|
||||
"""Locally vnpy_ctastrategy is absent → falls back to STRATEGY_NAMES."""
|
||||
names = {item["name"] for item in list_strategies()}
|
||||
# At minimum the fallback names appear (DoubleMaStrategy must be listed)
|
||||
assert "DoubleMaStrategy" in names or len(names) > 0
|
||||
|
||||
|
||||
def test_strategy_params_keys():
|
||||
p = strategy_params("DoubleMaStrategy")
|
||||
assert "parameters" in p
|
||||
assert isinstance(p["parameters"], list)
|
||||
assert "defaults" in p and isinstance(p["defaults"], dict)
|
||||
|
||||
|
||||
def test_strategy_params_unknown_returns_empty():
|
||||
p = strategy_params("NoSuchStrategy_xyz")
|
||||
assert p == {"parameters": [], "defaults": {}}
|
||||
Reference in New Issue
Block a user