import { YahooFinanceClient } from '../clients/YahooFinanceClient'; import { BenchmarkProvider } from './BenchmarkProvider'; import { DataMapper } from './DataMapper'; import { chunkArray } from '../utils/Chunker'; import { RuleMerger } from './RuleMerger'; import { Stock } from '../models/Stock'; import { Etf } from '../models/Etf'; import { Bond } from '../models/Bond'; import { StockScorer } from '../scorers/StockScorer'; import { EtfScorer } from '../scorers/EtfScorer'; import { BondScorer } from '../scorers/BondScorer'; import { SIGNAL, SIGNAL_ORDER, SCORE_MODE, ASSET_TYPE } from '../config/constants'; import type { Logger, MarketContext, Signal, AssetType, ScoreResult, ScreenerResult, ScreenerEngineOptions, ErrorResult, MappedData, StockData, EtfData, BondData, } from '../types'; export class ScreenerEngine { private static readonly BATCH_SIZE = 5; private static readonly BATCH_DELAY_MS = 1000; private client: YahooFinanceClient; private benchmarkProvider: BenchmarkProvider; private logger: Logger; constructor({ logger }: ScreenerEngineOptions = {}) { this.client = new YahooFinanceClient(); this.benchmarkProvider = new BenchmarkProvider({ logger: logger ?? (console as unknown as Logger), }); this.logger = logger ?? { write: (msg: string) => process.stdout.write(msg), log: (...args: unknown[]) => console.log(...args), warn: (...args: unknown[]) => console.warn(...args), }; } async screenTickers(tickers: string[]): Promise { return this._screenInternal(tickers, false); } async screenWithProgress(tickers: string[]): Promise { return this._screenInternal(tickers, true); } private async _screenInternal(tickers: string[], showProgress: boolean): Promise { const marketContext = await this._fetchMarketContext(showProgress); const results = this._initializeResults(); const chunks = chunkArray(tickers, ScreenerEngine.BATCH_SIZE); let processed = 0; for (const chunk of chunks) { await this._processBatch(chunk, marketContext, results); processed += chunk.length; this._logProgress(showProgress, processed, tickers.length); await this._rateLimitDelay(); } if (showProgress) { this.logger.write('\n'); } return { ...results, marketContext }; } private async _fetchMarketContext(showProgress: boolean): Promise { if (showProgress) { this.logger.write('⏳ Fetching market context...'); } const context = await this.benchmarkProvider.getMarketContext(); if (showProgress) { this.logger.write(' done\n'); } return context; } private _initializeResults(): Omit { return { STOCK: [], ETF: [], BOND: [], ERROR: [] }; } private async _processBatch( tickers: string[], marketContext: MarketContext, results: Omit, ): Promise { const batch = await Promise.all(tickers.map((t) => this._fetch(t))); batch.forEach((data) => this._process(data, marketContext, results)); } private _logProgress(showProgress: boolean, processed: number, total: number): void { if (showProgress) { this.logger.write(`\r⏳ Screening tickers... ${processed}/${total}`); } } private async _rateLimitDelay(): Promise { await new Promise((r) => setTimeout(r, ScreenerEngine.BATCH_DELAY_MS)); } private async _fetch(ticker: string): Promise { try { const summary = await this.client.fetchSummary(ticker); if (!summary?.price) throw new Error('Empty response from Yahoo'); return DataMapper.mapToStandardFormat(ticker, summary); } catch (err) { return { isError: true, ticker: ticker.toUpperCase(), message: (err as Error).message }; } } private _process( data: MappedData | ErrorResult, marketContext: MarketContext, results: Omit, ): void { if ('isError' in data && data.isError) { const e = data as ErrorResult; results.ERROR.push({ ticker: e.ticker, message: e.message }); return; } try { const asset = this._buildAsset(data as MappedData); const fundamental = this._score(asset, marketContext, SCORE_MODE.FUNDAMENTAL); const inflated = this._score(asset, marketContext, SCORE_MODE.INFLATED); (results[asset.type as AssetType] as unknown[]).push({ asset, fundamental, inflated, signal: this._signal(fundamental.label, inflated.label), }); } catch (err) { results.ERROR.push({ ticker: ((data as { ticker?: string }).ticker || 'UNKNOWN').toUpperCase(), message: (err as Error).message, }); } } // Typed scorer dispatch — instanceof narrows the asset so each scorer receives // its exact metrics type. No `as never` or unsafe casts required. private _score( asset: Stock | Etf | Bond, marketContext: MarketContext, mode: string, ): ScoreResult { const rules = RuleMerger.getRulesForAsset( asset.type as AssetType, asset.metrics as { sector?: string }, marketContext, mode, ); if (asset instanceof Stock) return StockScorer.score(asset.metrics, rules); if (asset instanceof Etf) return EtfScorer.score(asset.metrics, rules); if (asset instanceof Bond) return BondScorer.score(asset.metrics, rules, marketContext); // TypeScript exhaustive check: all three branches are handled above. throw new Error('No scorer for unknown asset type'); } private _buildAsset(data: Record): Stock | Etf | Bond { switch (((data.type as string) || ASSET_TYPE.STOCK).toUpperCase()) { case ASSET_TYPE.BOND: return new Bond(data as BondData); case ASSET_TYPE.ETF: return new Etf(data as EtfData); default: return new Stock(data as StockData); } } private _signal(fundamentalLabel: string, inflatedLabel: string): Signal { const green = (l: string) => l.startsWith('🟢'); const yellow = (l: string) => l.startsWith('🟡'); if (green(fundamentalLabel)) return SIGNAL.STRONG_BUY; if (green(inflatedLabel) && yellow(fundamentalLabel)) return SIGNAL.MOMENTUM; if (green(inflatedLabel) && !green(fundamentalLabel)) return SIGNAL.SPECULATION; if (yellow(fundamentalLabel) || yellow(inflatedLabel)) return SIGNAL.NEUTRAL; return SIGNAL.AVOID; } signalOrder(signal: Signal): number { return SIGNAL_ORDER[signal] ?? 5; } getMarketContext(): Promise { return this.benchmarkProvider.getMarketContext(); } }