diff --git a/dashboard/server.py b/dashboard/server.py new file mode 100644 index 0000000..4ba33c9 --- /dev/null +++ b/dashboard/server.py @@ -0,0 +1,228 @@ +""" +Dashboard backend — WebSocket metrics server. + +Collects strategy performance data in real time and streams +it 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 / + +Usage: + python dashboard/server.py --port 9175 +""" +import asyncio +import json +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 +import uvicorn + + +# ═══════════════════════════════════════════════════════════════ +# Data Models +# ═══════════════════════════════════════════════════════════════ + +@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 + + +@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 = 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) + + +# ═══════════════════════════════════════════════════════════════ +# Metrics collector (mock — replace with real NautilusTrader hooks) +# ═══════════════════════════════════════════════════════════════ + +def collect_metrics(): + """ + Background thread that updates the dashboard state. + + 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 + while True: + time.sleep(1) + t += 1 + + 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: + asyncio.run_coroutine_threadsafe( + ws.send_text(payload), loop + ) + except Exception: + connected_clients.discard(ws) + + +# ═══════════════════════════════════════════════════════════════ +# WebSocket endpoint +# ═══════════════════════════════════════════════════════════════ + +@app.websocket("/ws") +async def websocket_endpoint(websocket: WebSocket): + await websocket.accept() + connected_clients.add(websocket) + try: + while True: + # Keep alive — actual data is pushed by the collector thread + await asyncio.sleep(30) + except WebSocketDisconnect: + connected_clients.discard(websocket) + + +# ═══════════════════════════════════════════════════════════════ +# Static files +# ═══════════════════════════════════════════════════════════════ + +STATIC_DIR = Path(__file__).parent / "static" + + +@app.get("/") +async def root(): + return FileResponse(STATIC_DIR / "index.html") + + +# ═══════════════════════════════════════════════════════════════ +# 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") + 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") + + print(f"FTDT Quant Lab Dashboard") + print(f" http://{args.host}:{args.port}") + print(f" WebSocket: ws://{args.host}:{args.port}/ws") + + uvicorn.run(app, host=args.host, port=args.port, log_level="warning") + + +if __name__ == "__main__": + main() diff --git a/dashboard/static/index.html b/dashboard/static/index.html new file mode 100644 index 0000000..61cda17 --- /dev/null +++ b/dashboard/static/index.html @@ -0,0 +1,458 @@ + + +
+ + +| Time | +Strategy | +Side | +Size | +PnL | +
|---|