| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { getCompanyNews, getMarketNews } from './finnhub.client.js'; |
| import { logger } from '../utils/logger.js'; |
|
|
| const MAX_NEWS_PER_MARKET = 8; |
| const NEWS_CACHE_TTL_MS = 30 * 1000; |
|
|
| |
| const companyNewsCache = new Map(); |
| const generalNewsCache = { data: null, timestamp: 0 }; |
|
|
| |
|
|
| const FINANCIAL_KEYWORDS = { |
| |
| bitcoin: ['BTC', 'COIN', 'MSTR', 'RIOT', 'MARA'], |
| btc: ['BTC', 'COIN', 'MSTR'], |
| crypto: ['COIN', 'BTC', 'ETH', 'SOL', 'MSTR'], |
| ethereum: ['ETH', 'COIN'], |
| eth: ['ETH', 'COIN'], |
| solana: ['SOL', 'COIN'], |
| blockchain: ['COIN', 'BTC', 'IBM'], |
| altcoin: ['COIN', 'ETH', 'SOL'], |
| defi: ['COIN', 'ETH'], |
| stablecoin: ['COIN', 'CRCL'], |
|
|
| |
| dollar: ['DXY', 'UUP'], |
| euro: ['FXE'], |
| yen: ['FXY'], |
| pound: ['FXB'], |
| inflation: ['DXY', 'TLT', 'GLD'], |
| cpi: ['SPY', 'TLT', 'GLD'], |
| recession: ['SPY', 'QQQ', 'VIX', 'TLT'], |
| 'interest rate': ['TLT', 'TBT', 'DXY'], |
| 'rate cut': ['TLT', 'SPY', 'QQQ'], |
| 'rate hike': ['TBT', 'DXY'], |
| fed: ['TLT', 'SPY', 'DXY'], |
| powell: ['TLT', 'SPY'], |
| ecb: ['FXE', 'EWG'], |
| boj: ['FXY', 'EWJ'], |
| unemployment: ['SPY', 'TLT'], |
| gdp: ['SPY', 'DXY'], |
| tariff: ['SPY', 'FXI', 'XLI'], |
| 'trade war': ['FXI', 'SPY', 'GLD'], |
|
|
| |
| apple: ['AAPL'], |
| tesla: ['TSLA'], |
| nvidia: ['NVDA'], |
| amazon: ['AMZN'], |
| google: ['GOOGL'], |
| microsoft: ['MSFT'], |
| meta: ['META'], |
| facebook: ['META'], |
| openai: ['MSFT', 'NVDA'], |
| anthropic: ['GOOGL', 'AMZN'], |
| 'artificial intelligence': ['NVDA', 'MSFT', 'GOOGL', 'META'], |
| ai: ['NVDA', 'MSFT', 'GOOGL', 'META'], |
| semiconductor: ['NVDA', 'TSM', 'AVGO', 'AMD'], |
| chip: ['NVDA', 'TSM', 'AVGO', 'AMD'], |
| spacex: ['TSLA', 'RKLB'], |
| starlink: ['TSLA'], |
| boeing: ['BA'], |
| airbus: ['EADSY'], |
|
|
| |
| gold: ['GLD', 'IAU'], |
| silver: ['SLV'], |
| oil: ['USO', 'XLE'], |
| energy: ['XLE', 'USO'], |
| natgas: ['UNG'], |
| uranium: ['URA', 'CCJ'], |
|
|
| |
| 's&p': ['SPY', 'SPX'], |
| sp500: ['SPY'], |
| nasdaq: ['QQQ'], |
| 'stock market': ['SPY', 'QQQ', 'VIX'], |
| vix: ['VIX', 'SPY'], |
|
|
| |
| war: ['GLD', 'USO', 'DXY', 'VIX', 'LMT', 'RTX'], |
| conflict: ['GLD', 'USO', 'VIX', 'LMT'], |
| invasion: ['GLD', 'USO', 'VIX', 'LMT'], |
| ceasefire: ['SPY', 'USO'], |
| sanctions: ['USO', 'GLD', 'DXY'], |
| iran: ['USO', 'GLD', 'LMT'], |
| israel: ['USO', 'GLD', 'LMT'], |
| gaza: ['USO', 'GLD'], |
| hamas: ['USO', 'GLD'], |
| hezbollah: ['USO', 'GLD'], |
| ukraine: ['USO', 'GLD', 'LMT', 'RTX'], |
| russia: ['USO', 'GLD', 'LMT'], |
| putin: ['USO', 'GLD', 'LMT'], |
| china: ['FXI', 'USO', 'SPY'], |
| xi: ['FXI', 'SPY'], |
| taiwan: ['TSM', 'FXI', 'LMT'], |
| 'north korea': ['LMT', 'EWY'], |
| venezuela: ['USO'], |
| saudi: ['USO', 'XLE'], |
| opec: ['USO', 'XLE'], |
|
|
| |
| trump: ['SPY', 'DXY', 'VIX', 'XLE'], |
| biden: ['SPY', 'DXY', 'VIX'], |
| harris: ['SPY', 'VIX'], |
| desantis: ['SPY', 'XLE'], |
| vance: ['SPY', 'XLE'], |
| election: ['SPY', 'VIX', 'DXY'], |
| 'presidential election': ['SPY', 'VIX', 'DXY', 'GLD'], |
| republican: ['SPY', 'XLE', 'LMT'], |
| democrat: ['SPY', 'TLT'], |
| congress: ['SPY', 'TLT'], |
| senate: ['SPY', 'TLT'], |
| shutdown: ['SPY', 'TLT', 'VIX'], |
| 'debt ceiling': ['TLT', 'GLD', 'DXY'], |
| impeach: ['SPY', 'VIX'], |
| scotus: ['SPY'], |
| 'supreme court': ['SPY'], |
|
|
| |
| macron: ['EWQ', 'FXE'], |
| 'le pen': ['EWQ', 'FXE'], |
| merkel: ['EWG', 'FXE'], |
| scholz: ['EWG', 'FXE'], |
| meloni: ['EWI', 'FXE'], |
| starmer: ['EWU', 'FXB'], |
| milei: ['ARGT'], |
| lula: ['EWZ'], |
| bolsonaro: ['EWZ'], |
| erdogan: ['TUR'], |
| modi: ['INDA'], |
| zelensky: ['USO', 'LMT'], |
| netanyahu: ['USO', 'GLD'], |
|
|
| |
| netflix: ['NFLX'], |
| disney: ['DIS'], |
| marvel: ['DIS'], |
| spotify: ['SPOT'], |
| paramount: ['PARA'], |
| warner: ['WBD'], |
| 'box office': ['DIS', 'WBD', 'NFLX'], |
| oscar: ['NFLX', 'DIS', 'WBD'], |
| gta: ['TTWO'], |
| rockstar: ['TTWO'], |
| 'video game': ['TTWO', 'EA', 'ATVI'], |
|
|
| |
| vaccine: ['PFE', 'MRNA', 'JNJ'], |
| fda: ['PFE', 'MRNA', 'LLY'], |
| pandemic: ['PFE', 'MRNA', 'VIX'], |
| 'avian flu': ['PFE', 'MRNA'], |
| ozempic: ['NVO', 'LLY'], |
|
|
| |
| hurricane: ['XLE', 'HD', 'LOW'], |
| climate: ['ICLN', 'TAN', 'XLE'], |
| }; |
|
|
| |
| |
| |
| |
| function extractFinancialSymbols(question) { |
| if (!question) return { hasFinancialCorrelation: false, symbols: [] }; |
|
|
| const lower = question.toLowerCase(); |
| const matchedSymbols = new Set(); |
| const matchedKeywords = []; |
|
|
| for (const [keyword, symbols] of Object.entries(FINANCIAL_KEYWORDS)) { |
| |
| const regex = new RegExp(`\\b${keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b|${keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'i'); |
| if (regex.test(lower)) { |
| symbols.forEach((s) => matchedSymbols.add(s)); |
| matchedKeywords.push(keyword); |
| } |
| } |
|
|
| return { |
| hasFinancialCorrelation: matchedSymbols.size > 0, |
| symbols: Array.from(matchedSymbols), |
| keywords: matchedKeywords, |
| }; |
| } |
|
|
| |
|
|
| function normalizeText(text) { |
| if (!text) return ''; |
| return text |
| .toLowerCase() |
| .replace(/[^\w\s]/g, ' ') |
| .replace(/\s+/g, ' ') |
| .trim(); |
| } |
|
|
| function deduplicateNews(news) { |
| const seen = new Set(); |
| return news.filter((item) => { |
| const normalized = normalizeText(item.headline); |
| if (!normalized || seen.has(normalized)) return false; |
| seen.add(normalized); |
| return true; |
| }); |
| } |
|
|
| function shuffleArray(array) { |
| const arr = [...array]; |
| for (let i = arr.length - 1; i > 0; i--) { |
| const j = Math.floor(Math.random() * (i + 1)); |
| [arr[i], arr[j]] = [arr[j], arr[i]]; |
| } |
| return arr; |
| } |
|
|
| function scoreRelevance(item, marketKeywords) { |
| if (!marketKeywords.length) return 0; |
|
|
| const textHeadline = normalizeText(item.headline); |
| const textSummary = normalizeText(item.summary); |
| let score = 0; |
|
|
| for (const kw of marketKeywords) { |
| if (textHeadline.includes(kw)) score += 3; |
| if (textSummary.includes(kw)) score += 1; |
| } |
|
|
| |
| if (item.datetime) { |
| const ageHours = (Date.now() - new Date(item.datetime).getTime()) / (1000 * 60 * 60); |
| if (ageHours < 6) score += 2; |
| else if (ageHours < 24) score += 1; |
| } |
|
|
| return score; |
| } |
|
|
| function extractKeywords(question) { |
| if (!question) return []; |
| const stopWords = new Set([ |
| 'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', |
| 'of', 'with', 'by', 'from', 'up', 'about', 'into', 'through', 'during', |
| 'before', 'after', 'above', 'below', 'between', 'among', 'is', 'are', |
| 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', 'do', 'does', |
| 'did', 'will', 'would', 'could', 'should', 'may', 'might', 'must', 'shall', |
| 'can', 'need', 'dare', 'ought', 'used', 'won', 'would', 'it', 'this', |
| 'that', 'these', 'those', 'i', 'you', 'he', 'she', 'we', 'they', 'me', |
| 'him', 'her', 'us', 'them', 'my', 'your', 'his', 'her', 'its', 'our', |
| 'their', 'what', 'which', 'who', 'whom', 'whose', 'where', 'when', 'why', |
| 'how', 'all', 'any', 'both', 'each', 'few', 'more', 'most', 'other', |
| 'some', 'such', 'no', 'nor', 'not', 'only', 'own', 'same', 'so', 'than', |
| 'too', 'very', 'just', 'now', 'then', 'here', 'there', 'once', 'again', |
| 'further', 'once', 'also', 'back', 'still', 'yet', 'already', 'almost', |
| 'quite', 'rather', 'really', 'soon', 'today', 'tomorrow', 'yesterday', |
| 'el', 'la', 'los', 'las', 'un', 'una', 'unos', 'unas', 'y', 'o', 'pero', |
| 'en', 'de', 'a', 'por', 'para', 'con', 'sin', 'sobre', 'entre', 'durante', |
| 'antes', 'despues', 'durante', 'es', 'son', 'era', 'fue', 'ser', 'estar', |
| 'ha', 'han', 'habia', 'haya', 'hizo', 'hace', 'hacia', 'tiene', 'tienen', |
| 'tuvo', 'tendria', 'puede', 'podria', 'debe', 'necesita', 'quiere', 'cada', |
| 'algun', 'alguna', 'algunos', 'algunas', 'todo', 'toda', 'todos', 'todas', |
| 'muy', 'mas', 'menos', 'mucho', 'muchos', 'poco', 'pocos', 'bastante', |
| 'demasiado', 'casi', 'solo', 'solamente', 'tambien', 'aun', 'ya', 'todavia', |
| 'siempre', 'nunca', 'jamás', 'ahora', 'hoy', 'manana', 'ayer', 'aqui', 'alli', |
| 'alli', 'luego', 'entonces', 'asi', 'como', 'cuando', 'donde', 'porque', |
| 'por que', 'quien', 'cual', 'cuales', 'cuanto', 'cuanta', 'cuantos', 'cuantas', |
| ]); |
|
|
| return question |
| .toLowerCase() |
| .replace(/[^\w\s]/g, ' ') |
| .split(/\s+/) |
| .filter((w) => w.length > 2 && !stopWords.has(w)); |
| } |
|
|
| |
|
|
| function getCachedCompanyNews(symbol) { |
| const cached = companyNewsCache.get(symbol); |
| if (cached && Date.now() - cached.timestamp < NEWS_CACHE_TTL_MS) { |
| return cached.data; |
| } |
| return null; |
| } |
|
|
| function setCachedCompanyNews(symbol, data) { |
| companyNewsCache.set(symbol, { data, timestamp: Date.now() }); |
| } |
|
|
| function getCachedGeneralNews() { |
| if (generalNewsCache.data && Date.now() - generalNewsCache.timestamp < NEWS_CACHE_TTL_MS) { |
| return generalNewsCache.data; |
| } |
| return null; |
| } |
|
|
| function setCachedGeneralNews(data) { |
| generalNewsCache.data = data; |
| generalNewsCache.timestamp = Date.now(); |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function fetchFinancialNewsForMarket(market) { |
| if (!market || !market.question) return []; |
|
|
| const { hasFinancialCorrelation, symbols, keywords } = extractFinancialSymbols(market.question); |
|
|
| |
| |
| if (!hasFinancialCorrelation) { |
| logger.debug({ marketId: market.id, question: market.question }, 'No financial correlation, using general macro news as context'); |
| return fetchGeneralMarketNews(15); |
| } |
|
|
| logger.info({ marketId: market.id, symbols, keywords }, 'Fetching company news for financial market'); |
|
|
| |
| const allNews = []; |
| |
| const daysBack = 1 + Math.floor(Math.random() * 2); |
| |
| for (const symbol of symbols.slice(0, 5)) { |
| let news = getCachedCompanyNews(symbol); |
| if (!news) { |
| try { |
| news = await getCompanyNews(symbol, daysBack); |
| setCachedCompanyNews(symbol, news); |
| } catch (err) { |
| logger.warn({ err: err.message, symbol }, 'Failed to fetch company news'); |
| news = []; |
| } |
| } |
| allNews.push(...news); |
| } |
|
|
| |
| try { |
| const general = await fetchGeneralMarketNews(10); |
| allNews.push(...general); |
| } catch (e) { } |
|
|
| |
| const deduped = deduplicateNews(allNews); |
| deduped.sort((a, b) => { |
| const da = a.datetime ? new Date(a.datetime).getTime() : 0; |
| const db = b.datetime ? new Date(b.datetime).getTime() : 0; |
| return db - da; |
| }); |
|
|
| logger.info({ marketId: market.id, count: deduped.length, symbols }, 'Company news fetched'); |
| return deduped; |
| } |
|
|
| |
| |
| |
| |
| export async function fetchGeneralMarketNews(limit = 20) { |
| let news = getCachedGeneralNews(); |
| if (!news) { |
| try { |
| |
| const categories = ['general', 'forex', 'crypto', 'merger']; |
| const shuffledCats = shuffleArray(categories); |
| |
| const results = await Promise.all( |
| shuffledCats.map((cat) => getMarketNews(cat, 25)) |
| ); |
| |
| news = deduplicateNews(results.flat()); |
| news.sort((a, b) => { |
| const da = a.datetime ? new Date(a.datetime).getTime() : 0; |
| const db = b.datetime ? new Date(b.datetime).getTime() : 0; |
| return db - da; |
| }); |
| setCachedGeneralNews(news); |
| } catch (err) { |
| logger.warn({ err: err.message }, 'Failed to fetch general market news'); |
| return []; |
| } |
| } |
| |
| return shuffleArray(news).slice(0, limit); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function filterNewsByRelevance(news, question) { |
| if (!news.length || !question) return []; |
|
|
| const keywords = extractKeywords(question); |
| if (keywords.length === 0) { |
| return shuffleArray(news).slice(0, MAX_NEWS_PER_MARKET); |
| } |
|
|
| const scored = news.map((item) => ({ |
| item, |
| score: scoreRelevance(item, keywords), |
| })); |
|
|
| const relevant = scored |
| .filter((s) => s.score > 0) |
| .sort((a, b) => b.score - a.score) |
| .map((s) => s.item); |
|
|
| |
| if (relevant.length < 3) { |
| const usedIds = new Set(relevant.map((n) => n.id)); |
| const extra = shuffleArray(news.filter((n) => !usedIds.has(n.id))).slice(0, MAX_NEWS_PER_MARKET - relevant.length); |
| return [...relevant, ...extra].slice(0, MAX_NEWS_PER_MARKET); |
| } |
|
|
| return shuffleArray(relevant.slice(0, MAX_NEWS_PER_MARKET * 2)).slice(0, MAX_NEWS_PER_MARKET); |
| } |
|
|
| |
| |
| |
| export function invalidateNewsCache() { |
| companyNewsCache.clear(); |
| generalNewsCache.data = null; |
| generalNewsCache.timestamp = 0; |
| logger.info('News cache invalidated'); |
| } |
|
|
| |
| |
| |
| |
| export async function fetchFinancialNews() { |
| return fetchGeneralMarketNews(30); |
| } |
|
|
| |
| |
| |
| |
| export async function fetchHeadlinesForPipeline({ |
| symbols = [], |
| daysBack = 7, |
| limitPerSymbol = 20, |
| } = {}) { |
| if (!symbols.length) return []; |
|
|
| const allNews = []; |
| for (const symbol of symbols.slice(0, 5)) { |
| try { |
| const items = await getCompanyNews(symbol, daysBack); |
| allNews.push(...items.slice(0, limitPerSymbol).map((it) => ({ symbol, ...it }))); |
| } catch (err) { |
| logger.warn({ err: err.message, symbol }, 'getCompanyNews failed for symbol'); |
| } |
| } |
| return allNews; |
| } |
|
|