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:
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user