feat: VBT dashboard overhaul — pagination, caching, LTTB downsampling, deep links, export, more metrics
- Merge vbt_server.py into server.py (eliminate duplicated VBT API)
- Add server-side pagination (offset/limit) with metadata (total, has_more)
- Add server-side ?asset= filtering to results endpoint
- Add JSON file caching with 5s TTL to avoid re-parsing on every request
- Add LTTB (Largest-Triangle-Three-Buckets) downsampling for equity curves
- Add pre-computed drawdown curve to result detail response
- Add /api/vbt/result/{filename}/csv endpoint for trade export
- Add Calmar ratio and expectancy to results metadata
- Rebuild vbt.html frontend with:
- URL hash deep-linking (#filename) for bookmarkable views
- JSON and CSV export buttons in detail panel
- More metrics: Calmar, Sortino, Expectancy, End Equity (10 total)
- Running backtest progress indicator with elapsed seconds
- Pagination controls (prev/next) with page info
- Filter/sort changes auto-apply (no manual refresh needed)
- Better error states with retry buttons
- Run strategy selector independent of filter
This commit is contained in:
+179
-83
@@ -462,32 +462,82 @@ async def get_risk_metrics():
|
||||
# VBT Dashboard API — VectorBT backtest results browser
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
_vbt_meta_cache: dict[str, dict] = {} # filename → parsed summary dict
|
||||
_vbt_full_cache: dict[str, dict] = {} # filename → full result dict
|
||||
_vbt_cache_time: float = 0.0 # epoch of last cache rebuild
|
||||
_VBT_CACHE_TTL = 5.0 # seconds before re-scan
|
||||
|
||||
def _refresh_vbt_cache():
|
||||
"""Scan results dirs once and populate caches."""
|
||||
global _vbt_meta_cache, _vbt_full_cache, _vbt_cache_time
|
||||
now = time.time()
|
||||
if now - _vbt_cache_time < _VBT_CACHE_TTL:
|
||||
return
|
||||
new_meta: dict[str, dict] = {}
|
||||
new_full: dict[str, dict] = {}
|
||||
for d in [BACKTEST_DIR, HISTORICAL_DIR]:
|
||||
if not os.path.isdir(d):
|
||||
continue
|
||||
for fname in sorted(os.listdir(d)):
|
||||
if not fname.endswith(".json"):
|
||||
continue
|
||||
if fname in new_meta:
|
||||
continue
|
||||
fpath = os.path.join(d, fname)
|
||||
try:
|
||||
with open(fpath) as f:
|
||||
data = json.load(f)
|
||||
n = _normalize_vbt_fields(data)
|
||||
asset = _infer_asset(n.get("strategy", ""), fname)
|
||||
new_meta[fname] = {
|
||||
"filename": fname,
|
||||
"strategy": n.get("strategy", "unknown"),
|
||||
"asset": asset,
|
||||
"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"],
|
||||
"calmar": round((n["total_return_pct"] / max(abs(n["max_drawdown_pct"]), 0.01)), 2),
|
||||
"win_rate": n.get("win_rate", 0),
|
||||
"profit_factor": n["profit_factor"],
|
||||
"expectancy": n.get("expectancy", 0),
|
||||
"total_trades": n["total_trades"],
|
||||
"n_bars": n["n_bars"],
|
||||
"generated_at": n.get("generated_at", ""),
|
||||
"has_equity_curve": bool(n.get("equity_curve")),
|
||||
}
|
||||
new_full[fname] = n
|
||||
except (json.JSONDecodeError, IOError):
|
||||
pass
|
||||
_vbt_meta_cache = new_meta
|
||||
_vbt_full_cache = new_full
|
||||
_vbt_cache_time = now
|
||||
|
||||
|
||||
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
|
||||
dd = dd * 100
|
||||
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:
|
||||
@@ -500,24 +550,14 @@ def _normalize_vbt_fields(data: dict) -> dict:
|
||||
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:
|
||||
@@ -527,67 +567,80 @@ def _normalize_vbt_fields(data: dict) -> dict:
|
||||
return out
|
||||
|
||||
|
||||
def _lttb_downsample(points: list[dict], target: int) -> list[dict]:
|
||||
"""Largest-Triangle-Three-Buckets downsampling for visual fidelity."""
|
||||
n = len(points)
|
||||
if n <= target or target < 3:
|
||||
return points
|
||||
bucket_size = (n - 2) / (target - 2)
|
||||
result = [points[0]]
|
||||
a = 0
|
||||
for i in range(target - 2):
|
||||
avg_x_start = int((i + 0) * bucket_size) + 1
|
||||
avg_x_end = int((i + 1) * bucket_size) + 1
|
||||
avg_range = points[avg_x_start:avg_x_end]
|
||||
avg_x = sum(p.get("t", 0) if isinstance(p.get("t"), (int, float)) else 0 for p in avg_range) / max(len(avg_range), 1)
|
||||
avg_y = sum(p["v"] for p in avg_range) / max(len(avg_range), 1)
|
||||
range_offs = int((i + 1) * bucket_size) + 1
|
||||
range_to = int((i + 2) * bucket_size) + 1
|
||||
max_area = -1.0
|
||||
for j in range(range_offs, min(range_to + 1, n)):
|
||||
pa = result[a]
|
||||
pc = points[j]
|
||||
area = abs((pa.get("t", 0) if isinstance(pa.get("t"), (int, float)) else a) * (pc["v"] - avg_y) +
|
||||
pc.get("t", 0) * (avg_y - pa["v"]) +
|
||||
avg_x * (pa["v"] - pc["v"])) * 0.5
|
||||
if area > max_area:
|
||||
max_area = area
|
||||
next_a = j
|
||||
result.append(points[next_a])
|
||||
a = next_a
|
||||
result.append(points[-1])
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/vbt/results")
|
||||
async def list_vbt_results(
|
||||
strategy: str = "",
|
||||
interval: str = "",
|
||||
asset: str = "",
|
||||
sort: str = "date",
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
):
|
||||
"""List VectorBT backtest results with full metrics and filtering."""
|
||||
results = []
|
||||
for d in [BACKTEST_DIR, HISTORICAL_DIR]:
|
||||
if not os.path.isdir(d):
|
||||
continue
|
||||
for fname in sorted(os.listdir(d), reverse=True):
|
||||
if not fname.endswith(".json"):
|
||||
continue
|
||||
if strategy and strategy not in fname:
|
||||
continue
|
||||
fpath = os.path.join(d, fname)
|
||||
try:
|
||||
with open(fpath) as f:
|
||||
data = json.load(f)
|
||||
n = _normalize_vbt_fields(data)
|
||||
if interval and n.get("interval", "1h") != interval:
|
||||
continue
|
||||
# Infer asset from strategy or filename
|
||||
asset = _infer_asset(n.get("strategy", ""), fname)
|
||||
results.append({
|
||||
"filename": fname,
|
||||
"strategy": n.get("strategy", "unknown"),
|
||||
"asset": asset,
|
||||
"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
|
||||
if len(results) >= limit:
|
||||
break
|
||||
"""List VectorBT backtest results with full metrics, pagination, and server-side filtering."""
|
||||
_refresh_vbt_cache()
|
||||
results = list(_vbt_meta_cache.values())
|
||||
|
||||
# Sort
|
||||
if sort == "sharpe":
|
||||
results.sort(key=lambda r: r.get("sharpe", -999), reverse=True)
|
||||
elif sort == "return":
|
||||
results.sort(key=lambda r: r.get("total_return_pct", -999), reverse=True)
|
||||
elif sort == "dd":
|
||||
results.sort(key=lambda r: -abs(r.get("max_drawdown_pct", 999)), reverse=True)
|
||||
elif sort == "trades":
|
||||
results.sort(key=lambda r: r.get("total_trades", 0), reverse=True)
|
||||
else: # date
|
||||
if strategy:
|
||||
results = [r for r in results if strategy in r.get("filename", "")]
|
||||
if interval:
|
||||
results = [r for r in results if r.get("interval") == interval]
|
||||
if asset:
|
||||
results = [r for r in results if r.get("asset", "") == asset or r.get("asset", "").endswith("/" + asset)]
|
||||
|
||||
sort_keys = {
|
||||
"sharpe": ("sharpe", True),
|
||||
"return": ("total_return_pct", True),
|
||||
"dd": ("max_drawdown_pct", False),
|
||||
"trades": ("total_trades", True),
|
||||
"calmar": ("calmar", True),
|
||||
}
|
||||
if sort in sort_keys:
|
||||
key, rev = sort_keys[sort]
|
||||
results.sort(key=lambda r: r.get(key, -999 if rev else 999), reverse=rev)
|
||||
else:
|
||||
results.sort(key=lambda r: r.get("generated_at", ""), reverse=True)
|
||||
|
||||
return JSONResponse(results[:limit])
|
||||
total = len(results)
|
||||
page = results[offset:offset + limit]
|
||||
return JSONResponse({
|
||||
"results": page,
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"has_more": (offset + limit) < total,
|
||||
})
|
||||
|
||||
|
||||
def _infer_asset(strategy_name: str, filename: str) -> str:
|
||||
@@ -622,20 +675,32 @@ def _infer_asset(strategy_name: str, filename: str) -> str:
|
||||
|
||||
@app.get("/api/vbt/result/{filename}")
|
||||
async def get_vbt_result(filename: str):
|
||||
"""Get full VBT backtest result including equity curve."""
|
||||
for d in [BACKTEST_DIR, HISTORICAL_DIR]:
|
||||
fpath = os.path.join(d, filename)
|
||||
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:
|
||||
step = len(ec) // 500
|
||||
data["equity_curve"] = ec[::step]
|
||||
return JSONResponse(data)
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
"""Get full VBT backtest result including equity curve with LTTB downsampling."""
|
||||
_refresh_vbt_cache()
|
||||
if filename in _vbt_full_cache:
|
||||
data = dict(_vbt_full_cache[filename])
|
||||
else:
|
||||
for d in [BACKTEST_DIR, HISTORICAL_DIR]:
|
||||
fpath = os.path.join(d, filename)
|
||||
if os.path.exists(fpath):
|
||||
with open(fpath) as f:
|
||||
data = json.load(f)
|
||||
data = _normalize_vbt_fields(data)
|
||||
break
|
||||
else:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
|
||||
ec = data.get("equity_curve", [])
|
||||
if ec and len(ec) > 500:
|
||||
data["equity_curve"] = _lttb_downsample(ec, 500)
|
||||
if ec:
|
||||
values = [p["v"] for p in data["equity_curve"]]
|
||||
peak = values[0] if values else 0
|
||||
for i, v in enumerate(values):
|
||||
peak = max(peak, v)
|
||||
values[i] = round(-((peak - v) / peak * 100) if peak > 0 else 0, 2)
|
||||
data["drawdown_curve"] = values
|
||||
return JSONResponse(data)
|
||||
|
||||
|
||||
@app.get("/api/vbt/run")
|
||||
@@ -650,7 +715,6 @@ async def run_vbt_backtest(
|
||||
try:
|
||||
from backtests.vbt_runner import VBTBacktestRunner
|
||||
runner = VBTBacktestRunner()
|
||||
from datetime import datetime
|
||||
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
coin_suffix = f"_{coin}" if coin else ""
|
||||
result = runner.run_strategy(
|
||||
@@ -664,6 +728,7 @@ async def run_vbt_backtest(
|
||||
with open(fpath, "w") as f:
|
||||
json.dump(result, f, default=str)
|
||||
result["filename"] = fname
|
||||
_vbt_cache_time = 0.0
|
||||
return JSONResponse(result)
|
||||
return JSONResponse({"error": "no results generated"}, status_code=500)
|
||||
except Exception as e:
|
||||
@@ -700,11 +765,41 @@ async def list_vbt_strategies():
|
||||
{"key": "grid_mm", "name": "Grid Market Making", "coins": ["BTC"]},
|
||||
{"key": "composite_mm", "name": "Composite MM", "coins": ["BTC"]},
|
||||
{"key": "iceberg", "name": "Iceberg Detection", "coins": ["BTC"]},
|
||||
{"key": "momentum", "name": "Momentum Breakout", "coins": ["ETH"]},
|
||||
{"key": "mean_rev", "name": "Mean Reversion", "coins": ["ETH"]},
|
||||
{"key": "momentum", "name": "Momentum Breakout", "coins": ["BTC", "ETH"]},
|
||||
{"key": "mean_rev", "name": "Mean Reversion", "coins": ["BTC", "ETH"]},
|
||||
])
|
||||
|
||||
|
||||
@app.get("/api/vbt/result/{filename}/csv")
|
||||
async def get_vbt_csv(filename: str):
|
||||
"""Download VBT backtest trades as CSV."""
|
||||
from fastapi.responses import Response
|
||||
_refresh_vbt_cache()
|
||||
if filename in _vbt_full_cache:
|
||||
data = _vbt_full_cache[filename]
|
||||
else:
|
||||
for d in [BACKTEST_DIR, HISTORICAL_DIR]:
|
||||
fpath = os.path.join(d, filename)
|
||||
if os.path.exists(fpath):
|
||||
with open(fpath) as f:
|
||||
data = json.load(f)
|
||||
data = _normalize_vbt_fields(data)
|
||||
break
|
||||
else:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
trades = data.get("trades", [])
|
||||
header = "time,side,size,entry_px,exit_px,pnl,return_pct,duration\n"
|
||||
rows = []
|
||||
for t in trades:
|
||||
rows.append(f"{t.get('time','')},{t.get('side','')},{t.get('size','')},{t.get('entry_px','')},{t.get('exit_px','')},{t.get('pnl','')},{t.get('return_pct','')},{t.get('duration','')}")
|
||||
csv_content = header + "\n".join(rows)
|
||||
return Response(
|
||||
content=csv_content,
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}_trades.csv"}
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Static
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
@@ -802,6 +897,7 @@ def main():
|
||||
print(f" http://{args.host}:{args.port}")
|
||||
print(f" WebSocket: ws://{args.host}:{args.port}/ws")
|
||||
print(f" Backtests: /api/backtests")
|
||||
print(f" VBT Dashboard: /vbt")
|
||||
|
||||
uvicorn.run(app, host=args.host, port=args.port, log_level="warning")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user