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")
|
||||
|
||||
|
||||
+180
-77
@@ -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 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>';
|
||||
}).join('');
|
||||
}catch(e){console.error(e);}
|
||||
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');
|
||||
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('');
|
||||
|
||||
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>
|
||||
</html>
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user