Files
ftdt-quant-lab/dashboard/static/index.html
T
ramseshk 232103e167 Add live dashboard with WebSocket PnL streaming, deploy at ftdt.io/cv
Built a tasteful dark-themed dashboard showing real-time strategy
performance. Components:

- dashboard/server.py: FastAPI + WebSocket backend that collects
  strategy metrics and streams them to connected clients
- dashboard/static/index.html: Clean single-page dashboard with
  equity curve (Chart.js), per-strategy PnL cards with Sharpe,
  win rate, drawdown, and a live trade log
- Deployed as a background process on port 9175, proxied by Caddy
  at ftdt.io/cv via handle_path

Also added docs/WALLET_SETUP.md with step-by-step instructions
for setting up a Hyperliquid testnet wallet and claiming faucet USDC.

Design: dark theme, JetBrains Mono for numbers, Inter for labels,
status dots with pulse animation. No bloat — one HTML file + vanilla JS.
2026-08-03 11:56:29 +00:00

459 lines
12 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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">
<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;
--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;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 24px 20px;
}
/* ── Header ─────────────────────── */
header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 28px;
padding-bottom: 20px;
border-bottom: 1px solid var(--border);
}
.brand h1 {
font-size: 20px;
font-weight: 600;
color: var(--text-bright);
letter-spacing: -0.3px;
}
.brand span {
font-size: 12px;
color: var(--text);
display: block;
margin-top: 2px;
}
.summary {
text-align: right;
}
.summary .pnl {
font-family: var(--mono);
font-size: 28px;
font-weight: 600;
letter-spacing: -0.5px;
}
.summary .pnl.positive { color: var(--green); }
.summary .pnl.negative { color: var(--red); }
.summary .label {
font-size: 11px;
color: var(--text);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
margin-right: 6px;
}
.status-dot.live { background: var(--green); animation: pulse 2s infinite; }
.status-dot.idle { background: var(--amber); }
.status-dot.error { background: var(--red); }
@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: 16px;
margin-bottom: 20px;
}
.chart-card h2 {
font-size: 14px;
font-weight: 500;
color: var(--text-bright);
margin-bottom: 12px;
}
#equity-chart {
width: 100%;
height: 200px;
}
/* ── Strategy Grid ──────────────── */
.strategy-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.strategy-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 16px;
transition: border-color 0.2s;
}
.strategy-card:hover {
border-color: #2a2a32;
}
.strategy-card .name {
font-size: 12px;
font-weight: 500;
color: var(--text-bright);
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.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: 11px;
color: var(--text);
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.strategy-card .meta span {
white-space: nowrap;
}
.strategy-card .meta .val {
color: var(--text-bright);
font-family: var(--mono);
font-size: 11px;
}
/* ── Trade Log ──────────────────── */
.trade-log {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.trade-log h2 {
font-size: 14px;
font-weight: 500;
color: var(--text-bright);
padding: 14px 16px;
border-bottom: 1px solid var(--border);
}
.trade-table {
width: 100%;
border-collapse: collapse;
}
.trade-table th {
font-size: 10px;
font-weight: 500;
color: var(--text);
text-transform: uppercase;
letter-spacing: 0.5px;
text-align: left;
padding: 8px 16px;
border-bottom: 1px solid var(--border);
}
.trade-table td {
font-family: var(--mono);
font-size: 12px;
padding: 6px 16px;
border-bottom: 1px solid rgba(255,255,255,0.03);
}
.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: 20px;
font-size: 11px;
color: #3f3f46;
}
footer a {
color: #52525b;
text-decoration: none;
}
footer a:hover {
color: var(--text);
}
/* ── Responsive ─────────────────── */
@media (max-width: 640px) {
.container { padding: 16px 12px; }
header { flex-direction: column; gap: 12px; }
.summary { text-align: left; }
}
</style>
</head>
<body>
<div class="container">
<!-- Header -->
<header>
<div class="brand">
<h1>FTDT Quant Lab</h1>
<span>
<span class="status-dot live"></span>
Hyperliquid Testnet &middot;
<span id="connection-status">connecting...</span>
</span>
</div>
<div class="summary">
<div class="label">Total PnL</div>
<div class="pnl" id="total-pnl">$0.00</div>
<div class="label" id="total-pnl-pct" style="font-size:12px;margin-top:2px;">0.00%</div>
</div>
</header>
<!-- Equity Curve -->
<div class="chart-card">
<h2>Equity Curve</h2>
<canvas id="equity-chart"></canvas>
</div>
<!-- Strategy Cards -->
<div class="strategy-grid" id="strategy-grid"></div>
<!-- Trade Log -->
<div class="trade-log">
<h2>Recent Trades</h2>
<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>
</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
</footer>
</div>
<script>
// ═══════════════════════════════════════════════════════════
// Chart setup
// ═══════════════════════════════════════════════════════════
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 } }
},
y: {
display: true,
grid: { color: 'rgba(255,255,255,0.03)' },
ticks: {
color: '#52525b',
font: { size: 10 },
callback: v => '$' + v.toLocaleString()
}
}
}
}
});
// ═══════════════════════════════════════════════════════════
// WebSocket
// ═══════════════════════════════════════════════════════════
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = protocol + '//' + location.host + '/ws';
let ws;
function connect() {
ws = new WebSocket(wsUrl);
ws.onopen = () => {
document.getElementById('connection-status').innerHTML =
'<span style="color:#22c55e">live</span>';
document.getElementById('connection-status').parentElement
.querySelector('.status-dot').className = 'status-dot live';
};
ws.onclose = () => {
document.getElementById('connection-status').innerHTML =
'<span style="color:#f59e0b">reconnecting...</span>';
setTimeout(connect, 2000);
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
updateDashboard(data);
};
}
// ═══════════════════════════════════════════════════════════
// Dashboard update
// ═══════════════════════════════════════════════════════════
function updateDashboard(data) {
// Total PnL
const pnl = data.total_pnl;
const pnlEl = document.getElementById('total-pnl');
pnlEl.textContent = (pnl >= 0 ? '+' : '') + '$' + Math.abs(pnl).toFixed(2);
pnlEl.className = 'pnl ' + (pnl >= 0 ? 'positive' : 'negative');
const pnlPct = (pnl / data.total_equity * 100);
document.getElementById('total-pnl-pct').textContent =
(pnlPct >= 0 ? '+' : '') + pnlPct.toFixed(2) + '%';
// Strategy cards
const grid = document.getElementById('strategy-grid');
grid.innerHTML = '';
for (const [name, s] of Object.entries(data.strategies)) {
const card = document.createElement('div');
card.className = 'strategy-card';
const pnlClass = s.pnl >= 0 ? 'pos' : 'neg';
const pnlStr = (s.pnl >= 0 ? '+' : '') + '$' + Math.abs(s.pnl).toFixed(2);
const statusDot = s.status === 'running' ? 'live' :
s.status === 'error' ? 'error' : 'idle';
card.innerHTML = `
<div class="name">
<span class="status-dot ${statusDot}"></span>
${name}
</div>
<div class="pnl ${pnlClass}">${pnlStr}</div>
<div class="meta">
<span>Trades: <span class="val">${s.trades_today}</span></span>
<span>Win: <span class="val">${(s.win_rate * 100).toFixed(0)}%</span></span>
<span>Sharpe: <span class="val">${s.sharpe.toFixed(2)}</span></span>
<span>DD: <span class="val">${(s.max_drawdown * 100).toFixed(1)}%</span></span>
</div>
`;
grid.appendChild(card);
}
// Equity chart
if (data.equity_history && data.equity_history.length > 0) {
const labels = data.equity_history.map(p =>
new Date(p.t * 1000).toLocaleTimeString('en-US', { hour12: false })
);
const values = data.equity_history.map(p => p.v);
equityChart.data.labels = labels;
equityChart.data.datasets[0].data = values;
equityChart.update('none');
}
// Trade log
const tbody = document.getElementById('trade-body');
const trades = (data.trades || []).slice(-15).reverse();
tbody.innerHTML = trades.map(t => {
const pnlClass = t.pnl >= 0 ? 'pos' : 'neg';
const pnlStr = (t.pnl >= 0 ? '+' : '') + '$' + Math.abs(t.pnl).toFixed(4);
const sideClass = t.side === 'BUY' ? 'buy' : 'sell';
return `<tr>
<td>${t.time}</td>
<td>${t.strategy}</td>
<td class="${sideClass}">${t.side}</td>
<td>${t.size}</td>
<td class="${pnlClass}">${pnlStr}</td>
</tr>`;
}).join('');
}
// Start
connect();
</script>
</body>
</html>