RayMelius Claude Opus 4.6 commited on
Commit
2f6201d
Β·
1 Parent(s): 7559e0e

Add member detail panel to CH leaderboard

Browse files

Click any row on /ch/ to see a slide-out panel with:
- Capital, holdings value, total value, P&L stats
- Full holdings table with current prices and unrealized P&L
- Last 20 trades with time, symbol, side, qty, price, value
- New API endpoint: GET /ch/api/member/<member_id>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

clearing_house/app.py CHANGED
@@ -347,6 +347,44 @@ def api_portfolio():
347
  })
348
 
349
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
350
  @app.route("/ch/api/market")
351
  def api_market():
352
  return jsonify(_get_bbos())
 
347
  })
348
 
349
 
350
+ @app.route("/ch/api/member/<member_id>")
351
+ def api_member_detail(member_id):
352
+ """Full detail for a single member: capital, holdings, last 20 trades."""
353
+ member_id = member_id.upper().strip()
354
+ member = db.get_member(member_id)
355
+ if not member:
356
+ return jsonify({"error": "Member not found"}), 404
357
+
358
+ bbos = _get_bbos()
359
+ holdings = db.get_holdings(member_id)
360
+ daily = db.get_daily_trades(member_id)
361
+ trades = db.get_trade_log(member_id, limit=20)
362
+
363
+ for h in holdings:
364
+ bbo = bbos.get(h["symbol"], {})
365
+ current_price = bbo.get("mid") or h["avg_cost"]
366
+ h["current_price"] = round(current_price, 2)
367
+ h["value"] = round(current_price * h["quantity"], 2)
368
+ h["unrealized_pnl"] = round((current_price - h["avg_cost"]) * h["quantity"], 2)
369
+
370
+ total_holdings_value = sum(h["value"] for h in holdings)
371
+ total_value = round(member["capital"] + total_holdings_value, 2)
372
+ total_pnl = round(total_value - db.CH_STARTING_CAPITAL, 2)
373
+
374
+ return jsonify({
375
+ "member_id": member_id,
376
+ "capital": round(member["capital"], 2),
377
+ "holdings": holdings,
378
+ "holdings_value": round(total_holdings_value, 2),
379
+ "total_value": total_value,
380
+ "pnl": total_pnl,
381
+ "daily": daily,
382
+ "trades": trades,
383
+ "is_human": ai_trader.is_human_active(member_id),
384
+ "obligation": db.CH_DAILY_OBLIGATION,
385
+ })
386
+
387
+
388
  @app.route("/ch/api/market")
389
  def api_market():
390
  return jsonify(_get_bbos())
clearing_house/templates/dashboard.html CHANGED
@@ -45,7 +45,7 @@
45
  </thead>
46
  <tbody>
47
  {% for row in leaderboard %}
48
- <tr data-member="{{ row.member_id }}">
49
  <td style="color:var(--muted)">{{ row.rank }}</td>
50
  <td><strong>{{ row.member_id }}</strong></td>
51
  <td>
@@ -80,8 +80,24 @@
80
  <p style="color:var(--muted); font-size:11px; margin-top:8px;">
81
  Daily obligation: each member must trade at least {{ obligation }} securities.
82
  Holdings value is calculated at current market mid-price.
83
- Refreshes every 10 seconds.
84
  </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  {% endblock %}
86
 
87
  {% block extra_scripts %}
@@ -105,7 +121,7 @@ async function refreshLeaderboard() {
105
  ? `<span class="badge-human">Human</span>`
106
  : `<span class="badge-ai">AI</span>`;
107
  tbody.innerHTML += `
108
- <tr data-member="${row.member_id}">
109
  <td style="color:var(--muted)">${row.rank}</td>
110
  <td><strong>${row.member_id}</strong></td>
111
  <td>${typeBadge}</td>
@@ -119,6 +135,7 @@ async function refreshLeaderboard() {
119
  <td style="text-align:center">${oblBadge}</td>
120
  </tr>`;
121
  });
 
122
  document.getElementById('last-update').textContent =
123
  'Updated ' + new Date().toLocaleTimeString();
124
  } catch(e) { /* ignore */ }
