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) {
return useQuery<BacktestResult>({
queryKey: ['backtest-result', id],
queryFn: () => apiGet<BacktestResult>('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;
},
});
}
+23 -3
View File
@@ -25,7 +25,7 @@ export function BacktestPanel() {
const [backtestId, setBacktestId] = useState<string | undefined>(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() {
</Card>
{/* Results */}
{resultLoading && <LoadingSpinner />}
{result && (
{backtestId && !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 */}
<Card>
+26 -30
View File
@@ -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,
"status": row_dict.get("status", "unknown"),
"config": {
"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"),
},
"result": {
"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": row_dict.get("equity_curve"),
"equity_curve": equity_curve,
"trades": trades,
},
"status": row_dict.get("status", "unknown"),
"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,
}
+3 -1
View File
@@ -39,15 +39,17 @@ 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.
"""
if backtest_id is None:
backtest_id = str(uuid.uuid4())
try:
+5 -5
View File
@@ -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."""