phase-7: code restructure

This commit is contained in:
Kazuma
2026-06-05 22:05:55 -04:00
committed by Kazuma
parent 2b785aa861
commit 5b32bd7a04
108 changed files with 8931 additions and 3434 deletions
+198
View File
@@ -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();
}
}