feat: add autonomous paper-trading and calibration pipeline

This commit is contained in:
ImBenji
2026-08-03 14:03:27 +01:00
parent 5a9a2e4c6d
commit c4028cc394
46 changed files with 2246 additions and 115 deletions
+111 -12
View File
@@ -2,6 +2,11 @@ const https = require("https");
const http = require("http");
const { findMatchedCompaniesByEmbedding } = require("./embeddings");
const { getPriceContext, formatPriceContext } = require("./priceContext");
const REPROCESS_MIN_NEW_ARTICLES = 3;
const REPROCESS_COOLDOWN_HOURS = 6;
async function runAugorWorker(archiveDb, intelligenceDb, config) {
const loopDelay = config.workers?.augorLoopDelayMs ?? 1500;
@@ -11,6 +16,26 @@ async function runAugorWorker(archiveDb, intelligenceDb, config) {
SELECT * FROM article_queue WHERE status = 'pending' LIMIT 1
`);
const getProcessingState = intelligenceDb.prepare(
"SELECT last_processed_at, articles_at_last_run FROM event_processing_state WHERE event_id = ?"
);
const upsertProcessingState = intelligenceDb.prepare(`
INSERT INTO event_processing_state (event_id, last_processed_at, articles_at_last_run)
VALUES (?, CURRENT_TIMESTAMP, ?)
ON CONFLICT(event_id) DO UPDATE SET
last_processed_at = CURRENT_TIMESTAMP,
articles_at_last_run = excluded.articles_at_last_run
`);
const getCompanyAccuracy = intelligenceDb.prepare(`
SELECT
COUNT(*) as total,
SUM(correct_10d) as correct
FROM prediction_outcomes
WHERE company_id = ? AND correct_10d IS NOT NULL
`);
const recordEvent = intelligenceDb.prepare(
`INSERT INTO worker_events (worker) VALUES ('augor')`
);
@@ -45,8 +70,8 @@ async function runAugorWorker(archiveDb, intelligenceDb, config) {
VALUES (?, ?, ?, ?, ?)
`);
const insertPrediction = intelligenceDb.prepare(`
INSERT INTO event_predictions (event_id, company_id, type, direction, magnitude, timeframe, rationale, event_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO event_predictions (event_id, company_id, type, direction, magnitude, timeframe, rationale, probability, event_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const getEventDate = archiveDb.prepare(`
@@ -66,7 +91,6 @@ async function runAugorWorker(archiveDb, intelligenceDb, config) {
const queueRow = getPending.get();
if (!queueRow) {
await sleep(loopDelay);
continue;
}
@@ -98,12 +122,29 @@ async function runAugorWorker(archiveDb, intelligenceDb, config) {
const eventArticleIds = eventArticles.map(a => a.id);
// event-level batching guard — skip re-processing if we already ran on this event recently
// and not enough new articles have arrived to justify another LLM call
const state = getProcessingState.get(eventId);
if (state && state.last_processed_at) {
const newArticles = eventArticles.length - state.articles_at_last_run;
const lastRunMs = new Date(state.last_processed_at + "Z").getTime();
const hoursSince = (Date.now() - lastRunMs) / 3_600_000;
if (newArticles < REPROCESS_MIN_NEW_ARTICLES && hoursSince < REPROCESS_COOLDOWN_HOURS) {
for (const r of getEventArticleIds.all(eventId)) setStatusByArticleId.run(r.id);
console.log(`[augor] event ${eventId} — only ${newArticles} new articles in ${hoursSince.toFixed(1)}h, skipping re-process`);
continue;
}
}
const matchedCompanies = findMatchedCompaniesByEmbedding(
eventArticleIds, archiveDb, intelligenceDb, config
);
if (matchedCompanies.length === 0) {
for (const r of getEventArticleIds.all(eventId)) setStatusByArticleId.run(r.id);
upsertProcessingState.run(eventId, eventArticles.length);
console.log(`[augor] event ${eventId} — no company match, skipped`);
continue;
}
@@ -114,6 +155,7 @@ async function runAugorWorker(archiveDb, intelligenceDb, config) {
const eventDateRow = getEventDate.get(eventId);
const eventDate = eventDateRow ? eventDateRow.pub_date_effective : null;
const eventDateOnly = eventDate ? eventDate.slice(0, 10) : null;
const articleText = eventArticles.map((a, i) => {
const body = (a.content || a.description || "").slice(0, 2000);
@@ -130,9 +172,30 @@ async function runAugorWorker(archiveDb, intelligenceDb, config) {
factsBlock = `Known facts about ${company.name}:\n${lines}`;
}
const result = await callLlm(llmConfig, buildPrompt(company.name, event.title, articleText, factsBlock));
// pull live market context for the company at the time of the event
let priceBlock = null;
if (company.ticker && eventDateOnly) {
try {
const snapshot = await getPriceContext(intelligenceDb, company.ticker, eventDateOnly);
priceBlock = formatPriceContext(snapshot, company.ticker);
} catch (_) {}
}
// historical accuracy of past predictions for this company
let accuracyBlock = null;
const acc = getCompanyAccuracy.get(company.id);
if (acc && acc.total >= 5) {
const pct = (acc.correct / acc.total * 100).toFixed(0);
accuracyBlock = `Past prediction accuracy for ${company.name}: ${pct}% over ${acc.total} evaluated calls.`;
}
const result = await callLlm(llmConfig, buildPrompt(company.name, event.title, articleText, factsBlock, priceBlock, accuracyBlock));
if (result) {
const seenPreds = new Set();
const writeAll = intelligenceDb.transaction(() => {
for (const r of (result.knowledge?.relationships || [])) {
insertKnowledge.run(eventId, company.id, "relationship", JSON.stringify(r), eventDate);
@@ -145,7 +208,21 @@ async function runAugorWorker(archiveDb, intelligenceDb, config) {
}
for (const p of (result.predictions || [])) {
insertPrediction.run(eventId, company.id, p.type, p.direction, p.magnitude, p.timeframe, p.rationale, eventDate);
// hard filter — neutral predictions are dead weight, skip them
if (p.direction === "neutral") continue;
if (p.direction !== "positive" && p.direction !== "negative") continue;
const key = `${p.type}|${p.direction}|${p.magnitude}|${p.timeframe}`;
if (seenPreds.has(key)) continue;
seenPreds.add(key);
const prob = typeof p.probability === "number" && p.probability >= 0 && p.probability <= 1
? p.probability
: null;
insertPrediction.run(
eventId, company.id, p.type, p.direction, p.magnitude, p.timeframe, p.rationale, prob, eventDate
);
}
});
@@ -158,6 +235,7 @@ async function runAugorWorker(archiveDb, intelligenceDb, config) {
}
for (const r of getEventArticleIds.all(eventId)) setStatusByArticleId.run(r.id);
upsertProcessingState.run(eventId, eventArticles.length);
recordEvent.run();
pruneCounter++;
if (pruneCounter >= 100) { pruneEvents.run(); pruneCounter = 0; }
@@ -165,23 +243,44 @@ async function runAugorWorker(archiveDb, intelligenceDb, config) {
} catch (err) {
console.error("[augor] error:", err.message);
} finally {
// Enforce pacing on every path, including the many early `continue`
// branches for skipped or already-processed events.
await sleep(loopDelay);
}
}
}
function buildPrompt(companyName, eventTitle, articleText, factsBlock) {
function buildPrompt(companyName, eventTitle, articleText, factsBlock, priceBlock, accuracyBlock) {
const factsPart = factsBlock ? `${factsBlock}\n\n` : "";
const pricePart = priceBlock ? `Market context for ${companyName}:\n${priceBlock}\n\n` : "";
const accPart = accuracyBlock ? `${accuracyBlock}\n\n` : "";
return `You are a financial intelligence analyst focused on ${companyName}. Always respond in English regardless of the language of the input articles.
return `You are a financial intelligence analyst. Always respond in English.
${factsPart}Assess the impact of the following news event on ${companyName} given what you already know about the company.
Event: ${eventTitle}
${factsPart}${pricePart}${accPart}Event: ${eventTitle}
${articleText}
Return JSON only — no explanation. Shape:
Analyze the impact of this event on ${companyName}. Return JSON only — no explanation.
Strict rules — read carefully:
- predictions must be directly caused by THIS specific event — no speculation, no priced-in narrative
- only emit a prediction if the evidence is unambiguous AND the effect on ${companyName} is concrete and quantifiable
- the default answer is no prediction. an empty predictions array is the correct output for most news. only emit one when the event clearly moves the needle
- never emit two predictions that share type+direction+magnitude+timeframe — collapse them into one
- direction must be "positive" or "negative" only — no neutral predictions, ever. if the impact is unclear, emit nothing
- magnitude:
"high" = major revenue/market-share shift, expected >5% stock move
"medium" = measurable but limited, expected 1-5% stock move
omit any prediction that doesnt clear the medium bar
- timeframe:
"short" = days to 2 weeks (use sparingly — short-horizon predictions are unreliable)
"medium" = 2 weeks to 3 months
"long" = 3+ months — preferred when the thesis is structural
- probability: your honest calibrated probability that the directional call is correct over the stated timeframe, as a number between 0.5 and 0.95. if you cant honestly assign >= 0.6, dont emit the prediction
- if the market context shows the stock has already moved sharply (>10% in 30 days), be sceptical that this event adds new information — the move may already be priced in
{
"knowledge": {
"relationships": [
@@ -195,7 +294,7 @@ Return JSON only — no explanation. Shape:
]
},
"predictions": [
{ "type": "market_share|stock_price|competitive_position|other", "direction": "positive|negative|neutral", "magnitude": "high|medium|low", "timeframe": "short|medium|long", "rationale": "string" }
{ "type": "market_share|stock_price|competitive_position|other", "direction": "positive|negative", "magnitude": "high|medium", "timeframe": "short|medium|long", "probability": 0.0, "rationale": "string" }
]
}
+11
View File
@@ -0,0 +1,11 @@
const path = require('path');
const { runAutonomyWorker } = require('./autonomyWorker');
runAutonomyWorker({
archivePath: process.env.DURIIN_DB || path.resolve('/data/archive.sqlite'),
intelligencePath: process.env.INTELLIGENCE_DB || path.resolve('/data/intelligence.sqlite'),
pollMs: Number(process.env.AUTONOMY_POLL_MS) || 1000,
}).catch((error) => {
console.error('[autonomy] fatal:', error);
process.exit(1);
});
+98
View File
@@ -0,0 +1,98 @@
const os = require('os');
const Database = require('better-sqlite3');
const { initAutonomySchema } = require('../src/autonomy/schema');
const { enqueueJob, leaseNextJob, completeJob, failJob } = require('../src/autonomy/jobs');
function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
function reconcileArchiveBatch(archiveDb, intelligenceDb, batchSize = 250) {
const cursor = intelligenceDb.prepare("SELECT value FROM autonomy_cursors WHERE key = 'archive_reconcile'").get();
const afterId = cursor ? cursor.value : 0;
const rows = archiveDb.prepare(`
SELECT id, event_id, ingested_at, content, has_embedding
FROM articles
WHERE id > ?
ORDER BY id ASC
LIMIT ?
`).all(afterId, batchSize);
if (!rows.length) {
intelligenceDb.prepare(`
INSERT INTO autonomy_cursors(key, value) VALUES ('archive_reconcile', 0)
ON CONFLICT(key) DO UPDATE SET value = 0, updated_at = datetime('now')
`).run();
return { scanned: 0, nextCursor: 0, reset: true };
}
const enqueue = intelligenceDb.transaction(() => {
for (const row of rows) {
const isLive = row.ingested_at && Date.now() - Date.parse(row.ingested_at) <= 48 * 60 * 60 * 1000;
const readyForIntelligence = row.event_id && row.content && row.has_embedding;
if (readyForIntelligence) {
enqueueJob(intelligenceDb, {
jobType: 'coordinator_event',
lane: isLive ? 'live' : 'historical',
priority: isLive ? 100 : 10,
entityType: 'event',
entityId: row.event_id,
idempotencyKey: `coordinator_event:${row.event_id}`,
});
}
}
intelligenceDb.prepare(`
INSERT INTO autonomy_cursors(key, value) VALUES ('archive_reconcile', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')
`).run(rows[rows.length - 1].id);
});
enqueue();
return { scanned: rows.length, nextCursor: rows[rows.length - 1].id, reset: false };
}
function reconcileLiveBatch(archiveDb, intelligenceDb, batchSize = 250) {
const rows = archiveDb.prepare(`
SELECT id, event_id, ingested_at, content, has_embedding
FROM articles
WHERE ingested_at >= datetime('now', '-48 hours')
ORDER BY ingested_at DESC, id DESC
LIMIT ?
`).all(batchSize);
let queued = 0;
for (const row of rows) {
if (!row.event_id || !row.content || !row.has_embedding) continue;
const result = enqueueJob(intelligenceDb, {
jobType: 'coordinator_event', lane: 'live', priority: 100,
entityType: 'event', entityId: row.event_id,
idempotencyKey: `coordinator_event:${row.event_id}`,
});
if (result.inserted) queued++;
}
return { scanned: rows.length, queued };
}
async function runAutonomyWorker({ archivePath, intelligencePath, workerId = `autonomy-${os.hostname()}-${process.pid}`, pollMs = 1000 } = {}) {
const archiveDb = new Database(archivePath, { readonly: true });
const intelligenceDb = new Database(intelligencePath);
intelligenceDb.pragma('journal_mode = WAL');
initAutonomySchema(intelligenceDb);
while (true) {
const job = leaseNextJob(intelligenceDb, workerId, 120);
if (!job) { await sleep(pollMs); continue; }
try {
if (job.job_type === 'reconcile_archive') {
reconcileLiveBatch(archiveDb, intelligenceDb);
reconcileArchiveBatch(archiveDb, intelligenceDb);
// Keep the reconciler alive as a bounded maintenance loop.
enqueueJob(intelligenceDb, {
jobType: 'reconcile_archive', lane: 'maintenance', priority: 100,
entityType: 'archive', entityId: 'archive',
idempotencyKey: `reconcile_archive:${Date.now()}`,
});
}
completeJob(intelligenceDb, job.id, workerId);
} catch (error) {
failJob(intelligenceDb, job.id, workerId, error);
}
await sleep(pollMs);
}
}
module.exports = { reconcileArchiveBatch, reconcileLiveBatch, runAutonomyWorker };
+10
View File
@@ -0,0 +1,10 @@
const path = require('path');
const { runCalibrationWorker } = require('./calibrationWorker');
runCalibrationWorker({
intelligencePath: process.env.INTELLIGENCE_DB || path.resolve('/data/intelligence.sqlite'),
pollMs: Number(process.env.AUTONOMY_CALIBRATION_POLL_MS) || 60000,
}).catch((error) => {
console.error('[calibration] fatal:', error);
process.exit(1);
});
+88
View File
@@ -0,0 +1,88 @@
const os = require('os');
const Database = require('better-sqlite3');
const { initAutonomySchema } = require('../src/autonomy/schema');
const { calibrateOutcomes, cohortKey } = require('../src/autonomy/calibration');
const { decide } = require('../src/autonomy/policy');
function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
function refreshCalibration(db, version = `cal-${Date.now()}`) {
const groups = db.prepare(`
SELECT p.direction, p.event_type, p.horizon_days, o.*
FROM autonomy_predictions p
JOIN autonomy_outcomes o ON o.prediction_id = p.id
WHERE p.status = 'resolved' AND p.learning_eligible = 1
`).all().reduce((map, row) => {
const key = cohortKey({ direction: row.direction, eventType: row.event_type, horizonDays: row.horizon_days });
if (!map.has(key)) map.set(key, []);
map.get(key).push(row);
return map;
}, new Map());
const insert = db.prepare(`
INSERT INTO autonomy_calibration_snapshots
(cohort_key, sample_size, effective_sample_size, directional_probability,
expected_excess_return, lower_return, upper_return, parent_cohort_key, version)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const tx = db.transaction(() => {
for (const [key, rows] of groups) {
if (db.prepare("SELECT 1 FROM autonomy_calibration_snapshots WHERE cohort_key = ? AND version = ?").get(key, version)) continue;
const result = calibrateOutcomes(rows);
insert.run(key, result.sampleSize, result.effectiveSampleSize, result.directionalProbability,
result.expectedExcessReturn, result.lowerReturn, result.upperReturn, null, version);
}
});
tx();
return groups.size;
}
function createDecisions(db, strategyVersion = 'autonomy-1') {
const predictions = db.prepare(`
SELECT p.* FROM autonomy_predictions p
LEFT JOIN autonomy_decisions d ON d.prediction_id = p.id
WHERE d.prediction_id IS NULL AND p.status = 'open'
`).all();
const latest = db.prepare(`
SELECT * FROM autonomy_calibration_snapshots
WHERE cohort_key = ? ORDER BY created_at DESC, id DESC LIMIT 1
`);
const insert = db.prepare(`
INSERT INTO autonomy_decisions
(prediction_id, action, calibrated_probability, expected_excess_return, rationale, strategy_version)
VALUES (?, ?, ?, ?, ?, ?)
`);
let created = 0;
const tx = db.transaction(() => {
for (const prediction of predictions) {
const key = cohortKey({ direction: prediction.direction, eventType: prediction.event_type, horizonDays: prediction.horizon_days });
const calibration = latest.get(key);
const decision = calibration
? decide({ ...calibration, direction: prediction.direction }, { minSampleSize: 30 })
: { action: 'ABSTAIN', rationale: 'calibration unavailable' };
insert.run(prediction.id, decision.action, calibration?.directional_probability || null,
calibration?.expected_excess_return || null, decision.rationale, strategyVersion);
created++;
}
});
tx();
return created;
}
async function runCalibrationWorker({ intelligencePath, pollMs = 60000, workerId = `calibration-${os.hostname()}-${process.pid}` } = {}) {
const db = new Database(intelligencePath);
db.pragma('journal_mode = WAL');
initAutonomySchema(db);
while (true) {
try {
const state = db.prepare('SELECT COUNT(*) AS count, COALESCE(MAX(prediction_id), 0) AS max_id FROM autonomy_outcomes').get();
const groups = refreshCalibration(db, `cal-${state.count}-${state.max_id}`);
const decisions = createDecisions(db);
if (groups || decisions) console.log(`[${workerId}] calibration groups=${groups} decisions=${decisions}`);
} catch (error) {
console.error(`[${workerId}] calibration error:`, error.message);
}
await sleep(pollMs);
}
}
module.exports = { refreshCalibration, createDecisions, runCalibrationWorker };
+11
View File
@@ -0,0 +1,11 @@
const path = require('path');
const { runCoordinatorWorker } = require('./coordinatorWorker');
runCoordinatorWorker({
archivePath: process.env.DURIIN_DB || path.resolve('/data/archive.sqlite'),
intelligencePath: process.env.INTELLIGENCE_DB || path.resolve('/data/intelligence.sqlite'),
pollMs: Number(process.env.AUTONOMY_POLL_MS) || 1000,
}).catch((error) => {
console.error('[coordinator] fatal:', error);
process.exit(1);
});
+86
View File
@@ -0,0 +1,86 @@
const os = require('os');
const fs = require('fs');
const path = require('path');
const Database = require('better-sqlite3');
const { initAutonomySchema } = require('../src/autonomy/schema');
const { leaseNextJob, completeJob, failJob } = require('../src/autonomy/jobs');
const { callCoordinator } = require('../src/autonomy/llm');
const { acceptProposal, recordRejectedProposal } = require('../src/autonomy/coordinator');
function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
function loadConfig() {
const configPath = path.resolve(process.env.DURIIN_CONFIG || path.join(__dirname, '..', 'config.json'));
const raw = JSON.parse(fs.readFileSync(configPath, 'utf8'));
require('dotenv').config({ path: path.resolve(path.dirname(configPath), '.env') });
raw.openRouter = { ...(raw.openRouter || {}) };
if (process.env.OPEN_ROUTER_API_KEY) raw.openRouter.apiKey = process.env.OPEN_ROUTER_API_KEY;
if (process.env.OPEN_ROUTER_LLM_MODEL) raw.openRouter.llmModel = process.env.OPEN_ROUTER_LLM_MODEL;
return raw;
}
function buildPrompt(event, articles) {
const evidence = articles.map((article, index) =>
`[Evidence ${index + 1}] article_id=${article.id}\nTitle: ${article.title}\n${String(article.content || article.description || '').slice(0, 4000)}`
).join('\n\n---\n\n');
return `Event title: ${event.title}\n\n${evidence}\n\nReturn JSON only in this shape:\n${JSON.stringify({ predictions: [{
instrument: 'NVDA', direction: 'positive|negative', event_type: 'stable_enum',
causal_channel: 'short description', horizon_days: 10,
evidence_article_ids: [123], invalidation_condition: 'condition',
}] }, null, 2)}\n\nUse only instruments and evidence directly supported by the articles. Return an empty predictions array when there is no clear, tradable hypothesis. Never include probabilities, returns, confidence, position sizes, or actions.`;
}
async function runCoordinatorWorker({ archivePath, intelligencePath, workerId = `coordinator-${os.hostname()}-${process.pid}`, pollMs = 1000 } = {}) {
const archiveDb = new Database(archivePath, { readonly: true });
const intelligenceDb = new Database(intelligencePath);
intelligenceDb.pragma('journal_mode = WAL');
initAutonomySchema(intelligenceDb);
const config = loadConfig();
while (true) {
const job = leaseNextJob(intelligenceDb, workerId, 180, ['coordinator_event']);
if (!job) { await sleep(pollMs); continue; }
try {
const event = archiveDb.prepare('SELECT id, title FROM events WHERE id = ?').get(job.entity_id);
if (!event) throw new Error(`event ${job.entity_id} does not exist`);
const articles = archiveDb.prepare(`
SELECT id, title, description, content, pub_date_effective
FROM articles
WHERE event_id = ? AND content IS NOT NULL AND content != '' AND is_index_page = 0
ORDER BY pub_date_effective ASC, id ASC LIMIT 25
`).all(job.entity_id);
const allowlisted = intelligenceDb.prepare(
"SELECT 1 FROM autonomy_instruments WHERE active=1 AND tradable=1 LIMIT 1"
).get();
if (!allowlisted) throw new Error('no tradable instruments are allowlisted');
const historical = job.lane === 'historical';
const informationCutoff = historical
? (articles.map((article) => article.pub_date_effective).filter(Boolean).sort().pop() || new Date().toISOString())
: new Date().toISOString();
const raw = await callCoordinator(config, buildPrompt(event, articles));
try {
acceptProposal(intelligenceDb, archiveDb, raw, {
eventId: event.id,
informationCutoff,
model: config.openRouter.llmModel || 'unknown',
promptVersion: 'coordinator-1',
strategyVersion: 'autonomy-1',
learningEligible: !historical,
});
} catch (validationError) {
recordRejectedProposal(intelligenceDb, raw, {
eventId: event.id,
informationCutoff,
model: config.openRouter.llmModel || 'unknown',
promptVersion: 'coordinator-1',
learningEligible: !historical,
}, validationError.message);
}
completeJob(intelligenceDb, job.id, workerId);
} catch (error) {
failJob(intelligenceDb, job.id, workerId, error);
}
await sleep(pollMs);
}
}
module.exports = { buildPrompt, runCoordinatorWorker };
+46
View File
@@ -1,5 +1,6 @@
const Database = require("better-sqlite3");
const sqliteVec = require("sqlite-vec");
const { initAutonomySchema } = require("../src/autonomy/schema");
let archiveDb = null;
let intelligenceDb = null;
@@ -17,6 +18,7 @@ function getIntelligenceDb(dbPath) {
if (!intelligenceDb) {
intelligenceDb = new Database(dbPath);
intelligenceDb.pragma("journal_mode = WAL");
initAutonomySchema(intelligenceDb);
}
return intelligenceDb;
}
@@ -109,6 +111,7 @@ function runMigrations(db) {
function runColumnMigrations(db) {
try { db.exec("ALTER TABLE event_predictions ADD COLUMN event_date TEXT"); } catch (_) {}
try { db.exec("ALTER TABLE event_knowledge ADD COLUMN event_date TEXT"); } catch (_) {}
try { db.exec("ALTER TABLE event_predictions ADD COLUMN probability REAL"); } catch (_) {}
db.exec(`
CREATE TABLE IF NOT EXISTS worker_events (
@@ -137,6 +140,49 @@ function runColumnMigrations(db) {
);
`);
// tracks last-processed state per event so augor doesnt redundantly re-run on every new article
db.exec(`
CREATE TABLE IF NOT EXISTS event_processing_state (
event_id INTEGER PRIMARY KEY,
last_processed_at DATETIME,
articles_at_last_run INTEGER NOT NULL DEFAULT 0
);
`);
// cached daily price snapshots so the augor prompt can include real market context
db.exec(`
CREATE TABLE IF NOT EXISTS price_snapshots (
ticker TEXT NOT NULL,
as_of TEXT NOT NULL,
price REAL,
price_30d_ago REAL,
price_90d_ago REAL,
vol_30d REAL,
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (ticker, as_of)
);
`);
// outcomes — actual realized returns for each prediction, populated by the outcome worker
db.exec(`
CREATE TABLE IF NOT EXISTS prediction_outcomes (
prediction_id INTEGER PRIMARY KEY,
company_id INTEGER,
ticker TEXT,
event_date TEXT,
price_0 REAL,
price_5d REAL,
price_10d REAL,
r5 REAL,
r10 REAL,
correct_5d INTEGER,
correct_10d INTEGER,
evaluated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_prediction_outcomes_company ON prediction_outcomes (company_id);
`);
// 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')`);
}
+12
View File
@@ -0,0 +1,12 @@
const path = require('path');
const { runExecutionWorker } = require('./executionWorker');
runExecutionWorker({
intelligencePath: process.env.INTELLIGENCE_DB || path.resolve('/data/intelligence.sqlite'),
pollMs: Number(process.env.AUTONOMY_EXECUTION_POLL_MS) || 10000,
mode: process.env.AUTONOMY_EXECUTION_MODE || 'shadow',
notional: Number(process.env.AUTONOMY_DEFAULT_NOTIONAL) || 100,
}).catch((error) => {
console.error('[execution] fatal:', error);
process.exit(1);
});
+100
View File
@@ -0,0 +1,100 @@
const os = require('os');
const Database = require('better-sqlite3');
const { initAutonomySchema } = require('../src/autonomy/schema');
const { createOrderIntent } = require('../src/autonomy/orderIntents');
const { createAlpacaPaperClient } = require('../src/brokers/alpacaPaper');
function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
async function runExecutionWorker({ intelligencePath, pollMs = 10000, mode = 'shadow', notional = 100, workerId = `execution-${os.hostname()}-${process.pid}` } = {}) {
if (!['shadow', 'paper'].includes(mode)) throw new Error(`unsupported execution mode: ${mode}`);
const db = new Database(intelligencePath);
db.pragma('journal_mode = WAL');
initAutonomySchema(db);
const paperClient = mode === 'paper'
? createAlpacaPaperClient({ keyId: process.env.ALPACA_PAPER_KEY_ID, secretKey: process.env.ALPACA_PAPER_SECRET_KEY })
: null;
while (true) {
if (paperClient) {
try {
const [account, positions, orders] = await Promise.all([
paperClient.getAccount(), paperClient.getPositions(), paperClient.getOrders(),
]);
db.prepare(`
INSERT INTO autonomy_account_snapshots(broker, account_id, equity, cash, buying_power, payload)
VALUES ('alpaca-paper', ?, ?, ?, ?, ?)
`).run(account.id || null, Number(account.equity), Number(account.cash), Number(account.buying_power), JSON.stringify(account));
const insertPosition = db.prepare(`
INSERT INTO autonomy_position_snapshots(broker, instrument, quantity, market_value, unrealized_pl, payload)
VALUES ('alpaca-paper', ?, ?, ?, ?, ?)
`);
for (const position of positions || []) {
insertPosition.run(position.symbol, Number(position.qty), Number(position.market_value), Number(position.unrealized_pl), JSON.stringify(position));
}
for (const order of orders || []) {
if (!order.client_order_id) continue;
const mapped = { accepted: 'submitted', new: 'submitted', pending_new: 'submitted', partially_filled: 'partially_filled', filled: 'filled', canceled: 'cancelled', cancelled: 'cancelled', rejected: 'rejected' }[order.status];
if (!mapped) continue;
db.prepare(`
UPDATE autonomy_order_intents SET status=?, broker_order_id=?, updated_at=datetime('now')
WHERE client_order_id=?
`).run(mapped, order.id || null, order.client_order_id);
db.prepare(`
INSERT INTO autonomy_broker_events(broker, event_type, broker_id, payload)
VALUES ('alpaca-paper', ?, ?, ?)
`).run(order.status, order.id || null, JSON.stringify(order));
}
} catch (error) {
console.error(`[${workerId}] broker reconciliation:`, error.message);
}
}
const decisions = db.prepare(`
SELECT d.id FROM autonomy_decisions d
LEFT JOIN autonomy_order_intents oi ON oi.decision_id = d.id
WHERE oi.id IS NULL AND d.action IN ('BUY', 'SELL')
ORDER BY d.created_at ASC LIMIT 25
`).all();
for (const decision of decisions) {
try {
const intent = createOrderIntent(db, decision.id, notional, { tradable: true, maxNotional: notional });
if (mode === 'paper') db.prepare("UPDATE autonomy_order_intents SET status='pending', updated_at=datetime('now') WHERE client_order_id=?").run(intent.clientOrderId);
console.log(`[${workerId}] ${mode} intent ${intent.clientOrderId}`);
} catch (error) {
console.error(`[${workerId}] decision ${decision.id}:`, error.message);
}
}
if (paperClient) {
const pending = db.prepare("SELECT * FROM autonomy_order_intents WHERE status='pending' ORDER BY created_at ASC LIMIT 25").all();
for (const intent of pending) {
try {
let order;
try { order = await paperClient.getOrderByClientId(intent.client_order_id); } catch (error) {
if (error.status !== 404) throw error;
}
if (!order) {
order = await paperClient.submitOrder({
symbol: intent.instrument,
notional: String(intent.notional),
side: intent.side,
type: 'market',
time_in_force: 'day',
client_order_id: intent.client_order_id,
});
}
db.prepare(`
UPDATE autonomy_order_intents
SET status = ?, broker_order_id = ?, updated_at = datetime('now')
WHERE id = ?
`).run(order.status === 'filled' ? 'filled' : 'submitted', order.id || null, intent.id);
} catch (error) {
console.error(`[${workerId}] paper order ${intent.client_order_id}:`, error.message);
db.prepare("UPDATE autonomy_order_intents SET attempts=attempts+1, last_error=?, updated_at=datetime('now') WHERE id=?")
.run(String(error.message).slice(0, 1000), intent.id);
}
}
}
await sleep(pollMs);
}
}
module.exports = { runExecutionWorker };
+6
View File
@@ -8,6 +8,7 @@ const { ensureCompanyEmbeddings } = require("./embeddings");
const { runConsolidationWorker } = require("./consolidationWorker");
const { runGraphWorker } = require("./graphWorker");
const { runSignalWorker } = require("./signalWorker");
const { runOutcomeWorker } = require("./outcomeWorker");
require("dotenv").config({ path: path.resolve(__dirname, "../.env") });
@@ -77,6 +78,11 @@ runSignalWorker(archiveDb, intelligenceDb, config).catch(err => {
process.exit(1);
});
runOutcomeWorker(archiveDb, intelligenceDb, config).catch(err => {
console.error("[outcome] fatal:", err);
process.exit(1);
});
process.on("SIGINT", () => {
console.log("[intelligence] shutting down");
process.exit(0);
+10
View File
@@ -0,0 +1,10 @@
const path = require('path');
const { resolveAutonomyOutcomes } = require('./outcomeAutonomyWorker');
resolveAutonomyOutcomes({
intelligencePath: process.env.INTELLIGENCE_DB || path.resolve('/data/intelligence.sqlite'),
pollMs: Number(process.env.AUTONOMY_OUTCOME_POLL_MS) || 60000,
}).catch((error) => {
console.error('[autonomy-outcome] fatal:', error);
process.exit(1);
});
+71
View File
@@ -0,0 +1,71 @@
const os = require('os');
const https = require('https');
const Database = require('better-sqlite3');
const { initAutonomySchema } = require('../src/autonomy/schema');
const { calculateOutcome } = require('../src/autonomy/outcomes');
function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
function httpGet(url) {
return new Promise((resolve, reject) => {
const request = https.get(url, { headers: { 'User-Agent': 'duriin-autonomy/1.0' } }, (response) => {
let body = '';
response.setEncoding('utf8');
response.on('data', (chunk) => { body += chunk; });
response.on('end', () => response.statusCode >= 200 && response.statusCode < 300
? resolve(body) : reject(new Error(`market data returned ${response.statusCode}`)));
});
request.setTimeout(15000, () => request.destroy(new Error('market data timeout')));
request.on('error', reject);
});
}
async function history(symbol) {
const url = `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}?range=10y&interval=1d`;
const body = JSON.parse(await httpGet(url));
const result = body?.chart?.result?.[0];
if (!result) return [];
return (result.timestamp || []).map((timestamp, index) => ({
date: new Date(timestamp * 1000).toISOString().slice(0, 10),
close: result.indicators?.quote?.[0]?.close?.[index],
})).filter((row) => Number.isFinite(row.close));
}
async function resolveAutonomyOutcomes({ intelligencePath, workerId = `outcome-${os.hostname()}-${process.pid}`, pollMs = 60000 } = {}) {
const db = new Database(intelligencePath);
db.pragma('journal_mode = WAL');
initAutonomySchema(db);
const cache = new Map();
while (true) {
const predictions = db.prepare(`
SELECT p.* FROM autonomy_predictions p
LEFT JOIN autonomy_outcomes o ON o.prediction_id = p.id
WHERE p.status = 'open' AND o.prediction_id IS NULL
AND datetime(p.information_cutoff, '+' || p.horizon_days || ' days') <= datetime('now')
ORDER BY p.information_cutoff ASC LIMIT 25
`).all();
for (const prediction of predictions) {
try {
if (!cache.has(prediction.instrument)) cache.set(prediction.instrument, await history(prediction.instrument));
if (!cache.has('SPY')) cache.set('SPY', await history('SPY'));
const result = calculateOutcome(prediction, cache.get(prediction.instrument), cache.get('SPY'));
if (!result) {
db.prepare("UPDATE autonomy_predictions SET status = 'unresolvable' WHERE id = ?").run(prediction.id);
continue;
}
db.prepare(`
INSERT OR REPLACE INTO autonomy_outcomes
(prediction_id, price_0, price_horizon, benchmark_0, benchmark_horizon, excess_return, direction_correct, error_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(prediction.id, result.price0, result.priceHorizon, result.benchmark0, result.benchmarkHorizon,
result.excessReturn, result.directionCorrect, result.directionCorrect ? null : 'direction_error');
db.prepare("UPDATE autonomy_predictions SET status = 'resolved' WHERE id = ?").run(prediction.id);
} catch (error) {
console.error(`[autonomy-outcome] ${workerId} prediction ${prediction.id}:`, error.message);
}
await sleep(800);
}
await sleep(pollMs);
}
}
module.exports = { calculateOutcome, resolveAutonomyOutcomes };
+173
View File
@@ -0,0 +1,173 @@
// evaluates predictions older than 11 days against realized stock returns
// runs continuously, batching by ticker so we hit yahoo once per company per cycle
const { getPriceContext } = require("./priceContext");
const https = require("https");
async function runOutcomeWorker(archiveDb, intelligenceDb, config) {
const loopDelay = config.workers?.outcomeLoopDelayMs ?? 60000;
// pull predictions that are old enough to evaluate (>= 11 calendar days) and dont have an outcome yet
const getPending = intelligenceDb.prepare(`
SELECT ep.id, ep.company_id, ep.event_date, ep.direction, tc.ticker
FROM event_predictions ep
JOIN tracked_companies tc ON ep.company_id = tc.id
LEFT JOIN prediction_outcomes po ON po.prediction_id = ep.id
WHERE po.prediction_id IS NULL
AND ep.event_date IS NOT NULL
AND date(ep.event_date) <= date('now', '-11 days')
AND ep.direction IN ('positive', 'negative')
AND tc.ticker IS NOT NULL
AND tc.ticker NOT LIKE '%.%'
AND length(tc.ticker) <= 5
ORDER BY ep.event_date ASC
LIMIT 50
`);
const insertOutcome = intelligenceDb.prepare(`
INSERT OR REPLACE INTO prediction_outcomes
(prediction_id, company_id, ticker, event_date, price_0, price_5d, price_10d, r5, r10, correct_5d, correct_10d)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
while (true) {
try {
const pending = getPending.all();
if (pending.length === 0) {
await sleep(loopDelay);
continue;
}
// group by ticker so we only fetch each company's history once per cycle
const byTicker = new Map();
for (const p of pending) {
if (!byTicker.has(p.ticker)) byTicker.set(p.ticker, []);
byTicker.get(p.ticker).push(p);
}
let evaluated = 0;
for (const [ticker, preds] of byTicker.entries()) {
let history;
try {
history = await fetchYahooHistory(ticker, "1y");
} catch (err) {
console.error(`[outcome] yahoo error for ${ticker}: ${err.message}`);
continue;
}
if (!history || history.length === 0) continue;
for (const pred of preds) {
const eventDate = pred.event_date.slice(0, 10);
const price0 = nearestOnOrAfter(history, eventDate);
if (price0 == null) continue;
const date5 = addTradingDays(eventDate, 5);
const date10 = addTradingDays(eventDate, 10);
const price5 = nearestOnOrAfter(history, date5);
const price10 = nearestOnOrAfter(history, date10);
const r5 = price5 != null ? (price5 - price0) / price0 * 100 : null;
const r10 = price10 != null ? (price10 - price0) / price0 * 100 : null;
const correct5 = r5 == null ? null : (pred.direction === "positive" ? (r5 > 0 ? 1 : 0) : (r5 < 0 ? 1 : 0));
const correct10 = r10 == null ? null : (pred.direction === "positive" ? (r10 > 0 ? 1 : 0) : (r10 < 0 ? 1 : 0));
insertOutcome.run(
pred.id, pred.company_id, ticker, eventDate,
price0, price5, price10, r5, r10, correct5, correct10
);
evaluated++;
}
// small delay between tickers so we dont hammer yahoo
await sleep(800);
}
if (evaluated > 0) {
console.log(`[outcome] evaluated ${evaluated} predictions across ${byTicker.size} tickers`);
}
await sleep(loopDelay);
} catch (err) {
console.error("[outcome] cycle error:", err.message);
await sleep(loopDelay);
}
}
}
async function fetchYahooHistory(ticker, range) {
const url = `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(ticker)}?range=${range}&interval=1d`;
const body = await httpGet(url, { "User-Agent": "Mozilla/5.0 (compatible; duriin-intelligence/1.0)" });
const parsed = JSON.parse(body);
const result = parsed?.chart?.result?.[0];
if (!result) return null;
const ts = result.timestamp || [];
const closes = result.indicators?.quote?.[0]?.close || [];
const out = [];
for (let i = 0; i < ts.length; i++) {
if (closes[i] == null) continue;
out.push({
date: new Date(ts[i] * 1000).toISOString().slice(0, 10),
close: closes[i],
});
}
return out;
}
function nearestOnOrAfter(history, dateStr) {
for (const row of history) {
if (row.date >= dateStr) return row.close;
}
return null;
}
function addTradingDays(dateStr, n) {
const dt = new Date(dateStr);
let count = 0;
while (count < n) {
dt.setDate(dt.getDate() + 1);
const dow = dt.getDay();
if (dow >= 1 && dow <= 5) count++;
}
return dt.toISOString().slice(0, 10);
}
function httpGet(url, headers) {
return new Promise((resolve, reject) => {
const u = new URL(url);
const req = https.request({
hostname: u.hostname,
path: u.pathname + u.search,
method: "GET",
headers,
}, (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(`yahoo ${res.statusCode}: ${data.slice(0, 200)}`));
});
});
req.on("error", reject);
req.end();
});
}
function sleep(ms) {
return new Promise(r => setTimeout(r, ms));
}
module.exports = { runOutcomeWorker };
+171
View File
@@ -0,0 +1,171 @@
const https = require("https");
// fetches daily OHLC from yahoo finance v8 chart api
// no api key needed but rate limited so we cache aggressively
async function fetchYahooHistory(ticker, range = "6mo") {
const url = `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(ticker)}?range=${range}&interval=1d`;
const body = await httpGet(url, {
"User-Agent": "Mozilla/5.0 (compatible; duriin-intelligence/1.0)",
});
let parsed;
try {
parsed = JSON.parse(body);
} catch (_) {
throw new Error(`yahoo response not JSON: ${body.slice(0, 200)}`);
}
const result = parsed?.chart?.result?.[0];
if (!result) return null;
const ts = result.timestamp || [];
const closes = result.indicators?.quote?.[0]?.close || [];
const out = [];
for (let i = 0; i < ts.length; i++) {
if (closes[i] == null) continue;
out.push({
date: new Date(ts[i] * 1000).toISOString().slice(0, 10),
close: closes[i],
});
}
return out;
}
function nearestPriceOnOrBefore(history, dateStr) {
// history is sorted ascending by date
let last = null;
for (const row of history) {
if (row.date <= dateStr) last = row;
else break;
}
return last ? last.close : null;
}
function computeStdev(values) {
if (values.length < 2) return 0;
const mean = values.reduce((a, b) => a + b, 0) / values.length;
const sq = values.reduce((acc, v) => acc + (v - mean) ** 2, 0);
return Math.sqrt(sq / (values.length - 1));
}
function computeReturns(history) {
const rets = [];
for (let i = 1; i < history.length; i++) {
const prev = history[i - 1].close;
const cur = history[i].close;
if (prev > 0) rets.push((cur - prev) / prev);
}
return rets;
}
// returns { price, price_30d_ago, price_90d_ago, vol_30d } as_of a given date
async function getPriceContext(intelligenceDb, ticker, asOfDate) {
if (!ticker || !asOfDate) return null;
// skip private/synthetic tickers — yahoo wont know them
if (/^(OPENAI|ANTHROPIC|XAI|HUAWEI|BYTEDANCE|DEEPSEEK|MISTRAL|COHERE|GROQ|SCALEAI|MCKINSEY|DELOITTE|STABILITY|INFLECTION|SPACEX|BLUEORIGIN)$/i.test(ticker)) {
return null;
}
const cacheRow = intelligenceDb.prepare(
"SELECT price, price_30d_ago, price_90d_ago, vol_30d FROM price_snapshots WHERE ticker = ? AND as_of = ?"
).get(ticker, asOfDate);
if (cacheRow) return cacheRow;
let history;
try {
history = await fetchYahooHistory(ticker, "6mo");
} catch (err) {
// dont blow up the worker on a single bad ticker
return null;
}
if (!history || history.length === 0) return null;
const price = nearestPriceOnOrBefore(history, asOfDate);
if (price == null) return null;
const date30 = new Date(asOfDate);
date30.setDate(date30.getDate() - 30);
const price30 = nearestPriceOnOrBefore(history, date30.toISOString().slice(0, 10));
const date90 = new Date(asOfDate);
date90.setDate(date90.getDate() - 90);
const price90 = nearestPriceOnOrBefore(history, date90.toISOString().slice(0, 10));
// 30-day annualized vol from daily returns
const recent = history.filter(h => h.date <= asOfDate).slice(-30);
const vol30 = computeStdev(computeReturns(recent)) * Math.sqrt(252);
const snapshot = {
price,
price_30d_ago: price30,
price_90d_ago: price90,
vol_30d: vol30,
};
try {
intelligenceDb.prepare(
"INSERT OR REPLACE INTO price_snapshots (ticker, as_of, price, price_30d_ago, price_90d_ago, vol_30d) VALUES (?, ?, ?, ?, ?, ?)"
).run(ticker, asOfDate, snapshot.price, snapshot.price_30d_ago, snapshot.price_90d_ago, snapshot.vol_30d);
} catch (_) {}
return snapshot;
}
// formats the snapshot for inclusion in the LLM prompt
function formatPriceContext(snapshot, ticker) {
if (!snapshot || snapshot.price == null) return null;
const lines = [`${ticker} price as of event: $${snapshot.price.toFixed(2)}`];
if (snapshot.price_30d_ago) {
const ret30 = (snapshot.price - snapshot.price_30d_ago) / snapshot.price_30d_ago * 100;
lines.push(`30-day return: ${ret30 >= 0 ? "+" : ""}${ret30.toFixed(1)}%`);
}
if (snapshot.price_90d_ago) {
const ret90 = (snapshot.price - snapshot.price_90d_ago) / snapshot.price_90d_ago * 100;
lines.push(`90-day return: ${ret90 >= 0 ? "+" : ""}${ret90.toFixed(1)}%`);
}
if (snapshot.vol_30d) {
lines.push(`30-day annualized volatility: ${(snapshot.vol_30d * 100).toFixed(1)}%`);
}
return lines.join("\n");
}
function httpGet(url, headers) {
return new Promise((resolve, reject) => {
const u = new URL(url);
const req = https.request({
hostname: u.hostname,
path: u.pathname + u.search,
method: "GET",
headers,
}, (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(`yahoo ${res.statusCode}: ${data.slice(0, 200)}`));
});
});
req.on("error", reject);
req.end();
});
}
module.exports = { getPriceContext, formatPriceContext };
+19 -1
View File
@@ -4,6 +4,10 @@
async function runQueueFeeder(archiveDb, intelligenceDb, config) {
const batchSize = config.workers?.queueFeederBatchSize ?? 100;
const loopDelay = config.workers?.queueFeederLoopDelayMs ?? 3000;
const maxPending = Math.max(
batchSize,
config.workers?.queueFeederMaxPending ?? 250
);
const getCursor = intelligenceDb.prepare(
"SELECT value FROM cursors WHERE key = 'queue_feeder'"
@@ -16,11 +20,21 @@ async function runQueueFeeder(archiveDb, intelligenceDb, config) {
INSERT OR IGNORE INTO article_queue (article_id, status, created_at)
VALUES (?, 'pending', CURRENT_TIMESTAMP)
`);
const getPendingCount = intelligenceDb.prepare(
"SELECT COUNT(*) AS count FROM article_queue WHERE status = 'pending'"
);
while (true) {
try {
const pending = getPendingCount.get().count;
if (pending >= maxPending) {
await sleep(loopDelay);
continue;
}
const cursorRow = getCursor.get();
const cursor = cursorRow ? cursorRow.value : 0;
const availableSlots = Math.min(batchSize, maxPending - pending);
const articles = archiveDb.prepare(`
SELECT id FROM articles
@@ -31,7 +45,7 @@ async function runQueueFeeder(archiveDb, intelligenceDb, config) {
AND event_id IS NOT NULL
ORDER BY id ASC
LIMIT ?
`).all(cursor, batchSize);
`).all(cursor, availableSlots);
if (articles.length === 0) {
await sleep(loopDelay);
@@ -53,6 +67,10 @@ async function runQueueFeeder(archiveDb, intelligenceDb, config) {
console.log(`[feeder] queued ${inserted} articles, cursor now ${newCursor}`);
}
// Always yield between archive scans. The query is synchronous and can
// otherwise monopolise the event loop while catching up a large archive.
await sleep(loopDelay);
} catch (err) {
console.error("[feeder] error:", err.message);
await sleep(loopDelay);
+76 -9
View File
@@ -1,10 +1,14 @@
const https = require("https");
const http = require("http");
const { getPriceContext, formatPriceContext } = require("./priceContext");
const CONCURRENCY = 4;
const PREDICTION_WINDOW_DAYS = 21;
async function runSignalWorker(archiveDb, intelligenceDb, config) {
const loopDelay = config.workers?.signalLoopDelayMs ?? 1000;
const llmConfig = config.openRouter || {};
// add as_of column if it doesnt exist yet
@@ -28,15 +32,28 @@ async function runSignalWorker(archiveDb, intelligenceDb, config) {
LIMIT 1
`);
// decay window — only feed recent predictions into the signal prompt.
// backtest showed signal degrades sharply after ~10 days, so use 21d as a soft window
const getPredictions = intelligenceDb.prepare(`
SELECT type, direction, magnitude, timeframe, rationale, event_date, id
SELECT type, direction, magnitude, timeframe, rationale, probability, event_date, id
FROM event_predictions
WHERE company_id = ?
AND substr(event_date, 1, 10) <= ?
AND date(substr(event_date, 1, 10)) >= date(?, '-${PREDICTION_WINDOW_DAYS} days')
AND timeframe != 'short'
AND direction IN ('positive', 'negative')
ORDER BY event_date DESC
LIMIT 50
`);
const getCompanyAccuracy = intelligenceDb.prepare(`
SELECT
COUNT(*) as total,
SUM(correct_10d) as correct
FROM prediction_outcomes
WHERE company_id = ? AND correct_10d IS NOT NULL
`);
const getFacts = intelligenceDb.prepare(`
SELECT claim, type, confidence, confirmation_count
FROM company_facts
@@ -118,11 +135,31 @@ async function runSignalWorker(archiveDb, intelligenceDb, config) {
continue;
}
const predictions = getPredictions.all(company_id, checkpoint_date);
const predictions = getPredictions.all(company_id, checkpoint_date, checkpoint_date);
const facts = getFacts.all(company_id, checkpoint_date);
const relationships = getRelationships.all(company_id, checkpoint_date);
const prompt = buildPrompt(company.name, facts, relationships, predictions, checkpoint_date);
// skip if the decay window left us with nothing useful
if (predictions.length === 0) {
inFlight.delete(key);
continue;
}
// pull market context + historical accuracy for this company
let priceBlock = null;
if (company.ticker) {
try {
const snapshot = await getPriceContext(intelligenceDb, company.ticker, checkpoint_date);
priceBlock = formatPriceContext(snapshot, company.ticker);
} catch (_) {}
}
const acc = getCompanyAccuracy.get(company_id);
const accuracyBlock = (acc && acc.total >= 5)
? `Past prediction accuracy for ${company.name}: ${(acc.correct / acc.total * 100).toFixed(0)}% over ${acc.total} evaluated calls.`
: null;
const prompt = buildPrompt(company.name, facts, relationships, predictions, checkpoint_date, priceBlock, accuracyBlock);
let result;
try {
@@ -165,6 +202,10 @@ async function runSignalWorker(archiveDb, intelligenceDb, config) {
} catch (err) {
console.error(`[signal:${id}] cycle error:`, err.message);
} finally {
// Successful and early-exit paths must yield too; otherwise an invalid
// checkpoint can turn this into a tight synchronous SQLite loop.
await sleep(loopDelay);
}
}
}
@@ -179,7 +220,7 @@ async function runSignalWorker(archiveDb, intelligenceDb, config) {
}
function buildPrompt(companyName, facts, relationships, predictions, asOf) {
function buildPrompt(companyName, facts, relationships, predictions, asOf, priceBlock, accuracyBlock) {
const factsBlock = facts.length > 0
? facts.map(f => `- ${f.claim} (confirmed ${f.confirmation_count}x)`).join("\n")
: "No known facts yet.";
@@ -188,9 +229,30 @@ function buildPrompt(companyName, facts, relationships, predictions, asOf) {
? 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");
// recency-weighted prediction block — newer predictions get a [RECENT] tag,
// and high-magnitude + long-timeframe gets [HIGH CONFIDENCE].
// probability is surfaced when present so the LLM can weight by it.
const asOfMs = new Date(asOf + "T00:00:00Z").getTime();
const predBlock = predictions.map((p, i) => {
const tags = [];
if (p.magnitude === "high" && p.timeframe === "long") tags.push("HIGH CONFIDENCE");
if (p.event_date) {
const ageDays = Math.round((asOfMs - new Date(p.event_date.slice(0, 10) + "T00:00:00Z").getTime()) / 86_400_000);
if (ageDays <= 7) tags.push(`RECENT ${ageDays}d`);
else tags.push(`${ageDays}d old`);
}
const probStr = (typeof p.probability === "number") ? ` p=${p.probability.toFixed(2)}` : "";
const tagStr = tags.length ? ` [${tags.join(", ")}]` : "";
return `${i + 1}. [${p.type}]${tagStr}${probStr} ${p.direction} / ${p.magnitude} / ${p.timeframe}${p.rationale || "no rationale"}`;
}).join("\n");
const pricePart = priceBlock ? `\nMarket context for ${companyName}:\n${priceBlock}\n` : "";
const accPart = accuracyBlock ? `\n${accuracyBlock}\n` : "";
return `You are a financial intelligence analyst generating a trade signal for ${companyName} as of ${asOf}.
@@ -199,10 +261,12 @@ ${factsBlock}
Known relationships:
${relBlock}
Event predictions up to ${asOf}:
${pricePart}${accPart}
Recent event predictions (last 21 days):
${predBlock}
Weight RECENT and HIGH CONFIDENCE predictions more heavily. Discount older predictions and any that lack a probability score. Predictions that disagree with the recent price trajectory are weaker — be sceptical of bullish predictions on a name that has already rallied 20% in 30 days, and vice versa.
Generate a trade signal as JSON with this exact shape:
{
"signal": "BUY | HOLD | SELL",
@@ -214,11 +278,14 @@ Generate a trade signal as JSON with this exact shape:
"summary": "2-3 sentence plain English summary"
}
Default to HOLD when the predictions are mixed, stale, or low-probability. Reserve BUY/SELL for cases where the weight of high-confidence recent evidence is unambiguous.
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)
- Stretched valuation given recent price moves
- Negative prediction patterns in recent events
Only output valid JSON. Always respond in English.`;