feat: add autonomous paper-trading and calibration pipeline
This commit is contained in:
@@ -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 };
|
||||
Reference in New Issue
Block a user