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'; const SCORERS = { [ASSET_TYPE.STOCK]: StockScorer, [ASSET_TYPE.ETF]: EtfScorer, [ASSET_TYPE.BOND]: BondScorer, }; export class ScreenerEngine { // logger: object with .write() / .log() — defaults to a console shim so CLI behaviour is unchanged. // Pass a no-op logger ({ write: () => {}, log: () => {} }) in server context. constructor({ logger } = {}) { this.client = new YahooClient(); this.benchmarkProvider = new BenchmarkProvider({ logger: logger ?? console }); this.logger = logger ?? { write: (msg) => process.stdout.write(msg), log: (...args) => console.log(...args), }; } // Pure data method — returns structured results. Safe to use in a server route. async screenTickers(tickers) { const marketContext = await this.benchmarkProvider.getMarketContext(); const results = { 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. // The caller (bin/screen.js) is responsible for writing the report. async screenWithProgress(tickers) { this.logger.write('⏳ Fetching market context...'); const marketContext = await this.benchmarkProvider.getMarketContext(); this.logger.write(' done\n'); const results = { 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 }; } async _fetch(ticker) { 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.message }; } } _process(data, marketContext, results) { if (data.isError) { results.ERROR.push(data); return; } try { const asset = this._buildAsset(data); const scorer = SCORERS[asset.type]; if (!scorer) throw new Error(`No scorer for type: ${asset.type}`); const fundamental = scorer.score( asset.metrics, RuleMerger.getRulesForAsset( asset.type, asset.metrics, marketContext, SCORE_MODE.FUNDAMENTAL, ), marketContext, ); const inflated = scorer.score( asset.metrics, RuleMerger.getRulesForAsset(asset.type, asset.metrics, marketContext, SCORE_MODE.INFLATED), marketContext, ); results[asset.type].push({ asset, fundamental, inflated, signal: this._signal(fundamental.label, inflated.label), }); } catch (err) { results.ERROR.push({ ticker: (data.ticker || 'UNKNOWN').toUpperCase(), message: err.message, }); } } _buildAsset(data) { switch ((data.type || ASSET_TYPE.STOCK).toUpperCase()) { case ASSET_TYPE.BOND: return new Bond(data); case ASSET_TYPE.ETF: return new Etf(data); default: return new Stock(data); } } _signal(fundamentalLabel, inflatedLabel) { const green = (l) => l.startsWith('🟢'); const yellow = (l) => 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) { return SIGNAL_ORDER[signal] ?? 5; } }