From 0dfd6a262bc349f13a59dd9cd094115e70d487cb Mon Sep 17 00:00:00 2001 From: momentum dev Date: Sun, 21 Jun 2026 19:56:05 -0400 Subject: [PATCH] Phase-2 ON + dashboard catalyst visualization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn on the catalyst score blend + dilution veto, and make the engine VISIBLE so it can be watched live at the open: - api/main.py: snapshot now carries catalyst_engine {enabled, model, score_blend, dilution_veto, advisories, dilutive, vetoes_today} (config read from .env + live counts incl. how many entries the veto blocked today). - dashboard: a catalyst-engine status chip in the Armed Triggers header (model · N read · dilutive · veto · score · vetoes-today), and each armed gapper now shows the LLM's READ instead of just the raw headline — colored sentiment dot, catalyst type, and a red "⚠ VETO" badge on a confirmed dilution at/above the conviction floor (or "dilut?" below it). Verified headless: engine chip renders live (230 advisories, veto+score on); a seeded bullish gapper shows "fda approval", a dilutive one shows the VETO badge; no JS errors. Co-Authored-By: Claude Opus 4.8 --- api/main.py | 31 ++++++++++++++++++++ api/static/dashboard.html | 61 +++++++++++++++++++++++++++++++++++---- 2 files changed, 87 insertions(+), 5 deletions(-) diff --git a/api/main.py b/api/main.py index 0a75563..46f8833 100644 --- a/api/main.py +++ b/api/main.py @@ -72,6 +72,36 @@ def _available_dates(store) -> list[str]: return [] +def _catalyst_engine(store, catalyst: dict) -> dict: + """Catalyst-engine status for the dashboard: config (read from the same .env) + plus live activity (advisory + dilution counts, and how many entries the + dilution veto has blocked today).""" + try: + from config import OllamaConfig + cfg = OllamaConfig.from_env() + on = bool(cfg.enabled and cfg.enrichment_enabled) + model = cfg.model + score_blend = bool(cfg.catalyst_score_enabled) + veto = bool(cfg.dilution_veto_enabled) + except Exception: # noqa: BLE001 + on, model, score_blend, veto = False, "", False, False + adv = catalyst or {} + dilutive = sum(1 for a in adv.values() if isinstance(a, dict) and a.get("is_dilutive")) + vetoes = 0 + try: + row = store.con.execute( + "SELECT count(*) FROM events WHERE event_type='risk_rule_triggered' " + "AND timestamp::date = CURRENT_DATE " + "AND payload_json LIKE '%catalyst_dilution_veto%'" + ).fetchone() + vetoes = int(row[0]) if row and row[0] else 0 + except Exception: # noqa: BLE001 + vetoes = 0 + return {"enabled": on, "model": model, "score_blend": score_blend, + "dilution_veto": veto, "advisories": len(adv), + "dilutive": dilutive, "vetoes_today": vetoes} + + class DashboardState: """Shared state: event store path + optional live execution service.""" @@ -120,6 +150,7 @@ class DashboardState: "triggers": query_armed_triggers(store) if is_live else {"triggers": [], "armed": 0}, "equity_curve": query_equity_curve(store, for_date=for_date), "catalyst": catalyst, + "catalyst_engine": _catalyst_engine(store, catalyst) if is_live else {}, "approval_queue": approval_queue, "ready_signals": query_ready_signals_snapshot(store) if is_live else [], # watch_states is a LIVE concept (and the heaviest projection diff --git a/api/static/dashboard.html b/api/static/dashboard.html index e44fbf1..7976a10 100644 --- a/api/static/dashboard.html +++ b/api/static/dashboard.html @@ -212,8 +212,17 @@ .trig-trigline { position: absolute; top: -1px; bottom: -1px; width: 2px; background: #e6edf3; box-shadow: 0 0 4px rgba(230,237,243,.6); } .trig-dist { width: 70px; text-align: right; font-weight: 700; } - .trig-cat { width: 150px; color: var(--accent); font: 500 10px var(--mono); overflow: hidden; + .trig-cat { width: 196px; color: var(--accent); font: 500 10px var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .trig-adv { font: 600 10px var(--mono); color: #c9d3e6; } + .trig-cat-raw { color: var(--muted); font: 500 10px var(--mono); } + /* catalyst engine status chip in the Armed Triggers header */ + .cat-engine { margin-left: 10px; font: 600 10px var(--mono); color: var(--muted); + text-transform: none; letter-spacing: .2px; } + .cat-engine b { color: var(--text); font-weight: 700; } + .cat-engine b.on { color: var(--green); } .cat-engine b.off { color: var(--muted); } + .cat-engine .neg { color: var(--red); } + .cat-engine .cat-off { color: var(--muted); } .st-armed .trig-state { color: #58a6ff; } .st-armed .trig-fill { background: linear-gradient(90deg,#1f6feb,#58a6ff); } .st-fired .trig-state { color: #2ecc71; } .st-fired .trig-fill { background: #2ecc71; } .st-filled .trig-state { color: #2ecc71; } .st-filled .trig-fill { background: linear-gradient(90deg,#1f8f4e,#2ecc71); } @@ -331,7 +340,7 @@
-

