174 lines
5.2 KiB
JavaScript
174 lines
5.2 KiB
JavaScript
// 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 };
|