Verbose dashboard with backtesting tab and per-strategy 100 USDC allocation

Dashboard overhaul:
- Tabbed interface: Live Trading | Backtesting
- Live tab shows: global stats (equity, reserve, trades, win rate, active
  strategies), equity curve, per-strategy cards with allocation and PnL,
  real-time trade log
- Backtest tab: lists saved backtests with Sharpe, PnL, max DD, win rate;
  click to view full equity curve and detailed metrics
- Reads real data from /tmp/ftdt-metrics.json written by live node

Live node update:
- 5 strategies each with 100 USDC allocation (398 USDC reserve)
- Writes real-time metrics to shared JSON file
- Runs signal generators for each strategy type
- Logs tick-by-tick status

Backtest runner:
- Simulates 30 days of hourly data per strategy
- Different return profiles for each strategy type
- Saves results to backtests/results/ as JSON
- Accessible via dashboard API and frontend

Backtest results (30-day sim):
  Avellaneda-Stoikov:    +3.72%  Sharpe 2.53  DD 5.12%
  Order Book Imbalance:  +3.83%  Sharpe 1.60  DD 9.86%
  Pairs Trading:         +0.54%  Sharpe 0.41  DD 7.83%
  Funding Rate Arb:      +0.17%  Sharpe 0.35  DD 2.94%
  Iceberg Detection:     -9.15%  Sharpe -4.39 DD 11.94%
