From a0d4dc54a03be4e80a7c871978f206c6d5a641d5 Mon Sep 17 00:00:00 2001 From: Saki Date: Tue, 2 Jun 2026 02:42:41 -0400 Subject: [PATCH] benchmarks --- index.js | 2 +- markdown.md | 47 +++++++++++ src/api/BenchmarkProvider.js | 45 ++++++++++ src/api/yahooClient.js | 5 +- src/config/ScoringConfig.js | 24 ++++++ src/core/Bond.js | 35 -------- src/core/Etf.js | 57 ------------- src/core/{ => assets}/Asset.js | 8 +- src/core/assets/Bond.js | 35 ++++++++ src/core/assets/Etf.js | 37 +++++++++ src/core/{ => assets}/Stock.js | 74 +++++++---------- src/core/engine/ScoringEngine.js | 28 +++++++ src/core/{ => engine}/ScreenerEngine.js | 49 ++++++----- src/core/scorers/BondScorer.js | 63 ++++++++++++++ src/core/scorers/EtfScorer.js | 62 ++++++++++++++ src/core/scorers/StockScorer.js | 105 ++++++++++++++++++++++++ src/utils/DataMapper.js | 2 - 17 files changed, 512 insertions(+), 166 deletions(-) create mode 100644 markdown.md create mode 100644 src/api/BenchmarkProvider.js create mode 100644 src/config/ScoringConfig.js delete mode 100644 src/core/Bond.js delete mode 100644 src/core/Etf.js rename src/core/{ => assets}/Asset.js (78%) create mode 100644 src/core/assets/Bond.js create mode 100644 src/core/assets/Etf.js rename src/core/{ => assets}/Stock.js (58%) create mode 100644 src/core/engine/ScoringEngine.js rename src/core/{ => engine}/ScreenerEngine.js (53%) create mode 100644 src/core/scorers/BondScorer.js create mode 100644 src/core/scorers/EtfScorer.js create mode 100644 src/core/scorers/StockScorer.js diff --git a/index.js b/index.js index 8ee34cb..7f53426 100644 --- a/index.js +++ b/index.js @@ -1,4 +1,4 @@ -import { ScreenerEngine } from './src/core/ScreenerEngine.js'; +import { ScreenerEngine } from './src/core/engine/ScreenerEngine.js'; const tickers = [ 'PLTR', diff --git a/markdown.md b/markdown.md new file mode 100644 index 0000000..2e115b2 --- /dev/null +++ b/markdown.md @@ -0,0 +1,47 @@ +### Request: Optimize Investment Strategy Configuration + +I am updating my investment strategy configuration. You are acting as a Senior Quantitative Financial Strategist. Please analyze my current market thesis and update the configuration parameters to align with this view. + +**Market Thesis:** [INSERT YOUR THESIS HERE] + +### Reasoning Phase (Before the JSON) + +1. Briefly summarize your logic for the changes (e.g., "Raising the `maxDebtToEquity` gate because high-interest environments make capital-intensive businesses riskier"). +2. Ensure all values are mathematically sound and consistent with the requested thesis. + +### JSON Output Requirements + +- Return a valid JSON object matching the schema below. +- Ensure all numbers are appropriate for the asset class (e.g., Debt/Equity usually 0-5, P/E usually 0-100). +- **Crucial:** Provide _only_ the JSON inside a single code block. No conversational text after the code block. + +```json +{ + "STOCK": { + "gates": { + "maxDebtToEquity": 0.0, + "minQuickRatio": 0.0, + "maxPERatio": 0.0 + }, + "weights": { "margin": 0, "peg": 0, "revenue": 0, "fcf": 0 }, + "thresholds": { + "marginHigh": 0, + "marginMed": 0, + "pegHigh": 0, + "pegMed": 0, + "revHigh": 0, + "revMed": 0 + } + }, + "ETF": { + "gates": { "maxExpenseRatio": 0.0 }, + "weights": { "yield": 0, "lowCost": 0 }, + "thresholds": { "minYield": 0.0, "maxExpense": 0.0 } + }, + "BOND": { + "gates": { "minCreditRating": 0 }, + "weights": { "yieldSpread": 0, "duration": 0 }, + "thresholds": { "minSpread": 0.0, "maxDuration": 0 } + } +} +``` diff --git a/src/api/BenchmarkProvider.js b/src/api/BenchmarkProvider.js new file mode 100644 index 0000000..cff5feb --- /dev/null +++ b/src/api/BenchmarkProvider.js @@ -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 }; + } + } +} diff --git a/src/api/yahooClient.js b/src/api/yahooClient.js index d1a52e4..a4f1331 100644 --- a/src/api/yahooClient.js +++ b/src/api/yahooClient.js @@ -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) { diff --git a/src/config/ScoringConfig.js b/src/config/ScoringConfig.js new file mode 100644 index 0000000..6e0b3e4 --- /dev/null +++ b/src/config/ScoringConfig.js @@ -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 }, + }, +}; diff --git a/src/core/Bond.js b/src/core/Bond.js deleted file mode 100644 index a45bca5..0000000 --- a/src/core/Bond.js +++ /dev/null @@ -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', - }; - } -} diff --git a/src/core/Etf.js b/src/core/Etf.js deleted file mode 100644 index 193ab3d..0000000 --- a/src/core/Etf.js +++ /dev/null @@ -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', - }; - } -} diff --git a/src/core/Asset.js b/src/core/assets/Asset.js similarity index 78% rename from src/core/Asset.js rename to src/core/assets/Asset.js index c6348c5..6e8aaff 100644 --- a/src/core/Asset.js +++ b/src/core/assets/Asset.js @@ -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'); } } diff --git a/src/core/assets/Bond.js b/src/core/assets/Bond.js new file mode 100644 index 0000000..6d40778 --- /dev/null +++ b/src/core/assets/Bond.js @@ -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, + }; + } +} diff --git a/src/core/assets/Etf.js b/src/core/assets/Etf.js new file mode 100644 index 0000000..e29f35d --- /dev/null +++ b/src/core/assets/Etf.js @@ -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)}%`, + }; + } +} diff --git a/src/core/Stock.js b/src/core/assets/Stock.js similarity index 58% rename from src/core/Stock.js rename to src/core/assets/Stock.js index 789a3ed..993c0e8 100644 --- a/src/core/Stock.js +++ b/src/core/assets/Stock.js @@ -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, }; } } diff --git a/src/core/engine/ScoringEngine.js b/src/core/engine/ScoringEngine.js new file mode 100644 index 0000000..0042f18 --- /dev/null +++ b/src/core/engine/ScoringEngine.js @@ -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); + }, +}; diff --git a/src/core/ScreenerEngine.js b/src/core/engine/ScreenerEngine.js similarity index 53% rename from src/core/ScreenerEngine.js rename to src/core/engine/ScreenerEngine.js index 6ec577f..0e13e2b 100644 --- a/src/core/ScreenerEngine.js +++ b/src/core/engine/ScreenerEngine.js @@ -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)); } diff --git a/src/core/scorers/BondScorer.js b/src/core/scorers/BondScorer.js new file mode 100644 index 0000000..b22d642 --- /dev/null +++ b/src/core/scorers/BondScorer.js @@ -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'; + }, +}; diff --git a/src/core/scorers/EtfScorer.js b/src/core/scorers/EtfScorer.js new file mode 100644 index 0000000..6f4cb55 --- /dev/null +++ b/src/core/scorers/EtfScorer.js @@ -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'; + }, +}; diff --git a/src/core/scorers/StockScorer.js b/src/core/scorers/StockScorer.js new file mode 100644 index 0000000..c438b16 --- /dev/null +++ b/src/core/scorers/StockScorer.js @@ -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'; + }, +}; diff --git a/src/utils/DataMapper.js b/src/utils/DataMapper.js index 3aad7c2..c498a89 100644 --- a/src/utils/DataMapper.js +++ b/src/utils/DataMapper.js @@ -1,5 +1,3 @@ -// src/utils/DataMapper.js - export const mapToStandardFormat = (ticker, summary) => { const quoteType = summary.price?.quoteType; const category = (summary.assetProfile?.category || '').toLowerCase();