/** * QM News Actions - Fetch symbol-specific and general market news */ import type { ExecutionContext } from '@stock-bot/handlers'; import type { QMHandler } from '../qm.handler'; import { QM_CONFIG, QM_SESSION_IDS } from '../shared/config'; import { QMSessionManager } from '../shared/session-manager'; interface NewsArticle { id: string; publishedDate: Date; title: string; summary: string; source: string; url: string; symbols?: string[]; categories?: string[]; sentiment?: { score: number; label: string; // positive, negative, neutral }; imageUrl?: string; } /** * Update news for a single symbol */ export async function updateSymbolNews( this: QMHandler, input: { symbol: string; symbolId: number; qmSearchCode: string; lookbackDays?: number; }, _context?: ExecutionContext ): Promise<{ success: boolean; symbol: string; message: string; data?: any; }> { const { symbol, symbolId, qmSearchCode, lookbackDays = 30 } = input; this.logger.info('Fetching symbol news', { symbol, symbolId, lookbackDays }); const sessionManager = QMSessionManager.getInstance(); await sessionManager.initialize(this.cache, this.logger); const sessionId = QM_SESSION_IDS.LOOKUP; const session = await sessionManager.getSession(sessionId); if (!session || !session.uuid) { throw new Error(`No active session found for QM news`); } try { // Calculate date range const endDate = new Date(); const startDate = new Date(); startDate.setDate(startDate.getDate() - lookbackDays); // Build API request for symbol news const searchParams = new URLSearchParams({ symbol: symbol, symbolId: symbolId.toString(), qmodTool: 'News', webmasterId: '500', startDate: startDate.toISOString().split('T')[0], endDate: endDate.toISOString().split('T')[0], includeContent: 'true', pageSize: '50' } as Record); const apiUrl = `${QM_CONFIG.BASE_URL}/datatool/news.json?${searchParams.toString()}`; const response = await fetch(apiUrl, { method: 'GET', headers: session.headers, proxy: session.proxy, }); if (!response.ok) { throw new Error(`QM API request failed: ${response.status} ${response.statusText}`); } const newsData = await response.json(); // Update session success stats await sessionManager.incrementSuccessfulCalls(sessionId, session.uuid); // Process and store news data if (newsData && newsData.articles && newsData.articles.length > 0) { const processedArticles = newsData.articles.map((article: any) => ({ articleId: article.id || `${symbol}_${article.publishedDate}_${article.title.substring(0, 20)}`, symbol, symbolId, publishedDate: new Date(article.publishedDate), title: article.title, summary: article.summary || article.content?.substring(0, 500), source: article.source || 'Unknown', url: article.url, symbols: article.symbols || [symbol], categories: article.categories || [], sentiment: article.sentiment ? { score: parseFloat(article.sentiment.score) || 0, label: article.sentiment.label || 'neutral' } : null, imageUrl: article.imageUrl, isSymbolSpecific: true, updated_at: new Date() })); // Store in MongoDB await this.mongodb.batchUpsert( 'qmNews', processedArticles, ['articleId'] // Unique key ); // Calculate sentiment summary const sentimentCounts = processedArticles.reduce((acc: any, article: any) => { if (article.sentiment) { acc[article.sentiment.label] = (acc[article.sentiment.label] || 0) + 1; } return acc; }, {}); // Update operation tracking await this.operationRegistry.updateOperation('qm', qmSearchCode, 'news_update', { status: 'success', lastRecordDate: endDate, recordCount: processedArticles.length, metadata: { sentimentCounts, uniqueSources: new Set(processedArticles.map((a: any) => a.source)).size, avgSentimentScore: processedArticles .filter((a: any) => a.sentiment?.score) .reduce((sum: number, a: any, i: number, arr: any[]) => i === arr.length - 1 ? (sum + a.sentiment.score) / arr.length : sum + a.sentiment.score, 0 ) } }); this.logger.info('Symbol news updated successfully', { symbol, articleCount: processedArticles.length, sentimentCounts }); return { success: true, symbol, message: `Updated ${processedArticles.length} news articles`, data: { count: processedArticles.length, sentimentCounts, sources: new Set(processedArticles.map((a: any) => a.source)).size } }; } else { // No news found this.logger.info('No news articles found', { symbol }); await this.operationRegistry.updateOperation('qm', qmSearchCode, 'news_update', { status: 'success', lastRecordDate: endDate, recordCount: 0 }); return { success: true, symbol, message: 'No news articles found', data: { count: 0 } }; } } catch (error) { // Update session failure stats if (session.uuid) { await sessionManager.incrementFailedCalls(sessionId, session.uuid); } this.logger.error('Error fetching symbol news', { symbol, error: error instanceof Error ? error.message : 'Unknown error' }); // Update operation tracking for failure await this.operationRegistry.updateOperation('qm', qmSearchCode, 'news_update', { status: 'failure', error: error instanceof Error ? error.message : 'Unknown error' }); return { success: false, symbol, message: `Failed to fetch news: ${error instanceof Error ? error.message : 'Unknown error'}` }; } } /** * Update general market news */ export async function updateGeneralNews( this: QMHandler, input: { categories?: string[]; lookbackMinutes?: number; } = {}, _context?: ExecutionContext ): Promise<{ success: boolean; message: string; data?: any; }> { const { categories = ['market', 'economy', 'politics'], lookbackMinutes = 60 } = input; this.logger.info('Fetching general news', { categories, lookbackMinutes }); const sessionManager = QMSessionManager.getInstance(); await sessionManager.initialize(this.cache, this.logger); const sessionId = QM_SESSION_IDS.LOOKUP; const session = await sessionManager.getSession(sessionId); if (!session || !session.uuid) { throw new Error(`No active session found for QM general news`); } try { // Calculate time range const endDate = new Date(); const startDate = new Date(); startDate.setMinutes(startDate.getMinutes() - lookbackMinutes); // Build API request for general news const searchParams = new URLSearchParams({ qmodTool: 'MarketNews', webmasterId: '500', categories: categories.join(','), startDateTime: startDate.toISOString(), endDateTime: endDate.toISOString(), includeContent: 'true', pageSize: '100' } as Record); const apiUrl = `${QM_CONFIG.BASE_URL}/datatool/marketnews.json?${searchParams.toString()}`; const response = await fetch(apiUrl, { method: 'GET', headers: session.headers, proxy: session.proxy, }); if (!response.ok) { throw new Error(`QM API request failed: ${response.status} ${response.statusText}`); } const newsData = await response.json(); // Update session success stats await sessionManager.incrementSuccessfulCalls(sessionId, session.uuid); // Process and store general news if (newsData && newsData.articles && newsData.articles.length > 0) { const processedArticles = newsData.articles.map((article: any) => ({ articleId: article.id || `general_${article.publishedDate}_${article.title.substring(0, 20)}`, publishedDate: new Date(article.publishedDate), title: article.title, summary: article.summary || article.content?.substring(0, 500), source: article.source || 'Unknown', url: article.url, symbols: article.symbols || [], categories: article.categories || categories, sentiment: article.sentiment ? { score: parseFloat(article.sentiment.score) || 0, label: article.sentiment.label || 'neutral' } : null, imageUrl: article.imageUrl, isSymbolSpecific: false, isMarketMoving: article.isMarketMoving || false, importance: article.importance || 'medium', updated_at: new Date() })); // Store in MongoDB await this.mongodb.batchUpsert( 'qmNews', processedArticles, ['articleId'] // Unique key ); // Find high-importance articles const highImportanceCount = processedArticles.filter((a: any) => a.importance === 'high' || a.isMarketMoving ).length; // Update a general tracking document await this.mongodb.updateOne( 'qmOperationStats', { operation: 'general_news_update' }, { $set: { lastRunAt: new Date(), lastRecordCount: processedArticles.length, highImportanceCount, categories, updated_at: new Date() } }, { upsert: true } ); this.logger.info('General news updated successfully', { articleCount: processedArticles.length, highImportanceCount, categories }); return { success: true, message: `Updated ${processedArticles.length} general news articles`, data: { count: processedArticles.length, highImportanceCount, categories, sources: new Set(processedArticles.map((a: any) => a.source)).size } }; } else { // No news found this.logger.info('No general news articles found'); return { success: true, message: 'No general news articles found', data: { count: 0 } }; } } catch (error) { // Update session failure stats if (session.uuid) { await sessionManager.incrementFailedCalls(sessionId, session.uuid); } this.logger.error('Error fetching general news', { error: error instanceof Error ? error.message : 'Unknown error' }); return { success: false, message: `Failed to fetch general news: ${error instanceof Error ? error.message : 'Unknown error'}` }; } } /** * Schedule symbol news updates */ export async function scheduleSymbolNewsUpdates( this: QMHandler, input: { limit?: number; minHoursSinceRun?: number; forceUpdate?: boolean; } = {}, _context?: ExecutionContext ): Promise<{ message: string; symbolsQueued: number; errors: number; }> { const { limit = 200, minHoursSinceRun = 24 * 7, forceUpdate = false } = input; this.logger.info('Scheduling symbol news updates', { limit, minHoursSinceRun, forceUpdate }); try { // Get symbols that need news updates const staleSymbols = await this.operationRegistry.getStaleSymbols('qm', 'news_update', { minHoursSinceRun: forceUpdate ? 0 : minHoursSinceRun, limit }); if (staleSymbols.length === 0) { this.logger.info('No symbols need news updates'); return { message: 'No symbols need news updates', symbolsQueued: 0, errors: 0 }; } this.logger.info(`Found ${staleSymbols.length} symbols for news updates`); let symbolsQueued = 0; let errors = 0; // Schedule update jobs for (const item of staleSymbols) { try { if (!item.symbol.symbolId) { this.logger.warn(`Symbol ${item.symbol.symbol} missing symbolId, skipping`); continue; } await this.scheduleOperation('update-symbol-news', { symbol: item.symbol.symbol, symbolId: item.symbol.symbolId, qmSearchCode: item.symbol.qmSearchCode }, { priority: 4, // Lower priority than price data delay: symbolsQueued * 500 // 0.5 seconds between jobs }); symbolsQueued++; } catch (error) { this.logger.error(`Failed to schedule news update for ${item.symbol.symbol}`, { error }); errors++; } } this.logger.info('Symbol news update scheduling completed', { symbolsQueued, errors }); return { message: `Scheduled news updates for ${symbolsQueued} symbols`, symbolsQueued, errors }; } catch (error) { this.logger.error('Symbol news scheduling failed', { error }); throw error; } }