diff --git a/sanguo_api/kline.py b/sanguo_api/kline.py new file mode 100644 index 0000000..4b5e6e1 --- /dev/null +++ b/sanguo_api/kline.py @@ -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 + ] diff --git a/sanguo_api/routes.py b/sanguo_api/routes.py index b93aa90..f90ed38 100644 --- a/sanguo_api/routes.py +++ b/sanguo_api/routes.py @@ -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) \ No newline at end of file + 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}") \ No newline at end of file diff --git a/sanguo_api/strategy_registry.py b/sanguo_api/strategy_registry.py new file mode 100644 index 0000000..fb8991e --- /dev/null +++ b/sanguo_api/strategy_registry.py @@ -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) diff --git a/tests/api/test_backtest_routes.py b/tests/api/test_backtest_routes.py new file mode 100644 index 0000000..8eecc66 --- /dev/null +++ b/tests/api/test_backtest_routes.py @@ -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 diff --git a/tests/api/test_strategy_registry.py b/tests/api/test_strategy_registry.py new file mode 100644 index 0000000..aa1fff4 --- /dev/null +++ b/tests/api/test_strategy_registry.py @@ -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": {}}