Add AI reasoning history to CH member detail panel
Browse files- New ch_ai_decisions table stores LLM responses and parsed orders
- AI trader logs every decision: LLM response, fallback, invalid parse
- Member detail API returns last 10 AI decisions
- Detail panel shows AI Reasoning section with:
- LLM/Fallback/Invalid badges
- Parsed order (side, qty, symbol, price)
- Raw LLM response in scrollable monospace box
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- clearing_house/app.py +11 -0
- clearing_house/ch_ai_trader.py +16 -1
- clearing_house/ch_database.py +47 -0
- clearing_house/templates/dashboard.html +28 -0
clearing_house/app.py
CHANGED
|
@@ -371,6 +371,16 @@ def api_member_detail(member_id):
|
|
| 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),
|
|
@@ -380,6 +390,7 @@ def api_member_detail(member_id):
|
|
| 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 |
})
|
|
|
|
| 371 |
total_value = round(member["capital"] + total_holdings_value, 2)
|
| 372 |
total_pnl = round(total_value - db.CH_STARTING_CAPITAL, 2)
|
| 373 |
|
| 374 |
+
ai_decisions = db.get_ai_decisions(member_id, limit=10)
|
| 375 |
+
# Parse the stored JSON strings for the frontend
|
| 376 |
+
import json as _json
|
| 377 |
+
for d in ai_decisions:
|
| 378 |
+
if d.get("parsed_order") and isinstance(d["parsed_order"], str):
|
| 379 |
+
try:
|
| 380 |
+
d["parsed_order"] = _json.loads(d["parsed_order"])
|
| 381 |
+
except Exception:
|
| 382 |
+
pass
|
| 383 |
+
|
| 384 |
return jsonify({
|
| 385 |
"member_id": member_id,
|
| 386 |
"capital": round(member["capital"], 2),
|
|
|
|
| 390 |
"pnl": total_pnl,
|
| 391 |
"daily": daily,
|
| 392 |
"trades": trades,
|
| 393 |
+
"ai_decisions": ai_decisions,
|
| 394 |
"is_human": ai_trader.is_human_active(member_id),
|
| 395 |
"obligation": db.CH_DAILY_OBLIGATION,
|
| 396 |
})
|
clearing_house/ch_ai_trader.py
CHANGED
|
@@ -268,9 +268,24 @@ def _decide_order_llm(
|
|
| 268 |
if text:
|
| 269 |
order = _parse_llm_order(text, bbos)
|
| 270 |
if order and _validate_order(order, capital, holdings, bbos):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
return order
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
# Fallback: rule-based
|
| 273 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
|
| 275 |
|
| 276 |
def _build_prompt(member_id, capital, holdings, daily_trades, bbos, obligation_remaining):
|
|
|
|
| 268 |
if text:
|
| 269 |
order = _parse_llm_order(text, bbos)
|
| 270 |
if order and _validate_order(order, capital, holdings, bbos):
|
| 271 |
+
try:
|
| 272 |
+
db.record_ai_decision(member_id, text, order, source="llm")
|
| 273 |
+
except Exception as e:
|
| 274 |
+
print(f"[CH-AI] Failed to log decision: {e}")
|
| 275 |
return order
|
| 276 |
+
# Log failed parse/validation too
|
| 277 |
+
try:
|
| 278 |
+
db.record_ai_decision(member_id, text, order, source="llm-invalid")
|
| 279 |
+
except Exception:
|
| 280 |
+
pass
|
| 281 |
# Fallback: rule-based
|
| 282 |
+
fallback = _fallback_order(capital, holdings, bbos)
|
| 283 |
+
if fallback:
|
| 284 |
+
try:
|
| 285 |
+
db.record_ai_decision(member_id, "LLM unavailable, using rule-based fallback", fallback, source="fallback")
|
| 286 |
+
except Exception:
|
| 287 |
+
pass
|
| 288 |
+
return fallback
|
| 289 |
|
| 290 |
|
| 291 |
def _build_prompt(member_id, capital, holdings, daily_trades, bbos, obligation_remaining):
|
clearing_house/ch_database.py
CHANGED
|
@@ -65,10 +65,22 @@ CREATE TABLE IF NOT EXISTS ch_settlements (
|
|
| 65 |
settled_at REAL NOT NULL
|
| 66 |
);
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
CREATE INDEX IF NOT EXISTS idx_ch_trade_log_member
|
| 69 |
ON ch_trade_log(member_id, trading_date);
|
| 70 |
CREATE INDEX IF NOT EXISTS idx_ch_settlements_member
|
| 71 |
ON ch_settlements(member_id, trading_date);
|
|
|
|
|
|
|
| 72 |
"""
|
| 73 |
|
| 74 |
|
|
@@ -264,6 +276,41 @@ def get_settlements(member_id: str, limit: int = 30) -> list[dict]:
|
|
| 264 |
return [dict(r) for r in rows]
|
| 265 |
|
| 266 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
# ββ Leaderboard ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 268 |
|
| 269 |
def get_leaderboard(date: str | None = None) -> list[dict]:
|
|
|
|
| 65 |
settled_at REAL NOT NULL
|
| 66 |
);
|
| 67 |
|
| 68 |
+
CREATE TABLE IF NOT EXISTS ch_ai_decisions (
|
| 69 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 70 |
+
member_id TEXT NOT NULL,
|
| 71 |
+
llm_response TEXT NOT NULL,
|
| 72 |
+
parsed_order TEXT,
|
| 73 |
+
source TEXT NOT NULL DEFAULT 'llm',
|
| 74 |
+
trading_date TEXT NOT NULL,
|
| 75 |
+
timestamp REAL NOT NULL
|
| 76 |
+
);
|
| 77 |
+
|
| 78 |
CREATE INDEX IF NOT EXISTS idx_ch_trade_log_member
|
| 79 |
ON ch_trade_log(member_id, trading_date);
|
| 80 |
CREATE INDEX IF NOT EXISTS idx_ch_settlements_member
|
| 81 |
ON ch_settlements(member_id, trading_date);
|
| 82 |
+
CREATE INDEX IF NOT EXISTS idx_ch_ai_decisions_member
|
| 83 |
+
ON ch_ai_decisions(member_id, trading_date);
|
| 84 |
"""
|
| 85 |
|
| 86 |
|
|
|
|
| 276 |
return [dict(r) for r in rows]
|
| 277 |
|
| 278 |
|
| 279 |
+
# ββ AI Decisions ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 280 |
+
|
| 281 |
+
def record_ai_decision(
|
| 282 |
+
member_id: str,
|
| 283 |
+
llm_response: str,
|
| 284 |
+
parsed_order: dict | None,
|
| 285 |
+
source: str = "llm",
|
| 286 |
+
) -> None:
|
| 287 |
+
import json as _json
|
| 288 |
+
_conn().execute(
|
| 289 |
+
"""INSERT INTO ch_ai_decisions
|
| 290 |
+
(member_id, llm_response, parsed_order, source, trading_date, timestamp)
|
| 291 |
+
VALUES (?,?,?,?,?,?)""",
|
| 292 |
+
(
|
| 293 |
+
member_id,
|
| 294 |
+
llm_response,
|
| 295 |
+
_json.dumps(parsed_order) if parsed_order else None,
|
| 296 |
+
source,
|
| 297 |
+
today_str(),
|
| 298 |
+
time.time(),
|
| 299 |
+
),
|
| 300 |
+
)
|
| 301 |
+
_conn().commit()
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def get_ai_decisions(member_id: str, limit: int = 10) -> list[dict]:
|
| 305 |
+
rows = _conn().execute(
|
| 306 |
+
"""SELECT llm_response, parsed_order, source, timestamp
|
| 307 |
+
FROM ch_ai_decisions WHERE member_id=?
|
| 308 |
+
ORDER BY timestamp DESC LIMIT ?""",
|
| 309 |
+
(member_id, limit),
|
| 310 |
+
).fetchall()
|
| 311 |
+
return [dict(r) for r in rows]
|
| 312 |
+
|
| 313 |
+
|
| 314 |
# ββ Leaderboard ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 315 |
|
| 316 |
def get_leaderboard(date: str | None = None) -> list[dict]:
|
clearing_house/templates/dashboard.html
CHANGED
|
@@ -262,6 +262,34 @@ async function openDetail(memberId) {
|
|
| 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>';
|
|
|
|
| 262 |
html += '<p style="color:var(--muted)">No trades today</p>';
|
| 263 |
}
|
| 264 |
|
| 265 |
+
// AI Decision History
|
| 266 |
+
if (d.ai_decisions && d.ai_decisions.length > 0) {
|
| 267 |
+
html += `<h3 style="margin:20px 0 8px;">AI Reasoning (last ${d.ai_decisions.length})</h3>`;
|
| 268 |
+
d.ai_decisions.forEach(dec => {
|
| 269 |
+
const ts = new Date(dec.timestamp * 1000).toLocaleTimeString();
|
| 270 |
+
const srcBadge = dec.source === 'llm'
|
| 271 |
+
? '<span style="background:#e8eaf6; color:#5c6bc0; padding:1px 6px; border-radius:8px; font-size:10px;">LLM</span>'
|
| 272 |
+
: dec.source === 'fallback'
|
| 273 |
+
? '<span style="background:#fff3e0; color:#e65100; padding:1px 6px; border-radius:8px; font-size:10px;">Fallback</span>'
|
| 274 |
+
: `<span style="background:#ffebee; color:#c62828; padding:1px 6px; border-radius:8px; font-size:10px;">${dec.source}</span>`;
|
| 275 |
+
const order = dec.parsed_order;
|
| 276 |
+
const orderLine = order
|
| 277 |
+
? `<span class="${order.side === 'BUY' ? 'positive' : 'negative'}" style="font-weight:bold;">${order.side}</span> ${order.quantity} <strong>${order.symbol}</strong> @ β¬${fmt(order.price)}`
|
| 278 |
+
: '<span style="color:var(--muted)">No valid order</span>';
|
| 279 |
+
|
| 280 |
+
html += `
|
| 281 |
+
<div style="border:1px solid var(--border, #333); border-radius:6px; padding:10px; margin-bottom:8px; font-size:12px;">
|
| 282 |
+
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:6px;">
|
| 283 |
+
<div>${srcBadge} ${orderLine}</div>
|
| 284 |
+
<span style="color:var(--muted); font-size:11px;">${ts}</span>
|
| 285 |
+
</div>
|
| 286 |
+
<div style="color:var(--muted); white-space:pre-wrap; font-family:monospace; font-size:11px; max-height:80px; overflow-y:auto; background:rgba(0,0,0,0.15); padding:6px; border-radius:4px;">${dec.llm_response}</div>
|
| 287 |
+
</div>`;
|
| 288 |
+
});
|
| 289 |
+
} else if (!d.is_human) {
|
| 290 |
+
html += '<h3 style="margin:20px 0 8px;">AI Reasoning</h3><p style="color:var(--muted)">No AI decisions recorded yet</p>';
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
detailContent.innerHTML = html;
|
| 294 |
} catch(e) {
|
| 295 |
detailContent.innerHTML = '<p class="negative">Error loading member data</p>';
|