add signal generation feature; implement signals view in admin panel
This commit is contained in:
@@ -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')`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user