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:
ramseshk
2026-08-07 14:14:40 +08:00
parent 92ba6a564a
commit 6889e06a86
3 changed files with 359 additions and 364 deletions
+170 -74
View File
@@ -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."""
"""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)
# Ensure equity curve is compact for transport
break
else:
return JSONResponse({"error": "not found"}, status_code=404)
ec = data.get("equity_curve", [])
if ec and len(ec) > 500:
step = len(ec) // 500
data["equity_curve"] = ec[::step]
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)
return JSONResponse({"error": "not found"}, status_code=404)
@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")
+177 -74
View File
@@ -8,41 +8,48 @@
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'SF Mono','Cascadia Code','Ubuntu Mono',monospace;background:#0a0a0a;color:#888;min-height:100vh;overflow:hidden}
.pos{color:#03A9F4}.neg{color:#FF5252}.neu{color:#777}
.pos{color:#03A9F4}.neg{color:#FF5252}.neu{color:#777}.warn{color:#f59e0b}
.topbar{background:#111;border-bottom:1px solid #222;padding:8px 20px;display:flex;justify-content:space-between;align-items:center;height:40px}
.topbar h1{font-size:13px;font-weight:600;color:#ddd;letter-spacing:1px}
.topbar .dot{display:inline-block;width:6px;height:6px;background:#fff;border-radius:50%;margin-right:5px;animation:pulse 2s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
.topbar .status{font-size:10px;color:#999}
.main{display:grid;grid-template-columns:310px 1fr;height:calc(100vh - 40px)}
.sidebar{background:#0d0d0d;border-right:1px solid #222;overflow-y:auto;padding:6px}
.controls{padding:6px;border-bottom:1px solid #222;margin-bottom:4px}
.controls .row{display:flex;gap:4px;margin-bottom:4px}
.controls .row:last-child{margin-bottom:0}
.main{display:grid;grid-template-columns:320px 1fr;height:calc(100vh - 40px)}
.sidebar{background:#0d0d0d;border-right:1px solid #222;overflow-y:auto;padding:6px;display:flex;flex-direction:column}
.controls{padding:6px;border-bottom:1px solid #222;margin-bottom:4px;flex-shrink:0}
.controls .row{display:flex;gap:3px;margin-bottom:3px}
select{background:#181818;border:1px solid #333;color:#aaa;border-radius:3px;padding:5px 6px;font-size:10px;font-family:inherit;cursor:pointer;flex:1;min-width:0}
select:focus{outline:none;border-color:#555}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:3px;padding:5px 8px;border-radius:3px;font-size:10px;cursor:pointer;border:1px solid #333;background:#181818;color:#aaa;font-family:inherit;transition:all .12s;white-space:nowrap}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:3px;padding:4px 7px;border-radius:3px;font-size:10px;cursor:pointer;border:1px solid #333;background:#181818;color:#aaa;font-family:inherit;transition:all .12s;white-space:nowrap}
.btn:hover{background:#222;border-color:#555}
.btn-primary{background:#03A9F4;border-color:#03A9F4;color:#fff}
.btn-primary:hover{background:#0288d1}
.btn-success{background:#4CAF50;border-color:#4CAF50;color:#fff}
.btn-success:hover{background:#388E3C}
.btn:disabled{opacity:.4;cursor:not-allowed}
.result-item{background:#111;border:1px solid #222;border-left:3px solid transparent;border-radius:3px;padding:7px 8px;margin-bottom:3px;cursor:pointer;transition:all .12s}
.result-item{background:#111;border:1px solid #222;border-left:3px solid transparent;border-radius:3px;padding:7px 8px;margin-bottom:2px;cursor:pointer;transition:all .12s}
.result-item:hover{border-color:#333;background:#161616}
.result-item.active{border-color:#03A9F4;border-left-color:#03A9F4;background:#121212}
.result-item .name{font-size:11px;font-weight:600;color:#ccc;display:flex;justify-content:space-between;align-items:center}
.result-item .name .asset{font-size:9px;color:#03A9F4;background:#0d1f2b;border:1px solid #1a3a4a;border-radius:3px;padding:1px 5px}
.result-item .meta{font-size:9px;color:#555;margin-top:2px}
.result-item .stats{display:flex;gap:10px;margin-top:3px;font-size:9px;font-family:monospace}
.result-item .stats{display:flex;gap:8px;margin-top:3px;font-size:9px;font-family:monospace;flex-wrap:wrap}
.sidebar h3{font-size:9px;text-transform:uppercase;color:#555;margin:8px 0 4px;letter-spacing:2px;padding:0 4px}
.badge{font-size:9px;border-radius:3px;padding:1px 4px;margin-left:3px;font-weight:400}
#results-list{flex:1;overflow-y:auto;min-height:0}
.pagination-row{display:flex;align-items:center;justify-content:space-between;padding:4px 4px 2px;border-top:1px solid #1a1a1a;flex-shrink:0}
.pagination-row .count{font-size:9px;color:#555}
.pagination-row .nav-btn{font-size:9px;padding:2px 6px;cursor:pointer;border:1px solid #333;background:#181818;color:#aaa;border-radius:3px;font-family:inherit}
.pagination-row .nav-btn:hover{background:#222;border-color:#555}
.pagination-row .nav-btn:disabled{opacity:.3;cursor:default}
.content{padding:20px 28px;overflow-y:auto}
.content h2{font-size:15px;color:#ddd;margin-bottom:2px}
.content .sub{font-size:10px;color:#555;margin-bottom:16px}
.metrics-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:12px}
.metric{background:#111;border:1px solid #222;border-radius:4px;padding:12px 14px}
.content .sub{font-size:10px;color:#555;margin-bottom:8px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.metrics-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:6px;margin-bottom:10px}
.metric{background:#111;border:1px solid #222;border-radius:4px;padding:10px 12px}
.metric .label{font-size:8px;text-transform:uppercase;color:#555;letter-spacing:1.5px;margin-bottom:3px}
.metric .value{font-size:20px;font-weight:700;font-family:monospace}
.metric .value{font-size:18px;font-weight:700;font-family:monospace}
.chart-row{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px}
.chart-box{background:#111;border:1px solid #222;border-radius:4px;padding:12px}
.chart-box h4{font-size:9px;color:#555;text-transform:uppercase;letter-spacing:1.5px;margin-bottom:2px}
@@ -50,30 +57,34 @@ select:focus{outline:none;border-color:#555}
.empty{text-align:center;padding:60px 20px;color:#333}
.empty h2{font-size:14px;margin-bottom:6px;color:#555}
.loading{text-align:center;padding:14px;color:#333;font-size:10px}
.params-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:6px;margin-bottom:12px}
.error-box{background:#1a0a0a;border:1px solid #551111;border-radius:4px;padding:14px;text-align:center}
.error-box h3{color:#FF5252;font-size:12px;margin-bottom:6px}
.error-box button{margin-top:8px}
.params-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:6px;margin-bottom:10px}
.param{background:#111;border:1px solid #222;border-radius:3px;padding:8px 10px}
.param .k{font-size:9px;color:#555;text-transform:uppercase;margin-bottom:2px}
.param .v{font-size:13px;color:#03A9F4;font-family:monospace;font-weight:600}
.trades-table{width:100%;border-collapse:collapse;font-size:11px;font-family:monospace}
.trades-table th{text-align:left;padding:7px 10px;border-bottom:1px solid #222;color:#555;font-weight:500;font-size:9px;text-transform:uppercase;letter-spacing:1px;position:sticky;top:0;background:#111;z-index:1}
.trades-table td{padding:5px 10px;border-bottom:1px solid #1a1a1a;color:#aaa}
.trades-table tr:hover{background:#151515}
.trades-table .pnl-pos{color:#03A9F4}.trades-table .pnl-neg{color:#FF5252}
.trade-scroll{max-height:400px;overflow-y:auto;border:1px solid #222;border-radius:4px}
.export-row{display:flex;gap:6px;margin-bottom:6px}
.progress-bar{height:4px;background:#03A9F4;border-radius:2px;transition:width .3s;margin-top:4px}
.run-status{font-size:10px;color:#f59e0b;padding:4px 0}
</style>
</head>
<body>
<div class="topbar">
<h1><span class="dot"></span>FTDT QUANT LAB — VBT</h1>
<div class="status">● LIVE · Hyperliquid Mainnet</div>
<div class="status">Hyperliquid · VectorBT</div>
</div>
<div class="main">
<div class="sidebar">
<div class="controls">
<div class="row">
<select id="filter-strategy" onchange="loadResults()">
<select id="filter-strategy">
<option value="">All Strategies</option>
<option value="pairs">Pairs Trading</option>
<option value="hurst_vpin">Hurst VPIN</option>
@@ -82,8 +93,10 @@ select:focus{outline:none;border-color:#555}
<option value="grid_mm">Grid Market Making</option>
<option value="composite_mm">Composite MM</option>
<option value="iceberg">Iceberg Detection</option>
<option value="momentum">Momentum Breakout</option>
<option value="mean_rev">Mean Reversion</option>
</select>
<select id="filter-asset" onchange="loadResults()">
<select id="filter-asset">
<option value="">All Assets</option>
<option value="BTC">BTC</option>
<option value="ETH">ETH</option>
@@ -91,71 +104,130 @@ select:focus{outline:none;border-color:#555}
</select>
</div>
<div class="row">
<select id="filter-interval" onchange="loadResults()">
<select id="filter-interval">
<option value="">All Intervals</option>
<option value="1m">1m</option><option value="5m">5m</option><option value="15m">15m</option>
<option value="1h" selected>1h</option><option value="4h">4h</option><option value="1d">1d</option>
<option value="1h">1h</option><option value="4h">4h</option><option value="1d">1d</option>
</select>
<select id="filter-sort" onchange="loadResults()">
<select id="filter-sort">
<option value="date">Latest</option>
<option value="sharpe">Sharpe</option>
<option value="return">Return %</option>
<option value="calmar">Calmar</option>
<option value="dd">Min DD</option>
<option value="trades">Trades</option>
</select>
</div>
<div class="row">
<select id="run-interval"><option value="1m">1m</option><option value="5m">5m</option><option value="15m">15m</option><option value="1h" selected>1h</option><option value="4h">4h</option><option value="1d">1d</option></select>
<select id="run-limit"><option value="100">100b</option><option value="200">200b</option><option value="500" selected>500b</option><option value="1000">1Kb</option><option value="2000">2Kb</option><option value="5000">5Kb</option></select>
<select id="run-coin"><option value="">auto</option><option value="BTC">BTC</option><option value="ETH">ETH</option><option value="SOL">SOL</option></select>
<select id="run-strategy">
<option value="pairs">Pairs Trading</option>
<option value="hurst_vpin">Hurst VPIN</option>
<option value="as_mm">Avellaneda-Stoikov</option>
<option value="obi">Order Book Imbalance</option>
<option value="grid_mm">Grid Market Making</option>
<option value="composite_mm">Composite MM</option>
<option value="iceberg">Iceberg Detection</option>
<option value="momentum">Momentum Breakout</option>
<option value="mean_rev">Mean Reversion</option>
</select>
</div>
<button class="btn btn-primary" onclick="runNewBacktest()">▶ Run Backtest</button>
<div class="row">
<select id="run-interval">
<option value="1m">1m</option><option value="5m">5m</option><option value="15m">15m</option>
<option value="1h" selected>1h</option><option value="4h">4h</option><option value="1d">1d</option>
</select>
<select id="run-limit">
<option value="100">100 bars</option><option value="200">200 bars</option><option value="500" selected>500 bars</option>
<option value="1000">1K bars</option><option value="2000">2K bars</option><option value="5000">5K bars</option>
</select>
<select id="run-coin">
<option value="">auto</option><option value="BTC">BTC</option><option value="ETH">ETH</option><option value="SOL">SOL</option>
</select>
</div>
<div style="display:flex;gap:4px">
<button class="btn btn-primary" onclick="runNewBacktest()" id="btn-run">▶ Run Backtest</button>
<button class="btn" onclick="loadResults()" id="btn-refresh">↻</button>
</div>
<div id="run-status"></div>
</div>
<div style="font-size:9px;color:#444;padding:2px 4px" id="result-count"></div>
<div id="results-list"><div class="loading">Loading...</div></div>
<div class="pagination-row" id="pagination" style="display:none">
<button class="nav-btn" id="btn-prev" onclick="loadPage(-1)" disabled>← Prev</button>
<span class="count" id="page-info"></span>
<button class="nav-btn" id="btn-next" onclick="loadPage(1)" disabled>Next →</button>
</div>
</div>
<div class="content" id="content">
<div class="empty">
<h2>SELECT A BACKTEST</h2>
<p style="font-size:11px">Choose from sidebar or configure and run a new test</p>
<p style="font-size:11px">Choose from the sidebar or configure and run a new test</p>
</div>
</div>
</div>
<script>
const API='';
let currentResult=null, currentFilename=null;
const PAGE_SIZE=50;
let currentResult=null, currentFilename=null, currentOffset=0, currentTotal=0, runTimer=null;
function buildUrl(){let p=new URLSearchParams();const s=document.getElementById('filter-strategy').value,i=document.getElementById('filter-interval').value,a=document.getElementById('filter-asset').value,so=document.getElementById('filter-sort').value;p.set('limit',PAGE_SIZE);p.set('offset',currentOffset);if(s)p.set('strategy',s);if(i)p.set('interval',i);if(a)p.set('asset',a);p.set('sort',so);return`${API}/api/vbt/results?`+p;}
async function loadResults(){
const strat=document.getElementById('filter-strategy').value;
const interval=document.getElementById('filter-interval').value;
const sort=document.getElementById('filter-sort').value;
const asset=document.getElementById('filter-asset').value;
let url='${API}/api/vbt/results?limit=200&strategy='+strat+'&sort='+sort;
if(interval)url+='&interval='+interval;
document.getElementById('results-list').innerHTML='<div class="loading">Loading...</div>';
try{
const data=await (await fetch(url)).json();
const resp=await fetch(buildUrl());const data=await resp.json();
if(!data||!Array.isArray(data.results)){
const results=data.results||[];const total=data.total||results.length;const hasMore=data.has_more??false;
renderResultsList(results,total,hasMore);
}else{
renderResultsList(data,data.length,false);
}
}catch(e){document.getElementById('results-list').innerHTML='<div class="error-box" style="padding:8px;font-size:10px">Failed to load results</div><div style="font-size:9px;color:#555;text-align:center;margin-top:4px">'+e+'</div>';}
}
function renderResultsList(results,total,hasMore){
currentTotal=total;
const el=document.getElementById('results-list');
const filtered=asset?data.filter(r=>(r.asset||'').includes(asset)):data;
document.getElementById('result-count').textContent=filtered.length+' results';
if(!filtered.length){el.innerHTML='<div style="padding:8px;color:#555;font-size:10px">No results</div>';return;}
el.innerHTML=filtered.map((r,i)=>{
const c=r.sharpe>0.5?'pos':(r.sharpe<-0.5?'neg':'neu');
const rc=r.total_return_pct>=0?'pos':'neg';
return '<div class="result-item'+(i===0&&!currentResult?' active':'')+'" onclick="selectFile(\''+r.filename+'\')" id="item-'+r.filename+'"><div class="name">'+r.strategy+'<span class="asset">'+(r.asset||'?')+'</span></div><div class="meta">'+(r.interval||'1h')+' · '+(r.total_trades||0)+'t · '+(r.n_bars||0)+'b</div><div class="stats"><span class="'+c+'">S'+(r.sharpe||0).toFixed(2)+'</span><span class="'+rc+'">'+(r.total_return_pct||0).toFixed(1)+'%</span><span class="neu">PF'+(r.profit_factor||0).toFixed(2)+'</span></div></div>';
document.getElementById('result-count').textContent=total+' results';
if(!results.length){el.innerHTML='<div style="padding:8px;color:#555;font-size:10px">No results</div>';document.getElementById('pagination').style.display='none';return;}
el.innerHTML=results.map((r,i)=>{
const c=(r.sharpe||0)>0.5?'pos':((r.sharpe||0)<-0.5?'neg':'neu');
const rc=(r.total_return_pct||0)>=0?'pos':'neg';
const activeCls=r.filename===currentFilename?' active':'';
return '<div class="result-item'+activeCls+'" onclick="selectFile(\''+r.filename+'\')" id="item-'+r.filename+'"><div class="name">'+r.strategy+'<span class="asset">'+(r.asset||'?')+'</span></div><div class="meta">'+(r.interval||'1h')+' · '+(r.total_trades||0)+'t · '+(r.n_bars||0)+'b · '+(r.generated_at||'').substring(0,10)+'</div><div class="stats"><span class="'+c+'">S'+(r.sharpe||0).toFixed(2)+'</span><span class="'+rc+'">'+(r.total_return_pct||0).toFixed(1)+'%</span><span class="neu">PF'+(r.profit_factor||0).toFixed(2)+'</span><span class="neu">WR'+(Math.round((r.win_rate||0)*100))+'%</span></div></div>';
}).join('');
}catch(e){console.error(e);}
const pagination=document.getElementById('pagination');
if(total>PAGE_SIZE){
pagination.style.display='flex';
document.getElementById('btn-prev').disabled=currentOffset===0;
document.getElementById('btn-next').disabled=!hasMore;
const end=Math.min(currentOffset+PAGE_SIZE,total);
document.getElementById('page-info').textContent=(currentOffset+1)+'–'+end+' of '+total;
}else{pagination.style.display='none';}
document.getElementById('pagination').style.display=total>PAGE_SIZE?'flex':'none';
}
function loadPage(dir){
currentOffset=Math.max(0,currentOffset+dir*PAGE_SIZE);
loadResults();
}
async function selectFile(filename){
document.querySelectorAll('.result-item').forEach(el=>el.classList.remove('active'));
document.getElementById('item-'+filename)?.classList.add('active');
const item=document.getElementById('item-'+filename);
if(item)item.classList.add('active');
currentFilename=filename;
currentResult=null;
window.location.hash=filename;
const content=document.getElementById('content');
content.innerHTML='<div class="loading" style="padding:40px">Loading '+filename+'...</div>';
try{
const r=await (await fetch('${API}/api/vbt/result/'+filename)).json();
if(r.error){document.getElementById('content').innerHTML='<div class="empty"><h2>FILE NOT FOUND</h2></div>';return;}
const r=await(await fetch(API+'/api/vbt/result/'+encodeURIComponent(filename))).json();
if(r.error||!r.strategy){content.innerHTML='<div class="error-box"><h3>Error</h3><p style="font-size:11px;color:#999">'+r.error+'</p><button class="btn" onclick="selectFile(\''+filename+'\')">Retry</button></div>';return;}
currentResult=r;renderDetail(r);
}catch(e){document.getElementById('content').innerHTML='<div class="empty"><h2>ERROR</h2><p>'+e+'</p></div>';}
}catch(e){content.innerHTML='<div class="error-box"><h3>Error</h3><p style="font-size:11px;color:#999">'+e+'</p><button class="btn" onclick="selectFile(\''+filename+'\')">Retry</button></div>';}
}
function renderDetail(r){
@@ -163,44 +235,52 @@ function renderDetail(r){
const wr=(r.win_rate??0)*100,pf=r.profit_factor??0,eq=r.end_equity??10000;
const tr=r.total_trades??0,nb=r.n_bars??0,so=r.sortino??0;
const asset=r.asset||'-';const params=r.params||{};
const calmar=r.calmar||((dd)?(ret/abs(dd)):0);
const expec=r.expectancy??(tr?(r.pnl??0)/tr:0);
let avgDur='';
const trades=r.trades||[];
if(trades.length){const durations=trades.map(t=>String(t.duration||'')).filter(d=>d);if(durations.length)avgDur=durations[0];}
let params_html='';
for(const[k,v]of Object.entries(params)){
let cls='neu';
if(typeof v==='number'){
if(k.includes('entry')||k.includes('threshold'))cls=v>0.5?'pos':'neu';
else cls=v>0?'pos':(v<0?'neg':'neu');
}
if(typeof v==='number'){if(k.includes('entry')||k.includes('threshold'))cls=v>0.5?'pos':'neu';else cls=v>0?'pos':(v<0?'neg':'neu');}
params_html+='<div class="param"><div class="k">'+k.replace(/_/g,' ')+'</div><div class="v '+cls+'">'+(typeof v==='number'?v.toFixed(3):v)+'</div></div>';
}
let trades_html='';
const trades=r.trades||[];
if(trades.length){
const rows=trades.map(t=>'<tr><td style="color:#777;font-size:10px">'+String(t.time||'').substring(0,19)+'</td><td class="'+(t.side==='BUY'?'pos':'neg')+'">'+String(t.side||'')+'</td><td>'+Number(t.size||0).toFixed(6)+'</td><td>$'+Number(t.entry_px||0).toFixed(1)+'</td><td>$'+Number(t.exit_px||0).toFixed(1)+'</td><td class="'+(Number(t.pnl||0)>=0?'pnl-pos':'pnl-neg')+'">$'+Number(t.pnl||0).toFixed(4)+'</td><td>'+String(t.duration||'')+'</td></tr>').join('');
trades_html='<div class="chart-box full"><h4>Trade Log ('+trades.length+' trades)</h4><div class="trade-scroll"><table class="trades-table"><thead><tr><th>Time</th><th>Side</th><th>Size</th><th>Entry</th><th>Exit</th><th>PnL</th><th>Duration</th></tr></thead><tbody>'+rows+'</tbody></table></div></div>';
const rows=trades.map(t=>'<tr><td style="color:#777;font-size:10px">'+String(t.time||'').substring(0,19)+'</td><td class="'+(String(t.side||'').includes('BUY')?'pos':'neg')+'">'+String(t.side||'')+'</td><td>'+Number(t.size||0).toFixed(6)+'</td><td>$'+Number(t.entry_px||0).toFixed(1)+'</td><td>$'+Number(t.exit_px||0).toFixed(1)+'</td><td class="'+(Number(t.pnl||0)>=0?'pnl-pos':'pnl-neg')+'">$'+Number(t.pnl||0).toFixed(4)+'</td><td>'+String(t.duration||'')+'</td></tr>').join('');
trades_html='<div class="chart-box full"><div style="display:flex;justify-content:space-between;align-items:center"><h4>Trade Log ('+trades.length+' trades)</h4></div><div class="trade-scroll"><table class="trades-table"><thead><tr><th>Time</th><th>Side</th><th>Size</th><th>Entry</th><th>Exit</th><th>PnL</th><th>Duration</th></tr></thead><tbody>'+rows+'</tbody></table></div></div>';
}
document.getElementById('content').innerHTML=
'<h2>'+r.strategy+' <span class="badge" style="font-size:10px;background:#0d1f2b;color:#03A9F4;border:1px solid #1a3a4a">'+asset+'</span></h2>'+
'<div class="sub">'+(r.interval||'1h')+' · '+nb+' bars · '+tr+' trades · '+(r.generated_at||'')+'</div>'+
'<div class="sub"><span>'+(r.interval||'1h')+'</span><span>'+nb+' bars</span><span>'+tr+' trades</span><span>'+(r.generated_at||'').substring(0,19)+'</span>'+
'<div class="export-row">'+
'<button class="btn" onclick="exportJSON()" title="Download full result as JSON">JSON</button>'+
'<button class="btn" onclick="exportCSV()" title="Download trades as CSV">CSV</button>'+
'</div></div>'+
'<div class="metrics-grid">'+
'<div class="metric"><div class="label">Total Return</div><div class="value '+(ret>=0?'pos':'neg')+'">'+ret.toFixed(2)+'%</div></div>'+
'<div class="metric"><div class="label">Sharpe</div><div class="value '+(sh>=0?'pos':'neg')+'">'+sh.toFixed(2)+'</div></div>'+
'<div class="metric"><div class="label">Sortino</div><div class="value '+(so>=0?'pos':'neg')+'">'+so.toFixed(2)+'</div></div>'+
'<div class="metric"><div class="label">Calmar</div><div class="value '+(calmar>=0?'pos':'neg')+'">'+calmar.toFixed(2)+'</div></div>'+
'<div class="metric"><div class="label">Max DD</div><div class="value neg">'+dd.toFixed(2)+'%</div></div>'+
'<div class="metric"><div class="label">Win Rate</div><div class="value '+(wr>=50?'pos':'neg')+'">'+wr.toFixed(0)+'%</div></div>'+
'<div class="metric"><div class="label">Profit Factor</div><div class="value '+(pf>=1?'pos':'neg')+'">'+pf.toFixed(2)+'</div></div>'+
'<div class="metric"><div class="label">Expectancy</div><div class="value '+(expec>=0?'pos':'neg')+'">$'+expec.toFixed(4)+'</div></div>'+
'<div class="metric"><div class="label">Trades</div><div class="value neu">'+tr+'</div></div>'+
'<div class="metric"><div class="label">End Equity</div><div class="value neu">$'+eq.toLocaleString()+'</div></div>'+
'<div class="metric"><div class="label">Sortino</div><div class="value '+(so>=0?'pos':'neg')+'">'+so.toFixed(2)+'</div></div>'+
'<div class="metric"><div class="label">End Equity</div><div class="value neu">$'+parseInt(eq).toLocaleString()+'</div></div>'+
'</div>'+
'<div class="chart-box full"><h4>Strategy Parameters</h4><div class="params-grid">'+params_html+'</div></div>'+
'<div class="chart-row">'+
'<div class="chart-box full"><h4>Equity Curve</h4><div id="chart-eq" style="height:280px"></div></div>'+
'<div class="chart-box full"><h4>Equity Curve</h4><div id="chart-eq" style="height:300px"></div></div>'+
'</div>'+
'<div class="chart-row">'+
'<div class="chart-box"><h4>Drawdown</h4><div id="chart-dd" style="height:240px"></div></div>'+
'<div class="chart-box"><h4>Period Returns</h4><div id="chart-ret" style="height:240px"></div></div>'+
'<div class="chart-box"><h4>Drawdown</h4><div id="chart-dd" style="height:250px"></div></div>'+
'<div class="chart-box"><h4>Period Returns</h4><div id="chart-ret" style="height:250px"></div></div>'+
'</div>'+
trades_html;
renderCharts(r);
@@ -210,35 +290,58 @@ function renderCharts(r){
const ec=r.equity_curve||[];
if(!ec.length)return;
const times=ec.map(p=>p.t),values=ec.map(p=>p.v);
Plotly.newPlot('chart-eq',[{x:times,y:values,type:'scatter',mode:'lines',line:{color:'#03A9F4',width:1.2},fill:'tozeroy',fillcolor:'rgba(3,169,244,0.06)'}],{margin:{t:4,r:12,b:28,l:65},height:280,paper_bgcolor:'rgba(0,0,0,0)',plot_bgcolor:'rgba(0,0,0,0)',xaxis:{gridcolor:'#1a1a1a',tickfont:{color:'#444',size:9}},yaxis:{gridcolor:'#1a1a1a',tickfont:{color:'#444',size:9},tickprefix:'$'},showlegend:false},{responsive:true,displayModeBar:false});
const peak=values.reduce((a,v,i)=>(a.push(i?Math.max(a[i-1],v):v),a),[]);
const dd=values.map((v,i)=>i?-(peak[i]-v)/peak[i]*100:0);
Plotly.newPlot('chart-dd',[{x:times,y:dd,type:'scatter',mode:'none',fill:'tozeroy',fillcolor:'rgba(255,82,82,0.10)',line:{color:'#FF5252',width:0.8}}],{margin:{t:4,r:12,b:28,l:55},height:240,paper_bgcolor:'rgba(0,0,0,0)',plot_bgcolor:'rgba(0,0,0,0)',xaxis:{gridcolor:'#1a1a1a',tickfont:{color:'#444',size:9}},yaxis:{gridcolor:'#1a1a1a',tickfont:{color:'#444',size:9},ticksuffix:'%'},showlegend:false},{responsive:true,displayModeBar:false});
if(values.length>1){
const rets=values.slice(1).map((v,i)=>(v-values[i])/values[i]*100);
Plotly.newPlot('chart-ret',[{x:rets,type:'histogram',nbinsx:40,marker:{color:'#03A9F4',opacity:0.6,line:{color:'#111',width:1}}}],{margin:{t:4,r:12,b:28,l:45},height:240,paper_bgcolor:'rgba(0,0,0,0)',plot_bgcolor:'rgba(0,0,0,0)',xaxis:{gridcolor:'#1a1a1a',tickfont:{color:'#444',size:9},ticksuffix:'%'},yaxis:{gridcolor:'#1a1a1a',tickfont:{color:'#444',size:9}},showlegend:false,bargap:0.02},{responsive:true,displayModeBar:false});
}
Plotly.newPlot('chart-eq',[{x:times,y:values,type:'scatter',mode:'lines',line:{color:'#03A9F4',width:1.2},fill:'tozeroy',fillcolor:'rgba(3,169,244,0.06)'}],{margin:{t:4,r:12,b:28,l:65},height:300,paper_bgcolor:'rgba(0,0,0,0)',plot_bgcolor:'rgba(0,0,0,0)',xaxis:{gridcolor:'#1a1a1a',tickfont:{color:'#444',size:9}},yaxis:{gridcolor:'#1a1a1a',tickfont:{color:'#444',size:9},tickprefix:'$'},showlegend:false},{responsive:true,displayModeBar:false});
const ddData=r.drawdown_curve||[];
if(ddData.length){Plotly.newPlot('chart-dd',[{x:times,y:ddData,type:'scatter',mode:'none',fill:'tozeroy',fillcolor:'rgba(255,82,82,0.10)',line:{color:'#FF5252',width:0.8}}],{margin:{t:4,r:12,b:28,l:55},height:250,paper_bgcolor:'rgba(0,0,0,0)',plot_bgcolor:'rgba(0,0,0,0)',xaxis:{gridcolor:'#1a1a1a',tickfont:{color:'#444',size:9}},yaxis:{gridcolor:'#1a1a1a',tickfont:{color:'#444',size:9},ticksuffix:'%'},showlegend:false},{responsive:true,displayModeBar:false});}else{const peak=values.reduce((a,v,i)=>(a.push(i?Math.max(a[i-1],v):v),a),[]);const dd=values.map((v,i)=>i?-(peak[i]-v)/peak[i]*100:0);Plotly.newPlot('chart-dd',[{x:times,y:dd,type:'scatter',mode:'none',fill:'tozeroy',fillcolor:'rgba(255,82,82,0.10)',line:{color:'#FF5252',width:0.8}}],{margin:{t:4,r:12,b:28,l:55},height:250,paper_bgcolor:'rgba(0,0,0,0)',plot_bgcolor:'rgba(0,0,0,0)',xaxis:{gridcolor:'#1a1a1a',tickfont:{color:'#444',size:9}},yaxis:{gridcolor:'#1a1a1a',tickfont:{color:'#444',size:9},ticksuffix:'%'},showlegend:false},{responsive:true,displayModeBar:false});}
if(values.length>1){const rets=values.slice(1).map((v,i)=>(v-values[i])/values[i]*100);Plotly.newPlot('chart-ret',[{x:rets,type:'histogram',nbinsx:40,marker:{color:'#03A9F4',opacity:0.6,line:{color:'#111',width:1}}}],{margin:{t:4,r:12,b:28,l:45},height:250,paper_bgcolor:'rgba(0,0,0,0)',plot_bgcolor:'rgba(0,0,0,0)',xaxis:{gridcolor:'#1a1a1a',tickfont:{color:'#444',size:9},ticksuffix:'%'},yaxis:{gridcolor:'#1a1a1a',tickfont:{color:'#444',size:9}},showlegend:false,bargap:0.02},{responsive:true,displayModeBar:false});}
}
function exportJSON(){if(!currentResult)return;const blob=new Blob([JSON.stringify(currentResult,null,2)],{type:'application/json'});const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=(currentFilename||'backtest')+'.json';a.click();}
function exportCSV(){if(!currentFilename)return;const a=document.createElement('a');a.href=API+'/api/vbt/result/'+encodeURIComponent(currentFilename)+'/csv';a.download=currentFilename.replace('.json','')+'_trades.csv';a.click();}
async function runNewBacktest(){
const strat=document.getElementById('filter-strategy').value||'pairs';
const strat=document.getElementById('run-strategy').value;
const interval=document.getElementById('run-interval').value;
const limit=document.getElementById('run-limit').value;
const coin=document.getElementById('run-coin').value;
const btn=document.querySelector('.btn-primary');
const btn=document.getElementById('btn-run');
const status=document.getElementById('run-status');
const orig=btn.textContent;btn.textContent='⏳ Running...';btn.disabled=true;
status.innerHTML='<div class="run-status">Running '+strat+' '+interval+'...</div><div class="progress-bar" style="width:0%" id="progress-bar"></div>';
const startTime=Date.now();
runTimer=setInterval(()=>{
const elapsed=Math.round((Date.now()-startTime)/1000);
status.innerHTML='<div class="run-status">Running '+strat+' '+interval+'... '+elapsed+'s</div><div class="progress-bar" style="width:'+Math.min(elapsed*10,95)+'%"></div>';
},1000);
try{
let url='${API}/api/vbt/run?strategy='+strat+'&interval='+interval+'&limit='+limit;
let url=API+'/api/vbt/run?strategy='+strat+'&interval='+interval+'&limit='+limit;
if(coin)url+='&coin='+coin;
const resp=await fetch(url);
const data=await resp.json();
if(data.error){alert(data.error);btn.textContent=orig;btn.disabled=false;return;}
clearInterval(runTimer);runTimer=null;
if(data.error){status.innerHTML='<div style="font-size:10px;color:#FF5252;padding:4px 0">Error: '+data.error+'</div>';btn.textContent=orig;btn.disabled=false;return;}
currentResult=data;currentFilename=data.filename;
renderDetail(data);loadResults();
}catch(e){alert('Failed: '+e.message);}
renderDetail(data);
currentOffset=0;loadResults();
status.innerHTML='<div style="font-size:10px;color:#4CAF50;padding:4px 0">Done in '+Math.round((Date.now()-startTime)/1000)+'s</div>';
}catch(e){clearInterval(runTimer);runTimer=null;status.innerHTML='<div style="font-size:10px;color:#FF5252;padding:4px 0">Failed: '+e.message+'</div>';}
btn.textContent=orig;btn.disabled=false;
}
function applyFilter(){currentOffset=0;loadResults();}
document.getElementById('filter-strategy').addEventListener('change',applyFilter);
document.getElementById('filter-interval').addEventListener('change',applyFilter);
document.getElementById('filter-asset').addEventListener('change',applyFilter);
document.getElementById('filter-sort').addEventListener('change',applyFilter);
function restoreFromHash(){
const hash=window.location.hash.replace('#','');
if(hash&&hash.endsWith('.json')){selectFile(hash);}
}
window.addEventListener('hashchange',restoreFromHash);
loadResults();
setTimeout(restoreFromHash,100);
</script>
</body>
</html>
-204
View File
@@ -1,204 +0,0 @@
"""
Minimal VBT dashboard server — no live trading, no memory guard, no broadcast.
Just serves the VBT dashboard HTML and backtest API endpoints.
"""
import json, os, sys
from pathlib import Path
from datetime import datetime
project_root = str(Path(__file__).resolve().parent.parent)
sys.path.insert(0, project_root)
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse
RESULTS_DIR = Path(project_root) / "backtests" / "results"
HISTORICAL_DIR = RESULTS_DIR / "historical"
STATIC_DIR = Path(project_root) / "dashboard" / "static"
os.makedirs(RESULTS_DIR, exist_ok=True)
app = FastAPI(title="FTDT VBT Dashboard")
# ── Field normalization ──────────────────────────────────────────
def _normalize(data: dict) -> dict:
out = dict(data)
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
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
out["max_drawdown_pct"] = dd or 0
if out.get("max_drawdown_pct") is None:
out["max_drawdown_pct"] = 0
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
if "profit_factor" not in out:
trades = out.get("trades", [])
if trades:
gross_win = sum(t.get("pnl", t.get("pnl_net", t.get("pnl_gross", 0))) or 0
for t in trades if (t.get("pnl", t.get("pnl_net", t.get("pnl_gross", 0))) or 0) > 0)
gross_loss = abs(sum(t.get("pnl", t.get("pnl_net", t.get("pnl_gross", 0))) or 0
for t in trades if (t.get("pnl", t.get("pnl_net", t.get("pnl_gross", 0))) or 0) < 0))
out["profit_factor"] = round(gross_win / gross_loss, 3) if gross_loss > 0 else 0
else:
out["profit_factor"] = 0
if "total_trades" not in out:
out["total_trades"] = len(out.get("trades", []))
if out.get("total_trades") is None:
out["total_trades"] = 0
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", t.get("pnl_net", t.get("pnl_gross", 0))) or 0) > 0)
out["win_rate"] = round(wins / len(trades), 3)
return out
def _infer_asset(strategy_name: str, filename: str) -> str:
name = (strategy_name + " " + filename).lower()
for key, asset in {
"pairs": "BTC/ETH", "order book": "BTC", "obi": "BTC",
"iceberg": "BTC", "hurst": "BTC", "vpin": "BTC",
"avellaneda": "BTC", "as_mm": "BTC", "grid": "BTC",
"composite": "BTC", "funding": "BTC", "kalman": "BTC/ETH",
"cartea": "BTC", "gueant": "BTC", "hawkes": "BTC",
"deep lob": "BTC", "queue": "BTC",
}.items():
if key in name:
return asset
return "BTC" if "btc" in name or "eth" not in name else "ETH"
# ── REST API ────────────────────────────────────────────────────
@app.get("/api/vbt/results")
async def list_results(strategy: str = "", interval: str = "", sort: str = "date", limit: int = 200):
results = []
for d in [RESULTS_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
try:
with open(os.path.join(d, fname)) as f:
n = _normalize(json.load(f))
if interval and n.get("interval", "1h") != interval:
continue
results.append({
"filename": fname,
"strategy": n.get("strategy", "unknown"),
"asset": _infer_asset(n.get("strategy", ""), fname),
"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
sort_keys = {
"sharpe": ("sharpe", True), "return": ("total_return_pct", True),
"dd": ("max_drawdown_pct", False), "trades": ("total_trades", 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])
@app.get("/api/vbt/result/{filename}")
async def get_result(filename: str):
for d in [RESULTS_DIR, HISTORICAL_DIR]:
fpath = os.path.join(d, filename)
if os.path.exists(fpath):
with open(fpath) as f:
data = _normalize(json.load(f))
ec = data.get("equity_curve", [])
if ec and len(ec) > 500:
data["equity_curve"] = ec[::len(ec)//500]
return JSONResponse(data)
return JSONResponse({"error": "not found"}, status_code=404)
@app.get("/api/vbt/run")
async def run_backtest(strategy: str = "pairs", interval: str = "1h", limit: int = 500, coin: str = ""):
try:
from backtests.vbt_runner import VBTBacktestRunner
runner = VBTBacktestRunner()
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
result = runner.run_strategy(strategy=strategy, interval=interval, limit=limit)
if result:
if coin:
result["asset"] = coin.upper()
fname = f"{strategy}_{'' if not coin else coin+'_'}vbt_{ts}.json"
fpath = RESULTS_DIR / fname
with open(fpath, "w") as f:
json.dump(result, f, default=str)
result["filename"] = fname
return JSONResponse(result)
return JSONResponse({"error": "no results"}, status_code=500)
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@app.get("/api/vbt/strategies")
async def list_strategies():
return JSONResponse([
{"key": "pairs", "name": "Pairs Trading", "coins": ["BTC", "ETH"]},
{"key": "hurst_vpin", "name": "Hurst VPIN", "coins": ["BTC"]},
{"key": "as_mm", "name": "Avellaneda-Stoikov", "coins": ["BTC"]},
{"key": "obi", "name": "Order Book Imbalance", "coins": ["BTC"]},
{"key": "grid_mm", "name": "Grid Market Making", "coins": ["BTC"]},
{"key": "composite_mm", "name": "Composite MM", "coins": ["BTC"]},
{"key": "iceberg", "name": "Iceberg Detection", "coins": ["BTC"]},
])
# ── Static ──────────────────────────────────────────────────────
@app.get("/vbt")
async def vbt_page():
return FileResponse(STATIC_DIR / "vbt.html")
@app.get("/")
async def root():
return FileResponse(STATIC_DIR / "vbt.html")
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
# ── Main ────────────────────────────────────────────────────────
if __name__ == "__main__":
import uvicorn, argparse
p = argparse.ArgumentParser()
p.add_argument("--port", type=int, default=9175)
p.add_argument("--host", default="0.0.0.0")
args = p.parse_args()
print(f"VBT Dashboard → http://{args.host}:{args.port}/vbt")
uvicorn.run(app, host=args.host, port=args.port, log_level="error")