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:
+121
-147
@@ -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
@@ -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 ·
|
||||
<span class="status-dot live" id="status-dot"></span>
|
||||
<span id="connection-status">connecting...</span>
|
||||
· <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>
|
||||
· Part of my quant trading portfolio
|
||||
· 5 strategies · 100 USDC each · 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>
|
||||
|
||||
Reference in New Issue
Block a user