benchmarks
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import { YahooClient } from './YahooClient.js';
|
||||
|
||||
export class BenchmarkProvider {
|
||||
constructor() {
|
||||
this.client = new YahooClient();
|
||||
this.cache = { data: null, expiresAt: 0 };
|
||||
this.TTL_MS = 60 * 60 * 1000; // Cache for 1 hour
|
||||
}
|
||||
|
||||
async getMarketContext() {
|
||||
// 1. Return cached data if still valid
|
||||
if (this.cache.data && Date.now() < this.cache.expiresAt) {
|
||||
return this.cache.data;
|
||||
}
|
||||
|
||||
try {
|
||||
const [sp500, tn10y] = await Promise.all([
|
||||
this.client.fetchSummary('^GSPC'),
|
||||
this.client.fetchSummary('^TNX'),
|
||||
]);
|
||||
|
||||
const context = {
|
||||
sp500Price: sp500.price?.regularMarketPrice ?? 0,
|
||||
riskFreeRate: tn10y.price?.regularMarketPrice ?? 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// 2. Validate data sanity (prevent 0-value errors)
|
||||
if (context.sp500Price === 0 || context.riskFreeRate === 0) {
|
||||
throw new Error('Invalid market data received (zero values)');
|
||||
}
|
||||
|
||||
// 3. Update cache
|
||||
this.cache = { data: context, expiresAt: Date.now() + this.TTL_MS };
|
||||
return context;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Market data fetch failed, using last known or empty state:',
|
||||
error,
|
||||
);
|
||||
// If we have stale cache, use it even if expired, otherwise return safe defaults
|
||||
return this.cache.data || { sp500Price: 4500, riskFreeRate: 4.0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
// src/api/YahooClient.js
|
||||
import YahooFinance from 'yahoo-finance2';
|
||||
|
||||
export class YahooClient {
|
||||
constructor() {
|
||||
// Instantiate the client as required by v3
|
||||
this.yf = new YahooFinance();
|
||||
this.yf = new YahooFinance({
|
||||
suppressNotices: ['yahooSurvey'],
|
||||
});
|
||||
}
|
||||
|
||||
async fetchSummary(ticker, retries = 3, backoff = 1000) {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
export const ScoringRules = {
|
||||
STOCK: {
|
||||
gates: { maxDebtToEquity: 3.0, minQuickRatio: 0.25, maxPERatio: 80 },
|
||||
weights: { margin: 3, peg: 2, revenue: 2, fcf: 1 },
|
||||
thresholds: {
|
||||
marginHigh: 20,
|
||||
marginMed: 10,
|
||||
pegHigh: 1.3,
|
||||
pegMed: 2.0,
|
||||
revHigh: 15,
|
||||
revMed: 5,
|
||||
},
|
||||
},
|
||||
ETF: {
|
||||
gates: { maxExpenseRatio: 0.75 }, // Reject if too expensive
|
||||
weights: { yield: 2, lowCost: 3 },
|
||||
thresholds: { minYield: 0.02, maxExpense: 0.2 },
|
||||
},
|
||||
BOND: {
|
||||
gates: { minCreditRating: 5 }, // e.g., 5 = Investment Grade
|
||||
weights: { yieldSpread: 3, duration: 2 },
|
||||
thresholds: { minSpread: 1.5, maxDuration: 10 },
|
||||
},
|
||||
};
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Asset } from './Asset.js';
|
||||
|
||||
export class Bond extends Asset {
|
||||
constructor({ yieldToMaturity, duration, creditRating, ...rest }) {
|
||||
super(rest);
|
||||
this.yieldToMaturity = yieldToMaturity ?? 0;
|
||||
this.duration = duration ?? 0;
|
||||
this.creditRating = creditRating ?? 'N/A';
|
||||
}
|
||||
|
||||
evaluate() {
|
||||
let green = 0,
|
||||
orange = 0,
|
||||
red = 0;
|
||||
|
||||
// Custom Bond Rules
|
||||
if (this.yieldToMaturity > 4.5) green++;
|
||||
else orange++;
|
||||
if (this.duration < 5) green++;
|
||||
else red++;
|
||||
|
||||
const isStable = this.duration < 7; // < 7 years is generally lower interest-rate risk
|
||||
|
||||
return {
|
||||
Ticker: this.ticker,
|
||||
Type: 'BOND',
|
||||
Price: this.formatCurrency(this.currentPrice),
|
||||
'YTM%': `${this.yieldToMaturity}%`,
|
||||
Duration: this.duration,
|
||||
Rating: this.creditRating,
|
||||
'G/O/R': `${green}/${orange}/${red}`,
|
||||
Verdict: isStable ? '🟢 Stable' : '⚠️ Rate Sensitive',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { Asset } from './Asset.js';
|
||||
|
||||
export class Etf extends Asset {
|
||||
constructor(data) {
|
||||
super(data);
|
||||
this.expenseRatio = data.expenseRatio ?? 0;
|
||||
this.totalAssets = data.totalAssets ?? 0;
|
||||
this.yield = data.yield ?? 0;
|
||||
this.fiveYearReturn = data.fiveYearReturn ?? 0;
|
||||
}
|
||||
|
||||
evaluate() {
|
||||
let green = 0,
|
||||
orange = 0,
|
||||
red = 0;
|
||||
|
||||
// Rule 1: Expense Ratio
|
||||
if (this.expenseRatio !== null) {
|
||||
this.expenseRatio <= 0.15 ? green++ : red++;
|
||||
}
|
||||
|
||||
// Rule 2: Total Assets (size)
|
||||
if (this.totalAssets > 0) {
|
||||
this.totalAssets >= 1_000_000_000 ? green++ : red++;
|
||||
}
|
||||
|
||||
// Rule 3: Yield
|
||||
if (this.yield !== null) green++;
|
||||
|
||||
const isEfficient = this.expenseRatio < 0.2;
|
||||
|
||||
return {
|
||||
Type: 'ETF',
|
||||
Ticker: this.ticker,
|
||||
Price: this.formatCurrency(this.currentPrice),
|
||||
// Use optional chaining (?.) and nullish coalescing (?? 0)
|
||||
'Exp Ratio%': `${(this.expenseRatio ?? 0).toFixed(2)}%`,
|
||||
'Yield%': `${(this.yield ?? 0).toFixed(2)}%`,
|
||||
AUM: this.formatLargeNumber(this.totalAssets ?? 0),
|
||||
'5Y Return%': `${(this.fiveYearReturn ?? 0).toFixed(1)}%`,
|
||||
Verdict: isEfficient ? '🟢 Efficient' : '🔴 High Cost', // Simplified for testing
|
||||
};
|
||||
}
|
||||
|
||||
getJustification() {
|
||||
const reasons = [];
|
||||
if (this.expenseRatio > 0.15)
|
||||
reasons.push(`High Fee (${this.expenseRatio}%)`);
|
||||
if (this.totalAssets < 1_000_000_000) reasons.push(`Low AUM`);
|
||||
|
||||
return {
|
||||
Ticker: this.ticker,
|
||||
Verdict: red > 0 ? 'Avoid' : 'Core Hold',
|
||||
Reasoning: reasons.length > 0 ? reasons.join(', ') : 'Solid Foundation',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
// src/core/Asset.js
|
||||
import { ScoringEngine } from '../engine/ScoringEngine.js';
|
||||
export class Asset {
|
||||
constructor(data) {
|
||||
this.ticker = (data.ticker || 'UNKNOWN').toUpperCase();
|
||||
this.currentPrice = data.currentPrice || 0;
|
||||
this.type = data.type || 'stock';
|
||||
this.type = data.type || 'STOCK';
|
||||
}
|
||||
|
||||
// Helper: Format currency safely
|
||||
@@ -25,7 +25,7 @@ export class Asset {
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
evaluate() {
|
||||
throw new Error("Method 'evaluate()' must be implemented by subclass.");
|
||||
evaluate(context) {
|
||||
throw new Error('Evaluate method must be implemented by subclass');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Asset } from './Asset.js';
|
||||
import { ScoringEngine } from '../engine/ScoringEngine.js';
|
||||
|
||||
export class Bond extends Asset {
|
||||
constructor(data) {
|
||||
super(data);
|
||||
this.yieldToMaturity = data.yieldToMaturity ?? 0;
|
||||
this.duration = data.duration ?? 0;
|
||||
this.creditRating = data.creditRating ?? 'N/A';
|
||||
}
|
||||
|
||||
evaluate(marketContext) {
|
||||
// 1. Map data to the metrics the Engine expects
|
||||
const metrics = {
|
||||
ytm: this.yieldToMaturity,
|
||||
duration: this.duration,
|
||||
creditRating: this.creditRating,
|
||||
};
|
||||
|
||||
// 2. Delegate to Engine
|
||||
const scoreResult = ScoringEngine.evaluate('BOND', metrics, marketContext);
|
||||
|
||||
// 3. Return formatted display data
|
||||
return {
|
||||
Type: 'BOND',
|
||||
Ticker: this.ticker,
|
||||
Price: this.formatCurrency(this.currentPrice),
|
||||
Verdict: scoreResult.label,
|
||||
'G/O/R': scoreResult.scoreSummary,
|
||||
'YTM%': `${this.yieldToMaturity.toFixed(2)}%`,
|
||||
Duration: this.duration.toFixed(1),
|
||||
Rating: this.creditRating,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Asset } from './Asset.js';
|
||||
import { ScoringEngine } from '../engine/ScoringEngine.js'; // Import your engine
|
||||
|
||||
export class Etf extends Asset {
|
||||
constructor(data) {
|
||||
super(data);
|
||||
this.expenseRatio = data.expenseRatio ?? 0;
|
||||
this.totalAssets = data.totalAssets ?? 0;
|
||||
this.yield = data.yield ?? 0;
|
||||
this.fiveYearReturn = data.fiveYearReturn ?? 0;
|
||||
}
|
||||
|
||||
evaluate(marketContext) {
|
||||
// 1. Prepare metrics object for the Engine
|
||||
const metrics = {
|
||||
expRatio: this.expenseRatio,
|
||||
totalAssets: this.totalAssets,
|
||||
yield: this.yield,
|
||||
};
|
||||
|
||||
// 2. Delegate to Engine
|
||||
const scoreResult = ScoringEngine.evaluate('ETF', metrics, marketContext);
|
||||
|
||||
// 3. Return formatted display data
|
||||
return {
|
||||
Type: 'ETF',
|
||||
Ticker: this.ticker,
|
||||
Price: this.formatCurrency(this.currentPrice),
|
||||
Verdict: scoreResult.label,
|
||||
'G/O/R': scoreResult.scoreSummary,
|
||||
'Exp Ratio%': `${this.expenseRatio.toFixed(2)}%`,
|
||||
'Yield%': `${this.yield.toFixed(2)}%`,
|
||||
AUM: this.formatLargeNumber(this.totalAssets),
|
||||
'5Y Return%': `${this.fiveYearReturn.toFixed(1)}%`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Asset } from './Asset.js';
|
||||
import { ScoringEngine } from '../engine/ScoringEngine.js';
|
||||
|
||||
export class Stock extends Asset {
|
||||
constructor(data) {
|
||||
@@ -13,6 +14,7 @@ export class Stock extends Asset {
|
||||
this.revenueGrowth = data.revenueGrowth ?? 0;
|
||||
this.netProfitMargin = data.netProfitMargin ?? 0;
|
||||
this.pegRatio = data.pegRatio ?? null;
|
||||
this.peRatio = data.peRatio ?? null; // Ensure this is included
|
||||
}
|
||||
|
||||
_detectIndustryType(summary = {}) {
|
||||
@@ -43,61 +45,43 @@ export class Stock extends Asset {
|
||||
}
|
||||
|
||||
evaluate() {
|
||||
let green = 0,
|
||||
orange = 0,
|
||||
red = 0;
|
||||
|
||||
const metrics = [
|
||||
{
|
||||
val: this.quickRatio,
|
||||
green: (v) =>
|
||||
v > 1.0 ||
|
||||
((this.industry === 'SaaS' || this.industry === 'Mega-Cap') &&
|
||||
v >= 0.7),
|
||||
orange: (v) => v >= 0.7,
|
||||
},
|
||||
{
|
||||
val: this.debtToEquity,
|
||||
green: (v) => v < 1.0,
|
||||
orange: (v) => v <= 2.5 || this.industry === 'Capital-Heavy',
|
||||
},
|
||||
{ val: this.revenueGrowth, green: (v) => v > 10, orange: (v) => v >= 2 },
|
||||
{
|
||||
val: this.netProfitMargin,
|
||||
green: (v) => v > 15 || this.industry === 'Retail',
|
||||
orange: (v) => v >= 5,
|
||||
},
|
||||
{
|
||||
val: this.pegRatio,
|
||||
green: (v) => v > 0 && v <= 1.3,
|
||||
orange: (v) => v <= 3.5 || ['SaaS', 'Mega-Cap'].includes(this.industry),
|
||||
},
|
||||
];
|
||||
|
||||
metrics.forEach((m) => {
|
||||
const score = this._scoreMetric(m.val, null, m.green, m.orange);
|
||||
if (score === 1) green++;
|
||||
else if (score === -1) orange++;
|
||||
else if (m.val !== null) red++;
|
||||
});
|
||||
|
||||
if (this.fcfGrowth === 'positive') green++;
|
||||
else if (['SaaS', 'Mega-Cap'].includes(this.industry)) orange++;
|
||||
else red++;
|
||||
|
||||
const verdict = this.calculateVerdict(red, orange, green);
|
||||
// 1. Prepare the metrics object
|
||||
const metrics = {
|
||||
industry: this.industry,
|
||||
quickRatio: this.quickRatio,
|
||||
debtToEquity: this.debtToEquity,
|
||||
revenueGrowth: this.revenueGrowth,
|
||||
netProfitMargin: this.netProfitMargin,
|
||||
pegRatio: this.pegRatio,
|
||||
fcfGrowth: this.fcfGrowth,
|
||||
peRatio: this.peRatio, // Ensure this is mapped
|
||||
};
|
||||
|
||||
// 2. Delegate to the Engine
|
||||
// We pass 'this.context' if you have it stored, or null
|
||||
const scoreResult = ScoringEngine.evaluate('STOCK', metrics, this.context);
|
||||
const formatFcf = (fcfStatus) => {
|
||||
const map = {
|
||||
positive: '🟢',
|
||||
neutral: '🟠',
|
||||
negative: '🔴',
|
||||
};
|
||||
return map[fcfStatus] || 'N/A';
|
||||
};
|
||||
// 3. Return the formatted object
|
||||
return {
|
||||
Ticker: this.ticker,
|
||||
Type: 'STOCK',
|
||||
Price: this.formatCurrency(this.currentPrice),
|
||||
Verdict: scoreResult.label, // This is your official Engine verdict
|
||||
'G/O/R': scoreResult.scoreSummary, // This is your official Engine summary
|
||||
'PE Ratio': this.peRatio?.toFixed(2) ?? 'N/A',
|
||||
'FCF%': formatFcf(this.fcfGrowth),
|
||||
'PEG/Fee': this.pegRatio?.toFixed(2) ?? 'N/A',
|
||||
'Rev%': `${this.revenueGrowth.toFixed(1)}%`,
|
||||
'Marg%': `${this.netProfitMargin.toFixed(1)}%`,
|
||||
Quick: this.quickRatio?.toFixed(2) ?? 'N/A',
|
||||
'D/E': this.debtToEquity.toFixed(2),
|
||||
'G/O/R': `${green}/${orange}/${red}`,
|
||||
Verdict: verdict,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { StockScorer } from '../scorers/StockScorer.js';
|
||||
import { EtfScorer } from '../scorers/EtfScorer.js';
|
||||
import { BondScorer } from '../scorers/BondScorer.js';
|
||||
|
||||
export const ScoringEngine = {
|
||||
// Registry of available strategies
|
||||
_scorers: {
|
||||
STOCK: StockScorer,
|
||||
ETF: EtfScorer,
|
||||
BOND: BondScorer,
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} type - 'STOCK', 'ETF', or 'BOND'
|
||||
* @param {Object} data - The raw metric data
|
||||
* @param {Object} context - Optional market context (for bonds)
|
||||
*/
|
||||
evaluate(type, data, context = {}) {
|
||||
const scorer = this._scorers[type];
|
||||
|
||||
if (!scorer) {
|
||||
throw new Error(`No scorer found for asset type: ${type}`);
|
||||
}
|
||||
|
||||
// Every scorer now shares the exact same signature: .score()
|
||||
return scorer.score(data, context);
|
||||
},
|
||||
};
|
||||
@@ -1,13 +1,15 @@
|
||||
import { YahooClient } from '../api/YahooClient.js';
|
||||
import { mapToStandardFormat } from '../utils/DataMapper.js';
|
||||
import { Stock } from './Stock.js';
|
||||
import { Etf } from './Etf.js';
|
||||
import { Bond } from './Bond.js';
|
||||
import { chunkArray } from '../utils/Chunker.js';
|
||||
import { YahooClient } from '../../api/YahooClient.js';
|
||||
import { mapToStandardFormat } from '../../utils/DataMapper.js';
|
||||
import { Stock } from '../assets/Stock.js';
|
||||
import { Etf } from '../assets/Etf.js';
|
||||
import { Bond } from '../assets/Bond.js';
|
||||
import { chunkArray } from '../../utils/Chunker.js';
|
||||
import { BenchmarkProvider } from '../../api/BenchmarkProvider.js';
|
||||
|
||||
export class ScreenerEngine {
|
||||
constructor() {
|
||||
this.client = new YahooClient();
|
||||
this.benchmarkProvider = new BenchmarkProvider();
|
||||
}
|
||||
|
||||
_createAssetInstance(data) {
|
||||
@@ -39,38 +41,45 @@ export class ScreenerEngine {
|
||||
}
|
||||
|
||||
async runParallelScreener(tickerList) {
|
||||
// 1. Fetch market context (the "Ground Truth" for your scoring)
|
||||
const marketContext = await this.benchmarkProvider.getMarketContext();
|
||||
console.log(
|
||||
`📊 Market Context Loaded: S&P500 at ${marketContext.sp500Price}, 10Y Yield at ${marketContext.riskFreeRate}%`,
|
||||
);
|
||||
|
||||
const chunks = chunkArray(tickerList, 5);
|
||||
const results = { STOCK: [], ETF: [], BOND: [] };
|
||||
// Use a flexible results object that populates dynamically
|
||||
const results = {};
|
||||
|
||||
for (const chunk of chunks) {
|
||||
console.log(`🚀 Processing batch: ${chunk.join(', ')}`);
|
||||
|
||||
const rawDataBatch = await Promise.all(
|
||||
chunk.map((t) => this._fetchAndProcess(t)),
|
||||
);
|
||||
|
||||
rawDataBatch.forEach((data) => {
|
||||
// Handle failed fetches
|
||||
if (data.isError) {
|
||||
results.STOCK.push(data);
|
||||
if (!results['ERROR']) results['ERROR'] = [];
|
||||
results['ERROR'].push(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Instantiate specific asset (Stock, ETF, or Bond)
|
||||
const asset = this._createAssetInstance(data);
|
||||
const evaluated = asset.evaluate();
|
||||
|
||||
// --- THE FIX ---
|
||||
// If the evaluated.Type doesn't match a bucket,
|
||||
// this console.warn will tell us exactly what key is missing.
|
||||
const category = (evaluated.Type || data.type || 'STOCK').toUpperCase();
|
||||
// 2. PASS the marketContext to the evaluation logic
|
||||
// Ensure your Asset classes accept this in their evaluate() method
|
||||
const evaluated = asset.evaluate(marketContext);
|
||||
|
||||
if (results[category]) {
|
||||
results[category].push(evaluated);
|
||||
} else {
|
||||
console.warn(
|
||||
`WARNING: Data dropped! Ticker ${data.ticker} has Type "${category}" which doesn't match results keys.`,
|
||||
);
|
||||
}
|
||||
// 3. Dynamically collect results by Category (STOCK, ETF, BOND)
|
||||
const category = (evaluated.Type || 'UNKNOWN').toUpperCase();
|
||||
if (!results[category]) results[category] = [];
|
||||
results[category].push(evaluated);
|
||||
});
|
||||
|
||||
// Throttling to respect API rate limits
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { ScoringRules } from '../../config/ScoringConfig.js';
|
||||
|
||||
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;
|
||||
const metrics = this._sanitize(m);
|
||||
|
||||
// Safety check for riskFreeRate (ensure it's a decimal, e.g., 0.04)
|
||||
const riskFreeRate = context?.riskFreeRate ?? 0.04;
|
||||
const spread = metrics.ytm - riskFreeRate;
|
||||
|
||||
let score = 0;
|
||||
const breakdown = {};
|
||||
|
||||
// 1. Spread Logic: If spread is >= 0, it's at least neutral
|
||||
breakdown.spread =
|
||||
spread >= thresholds.minSpread ? weights.yieldSpread : -2;
|
||||
|
||||
// 2. Duration Logic
|
||||
breakdown.duration =
|
||||
metrics.duration <= thresholds.maxDuration ? weights.duration : -1;
|
||||
|
||||
// 3. Credit Rating Logic (Handling 'N/A')
|
||||
if (metrics.creditRating === 'Junk') {
|
||||
score -= 5;
|
||||
}
|
||||
|
||||
score = Object.values(breakdown).reduce((a, b) => a + b, 0);
|
||||
|
||||
return {
|
||||
label: this._getLabel(score),
|
||||
scoreSummary: `Score: ${score}`,
|
||||
audit: { breakdown },
|
||||
};
|
||||
},
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
_sanitize(m) {
|
||||
// Convert percentage string '3.95%' to decimal 0.0395
|
||||
const parsePercent = (val) => {
|
||||
if (typeof val === 'string') val = val.replace('%', '');
|
||||
return parseFloat(val) / 100 || 0;
|
||||
};
|
||||
|
||||
return {
|
||||
ytm: parsePercent(m.ytm),
|
||||
duration: parseFloat(m.duration) || 0,
|
||||
creditRating:
|
||||
m.creditRating === 'N/A' ? 'InvestmentGrade' : m.creditRating, // Treat N/A as safe
|
||||
};
|
||||
},
|
||||
|
||||
_getLabel(score) {
|
||||
if (score >= 4) return '🟢 Attractive';
|
||||
if (score >= 1) return '🟡 Neutral';
|
||||
return '🔴 Avoid';
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
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;
|
||||
const metrics = this._sanitize(m);
|
||||
|
||||
// 1. GATE KEEPING: High fees trigger an automatic reject
|
||||
if (metrics.expenseRatio > gates.maxExpenseRatio) {
|
||||
return {
|
||||
label: '🔴 REJECT',
|
||||
scoreSummary: 'GATE FAILED: High Expense Ratio',
|
||||
};
|
||||
}
|
||||
|
||||
// 2. SCORING REGISTRY
|
||||
const scoringRegistry = [
|
||||
{
|
||||
key: 'cost',
|
||||
fn: () =>
|
||||
metrics.expenseRatio <= thresholds.maxExpense ? weights.lowCost : -3,
|
||||
},
|
||||
{
|
||||
key: 'yield',
|
||||
fn: () => (metrics.yield >= thresholds.minYield ? weights.yield : -1),
|
||||
},
|
||||
{ key: 'vol', fn: () => (metrics.volume >= 100000 ? 0 : -2) },
|
||||
];
|
||||
|
||||
const breakdown = {};
|
||||
const totalScore = scoringRegistry.reduce((sum, item) => {
|
||||
breakdown[item.key] = item.fn();
|
||||
return sum + breakdown[item.key];
|
||||
}, 0);
|
||||
|
||||
// 3. RESULT
|
||||
return {
|
||||
label: this._getLabel(totalScore),
|
||||
scoreSummary: `Score: ${totalScore}`,
|
||||
audit: { passedGates: true, breakdown },
|
||||
};
|
||||
},
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
_sanitize(m) {
|
||||
return {
|
||||
expenseRatio: parseFloat(m.expenseRatio) || 0,
|
||||
yield: parseFloat(m.yield) || 0,
|
||||
volume: parseFloat(m.volume) || 0,
|
||||
};
|
||||
},
|
||||
|
||||
_getLabel(score) {
|
||||
if (score >= 3) return '🟢 Efficient';
|
||||
if (score >= 0) return '🟡 Neutral';
|
||||
return '🔴 Expensive/Low Yield';
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
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;
|
||||
const metrics = this._sanitize(m);
|
||||
|
||||
// 1. GATEKEEPING
|
||||
const gateResult = this._checkGates(metrics, gates);
|
||||
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
|
||||
const scoringRegistry = [
|
||||
{
|
||||
key: 'margin',
|
||||
fn: () =>
|
||||
this._scoreMargin(metrics.netProfitMargin, thresholds, weights),
|
||||
},
|
||||
{
|
||||
key: 'peg',
|
||||
fn: () => this._scorePeg(metrics.pegRatio, thresholds, weights),
|
||||
},
|
||||
{
|
||||
key: 'rev',
|
||||
fn: () =>
|
||||
this._scoreRevenue(metrics.revenueGrowth, thresholds, weights),
|
||||
},
|
||||
{ key: 'fcf', fn: () => this._scoreFcf(metrics.fcfGrowth) },
|
||||
];
|
||||
|
||||
const breakdown = {};
|
||||
const totalScore = scoringRegistry.reduce((sum, item) => {
|
||||
breakdown[item.key] = item.fn();
|
||||
return sum + breakdown[item.key];
|
||||
}, 0);
|
||||
|
||||
// 3. FINAL VERDICT
|
||||
return {
|
||||
label: this._getLabel(totalScore),
|
||||
scoreSummary: `Score: ${totalScore}`,
|
||||
analystRating: m.analystConsensus || 'N/A', // Add this
|
||||
audit: { passedGates: true, breakdown },
|
||||
};
|
||||
},
|
||||
|
||||
// --- 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');
|
||||
|
||||
return {
|
||||
passed: failures.length === 0,
|
||||
reason: `GATE FAILED: ${failures.join(', ')}`,
|
||||
};
|
||||
},
|
||||
|
||||
// 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),
|
||||
|
||||
_getLabel(score) {
|
||||
if (score >= 5) return '🟢 BUY (High Conviction)';
|
||||
if (score >= 2) return '🟢 BUY (Speculative)';
|
||||
if (score < -2) return '🔴 REJECT';
|
||||
return '🟡 HOLD';
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,3 @@
|
||||
// src/utils/DataMapper.js
|
||||
|
||||
export const mapToStandardFormat = (ticker, summary) => {
|
||||
const quoteType = summary.price?.quoteType;
|
||||
const category = (summary.assetProfile?.category || '').toLowerCase();
|
||||
|
||||
Reference in New Issue
Block a user