fix: normalize old backtest field names in VBT dashboard API

Old files used pnl_pct (not total_return_pct), max_dd (decimal,
not max_drawdown_pct %), num_periods (not n_bars), no profit_factor.
Added _normalize_vbt_fields() that:
- Maps pnl_pct/ann_return_pct → total_return_pct
- Converts max_dd (decimal) → max_drawdown_pct (percentage)
- Maps num_periods → n_bars
- Computes profit_factor from trades (gross_wins / gross_losses)
- Computes win_rate from trades if missing

Both /api/vbt/results and /api/vbt/result/{filename} now normalise.
Verified: old Cartea-Jaimungal file now shows ret=0.82%, pf=1.18, bars=720
This commit is contained in:
ramseshk
2026-08-07 12:35:18 +08:00
parent 737b24895c
commit 623345c4d7
+80 -13
View File
@@ -462,6 +462,71 @@ async def get_risk_metrics():
# VBT Dashboard API — VectorBT backtest results browser
# ═══════════════════════════════════════════════════════════
def _normalize_vbt_fields(data: dict) -> dict:
"""Normalise old/new backtest file field names to a consistent schema."""
out = dict(data)
# total_return_pct
if "total_return_pct" not in out:
out["total_return_pct"] = out.get("pnl_pct", out.get("ann_return_pct", 0))
if out.get("total_return_pct") is None:
out["total_return_pct"] = 0
# max_drawdown_pct
if "max_drawdown_pct" not in out:
dd = out.get("max_dd_pct", out.get("max_dd"))
if dd is not None and isinstance(dd, (int, float)) and abs(dd) < 1:
dd = dd * 100 # decimal → percent
out["max_drawdown_pct"] = dd or 0
if out.get("max_drawdown_pct") is None:
out["max_drawdown_pct"] = 0
# n_bars
if "n_bars" not in out:
out["n_bars"] = out.get("num_periods", 0)
if out.get("n_bars") is None:
out["n_bars"] = 0
# profit_factor → compute from trades if missing
if "profit_factor" not in out and "trades" in out:
trades = out.get("trades", [])
if trades:
gross_win = sum(
t.get("pnl_net", t.get("pnl_gross", t.get("pnl", 0)))
for t in trades if (t.get("pnl_net", t.get("pnl_gross", t.get("pnl", 0))) or 0) > 0
)
gross_loss = abs(sum(
t.get("pnl_net", t.get("pnl_gross", t.get("pnl", 0)))
for t in trades if (t.get("pnl_net", t.get("pnl_gross", t.get("pnl", 0))) or 0) < 0
))
out["profit_factor"] = round(gross_win / gross_loss, 3) if gross_loss > 0 else 0
elif out.get("pnl_gross") is not None and out.get("fees_total") is not None:
# Synthetic: approximate PF from gross/fees relationship
pnl_gross = out.get("pnl_gross", 0)
fees = out.get("fees_total", 0)
if fees > 0:
wins = pnl_gross + fees if pnl_gross > 0 else fees
losses = fees if pnl_gross > 0 else fees - pnl_gross
out["profit_factor"] = round(wins / losses, 3) if losses > 0 else 0
if "profit_factor" not in out:
out["profit_factor"] = 0
# total_trades
if "total_trades" not in out:
out["total_trades"] = len(out.get("trades", []))
if out.get("total_trades") is None:
out["total_trades"] = 0
# win_rate → compute from trades if missing/zero
if not out.get("win_rate") and "trades" in out:
trades = out.get("trades", [])
if trades:
wins = sum(1 for t in trades if (t.get("pnl_net", t.get("pnl_gross", t.get("pnl", 0))) or 0) > 0)
out["win_rate"] = round(wins / len(trades), 3)
return out
@app.get("/api/vbt/results")
async def list_vbt_results(strategy: str = "", limit: int = 50):
"""List VectorBT backtest results with full metrics."""
@@ -478,21 +543,22 @@ async def list_vbt_results(strategy: str = "", limit: int = 50):
try:
with open(fpath) as f:
data = json.load(f)
n = _normalize_vbt_fields(data)
results.append({
"filename": fname,
"strategy": data.get("strategy", "unknown"),
"engine": data.get("engine", "vectorbt"),
"interval": data.get("interval", "1h"),
"sharpe": data.get("sharpe", 0),
"sortino": data.get("sortino", 0),
"total_return_pct": data.get("total_return_pct", 0),
"max_drawdown_pct": data.get("max_drawdown_pct", 0),
"win_rate": data.get("win_rate", 0),
"profit_factor": data.get("profit_factor", 0),
"total_trades": data.get("total_trades", 0),
"n_bars": data.get("n_bars", 0),
"generated_at": data.get("generated_at", ""),
"has_equity_curve": bool(data.get("equity_curve")),
"strategy": n.get("strategy", "unknown"),
"engine": n.get("engine", "vectorbt"),
"interval": n.get("interval", "1h"),
"sharpe": n.get("sharpe", 0),
"sortino": n.get("sortino", 0),
"total_return_pct": n["total_return_pct"],
"max_drawdown_pct": n["max_drawdown_pct"],
"win_rate": n.get("win_rate", 0),
"profit_factor": n["profit_factor"],
"total_trades": n["total_trades"],
"n_bars": n["n_bars"],
"generated_at": n.get("generated_at", ""),
"has_equity_curve": bool(n.get("equity_curve")),
})
except (json.JSONDecodeError, IOError):
pass
@@ -510,6 +576,7 @@ async def get_vbt_result(filename: str):
if os.path.exists(fpath):
with open(fpath) as f:
data = json.load(f)
data = _normalize_vbt_fields(data)
# Ensure equity curve is compact for transport
ec = data.get("equity_curve", [])
if ec and len(ec) > 500: