import { guessMarket } from "../datahub/client.js"; /** * External news providers for strategy symbol monitoring. * * Provider priority: * 1. CoinGecko Trending — free, no API key required (crypto only) * 2. Finnhub — free tier 60 calls/min, requires API key * 3. NewsAPI — free tier 100 calls/day, requires API key * * Default: CoinGecko (zero-config, works out of the box). */ import type { MarketType } from "../types.js"; import type { NewsItem, NewsProvider, SymbolNewsResult } from "./types.js"; // ── CoinGecko Trending (no key required) ───────────────────────────────── /** CoinGecko trending + search. Free, no API key. Crypto only. */ export class CoinGeckoTrendingProvider implements NewsProvider { readonly name = "coingecko"; private readonly baseUrl = "https://api.coingecko.com/api/v3"; async fetchNews(symbols: string[], market: MarketType): Promise { if (market !== "crypto") return []; try { const resp = await fetch(`${this.baseUrl}/search/trending`, { signal: AbortSignal.timeout(15_000), }); if (!resp.ok) return []; const data = (await resp.json()) as { coins?: Array<{ item: { id: string; coin_id: number; name: string; symbol: string; market_cap_rank?: number; price_btc?: number; score: number; }; }>; }; const trendingCoins = data.coins ?? []; const symbolSet = new Set(symbols.map((s) => s.split("/")[0]?.toUpperCase())); const now = new Date().toISOString(); return trendingCoins .filter((c) => symbolSet.has(c.item.symbol.toUpperCase())) .map((c) => ({ id: `cg-trending-${c.item.id}`, title: `${c.item.name} (${c.item.symbol.toUpperCase()}) is trending on CoinGecko`, summary: `Rank #${c.item.market_cap_rank ?? "N/A"}, trending score: ${c.item.score}`, source: "CoinGecko Trending", url: `https://www.coingecko.com/en/coins/${c.item.id}`, publishedAt: now, symbols: [`${c.item.symbol.toUpperCase()}/USDT`], relevanceScore: Math.max(0, 1 - c.item.score * 0.1), })); } catch { return []; } } } // ── Finnhub (requires API key) ─────────────────────────────────────────── /** Finnhub company news + market news. Requires API key. */ export class FinnhubNewsProvider implements NewsProvider { readonly name = "finnhub"; private readonly baseUrl = "https://finnhub.io/api/v1"; constructor(private readonly apiKey: string) {} async fetchNews(symbols: string[], market: MarketType): Promise { if (market === "crypto") { return this.fetchMarketNews("crypto"); } const results: NewsItem[] = []; for (const symbol of symbols.slice(0, 5)) { const items = await this.fetchCompanyNews(symbol); results.push(...items); } return results; } private async fetchCompanyNews(symbol: string): Promise { const cleanSymbol = symbol.replace(/\.(SH|SZ|BJ|HK)$/i, ""); const to = new Date().toISOString().slice(0, 10); const from = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10); try { const url = `${this.baseUrl}/company-news?symbol=${cleanSymbol}&from=${from}&to=${to}&token=${this.apiKey}`; const resp = await fetch(url, { signal: AbortSignal.timeout(15_000) }); if (!resp.ok) return []; const data = (await resp.json()) as Array<{ id: number; headline: string; summary: string; source: string; url: string; datetime: number; related: string; }>; return data.slice(0, 10).map((item) => ({ id: `finnhub-${item.id}`, title: item.headline, summary: item.summary.slice(0, 300), source: item.source, url: item.url, publishedAt: new Date(item.datetime * 1000).toISOString(), symbols: [symbol], relevanceScore: 0.8, })); } catch { return []; } } private async fetchMarketNews(category: string): Promise { try { const url = `${this.baseUrl}/news?category=${category}&token=${this.apiKey}`; const resp = await fetch(url, { signal: AbortSignal.timeout(15_000) }); if (!resp.ok) return []; const data = (await resp.json()) as Array<{ id: number; headline: string; summary: string; source: string; url: string; datetime: number; }>; return data.slice(0, 10).map((item) => ({ id: `finnhub-mkt-${item.id}`, title: item.headline, summary: item.summary.slice(0, 300), source: item.source, url: item.url, publishedAt: new Date(item.datetime * 1000).toISOString(), symbols: [], relevanceScore: 0.5, })); } catch { return []; } } } // ── NewsAPI (requires API key) ─────────────────────────────────────────── /** NewsAPI.org everything search. Requires API key. */ export class NewsApiProvider implements NewsProvider { readonly name = "newsapi"; private readonly baseUrl = "https://newsapi.org/v2"; constructor(private readonly apiKey: string) {} async fetchNews(symbols: string[], _market: MarketType): Promise { const query = symbols .slice(0, 3) .map((s) => s.split("/")[0]?.replace(/\.(SH|SZ|BJ|HK)$/i, "")) .join(" OR "); if (!query) return []; try { const url = `${this.baseUrl}/everything?q=${encodeURIComponent(query)}&sortBy=publishedAt&pageSize=10&apiKey=${this.apiKey}`; const resp = await fetch(url, { signal: AbortSignal.timeout(15_000) }); if (!resp.ok) return []; const data = (await resp.json()) as { articles?: Array<{ title: string; description: string; source: { name: string }; url: string; publishedAt: string; }>; }; return (data.articles ?? []).map((a, i) => ({ id: `newsapi-${Date.now()}-${i}`, title: a.title, summary: (a.description ?? "").slice(0, 300), source: a.source.name, url: a.url, publishedAt: a.publishedAt, symbols, relevanceScore: 0.6, })); } catch { return []; } } } // ── Aggregated provider ────────────────────────────────────────────────── /** * Aggregates multiple news providers with fallback. * Deduplicates by news ID and sorts by publish time. */ export class AggregatedNewsProvider { private readonly providers: NewsProvider[]; constructor(providers: NewsProvider[]) { this.providers = providers; } /** Fetch news for a list of symbols, grouped by symbol. */ async fetchNewsForSymbols(symbols: string[]): Promise { const symbolsByMarket = new Map(); for (const sym of symbols) { const market = guessMarket(sym); const list = symbolsByMarket.get(market) ?? []; list.push(sym); symbolsByMarket.set(market, list); } const allNews: NewsItem[] = []; for (const [market, syms] of symbolsByMarket) { for (const provider of this.providers) { try { const items = await provider.fetchNews(syms, market); allNews.push(...items); if (items.length > 0) break; // first successful provider wins } catch { continue; } } } // Deduplicate by id const seen = new Set(); const unique = allNews.filter((item) => { if (seen.has(item.id)) return false; seen.add(item.id); return true; }); // Group by symbol const resultMap = new Map(); for (const sym of symbols) { resultMap.set(sym, { symbol: sym, market: guessMarket(sym), news: [], }); } for (const item of unique) { if (item.symbols.length === 0) { // Market-wide news: attach to all symbols of matching market for (const [sym, result] of resultMap) { result.news.push(item); } } else { for (const sym of item.symbols) { const result = resultMap.get(sym); if (result) result.news.push(item); } } } // Sort each symbol's news by publish time (newest first) for (const result of resultMap.values()) { result.news.sort((a, b) => b.publishedAt.localeCompare(a.publishedAt)); } return [...resultMap.values()]; } } // ── Factory ────────────────────────────────────────────────────────────── /** Create news providers based on config. Always includes CoinGecko as fallback. */ export function createNewsProviders(config: { newsProvider: string; newsApiKey?: string; }): NewsProvider[] { const providers: NewsProvider[] = []; if (config.newsApiKey) { if (config.newsProvider === "finnhub") { providers.push(new FinnhubNewsProvider(config.newsApiKey)); } else if (config.newsProvider === "newsapi") { providers.push(new NewsApiProvider(config.newsApiKey)); } } // CoinGecko is always available as fallback (no key required) providers.push(new CoinGeckoTrendingProvider()); return providers; }