code enhancements for hybrid analysis and clean-up dashboards.
This commit is contained in:
@@ -1,6 +1,12 @@
|
|||||||
export const ScoringRules = {
|
export const ScoringRules = {
|
||||||
STOCK: {
|
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 },
|
weights: { margin: 3, peg: 2, revenue: 2, fcf: 1 },
|
||||||
thresholds: {
|
thresholds: {
|
||||||
marginHigh: 20,
|
marginHigh: 20,
|
||||||
@@ -12,12 +18,14 @@ export const ScoringRules = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
ETF: {
|
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 },
|
weights: { yield: 2, lowCost: 3 },
|
||||||
thresholds: { minYield: 0.02, maxExpense: 0.2 },
|
thresholds: { minYield: 0.02, maxExpense: 0.2 },
|
||||||
},
|
},
|
||||||
BOND: {
|
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 },
|
weights: { yieldSpread: 3, duration: 2 },
|
||||||
thresholds: { minSpread: 1.5, maxDuration: 10 },
|
thresholds: { minSpread: 1.5, maxDuration: 10 },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { StockScorer } from '../scorers/StockScorer.js';
|
import { StockScorer } from '../scorers/StockScorer.js';
|
||||||
import { EtfScorer } from '../scorers/EtfScorer.js';
|
import { EtfScorer } from '../scorers/EtfScorer.js';
|
||||||
import { BondScorer } from '../scorers/BondScorer.js';
|
import { BondScorer } from '../scorers/BondScorer.js';
|
||||||
|
import { ScoringRules } from '../../config/ScoringConfig.js';
|
||||||
|
|
||||||
export const ScoringEngine = {
|
export const ScoringEngine = {
|
||||||
// Registry of available strategies
|
// Registry of available strategies
|
||||||
@@ -15,14 +16,15 @@ export const ScoringEngine = {
|
|||||||
* @param {Object} data - The raw metric data
|
* @param {Object} data - The raw metric data
|
||||||
* @param {Object} context - Optional market context (for bonds)
|
* @param {Object} context - Optional market context (for bonds)
|
||||||
*/
|
*/
|
||||||
|
// In ScoringEngine.js
|
||||||
evaluate(type, data, context = {}) {
|
evaluate(type, data, context = {}) {
|
||||||
const scorer = this._scorers[type];
|
const scorer = this._scorers[type];
|
||||||
|
const rules = ScoringRules[type]; // This returns ScoringRules.STOCK
|
||||||
|
|
||||||
if (!scorer) {
|
if (!scorer) throw new Error(`No scorer found: ${type}`);
|
||||||
throw new Error(`No scorer found for asset type: ${type}`);
|
if (!rules) throw new Error(`No configuration rules found: ${type}`);
|
||||||
}
|
|
||||||
|
|
||||||
// Every scorer now shares the exact same signature: .score()
|
// IMPORTANT: Ensure you pass the specific rules set
|
||||||
return scorer.score(data, context);
|
return scorer.score(data, rules, context);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -87,11 +87,42 @@ export class ScreenerEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_display(results) {
|
_display(results) {
|
||||||
console.log('\n--- EQUITY MATRIX ---\n');
|
const formatters = {
|
||||||
console.table(results.STOCK);
|
STOCK: (data) =>
|
||||||
console.log('\n--- ETF MATRIX ---\n');
|
data.map((item) => ({
|
||||||
console.table(results.ETF);
|
Ticker: item.Ticker,
|
||||||
console.log('\n--- BOND MATRIX ---\n');
|
Verdict: item.Verdict,
|
||||||
console.table(results.BOND);
|
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} m - Metrics (ytm, duration, creditRating)
|
||||||
* @param {Object} context - Market environment (riskFreeRate)
|
* @param {Object} context - Market environment (riskFreeRate)
|
||||||
*/
|
*/
|
||||||
score(m, context) {
|
score(m, rules, context) {
|
||||||
const { gates, weights, thresholds } = ScoringRules.BOND;
|
const { gates, weights, thresholds } = rules;
|
||||||
const metrics = this._sanitize(m);
|
const metrics = this._sanitize(m);
|
||||||
|
|
||||||
// Safety check for riskFreeRate (ensure it's a decimal, e.g., 0.04)
|
// 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.
|
* EtfScorer: Evaluates ETFs with mandatory fee gates and weighted scoring.
|
||||||
*/
|
*/
|
||||||
export const EtfScorer = {
|
export const EtfScorer = {
|
||||||
score(m) {
|
score(m, rules) {
|
||||||
const { gates, weights, thresholds } = ScoringRules.ETF;
|
const { gates, weights, thresholds } = rules;
|
||||||
const metrics = this._sanitize(m);
|
const metrics = this._sanitize(m);
|
||||||
|
|
||||||
// 1. GATE KEEPING: High fees trigger an automatic reject
|
// 1. GATE KEEPING: High fees trigger an automatic reject
|
||||||
|
|||||||
@@ -1,37 +1,48 @@
|
|||||||
import { ScoringRules } from '../../config/ScoringConfig.js';
|
import { ScoringRules } from '../../config/ScoringConfig.js';
|
||||||
|
|
||||||
/**
|
|
||||||
* StockScorer: Evaluates stock fundamentals using a gatekeeper and weighted scoring registry.
|
|
||||||
*/
|
|
||||||
export const StockScorer = {
|
export const StockScorer = {
|
||||||
score(m) {
|
score(m, rules) {
|
||||||
const { gates, weights, thresholds } = ScoringRules.STOCK;
|
const { gates, weights, thresholds } = rules;
|
||||||
const metrics = this._sanitize(m);
|
const metrics = this._sanitize(m);
|
||||||
|
|
||||||
// 1. GATEKEEPING
|
// 1. DYNAMIC GATE KEEPING
|
||||||
const gateResult = this._checkGates(metrics, gates);
|
const gateResult = this._checkGates(metrics, gates);
|
||||||
if (!gateResult.passed) {
|
if (!gateResult.passed)
|
||||||
return { label: '🔴 REJECT', scoreSummary: gateResult.reason };
|
return { label: '🔴 REJECT', scoreSummary: gateResult.reason };
|
||||||
}
|
|
||||||
|
|
||||||
// 2. SCORING REGISTRY
|
// 2. DYNAMIC SCORING REGISTRY
|
||||||
// Add new metrics here to automatically include them in the score and audit
|
|
||||||
const scoringRegistry = [
|
const scoringRegistry = [
|
||||||
{
|
{
|
||||||
key: 'margin',
|
key: 'margin',
|
||||||
fn: () =>
|
fn: () =>
|
||||||
this._scoreMargin(metrics.netProfitMargin, thresholds, weights),
|
this._scoreValue(
|
||||||
|
metrics.netProfitMargin,
|
||||||
|
thresholds.marginHigh,
|
||||||
|
thresholds.marginMed,
|
||||||
|
weights.margin,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'peg',
|
key: 'peg',
|
||||||
fn: () => this._scorePeg(metrics.pegRatio, thresholds, weights),
|
fn: () =>
|
||||||
|
this._scorePeg(
|
||||||
|
metrics.pegRatio,
|
||||||
|
thresholds.pegHigh,
|
||||||
|
thresholds.pegMed,
|
||||||
|
weights.peg,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'rev',
|
key: 'rev',
|
||||||
fn: () =>
|
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 = {};
|
const breakdown = {};
|
||||||
@@ -40,7 +51,6 @@ export const StockScorer = {
|
|||||||
return sum + breakdown[item.key];
|
return sum + breakdown[item.key];
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
// 3. FINAL VERDICT
|
|
||||||
return {
|
return {
|
||||||
label: this._getLabel(totalScore),
|
label: this._getLabel(totalScore),
|
||||||
scoreSummary: `Score: ${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) {
|
_checkGates(m, g) {
|
||||||
const failures = [];
|
const failures = [];
|
||||||
if (m.debtToEquity > g.maxDebtToEquity) failures.push('Debt/Equity');
|
if (m.debtToEquity > g.maxDebtToEquity) failures.push('Debt/Equity');
|
||||||
if (m.quickRatio < g.minQuickRatio) failures.push('QuickRatio');
|
if (m.quickRatio < g.minQuickRatio) failures.push('QuickRatio');
|
||||||
if (m.peRatio > g.maxPERatio) failures.push('PERatio');
|
if (m.peRatio > g.maxPERatio) failures.push('PERatio');
|
||||||
if (m.pegRatio > 5) failures.push('High Valuation (PEG > 5)');
|
if (m.pegRatio > g.maxPegGate) failures.push('High Valuation');
|
||||||
if (m.netProfitMargin < 2) failures.push('Low Profit Margin');
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
passed: failures.length === 0,
|
passed: failures.length === 0,
|
||||||
@@ -87,14 +72,23 @@ export const StockScorer = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
// Scoring Methods
|
_scoreValue: (val, high, med, weight) =>
|
||||||
_scoreMargin: (val, t, w) =>
|
val >= high ? weight : val >= med ? 1 : -2,
|
||||||
val >= t.marginHigh ? w.margin : val >= t.marginMed ? 1 : -2,
|
|
||||||
_scorePeg: (val, t, w) =>
|
_scorePeg: (val, high, med, weight) =>
|
||||||
val > 0 && val <= t.pegHigh ? w.peg : val <= t.pegMed ? 0 : -2,
|
val > 0 && val <= high ? weight : val <= med ? 0 : -2,
|
||||||
_scoreRevenue: (val, t, w) =>
|
|
||||||
val >= t.revHigh ? w.revenue : val >= t.revMed ? 0 : -1,
|
_sanitize(m) {
|
||||||
_scoreFcf: (val) => (val === 'positive' ? 2 : val === 'negative' ? -2 : 0),
|
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) {
|
_getLabel(score) {
|
||||||
if (score >= 5) return '🟢 BUY (High Conviction)';
|
if (score >= 5) return '🟢 BUY (High Conviction)';
|
||||||
|
|||||||
Reference in New Issue
Block a user