@@ -127,4 +144,127 @@ async function refreshLeaderboard() {
127
  function fmt(n) {
128
  return Number(n).toLocaleString('en-US', {minimumFractionDigits:2, maximumFractionDigits:2});
129
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  {% endblock %}
 
45
  </thead>
46
  <tbody>
47
  {% for row in leaderboard %}
48
+ <tr data-member="{{ row.member_id }}" style="cursor:pointer">
49
  <td style="color:var(--muted)">{{ row.rank }}</td>
50
  <td><strong>{{ row.member_id }}</strong></td>
51
  <td>
 
80
  <p style="color:var(--muted); font-size:11px; margin-top:8px;">
81
  Daily obligation: each member must trade at least {{ obligation }} securities.
82
  Holdings value is calculated at current market mid-price.
83
+ Click a row for full member detail. Refreshes every 10 seconds.
84
  </p>
85
+
86
+ <!-- Member detail slide-out panel -->
87
+ <div id="member-overlay" style="display:none; position:fixed; top:0; right:0; bottom:0; left:0; z-index:1000;">
88
+ <div id="overlay-bg" style="position:absolute; inset:0; background:rgba(0,0,0,0.5);"></div>
89
+ <div id="member-panel" style="
90
+ position:absolute; top:0; right:0; bottom:0; width:520px; max-width:95vw;
91
+ background:var(--card-bg, #1e1e2e); border-left:1px solid var(--border, #333);
92
+ overflow-y:auto; padding:24px; box-shadow:-4px 0 20px rgba(0,0,0,0.4);
93
+ ">
94
+ <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
95
+ <h2 id="detail-title" style="margin:0;"></h2>
96
+ <button id="detail-close" style="background:none; border:none; color:var(--text, #ccc); font-size:24px; cursor:pointer;">&times;</button>
97
+ </div>
98
+ <div id="detail-content">Loading...</div>
99
+ </div>
100
+ </div>
101
  {% endblock %}
102
 
103
  {% block extra_scripts %}
 
121
  ? `<span class="badge-human">Human</span>`
122
  : `<span class="badge-ai">AI</span>`;
123
  tbody.innerHTML += `
124
+ <tr data-member="${row.member_id}" style="cursor:pointer">
125
  <td style="color:var(--muted)">${row.rank}</td>
126
  <td><strong>${row.member_id}</strong></td>
127
  <td>${typeBadge}</td>
 
135
  <td style="text-align:center">${oblBadge}</td>
136
  </tr>`;
137
  });
138
+ bindRowClicks();
139
  document.getElementById('last-update').textContent =
140
  'Updated ' + new Date().toLocaleTimeString();
141
  } catch(e) { /* ignore */ }
 
144
  function fmt(n) {
145
  return Number(n).toLocaleString('en-US', {minimumFractionDigits:2, maximumFractionDigits:2});
146
  }
