import { YahooClient } from '../market/YahooClient.js'; import { BenchmarkProvider } from '../market/BenchmarkProvider.js'; import { mapToStandardFormat } from './DataMapper.js'; import { chunkArray } from './Chunker.js'; import { RuleMerger } from './RuleMerger.js'; import { Stock } from './assets/Stock.js'; import { Etf } from './assets/Etf.js'; import { Bond } from './assets/Bond.js'; import { StockScorer } from './scorers/StockScorer.js'; import { EtfScorer } from './scorers/EtfScorer.js'; import { BondScorer } from './scorers/BondScorer.js'; import { SIGNAL, SIGNAL_ORDER, SCORE_MODE, ASSET_TYPE } from '../config/constants.js'; import type { Logger, MarketContext, Signal, AssetType, ScreenerResult } from '../types.js'; const SCORERS: Record = { [ASSET_TYPE.STOCK]: StockScorer, [ASSET_TYPE.ETF]: EtfScorer, [ASSET_TYPE.BOND]: BondScorer, }; interface ScreenerEngineOptions { logger?: Logger; } interface ErrorResult { isError: true; ticker: string; message: string; } type FetchResult = ReturnType | ErrorResult; export class ScreenerEngine { private client: YahooClient; private benchmarkProvider: BenchmarkProvider; private logger: Logger; constructor({ logger }: ScreenerEngineOptions = {}) { this.client = new YahooClient(); 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), }; } // Pure data method — returns structured results. Safe to use in a server route. async screenTickers(tickers: string[]): Promise { const marketContext = await this.benchmarkProvider.getMarketContext(); const results: Omit = { STOCK: [], ETF: [], BOND: [], ERROR: [], }; for (const chunk of chunkArray(tickers, 5)) { const batch = await Promise.all(chunk.map((t) => this._fetch(t))); batch.forEach((data) => this._process(data, marketContext, results)); await new Promise((r) => setTimeout(r, 1000)); } return { ...results, marketContext }; } // CLI helper — emits progress to logger, returns structured results. async screenWithProgress(tickers: string[]): Promise { this.logger.write('⏳ Fetching market context...'); const marketContext = await this.benchmarkProvider.getMarketContext(); this.logger.write(' done\n'); const results: Omit = { STOCK: [], ETF: [], BOND: [], ERROR: [], }; const chunks = chunkArray(tickers, 5); let processed = 0; for (const chunk of chunks) { const batch = await Promise.all(chunk.map((t) => this._fetch(t))); batch.forEach((data) => this._process(data, marketContext, results)); processed += chunk.length; this.logger.write(`\r⏳ Screening tickers... ${processed}/${tickers.length}`); await new Promise((r) => setTimeout(r, 1000)); } this.logger.write('\n'); return { ...results, marketContext }; } 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 mapToStandardFormat(ticker, summary); } catch (err) { return { isError: true, ticker: ticker.toUpperCase(), message: (err as Error).message }; } } private _process( data: FetchResult, marketContext: MarketContext, results: Omit, ): void { if ('isError' in data && data.isError) { results.ERROR.push({ ticker: data.ticker, message: data.message }); return; } try { const asset = this._buildAsset(data as ReturnType); const scorer = SCORERS[asset.type as AssetType]; if (!scorer) throw new Error(`No scorer for type: ${asset.type}`); const fundamental = scorer.score( asset.metrics as never, RuleMerger.getRulesForAsset( asset.type as AssetType, asset.metrics as { sector?: string }, marketContext, SCORE_MODE.FUNDAMENTAL, ), marketContext, ); const inflated = scorer.score( asset.metrics as never, RuleMerger.getRulesForAsset( asset.type as AssetType, asset.metrics as { sector?: string }, marketContext, SCORE_MODE.INFLATED, ), marketContext, ); (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, }); } } 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 never); case ASSET_TYPE.ETF: return new Etf(data as never); default: return new Stock(data as never); } } 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; } }