phase-7: code restructure
This commit is contained in:
committed by
saikiranvella
parent
c160e65bd6
commit
357b0c0f6e
@@ -0,0 +1,198 @@
|
||||
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<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 (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<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> {
|
||||
await new Promise<void>((r) => setTimeout(r, ScreenerEngine.BATCH_DELAY_MS));
|
||||
}
|
||||
|
||||
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.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<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);
|
||||
}
|
||||
}
|
||||
|
||||
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<MarketContext> {
|
||||
return this.benchmarkProvider.getMarketContext();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user