Files
market_screener/server/services/CatalystAnalyst.ts
T
2026-06-05 22:44:04 -04:00

109 lines
3.6 KiB
TypeScript

import { YahooFinanceClient } from '../clients/YahooFinanceClient';
import type { Logger, CatalystResult, Story, YahooNewsItem } from '../types';
export class CatalystAnalyst {
private static readonly NEWS_QUERIES = [
'stock market today',
'earnings report today',
'market news catalyst',
'federal reserve interest rates',
'stock upgrade downgrade analyst',
];
private static readonly MAX_STORIES = 20;
private static readonly TICKER_REGEX = /^[A-Z]{1,6}$/;
private client: YahooFinanceClient;
private logger: Pick<Logger, 'write'>;
constructor({ logger }: { logger?: Pick<Logger, 'write'> } = {}) {
this.client = new YahooFinanceClient();
this.logger = logger ?? { write: (msg: string) => process.stdout.write(msg) };
}
async run(): Promise<CatalystResult> {
this.logger.write('🔍 Fetching market news...');
const rawStories = await this.fetchNews();
if (!rawStories.length) {
this.logger.write(' ⚠ all news queries failed — check network or Yahoo rate limit\n');
return { tickers: [], tickerFrequency: {}, stories: [] };
}
const stories = rawStories.map((s) => ({
title: s.title,
link: s.link ?? '',
source: s.publisher ?? 'unknown',
tickers: (s.relatedTickers ?? [])
.map((t) => t.split(':')[0].toUpperCase())
.filter((t) => CatalystAnalyst.TICKER_REGEX.test(t)),
}));
const { tickers, tickerFrequency } = CatalystAnalyst.rankTickers(stories);
this.logger.write(` ${stories.length} stories, ${tickers.length} tickers\n`);
return { tickers, tickerFrequency, stories };
}
// Search by specific ticker for the /api/analyze endpoint.
async fetchStoriesForTickers(tickers: string[]): Promise<Story[]> {
const seen = new Map<string, YahooNewsItem>();
await Promise.all(
tickers.slice(0, 10).map(async (ticker) => {
try {
const news = await this.client.search(ticker, { newsCount: 3, quotesCount: 0 });
for (const item of news) {
if (!seen.has(item.title)) seen.set(item.title, item);
}
} catch {
/* skip tickers Yahoo can't resolve */
}
}),
);
return [...seen.values()].slice(0, 15).map((s) => ({
title: s.title,
link: s.link ?? '',
source: s.publisher ?? 'unknown',
tickers: (s.relatedTickers ?? [])
.map((t) => t.split(':')[0].toUpperCase())
.filter((t) => CatalystAnalyst.TICKER_REGEX.test(t)),
}));
}
private async fetchNews(): Promise<YahooNewsItem[]> {
const seen = new Map<string, YahooNewsItem>();
let successCount = 0;
for (const query of CatalystAnalyst.NEWS_QUERIES) {
try {
const news = await this.client.search(query, { newsCount: 8, quotesCount: 0 });
successCount++;
for (const s of news) {
if (!seen.has(s.title)) {
seen.set(s.title, {
title: s.title,
publisher: s.publisher,
link: s.link,
relatedTickers: s.relatedTickers ?? [],
});
}
}
} catch {
/* skip failed query — tracked via successCount */
}
}
if (successCount === 0) return [];
return [...seen.values()].slice(0, CatalystAnalyst.MAX_STORIES);
}
static rankTickers(stories: Story[]): {
tickers: string[];
tickerFrequency: Record<string, number>;
} {
const freq: Record<string, number> = {};
for (const { tickers } of stories) {
for (const t of tickers) {
freq[t] = (freq[t] ?? 0) + 1;
}
}
const tickers = Object.keys(freq).sort((a, b) => freq[b] - freq[a]);
return { tickers, tickerFrequency: freq };
}
}