phase-2: extract shared utils
This commit is contained in:
committed by
saikiranvella
parent
5a4b4aa6d1
commit
d5cf3fc31f
@@ -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