phase-2: extract shared utils

This commit is contained in:
Kazuma
2026-06-04 11:06:30 -04:00
committed by Kazuma
parent 96e48aebe5
commit f5a338fc4e
49 changed files with 299 additions and 120 deletions
+4
View File
@@ -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),
);
+153
View File
@@ -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,
});
+33
View File
@@ -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;
},
};
+141
View File
@@ -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;
}
}
+19
View File
@@ -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();
}
}
+29
View File
@@ -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})`,
};
}
}
+26
View File
@@ -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)}%`,
};
}
}
+134
View File
@@ -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;
}
}
+40
View File
@@ -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,
};
},
};
+36
View File
@@ -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 },
};
},
};
+157
View File
@@ -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,
};
},
};