import { YahooFinanceClient, BenchmarkProvider, chunkArray, Stock, Etf, Bond, SIGNAL, SIGNAL_ORDER, SCORE_MODE, ASSET_TYPE, } from '../../domains/shared'; import { DataMapper } from './transform/DataMapper'; import { RuleMerger } from './transform/RuleMerger'; import { StockScorer } from './scorers/StockScorer'; import { EtfScorer } from './scorers/EtfScorer'; import { BondScorer } from './scorers/BondScorer'; import type { Logger, MarketContext, Signal, AssetType, ScoreResult, ScreenerResult, ScreenerEngineOptions, ErrorResult, MappedData, StockData, EtfData, BondData, } from '../../domains/shared'; export class ScreenerEngine { private static readonly BATCH_SIZE = 5; private static readonly BATCH_DELAY_MS = 1000; private logger: Logger; constructor( private readonly client: YahooFinanceClient, private readonly benchmarkProvider: BenchmarkProvider, { logger }: ScreenerEngineOptions = {}, ) { // eslint-disable-next-line no-console this.logger = logger ?? { write: (msg: string) => process.stdout.write(msg), // eslint-disable-next-line no-console log: (...args: unknown[]) => console.log(...args), // eslint-disable-next-line no-console 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, inflated), }); } 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); } } // Signal derives from the structured verdict tier — never from label strings. // Rewording a display label can no longer silently corrupt signals. private signal(fundamental: ScoreResult, inflated: ScoreResult): Signal { if (fundamental.tier === 'PASS') return SIGNAL.STRONG_BUY; if (inflated.tier === 'PASS' && fundamental.tier === 'HOLD') return SIGNAL.MOMENTUM; if (inflated.tier === 'PASS') return SIGNAL.SPECULATION; if (fundamental.tier === 'HOLD' || inflated.tier === 'HOLD') return SIGNAL.NEUTRAL; return SIGNAL.AVOID; } signalOrder(signal: Signal): number { return SIGNAL_ORDER[signal] ?? 5; } getMarketContext(): Promise { return this.benchmarkProvider.getMarketContext(); } }