441 lines
No EOL
13 KiB
TypeScript
441 lines
No EOL
13 KiB
TypeScript
/**
|
|
* 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<string, string>);
|
|
|
|
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<string, string>);
|
|
|
|
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;
|
|
}
|
|
} |