code enhancements for hybrid analysis and clean-up dashboards.
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
export const ScoringRules = {
|
||||
STOCK: {
|
||||
gates: { maxDebtToEquity: 3.0, minQuickRatio: 0.25, maxPERatio: 80 },
|
||||
meta: { version: '1.2', description: 'Value & Growth Hybrid' },
|
||||
gates: {
|
||||
maxDebtToEquity: 3.0,
|
||||
minQuickRatio: 0.25,
|
||||
maxPERatio: 80,
|
||||
maxPegGate: 5.0,
|
||||
},
|
||||
weights: { margin: 3, peg: 2, revenue: 2, fcf: 1 },
|
||||
thresholds: {
|
||||
marginHigh: 20,
|
||||
@@ -12,12 +18,14 @@ export const ScoringRules = {
|
||||
},
|
||||
},
|
||||
ETF: {
|
||||
gates: { maxExpenseRatio: 0.75 }, // Reject if too expensive
|
||||
meta: { version: '1.0', description: 'Cost & Yield Efficiency' },
|
||||
gates: { maxExpenseRatio: 0.75 },
|
||||
weights: { yield: 2, lowCost: 3 },
|
||||
thresholds: { minYield: 0.02, maxExpense: 0.2 },
|
||||
},
|
||||
BOND: {
|
||||
gates: { minCreditRating: 5 }, // e.g., 5 = Investment Grade
|
||||
meta: { version: '1.0', description: 'Credit Quality & Duration' },
|
||||
gates: { minCreditRating: 5 },
|
||||
weights: { yieldSpread: 3, duration: 2 },
|
||||
thresholds: { minSpread: 1.5, maxDuration: 10 },
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { StockScorer } from '../scorers/StockScorer.js';
|
||||
import { EtfScorer } from '../scorers/EtfScorer.js';
|
||||
import { BondScorer } from '../scorers/BondScorer.js';
|
||||
import { ScoringRules } from '../../config/ScoringConfig.js';
|
||||
|
||||
export const ScoringEngine = {
|
||||
// Registry of available strategies
|
||||
@@ -15,14 +16,15 @@ export const ScoringEngine = {
|
||||
* @param {Object} data - The raw metric data
|
||||
* @param {Object} context - Optional market context (for bonds)
|
||||
*/
|
||||
// In ScoringEngine.js
|
||||
evaluate(type, data, context = {}) {
|
||||
const scorer = this._scorers[type];
|
||||
const rules = ScoringRules[type]; // This returns ScoringRules.STOCK
|
||||
|
||||
if (!scorer) {
|
||||
throw new Error(`No scorer found for asset type: ${type}`);
|
||||
}
|
||||
if (!scorer) throw new Error(`No scorer found: ${type}`);
|
||||
if (!rules) throw new Error(`No configuration rules found: ${type}`);
|
||||
|
||||
// Every scorer now shares the exact same signature: .score()
|
||||
return scorer.score(data, context);
|
||||
// IMPORTANT: Ensure you pass the specific rules set
|
||||
return scorer.score(data, rules, context);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -87,11 +87,42 @@ export class ScreenerEngine {
|
||||
}
|
||||
|
||||
_display(results) {
|
||||
console.log('\n--- EQUITY MATRIX ---\n');
|
||||
console.table(results.STOCK);
|
||||
console.log('\n--- ETF MATRIX ---\n');
|
||||
console.table(results.ETF);
|
||||
console.log('\n--- BOND MATRIX ---\n');
|
||||
console.table(results.BOND);
|
||||
const formatters = {
|
||||
STOCK: (data) =>
|
||||
data.map((item) => ({
|
||||
Ticker: item.Ticker,
|
||||
Verdict: item.Verdict,
|
||||
Score: item['G/O/R'],
|
||||
PE: item['PE Ratio'],
|
||||
PEG: item['PEG/Fee'],
|
||||
Rev: item['Rev%'],
|
||||
Marg: item['Marg%'],
|
||||
})),
|
||||
|
||||
ETF: (data) =>
|
||||
data.map((item) => ({
|
||||
Ticker: item.Ticker,
|
||||
Verdict: item.Verdict,
|
||||
Yield: item['Yield%'],
|
||||
Exp: item['Exp Ratio%'],
|
||||
AUM: item.AUM,
|
||||
})),
|
||||
|
||||
BOND: (data) =>
|
||||
data.map((item) => ({
|
||||
Ticker: item.Ticker,
|
||||
Verdict: item.Verdict,
|
||||
YTM: item['YTM%'],
|
||||
Dur: item.Duration,
|
||||
Rating: item.Rating,
|
||||
})),
|
||||
};
|
||||
|
||||
Object.keys(formatters).forEach((type) => {
|
||||
if (results[type]) {
|
||||
console.log(`\n--- ${type} MATRIX ---`);
|
||||
console.table(formatters[type](results[type]));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ export const BondScorer = {
|
||||
* @param {Object} m - Metrics (ytm, duration, creditRating)
|
||||
* @param {Object} context - Market environment (riskFreeRate)
|
||||
*/
|
||||
score(m, context) {
|
||||
const { gates, weights, thresholds } = ScoringRules.BOND;
|
||||
score(m, rules, context) {
|
||||
const { gates, weights, thresholds } = rules;
|
||||
const metrics = this._sanitize(m);
|
||||
|
||||
// Safety check for riskFreeRate (ensure it's a decimal, e.g., 0.04)
|
||||
|
||||
@@ -4,8 +4,8 @@ import { ScoringRules } from '../../config/ScoringConfig.js';
|
||||
* EtfScorer: Evaluates ETFs with mandatory fee gates and weighted scoring.
|
||||
*/
|
||||
export const EtfScorer = {
|
||||
score(m) {
|
||||
const { gates, weights, thresholds } = ScoringRules.ETF;
|
||||
score(m, rules) {
|
||||
const { gates, weights, thresholds } = rules;
|
||||
const metrics = this._sanitize(m);
|
||||
|
||||
// 1. GATE KEEPING: High fees trigger an automatic reject
|
||||
|
||||
@@ -1,37 +1,48 @@
|
||||
import { ScoringRules } from '../../config/ScoringConfig.js';
|
||||
|
||||
/**
|
||||
* StockScorer: Evaluates stock fundamentals using a gatekeeper and weighted scoring registry.
|
||||
*/
|
||||
export const StockScorer = {
|
||||
score(m) {
|
||||
const { gates, weights, thresholds } = ScoringRules.STOCK;
|
||||
score(m, rules) {
|
||||
const { gates, weights, thresholds } = rules;
|
||||
const metrics = this._sanitize(m);
|
||||
|
||||
// 1. GATEKEEPING
|
||||
// 1. DYNAMIC GATE KEEPING
|
||||
const gateResult = this._checkGates(metrics, gates);
|
||||
if (!gateResult.passed) {
|
||||
if (!gateResult.passed)
|
||||
return { label: '🔴 REJECT', scoreSummary: gateResult.reason };
|
||||
}
|
||||
|
||||
// 2. SCORING REGISTRY
|
||||
// Add new metrics here to automatically include them in the score and audit
|
||||
// 2. DYNAMIC SCORING REGISTRY
|
||||
const scoringRegistry = [
|
||||
{
|
||||
key: 'margin',
|
||||
fn: () =>
|
||||
this._scoreMargin(metrics.netProfitMargin, thresholds, weights),
|
||||
this._scoreValue(
|
||||
metrics.netProfitMargin,
|
||||
thresholds.marginHigh,
|
||||
thresholds.marginMed,
|
||||
weights.margin,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'peg',
|
||||
fn: () => this._scorePeg(metrics.pegRatio, thresholds, weights),
|
||||
fn: () =>
|
||||
this._scorePeg(
|
||||
metrics.pegRatio,
|
||||
thresholds.pegHigh,
|
||||
thresholds.pegMed,
|
||||
weights.peg,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'rev',
|
||||
fn: () =>
|
||||
this._scoreRevenue(metrics.revenueGrowth, thresholds, weights),
|
||||
this._scoreValue(
|
||||
metrics.revenueGrowth,
|
||||
thresholds.revHigh,
|
||||
thresholds.revMed,
|
||||
weights.revenue,
|
||||
),
|
||||
},
|
||||
{ key: 'fcf', fn: () => this._scoreFcf(metrics.fcfGrowth) },
|
||||
{ key: 'fcf', fn: () => (metrics.fcfGrowth === 'positive' ? 2 : -2) },
|
||||
];
|
||||
|
||||
const breakdown = {};
|
||||
@@ -40,7 +51,6 @@ export const StockScorer = {
|
||||
return sum + breakdown[item.key];
|
||||
}, 0);
|
||||
|
||||
// 3. FINAL VERDICT
|
||||
return {
|
||||
label: this._getLabel(totalScore),
|
||||
scoreSummary: `Score: ${totalScore}`,
|
||||
@@ -49,37 +59,12 @@ export const StockScorer = {
|
||||
};
|
||||
},
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
_sanitize(m) {
|
||||
// Helper to handle 'N/A' or nulls
|
||||
const parseMetric = (val, defaultValue) => {
|
||||
if (val === 'N/A' || val === null || val === undefined)
|
||||
return defaultValue;
|
||||
const parsed = parseFloat(val);
|
||||
return isNaN(parsed) ? defaultValue : parsed;
|
||||
};
|
||||
|
||||
return {
|
||||
debtToEquity: parseMetric(m.debtToEquity, 0),
|
||||
quickRatio: parseMetric(m.quickRatio, 0),
|
||||
// If P/E is N/A, we shouldn't treat it as 999 (expensive).
|
||||
// We should treat it as 'Neutral' or 'Missing' for the gate.
|
||||
peRatio: parseMetric(m.peRatio, 0),
|
||||
netProfitMargin: parseMetric(m.netProfitMargin, 0),
|
||||
pegRatio: parseMetric(m.pegRatio, 999),
|
||||
revenueGrowth: parseMetric(m.revenueGrowth, 0),
|
||||
fcfGrowth: m.fcfGrowth ?? 'neutral',
|
||||
};
|
||||
},
|
||||
|
||||
_checkGates(m, g) {
|
||||
const failures = [];
|
||||
if (m.debtToEquity > g.maxDebtToEquity) failures.push('Debt/Equity');
|
||||
if (m.quickRatio < g.minQuickRatio) failures.push('QuickRatio');
|
||||
if (m.peRatio > g.maxPERatio) failures.push('PERatio');
|
||||
if (m.pegRatio > 5) failures.push('High Valuation (PEG > 5)');
|
||||
if (m.netProfitMargin < 2) failures.push('Low Profit Margin');
|
||||
if (m.pegRatio > g.maxPegGate) failures.push('High Valuation');
|
||||
|
||||
return {
|
||||
passed: failures.length === 0,
|
||||
@@ -87,14 +72,23 @@ export const StockScorer = {
|
||||
};
|
||||
},
|
||||
|
||||
// Scoring Methods
|
||||
_scoreMargin: (val, t, w) =>
|
||||
val >= t.marginHigh ? w.margin : val >= t.marginMed ? 1 : -2,
|
||||
_scorePeg: (val, t, w) =>
|
||||
val > 0 && val <= t.pegHigh ? w.peg : val <= t.pegMed ? 0 : -2,
|
||||
_scoreRevenue: (val, t, w) =>
|
||||
val >= t.revHigh ? w.revenue : val >= t.revMed ? 0 : -1,
|
||||
_scoreFcf: (val) => (val === 'positive' ? 2 : val === 'negative' ? -2 : 0),
|
||||
_scoreValue: (val, high, med, weight) =>
|
||||
val >= high ? weight : val >= med ? 1 : -2,
|
||||
|
||||
_scorePeg: (val, high, med, weight) =>
|
||||
val > 0 && val <= high ? weight : val <= med ? 0 : -2,
|
||||
|
||||
_sanitize(m) {
|
||||
return {
|
||||
debtToEquity: parseFloat(m.debtToEquity) || 0,
|
||||
quickRatio: parseFloat(m.quickRatio) || 0,
|
||||
peRatio: parseFloat(m.peRatio) || 0,
|
||||
netProfitMargin: parseFloat(m.netProfitMargin) || 0,
|
||||
pegRatio: parseFloat(m.pegRatio) || 999,
|
||||
revenueGrowth: parseFloat(m.revenueGrowth) || 0,
|
||||
fcfGrowth: m.fcfGrowth ?? 'neutral',
|
||||
};
|
||||
},
|
||||
|
||||
_getLabel(score) {
|
||||
if (score >= 5) return '🟢 BUY (High Conviction)';
|
||||
|
||||
Reference in New Issue
Block a user