209 lines
6.7 KiB
TypeScript
209 lines
6.7 KiB
TypeScript
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;
|
|
private readonly batchDelayMs: number;
|
|
|
|
constructor(
|
|
private readonly client: YahooFinanceClient,
|
|
private readonly benchmarkProvider: BenchmarkProvider,
|
|
{ logger, batchDelayMs }: ScreenerEngineOptions = {},
|
|
) {
|
|
this.batchDelayMs = batchDelayMs ?? ScreenerEngine.BATCH_DELAY_MS;
|
|
// 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<ScreenerResult> {
|
|
return this.screenInternal(tickers, false);
|
|
}
|
|
|
|
async screenWithProgress(tickers: string[]): Promise<ScreenerResult> {
|
|
return this.screenInternal(tickers, true);
|
|
}
|
|
|
|
private async screenInternal(tickers: string[], showProgress: boolean): Promise<ScreenerResult> {
|
|
const marketContext = await this.fetchMarketContext(showProgress);
|
|
const results = this.initializeResults();
|
|
const chunks = chunkArray(tickers, ScreenerEngine.BATCH_SIZE);
|
|
let processed = 0;
|
|
|
|
for (let i = 0; i < chunks.length; i++) {
|
|
await this.processBatch(chunks[i], marketContext, results);
|
|
processed += chunks[i].length;
|
|
this.logProgress(showProgress, processed, tickers.length);
|
|
// Rate-limit pause between batches — never after the last one
|
|
if (i < chunks.length - 1) await this.rateLimitDelay();
|
|
}
|
|
|
|
if (showProgress) {
|
|
this.logger.write('\n');
|
|
}
|
|
|
|
return { ...results, marketContext };
|
|
}
|
|
|
|
private async fetchMarketContext(showProgress: boolean): Promise<MarketContext> {
|
|
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<ScreenerResult, 'marketContext'> {
|
|
return { STOCK: [], ETF: [], BOND: [], ERROR: [] };
|
|
}
|
|
|
|
private async processBatch(
|
|
tickers: string[],
|
|
marketContext: MarketContext,
|
|
results: Omit<ScreenerResult, 'marketContext'>,
|
|
): Promise<void> {
|
|
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<void> {
|
|
if (this.batchDelayMs <= 0) return;
|
|
await new Promise<void>((r) => setTimeout(r, this.batchDelayMs));
|
|
}
|
|
|
|
private async fetch(ticker: string): Promise<MappedData | ErrorResult> {
|
|
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<ScreenerResult, 'marketContext'>,
|
|
): 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<string, unknown>): 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<MarketContext> {
|
|
return this.benchmarkProvider.getMarketContext();
|
|
}
|
|
}
|