From f2d8744a4fa831e9b80ad951e52cf24dfddd308b Mon Sep 17 00:00:00 2001 From: Celes Renata Date: Fri, 17 Apr 2026 00:31:17 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20backtest=20submission=20shows=20no=20res?= =?UTF-8?q?ults=20=E2=80=94=204=20bugs=20fixed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ID mismatch: API generated a throwaway UUID while BacktestReplay generated its own internally. Frontend polled with wrong ID and never found the DB row. Now pre-generate ID in endpoint and pass it to BacktestReplay. - Field name: API returned 'backtest_id' but frontend read 'data.id'. Unified to 'id' everywhere. - No polling: useBacktestResult fired once and never refreshed. Added refetchInterval that polls every 2s while status is running. - Response shape: GET endpoint nested results under 'result' object but frontend expected flat fields. Flattened response to match BacktestResult type. - Added running/failed/completed status indicators in BacktestPanel. --- frontend/src/api/tradingHooks.ts | 7 +- frontend/src/pages/trading/BacktestPanel.tsx | 26 ++++++- services/trading/app.py | 76 ++++++++++---------- services/trading/backtest_replay.py | 6 +- tests/test_pbt_trading_api.py | 10 +-- 5 files changed, 74 insertions(+), 51 deletions(-) diff --git a/frontend/src/api/tradingHooks.ts b/frontend/src/api/tradingHooks.ts index f98f4d3..0eaddc2 100644 --- a/frontend/src/api/tradingHooks.ts +++ b/frontend/src/api/tradingHooks.ts @@ -234,12 +234,17 @@ export function useTradingConfig() { }); } -/** Fetch a backtest result by ID. */ +/** Fetch a backtest result by ID. Polls every 2s while status is pending/running. */ export function useBacktestResult(id: string | undefined) { return useQuery({ queryKey: ['backtest-result', id], queryFn: () => apiGet('trading', `/api/trading/backtest/${id}`), enabled: !!id, + refetchInterval: (query) => { + const status = query.state.data?.status; + if (!status || status === 'running' || status === 'pending') return 2000; + return false; + }, }); } diff --git a/frontend/src/pages/trading/BacktestPanel.tsx b/frontend/src/pages/trading/BacktestPanel.tsx index 400f5b8..2dc2653 100644 --- a/frontend/src/pages/trading/BacktestPanel.tsx +++ b/frontend/src/pages/trading/BacktestPanel.tsx @@ -25,7 +25,7 @@ export function BacktestPanel() { const [backtestId, setBacktestId] = useState(undefined); const launch = useBacktestLaunch(); - const { data: result, isLoading: resultLoading } = useBacktestResult(backtestId); + const { data: result } = useBacktestResult(backtestId); function handleLaunch() { if (!startDate || !endDate) return; @@ -108,8 +108,28 @@ export function BacktestPanel() { {/* Results */} - {resultLoading && } - {result && ( + {backtestId && !result && ( + +
+ + Backtest running… +
+
+ )} + {result && result.status === 'running' && ( + +
+ + Backtest in progress… +
+
+ )} + {result && result.status === 'failed' && ( + +

Backtest failed. Check server logs for details.

+
+ )} + {result && result.status === 'completed' && ( <> {/* Summary Metrics */} diff --git a/services/trading/app.py b/services/trading/app.py index 223e712..c614ccb 100644 --- a/services/trading/app.py +++ b/services/trading/app.py @@ -14,7 +14,7 @@ from __future__ import annotations import logging import uuid from contextlib import asynccontextmanager -from datetime import datetime, timezone +from datetime import date, datetime, timezone from typing import Any, Optional import asyncpg @@ -490,6 +490,8 @@ async def launch_backtest(body: BacktestRequest) -> dict[str, str]: """Launch a backtest run and return its ID. Task 32.5: Uses BacktestReplay to run the backtest in a background task. + The backtest_id is pre-generated and passed to BacktestReplay so the + frontend can poll for results using the same ID. """ if engine is None: raise HTTPException(503, "Engine not initialised") @@ -499,6 +501,8 @@ async def launch_backtest(body: BacktestRequest) -> dict[str, str]: from services.trading.backtest_replay import BacktestReplay from services.trading.backtester import BacktestConfig + backtest_id = str(uuid.uuid4()) + bt_config = BacktestConfig( start_date=date_type.fromisoformat(body.start_date), end_date=date_type.fromisoformat(body.end_date), @@ -512,15 +516,12 @@ async def launch_backtest(body: BacktestRequest) -> dict[str, str]: async def _run_backtest(): try: - await replay.run(bt_config) + await replay.run(bt_config, backtest_id=backtest_id) except Exception: logger.exception("Backtest failed") asyncio.create_task(_run_backtest()) - # Generate a backtest_id — the replay generates its own, but we return - # a placeholder immediately. The actual ID is in backtest_runs table. - backtest_id = str(uuid.uuid4()) - return {"backtest_id": backtest_id, "status": "running"} + return {"id": backtest_id, "status": "running"} @app.get("/api/trading/backtest/{backtest_id}") @@ -528,14 +529,12 @@ async def get_backtest(backtest_id: str) -> dict[str, Any]: """Retrieve backtest results from PostgreSQL. Task 32.5: Queries backtest_runs and backtest_trades tables. + Returns a flat object matching the frontend BacktestResult type. """ if engine is None or engine.pool is None: - # Fallback for when pool is not available return { - "backtest_id": backtest_id, + "id": backtest_id, "status": "pending", - "config": None, - "result": None, } try: @@ -545,25 +544,23 @@ async def get_backtest(backtest_id: str) -> dict[str, Any]: ) if row is None: return { - "backtest_id": backtest_id, + "id": backtest_id, "status": "not_found", - "config": None, - "result": None, } row_dict = dict(row) - # Convert non-serializable types - for key, val in row_dict.items(): - if isinstance(val, (datetime,)): - row_dict[key] = val.isoformat() - elif hasattr(val, "__str__") and not isinstance(val, (str, int, float, bool, type(None))): - row_dict[key] = str(val) + + # Parse equity_curve from JSONB + equity_curve = row_dict.get("equity_curve", []) + if isinstance(equity_curve, str): + import json as _json + equity_curve = _json.loads(equity_curve) # Fetch trades trades = [] try: trade_rows = await engine.pool.fetch( - "SELECT * FROM backtest_trades WHERE backtest_id = $1", + "SELECT * FROM backtest_trades WHERE backtest_id = $1 ORDER BY created_at", backtest_id, ) for tr in trade_rows: @@ -571,6 +568,8 @@ async def get_backtest(backtest_id: str) -> dict[str, Any]: for key, val in trade_dict.items(): if isinstance(val, (datetime,)): trade_dict[key] = val.isoformat() + elif isinstance(val, date): + trade_dict[key] = val.isoformat() elif hasattr(val, "__str__") and not isinstance(val, (str, int, float, bool, type(None))): trade_dict[key] = str(val) trades.append(trade_dict) @@ -578,32 +577,29 @@ async def get_backtest(backtest_id: str) -> dict[str, Any]: pass return { - "backtest_id": backtest_id, + "id": str(row_dict.get("id", backtest_id)), + "start_date": str(row_dict.get("start_date", "")), + "end_date": str(row_dict.get("end_date", "")), + "initial_capital": row_dict.get("initial_capital"), + "risk_tier": row_dict.get("risk_tier"), + "config": row_dict.get("config", {}), + "total_return": row_dict.get("total_return"), + "sharpe_ratio": row_dict.get("sharpe_ratio"), + "max_drawdown": row_dict.get("max_drawdown"), + "win_rate": row_dict.get("win_rate"), + "profit_factor": row_dict.get("profit_factor"), + "trade_count": row_dict.get("trade_count"), + "equity_curve": equity_curve, + "trades": trades, "status": row_dict.get("status", "unknown"), - "config": { - "start_date": str(row_dict.get("start_date", "")), - "end_date": str(row_dict.get("end_date", "")), - "initial_capital": row_dict.get("initial_capital"), - "risk_tier": row_dict.get("risk_tier"), - }, - "result": { - "total_return": row_dict.get("total_return"), - "sharpe_ratio": row_dict.get("sharpe_ratio"), - "max_drawdown": row_dict.get("max_drawdown"), - "win_rate": row_dict.get("win_rate"), - "profit_factor": row_dict.get("profit_factor"), - "trade_count": row_dict.get("trade_count"), - "equity_curve": row_dict.get("equity_curve"), - "trades": trades, - }, + "completed_at": row_dict["completed_at"].isoformat() if row_dict.get("completed_at") else None, + "created_at": row_dict["created_at"].isoformat() if row_dict.get("created_at") else None, } except Exception: logger.debug("Could not query backtest results — tables may not exist") return { - "backtest_id": backtest_id, + "id": backtest_id, "status": "pending", - "config": None, - "result": None, } diff --git a/services/trading/backtest_replay.py b/services/trading/backtest_replay.py index 89c5b6d..5fbb240 100644 --- a/services/trading/backtest_replay.py +++ b/services/trading/backtest_replay.py @@ -39,16 +39,18 @@ class BacktestReplay: self.pool = pool self._perf = PerformanceComputer() - async def run(self, config: BacktestConfig) -> BacktestResult: + async def run(self, config: BacktestConfig, backtest_id: str | None = None) -> BacktestResult: """Execute a full backtest replay. Args: config: Backtest configuration (date range, capital, risk tier). + backtest_id: Optional pre-generated ID. If not provided, one is generated. Returns: BacktestResult with metrics, trade log, and equity curve. """ - backtest_id = str(uuid.uuid4()) + if backtest_id is None: + backtest_id = str(uuid.uuid4()) try: # Fetch historical recommendations diff --git a/tests/test_pbt_trading_api.py b/tests/test_pbt_trading_api.py index c1a47c7..14fad84 100644 --- a/tests/test_pbt_trading_api.py +++ b/tests/test_pbt_trading_api.py @@ -222,7 +222,7 @@ class TestProperty29PersistenceRoundTrip: assert isinstance(resp.json()["ready"], bool) def test_backtest_returns_id(self) -> None: - """POST /api/trading/backtest returns a backtest_id string.""" + """POST /api/trading/backtest returns an id string.""" client = _get_client() resp = client.post( "/api/trading/backtest", @@ -235,9 +235,9 @@ class TestProperty29PersistenceRoundTrip: ) assert resp.status_code == 200 data = resp.json() - assert "backtest_id" in data - assert isinstance(data["backtest_id"], str) - assert len(data["backtest_id"]) > 0 + assert "id" in data + assert isinstance(data["id"], str) + assert len(data["id"]) > 0 def test_backtest_get_returns_placeholder(self) -> None: """GET /api/trading/backtest/{id} returns a result dict.""" @@ -246,7 +246,7 @@ class TestProperty29PersistenceRoundTrip: resp = client.get(f"/api/trading/backtest/{test_id}") assert resp.status_code == 200 data = resp.json() - assert data["backtest_id"] == test_id + assert data["id"] == test_id def test_decisions_returns_list(self) -> None: """GET /api/trading/decisions returns a list."""