From 887a33f2782371b11e632b45747a3603479c960d Mon Sep 17 00:00:00 2001 From: ramseshk <45832522+ramseshk@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:53:52 +0800 Subject: [PATCH] feat: trade log table, strategy params panel, B+W color scheme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboard: - Trade log table: all trades with time, side, size, entry/exit price, PnL, duration in scrollable panel below charts - Strategy params panel: displays all coefficients (z_entry, gamma, obi_entry, grid_levels, etc.) for the selected strategy - Color scheme: professional black/white • positive: #03A9F4 (light blue) • negative: #FF5252 (red) • neutral: #777 (gray) • backgrounds: #0a0a0a / #111 / #181818 • borders: #222 / #333 VBT runner: - _extract_metrics now captures trades from pf.trades.records_readable (Avg Entry Price, Avg Exit Price, PnL, Return, Duration, Direction) - _strategy_params() returns key coefficients per strategy type - _empty_result includes empty trades/params New vbt_server.py: minimal standalone dashboard (no live trading machinery, no memory guard, no broadcast loop) — avoids crashing issues --- backtests/vbt_runner.py | 38 ++++ dashboard/static/vbt.html | 369 ++++++++++++++++---------------------- dashboard/vbt_server.py | 204 +++++++++++++++++++++ 3 files changed, 399 insertions(+), 212 deletions(-) create mode 100644 dashboard/vbt_server.py diff --git a/backtests/vbt_runner.py b/backtests/vbt_runner.py index a9dee58..9a6a9b3 100644 --- a/backtests/vbt_runner.py +++ b/backtests/vbt_runner.py @@ -442,6 +442,24 @@ class VBTBacktestRunner: return coin_map.get(strategy, ["BTC"]) def _extract_metrics(self, pf, stats, strategy, interval, n_bars) -> dict: + # Extract trade records from VectorBT portfolio + trades = [] + try: + trade_records = pf.trades.records_readable + for _, t in trade_records.iterrows(): + trades.append({ + "time": str(t.get("Exit Timestamp", t.get("Entry Timestamp", "")))[:19], + "side": "BUY" if str(t.get("Direction", "")) == "Long" else "SELL", + "size": round(float(t.get("Size", 0)), 6), + "entry_px": round(float(t.get("Avg Entry Price", 0)), 2), + "exit_px": round(float(t.get("Avg Exit Price", 0)), 2), + "pnl": round(float(t.get("PnL", 0)), 4), + "return_pct": round(float(t.get("Return", 0)) * 100, 3), + "duration": str(t.get("Duration", "")), + }) + except Exception: + pass + return { "strategy": strategy, "interval": interval, @@ -456,6 +474,8 @@ class VBTBacktestRunner: "win_rate": round(float(stats.get("Win Rate [%]", 0)) / 100, 3), "profit_factor": round(float(stats.get("Profit Factor", 0)), 3), "expectancy": round(float(stats.get("Expectancy", 0)), 3), + "trades": trades, + "params": _strategy_params(strategy), } def _empty_result(self, strategy: str, interval: str) -> dict: @@ -472,10 +492,28 @@ class VBTBacktestRunner: "max_drawdown_pct": 0.0, "win_rate": 0.0, "total_trades": 0, + "trades": [], + "params": _strategy_params(strategy), "generated_at": datetime.now(timezone.utc).isoformat(), } +def _strategy_params(strategy: str) -> dict: + """Return the key parameters/coefficients for a strategy.""" + params = { + "pairs": {"z_entry": 1.5, "z_exit": 0.5, "lookback": 20, "type": "Stat Arb"}, + "hurst_vpin": {"hurst_entry": 0.55, "hurst_exit": 0.45, "vpin_threshold": 0.25, "vpin_window": 50, "hurst_window": 64, "type": "Directional"}, + "as_mm": {"gamma": 0.1, "sigma_dynamic": True, "inventory_skew": True, "type": "Market Making"}, + "obi": {"obi_lookback": 20, "obi_entry": 0.35, "obi_exit": 0.10, "type": "Reversal"}, + "grid_mm": {"grid_levels": 10, "grid_spacing_pct": 0.1, "rebalance_every": 20, "type": "Market Making"}, + "composite_mm": {"obi_weight": 0.30, "as_weight": 0.40, "hurst_weight": 0.30, "entry_score": 0.50, "type": "Ensemble"}, + "iceberg": {"vol_mult": 1.8, "min_consec": 3, "max_hold": 8, "type": "Momentum"}, + "momentum": {"bollinger_window": 20, "bollinger_std": 2.0, "type": "Momentum"}, + "mean_rev": {"vwap_window": 20, "deviation": 1.0, "type": "Reversal"}, + } + return params.get(strategy, {"type": "Unknown"}) + + def _generate_signals_sweep( strategy: str, data: dict[str, pd.DataFrame], diff --git a/dashboard/static/vbt.html b/dashboard/static/vbt.html index 2f5a76d..f7b0b25 100644 --- a/dashboard/static/vbt.html +++ b/dashboard/static/vbt.html @@ -3,53 +3,65 @@ -FTDT Quant Lab — VectorBT Dashboard +FTDT Quant Lab — VBT Dashboard @@ -81,218 +93,151 @@ select:focus{outline:none;border-color:#3b82f6}
- - - + + +
-
+
Loading...

SELECT A BACKTEST

-

Choose from sidebar or configure params and run a new test

+

Choose from sidebar or configure and run a new test

diff --git a/dashboard/vbt_server.py b/dashboard/vbt_server.py new file mode 100644 index 0000000..734da6a --- /dev/null +++ b/dashboard/vbt_server.py @@ -0,0 +1,204 @@ +""" +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")