phase-2: extract shared utils
This commit is contained in:
committed by
saikiranvella
parent
5a4b4aa6d1
commit
d5cf3fc31f
@@ -0,0 +1,4 @@
|
||||
export const chunkArray = (array, size) =>
|
||||
Array.from({ length: Math.ceil(array.length / size) }, (_, i) =>
|
||||
array.slice(i * size, i * size + size),
|
||||
);
|
||||
@@ -0,0 +1,153 @@
|
||||
export const mapToStandardFormat = (ticker, summary) => {
|
||||
const quoteType = summary.price?.quoteType;
|
||||
const category = (summary.assetProfile?.category || '').toLowerCase();
|
||||
const yieldVal = summary.summaryDetail?.trailingAnnualDividendYield ?? 0;
|
||||
// Logic to determine type
|
||||
const isBond =
|
||||
category.includes('bond') ||
|
||||
category.includes('fixed income') ||
|
||||
category.includes('treasury') ||
|
||||
(quoteType === 'ETF' && yieldVal > 0.02 && category === ''); // Heuristic fallback
|
||||
if (quoteType === 'ETF') {
|
||||
return isBond
|
||||
? {
|
||||
type: 'BOND',
|
||||
ticker,
|
||||
...mapBondData(summary),
|
||||
}
|
||||
: {
|
||||
type: 'ETF',
|
||||
ticker,
|
||||
...mapEtfData(summary),
|
||||
};
|
||||
}
|
||||
// Default to STOCK (covers 'EQUITY' or missing types)
|
||||
return {
|
||||
type: 'STOCK',
|
||||
ticker,
|
||||
...mapStockData(summary),
|
||||
};
|
||||
};
|
||||
|
||||
const mapStockData = (summary) => {
|
||||
const fd = summary.financialData ?? {};
|
||||
const ks = summary.defaultKeyStatistics ?? {};
|
||||
const sd = summary.summaryDetail ?? {};
|
||||
const pr = summary.price ?? {};
|
||||
|
||||
const currentPrice = pr.regularMarketPrice ?? 0;
|
||||
const sharesOutstanding = ks.sharesOutstanding ?? 0;
|
||||
const operatingCashflow = fd.operatingCashflow ?? 0;
|
||||
const freeCashflow = fd.freeCashflow ?? 0;
|
||||
|
||||
// P/FFO proxy (price / operating cash flow per share) — used for REIT scoring
|
||||
const pFFO =
|
||||
operatingCashflow > 0 && sharesOutstanding > 0
|
||||
? currentPrice / (operatingCashflow / sharesOutstanding)
|
||||
: null;
|
||||
|
||||
// FCF yield = free cash flow per share / price.
|
||||
// Negative FCF is preserved (not nulled) — a company burning cash should fail the gate,
|
||||
// not be silently skipped as "no data".
|
||||
const fcfYield =
|
||||
freeCashflow !== 0 && sharesOutstanding > 0 && currentPrice > 0
|
||||
? (freeCashflow / sharesOutstanding / currentPrice) * 100
|
||||
: null;
|
||||
|
||||
// PEG computation: use Yahoo's value first; fall back to trailingPE / earningsGrowth
|
||||
// earningsGrowth from Yahoo is a decimal (e.g. 0.15 = 15%), convert to whole number first
|
||||
const yahoosPEG = ks.pegRatio ?? null;
|
||||
const trailingPE = sd.trailingPE ?? null;
|
||||
const earningsGrowth = fd.earningsGrowth != null ? fd.earningsGrowth * 100 : null; // now in %
|
||||
const computedPEG =
|
||||
trailingPE != null && earningsGrowth > 0 ? +(trailingPE / earningsGrowth).toFixed(2) : null;
|
||||
const pegRatio = yahoosPEG ?? computedPEG; // prefer Yahoo's, fall back to computed
|
||||
|
||||
// Quick ratio — fall back to currentRatio when quickRatio is missing
|
||||
const quickRatio = fd.quickRatio ?? fd.currentRatio ?? null;
|
||||
|
||||
return {
|
||||
// Valuation — trailing PE is the audited number; forward PE is an analyst estimate
|
||||
// (historically 10-15% optimistic). Use trailing as primary for fundamental mode.
|
||||
peRatio: trailingPE ?? ks.forwardPE,
|
||||
trailingPE,
|
||||
pegRatio,
|
||||
priceToBook: ks.priceToBook ?? null,
|
||||
evToEbitda: ks.enterpriseToEbitda ?? null,
|
||||
|
||||
// Profitability
|
||||
netProfitMargin: fd.profitMargins != null ? fd.profitMargins * 100 : null,
|
||||
operatingMargin: fd.operatingMargins != null ? fd.operatingMargins * 100 : null,
|
||||
returnOnEquity: fd.returnOnEquity != null ? fd.returnOnEquity * 100 : null,
|
||||
|
||||
// Growth
|
||||
revenueGrowth: fd.revenueGrowth != null ? fd.revenueGrowth * 100 : null,
|
||||
earningsGrowth,
|
||||
|
||||
// Financial health
|
||||
debtToEquity: fd.debtToEquity != null ? fd.debtToEquity / 100 : null,
|
||||
quickRatio,
|
||||
|
||||
// Cash flow
|
||||
fcfYield,
|
||||
pFFO,
|
||||
|
||||
// Income
|
||||
dividendYield:
|
||||
sd.trailingAnnualDividendYield != null ? sd.trailingAnnualDividendYield * 100 : null,
|
||||
|
||||
// Risk & momentum
|
||||
beta: sd.beta ?? null,
|
||||
week52High: sd.fiftyTwoWeekHigh ?? null,
|
||||
week52Low: sd.fiftyTwoWeekLow ?? null,
|
||||
|
||||
currentPrice,
|
||||
assetProfile: summary.assetProfile || {},
|
||||
};
|
||||
};
|
||||
|
||||
const mapEtfData = (summary) => ({
|
||||
expenseRatio: (summary.summaryDetail?.expenseRatio ?? 0) * 100,
|
||||
totalAssets: summary.summaryDetail?.totalAssets ?? 0,
|
||||
yield: (summary.summaryDetail?.trailingAnnualDividendYield ?? 0) * 100,
|
||||
// fiveYearAverageReturn is annualised total return — valid proxy for performance vs peers.
|
||||
fiveYearReturn: (summary.defaultKeyStatistics?.fiveYearAverageReturn ?? 0) * 100,
|
||||
// averageVolume from summaryDetail is average daily trading volume — used for liquidity gate.
|
||||
volume: summary.summaryDetail?.averageVolume ?? summary.price?.averageVolume ?? 0,
|
||||
currentPrice: summary.price?.regularMarketPrice ?? 0,
|
||||
});
|
||||
|
||||
/**
|
||||
* Infer credit rating from ETF category string (Yahoo Finance doesn't expose
|
||||
* bond credit ratings directly). Defaults to BBB (investment grade) when unknown.
|
||||
*/
|
||||
const inferCreditRating = (category) => {
|
||||
const cat = (category || '').toLowerCase();
|
||||
if (cat.includes('government') || cat.includes('treasury')) return 'AAA';
|
||||
if (cat.includes('muni')) return 'AA';
|
||||
if (cat.includes('high yield') || cat.includes('junk')) return 'BB';
|
||||
if (cat.includes('corporate') || cat.includes('investment grade')) return 'A';
|
||||
return 'BBB'; // conservative default
|
||||
};
|
||||
|
||||
// Infers approximate effective duration (years) from bond ETF category name.
|
||||
// Buckets match standard industry classifications (short < 3y, intermediate 3-7y, long > 10y).
|
||||
const inferDuration = (category) => {
|
||||
const cat = (category || '').toLowerCase();
|
||||
if (cat.includes('short') || cat.includes('ultrashort') || cat.includes('1-3')) return 2;
|
||||
if (cat.includes('intermediate') || cat.includes('3-7') || cat.includes('3-10')) return 5;
|
||||
if (cat.includes('long') || cat.includes('10+') || cat.includes('20+')) return 18;
|
||||
if (cat.includes('target maturity') || cat.includes('defined maturity')) return 4;
|
||||
return 6; // conservative default — typical aggregate bond fund duration
|
||||
};
|
||||
|
||||
const mapBondData = (summary) => ({
|
||||
yieldToMaturity: (summary.summaryDetail?.yield ?? 0) * 100,
|
||||
// KNOWN LIMITATION: Yahoo Finance does not expose effective duration via the modules
|
||||
// we fetch (assetProfile, financialData, defaultKeyStatistics, price, summaryDetail).
|
||||
// The `fundProfile` module has duration for some funds but requires a separate fetch.
|
||||
// We use the ETF category name to infer a rough duration bucket as a proxy.
|
||||
duration: inferDuration(summary.assetProfile?.category),
|
||||
creditRating: inferCreditRating(summary.assetProfile?.category),
|
||||
currentPrice: summary.price?.regularMarketPrice ?? 0,
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ScoringRules } from '../config/ScoringConfig.js';
|
||||
import { MarketRegime } from '../market/MarketRegime.js';
|
||||
import { SCORE_MODE } from '../config/constants.js';
|
||||
|
||||
export const RuleMerger = {
|
||||
getRulesForAsset(type, metrics, marketContext = {}, mode = SCORE_MODE.FUNDAMENTAL) {
|
||||
const base = ScoringRules[type];
|
||||
if (!base) throw new Error(`No rules configured for asset type: ${type}`);
|
||||
|
||||
let rules = JSON.parse(JSON.stringify(base));
|
||||
|
||||
if (type === 'STOCK' && metrics.sector) {
|
||||
const override = base.SECTOR_OVERRIDE?.[metrics.sector.toUpperCase()];
|
||||
if (override) {
|
||||
rules.gates = { ...rules.gates, ...override.gates };
|
||||
rules.weights = { ...rules.weights, ...override.weights };
|
||||
rules.thresholds = { ...rules.thresholds, ...override.thresholds };
|
||||
}
|
||||
}
|
||||
delete rules.SECTOR_OVERRIDE;
|
||||
|
||||
if (mode === SCORE_MODE.INFLATED) {
|
||||
const { gates, thresholds } = new MarketRegime(marketContext).getInflatedOverrides(
|
||||
type,
|
||||
metrics.sector,
|
||||
);
|
||||
rules.gates = { ...rules.gates, ...gates };
|
||||
rules.thresholds = { ...rules.thresholds, ...thresholds };
|
||||
}
|
||||
|
||||
return rules;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export class Asset {
|
||||
constructor(data) {
|
||||
this.ticker = (data.ticker || 'UNKNOWN').toUpperCase();
|
||||
this.currentPrice = data.currentPrice || 0;
|
||||
this.type = (data.type || 'STOCK').toUpperCase();
|
||||
}
|
||||
|
||||
formatCurrency(val) {
|
||||
return val ? `$${val.toFixed(2)}` : 'N/A';
|
||||
}
|
||||
|
||||
formatLargeNumber(num) {
|
||||
if (!num) return 'N/A';
|
||||
if (num >= 1e12) return `${(num / 1e12).toFixed(2)}T`;
|
||||
if (num >= 1e9) return `${(num / 1e9).toFixed(2)}B`;
|
||||
if (num >= 1e6) return `${(num / 1e6).toFixed(2)}M`;
|
||||
return num.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { CREDIT_RATING_SCALE } from '../../config/ScoringConfig.js';
|
||||
import { Asset } from './Asset.js';
|
||||
|
||||
export class Bond extends Asset {
|
||||
constructor(data) {
|
||||
super(data);
|
||||
|
||||
const creditRating = data.creditRating || 'BBB';
|
||||
const creditRatingNumeric = CREDIT_RATING_SCALE[creditRating] ?? 7;
|
||||
|
||||
this.metrics = {
|
||||
ytm: parseFloat(data.yieldToMaturity) || 0,
|
||||
duration: parseFloat(data.duration) || 0,
|
||||
creditRating,
|
||||
creditRatingNumeric,
|
||||
};
|
||||
}
|
||||
|
||||
getDisplayMetrics() {
|
||||
return {
|
||||
Ticker: this.ticker,
|
||||
Type: 'BOND',
|
||||
Price: this.formatCurrency(this.currentPrice),
|
||||
'YTM%': `${this.metrics.ytm.toFixed(2)}%`,
|
||||
Duration: this.metrics.duration.toFixed(1),
|
||||
Rating: `${this.metrics.creditRating} (${this.metrics.creditRatingNumeric})`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Asset } from './Asset.js';
|
||||
|
||||
export class Etf extends Asset {
|
||||
constructor(data) {
|
||||
super(data);
|
||||
this.metrics = {
|
||||
expenseRatio: parseFloat(data.expenseRatio) || 0,
|
||||
totalAssets: parseFloat(data.totalAssets) || 0,
|
||||
yield: parseFloat(data.yield) || 0,
|
||||
volume: parseFloat(data.volume) || 0,
|
||||
fiveYearReturn: parseFloat(data.fiveYearReturn) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
getDisplayMetrics() {
|
||||
return {
|
||||
Ticker: this.ticker,
|
||||
Type: 'ETF',
|
||||
Price: this.formatCurrency(this.currentPrice),
|
||||
'Exp Ratio%': `${this.metrics.expenseRatio.toFixed(2)}%`,
|
||||
'Yield%': `${this.metrics.yield.toFixed(2)}%`,
|
||||
AUM: this.formatLargeNumber(this.metrics.totalAssets),
|
||||
'5Y Return%': `${this.metrics.fiveYearReturn.toFixed(1)}%`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Asset } from './Asset.js';
|
||||
|
||||
export class Stock extends Asset {
|
||||
constructor(data) {
|
||||
super(data);
|
||||
// console.log('Data:', data);
|
||||
this.sector = this._mapToStandardSector(data || {});
|
||||
|
||||
this.metrics = {
|
||||
sector: this.sector,
|
||||
// Valuation
|
||||
peRatio: data.peRatio ?? null,
|
||||
pegRatio: data.pegRatio ?? null,
|
||||
priceToBook: data.priceToBook ?? null,
|
||||
// Profitability
|
||||
netProfitMargin: data.netProfitMargin ?? null,
|
||||
operatingMargin: data.operatingMargin ?? null,
|
||||
returnOnEquity: data.returnOnEquity ?? null,
|
||||
// Growth
|
||||
revenueGrowth: data.revenueGrowth ?? null,
|
||||
earningsGrowth: data.earningsGrowth ?? null,
|
||||
// Financial health
|
||||
debtToEquity: data.debtToEquity ?? null,
|
||||
quickRatio: data.quickRatio ?? null,
|
||||
// Cash flow
|
||||
fcfYield: data.fcfYield ?? null,
|
||||
pFFO: data.pFFO ?? null,
|
||||
// Income
|
||||
dividendYield: data.dividendYield ?? null,
|
||||
// Risk & momentum
|
||||
beta: data.beta ?? null,
|
||||
week52High: data.week52High ?? null,
|
||||
week52Low: data.week52Low ?? null,
|
||||
currentPrice: data.currentPrice ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
_mapToStandardSector(data) {
|
||||
const profile = data.assetProfile || {};
|
||||
const industry = (profile.industry || '').toLowerCase();
|
||||
const sector = (profile.sector || '').toLowerCase();
|
||||
const combined = `${industry} ${sector}`;
|
||||
|
||||
// Yahoo Finance sector/industry strings mapped to our internal sector constants.
|
||||
// Order matters — more specific matches first.
|
||||
if (
|
||||
combined.includes('technology') ||
|
||||
combined.includes('electronic') ||
|
||||
combined.includes('semiconductor') ||
|
||||
combined.includes('software')
|
||||
)
|
||||
return 'TECHNOLOGY';
|
||||
if (combined.includes('real estate') || combined.includes('reit')) return 'REIT';
|
||||
if (
|
||||
combined.includes('financial') ||
|
||||
combined.includes('bank') ||
|
||||
combined.includes('insurance') ||
|
||||
combined.includes('asset management')
|
||||
)
|
||||
return 'FINANCIAL';
|
||||
if (
|
||||
combined.includes('energy') ||
|
||||
combined.includes('oil') ||
|
||||
combined.includes('gas') ||
|
||||
combined.includes('petroleum')
|
||||
)
|
||||
return 'ENERGY';
|
||||
if (
|
||||
combined.includes('health') ||
|
||||
combined.includes('biotech') ||
|
||||
combined.includes('pharmaceutical') ||
|
||||
combined.includes('medical')
|
||||
)
|
||||
return 'HEALTHCARE';
|
||||
// Yahoo calls this "Communication Services" — covers META, GOOGL, NFLX, DIS, T
|
||||
if (
|
||||
combined.includes('communication') ||
|
||||
combined.includes('media') ||
|
||||
combined.includes('entertainment') ||
|
||||
combined.includes('telecom')
|
||||
)
|
||||
return 'COMMUNICATION';
|
||||
if (
|
||||
combined.includes('consumer defensive') ||
|
||||
combined.includes('consumer staples') ||
|
||||
combined.includes('household') ||
|
||||
combined.includes('beverage') ||
|
||||
combined.includes('food')
|
||||
)
|
||||
return 'CONSUMER_STAPLES';
|
||||
if (
|
||||
combined.includes('consumer cyclical') ||
|
||||
combined.includes('consumer discretionary') ||
|
||||
combined.includes('retail') ||
|
||||
combined.includes('apparel') ||
|
||||
combined.includes('auto')
|
||||
)
|
||||
return 'CONSUMER_DISCRETIONARY';
|
||||
|
||||
return 'GENERAL';
|
||||
}
|
||||
|
||||
getDisplayMetrics() {
|
||||
const fmt = (v, dec = 1, suffix = '') => (v != null ? `${v.toFixed(dec)}${suffix}` : null);
|
||||
const m = this.metrics;
|
||||
const w52pos =
|
||||
m.week52High > 0 && m.week52Low != null && m.currentPrice > 0
|
||||
? (((m.currentPrice - m.week52Low) / (m.week52High - m.week52Low)) * 100).toFixed(0) + '%'
|
||||
: null;
|
||||
|
||||
// Only include fields that have actual data — null fields are omitted
|
||||
const display = {
|
||||
Ticker: this.ticker,
|
||||
Price: this.formatCurrency(this.currentPrice),
|
||||
Sector: this.sector,
|
||||
};
|
||||
if (m.peRatio != null) display['P/E'] = fmt(m.peRatio, 1);
|
||||
if (m.pegRatio != null) display['PEG'] = fmt(m.pegRatio, 2);
|
||||
if (m.priceToBook != null) display['P/B'] = fmt(m.priceToBook, 2);
|
||||
if (m.returnOnEquity != null) display['ROE%'] = fmt(m.returnOnEquity, 1, '%');
|
||||
if (m.operatingMargin != null) display['OpMgn%'] = fmt(m.operatingMargin, 1, '%');
|
||||
if (m.netProfitMargin != null) display['NetMgn%'] = fmt(m.netProfitMargin, 1, '%');
|
||||
if (m.revenueGrowth != null) display['Rev%'] = fmt(m.revenueGrowth, 1, '%');
|
||||
if (m.fcfYield != null) display['FCF Yld%'] = fmt(m.fcfYield, 1, '%');
|
||||
if (m.dividendYield != null) display['Div%'] = fmt(m.dividendYield, 2, '%');
|
||||
if (m.debtToEquity != null) display['D/E'] = fmt(m.debtToEquity, 2);
|
||||
if (m.quickRatio != null) display['Quick'] = fmt(m.quickRatio, 2);
|
||||
if (m.beta != null) display['Beta'] = fmt(m.beta, 2);
|
||||
if (w52pos != null) display['52W Pos'] = w52pos;
|
||||
if (m.pFFO != null) display['P/FFO'] = fmt(m.pFFO, 1);
|
||||
|
||||
return display;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export const BondScorer = {
|
||||
score(m, rules, context) {
|
||||
const { gates, weights, thresholds } = rules;
|
||||
const metrics = this._sanitize(m);
|
||||
const riskFreeRate = (context?.riskFreeRate ?? 4.0) / 100;
|
||||
|
||||
if (metrics.creditRatingNumeric < gates.minCreditRating) {
|
||||
return {
|
||||
label: '🔴 Avoid',
|
||||
scoreSummary: `Gate failed: ${metrics.creditRating} (${metrics.creditRatingNumeric}) < ${gates.minCreditRating}`,
|
||||
audit: { passedGates: false },
|
||||
};
|
||||
}
|
||||
|
||||
// Convert spread to percentage to match minSpread threshold (e.g. 1.0 = 1%)
|
||||
const spreadPct = (metrics.ytm - riskFreeRate) * 100;
|
||||
|
||||
const breakdown = {
|
||||
spread: spreadPct >= thresholds.minSpread ? weights.yieldSpread : -2,
|
||||
duration: metrics.duration <= thresholds.maxDuration ? weights.duration : -1,
|
||||
};
|
||||
const score = Object.values(breakdown).reduce((a, b) => a + b, 0);
|
||||
|
||||
return {
|
||||
label: score >= 4 ? '🟢 Attractive' : score >= 1 ? '🟡 Neutral' : '🔴 Avoid',
|
||||
scoreSummary: `Score: ${score}`,
|
||||
audit: { breakdown },
|
||||
};
|
||||
},
|
||||
|
||||
_sanitize(m) {
|
||||
const pct = (v) => parseFloat(typeof v === 'string' ? v.replace('%', '') : v) / 100 || 0;
|
||||
return {
|
||||
ytm: pct(m.ytm),
|
||||
duration: parseFloat(m.duration) || 0,
|
||||
creditRating: m.creditRating || 'BBB',
|
||||
creditRatingNumeric: m.creditRatingNumeric ?? 7,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
export const EtfScorer = {
|
||||
score(m, rules) {
|
||||
const { gates, weights, thresholds } = rules;
|
||||
const metrics = {
|
||||
expenseRatio: parseFloat(m.expenseRatio) || 0,
|
||||
yield: parseFloat(m.yield) || 0,
|
||||
volume: parseFloat(m.volume) || 0,
|
||||
fiveYearReturn: parseFloat(m.fiveYearReturn) || 0,
|
||||
};
|
||||
|
||||
if (metrics.expenseRatio > gates.maxExpenseRatio) {
|
||||
return { label: '🔴 REJECT', scoreSummary: 'Gate failed: High Expense Ratio' };
|
||||
}
|
||||
|
||||
const breakdown = {
|
||||
cost: metrics.expenseRatio <= thresholds.maxExpense ? weights.lowCost : -3,
|
||||
yield: metrics.yield >= thresholds.minYield ? weights.yield : -1,
|
||||
vol: metrics.volume >= (thresholds.minVolume ?? 1000000) ? 0 : -2,
|
||||
// 5Y return: strong long-term performance vs the ~10% S&P average is rewarded
|
||||
fiveYearReturn:
|
||||
thresholds.minFiveYearReturn != null
|
||||
? metrics.fiveYearReturn >= thresholds.minFiveYearReturn
|
||||
? (weights.fiveYearReturn ?? 1)
|
||||
: -1
|
||||
: 0,
|
||||
};
|
||||
|
||||
const score = Object.values(breakdown).reduce((a, b) => a + b, 0);
|
||||
|
||||
return {
|
||||
label: score >= 3 ? '🟢 Efficient' : score >= 0 ? '🟡 Neutral' : '🔴 Expensive/Low Yield',
|
||||
scoreSummary: `Score: ${score}`,
|
||||
audit: { passedGates: true, breakdown },
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
import { SIGNAL } from '../../config/constants.js';
|
||||
|
||||
const n = (v) => {
|
||||
const f = parseFloat(v);
|
||||
return !isNaN(f) && f !== 0 ? f : null;
|
||||
};
|
||||
|
||||
const scoreValue = (val, high, med, weight) => (val >= high ? weight : val >= med ? 1 : -1);
|
||||
const scorePeg = (val, high, med, weight) => (val <= high ? weight : val <= med ? 1 : -1);
|
||||
|
||||
export const StockScorer = {
|
||||
score(metrics, rules) {
|
||||
const { gates, weights, thresholds } = rules;
|
||||
const m = this._sanitize(metrics);
|
||||
|
||||
const failures = [
|
||||
m.debtToEquity != null &&
|
||||
m.debtToEquity > gates.maxDebtToEquity &&
|
||||
`D/E ${m.debtToEquity.toFixed(1)} > ${gates.maxDebtToEquity}`,
|
||||
m.quickRatio != null &&
|
||||
m.quickRatio < gates.minQuickRatio &&
|
||||
`Quick ${m.quickRatio.toFixed(2)} < ${gates.minQuickRatio}`,
|
||||
m.peRatio != null &&
|
||||
m.peRatio > gates.maxPERatio &&
|
||||
`P/E ${m.peRatio.toFixed(0)} > ${gates.maxPERatio}`,
|
||||
m.pegRatio != null &&
|
||||
m.pegRatio > gates.maxPegGate &&
|
||||
`PEG ${m.pegRatio.toFixed(1)} > ${gates.maxPegGate}`,
|
||||
m.priceToBook != null &&
|
||||
gates.maxPriceToBook &&
|
||||
m.priceToBook > gates.maxPriceToBook &&
|
||||
`P/B ${m.priceToBook.toFixed(1)} > ${gates.maxPriceToBook}`,
|
||||
].filter(Boolean);
|
||||
|
||||
if (failures.length > 0) {
|
||||
return {
|
||||
label: '🔴 REJECT',
|
||||
scoreSummary: `Gate failed: ${failures.join(' | ')}`,
|
||||
audit: { passedGates: false, failures },
|
||||
};
|
||||
}
|
||||
|
||||
const factors = [
|
||||
{
|
||||
key: 'roe',
|
||||
active: weights.roe > 0 && m.returnOnEquity != null,
|
||||
fn: () => scoreValue(m.returnOnEquity, thresholds.roeHigh, thresholds.roeMed, weights.roe),
|
||||
},
|
||||
{
|
||||
key: 'opMargin',
|
||||
active: weights.opMargin > 0 && m.operatingMargin != null,
|
||||
fn: () =>
|
||||
scoreValue(
|
||||
m.operatingMargin,
|
||||
thresholds.opMarginHigh,
|
||||
thresholds.opMarginMed,
|
||||
weights.opMargin,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'margin',
|
||||
active: weights.margin > 0 && m.netProfitMargin != null,
|
||||
fn: () =>
|
||||
scoreValue(
|
||||
m.netProfitMargin,
|
||||
thresholds.marginHigh,
|
||||
thresholds.marginMed,
|
||||
weights.margin,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'peg',
|
||||
active: weights.peg > 0 && m.pegRatio != null,
|
||||
fn: () => scorePeg(m.pegRatio, thresholds.pegHigh, thresholds.pegMed, weights.peg),
|
||||
},
|
||||
{
|
||||
key: 'revenue',
|
||||
active: weights.revenue > 0 && m.revenueGrowth != null,
|
||||
fn: () =>
|
||||
scoreValue(m.revenueGrowth, thresholds.revHigh, thresholds.revMed, weights.revenue),
|
||||
},
|
||||
{
|
||||
key: 'fcf',
|
||||
active: weights.fcf > 0 && m.fcfYield != null,
|
||||
fn: () =>
|
||||
scoreValue(m.fcfYield, thresholds.fcfHigh ?? 5, thresholds.fcfMed ?? 2, weights.fcf),
|
||||
},
|
||||
{
|
||||
key: 'yield',
|
||||
active: (weights.yield ?? 0) > 0 && m.dividendYield != null,
|
||||
fn: () => (m.dividendYield >= (thresholds.minYield ?? 4) ? weights.yield : -1),
|
||||
},
|
||||
{
|
||||
key: 'pFFO',
|
||||
active: (weights.pFFO ?? 0) > 0 && m.pFFO != null,
|
||||
fn: () => (m.pFFO <= (thresholds.maxPFFO ?? 15) ? weights.pFFO : -2),
|
||||
},
|
||||
{
|
||||
key: 'priceToBook',
|
||||
active: (weights.priceToBook ?? 0) > 0 && m.priceToBook != null,
|
||||
fn: () => scoreValue(1 / m.priceToBook, 1 / 1.0, 1 / 2.0, weights.priceToBook),
|
||||
},
|
||||
];
|
||||
|
||||
const breakdown = {};
|
||||
const totalScore = factors.reduce((sum, f) => {
|
||||
if (!f.active) return sum;
|
||||
breakdown[f.key] = f.fn();
|
||||
return sum + breakdown[f.key];
|
||||
}, 0);
|
||||
|
||||
const riskFlags = [
|
||||
m.beta != null && m.beta > 1.5 && `High volatility (β ${m.beta.toFixed(2)})`,
|
||||
m.beta != null && m.beta < 0 && `Inverse market correlation (β ${m.beta.toFixed(2)})`,
|
||||
m.week52Position != null && m.week52Position > 0.9 && 'Near 52-week high — crowded trade',
|
||||
m.week52Position != null &&
|
||||
m.week52Position < 0.1 &&
|
||||
'Near 52-week low — potential opportunity',
|
||||
].filter(Boolean);
|
||||
|
||||
return {
|
||||
label: this._label(totalScore),
|
||||
scoreSummary: `Score: ${totalScore}`,
|
||||
audit: { passedGates: true, breakdown, riskFlags: riskFlags.length ? riskFlags : null },
|
||||
};
|
||||
},
|
||||
|
||||
_label(score) {
|
||||
if (score >= 8) return '🟢 BUY (High Conviction)';
|
||||
if (score >= 4) return '🟢 BUY (Speculative)';
|
||||
if (score >= 0) return '🟡 HOLD';
|
||||
return '🔴 REJECT';
|
||||
},
|
||||
|
||||
_sanitize(m) {
|
||||
const w52 =
|
||||
m.week52High > 0 && m.week52Low != null && m.currentPrice > 0
|
||||
? (m.currentPrice - m.week52Low) / (m.week52High - m.week52Low)
|
||||
: null;
|
||||
return {
|
||||
debtToEquity: n(m.debtToEquity),
|
||||
quickRatio: n(m.quickRatio),
|
||||
peRatio: n(m.peRatio),
|
||||
pegRatio: n(m.pegRatio),
|
||||
priceToBook: n(m.priceToBook),
|
||||
netProfitMargin: n(m.netProfitMargin),
|
||||
operatingMargin: n(m.operatingMargin),
|
||||
returnOnEquity: n(m.returnOnEquity),
|
||||
revenueGrowth: n(m.revenueGrowth),
|
||||
fcfYield: n(m.fcfYield),
|
||||
dividendYield: n(m.dividendYield),
|
||||
pFFO: n(m.pFFO),
|
||||
beta: n(m.beta),
|
||||
week52Position: w52,
|
||||
};
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user