From 623345c4d718b7514b08eb732a7d64941d4f9bf0 Mon Sep 17 00:00:00 2001 From: ramseshk <45832522+ramseshk@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:35:18 +0800 Subject: [PATCH] fix: normalize old backtest field names in VBT dashboard API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- dashboard/server.py | 93 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 80 insertions(+), 13 deletions(-) diff --git a/dashboard/server.py b/dashboard/server.py index 82b73d0..6163c13 100644 --- a/dashboard/server.py +++ b/dashboard/server.py @@ -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: