109 lines
3.6 KiB
TypeScript
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 };
|
|
}
|
|
}
|