fix: backtest submission shows no results — 4 bugs fixed

- 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.
This commit is contained in:
Celes Renata
2026-04-17 00:31:17 +00:00
parent f11aa0a1ee
commit f2d8744a4f
5 changed files with 74 additions and 51 deletions
+6 -1
View File
@@ -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) { export function useBacktestResult(id: string | undefined) {
return useQuery<BacktestResult>({ return useQuery<BacktestResult>({
queryKey: ['backtest-result', id], queryKey: ['backtest-result', id],
queryFn: () => apiGet<BacktestResult>('trading', `/api/trading/backtest/${id}`), queryFn: () => apiGet<BacktestResult>('trading', `/api/trading/backtest/${id}`),
enabled: !!id, enabled: !!id,
refetchInterval: (query) => {
const status = query.state.data?.status;
if (!status || status === 'running' || status === 'pending') return 2000;
return false;
},
}); });
} }
+23 -3
View File
@@ -25,7 +25,7 @@ export function BacktestPanel() {
const [backtestId, setBacktestId] = useState<string | undefined>(undefined); const [backtestId, setBacktestId] = useState<string | undefined>(undefined);
const launch = useBacktestLaunch(); const launch = useBacktestLaunch();
const { data: result, isLoading: resultLoading } = useBacktestResult(backtestId); const { data: result } = useBacktestResult(backtestId);
function handleLaunch() { function handleLaunch() {
if (!startDate || !endDate) return; if (!startDate || !endDate) return;
@@ -108,8 +108,28 @@ export function BacktestPanel() {
</Card> </Card>
{/* Results */} {/* Results */}
{resultLoading && <LoadingSpinner />} {backtestId && !result && (
{result && ( <Card>
<div className="flex items-center gap-3">
<LoadingSpinner />
<span className="text-sm text-gray-400">Backtest running</span>
</div>
</Card>
)}
{result && result.status === 'running' && (
<Card>
<div className="flex items-center gap-3">
<LoadingSpinner />
<span className="text-sm text-gray-400">Backtest in progress</span>
</div>
</Card>
)}
{result && result.status === 'failed' && (
<Card>
<p className="text-sm text-red-400">Backtest failed. Check server logs for details.</p>
</Card>
)}
{result && result.status === 'completed' && (
<> <>
{/* Summary Metrics */} {/* Summary Metrics */}
<Card> <Card>
+36 -40
View File
@@ -14,7 +14,7 @@ from __future__ import annotations
import logging import logging
import uuid import uuid
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime, timezone from datetime import date, datetime, timezone
from typing import Any, Optional from typing import Any, Optional
import asyncpg import asyncpg
@@ -490,6 +490,8 @@ async def launch_backtest(body: BacktestRequest) -> dict[str, str]:
"""Launch a backtest run and return its ID. """Launch a backtest run and return its ID.
Task 32.5: Uses BacktestReplay to run the backtest in a background task. 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: if engine is None:
raise HTTPException(503, "Engine not initialised") 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.backtest_replay import BacktestReplay
from services.trading.backtester import BacktestConfig from services.trading.backtester import BacktestConfig
backtest_id = str(uuid.uuid4())
bt_config = BacktestConfig( bt_config = BacktestConfig(
start_date=date_type.fromisoformat(body.start_date), start_date=date_type.fromisoformat(body.start_date),
end_date=date_type.fromisoformat(body.end_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(): async def _run_backtest():
try: try:
await replay.run(bt_config) await replay.run(bt_config, backtest_id=backtest_id)
except Exception: except Exception:
logger.exception("Backtest failed") logger.exception("Backtest failed")
asyncio.create_task(_run_backtest()) asyncio.create_task(_run_backtest())
# Generate a backtest_id — the replay generates its own, but we return return {"id": backtest_id, "status": "running"}
# a placeholder immediately. The actual ID is in backtest_runs table.
backtest_id = str(uuid.uuid4())
return {"backtest_id": backtest_id, "status": "running"}
@app.get("/api/trading/backtest/{backtest_id}") @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. """Retrieve backtest results from PostgreSQL.
Task 32.5: Queries backtest_runs and backtest_trades tables. 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: if engine is None or engine.pool is None:
# Fallback for when pool is not available
return { return {
"backtest_id": backtest_id, "id": backtest_id,
"status": "pending", "status": "pending",
"config": None,
"result": None,
} }
try: try:
@@ -545,25 +544,23 @@ async def get_backtest(backtest_id: str) -> dict[str, Any]:
) )
if row is None: if row is None:
return { return {
"backtest_id": backtest_id, "id": backtest_id,
"status": "not_found", "status": "not_found",
"config": None,
"result": None,
} }
row_dict = dict(row) row_dict = dict(row)
# Convert non-serializable types
for key, val in row_dict.items(): # Parse equity_curve from JSONB
if isinstance(val, (datetime,)): equity_curve = row_dict.get("equity_curve", [])
row_dict[key] = val.isoformat() if isinstance(equity_curve, str):
elif hasattr(val, "__str__") and not isinstance(val, (str, int, float, bool, type(None))): import json as _json
row_dict[key] = str(val) equity_curve = _json.loads(equity_curve)
# Fetch trades # Fetch trades
trades = [] trades = []
try: try:
trade_rows = await engine.pool.fetch( 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, backtest_id,
) )
for tr in trade_rows: 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(): for key, val in trade_dict.items():
if isinstance(val, (datetime,)): if isinstance(val, (datetime,)):
trade_dict[key] = val.isoformat() 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))): elif hasattr(val, "__str__") and not isinstance(val, (str, int, float, bool, type(None))):
trade_dict[key] = str(val) trade_dict[key] = str(val)
trades.append(trade_dict) trades.append(trade_dict)
@@ -578,32 +577,29 @@ async def get_backtest(backtest_id: str) -> dict[str, Any]:
pass pass
return { 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"), "status": row_dict.get("status", "unknown"),
"config": { "completed_at": row_dict["completed_at"].isoformat() if row_dict.get("completed_at") else None,
"start_date": str(row_dict.get("start_date", "")), "created_at": row_dict["created_at"].isoformat() if row_dict.get("created_at") else None,
"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,
},
} }
except Exception: except Exception:
logger.debug("Could not query backtest results — tables may not exist") logger.debug("Could not query backtest results — tables may not exist")
return { return {
"backtest_id": backtest_id, "id": backtest_id,
"status": "pending", "status": "pending",
"config": None,
"result": None,
} }
+4 -2
View File
@@ -39,16 +39,18 @@ class BacktestReplay:
self.pool = pool self.pool = pool
self._perf = PerformanceComputer() 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. """Execute a full backtest replay.
Args: Args:
config: Backtest configuration (date range, capital, risk tier). config: Backtest configuration (date range, capital, risk tier).
backtest_id: Optional pre-generated ID. If not provided, one is generated.
Returns: Returns:
BacktestResult with metrics, trade log, and equity curve. BacktestResult with metrics, trade log, and equity curve.
""" """
backtest_id = str(uuid.uuid4()) if backtest_id is None:
backtest_id = str(uuid.uuid4())
try: try:
# Fetch historical recommendations # Fetch historical recommendations
+5 -5
View File
@@ -222,7 +222,7 @@ class TestProperty29PersistenceRoundTrip:
assert isinstance(resp.json()["ready"], bool) assert isinstance(resp.json()["ready"], bool)
def test_backtest_returns_id(self) -> None: 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() client = _get_client()
resp = client.post( resp = client.post(
"/api/trading/backtest", "/api/trading/backtest",
@@ -235,9 +235,9 @@ class TestProperty29PersistenceRoundTrip:
) )
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert "backtest_id" in data assert "id" in data
assert isinstance(data["backtest_id"], str) assert isinstance(data["id"], str)
assert len(data["backtest_id"]) > 0 assert len(data["id"]) > 0
def test_backtest_get_returns_placeholder(self) -> None: def test_backtest_get_returns_placeholder(self) -> None:
"""GET /api/trading/backtest/{id} returns a result dict.""" """GET /api/trading/backtest/{id} returns a result dict."""
@@ -246,7 +246,7 @@ class TestProperty29PersistenceRoundTrip:
resp = client.get(f"/api/trading/backtest/{test_id}") resp = client.get(f"/api/trading/backtest/{test_id}")
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert data["backtest_id"] == test_id assert data["id"] == test_id
def test_decisions_returns_list(self) -> None: def test_decisions_returns_list(self) -> None:
"""GET /api/trading/decisions returns a list.""" """GET /api/trading/decisions returns a list."""