add signal generation feature; implement signals view in admin panel

This commit is contained in:
ImBenji
2026-04-23 22:00:36 +01:00
parent 53058ab94d
commit 9d89dc95e4
5 changed files with 1009 additions and 20 deletions
+17
View File
@@ -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')`);
}
+6
View File
@@ -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);
+255
View File
@@ -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 };