147
+
148
+ // ── Member detail panel ───────────────────────────────────────────
149
+ const overlay = document.getElementById('member-overlay');
150
+ const detailTitle = document.getElementById('detail-title');
151
+ const detailContent = document.getElementById('detail-content');
152
+
153
+ document.getElementById('overlay-bg').addEventListener('click', closeDetail);
154
+ document.getElementById('detail-close').addEventListener('click', closeDetail);
155
+
156
+ function closeDetail() {
157
+ overlay.style.display = 'none';
158
+ }
159
+
160
+ function bindRowClicks() {
161
+ document.querySelectorAll('#lb-table tbody tr[data-member]').forEach(tr => {
162
+ tr.style.cursor = 'pointer';
163
+ tr.onclick = () => openDetail(tr.dataset.member);
164
+ });
165
+ }
166
+ bindRowClicks();
167
+
168
+ async function openDetail(memberId) {
169
+ overlay.style.display = 'block';
170
+ detailTitle.textContent = memberId;
171
+ detailContent.innerHTML = '<p style="color:var(--muted)">Loading...</p>';
172
+
173
+ try {
174
+ const resp = await fetch(`/ch/api/member/${memberId}`);
175
+ if (!resp.ok) { detailContent.innerHTML = '<p class="negative">Failed to load</p>'; return; }
176
+ const d = await resp.json();
177
+
178
+ const pnlClass = d.pnl >= 0 ? 'positive' : 'negative';
179
+ const pnlSign = d.pnl >= 0 ? '+' : '';
180
+ const typeBadge = d.is_human
181
+ ? '<span class="badge-human">Human</span>'
182
+ : '<span class="badge-ai">AI</span>';
183
+ const oblStatus = d.daily.total_securities >= d.obligation
184
+ ? `<span class="badge-ok">Met (${d.daily.total_securities}/${d.obligation})</span>`
185
+ : `<span class="badge-bad">${d.daily.total_securities}/${d.obligation}</span>`;
186
+
187
+ let html = `
188
+ <div style="display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-bottom:18px;">
189
+ <div class="card stat" style="padding:12px;">
190
+ <div class="val" style="font-size:18px;">€${fmt(d.capital)}</div>
191
+ <div class="lbl">Available Capital</div>
192
+ </div>
193
+ <div class="card stat" style="padding:12px;">
194
+ <div class="val" style="font-size:18px;">€${fmt(d.holdings_value)}</div>
195
+ <div class="lbl">Holdings Value</div>
196
+ </div>
197
+ <div class="card stat" style="padding:12px;">
198
+ <div class="val" style="font-size:18px;"><strong>€${fmt(d.total_value)}</strong></div>
199
+ <div class="lbl">Total Value</div>
200
+ </div>
201
+ <div class="card stat" style="padding:12px;">
202
+ <div class="val ${pnlClass}" style="font-size:18px;">${pnlSign}€${fmt(d.pnl)}</div>
203
+ <div class="lbl">P&amp;L</div>
204
+ </div>
205
+ </div>
206
+ <div style="margin-bottom:14px;">
207
+ ${typeBadge} &nbsp; Obligation: ${oblStatus}
208
+ &nbsp; Buys: <strong>${d.daily.buy_count}</strong>
209
+ &nbsp; Sells: <strong>${d.daily.sell_count}</strong>
210
+ </div>`;
211
+
212
+ // Holdings table
213
+ if (d.holdings.length > 0) {
214
+ html += `<h3 style="margin:16px 0 8px;">Holdings</h3>
215
+ <table style="width:100%; font-size:13px;">
216
+ <thead><tr>
217
+ <th>Symbol</th><th style="text-align:right">Qty</th>
218
+ <th style="text-align:right">Avg Cost</th><th style="text-align:right">Price</th>
219
+ <th style="text-align:right">Value</th><th style="text-align:right">P&amp;L</th>
220
+ </tr></thead><tbody>`;
221
+ d.holdings.forEach(h => {
222
+ const hPnlClass = h.unrealized_pnl >= 0 ? 'positive' : 'negative';
223
+ const hSign = h.unrealized_pnl >= 0 ? '+' : '';
224
+ html += `<tr>
225
+ <td><strong>${h.symbol}</strong></td>
226
+ <td style="text-align:right">${h.quantity}</td>
227
+ <td style="text-align:right">€${fmt(h.avg_cost)}</td>
228
+ <td style="text-align:right">€${fmt(h.current_price)}</td>
229
+ <td style="text-align:right">€${fmt(h.value)}</td>
230
+ <td style="text-align:right" class="${hPnlClass}">${hSign}€${fmt(h.unrealized_pnl)}</td>
231
+ </tr>`;
232
+ });
233
+ html += '</tbody></table>';
234
+ } else {
235
+ html += '<p style="color:var(--muted); margin-top:16px;">No holdings</p>';
236
+ }
237
+
238
+ // Recent trades table
239
+ html += `<h3 style="margin:20px 0 8px;">Last ${d.trades.length} Trades</h3>`;
240
+ if (d.trades.length > 0) {
241
+ html += `<table style="width:100%; font-size:13px;">
242
+ <thead><tr>
243
+ <th>Time</th><th>Symbol</th><th>Side</th>
244
+ <th style="text-align:right">Qty</th><th style="text-align:right">Price</th>
245
+ <th style="text-align:right">Value</th>
246
+ </tr></thead><tbody>`;
247
+ d.trades.forEach(t => {
248
+ const sideClass = t.side === 'BUY' ? 'positive' : 'negative';
249
+ const ts = new Date(t.timestamp * 1000).toLocaleTimeString();
250
+ const val = (t.quantity * t.price).toFixed(2);
251
+ html += `<tr>
252
+ <td style="color:var(--muted)">${ts}</td>
253
+ <td><strong>${t.symbol}</strong></td>
254
+ <td class="${sideClass}">${t.side}</td>
255
+ <td style="text-align:right">${t.quantity}</td>
256
+ <td style="text-align:right">€${fmt(t.price)}</td>
257
+ <td style="text-align:right">€${fmt(val)}</td>
258
+ </tr>`;
259
+ });
260
+ html += '</tbody></table>';
261
+ } else {
262
+ html += '<p style="color:var(--muted)">No trades today</p>';
263
+ }
264
+
265
+ detailContent.innerHTML = html;
266
+ } catch(e) {
267
+ detailContent.innerHTML = '<p class="negative">Error loading member data</p>';
268
+ }
269
+ }
270
  {% endblock %}