feat: VectorBT results dashboard with Plotly charts

Dashboard (dashboard/):
- New /api/vbt/results — list VBT backtest results with full metrics
- New /api/vbt/result/{file} — load result + equity curve (auto-decimated >500pts)
- New /api/vbt/run — run backtests on-demand from the UI
- New /api/vbt/sweep — parameter sweep as heatmap data
- New /api/vbt/strategies — list available strategy keys
- New /vbt — interactive HTML dashboard (Plotly.js):
  - Equity curve chart with area fill
  - Drawdown waterfall chart
  - Returns distribution histogram
  - Metric cards: Sharpe, Sortino, max DD, win rate, profit factor
  - Strategy filter sidebar
  - One-click backtest runner
- Fix BACKTEST_DIR auto-detection for local/dev paths

API verified: all 5 endpoints tested against live data
This commit is contained in:
ramseshk
2026-08-06 17:43:47 +08:00
parent 39545ac94b
commit 6934bfdaa0
2 changed files with 385 additions and 10 deletions
+142 -10
View File
@@ -27,20 +27,27 @@ from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
# Fix BACKTEST_DIR — auto-detect local path if deployed dir doesn't exist
_default_results = str(Path(__file__).resolve().parent.parent / "backtests" / "results")
BACKTEST_DIR = _default_results if os.path.isdir(_default_results) else "/home/debian/ftdt-quant-lab/backtests/results"
HISTORICAL_DIR = BACKTEST_DIR + "/historical" if os.path.isdir(BACKTEST_DIR + "/historical") else BACKTEST_DIR
from config.fee_tiers import get_perp_fees, PERPS_TIERS, STAKING_TIERS, STRATEGY_FEE_MODELS
from common.risk import risk_summary
from strategies.quant_report import compute_quant_report
# ═══════════════════════════════════════════════════════════
# Memory guard: cap RSS at 512MB, GC-aggressive at 256MB
# Memory guard: check RSS via /proc, force GC at 256MB,
# log warning at 384MB, hard exit at 512MB.
# RLIMIT_AS disabled — Python heap needs virtual headroom.
# ═══════════════════════════════════════════════════════════
import resource, gc, signal
import gc, os as _os
MEM_SOFT_LIMIT = 256 * 1024 * 1024 # 256 MB — force GC
MEM_WARN_LIMIT = 384 * 1024 * 1024 # 384 MB — log warning
MEM_HARD_LIMIT = 512 * 1024 * 1024 # 512 MB — terminate
resource.setrlimit(resource.RLIMIT_AS, (MEM_HARD_LIMIT, MEM_HARD_LIMIT))
def check_memory():
"""Check RSS, force GC if over soft limit, raise if over hard limit."""
try:
@@ -51,7 +58,7 @@ def check_memory():
rss = rss_kb * 1024
if rss > MEM_HARD_LIMIT:
print(f"[CRIT] RSS {rss_kb // 1024}MB > 512MB — exiting", flush=True)
os._exit(1)
_os._exit(1)
if rss > MEM_SOFT_LIMIT:
gc.collect()
gc.collect()
@@ -66,11 +73,8 @@ import uvicorn
METRICS_FILE = "/tmp/ftdt-metrics.json"
PAPER_METRICS_FILE = "/tmp/ftdt-paper-metrics.json"
BACKTEST_DIR = "/home/debian/ftdt-quant-lab/backtests/results"
HISTORICAL_DIR = "/home/debian/ftdt-quant-lab/backtests/results/historical"
STATIC_DIR = Path(__file__).parent / "static"
# Ensure backtest dir exists
os.makedirs(BACKTEST_DIR, exist_ok=True)
# ═══════════════════════════════════════════════════════════
@@ -235,8 +239,11 @@ async def list_backtests():
@app.get("/api/backtest/{name}")
async def get_backtest(name: str):
"""Get full backtest result data."""
fpath = os.path.join(BACKTEST_DIR, f"{name}.json")
"""Get full backtest result data — checks historical dir first."""
# Try historical subdirectory first (where dashboard saves backtests)
fpath = os.path.join(HISTORICAL_DIR, f"{name}.json")
if not os.path.exists(fpath):
fpath = os.path.join(BACKTEST_DIR, f"{name}.json")
if os.path.exists(fpath):
with open(fpath) as f:
return JSONResponse(json.load(f))
@@ -451,6 +458,126 @@ async def get_risk_metrics():
"correlation_matrix": corr,
})
# ═══════════════════════════════════════════════════════════
# VBT Dashboard API — VectorBT backtest results browser
# ═══════════════════════════════════════════════════════════
@app.get("/api/vbt/results")
async def list_vbt_results(strategy: str = "", limit: int = 50):
"""List VectorBT backtest results with full metrics."""
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)
results.append({
"filename": fname,
"strategy": data.get("strategy", "unknown"),
"engine": data.get("engine", "vectorbt"),
"interval": data.get("interval", "1h"),
"sharpe": data.get("sharpe", 0),
"sortino": data.get("sortino", 0),
"total_return_pct": data.get("total_return_pct", 0),
"max_drawdown_pct": data.get("max_drawdown_pct", 0),
"win_rate": data.get("win_rate", 0),
"profit_factor": data.get("profit_factor", 0),
"total_trades": data.get("total_trades", 0),
"n_bars": data.get("n_bars", 0),
"generated_at": data.get("generated_at", ""),
"has_equity_curve": bool(data.get("equity_curve")),
})
except (json.JSONDecodeError, IOError):
pass
if len(results) >= limit:
break
results.sort(key=lambda r: r.get("generated_at", ""), reverse=True)
return JSONResponse(results[:limit])
@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)
# 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)
@app.get("/api/vbt/run")
async def run_vbt_backtest(
strategy: str = "pairs",
interval: str = "1h",
limit: int = 500,
testnet: bool = False,
):
"""Run a new VectorBT backtest and return results."""
try:
from backtests.vbt_runner import VBTBacktestRunner
runner = VBTBacktestRunner()
from datetime import datetime
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
result = runner.run_strategy(
strategy=strategy, interval=interval, testnet=testnet, limit=limit
)
if result:
fname = f"{strategy}_vbt_{ts}.json"
fpath = os.path.join(BACKTEST_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 generated"}, status_code=500)
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@app.get("/api/vbt/sweep")
async def run_vbt_sweep(strategy: str = "pairs"):
"""Run parameter sweep and return heatmap data."""
try:
from backtests.vbt_runner import VBTBacktestRunner
runner = VBTBacktestRunner()
df = runner.param_sweep(strategy=strategy)
if df is not None and not df.empty:
rows = df.to_dict(orient="records")
return JSONResponse({
"strategy": strategy,
"results": rows,
"best": max(rows, key=lambda r: r.get("sharpe", -999)),
})
return JSONResponse({"error": "no sweep results"}, status_code=500)
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@app.get("/api/vbt/strategies")
async def list_vbt_strategies():
"""List available strategies for VBT backtesting."""
return JSONResponse([
{"key": "pairs", "name": "Pairs Trading", "coins": ["BTC", "ETH"]},
{"key": "hurst_vpin", "name": "Hurst VPIN", "coins": ["BTC"]},
{"key": "as_mm", "name": "Avellaneda-Stoikov MM", "coins": ["BTC"]},
{"key": "momentum", "name": "Momentum Breakout", "coins": ["BTC"]},
{"key": "mean_rev", "name": "Mean Reversion", "coins": ["BTC"]},
])
# ═══════════════════════════════════════════════════════════
# Static
# ═══════════════════════════════════════════════════════════
@@ -460,6 +587,11 @@ async def root():
return FileResponse(STATIC_DIR / "index.html")
@app.get("/vbt")
async def vbt_dashboard():
return FileResponse(STATIC_DIR / "vbt.html")
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")