Phase-2 ON + dashboard catalyst visualization
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
8305482376
commit
0dfd6a262b
2 files changed
+87
-5
No files matched your search
+31
@@ -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
|
||||
|
||||
@@ -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 @@
|
||||
|
||||
<main>
|
||||
<section class="panel full">
|
||||
<h2>Armed Triggers <span id="trig-count" class="count">0</span><span class="hint">live proximity to the opening-range breakout · closest to fire on top</span></h2>
|
||||
<h2>Armed Triggers <span id="trig-count" class="count">0</span><span id="cat-engine" class="cat-engine"></span><span class="hint">live proximity to the opening-range breakout · closest to fire on top</span></h2>
|
||||
<div id="armed-triggers"><span class="empty">No armed gappers — waiting for the open / first bars.</span></div>
|
||||
</section>
|
||||
|
||||
@@ -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 ? '<span class="trig-cat-raw" title="' + esc(rawHeadline) + '">' + esc(rawHeadline) + '</span>' : '';
|
||||
}
|
||||
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
|
||||
? ' <span class="pill short" title="confirmed dilution, conv ' + conv.toFixed(2) + ' — entry VETOED">⚠ VETO</span>'
|
||||
: ' <span class="pill blocked" title="dilution flagged, conv ' + conv.toFixed(2) + ' (below 0.60 veto floor)">dilut?</span>';
|
||||
} 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 '<span class="trig-adv" title="' + esc(tip) + '">' + dot + ' ' + esc(typ) + badge + '</span>';
|
||||
}
|
||||
|
||||
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 = '<span class="empty">No armed gappers — waiting for the open / first bars.</span>'; return; }
|
||||
@@ -611,11 +641,31 @@ function renderTriggers(tw) {
|
||||
+ (hasBar ? '<div class="trig-fill" style="width:' + fill.toFixed(1) + '%"></div><div class="trig-trigline" style="left:' + trigPos.toFixed(1) + '%"></div>' : '')
|
||||
+ '</div>'
|
||||
+ '<div class="trig-dist ' + distCls + '">' + distTxt + '</div>'
|
||||
+ '<div class="trig-cat">' + (t.catalyst ? esc(t.catalyst) : '') + '</div>'
|
||||
+ '<div class="trig-cat">' + trigCatalyst(cat[(t.symbol || '').toUpperCase()] || cat[t.symbol], t.catalyst) + '</div>'
|
||||
+ '</div>';
|
||||
}).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 = '<span class="cat-off">🧠 catalyst engine off</span>';
|
||||
el.title = 'Local-LLM news enrichment disabled (OLLAMA_ENABLED / NEWS_ENRICH_ENABLED)';
|
||||
return;
|
||||
}
|
||||
const veto = e.dilution_veto ? '<b class="on">veto</b>' : '<b class="off">veto off</b>';
|
||||
const score = e.score_blend ? ' · <b class="on">score</b>' : '';
|
||||
el.innerHTML = '🧠 <b>' + esc(e.model || 'llm') + '</b> · ' + (e.advisories || 0) + ' read'
|
||||
+ (e.dilutive ? ' · <span class="neg">' + e.dilutive + ' dilutive</span>' : '')
|
||||
+ ' · ' + veto + score
|
||||
+ (e.vetoes_today ? ' · <span class="neg">⚠ ' + e.vetoes_today + ' vetoed</span>' : '');
|
||||
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 || [];
|
||||
|
||||
Reference in new issue
Block a user