From 304844903c6cb0cc1aba101850770be2b2d5d53a Mon Sep 17 00:00:00 2001 From: claude_dev Date: Sat, 11 Jul 2026 13:48:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20=E5=9B=9E=E6=B5=8B=E7=BB=93?= =?UTF-8?q?=E6=9E=9CAPI=E5=8A=A0relative=5Fmetrics+=E5=9F=BA=E5=87=86?= =?UTF-8?q?=E6=9B=B2=E7=BA=BF/=E9=A3=8E=E9=99=A9=E5=BA=8F=E5=88=97/?= =?UTF-8?q?=E6=8C=81=E4=BB=93/=E6=97=A5=E5=BF=974=E7=AB=AF=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sanguo_api/routes.py | 117 +++++++++++- sanguo_api/schemas.py | 1 + tests/api/test_routes.py | 376 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 493 insertions(+), 1 deletion(-) diff --git a/sanguo_api/routes.py b/sanguo_api/routes.py index 827de16..abd49ac 100644 --- a/sanguo_api/routes.py +++ b/sanguo_api/routes.py @@ -62,6 +62,10 @@ def login(req: LoginRequest): @router.post("/backtest/cta", dependencies=[Depends(verify_token)]) async def submit_cta(req: CtaBacktestRequest): """Submit CTA backtest task""" + # Validate benchmark parameter + if req.benchmark not in ("hs300", "zz500"): + raise HTTPException(status_code=422, detail=f"Invalid benchmark: {req.benchmark}. Must be 'hs300' or 'zz500'") + cls = get_strategy_class(req.strategy) if cls is None: raise HTTPException(status_code=400, detail=f"未知策略: {req.strategy}") @@ -130,9 +134,21 @@ def get_result(task_id: str): 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 = [ + "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, @@ -306,4 +322,103 @@ def optimization_results(task_id: str): "params": getattr(r, "params", {}), "statistics": getattr(r, "statistics", {}), }) - return {"task_id": task_id, "results": rows} \ No newline at end of file + 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 for a task_id from orchestrator config.""" + orch = get_orchestrator() + if hasattr(orch, 'db_path') and orch.db_path: + file_dir = os.path.dirname(os.path.abspath(orch.db_path)) + metrics_file = os.path.join(file_dir, f"{task_id}_metrics.json") + if os.path.exists(metrics_file): + return metrics_file + 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: + raise HTTPException(status_code=404, detail="metrics file not found") + + 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: + raise HTTPException(status_code=404, detail="metrics file not found") + + 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", {}) + + return { + "dates": alpha.get("dates", []), + "alpha": alpha.get("values", []), + "beta": beta.get("values", []), + "drawdown": drawdown.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": ""} \ No newline at end of file diff --git a/sanguo_api/schemas.py b/sanguo_api/schemas.py index 1ef215a..2f93929 100644 --- a/sanguo_api/schemas.py +++ b/sanguo_api/schemas.py @@ -11,6 +11,7 @@ class CtaBacktestRequest(BaseModel): params: dict = {} start: str end: str + benchmark: str = "hs300" class OptimizeRequest(BaseModel): diff --git a/tests/api/test_routes.py b/tests/api/test_routes.py index 1cbbb98..7a5a9ed 100644 --- a/tests/api/test_routes.py +++ b/tests/api/test_routes.py @@ -341,3 +341,379 @@ def test_optimize_route_calls_submit(tmp_path): assert resp.status_code == 200 assert resp.json()["task_id"] == "opt_1" + + +# ============================================ +# NEW TESTS FOR TASK 4 (API 扩展端点) +# ============================================ + +def test_backtest_cta_accepts_benchmark(): + """Test POST /api/v1/backtest/cta accepts benchmark parameter""" + from sanguo_api.app import create_app + from sanguo_api.auth import set_jwt_config, create_token + import tempfile + import os + + set_jwt_config(secret="test", expire_minutes=60) + + with tempfile.TemporaryDirectory() as tmp: + db_path = os.path.join(tmp, "test.db") + app = create_app(db_path=db_path, file_dir=tmp) + token = create_token("admin") + + with patch("sanguo_api.routes.get_orchestrator") as mock_get_orch, \ + patch("sanguo_api.routes.get_strategy_class", return_value=Mock()): + mock_orch = Mock() + mock_orch.submit_cta = AsyncMock(return_value="cta_test_123") + mock_get_orch.return_value = mock_orch + + client = TestClient(app) + response = client.post( + "/api/v1/backtest/cta", + json={ + "symbol": "600000SH", + "strategy": "DoubleSMA", + "params": {"fast": 5, "slow": 20}, + "start": "2024-01-01", + "end": "2024-12-31", + "benchmark": "zz500" + }, + headers={"Authorization": f"Bearer {token}"} + ) + + assert response.status_code == 200 + data = response.json() + assert "task_id" in data + assert data["task_id"] == "cta_test_123" + + +def test_backtest_cta_rejects_invalid_benchmark(): + """Test POST /api/v1/backtest/cta rejects invalid benchmark""" + from sanguo_api.app import create_app + from sanguo_api.auth import set_jwt_config, create_token + import tempfile + import os + + set_jwt_config(secret="test", expire_minutes=60) + + with tempfile.TemporaryDirectory() as tmp: + db_path = os.path.join(tmp, "test.db") + app = create_app(db_path=db_path, file_dir=tmp) + token = create_token("admin") + + client = TestClient(app) + response = client.post( + "/api/v1/backtest/cta", + json={ + "symbol": "600000SH", + "strategy": "DoubleSMA", + "params": {"fast": 5, "slow": 20}, + "start": "2024-01-01", + "end": "2024-12-31", + "benchmark": "xxx" # Invalid benchmark + }, + headers={"Authorization": f"Bearer {token}"} + ) + + assert response.status_code == 422 + + +def test_task_result_includes_relative_metrics(): + """Test GET /api/v1/task/:id/result includes relative_metrics""" + from sanguo_api.app import create_app + from sanguo_api.auth import set_jwt_config, create_token + from sanguo_backtest.result_store import BacktestResult + import tempfile + import os + + set_jwt_config(secret="test", expire_minutes=60) + + with tempfile.TemporaryDirectory() as tmp: + db_path = os.path.join(tmp, "test.db") + app = create_app(db_path=db_path, file_dir=tmp) + token = create_token("admin") + + with patch("sanguo_api.routes.get_orchestrator") as mock_get_orch: + mock_orch = Mock() + mock_result = BacktestResult( + task_id="cta_test_123", type="cta", status="done", strategy="S", symbol="600000", + params={}, start="2024-01-01", end="2024-12-31", + statistics={ + "total_trades": 10, + "total_return": 0.15, + "alpha": 0.05, + "beta": 1.2, + "sortino_ratio": 1.5, + "information_ratio": 0.8, + "annual_volatility": 0.2, + "benchmark_return": 0.1, + "benchmark_volatility": 0.18 + }, + ) + mock_orch.get_result.return_value = mock_result + mock_get_orch.return_value = mock_orch + + client = TestClient(app) + response = client.get("/api/v1/task/cta_test_123/result", headers={"Authorization": f"Bearer {token}"}) + + assert response.status_code == 200 + data = response.json() + assert "relative_metrics" in data + assert data["relative_metrics"]["alpha"] == 0.05 + assert data["relative_metrics"]["beta"] == 1.2 + + +def test_benchmark_curve_endpoint(): + """Test GET /api/v1/task/:id/benchmark-curve returns benchmark curve data""" + from sanguo_api.app import create_app + from sanguo_api.auth import set_jwt_config, create_token + import tempfile + import os + import json + + set_jwt_config(secret="test", expire_minutes=60) + + with tempfile.TemporaryDirectory() as tmp: + db_path = os.path.join(tmp, "test.db") + file_dir = tmp + app = create_app(db_path=db_path, file_dir=file_dir) + token = create_token("admin") + + # Create mock metrics file + metrics_data = { + "series": { + "equity_curve": { + "dates": ["2024-01-02", "2024-01-03"], + "values": [1000000.0, 1001000.0] + }, + "benchmark_curve": { + "dates": ["2024-01-02", "2024-01-03"], + "values": [1.0, 1.001] + } + } + } + metrics_file = os.path.join(file_dir, "cta_test_123_metrics.json") + with open(metrics_file, "w") as f: + json.dump(metrics_data, f) + + client = TestClient(app) + response = client.get("/api/v1/task/cta_test_123/benchmark-curve", headers={"Authorization": f"Bearer {token}"}) + + assert response.status_code == 200 + data = response.json() + assert "dates" in data + assert "strategy" in data + assert "benchmark" in data + assert len(data["dates"]) == 2 + assert len(data["strategy"]) == 2 + assert len(data["benchmark"]) == 2 + + +def test_benchmark_curve_endpoint_not_found(): + """Test GET /api/v1/task/:id/benchmark-curve returns 404 when file not found""" + from sanguo_api.app import create_app + from sanguo_api.auth import set_jwt_config, create_token + import tempfile + import os + + set_jwt_config(secret="test", expire_minutes=60) + + with tempfile.TemporaryDirectory() as tmp: + db_path = os.path.join(tmp, "test.db") + app = create_app(db_path=db_path, file_dir=tmp) + token = create_token("admin") + + client = TestClient(app) + response = client.get("/api/v1/task/unknown_task/benchmark-curve", headers={"Authorization": f"Bearer {token}"}) + + assert response.status_code == 404 + + +def test_risk_series_endpoint(): + """Test GET /api/v1/task/:id/risk-series returns risk series data""" + from sanguo_api.app import create_app + from sanguo_api.auth import set_jwt_config, create_token + import tempfile + import os + import json + + set_jwt_config(secret="test", expire_minutes=60) + + with tempfile.TemporaryDirectory() as tmp: + db_path = os.path.join(tmp, "test.db") + file_dir = tmp + app = create_app(db_path=db_path, file_dir=file_dir) + token = create_token("admin") + + # Create mock metrics file + metrics_data = { + "series": { + "alpha": { + "dates": ["2024-01-02", "2024-01-03"], + "values": [0.05, 0.06] + }, + "beta": { + "dates": ["2024-01-02", "2024-01-03"], + "values": [1.2, 1.1] + }, + "drawdown": { + "dates": ["2024-01-02", "2024-01-03"], + "values": [-0.01, -0.005] + } + } + } + metrics_file = os.path.join(file_dir, "cta_test_123_metrics.json") + with open(metrics_file, "w") as f: + json.dump(metrics_data, f) + + client = TestClient(app) + response = client.get("/api/v1/task/cta_test_123/risk-series", headers={"Authorization": f"Bearer {token}"}) + + assert response.status_code == 200 + data = response.json() + assert "dates" in data + assert "alpha" in data + assert "beta" in data + assert "drawdown" in data + assert len(data["dates"]) == 2 + + +def test_risk_series_endpoint_not_found(): + """Test GET /api/v1/task/:id/risk-series returns 404 when file not found""" + from sanguo_api.app import create_app + from sanguo_api.auth import set_jwt_config, create_token + import tempfile + import os + + set_jwt_config(secret="test", expire_minutes=60) + + with tempfile.TemporaryDirectory() as tmp: + db_path = os.path.join(tmp, "test.db") + app = create_app(db_path=db_path, file_dir=tmp) + token = create_token("admin") + + client = TestClient(app) + response = client.get("/api/v1/task/unknown_task/risk-series", headers={"Authorization": f"Bearer {token}"}) + + assert response.status_code == 404 + + +def test_daily_holdings_endpoint(): + """Test GET /api/v1/task/:id/daily-holdings returns daily holdings data""" + from sanguo_api.app import create_app + from sanguo_api.auth import set_jwt_config, create_token + from sanguo_backtest.result_store import BacktestResult + import tempfile + import os + import pandas as pd + + set_jwt_config(secret="test", expire_minutes=60) + + with tempfile.TemporaryDirectory() as tmp: + db_path = os.path.join(tmp, "test.db") + app = create_app(db_path=db_path, file_dir=tmp) + token = create_token("admin") + + with patch("sanguo_api.routes.get_orchestrator") as mock_get_orch: + # Create mock equity curve data + equity_df = pd.DataFrame({ + "date": ["2024-01-02", "2024-01-03"], + "balance": [1000000.0, 1001000.0], + "return": [0.0, 0.001] + }) + + mock_result = BacktestResult( + task_id="cta_test_123", type="cta", status="done", strategy="S", symbol="600000", + params={}, start="2024-01-01", end="2024-12-31", + statistics={}, + equity_curve=equity_df + ) + mock_orch = Mock() + mock_orch.get_result.return_value = mock_result + mock_get_orch.return_value = mock_orch + + client = TestClient(app) + response = client.get("/api/v1/task/cta_test_123/daily-holdings", headers={"Authorization": f"Bearer {token}"}) + + assert response.status_code == 200 + data = response.json() + assert "daily_holdings" in data + assert len(data["daily_holdings"]) == 2 + + +def test_daily_holdings_endpoint_not_found(): + """Test GET /api/v1/task/:id/daily-holdings returns 404 when result not ready""" + from sanguo_api.app import create_app + from sanguo_api.auth import set_jwt_config, create_token + import tempfile + import os + + set_jwt_config(secret="test", expire_minutes=60) + + with tempfile.TemporaryDirectory() as tmp: + db_path = os.path.join(tmp, "test.db") + app = create_app(db_path=db_path, file_dir=tmp) + token = create_token("admin") + + with patch("sanguo_api.routes.get_orchestrator") as mock_get_orch: + mock_orch = Mock() + mock_orch.get_result.return_value = None + mock_get_orch.return_value = mock_orch + + client = TestClient(app) + response = client.get("/api/v1/task/unknown_task/daily-holdings", headers={"Authorization": f"Bearer {token}"}) + + assert response.status_code == 404 + + +def test_log_endpoint(): + """Test GET /api/v1/task/:id/log returns log data""" + from sanguo_api.app import create_app + from sanguo_api.auth import set_jwt_config, create_token + import tempfile + import os + + set_jwt_config(secret="test", expire_minutes=60) + + with tempfile.TemporaryDirectory() as tmp: + db_path = os.path.join(tmp, "test.db") + file_dir = tmp + app = create_app(db_path=db_path, file_dir=file_dir) + token = create_token("admin") + + # Create mock log file + log_file = os.path.join(file_dir, "cta_test_123.log") + with open(log_file, "w") as f: + f.write("Backtest started\nBacktest completed\n") + + client = TestClient(app) + response = client.get("/api/v1/task/cta_test_123/log", headers={"Authorization": f"Bearer {token}"}) + + assert response.status_code == 200 + data = response.json() + assert "log" in data + assert "Backtest started" in data["log"] + + +def test_log_endpoint_not_found(): + """Test GET /api/v1/task/:id/log returns empty log when file not found""" + from sanguo_api.app import create_app + from sanguo_api.auth import set_jwt_config, create_token + import tempfile + import os + + set_jwt_config(secret="test", expire_minutes=60) + + with tempfile.TemporaryDirectory() as tmp: + db_path = os.path.join(tmp, "test.db") + app = create_app(db_path=db_path, file_dir=tmp) + token = create_token("admin") + + client = TestClient(app) + response = client.get("/api/v1/task/unknown_task/log", headers={"Authorization": f"Bearer {token}"}) + + # Log endpoint returns empty log instead of 404 + assert response.status_code == 200 + data = response.json() + assert "log" in data + assert data["log"] == ""