phase-2: extract shared utils

This commit is contained in:
Kazuma
2026-06-04 11:06:30 -04:00
parent b75e8bda72
commit 0a0a368b87
49 changed files with 299 additions and 120 deletions
+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,
};
},
};