142 lines
4.8 KiB
JavaScript
142 lines
4.8 KiB
JavaScript
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;
|
|
}
|
|
}
|