Night shift: tweet analyzer, data connectors, feed monitor, market watch portal

This commit is contained in:
2026-02-12 00:16:41 -06:00
parent f623cba45c
commit 07f1448d57
20 changed files with 1825 additions and 388 deletions

View File

@ -0,0 +1,296 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Market Watch — Paper Trading Dashboard</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0a0e1a; color: #e0e6f0; min-height: 100vh; }
.header { background: linear-gradient(135deg, #0f1629, #1a2342); padding: 20px 30px; border-bottom: 1px solid #1e2a4a; display: flex; justify-content: space-between; align-items: center; }
.header h1 { font-size: 1.5rem; font-weight: 600; }
.header h1 span { color: #4ecdc4; }
.header .meta { font-size: 0.85rem; color: #7a8bb5; }
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; margin-bottom: 20px; }
.card { background: linear-gradient(145deg, #111827, #0f1520); border: 1px solid #1e2a4a; border-radius: 12px; padding: 20px; }
.card h2 { font-size: 1rem; color: #7a8bb5; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 12px; font-weight: 500; }
.card h2 .icon { margin-right: 6px; }
.stat-row { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 8px; }
.stat-label { color: #7a8bb5; font-size: 0.85rem; }
.stat-value { font-size: 1.1rem; font-weight: 600; }
.stat-big { font-size: 2rem; font-weight: 700; }
.green { color: #4ecdc4; }
.red { color: #ff6b6b; }
.neutral { color: #7a8bb5; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: 600; }
.badge-green { background: rgba(78,205,196,0.15); color: #4ecdc4; }
.badge-red { background: rgba(255,107,107,0.15); color: #ff6b6b; }
.badge-blue { background: rgba(100,149,237,0.15); color: #6495ed; }
table { width: 100%; border-collapse: collapse; }
th { text-align: left; color: #7a8bb5; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1px; padding: 8px; border-bottom: 1px solid #1e2a4a; }
td { padding: 8px; font-size: 0.9rem; border-bottom: 1px solid #111827; }
tr:hover td { background: rgba(255,255,255,0.02); }
.chart-wrap { height: 250px; position: relative; }
.tabs { display: flex; gap: 8px; margin-bottom: 20px; }
.tab { padding: 8px 16px; border-radius: 8px; cursor: pointer; font-size: 0.9rem; border: 1px solid #1e2a4a; background: transparent; color: #7a8bb5; transition: all 0.2s; }
.tab.active { background: #4ecdc4; color: #0a0e1a; border-color: #4ecdc4; font-weight: 600; }
.tab:hover:not(.active) { border-color: #4ecdc4; color: #4ecdc4; }
.game-section { display: none; }
.game-section.active { display: block; }
.spinner { display: inline-block; width: 20px; height: 20px; border: 2px solid #1e2a4a; border-top-color: #4ecdc4; border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.empty { text-align: center; color: #7a8bb5; padding: 40px; }
</style>
</head>
<body>
<div class="header">
<h1>📊 <span>Market Watch</span> — Paper Trading</h1>
<div class="meta">Auto-refresh 60s · <span id="lastUpdate">Loading...</span></div>
</div>
<div class="container">
<div class="tabs" id="gameTabs"></div>
<div id="gameContent"><div class="empty"><div class="spinner"></div> Loading games...</div></div>
</div>
<script>
let games = [];
let activeGameIdx = 0;
async function fetchJSON(url) {
const r = await fetch(url);
return r.json();
}
function pnlClass(v) { return v > 0 ? 'green' : v < 0 ? 'red' : 'neutral'; }
function pnlSign(v) { return v > 0 ? '+' : ''; }
function fmt(v, d=2) { return v != null ? Number(v).toFixed(d) : '—'; }
function fmtK(v) { return v >= 1e6 ? (v/1e6).toFixed(1)+'M' : v >= 1e3 ? (v/1e3).toFixed(1)+'K' : fmt(v); }
function renderTabs() {
const el = document.getElementById('gameTabs');
el.innerHTML = games.map((g, i) =>
`<button class="tab ${i === activeGameIdx ? 'active' : ''}" onclick="switchGame(${i})">${g.name || g.game_id.slice(0,8)}</button>`
).join('');
}
function switchGame(idx) {
activeGameIdx = idx;
renderTabs();
renderGame(games[idx]);
}
async function renderGame(game) {
const el = document.getElementById('gameContent');
const gid = game.game_id;
// Fetch details in parallel
const [detail, trades, portfolio] = await Promise.all([
fetchJSON(`/api/game/${gid}`),
fetchJSON(`/api/game/${gid}/trades`),
fetchJSON(`/api/game/${gid}/portfolio`)
]);
const player = game.players?.[0] || 'unknown';
const pf = portfolio[player] || {};
const board = detail.leaderboard || [];
const entry = board[0] || {};
const starting = game.starting_cash || 100000;
const totalVal = pf.total_value || entry.total_value || starting;
const totalPnl = totalVal - starting;
const pnlPct = (totalPnl / starting * 100);
const sells = trades.filter(t => t.action === 'SELL');
const wins = sells.filter(t => (t.realized_pnl||0) > 0);
const winRate = sells.length ? (wins.length / sells.length * 100) : null;
const totalFees = trades.reduce((s,t) => s + (t.fees||0), 0);
const realizedPnl = sells.reduce((s,t) => s + (t.realized_pnl||0), 0);
// Equity curve from snapshots
const snapshots = detail.snapshots?.[player] || [];
let html = `
<!-- Summary Cards -->
<div class="grid">
<div class="card">
<h2><span class="icon">💰</span>Portfolio Value</h2>
<div class="stat-big ${pnlClass(totalPnl)}">$${fmtK(totalVal)}</div>
<div class="stat-row">
<span class="stat-label">P&L</span>
<span class="stat-value ${pnlClass(totalPnl)}">${pnlSign(totalPnl)}$${fmt(totalPnl)} (${pnlSign(pnlPct)}${fmt(pnlPct)}%)</span>
</div>
<div class="stat-row">
<span class="stat-label">Starting Cash</span>
<span class="stat-value">$${fmtK(starting)}</span>
</div>
<div class="stat-row">
<span class="stat-label">Cash Available</span>
<span class="stat-value">$${fmtK(pf.cash || 0)}</span>
</div>
</div>
<div class="card">
<h2><span class="icon">📈</span>Trading Stats</h2>
<div class="stat-row">
<span class="stat-label">Total Trades</span>
<span class="stat-value">${trades.length}</span>
</div>
<div class="stat-row">
<span class="stat-label">Win Rate</span>
<span class="stat-value ${winRate && winRate > 50 ? 'green' : winRate ? 'red' : 'neutral'}">${winRate != null ? fmt(winRate,1)+'%' : '—'}</span>
</div>
<div class="stat-row">
<span class="stat-label">Realized P&L</span>
<span class="stat-value ${pnlClass(realizedPnl)}">${pnlSign(realizedPnl)}$${fmt(realizedPnl)}</span>
</div>
<div class="stat-row">
<span class="stat-label">Total Fees</span>
<span class="stat-value red">-$${fmt(totalFees)}</span>
</div>
</div>
<div class="card">
<h2><span class="icon">🎯</span>Game Info</h2>
<div class="stat-row">
<span class="stat-label">Game</span>
<span class="stat-value">${game.name || gid.slice(0,8)}</span>
</div>
<div class="stat-row">
<span class="stat-label">Type</span>
<span class="stat-value"><span class="badge badge-blue">${game.game_type || 'stock'}</span></span>
</div>
<div class="stat-row">
<span class="stat-label">Player</span>
<span class="stat-value">${player}</span>
</div>
<div class="stat-row">
<span class="stat-label">Open Positions</span>
<span class="stat-value">${Object.keys(pf.positions || {}).length}</span>
</div>
</div>
</div>
<!-- Equity Chart -->
${snapshots.length > 1 ? `
<div class="card" style="margin-bottom:16px">
<h2><span class="icon">📊</span>Equity Curve</h2>
<div class="chart-wrap"><canvas id="equityChart"></canvas></div>
</div>` : ''}
<!-- Positions -->
<div class="card" style="margin-bottom:16px">
<h2><span class="icon">📋</span>Open Positions</h2>
${Object.keys(pf.positions||{}).length ? `
<table>
<thead><tr><th>Symbol</th><th>Shares/Qty</th><th>Avg Cost</th><th>Current</th><th>Value</th><th>P&L</th></tr></thead>
<tbody>
${Object.entries(pf.positions||{}).map(([sym, pos]) => {
const upnl = pos.unrealized_pnl || ((pos.current_price||pos.avg_cost) - pos.avg_cost) * (pos.shares||pos.quantity||0);
const val = pos.market_value || (pos.current_price||pos.avg_cost) * (pos.shares||pos.quantity||0);
return `<tr>
<td><strong>${sym}</strong></td>
<td>${pos.shares||pos.quantity||0}</td>
<td>$${fmt(pos.avg_cost)}</td>
<td>$${fmt(pos.current_price||pos.live_price||pos.avg_cost)}</td>
<td>$${fmtK(val)}</td>
<td class="${pnlClass(upnl)}">${pnlSign(upnl)}$${fmt(upnl)}</td>
</tr>`;
}).join('')}
</tbody>
</table>` : '<div class="empty">No open positions</div>'}
</div>
<!-- Recent Trades -->
<div class="card">
<h2><span class="icon">🔄</span>Recent Trades (last 25)</h2>
${trades.length ? `
<table>
<thead><tr><th>Time</th><th>Action</th><th>Symbol</th><th>Qty</th><th>Price</th><th>P&L</th></tr></thead>
<tbody>
${trades.slice(0,25).map(t => {
const rpnl = t.realized_pnl || 0;
const actionClass = t.action === 'BUY' ? 'badge-green' : t.action === 'SELL' ? 'badge-red' : 'badge-blue';
return `<tr>
<td style="font-size:0.8rem;color:#7a8bb5">${t.timestamp ? new Date(t.timestamp).toLocaleString() : '—'}</td>
<td><span class="badge ${actionClass}">${t.action}</span></td>
<td><strong>${t.ticker||t.symbol||'—'}</strong></td>
<td>${t.shares||t.quantity||'—'}</td>
<td>$${fmt(t.price)}</td>
<td class="${pnlClass(rpnl)}">${t.action==='SELL' ? pnlSign(rpnl)+'$'+fmt(rpnl) : '—'}</td>
</tr>`;
}).join('')}
</tbody>
</table>` : '<div class="empty">No trades yet</div>'}
</div>`;
el.innerHTML = html;
// Draw equity chart
if (snapshots.length > 1) {
const ctx = document.getElementById('equityChart').getContext('2d');
const labels = snapshots.map(s => {
const d = s.timestamp || s.date;
if (!d) return '—';
const dt = new Date(d);
return isNaN(dt) ? String(d).slice(0,10) : dt.toLocaleDateString();
});
const values = snapshots.map(s => s.total_value || s.equity || starting);
new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [{
label: 'Equity',
data: values,
borderColor: '#4ecdc4',
backgroundColor: 'rgba(78,205,196,0.1)',
fill: true,
tension: 0.3,
pointRadius: 0,
borderWidth: 2
}, {
label: 'Starting',
data: Array(labels.length).fill(starting),
borderColor: '#7a8bb544',
borderDash: [5,5],
borderWidth: 1,
pointRadius: 0
}]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { grid: { color: '#1e2a4a' }, ticks: { color: '#7a8bb5', maxTicksLimit: 8 } },
y: { grid: { color: '#1e2a4a' }, ticks: { color: '#7a8bb5', callback: v => '$'+fmtK(v) } }
}
}
});
}
}
async function init() {
try {
games = await fetchJSON('/api/games');
if (!games.length) {
document.getElementById('gameContent').innerHTML = '<div class="empty">No games found</div>';
return;
}
renderTabs();
await renderGame(games[activeGameIdx]);
document.getElementById('lastUpdate').textContent = new Date().toLocaleTimeString();
} catch (e) {
document.getElementById('gameContent').innerHTML = `<div class="empty">Error: ${e.message}</div>`;
}
}
init();
setInterval(async () => {
try {
games = await fetchJSON('/api/games');
await renderGame(games[activeGameIdx]);
document.getElementById('lastUpdate').textContent = new Date().toLocaleTimeString();
} catch(e) {}
}, 60000);
</script>
</body>
</html>