From 9d89dc95e47b012dbb289c3ab0b7d9465c106f28 Mon Sep 17 00:00:00 2001 From: ImBenji Date: Thu, 23 Apr 2026 22:00:36 +0100 Subject: [PATCH] add signal generation feature; implement signals view in admin panel --- admin.html | 637 +++++++++++++++++++++++++++++++++-- intelligence/db.js | 17 + intelligence/index.js | 6 + intelligence/signalWorker.js | 255 ++++++++++++++ src/routes/admin.js | 114 +++++++ 5 files changed, 1009 insertions(+), 20 deletions(-) create mode 100644 intelligence/signalWorker.js diff --git a/admin.html b/admin.html index 57b1964..838a9b4 100644 --- a/admin.html +++ b/admin.html @@ -602,18 +602,165 @@ } .graph-conn-row { - padding: 5px 0; font-size: 12px; + border-bottom: 1px solid var(--border-light); + } + + .graph-conn-row:last-child { + border-bottom: none; + } + + .graph-conn-head { display: flex; justify-content: space-between; + align-items: center; + gap: 8px; + padding: 6px 0; + cursor: pointer; + user-select: none; + } + + .graph-conn-head:hover .graph-conn-label { color: var(--accent); } + + .graph-conn-right { + display: flex; + align-items: center; gap: 8px; } + .graph-conn-toggle { + color: var(--muted-dark); + font-size: 10px; + width: 10px; + display: inline-block; + } + + .graph-conn-row.expanded .graph-conn-toggle { color: var(--accent); } + .graph-conn-sector { color: var(--muted-dark); font-size: 11px; } + .graph-conn-body { + display: none; + padding: 4px 0 10px 4px; + border-left: 2px solid var(--border); + margin-left: 2px; + padding-left: 10px; + } + + .graph-evidence-loading, + .graph-evidence-error, + .graph-evidence-empty { + color: var(--muted-dark); + font-size: 11px; + padding: 4px 0; + } + .graph-evidence-error { color: var(--destructive-fg); } + + .graph-evidence-stats { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + color: var(--muted); + padding: 2px 0 6px; + } + + .graph-evidence-badge { + display: inline-block; + padding: 1px 7px; + border-radius: 10px; + background: var(--border); + color: var(--foreground); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.4px; + } + + .graph-evidence-section { + margin-top: 8px; + margin-bottom: 4px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--muted-dark); + } + + .graph-evidence-fact { + padding: 5px 0; + border-bottom: 1px dashed var(--border-light); + } + .graph-evidence-fact:last-child { border-bottom: none; } + + .graph-evidence-claim { + font-size: 11.5px; + color: var(--foreground); + line-height: 1.4; + } + + .graph-evidence-meta { + font-size: 10.5px; + color: var(--muted-dark); + margin-top: 2px; + } + + .graph-evidence-event { + margin: 4px 0; + font-size: 11px; + } + + .graph-evidence-event > summary { + cursor: pointer; + list-style: none; + display: flex; + justify-content: space-between; + gap: 8px; + padding: 4px 0; + color: var(--foreground); + } + .graph-evidence-event > summary::-webkit-details-marker { display: none; } + .graph-evidence-event > summary:hover { color: var(--accent); } + + .graph-evidence-event-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .graph-evidence-event-id { + color: var(--muted-dark); + font-size: 10px; + flex-shrink: 0; + } + + .graph-evidence-articles { + list-style: none; + padding: 4px 0 6px 10px; + margin: 0; + border-left: 1px solid var(--border-light); + } + + .graph-evidence-articles li { + padding: 4px 0; + } + + .graph-evidence-articles a { + color: var(--accent); + text-decoration: none; + font-size: 11px; + line-height: 1.4; + display: block; + } + .graph-evidence-articles a:hover { text-decoration: underline; } + + .graph-evidence-article-meta { + color: var(--muted-dark); + font-size: 10px; + margin-top: 1px; + } + .graph-empty-msg { color: var(--muted-dark); font-size: 13px; @@ -628,11 +775,177 @@ font-family: inherit; } + .graph-node-label-untracked { + fill: var(--muted-dark); + font-size: 10px; + } + .ig-label-bg { fill: var(--bg-subtle); fill-opacity: 0.8; } + .graph-conn-row.untracked .graph-conn-head { + cursor: default; + } + .graph-conn-row.untracked .graph-conn-head:hover .graph-conn-label { + color: inherit; + } + .graph-conn-row.untracked .graph-conn-label { + color: var(--muted); + } + .graph-conn-row.untracked .graph-conn-sector { + font-style: italic; + } + + + /* ── signal cards ── */ + + #intel-signals-wrap { + display: none; + } + + .signal-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 14px; + } + + .signal-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; + cursor: pointer; + transition: border-color .15s; + } + + .signal-card:hover { border-color: var(--muted-dark); } + + .signal-card-glance { + padding: 16px; + } + + .signal-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 10px; + gap: 8px; + } + + .signal-company { + font-size: 14px; + font-weight: 600; + color: var(--foreground); + line-height: 1.2; + } + + .signal-ticker { + font-size: 11px; + color: var(--muted); + margin-top: 2px; + } + + .signal-badge { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 5px; + font-size: 12px; + font-weight: 700; + letter-spacing: .04em; + flex-shrink: 0; + } + + .signal-badge.BUY { background: rgba(20,83,45,.6); color: #4ade80; border: 1px solid rgba(74,222,128,.2); } + .signal-badge.HOLD { background: rgba(92,66,14,.6); color: #fbbf24; border: 1px solid rgba(251,191,36,.2); } + .signal-badge.SELL { background: rgba(127,29,29,.6); color: #f87171; border: 1px solid rgba(248,113,113,.2); } + + .signal-tags { + display: flex; + gap: 5px; + flex-wrap: wrap; + margin-bottom: 10px; + } + + .signal-tag { + background: var(--bg-subtle); + border: 1px solid var(--border); + color: var(--muted); + padding: 2px 7px; + border-radius: 3px; + font-size: 11px; + font-weight: 500; + } + + .signal-summary { + font-size: 12px; + color: var(--muted); + line-height: 1.5; + margin-bottom: 8px; + } + + .signal-ts { + font-size: 10.5px; + color: var(--muted-dark); + } + + .signal-card-detail { + display: none; + padding: 0 16px 16px; + border-top: 1px solid var(--border-light); + padding-top: 14px; + } + + .signal-card.expanded .signal-card-detail { display: block; } + + .signal-detail-section { + margin-bottom: 12px; + } + + .signal-detail-label { + font-size: 10.5px; + text-transform: uppercase; + letter-spacing: .06em; + color: var(--muted-dark); + font-weight: 600; + margin-bottom: 5px; + } + + .signal-detail-section ul { + padding-left: 16px; + margin: 0; + font-size: 12px; + color: var(--muted); + line-height: 1.7; + } + + .signal-detail-section p { + font-size: 12px; + color: var(--muted); + line-height: 1.6; + margin: 0; + } + + .signal-regen-btn { + margin-top: 10px; + font-size: 12px; + padding: 5px 12px; + background: transparent; + border-color: var(--border); + color: var(--muted); + } + + .signal-regen-btn:hover { color: var(--foreground); background: var(--bg-subtle); } + + .signal-empty { + color: var(--muted-dark); + font-size: 13px; + padding: 40px 0; + text-align: center; + } + @@ -740,6 +1053,7 @@ @@ -773,6 +1087,12 @@ + +
+
+ +
+
@@ -1253,10 +1573,18 @@ async function loadIntelligence() { const view = document.getElementById('i-view').value; if (view === 'graph') { + showSignalsView(false); await loadIntelGraph(); return; } + if (view === 'signals') { + showGraphView(false); + await loadSignals(); + return; + } + + showSignalsView(false); showGraphView(false); const companyId = document.getElementById('i-company').value; @@ -1452,31 +1780,55 @@ async function loadIntelGraph() { // map company_id → node id (we use ticker; fall back to synthetic id) const idByCompanyId = new Map(); + const untrackedIds = new Set(); graphNodes = []; for (const n of data.nodes) { - if (!n.tracked) continue; - const nodeId = n.ticker || `C${n.id}`; - idByCompanyId.set(n.id, nodeId); - graphNodes.push({ - id: nodeId, - label: n.name, - sector: inferSector(n.ticker, n.name), - }); + if (n.tracked) { + const nodeId = n.ticker || `C${n.id}`; + idByCompanyId.set(n.id, nodeId); + graphNodes.push({ + id: nodeId, + companyId: n.id, + label: n.name, + sector: inferSector(n.ticker, n.name), + tracked: true, + }); + } else { + // untracked entity — keyed by name (prefixed so it cant collide with a ticker) + const nodeId = `U:${n.name}`; + if (untrackedIds.has(nodeId)) continue; + untrackedIds.add(nodeId); + graphNodes.push({ + id: nodeId, + companyId: null, + label: n.name, + sector: null, + tracked: false, + }); + } } - // normalize edges: only tracked↔tracked, strict spec types, dedupe + // normalize edges: include tracked↔tracked and tracked→untracked, dedupe const seen = new Set(); graphEdges = []; for (const e of data.edges) { let type = (e.relationship_type || '').toLowerCase(); let src = idByCompanyId.get(e.from_company_id); - let tgt = e.to_company_id ? idByCompanyId.get(e.to_company_id) : null; + let tgt; + + if (e.to_company_id) { + tgt = idByCompanyId.get(e.to_company_id); + } else if (e.to_entity && untrackedIds.has(`U:${e.to_entity}`)) { + tgt = `U:${e.to_entity}`; + } + if (!src || !tgt) continue; // dependency is the reciprocal of investor — flip direction and normalize - if (type === 'dependency') { + // only safe to flip when both ends are tracked + if (type === 'dependency' && e.to_company_id) { type = 'investor'; [src, tgt] = [tgt, src]; } @@ -1549,7 +1901,7 @@ function renderIntelGraph() { .data(nodesCopy, d => d.id) .join('g') .attr('class', 'ig-node') - .style('cursor', 'pointer') + .style('cursor', d => d.tracked ? 'pointer' : 'default') .call(d3.drag() .on('start', dragStart) .on('drag', dragMove) @@ -1557,27 +1909,36 @@ function renderIntelGraph() { nodeSel.append('circle') .attr('r', graphNodeRadius) - .attr('fill', d => SECTOR_COLOR[d.sector] || '#888') - .attr('stroke', 'transparent') - .attr('stroke-width', 2) + .attr('fill', d => d.tracked ? (SECTOR_COLOR[d.sector] || '#888') : 'none') + .attr('stroke', d => d.tracked ? 'transparent' : '#6b7280') + .attr('stroke-width', d => d.tracked ? 2 : 1.5) .on('mouseenter', function (ev, d) { + if (!d.tracked) { + d3.select(this).attr('stroke', '#94a3b8'); + return; + } if (d.id !== graphSelectedId) { d3.select(this).attr('stroke', 'rgba(255,255,255,0.6)'); } }) .on('mouseleave', function (ev, d) { + if (!d.tracked) { + d3.select(this).attr('stroke', '#6b7280'); + return; + } if (d.id !== graphSelectedId) { d3.select(this).attr('stroke', 'transparent'); } }) .on('click', (ev, d) => { ev.stopPropagation(); + if (!d.tracked) return; selectGraphNode(d); }); // label text first (to measure), then bg rect inserted before it nodeSel.append('text') - .attr('class', 'graph-node-label') + .attr('class', d => d.tracked ? 'graph-node-label' : 'graph-node-label graph-node-label-untracked') .attr('text-anchor', 'middle') .attr('y', d => graphNodeRadius(d) + 13) .text(d => d.label); @@ -1672,8 +2033,14 @@ function applyGraphFilters() { .style('opacity', d => (lit && !lit.has(d.id)) ? 0.15 : 1); nodeSel.select('circle') - .attr('stroke', d => d.id === graphSelectedId ? '#ffffff' : 'transparent') - .attr('stroke-width', d => d.id === graphSelectedId ? 2.5 : 2); + .attr('stroke', d => { + if (!d.tracked) return '#6b7280'; + return d.id === graphSelectedId ? '#ffffff' : 'transparent'; + }) + .attr('stroke-width', d => { + if (!d.tracked) return 1.5; + return d.id === graphSelectedId ? 2.5 : 2; + }); linkSel .style('display', e => { @@ -1747,7 +2114,30 @@ function renderGraphInfo(node) { any = true; html += `
${type} (${list.length})
`; for (const o of list) { - html += `
${escapeHtmlG(o.label)}${escapeHtmlG(o.sector)}
`; + const canExpand = !!o.companyId; + if (canExpand) { + html += ` +
+
+ ${escapeHtmlG(o.label)} + + ${escapeHtmlG(o.sector)} + + +
+
+
`; + } else { + html += ` +
+
+ ${escapeHtmlG(o.label)} + + untracked + +
+
`; + } } } @@ -1756,6 +2146,96 @@ function renderGraphInfo(node) { } document.getElementById('graph-info').innerHTML = html; + + document.querySelectorAll('#graph-info .graph-conn-row').forEach(row => { + if (row.classList.contains('untracked')) return; + row.querySelector('.graph-conn-head').addEventListener('click', () => toggleEvidenceRow(row)); + }); +} + + +async function toggleEvidenceRow(row) { + const body = row.querySelector('.graph-conn-body'); + const toggle = row.querySelector('.graph-conn-toggle'); + + const expanded = row.classList.toggle('expanded'); + toggle.textContent = expanded ? '▾' : '▸'; + + if (!expanded) { + body.style.display = 'none'; + return; + } + + body.style.display = ''; + + if (row.dataset.loaded) return; + + body.innerHTML = '
Loading evidence…
'; + + const fromId = row.dataset.from; + const toId = row.dataset.to; + const type = row.dataset.type; + + try { + const data = await api(`/admin/api/intelligence/edge-evidence?from_id=${fromId}&to_id=${toId}&type=${encodeURIComponent(type)}`); + + let html = ''; + + if (data.edge) { + html += `
+ ${escapeHtmlG(data.edge.confidence || 'low')} + ×${data.edge.confirmation_count || 1} confirmations +
`; + } + + if (data.facts && data.facts.length) { + html += '
Backing facts
'; + for (const f of data.facts) { + html += `
+
${escapeHtmlG(f.claim)}
+
${escapeHtmlG(f.confidence || 'low')} · ×${f.confirmation_count || 1}
+
`; + } + } + + if (data.events && data.events.length) { + html += `
Source events (${data.events.length})
`; + for (const ev of data.events) { + const title = ev.title || `Event #${ev.id}`; + html += `
+ + ${escapeHtmlG(title)} + #${ev.id} + `; + + if (ev.articles && ev.articles.length) { + html += '
    '; + for (const a of ev.articles) { + const date = a.pub_date ? String(a.pub_date).slice(0, 10) : ''; + html += `
  • + ${escapeHtmlG(a.title || a.url || 'untitled')} + +
  • `; + } + html += '
'; + } else { + html += '
No articles.
'; + } + + html += '
'; + } + } + + if (!html) { + html = '
No backing evidence recorded.
'; + } + + body.innerHTML = html; + row.dataset.loaded = '1'; + + } catch (err) { + body.innerHTML = '
Failed to load evidence.
'; + } } @@ -1821,6 +2301,123 @@ document.getElementById('intelOverlay').onclick = function(e) { if (e.target === this) this.classList.remove('open'); }; +// ── signals ──────────────────────────────────────────────────────────────── + +function showSignalsView(visible) { + document.getElementById('intel-signals-wrap').style.display = visible ? 'block' : 'none'; + document.getElementById('intel-table-wrap').style.display = visible ? 'none' : ''; + document.getElementById('intel-pagination').style.display = visible ? 'none' : ''; + document.getElementById('intel-graph-wrap').style.display = 'none'; +} + + +async function loadSignals() { + showSignalsView(true); + + document.getElementById('i-type').parentElement.style.display = 'none'; + document.getElementById('i-sort-wrap').style.display = 'none'; + + let data; + try { + data = await api('/admin/api/intelligence/signals'); + } catch (_) { + data = []; + } + + const grid = document.getElementById('intel-signals-grid'); + const empty = document.getElementById('intel-signals-empty'); + + if (!data || data.length === 0) { + grid.innerHTML = ''; + empty.style.display = ''; + return; + } + + empty.style.display = 'none'; + + grid.innerHTML = data.map(s => { + const firstSentence = (s.summary || '').split(/\.\s+/)[0].replace(/\.$/, '') + '.'; + const ts = s.generated_at ? s.generated_at.slice(0, 16).replace('T', ' ') : '—'; + + let drivers = []; + let risks = []; + let predIds = []; + try { drivers = JSON.parse(s.key_drivers || '[]'); } catch (_) {} + try { risks = JSON.parse(s.risk_factors || '[]'); } catch (_) {} + try { predIds = JSON.parse(s.supporting_prediction_ids || '[]'); } catch (_) {} + + const driverItems = drivers.map(d => `
  • ${escapeHtml(d)}
  • `).join(''); + const riskItems = risks.map(r => `
  • ${escapeHtml(r)}
  • `).join(''); + + return ` +
    +
    +
    +
    +
    ${escapeHtml(s.company_name)}
    +
    ${escapeHtml(s.ticker)}
    +
    + ${s.signal} +
    +
    + conf: ${s.confidence} + risk: ${s.risk_level} + ${s.timeframe} +
    +
    ${escapeHtml(firstSentence)}
    +
    Generated ${ts}
    +
    +
    +
    +
    Summary
    +

    ${escapeHtml(s.summary || '—')}

    +
    + ${driverItems ? `
    Key drivers
      ${driverItems}
    ` : ''} + ${riskItems ? `
    Risk factors
      ${riskItems}
    ` : ''} +
    +
    Data used
    +

    ${predIds.length} predictions  ·  window: 90 days

    +
    + +
    +
    + `; + }).join(''); +} + + +function toggleSignalCard(companyId) { + const card = document.getElementById(`signal-card-${companyId}`); + if (card) card.classList.toggle('expanded'); +} + + +async function regenerateSignal(ev, companyId) { + ev.stopPropagation(); + + try { + await api(`/admin/api/intelligence/signals/${companyId}`, { method: 'DELETE' }); + toast('Signal cleared — worker will regenerate on next cycle'); + const card = document.getElementById(`signal-card-${companyId}`); + if (card) card.remove(); + + const grid = document.getElementById('intel-signals-grid'); + if (!grid.children.length) { + document.getElementById('intel-signals-empty').style.display = ''; + } + } catch (_) { + toast('Failed to clear signal', true); + } +} + + +function escapeHtml(s) { + return String(s || '').replace(/[&<>"']/g, c => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' + }[c])); +} + + // ── sql console ──────────────────────────────────────────────────────────── async function runSql() { diff --git a/intelligence/db.js b/intelligence/db.js index 83e125b..ec5e089 100644 --- a/intelligence/db.js +++ b/intelligence/db.js @@ -120,6 +120,23 @@ function runColumnMigrations(db) { CREATE INDEX IF NOT EXISTS idx_worker_events_lookup ON worker_events (worker, completed_at); `); + db.exec(` + CREATE TABLE IF NOT EXISTS trade_signals ( + id INTEGER PRIMARY KEY, + company_id INTEGER, + signal TEXT, + confidence TEXT, + timeframe TEXT, + risk_level TEXT, + risk_factors TEXT, + summary TEXT, + key_drivers TEXT, + supporting_prediction_ids TEXT, + generated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + window_days INTEGER + ); + `); + // prune rows older than 1 hour so the table doesnt grow unbounded db.exec(`DELETE FROM worker_events WHERE completed_at < datetime('now', '-1 hour')`); } diff --git a/intelligence/index.js b/intelligence/index.js index ea48d51..d0fe233 100644 --- a/intelligence/index.js +++ b/intelligence/index.js @@ -7,6 +7,7 @@ const { runAugorWorker } = require("./augorWorker"); const { ensureCompanyEmbeddings } = require("./embeddings"); const { runConsolidationWorker } = require("./consolidationWorker"); const { runGraphWorker } = require("./graphWorker"); +const { runSignalWorker } = require("./signalWorker"); const configPath = path.resolve(__dirname, "../config.json"); @@ -64,6 +65,11 @@ runGraphWorker(archiveDb, intelligenceDb, config).catch(err => { process.exit(1); }); +runSignalWorker(archiveDb, intelligenceDb, config).catch(err => { + console.error("[signal] fatal:", err); + process.exit(1); +}); + process.on("SIGINT", () => { console.log("[intelligence] shutting down"); process.exit(0); diff --git a/intelligence/signalWorker.js b/intelligence/signalWorker.js new file mode 100644 index 0000000..c0ef82b --- /dev/null +++ b/intelligence/signalWorker.js @@ -0,0 +1,255 @@ +const https = require("https"); +const http = require("http"); + + +async function runSignalWorker(archiveDb, intelligenceDb, config) { + const loopDelay = config.workers?.signalLoopDelayMs ?? 120000; + const llmConfig = config.openRouter || {}; + + const getCompanies = intelligenceDb.prepare("SELECT * FROM tracked_companies ORDER BY id"); + + const getLastSignal = intelligenceDb.prepare(` + SELECT generated_at FROM trade_signals WHERE company_id = ? ORDER BY generated_at DESC LIMIT 1 + `); + + const getFacts = intelligenceDb.prepare(` + SELECT claim, type, confidence, confirmation_count + FROM company_facts + WHERE company_id = ? + ORDER BY confirmation_count DESC + LIMIT 40 + `); + + const getPredictions = intelligenceDb.prepare(` + SELECT type, direction, magnitude, timeframe, rationale, event_date, id + FROM event_predictions + WHERE company_id = ? + AND created_at >= datetime('now', '-90 days') + ORDER BY created_at DESC + LIMIT 50 + `); + + const getRelationships = intelligenceDb.prepare(` + SELECT relationship_type, to_entity, confidence, confirmation_count + FROM company_relationships + WHERE from_company_id = ? + ORDER BY confirmation_count DESC + LIMIT 20 + `); + + const deleteSignal = intelligenceDb.prepare( + "DELETE FROM trade_signals WHERE company_id = ?" + ); + + const insertSignal = intelligenceDb.prepare(` + INSERT INTO trade_signals + (company_id, signal, confidence, timeframe, risk_level, risk_factors, summary, key_drivers, supporting_prediction_ids, window_days) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 90) + `); + + const recordEvent = intelligenceDb.prepare( + `INSERT INTO worker_events (worker) VALUES ('signal')` + ); + + const pruneEvents = intelligenceDb.prepare( + `DELETE FROM worker_events WHERE worker = 'signal' AND completed_at < datetime('now', '-1 hour')` + ); + + let pruneCounter = 0; + + while (true) { + try { + const companies = getCompanies.all(); + + // pick the company with the oldest (or missing) signal + let target = null; + let oldestTs = null; + + for (const company of companies) { + const row = getLastSignal.get(company.id); + + if (!row) { + // never generated — highest priority + target = company; + break; + } + + if (oldestTs === null || row.generated_at < oldestTs) { + oldestTs = row.generated_at; + target = company; + } + } + + if (!target) { + await sleep(loopDelay); + continue; + } + + + const predictions = getPredictions.all(target.id); + + if (predictions.length < 3) { + await sleep(loopDelay); + continue; + } + + const facts = getFacts.all(target.id); + const relationships = getRelationships.all(target.id); + + const prompt = buildPrompt(target.name, facts, relationships, predictions); + + let result; + try { + result = await callLlm(llmConfig, prompt); + } catch (err) { + console.error(`[signal] LLM error for ${target.name}:`, err.message); + await sleep(loopDelay); + continue; + } + + if (!result) { + console.log(`[signal] ${target.name} — LLM returned null, skipping`); + await sleep(loopDelay); + continue; + } + + const predictionIds = predictions.map(p => p.id); + + const write = intelligenceDb.transaction(() => { + deleteSignal.run(target.id); + insertSignal.run( + target.id, + result.signal, + result.confidence, + result.timeframe, + result.risk_level, + JSON.stringify(result.risk_factors || []), + result.summary, + JSON.stringify(result.key_drivers || []), + JSON.stringify(predictionIds) + ); + }); + + write(); + + recordEvent.run(); + pruneCounter++; + if (pruneCounter >= 20) { pruneEvents.run(); pruneCounter = 0; } + + console.log(`[signal] ${target.name} — ${result.signal} (${result.confidence} confidence, ${result.risk_level} risk)`); + + } catch (err) { + console.error("[signal] cycle error:", err.message); + } + + await sleep(loopDelay); + } +} + + +function buildPrompt(companyName, facts, relationships, predictions) { + const factsBlock = facts.length > 0 + ? facts.map(f => `- ${f.claim} (confirmed ${f.confirmation_count}x)`).join("\n") + : "No known facts yet."; + + const relBlock = relationships.length > 0 + ? relationships.map(r => `- ${r.relationship_type}: ${r.to_entity} (${r.confidence})`).join("\n") + : "No known relationships."; + + const predBlock = predictions.map((p, i) => + `${i + 1}. [${p.type}] ${p.direction} / ${p.magnitude} / ${p.timeframe} — ${p.rationale || "no rationale"}` + ).join("\n"); + + return `You are a financial intelligence analyst generating a trade signal for ${companyName}. + +Known facts about ${companyName} (most confirmed first): +${factsBlock} + +Known relationships: +${relBlock} + +Recent event predictions (last 90 days): +${predBlock} + +Generate a trade signal as JSON with this exact shape: +{ + "signal": "BUY | HOLD | SELL", + "confidence": "low | medium | high | very_high", + "timeframe": "short | medium | long", + "risk_level": "low | medium | high | very_high", + "risk_factors": ["string", ...], + "key_drivers": ["string", ...], + "summary": "2-3 sentence plain English summary" +} + +Risk factors should be derived from: +- Supply chain concentration (heavy dependence on single suppliers) +- Geopolitical exposure (relationships with entities in sensitive regions) +- Competitive threats (strong competitors gaining ground) +- Regulatory exposure (themes mentioning regulation or export controls) +- Negative prediction patterns in recent events + +Only output valid JSON. Always respond in English.`; +} + + +async function callLlm(llmConfig, prompt) { + const body = JSON.stringify({ + model: llmConfig.llmModel || llmConfig.model, + messages: [{ role: "user", content: prompt }], + temperature: 0.1, + }); + + const url = new URL("https://openrouter.ai/api/v1/chat/completions"); + + const responseText = await httpPost(url, body, { + "Content-Type": "application/json", + "Authorization": `Bearer ${llmConfig.apiKey || ""}`, + }); + + let parsed; + try { + parsed = JSON.parse(responseText); + } catch (_) { + throw new Error(`LLM response not JSON: ${responseText.slice(0, 300)}`); + } + + const content = parsed.choices?.[0]?.message?.content; + if (!content) return null; + + const stripped = content.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "").trim(); + return JSON.parse(stripped); +} + + +function httpPost(url, body, headers) { + return new Promise((resolve, reject) => { + const lib = url.protocol === "https:" ? https : http; + + const req = lib.request({ + hostname: url.hostname, + port: url.port || (url.protocol === "https:" ? 443 : 80), + path: url.pathname + url.search, + method: "POST", + headers: { ...headers, "Content-Length": Buffer.byteLength(body) }, + }, (res) => { + let data = ""; + res.on("data", chunk => data += chunk); + res.on("end", () => { + if (res.statusCode >= 200 && res.statusCode < 300) resolve(data); + else reject(new Error(`LLM ${res.statusCode}: ${data.slice(0, 300)}`)); + }); + }); + + req.on("error", reject); + req.write(body); + req.end(); + }); +} + + +function sleep(ms) { + return new Promise(r => setTimeout(r, ms)); +} + +module.exports = { runSignalWorker }; diff --git a/src/routes/admin.js b/src/routes/admin.js index 662812b..795ad00 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -364,6 +364,37 @@ async function adminRoutes(fastify) { }); + // trade signals + fastify.get('/admin/api/intelligence/signals', async (request, reply) => { + if (!checkAuth(request, reply)) return; + const db = getIntelligenceDb(); + if (!db) return []; + + let rows; + try { + rows = db.prepare(` + SELECT ts.*, tc.name as company_name, tc.ticker + FROM trade_signals ts + JOIN tracked_companies tc ON tc.id = ts.company_id + ORDER BY ts.generated_at DESC + `).all(); + } catch (_) { + return []; + } + + return rows; + }); + + fastify.delete('/admin/api/intelligence/signals/:company_id', async (request, reply) => { + if (!checkAuth(request, reply)) return; + const db = getIntelligenceDb(); + if (!db) { reply.code(503); return { error: 'intelligence db unavailable' }; } + + const companyId = parseInt(request.params.company_id, 10); + db.prepare('DELETE FROM trade_signals WHERE company_id = ?').run(companyId); + return { ok: true }; + }); + // per-company facts for the graph sidebar fastify.get('/admin/api/intelligence/facts/:company_id', async (request, reply) => { if (!checkAuth(request, reply)) return; @@ -381,6 +412,89 @@ async function adminRoutes(fastify) { }); + // evidence backing a single graph edge — the relationship row itself, + // the consolidated facts that produced it, and the source events+articles. + fastify.get('/admin/api/intelligence/edge-evidence', async (request, reply) => { + if (!checkAuth(request, reply)) return; + const idb = getIntelligenceDb(); + if (!idb) return { edge: null, facts: [], events: [] }; + + const fromId = parseInt(request.query.from_id, 10); + const toId = parseInt(request.query.to_id, 10); + const type = (request.query.type || '').toLowerCase(); + + if (!fromId || !toId || !type) { + reply.code(400); + return { error: 'missing from_id / to_id / type' }; + } + + // direct edge — try the exact match first + let edge = idb.prepare(` + SELECT * FROM company_relationships + WHERE from_company_id = ? AND to_company_id = ? AND relationship_type = ? + `).get(fromId, toId, type); + + // investor edges are stored as 'dependency' in the reverse direction too — + // the frontend normalizes dependency→investor, so lookup the flipped row + if (!edge && type === 'investor') { + edge = idb.prepare(` + SELECT * FROM company_relationships + WHERE from_company_id = ? AND to_company_id = ? AND relationship_type = 'dependency' + `).get(toId, fromId); + } + + const toCompany = idb.prepare(`SELECT name FROM tracked_companies WHERE id = ?`).get(toId); + const toName = toCompany ? toCompany.name : null; + + // backing facts — relationship-type facts on the source company that mention the target + let facts = []; + if (toName) { + facts = idb.prepare(` + SELECT id, claim, confidence, confirmation_count, supporting_event_ids, first_seen_at, last_seen_at + FROM company_facts + WHERE company_id = ? AND type = 'relationship' AND claim LIKE ? + ORDER BY confirmation_count DESC + LIMIT 20 + `).all(fromId, `%${toName}%`); + } + + // merge event ids from the edge row + all backing facts + const eventIds = new Set(); + if (edge && edge.supporting_event_ids) { + try { JSON.parse(edge.supporting_event_ids).forEach(id => eventIds.add(id)); } catch (_) {} + } + for (const f of facts) { + try { JSON.parse(f.supporting_event_ids || '[]').forEach(id => eventIds.add(id)); } catch (_) {} + } + + // resolve events + articles from archive.sqlite + const events = []; + if (eventIds.size > 0) { + const ids = [...eventIds]; + const placeholders = ids.map(() => '?').join(','); + const eventRows = db.prepare(` + SELECT id, title, created_at FROM events + WHERE id IN (${placeholders}) + ORDER BY created_at DESC + `).all(...ids); + + const artStmt = db.prepare(` + SELECT id, title, source, pub_date, url + FROM articles + WHERE event_id = ? + ORDER BY COALESCE(pub_date, ingested_at) DESC + LIMIT 8 + `); + + for (const ev of eventRows) { + events.push({ ...ev, articles: artStmt.all(ev.id) }); + } + } + + return { edge, facts, events }; + }); + + // raw sql console — supports multiple statements separated by ; fastify.post('/admin/api/sql', async (request, reply) => { if (!checkAuth(request, reply)) return;