phase-7: code restructure

This commit is contained in:
Kazuma
2026-06-05 22:05:55 -04:00
parent 69d13c3dbe
commit 73db0fe7a8
108 changed files with 8931 additions and 3434 deletions
+50
View File
@@ -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,
};
}
}
+48
View File
@@ -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 },
};
}
}
+251
View File
@@ -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),
};
}
}