This commit is contained in:
ramseshk
2026-08-04 03:12:21 +00:00
parent bbd309db6b
commit 7dd9e78e0b
9 changed files with 18969 additions and 697 deletions
+121 -147
View File
@@ -1,225 +1,199 @@
"""
Dashboard backend — WebSocket metrics server.
Collects strategy performance data in real time and streams
it to connected dashboard clients via WebSocket.
Reads live metrics from a shared JSON file (written by the live node)
and serves backtest results from disk. Streams everything to
connected dashboard clients via WebSocket.
Architecture:
- FastAPI serves the WebSocket endpoint at /ws
- A background thread collects metrics at 1-second intervals
- Connected clients receive JSON updates with PnL, positions,
and trade history for all strategies
- Serves static dashboard HTML at /
- /ws — WebSocket for real-time streaming
- /backtests — list available backtest results
- /backtest/{name} — serve specific backtest result
- / — static HTML dashboard
Usage:
python dashboard/server.py --port 9175
"""
import asyncio
import json
import os
import time
import threading
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, JSONResponse
import uvicorn
# ═══════════════════════════════════════════════════════════
# Constants
# ═══════════════════════════════════════════════════════════
# ═══════════════════════════════════════════════════════════════
# Data Models
# ═══════════════════════════════════════════════════════════════
METRICS_FILE = "/tmp/ftdt-metrics.json"
BACKTEST_DIR = "/home/debian/ftdt-quant-lab/backtests/results"
STATIC_DIR = Path(__file__).parent / "static"
@dataclass
class StrategyMetrics:
"""Real-time metrics for a single strategy."""
name: str
pnl: float = 0.0
pnl_pct: float = 0.0
position: float = 0.0
trades_today: int = 0
win_rate: float = 0.0
sharpe: float = 0.0
max_drawdown: float = 0.0
status: str = "idle" # idle, running, error
# Ensure backtest dir exists
os.makedirs(BACKTEST_DIR, exist_ok=True)
@dataclass
class DashboardState:
"""Complete dashboard state broadcast to clients."""
timestamp: float = 0.0
total_pnl: float = 0.0
total_equity: float = 10000.0
strategies: dict[str, StrategyMetrics] = field(default_factory=dict)
equity_history: list[dict] = field(default_factory=list)
trades: list[dict] = field(default_factory=list)
# ═══════════════════════════════════════════════════════════════
# Globals
# ═══════════════════════════════════════════════════════════════
# ═══════════════════════════════════════════════════════════
# App
# ═══════════════════════════════════════════════════════════
app = FastAPI(title="FTDT Quant Lab Dashboard")
state = DashboardState()
connected_clients: set[WebSocket] = set()
state_lock = threading.Lock()
# Initialize strategy placeholders
STRATEGY_NAMES = [
"Order Book Imbalance",
"Iceberg Detection",
"Funding Rate Arb",
"Pairs Trading",
"Avellaneda-Stoikov",
]
for name in STRATEGY_NAMES:
state.strategies[name] = StrategyMetrics(name=name)
loop: Optional[asyncio.AbstractEventLoop] = None
# ═══════════════════════════════════════════════════════════════
# Metrics collector (mock — replace with real NautilusTrader hooks)
# ═══════════════════════════════════════════════════════════════
# ═══════════════════════════════════════════════════════════
# Metrics reader
# ═══════════════════════════════════════════════════════════
def collect_metrics():
"""
Background thread that updates the dashboard state.
def read_metrics() -> dict:
"""Read the shared metrics file written by the live node."""
try:
if os.path.exists(METRICS_FILE):
with open(METRICS_FILE) as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
pass
return _empty_metrics()
In production, this would read from NautilusTrader's
portfolio and risk engine. For now, it generates demo
data so the dashboard shows something meaningful.
"""
import random
import math
t = 0
def _empty_metrics() -> dict:
return {
"timestamp": time.time(),
"wallet": "0x...",
"total_equity": 898.0,
"total_pnl": 0.0,
"total_pnl_pct": 0.0,
"equity_history": [],
"strategies": {},
"trades": [],
"status": "starting",
}
# ═══════════════════════════════════════════════════════════
# Background broadcaster
# ═══════════════════════════════════════════════════════════
async def broadcast_to_client(ws: WebSocket, payload: str):
try:
await ws.send_text(payload)
except Exception:
connected_clients.discard(ws)
def broadcast_loop():
"""Continuously read metrics and broadcast to all clients."""
while True:
time.sleep(1)
t += 1
data = read_metrics()
payload = json.dumps(data, default=str)
with state_lock:
state.timestamp = time.time()
# Simulate some PnL movement
for i, name in enumerate(STRATEGY_NAMES):
s = state.strategies[name]
# Each strategy has different behavior
if name == "Order Book Imbalance":
s.pnl += random.gauss(0.02, 0.5)
s.trades_today = int(t / 30)
s.win_rate = 0.52 + random.gauss(0, 0.02)
elif name == "Iceberg Detection":
s.pnl += random.gauss(0.01, 0.3)
s.trades_today = int(t / 60)
s.win_rate = 0.48 + random.gauss(0, 0.03)
elif name == "Funding Rate Arb":
s.pnl += 0.001 # Steady carry
s.trades_today = 1
s.win_rate = 0.99
elif name == "Pairs Trading":
s.pnl += random.gauss(0.0, 0.4)
s.trades_today = int(t / 45)
s.win_rate = 0.55 + random.gauss(0, 0.02)
elif name == "Avellaneda-Stoikov":
s.pnl += random.gauss(0.03, 0.2)
s.trades_today = int(t / 10)
s.win_rate = 0.60 + random.gauss(0, 0.01)
s.pnl_pct = (s.pnl / state.total_equity) * 100
s.position = s.pnl * random.uniform(0.1, 0.5)
s.sharpe = 0.5 + random.gauss(0, 0.1)
s.max_drawdown = abs(s.pnl) * 0.3 if s.pnl < 0 else 0.0
s.status = "running"
state.total_pnl = sum(s.pnl for s in state.strategies.values())
# Keep equity history (last 200 points)
state.equity_history.append({
"t": state.timestamp,
"v": state.total_equity + state.total_pnl,
})
if len(state.equity_history) > 200:
state.equity_history = state.equity_history[-200:]
# Add trade if significant PnL move
if abs(state.total_pnl) % 0.5 < 0.01 and len(state.trades) < 50:
state.trades.append({
"time": time.strftime("%H:%M:%S"),
"strategy": random.choice(STRATEGY_NAMES),
"side": random.choice(["BUY", "SELL"]),
"size": round(random.uniform(0.001, 0.01), 4),
"pnl": round(random.gauss(0.1, 0.5), 4),
})
# Broadcast to all connected clients
payload = json.dumps(asdict(state), default=str)
# We need to run this in the event loop
for ws in list(connected_clients):
try:
if loop:
asyncio.run_coroutine_threadsafe(
ws.send_text(payload), loop
broadcast_to_client(ws, payload), loop
)
except Exception:
connected_clients.discard(ws)
# ═══════════════════════════════════════════════════════════════
# WebSocket endpoint
# ═══════════════════════════════════════════════════════════════
# ═══════════════════════════════════════════════════════════
# WebSocket
# ═══════════════════════════════════════════════════════════
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
connected_clients.add(websocket)
try:
# Send initial state immediately
data = read_metrics()
await websocket.send_text(json.dumps(data, default=str))
while True:
# Keep alive — actual data is pushed by the collector thread
await asyncio.sleep(30)
except WebSocketDisconnect:
connected_clients.discard(websocket)
# ═══════════════════════════════════════════════════════════════
# Static files
# ═══════════════════════════════════════════════════════════════
# ═══════════════════════════════════════════════════════════
# Backtest endpoints
# ═══════════════════════════════════════════════════════════
STATIC_DIR = Path(__file__).parent / "static"
@app.get("/api/backtests")
async def list_backtests():
"""List all saved backtest results."""
results = []
if os.path.isdir(BACKTEST_DIR):
for fname in sorted(os.listdir(BACKTEST_DIR), reverse=True):
if fname.endswith(".json"):
fpath = os.path.join(BACKTEST_DIR, fname)
try:
with open(fpath) as f:
data = json.load(f)
results.append({
"name": fname.replace(".json", ""),
"strategy": data.get("strategy", "unknown"),
"start": data.get("start_time"),
"end": data.get("end_time"),
"sharpe": data.get("sharpe", 0),
"pnl_pct": data.get("pnl_pct", 0),
"max_dd": data.get("max_dd", 0),
"win_rate": data.get("win_rate", 0),
})
except (json.JSONDecodeError, IOError):
pass
return JSONResponse(results)
@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")
if os.path.exists(fpath):
with open(fpath) as f:
return JSONResponse(json.load(f))
return JSONResponse({"error": "not found"}, status_code=404)
# ═══════════════════════════════════════════════════════════
# Static
# ═══════════════════════════════════════════════════════════
@app.get("/")
async def root():
return FileResponse(STATIC_DIR / "index.html")
# ═══════════════════════════════════════════════════════════════
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
# ═══════════════════════════════════════════════════════════
# Main
# ═══════════════════════════════════════════════════════════════
loop: asyncio.AbstractEventLoop = None
# ═══════════════════════════════════════════════════════════
def main():
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, default=9175)
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--host", default="0.0.0.0")
args = parser.parse_args()
global loop
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
# Start metrics collector in background
collector = threading.Thread(target=collect_metrics, daemon=True)
collector.start()
# Mount static files
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
# Start metrics broadcaster
broadcaster = threading.Thread(target=broadcast_loop, daemon=True)
broadcaster.start()
print(f"FTDT Quant Lab Dashboard")
print(f" http://{args.host}:{args.port}")
print(f" WebSocket: ws://{args.host}:{args.port}/ws")
print(f" Backtests: /api/backtests")
uvicorn.run(app, host=args.host, port=args.port, log_level="warning")
+290 -471
View File
@@ -4,321 +4,110 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>FTDT Quant Lab</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<style>
:root {
--bg: #0a0a0c;
--surface: #131316;
--border: #1e1e24;
--text: #a1a1aa;
--text-bright: #e4e4e7;
--green: #22c55e;
--red: #ef4444;
--blue: #3b82f6;
--amber: #f59e0b;
--purple: #a855f7;
--radius: 8px;
--font: 'Inter', system-ui, sans-serif;
--bg: #0a0a0c; --surface: #131316; --border: #1e1e24;
--text: #a1a1aa; --text-bright: #e4e4e7;
--green: #22c55e; --red: #ef4444; --blue: #3b82f6;
--amber: #f59e0b; --purple: #a855f7;
--radius: 8px; --font: 'Inter', system-ui, sans-serif;
--mono: 'JetBrains Mono', monospace;
}
* { margin:0; padding:0; box-sizing:border-box; }
body { background:var(--bg); color:var(--text); font-family:var(--font); min-height:100vh; line-height:1.5; -webkit-font-smoothing:antialiased; }
.container { max-width:1200px; margin:0 auto; padding:16px 12px; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--font);
min-height: 100vh;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
/* Tabs */
.tabs { display:flex; gap:0; margin-bottom:16px; border-bottom:1px solid var(--border); }
.tab-btn {
background:none; border:none; color:var(--text); font-family:var(--font);
font-size:13px; font-weight:500; padding:8px 16px; cursor:pointer;
border-bottom:2px solid transparent; transition:all 0.15s;
}
.tab-btn:hover { color:var(--text-bright); }
.tab-btn.active { color:var(--text-bright); border-bottom-color:var(--blue); }
.container {
max-width: 1200px;
margin: 0 auto;
padding: 16px 12px;
}
/* ── Header ─────────────────────── */
header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
padding-bottom: 14px;
border-bottom: 1px solid var(--border);
gap: 12px;
}
.brand h1 {
font-size: 18px;
font-weight: 600;
color: var(--text-bright);
letter-spacing: -0.3px;
}
.brand .sub {
font-size: 11px;
color: var(--text);
display: flex;
align-items: center;
gap: 4px;
margin-top: 3px;
flex-wrap: wrap;
}
.summary {
text-align: right;
flex-shrink: 0;
}
.summary .pnl {
font-family: var(--mono);
font-size: 26px;
font-weight: 600;
letter-spacing: -0.5px;
line-height: 1.1;
}
.summary .pnl.positive { color: var(--green); }
.summary .pnl.negative { color: var(--red); }
.summary .label {
font-size: 10px;
color: var(--text);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-dot {
display: inline-block;
width: 6px; height: 6px;
border-radius: 50%;
margin-right: 4px;
flex-shrink: 0;
}
.status-dot.live { background: var(--green); animation: pulse 2s infinite; }
.status-dot.idle { background: var(--amber); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* ── Equity Chart ───────────────── */
.chart-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px;
margin-bottom: 14px;
}
.chart-card h2 {
font-size: 12px;
font-weight: 500;
color: var(--text-bright);
margin-bottom: 8px;
}
.chart-wrap {
position: relative;
width: 100%;
height: 180px;
}
/* ── Strategy Grid ──────────────── */
.strategy-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px;
margin-bottom: 14px;
}
.strategy-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 14px;
transition: border-color 0.2s;
min-width: 0;
}
.strategy-card:hover { border-color: #2a2a32; }
.strategy-card .name {
font-size: 11px;
font-weight: 500;
color: var(--text-bright);
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.strategy-card .pnl {
font-family: var(--mono);
font-size: 16px;
font-weight: 600;
margin-bottom: 3px;
}
.strategy-card .pnl.pos { color: var(--green); }
.strategy-card .pnl.neg { color: var(--red); }
.strategy-card .meta {
font-size: 10px;
color: var(--text);
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.strategy-card .meta .val {
color: var(--text-bright);
font-family: var(--mono);
font-size: 10px;
}
/* ── Trade Log ──────────────────── */
.trade-log {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.trade-log h2 {
font-size: 12px;
font-weight: 500;
color: var(--text-bright);
padding: 12px 14px;
border-bottom: 1px solid var(--border);
}
.trade-scroll {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.trade-table {
width: 100%;
border-collapse: collapse;
min-width: 400px;
}
.trade-table th {
font-size: 9px;
font-weight: 500;
color: var(--text);
text-transform: uppercase;
letter-spacing: 0.4px;
text-align: left;
padding: 7px 12px;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.trade-table td {
font-family: var(--mono);
font-size: 11px;
padding: 5px 12px;
border-bottom: 1px solid rgba(255,255,255,0.02);
white-space: nowrap;
}
.trade-table td.pos { color: var(--green); }
.trade-table td.neg { color: var(--red); }
.trade-table td.buy { color: var(--green); }
.trade-table td.sell { color: var(--red); }
/* ── Footer ─────────────────────── */
footer {
text-align: center;
padding: 16px;
font-size: 10px;
color: #3f3f46;
}
footer a { color: #52525b; text-decoration: none; }
footer a:hover { color: var(--text); }
/* ── Mobile: < 480px ────────────── */
@media (max-width: 480px) {
.container { padding: 10px 8px; }
header {
flex-direction: column;
gap: 8px;
}
.summary {
text-align: left;
width: 100%;
}
.summary .pnl { font-size: 22px; }
.brand h1 { font-size: 16px; }
.strategy-grid {
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.strategy-card {
padding: 10px;
}
.strategy-card .pnl { font-size: 14px; }
.strategy-card .meta { gap: 4px; }
.strategy-card .meta .val { font-size: 9px; }
.chart-wrap { height: 140px; }
.trade-table th,
.trade-table td { padding: 5px 8px; font-size: 10px; }
/* Hide less critical columns on very small screens */
.trade-table th:nth-child(3),
.trade-table td:nth-child(3) { display: none; }
footer { font-size: 9px; padding: 10px; }
}
/* ── Small phone: < 360px ───────── */
@media (max-width: 360px) {
.strategy-grid {
grid-template-columns: 1fr;
}
.chart-wrap { height: 120px; }
.trade-table th:nth-child(4),
.trade-table td:nth-child(4) { display: none; }
}
/* ── Tablet: 481-768px ──────────── */
@media (min-width: 481px) and (max-width: 768px) {
.container { padding: 18px 14px; }
.strategy-grid {
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
}
}
/* ── Desktop: 769px+ ────────────── */
@media (min-width: 769px) {
.container { padding: 24px 20px; }
.strategy-grid {
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
}
.chart-wrap { height: 200px; }
/* Header */
header { display:flex; justify-content:space-between; align-items:flex-start; margin-bottom:16px; padding-bottom:14px; border-bottom:1px solid var(--border); gap:12px; }
.brand h1 { font-size:18px; font-weight:600; color:var(--text-bright); letter-spacing:-0.3px; }
.brand .sub { font-size:11px; color:var(--text); display:flex; align-items:center; gap:4px; margin-top:3px; flex-wrap:wrap; }
.summary { text-align:right; flex-shrink:0; }
.summary .big-pnl { font-family:var(--mono); font-size:28px; font-weight:700; letter-spacing:-0.5px; line-height:1.1; }
.summary .big-pnl.pos { color:var(--green); } .summary .big-pnl.neg { color:var(--red); }
.summary .label { font-size:10px; color:var(--text); text-transform:uppercase; letter-spacing:0.5px; }
.status-dot { display:inline-block; width:6px; height:6px; border-radius:50%; margin-right:4px; flex-shrink:0; }
.status-dot.live { background:var(--green); animation:pulse 2s infinite; }
.status-dot.idle { background:var(--amber); }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
/* Cards */
.chart-card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:12px; margin-bottom:12px; }
.chart-card h2 { font-size:12px; font-weight:500; color:var(--text-bright); margin-bottom:8px; display:flex; justify-content:space-between; }
.chart-wrap { position:relative; width:100%; height:200px; }
/* Stats row */
.stats-row { display:grid; grid-template-columns:repeat(5,1fr); gap:8px; margin-bottom:12px; }
.stat-card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:10px 12px; }
.stat-card .name { font-size:10px; color:var(--text); text-transform:uppercase; letter-spacing:0.3px; }
.stat-card .val { font-family:var(--mono); font-size:16px; font-weight:600; color:var(--text-bright); margin-top:2px; }
.stat-card .val.pos { color:var(--green); } .stat-card .val.neg { color:var(--red); }
/* Strategy grid */
.strategy-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(220px,1fr)); gap:10px; margin-bottom:12px; }
.strategy-card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:14px; transition:border-color 0.2s; min-width:0; }
.strategy-card:hover { border-color:#2a2a32; }
.strategy-card .name { font-size:12px; font-weight:500; color:var(--text-bright); margin-bottom:6px; display:flex; align-items:center; gap:5px; }
.strategy-card .alloc { font-size:10px; color:var(--text); margin-bottom:8px; }
.strategy-card .pnl { font-family:var(--mono); font-size:18px; font-weight:600; margin-bottom:4px; }
.strategy-card .pnl.pos { color:var(--green); } .strategy-card .pnl.neg { color:var(--red); }
.strategy-card .meta { font-size:10px; color:var(--text); display:flex; flex-wrap:wrap; gap:8px; }
.strategy-card .meta .val { color:var(--text-bright); font-family:var(--mono); font-size:10px; }
/* Trade log */
.trade-log { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); overflow:hidden; }
.trade-log h2 { font-size:12px; font-weight:500; color:var(--text-bright); padding:12px 14px; border-bottom:1px solid var(--border); }
.trade-scroll { overflow-x:auto; -webkit-overflow-scrolling:touch; }
.trade-table { width:100%; border-collapse:collapse; min-width:500px; }
.trade-table th { font-size:9px; font-weight:500; color:var(--text); text-transform:uppercase; letter-spacing:0.4px; text-align:left; padding:7px 10px; border-bottom:1px solid var(--border); white-space:nowrap; }
.trade-table td { font-family:var(--mono); font-size:11px; padding:5px 10px; border-bottom:1px solid rgba(255,255,255,0.02); white-space:nowrap; }
.trade-table td.pos { color:var(--green); } .trade-table td.neg { color:var(--red); } .trade-table td.buy { color:var(--green); } .trade-table td.sell { color:var(--red); }
/* Backtest list */
.bt-list { display:flex; flex-direction:column; gap:6px; }
.bt-item { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:12px 14px; cursor:pointer; transition:border-color 0.15s; display:flex; justify-content:space-between; align-items:center; gap:12px; }
.bt-item:hover { border-color:#2a2a32; }
.bt-item.selected { border-color:var(--blue); }
.bt-item .bt-name { font-size:13px; font-weight:500; color:var(--text-bright); }
.bt-item .bt-meta { font-size:10px; color:var(--text); }
.bt-item .bt-stats { display:flex; gap:16px; flex-shrink:0; }
.bt-item .bt-stat { text-align:right; }
.bt-item .bt-stat .lbl { font-size:9px; color:var(--text); text-transform:uppercase; }
.bt-item .bt-stat .v { font-family:var(--mono); font-size:12px; font-weight:500; color:var(--text-bright); }
.bt-item .bt-stat .v.g { color:var(--green); } .bt-item .bt-stat .v.r { color:var(--red); }
.bt-detail { margin-top:12px; display:none; }
.bt-detail.active { display:block; }
footer { text-align:center; padding:16px; font-size:10px; color:#3f3f46; }
footer a { color:#52525b; text-decoration:none; } footer a:hover { color:var(--text); }
/* Mobile */
@media (max-width:480px) {
.container { padding:10px 8px; }
header { flex-direction:column; gap:8px; }
.summary { text-align:left; width:100%; }
.summary .big-pnl { font-size:22px; }
.stats-row { grid-template-columns:repeat(3,1fr); }
.strategy-grid { grid-template-columns:1fr 1fr; gap:6px; }
.strategy-card { padding:10px; }
.strategy-card .pnl { font-size:14px; }
.chart-wrap { height:140px; }
.bt-item { flex-direction:column; align-items:flex-start; }
.bt-item .bt-stats { width:100%; justify-content:space-between; }
}
@media (max-width:360px) { .strategy-grid { grid-template-columns:1fr; } .stats-row { grid-template-columns:repeat(2,1fr); } }
</style>
</head>
<body>
@@ -329,225 +118,255 @@
<div class="brand">
<h1>FTDT Quant Lab</h1>
<div class="sub">
<span class="status-dot live"></span>
Hyperliquid Testnet &middot;
<span class="status-dot live" id="status-dot"></span>
<span id="connection-status">connecting...</span>
&middot; <span id="wallet-display"></span>
</div>
</div>
<div class="summary">
<div class="label">Total PnL</div>
<div class="pnl" id="total-pnl">$0.00</div>
<div class="big-pnl" id="total-pnl">$0.00</div>
<div class="label" id="total-pnl-pct" style="margin-top:2px;">0.00%</div>
</div>
</header>
<!-- Equity Curve -->
<div class="chart-card">
<h2>Equity Curve</h2>
<div class="chart-wrap">
<canvas id="equity-chart"></canvas>
<!-- Tabs -->
<div class="tabs">
<button class="tab-btn active" onclick="switchTab('live')">Live Trading</button>
<button class="tab-btn" onclick="switchTab('backtest')">Backtesting</button>
</div>
<!-- LIVE TAB -->
<div id="tab-live">
<!-- Global Stats -->
<div class="stats-row" id="stats-row"></div>
<!-- Equity Chart -->
<div class="chart-card">
<h2>Equity Curve <span style="font-weight:400;color:var(--text);">— all strategies combined</span></h2>
<div class="chart-wrap"><canvas id="equity-chart"></canvas></div>
</div>
<!-- Strategy Cards -->
<div class="strategy-grid" id="strategy-grid"></div>
<!-- Trade Log -->
<div class="trade-log">
<h2>Recent Trades</h2>
<div class="trade-scroll">
<table class="trade-table">
<thead><tr><th>Time</th><th>Strategy</th><th>Side</th><th>Size</th><th>Price</th><th>PnL</th></tr></thead>
<tbody id="trade-body"></tbody>
</table>
</div>
</div>
</div>
<!-- Strategy Cards -->
<div class="strategy-grid" id="strategy-grid"></div>
<!-- Trade Log -->
<div class="trade-log">
<h2>Recent Trades</h2>
<div class="trade-scroll">
<table class="trade-table">
<thead>
<tr>
<th>Time</th>
<th>Strategy</th>
<th>Side</th>
<th>Size</th>
<th>PnL</th>
</tr>
</thead>
<tbody id="trade-body"></tbody>
</table>
<!-- BACKTEST TAB -->
<div id="tab-backtest" style="display:none;">
<div class="chart-card" id="bt-detail-card" style="display:none;">
<h2>Backtest: <span id="bt-title"></span></h2>
<div class="stats-row" id="bt-stats-row"></div>
<div class="chart-wrap"><canvas id="bt-chart"></canvas></div>
</div>
<div class="chart-card">
<h2>Saved Backtests <span style="font-weight:400;color:var(--text);">— click to view details</span></h2>
<div class="bt-list" id="bt-list"></div>
</div>
</div>
<footer>
<a href="https://git.ftdt.io/rams/ftdt-quant-lab" target="_blank">rams/ftdt-quant-lab</a>
&middot; Part of my quant trading portfolio
&middot; 5 strategies &middot; 100 USDC each &middot; Hyperliquid Testnet
</footer>
</div>
<script>
// ═══════════════════════════════════════════════════════════
// Chart setup
// State
// ═══════════════════════════════════════════════════════════
let currentTab = 'live';
let liveData = null;
let backtests = [];
let selectedBacktest = null;
const ctx = document.getElementById('equity-chart').getContext('2d');
const equityChart = new Chart(ctx, {
type: 'line',
data: {
labels: [],
datasets: [{
label: 'Equity',
data: [],
borderColor: '#3b82f6',
backgroundColor: 'rgba(59,130,246,0.08)',
borderWidth: 1.5,
fill: true,
pointRadius: 0,
tension: 0.3,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
interaction: { intersect: false, mode: 'index' },
plugins: { legend: { display: false } },
scales: {
x: {
display: true,
grid: { color: 'rgba(255,255,255,0.03)' },
ticks: { color: '#52525b', font: { size: 10 }, maxTicksLimit: 6 }
},
y: {
display: true,
grid: { color: 'rgba(255,255,255,0.03)' },
ticks: {
color: '#52525b',
font: { size: 10 },
maxTicksLimit: 5,
callback: function(v) { return '$' + v.toLocaleString(); }
}
}
}
// ═══════════════════════════════════════════════════════════
// Charts
// ═══════════════════════════════════════════════════════════
const equityChart = new Chart(document.getElementById('equity-chart').getContext('2d'), {
type: 'line', data: {labels:[],datasets:[{label:'Equity',data:[],borderColor:'#3b82f6',backgroundColor:'rgba(59,130,246,0.08)',borderWidth:1.5,fill:true,pointRadius:0,tension:0.3}]},
options: {responsive:true,maintainAspectRatio:false,animation:false,plugins:{legend:{display:false}},scales:{x:{grid:{color:'rgba(255,255,255,0.03)'},ticks:{color:'#52525b',font:{size:10},maxTicksLimit:6}},y:{grid:{color:'rgba(255,255,255,0.03)'},ticks:{color:'#52525b',font:{size:10},maxTicksLimit:5,callback:function(v){return'$'+v.toLocaleString()}}}}}
});
const btChart = new Chart(document.getElementById('bt-chart').getContext('2d'), {
type: 'line', data: {labels:[],datasets:[{label:'Equity',data:[],borderColor:'#a855f7',backgroundColor:'rgba(168,85,247,0.08)',borderWidth:2,fill:true,pointRadius:0,tension:0.3}]},
options: {responsive:true,maintainAspectRatio:false,animation:false,plugins:{legend:{display:false}},scales:{x:{grid:{color:'rgba(255,255,255,0.03)'},ticks:{color:'#52525b',font:{size:10},maxTicksLimit:6}},y:{grid:{color:'rgba(255,255,255,0.03)'},ticks:{color:'#52525b',font:{size:10},maxTicksLimit:5,callback:function(v){return'$'+v.toFixed(1)}}}}
}
});
// ═══════════════════════════════════════════════════════════
// WebSocket
// Tab switching
// ═══════════════════════════════════════════════════════════
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = protocol + '//' + location.host + '/ws';
let ws;
let reconnectTimer;
function connect() {
if (ws) { ws.close(); }
ws = new WebSocket(wsUrl);
ws.onopen = function() {
var statusEl = document.getElementById('connection-status');
statusEl.innerHTML = '<span style="color:#22c55e">live</span>';
var dot = statusEl.parentElement.querySelector('.status-dot');
if (dot) dot.className = 'status-dot live';
console.log('WS connected');
};
ws.onclose = function() {
var statusEl = document.getElementById('connection-status');
statusEl.innerHTML = '<span style="color:#f59e0b">reconnecting...</span>';
clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(connect, 2000);
};
ws.onerror = function() {
console.log('WS error');
};
ws.onmessage = function(event) {
try {
var data = JSON.parse(event.data);
updateDashboard(data);
} catch(e) {
console.log('Parse error:', e);
}
};
function switchTab(tab) {
currentTab = tab;
document.querySelectorAll('.tab-btn').forEach(function(b){b.classList.remove('active');});
document.getElementById('tab-live').style.display = tab === 'live' ? 'block' : 'none';
document.getElementById('tab-backtest').style.display = tab === 'backtest' ? 'block' : 'none';
if (tab === 'live') document.querySelectorAll('.tab-btn')[0].classList.add('active');
if (tab === 'backtest') { document.querySelectorAll('.tab-btn')[1].classList.add('active'); loadBacktests(); }
}
// ═══════════════════════════════════════════════════════════
// Dashboard update
// WebSocket
// ═══════════════════════════════════════════════════════════
let ws;
function connectWS() {
ws = new WebSocket((location.protocol==='https:'?'wss:':'ws:')+'//'+location.host+'/ws');
ws.onopen = function() {
document.getElementById('connection-status').innerHTML = '<span style="color:#22c55e">live</span>';
document.getElementById('status-dot').className = 'status-dot live';
};
ws.onclose = function() { document.getElementById('connection-status').innerHTML = '<span style="color:#f59e0b">reconnecting...</span>'; setTimeout(connectWS,2000); };
ws.onmessage = function(e) { try { liveData = JSON.parse(e.data); if(currentTab==='live') renderLive(); } catch(ex){} };
}
function updateDashboard(data) {
// Total PnL
var pnl = data.total_pnl || 0;
var pnlEl = document.getElementById('total-pnl');
var prefix = pnl >= 0 ? '+' : '';
pnlEl.textContent = prefix + '$' + Math.abs(pnl).toFixed(2);
pnlEl.className = 'pnl ' + (pnl >= 0 ? 'positive' : 'negative');
// ═══════════════════════════════════════════════════════════
// Render Live
// ═══════════════════════════════════════════════════════════
function renderLive() {
var d = liveData;
if (!d) return;
var equity = data.total_equity || 10000;
var pnlPct = (pnl / equity * 100);
var pctEl = document.getElementById('total-pnl-pct');
pctEl.textContent = (pnlPct >= 0 ? '+' : '') + pnlPct.toFixed(2) + '%';
pctEl.style.color = pnl >= 0 ? '#22c55e' : '#ef4444';
// Header
var pnl = d.total_pnl || 0;
var el = document.getElementById('total-pnl');
el.textContent = (pnl>=0?'+':'') + '$' + Math.abs(pnl).toFixed(2);
el.className = 'big-pnl ' + (pnl>=0?'pos':'neg');
document.getElementById('total-pnl-pct').textContent = ((d.total_pnl_pct||0)>=0?'+':'') + (d.total_pnl_pct||0).toFixed(2) + '%';
document.getElementById('wallet-display').textContent = (d.wallet||'').slice(0,10)+'...';
// Stats row
var strats = d.strategies || {};
var allTrades = 0; var avgWin = 0; var running = 0;
var keys = Object.keys(strats);
for (var i=0;i<keys.length;i++) {
var s = strats[keys[i]];
allTrades += s.trades_today || 0;
avgWin += s.win_rate || 0;
if (s.status==='running') running++;
}
avgWin = keys.length>0 ? Math.round(avgWin/keys.length*100) : 0;
document.getElementById('stats-row').innerHTML =
'<div class="stat-card"><div class="name">Equity</div><div class="val">$' + ((d.base_equity||0)+(pnl)).toFixed(0) + '</div></div>' +
'<div class="stat-card"><div class="name">Reserve</div><div class="val">$' + (d.reserve||0).toFixed(0) + '</div></div>' +
'<div class="stat-card"><div class="name">Trades</div><div class="val">' + allTrades + '</div></div>' +
'<div class="stat-card"><div class="name">Avg Win Rate</div><div class="val">' + avgWin + '%</div></div>' +
'<div class="stat-card"><div class="name">Active</div><div class="val">' + running + '/' + keys.length + '</div></div>';
// Strategy cards
var grid = document.getElementById('strategy-grid');
var strategies = data.strategies || {};
var cardsHtml = '';
var keys = Object.keys(strategies);
for (var i = 0; i < keys.length; i++) {
var name = keys[i];
var s = strategies[name];
var spnl = s.pnl || 0;
var pClass = spnl >= 0 ? 'pos' : 'neg';
var pStr = (spnl >= 0 ? '+' : '') + '$' + Math.abs(spnl).toFixed(2);
var dotClass = s.status === 'running' ? 'live' : 'idle';
cardsHtml += '<div class="strategy-card">' +
'<div class="name"><span class="status-dot ' + dotClass + '"></span>' + name + '</div>' +
'<div class="pnl ' + pClass + '">' + pStr + '</div>' +
var html = '';
for (var j=0;j<keys.length;j++) {
var name = keys[j], s = strats[name];
var spnl = s.pnl||0, pClass = spnl>=0?'pos':'neg';
var pStr = (spnl>=0?'+':'') + '$' + Math.abs(spnl).toFixed(2);
var dot = s.status==='running'?'live':'idle';
var pct = (s.pnl_pct||0)>=0?'+':'';
html += '<div class="strategy-card">' +
'<div class="name"><span class="status-dot '+dot+'"></span>'+name+'</div>' +
'<div class="alloc">Allocation: '+(s.allocation||100)+' USDC | PnL: '+pct+(s.pnl_pct||0).toFixed(2)+'%</div>' +
'<div class="pnl '+pClass+'">'+pStr+'</div>' +
'<div class="meta">' +
'<span>Trades: <span class="val">' + (s.trades_today || 0) + '</span></span>' +
'<span>Win: <span class="val">' + Math.round((s.win_rate || 0) * 100) + '%</span></span>' +
'<span>DD: <span class="val">' + ((s.max_drawdown || 0) * 100).toFixed(1) + '%</span></span>' +
'<span>Trades: <span class="val">'+(s.trades_today||0)+'</span></span>' +
'<span>Win: <span class="val">'+Math.round((s.win_rate||0)*100)+'%</span></span>' +
'<span>Pos: <span class="val">'+(s.position||0).toFixed(4)+'</span></span>' +
'</div>' +
'</div>';
}
grid.innerHTML = cardsHtml;
grid.innerHTML = html;
// Equity chart
var history = data.equity_history || [];
if (history.length > 0) {
var labels = [];
var values = [];
for (var j = 0; j < history.length; j++) {
var ts = history[j].t * 1000;
labels.push(new Date(ts).toLocaleTimeString('en-US', { hour12: false }));
values.push(history[j].v);
}
var hist = d.equity_history || [];
if (hist.length > 0) {
var labels=[]; var values=[];
for (var k=0;k<hist.length;k++) { labels.push(new Date(hist[k].t*1000).toLocaleTimeString('en-US',{hour12:false})); values.push(hist[k].v); }
equityChart.data.labels = labels;
equityChart.data.datasets[0].data = values;
equityChart.update('none');
}
// Trade log
var trades = (data.trades || []).slice(-15).reverse();
var tbody = document.getElementById('trade-body');
var rowsHtml = '';
for (var k = 0; k < trades.length; k++) {
var t = trades[k];
var tpnlClass = (t.pnl || 0) >= 0 ? 'pos' : 'neg';
var tpnlStr = ((t.pnl || 0) >= 0 ? '+' : '') + '$' + Math.abs(t.pnl || 0).toFixed(4);
var sideClass = t.side === 'BUY' ? 'buy' : 'sell';
rowsHtml += '<tr>' +
'<td>' + (t.time || '') + '</td>' +
'<td>' + (t.strategy || '') + '</td>' +
'<td class="' + sideClass + '">' + (t.side || '') + '</td>' +
'<td>' + (t.size || '') + '</td>' +
'<td class="' + tpnlClass + '">' + tpnlStr + '</td>' +
'</tr>';
var trades = (d.trades||[]).slice(-15).reverse();
var rows = '';
for (var m=0;m<trades.length;m++) {
var t = trades[m], tpClass = (t.pnl||0)>=0?'pos':'neg';
var tpStr = ((t.pnl||0)>=0?'+':'') + '$' + Math.abs(t.pnl||0).toFixed(4);
var sideClass = t.side==='BUY'?'buy':'sell';
rows += '<tr><td>'+t.time+'</td><td>'+t.strategy+'</td><td class="'+sideClass+'">'+t.side+'</td><td>'+t.size+'</td><td>'+(t.price||'—')+'</td><td class="'+tpClass+'">'+tpStr+'</td></tr>';
}
tbody.innerHTML = rowsHtml;
document.getElementById('trade-body').innerHTML = rows;
}
// ═══════════════════════════════════════════════════════════
// Backtests
// ═══════════════════════════════════════════════════════════
function loadBacktests() {
fetch('/api/backtests').then(function(r){return r.json();}).then(function(data){
backtests = data;
var list = document.getElementById('bt-list');
var html = '';
for (var i=0;i<data.length;i++) {
var bt = data[i];
var pClass = bt.pnl_pct>=0?'g':'r';
html += '<div class="bt-item" onclick="viewBacktest(\''+bt.name+'\')" id="bt-item-'+bt.name+'">' +
'<div><div class="bt-name">'+bt.strategy+'</div><div class="bt-meta">'+bt.name+'</div></div>' +
'<div class="bt-stats">' +
'<div class="bt-stat"><div class="lbl">PnL</div><div class="v '+pClass+'">'+(bt.pnl_pct>=0?'+':'')+bt.pnl_pct.toFixed(2)+'%</div></div>' +
'<div class="bt-stat"><div class="lbl">Sharpe</div><div class="v">'+bt.sharpe.toFixed(2)+'</div></div>' +
'<div class="bt-stat"><div class="lbl">Max DD</div><div class="v r">'+bt.max_dd.toFixed(2)+'%</div></div>' +
'<div class="bt-stat"><div class="lbl">Win Rate</div><div class="v">'+(bt.win_rate*100).toFixed(0)+'%</div></div>' +
'</div>';
}
list.innerHTML = html || '<div style="color:var(--text);padding:12px;font-size:12px;">No backtests yet. Run: python backtests/run.py --strategy all</div>';
});
}
function viewBacktest(name) {
fetch('/api/backtest/'+name).then(function(r){return r.json();}).then(function(bt){
selectedBacktest = bt;
document.getElementById('bt-detail-card').style.display = 'block';
document.getElementById('bt-title').textContent = bt.strategy + ' — ' + bt.description;
document.querySelectorAll('.bt-item').forEach(function(el){el.classList.remove('selected');});
document.getElementById('bt-item-'+name).classList.add('selected');
document.getElementById('bt-stats-row').innerHTML =
'<div class="stat-card"><div class="name">PnL</div><div class="val '+(bt.pnl>=0?'pos':'neg')+'">'+(bt.pnl>=0?'+':'')+bt.pnl.toFixed(2)+'%</div></div>' +
'<div class="stat-card"><div class="name">Annual Return</div><div class="val '+(bt.ann_return_pct>=0?'pos':'neg')+'">'+(bt.ann_return_pct>=0?'+':'')+bt.ann_return_pct.toFixed(1)+'%</div></div>' +
'<div class="stat-card"><div class="name">Sharpe</div><div class="val">'+bt.sharpe.toFixed(2)+'</div></div>' +
'<div class="stat-card"><div class="name">Sortino</div><div class="val">'+bt.sortino.toFixed(2)+'</div></div>' +
'<div class="stat-card"><div class="name">Max DD</div><div class="val neg">'+bt.max_dd_pct.toFixed(2)+'%</div></div>' +
'<div class="stat-card"><div class="name">Win Rate</div><div class="val">'+(bt.win_rate*100).toFixed(0)+'%</div></div>' +
'<div class="stat-card"><div class="name">Trades</div><div class="val">'+bt.total_trades+'</div></div>' +
'<div class="stat-card"><div class="name">Allocation</div><div class="val">$'+bt.allocation+'</div></div>' +
'<div class="stat-card"><div class="name">End Equity</div><div class="val">$'+bt.end_equity.toFixed(2)+'</div></div>' +
'<div class="stat-card"><div class="name">Period</div><div class="val" style="font-size:13px;">30d</div></div>';
var labels=[],values=[];
var curve = bt.equity_curve || [];
for (var i=0;i<curve.length;i++) { labels.push(new Date(curve[i].t).toLocaleDateString('en-US',{month:'short',day:'numeric'})); values.push(curve[i].v); }
btChart.data.labels = labels;
btChart.data.datasets[0].data = values;
btChart.update();
document.getElementById('tab-backtest').scrollIntoView({behavior:'smooth'});
});
}
// Start
connect();
connectWS();
loadBacktests();
</script>
</body>
</html>