Armed Triggers 0live proximity to the opening-range breakout · closest to fire on top

+

Armed Triggers 0live proximity to the opening-range breakout · closest to fire on top

No armed gappers — waiting for the open / first bars.
@@ -582,8 +591,29 @@ function renderWatch(ws) { // LIVE price sits between the stop (left) and the breakout trigger (the marker). // The fill grows toward the marker as price nears the break; crossing it = fired. const TRIG_ORDER = { fired: 0, filled: 1, armed: 2, weak: 3, waiting: 4 }; -function renderTriggers(tw) { +// catalyst read for one armed symbol: colored dot + type + veto/dilutive badge. +// VETO floor is conviction >= 0.6 (matches the execution dilution veto). +function trigCatalyst(adv, rawHeadline) { + if (!adv || !adv.catalyst_type || adv.catalyst_type === 'none') { + return rawHeadline ? '' + esc(rawHeadline) + '' : ''; + } + const s = +adv.sentiment || 0, conv = +adv.conviction || 0; + const typ = String(adv.catalyst_type).replace(/_/g, ' '); + let dot, badge = ''; + if (adv.is_dilutive) { + dot = '🔴'; + badge = conv >= 0.6 + ? ' ⚠ VETO' + : ' dilut?'; + } else { dot = s > 0.2 ? '🟢' : s < -0.2 ? '🔻' : '⚪'; } + const tip = typ + ' · sentiment ' + (s >= 0 ? '+' : '') + s.toFixed(2) + ' · conviction ' + conv.toFixed(2) + + (adv.rationale ? ' — ' + adv.rationale : ''); + return '' + dot + ' ' + esc(typ) + badge + ''; +} + +function renderTriggers(tw, catMap) { const arr = (tw && tw.triggers) || []; + const cat = catMap || {}; $("trig-count").textContent = tw && tw.armed != null ? tw.armed : 0; const host = $("armed-triggers"); if (!arr.length) { host.innerHTML = 'No armed gappers — waiting for the open / first bars.'; return; } @@ -611,11 +641,31 @@ function renderTriggers(tw) { + (hasBar ? '
' : '') + '' + '
' + distTxt + '
' - + '
' + (t.catalyst ? esc(t.catalyst) : '') + '
' + + '
' + trigCatalyst(cat[(t.symbol || '').toUpperCase()] || cat[t.symbol], t.catalyst) + '
' + ''; }).join(''); } +// catalyst engine status chip (shows the LLM news engine is live + its activity) +function renderCatalystEngine(e) { + const el = $("cat-engine"); if (!el) return; + if (!e || !e.enabled) { + el.innerHTML = '🧠 catalyst engine off'; + el.title = 'Local-LLM news enrichment disabled (OLLAMA_ENABLED / NEWS_ENRICH_ENABLED)'; + return; + } + const veto = e.dilution_veto ? 'veto' : 'veto off'; + const score = e.score_blend ? ' · score' : ''; + el.innerHTML = '🧠 ' + esc(e.model || 'llm') + ' · ' + (e.advisories || 0) + ' read' + + (e.dilutive ? ' · ' + e.dilutive + ' dilutive' : '') + + ' · ' + veto + score + + (e.vetoes_today ? ' · ⚠ ' + e.vetoes_today + ' vetoed' : ''); + el.title = 'Catalyst engine LIVE — ' + (e.advisories || 0) + ' headlines classified by ' + + (e.model || 'the LLM') + ', ' + (e.dilutive || 0) + ' flagged dilutive, ' + + (e.vetoes_today || 0) + ' entries vetoed today. Score blend: ' + + (e.score_blend ? 'on' : 'off') + ', dilution veto: ' + (e.dilution_veto ? 'on' : 'off') + '.'; +} + // intraday equity sparkline — the day's P&L shape vs the prior close (dashed) function renderEquity(ec) { const host = $("equity-spark"); if (!host) return; @@ -698,7 +748,8 @@ function render(d) { // broker sync runs ~every 60s, so >90s = a missed sync (amber), >180s = stale (red) fr.style.color = (rk.data_age_s == null) ? "" : (rk.data_age_s > 180 ? "#ff4d4f" : rk.data_age_s > 90 ? "#f1c40f" : ""); - renderTriggers(d.triggers); + renderTriggers(d.triggers, d.catalyst); + renderCatalystEngine(d.catalyst_engine); renderEquity(d.equity_curve); const accts = d.accounts || [];