Files
Duriin-API/workers/priceContext.js

172 lines
5.0 KiB
JavaScript

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 };