phase-7: code restructure
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import type { BondMetrics, MarketContext, ScoreResult, SanitizedBondMetrics } from '../types';
|
||||
|
||||
export class BondScorer {
|
||||
static score(
|
||||
m: BondMetrics,
|
||||
rules: {
|
||||
gates: Record<string, number>;
|
||||
weights: Record<string, number>;
|
||||
thresholds: Record<string, number>;
|
||||
},
|
||||
context?: MarketContext | null,
|
||||
): ScoreResult {
|
||||
const { gates, weights, thresholds } = rules;
|
||||
const metrics = BondScorer._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 },
|
||||
};
|
||||
}
|
||||
|
||||
const spreadPct = (metrics.ytm - riskFreeRate) * 100;
|
||||
|
||||
const breakdown: Record<string, number> = {
|
||||
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: { passedGates: true, breakdown },
|
||||
};
|
||||
}
|
||||
|
||||
private static _sanitize(m: BondMetrics): SanitizedBondMetrics {
|
||||
const pct = (v: unknown): number =>
|
||||
parseFloat(typeof v === 'string' ? v.replace('%', '') : String(v)) / 100 || 0;
|
||||
return {
|
||||
ytm: pct(m.ytm),
|
||||
duration: parseFloat(String(m.duration)) || 0,
|
||||
creditRating: m.creditRating || 'BBB',
|
||||
creditRatingNumeric: m.creditRatingNumeric ?? 7,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { EtfMetrics, ScoreResult } from '../types';
|
||||
|
||||
export class EtfScorer {
|
||||
static score(
|
||||
m: EtfMetrics,
|
||||
rules: {
|
||||
gates: Record<string, number>;
|
||||
weights: Record<string, number>;
|
||||
thresholds: Record<string, number>;
|
||||
},
|
||||
): ScoreResult {
|
||||
const { gates, weights, thresholds } = rules;
|
||||
const metrics = {
|
||||
expenseRatio: parseFloat(String(m.expenseRatio)) || 0,
|
||||
yield: parseFloat(String(m.yield)) || 0,
|
||||
volume: parseFloat(String(m.volume)) || 0,
|
||||
fiveYearReturn: parseFloat(String(m.fiveYearReturn)) || 0,
|
||||
};
|
||||
|
||||
if (metrics.expenseRatio > gates.maxExpenseRatio) {
|
||||
return {
|
||||
label: '🔴 REJECT',
|
||||
scoreSummary: 'Gate failed: High Expense Ratio',
|
||||
audit: { passedGates: false },
|
||||
};
|
||||
}
|
||||
|
||||
const breakdown: Record<string, number> = {
|
||||
cost: metrics.expenseRatio <= thresholds.maxExpense ? weights.lowCost : -3,
|
||||
yield: metrics.yield >= thresholds.minYield ? weights.yield : -1,
|
||||
vol: metrics.volume >= (thresholds.minVolume ?? 1_000_000) ? 0 : -2,
|
||||
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,251 @@
|
||||
import type { NumVal, SanitizedMetrics, ScoreResult, StockMetrics } from '../types';
|
||||
|
||||
export class StockScorer {
|
||||
private static n(v: unknown): NumVal {
|
||||
const f = parseFloat(String(v));
|
||||
return !isNaN(f) && f !== 0 ? f : null;
|
||||
}
|
||||
|
||||
private static scoreValue(val: number, high: number, med: number, weight: number): number {
|
||||
return val >= high ? weight : val >= med ? 1 : -1;
|
||||
}
|
||||
|
||||
private static scorePeg(val: number, high: number, med: number, weight: number): number {
|
||||
return val <= high ? weight : val <= med ? 1 : -1;
|
||||
}
|
||||
static score(
|
||||
metrics: StockMetrics,
|
||||
rules: {
|
||||
gates: Record<string, number>;
|
||||
weights: Record<string, number>;
|
||||
thresholds: Record<string, number>;
|
||||
},
|
||||
): ScoreResult {
|
||||
const { gates, weights, thresholds } = rules;
|
||||
const m = StockScorer._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) as string[];
|
||||
|
||||
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: () =>
|
||||
StockScorer.scoreValue(
|
||||
m.returnOnEquity!,
|
||||
thresholds.roeHigh,
|
||||
thresholds.roeMed,
|
||||
weights.roe,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'opMargin',
|
||||
active: weights.opMargin > 0 && m.operatingMargin != null,
|
||||
fn: () =>
|
||||
StockScorer.scoreValue(
|
||||
m.operatingMargin!,
|
||||
thresholds.opMarginHigh,
|
||||
thresholds.opMarginMed,
|
||||
weights.opMargin,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'margin',
|
||||
active: weights.margin > 0 && m.netProfitMargin != null,
|
||||
fn: () =>
|
||||
StockScorer.scoreValue(
|
||||
m.netProfitMargin!,
|
||||
thresholds.marginHigh,
|
||||
thresholds.marginMed,
|
||||
weights.margin,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'peg',
|
||||
active: weights.peg > 0 && m.pegRatio != null,
|
||||
fn: () =>
|
||||
StockScorer.scorePeg(m.pegRatio!, thresholds.pegHigh, thresholds.pegMed, weights.peg),
|
||||
},
|
||||
{
|
||||
key: 'revenue',
|
||||
active: weights.revenue > 0 && m.revenueGrowth != null,
|
||||
fn: () =>
|
||||
StockScorer.scoreValue(
|
||||
m.revenueGrowth!,
|
||||
thresholds.revHigh,
|
||||
thresholds.revMed,
|
||||
weights.revenue,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'fcf',
|
||||
active: weights.fcf > 0 && m.fcfYield != null,
|
||||
fn: () =>
|
||||
StockScorer.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: () => StockScorer.scoreValue(1 / m.priceToBook!, 1 / 1.0, 1 / 2.0, weights.priceToBook),
|
||||
},
|
||||
// ── Expert features ────────────────────────────────────────────────
|
||||
{
|
||||
// Analyst consensus: Yahoo recommendationMean 1=Strong Buy → 5=Strong Sell.
|
||||
// We invert and score: ≤ analystBuy gets full weight, ≤ analystHold gets 1pt,
|
||||
// above Hold loses weight. Requires ≥ 3 analysts to avoid noise from thin coverage.
|
||||
key: 'analyst',
|
||||
active:
|
||||
(weights.analyst ?? 0) > 0 &&
|
||||
m.analystRating != null &&
|
||||
(metrics.numberOfAnalysts ?? 0) >= 3,
|
||||
fn: (): number => {
|
||||
const r = m.analystRating!;
|
||||
const buyThreshold = thresholds.analystBuy ?? 2.0;
|
||||
const holdThreshold = thresholds.analystHold ?? 3.0;
|
||||
if (r <= buyThreshold) return weights.analyst ?? 2;
|
||||
if (r <= holdThreshold) return 1;
|
||||
if (r <= 4.0) return -1;
|
||||
return -(weights.analyst ?? 2); // Strong Sell
|
||||
},
|
||||
},
|
||||
{
|
||||
// DCF margin of safety: how undervalued the stock is vs. 2-stage FCF model.
|
||||
// Positive = undervalued (good), negative = overvalued (bad).
|
||||
// Only fires when DCF could be computed (positive FCF required).
|
||||
key: 'dcf',
|
||||
active: (weights.dcf ?? 0) > 0 && m.dcfMarginOfSafety != null,
|
||||
fn: (): number => {
|
||||
const mos = m.dcfMarginOfSafety!;
|
||||
const undervalued = thresholds.dcfUndervalued ?? 20;
|
||||
const fairValue = thresholds.dcfFairValue ?? 0;
|
||||
if (mos >= undervalued) return weights.dcf ?? 2;
|
||||
if (mos >= fairValue) return 1;
|
||||
if (mos >= -20) return -1;
|
||||
return -(weights.dcf ?? 2); // significantly overvalued
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const breakdown: Record<string, number> = {};
|
||||
const totalScore = factors.reduce((sum, f) => {
|
||||
if (!f.active) return sum;
|
||||
breakdown[f.key] = f.fn() as number;
|
||||
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)})`,
|
||||
// 52-week position flags
|
||||
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',
|
||||
// 52-week momentum flags
|
||||
m.week52Change != null &&
|
||||
m.week52Change >= 50 &&
|
||||
`Strong uptrend: +${m.week52Change.toFixed(0)}% in 52 weeks`,
|
||||
m.week52Change != null &&
|
||||
m.week52Change <= -30 &&
|
||||
`Significant drawdown: ${m.week52Change.toFixed(0)}% in 52 weeks`,
|
||||
// Distance from 52-week high
|
||||
m.week52FromHigh != null &&
|
||||
m.week52FromHigh <= -20 &&
|
||||
`${Math.abs(m.week52FromHigh).toFixed(0)}% off 52-week high`,
|
||||
// Analyst/DCF divergence signal
|
||||
m.analystUpside != null &&
|
||||
m.analystUpside >= 25 &&
|
||||
`Analyst consensus: ${m.analystUpside.toFixed(0)}% upside to target`,
|
||||
m.analystUpside != null &&
|
||||
m.analystUpside <= -15 &&
|
||||
`Analyst consensus: target ${Math.abs(m.analystUpside).toFixed(0)}% below current price`,
|
||||
m.dcfMarginOfSafety != null &&
|
||||
m.dcfMarginOfSafety >= 30 &&
|
||||
`DCF: ${m.dcfMarginOfSafety.toFixed(0)}% margin of safety`,
|
||||
m.dcfMarginOfSafety != null &&
|
||||
m.dcfMarginOfSafety <= -30 &&
|
||||
`DCF: stock trading ${Math.abs(m.dcfMarginOfSafety).toFixed(0)}% above intrinsic value`,
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
return {
|
||||
label: StockScorer._label(totalScore),
|
||||
scoreSummary: `Score: ${totalScore}`,
|
||||
audit: { passedGates: true, breakdown, riskFlags: riskFlags.length ? riskFlags : null },
|
||||
};
|
||||
}
|
||||
|
||||
private static _label(score: number): string {
|
||||
if (score >= 8) return '🟢 BUY (High Conviction)';
|
||||
if (score >= 4) return '🟢 BUY (Speculative)';
|
||||
if (score >= 0) return '🟡 HOLD';
|
||||
return '🔴 REJECT';
|
||||
}
|
||||
|
||||
private static _sanitize(m: StockMetrics): SanitizedMetrics {
|
||||
const w52 =
|
||||
m.week52High != null && m.week52High > 0 && m.week52Low != null && m.currentPrice > 0
|
||||
? (m.currentPrice - m.week52Low) / (m.week52High - m.week52Low)
|
||||
: null;
|
||||
return {
|
||||
debtToEquity: StockScorer.n(m.debtToEquity),
|
||||
quickRatio: StockScorer.n(m.quickRatio),
|
||||
peRatio: StockScorer.n(m.peRatio),
|
||||
pegRatio: StockScorer.n(m.pegRatio),
|
||||
priceToBook: StockScorer.n(m.priceToBook),
|
||||
netProfitMargin: StockScorer.n(m.netProfitMargin),
|
||||
operatingMargin: StockScorer.n(m.operatingMargin),
|
||||
returnOnEquity: StockScorer.n(m.returnOnEquity),
|
||||
revenueGrowth: StockScorer.n(m.revenueGrowth),
|
||||
fcfYield: StockScorer.n(m.fcfYield),
|
||||
dividendYield: StockScorer.n(m.dividendYield),
|
||||
pFFO: StockScorer.n(m.pFFO),
|
||||
beta: StockScorer.n(m.beta),
|
||||
week52Position: w52,
|
||||
week52Change: StockScorer.n(m.week52Change),
|
||||
week52FromHigh: StockScorer.n(m.week52FromHigh),
|
||||
analystRating: StockScorer.n(m.analystRating),
|
||||
analystUpside: StockScorer.n(m.analystUpside),
|
||||
dcfMarginOfSafety: StockScorer.n(m.dcfMarginOfSafety),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user