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