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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user