From 225b88ea4f5e40e15e9d7f9ce48c37b06a21dca6 Mon Sep 17 00:00:00 2001 From: Saki Date: Tue, 2 Jun 2026 04:58:07 -0400 Subject: [PATCH] Segmentational Analysis --- README.md | 58 +++++++++++++++++ src/core/assets/Asset.js | 20 ++---- src/core/assets/Bond.js | 35 ++++------- src/core/assets/Etf.js | 35 +++++------ src/core/assets/Stock.js | 96 +++++++++++----------------- src/core/engine/ScoringEngine.js | 31 +++++++-- src/core/engine/ScreenerEngine.js | 101 +++++++++++++----------------- src/utils/DataMapper.js | 3 +- src/utils/RulesMerger.js | 37 +++++++++++ 9 files changed, 237 insertions(+), 179 deletions(-) create mode 100644 README.md create mode 100644 src/utils/RulesMerger.js diff --git a/README.md b/README.md new file mode 100644 index 0000000..9a12501 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# Financial Screener & Personal Finance Assistant + +## Project Overview + +This project is a modular, rule-based financial analysis engine designed to evaluate assets and manage personal investment portfolios. It separates data acquisition, strategy configuration, and evaluation logic to provide actionable investment insights. + +--- + +## Architecture Structure + +### 1. Data Pipeline (`/src/data/`) + +- **Fetcher:** Handles API communication (e.g., Yahoo Finance). +- **Mapper:** Normalizes disparate API responses into a unified flat object structure. +- **Asset Models (`/models/`):** Defines common properties for `Stock`, `Etf`, and `Bond`. + +### 2. Logic & Configuration (`/src/config/` & `/src/utils/`) + +- **`ScoringConfig.js`:** Houses all thresholds, gates, and weights. +- **`RuleMerger.js`:** Dynamically applies sector-specific overrides to base rules. + +### 3. Evaluation & Personal Assistant (`/src/engine/` & `/src/assistant/`) + +- **`ScoringEngine.js`:** Orchestrates evaluation, applying market context and sector overrides. +- **`PortfolioManager.js` (NEW):** Tracks individual holdings, cost basis, and performance metrics. +- **`AdvisorModule.js` (NEW):** Provides personalized suggestions based on screening results and portfolio health. +- **`EventMonitor.js` (NEW):** Tracks calendar events (Earnings Calls) to trigger alerts. + +--- + +## Data Flow Diagram + +--- + +## Future Enhancements + +### Phase 1: Core Engine & Soft Scoring + +- **Soft Scoring System:** Transition from "Hard Gates" to a weighted point-based system. +- **Market Context Integration:** Automate the `marketContext` parameter by fetching real-time 10Y Treasury Yields. + +### Phase 2: Personal Finance Features + +- **Personal Portfolio Tracking:** Implement a `PortfolioManager` to track custom user holdings, monitor unrealized P&L, and calculate weightings relative to total assets. +- **Automated Financial Coaching:** Develop an `AdvisorModule` that analyzes the portfolio and provides suggestions (e.g., "Reduce exposure to High-Debt REITs," or "Rebalance to increase Technology allocation"). +- **Earnings Call Notification System:** \* Integrate an earnings calendar API. + - Implement a polling or webhook service to monitor for upcoming calls. + - Add a notification service (Email, Push, or CLI log) to alert the user 24 hours prior to a scheduled earnings call. + +### Phase 3: Infrastructure & Intelligence + +- **Caching Layer:** Use local JSON caching to reduce API overhead. +- **Sentiment Analysis:** Integrate news-scrapers to weight "Buy" signals based on recent headlines. +- **Backtesting Module:** Run historical simulations to test strategy performance. + +--- + +_Maintained by: AI Collaborator_ diff --git a/src/core/assets/Asset.js b/src/core/assets/Asset.js index 6e8aaff..9155d22 100644 --- a/src/core/assets/Asset.js +++ b/src/core/assets/Asset.js @@ -1,31 +1,23 @@ -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').toUpperCase(); // STOCK, ETF, or BOND + + // Store all raw data as a property so it's accessible but not "logic-heavy" + this.rawData = data; } - // Helper: Format currency safely + // Pure Formatting Helpers - These are the only "logic" this class should own formatCurrency(val) { return val ? `$${val.toFixed(2)}` : 'N/A'; } - // Shared Logic: Generate the verdict score string - calculateVerdict(red, orange, green) { - if (red > 0) return '🔴 REJECT'; - if (orange >= 3) return '🟡 WATCHLIST'; - return '🟢 BUY'; - } - formatLargeNumber(num) { + if (!num) return 'N/A'; if (num >= 1e12) return (num / 1e12).toFixed(2) + 'T'; if (num >= 1e9) return (num / 1e9).toFixed(2) + 'B'; if (num >= 1e6) return (num / 1e6).toFixed(2) + 'M'; return num.toString(); } - - 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 index 6d40778..2ec59c7 100644 --- a/src/core/assets/Bond.js +++ b/src/core/assets/Bond.js @@ -1,35 +1,26 @@ 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'; + + // Store metrics in a flat object for the ScoringEngine + this.metrics = { + ytm: parseFloat(data.yieldToMaturity) || 0, + duration: parseFloat(data.duration) || 0, + 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 + // Helper for dashboard display + getDisplayMetrics() { return { - Type: 'BOND', Ticker: this.ticker, + Type: 'BOND', 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, + 'YTM%': `${this.metrics.ytm.toFixed(2)}%`, + Duration: this.metrics.duration.toFixed(1), + Rating: this.metrics.creditRating, }; } } diff --git a/src/core/assets/Etf.js b/src/core/assets/Etf.js index e29f35d..e46c6e6 100644 --- a/src/core/assets/Etf.js +++ b/src/core/assets/Etf.js @@ -1,36 +1,29 @@ 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, + // Store metrics in a flat object for the ScoringEngine + this.metrics = { + expRatio: parseFloat(data.expenseRatio) || 0, + totalAssets: parseFloat(data.totalAssets) || 0, + yield: parseFloat(data.yield) || 0, }; - // 2. Delegate to Engine - const scoreResult = ScoringEngine.evaluate('ETF', metrics, marketContext); + // Keep performance metrics for display only + this.fiveYearReturn = parseFloat(data.fiveYearReturn) || 0; + } - // 3. Return formatted display data + // Helper for dashboard display + getDisplayMetrics() { return { - Type: 'ETF', Ticker: this.ticker, + Type: 'ETF', 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), + 'Exp Ratio%': `${this.metrics.expRatio.toFixed(2)}%`, + 'Yield%': `${this.metrics.yield.toFixed(2)}%`, + AUM: this.formatLargeNumber(this.metrics.totalAssets), '5Y Return%': `${this.fiveYearReturn.toFixed(1)}%`, }; } diff --git a/src/core/assets/Stock.js b/src/core/assets/Stock.js index 2a54d2d..c1deeff 100644 --- a/src/core/assets/Stock.js +++ b/src/core/assets/Stock.js @@ -1,80 +1,60 @@ import { Asset } from './Asset.js'; -import { ScoringEngine } from '../engine/ScoringEngine.js'; export class Stock extends Asset { constructor(data) { super(data); - this.summaryData = data.summaryData; - // Map industry detection to standard sector keys used in ScoringConfig - this.sector = this._mapToStandardSector(data.summaryData); + // console.log('Data:', data); + this.sector = this._mapToStandardSector(data || {}); - // Financial Metrics - this.quickRatio = data.quickRatio ?? null; - this.debtToEquity = data.debtToEquity ?? 0; - this.fcfGrowth = data.fcfGrowth ?? 'neutral'; - this.revenueGrowth = data.revenueGrowth ?? 0; - this.netProfitMargin = data.netProfitMargin ?? 0; - this.pegRatio = data.pegRatio ?? null; - this.peRatio = data.peRatio ?? null; + // Financial Metrics - These are now just "state" + this.metrics = { + sector: this.sector, + quickRatio: parseFloat(data.quickRatio) || 0, + debtToEquity: parseFloat(data.debtToEquity) || 0, + fcfGrowth: data.fcfGrowth ?? 'neutral', + revenueGrowth: parseFloat(data.revenueGrowth) || 0, + netProfitMargin: parseFloat(data.netProfitMargin) || 0, + pegRatio: parseFloat(data.pegRatio) || null, + peRatio: parseFloat(data.peRatio) || null, + }; } - _mapToStandardSector(summary = {}) { - const profile = summary.assetProfile || {}; - const industry = (profile.industry || '').toLowerCase(); + _mapToStandardSector(data) { + // 1. Safely grab the profile from the data object + const profile = data.assetProfile || {}; - // Mapping logic to match your ScoringConfig.SECTOR_OVERRIDE keys - if ( - industry.includes('software') || - industry.includes('tech') || - industry.includes('semiconductor') - ) + // 2. Extract values safely + const industry = (profile.industry || '').toLowerCase(); + const sector = (profile.sector || '').toLowerCase(); + const combined = `${industry} ${sector}`; + + // 3. Match logic + if (combined.includes('technology') || combined.includes('electronic')) return 'TECHNOLOGY'; - if (industry.includes('reit') || industry.includes('real estate')) + if (combined.includes('real estate') || combined.includes('reit')) return 'REIT'; - if ( - industry.includes('bank') || - industry.includes('financial') || - industry.includes('insurance') - ) + if (combined.includes('financial') || combined.includes('bank')) return 'FINANCIAL'; - return 'GENERAL'; // Maps to your base STOCK rules + return 'GENERAL'; } - evaluate(marketContext) { - // 1. Prepare metrics + the detected sector - const metrics = { - sector: this.sector, // Engine uses this to pull the correct override - quickRatio: this.quickRatio, - debtToEquity: this.debtToEquity, - revenueGrowth: this.revenueGrowth, - netProfitMargin: this.netProfitMargin, - pegRatio: this.pegRatio, - fcfGrowth: this.fcfGrowth, - peRatio: this.peRatio, - }; + // Helper for dashboard display + getDisplayMetrics() { + const formatFcf = (s) => + ({ positive: '🟢', neutral: '🟠', negative: '🔴' })[s] || 'N/A'; - // 2. Delegate to Engine (which now handles the merge) - const scoreResult = ScoringEngine.evaluate('STOCK', metrics, marketContext); - - const formatFcf = (fcfStatus) => - ({ positive: '🟢', neutral: '🟠', negative: '🔴' })[fcfStatus] || 'N/A'; - - // 3. Return formatted results return { Ticker: this.ticker, - Type: 'STOCK', Price: this.formatCurrency(this.currentPrice), - Verdict: scoreResult.label, - 'G/O/R': scoreResult.scoreSummary, - Sector: this.sector, // Added for visibility - '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), + Sector: this.sector, + 'PE Ratio': this.metrics.peRatio?.toFixed(2) ?? 'N/A', + 'FCF%': formatFcf(this.metrics.fcfGrowth), + 'PEG/Fee': this.metrics.pegRatio?.toFixed(2) ?? 'N/A', + 'Rev%': `${this.metrics.revenueGrowth.toFixed(1)}%`, + 'Marg%': `${this.metrics.netProfitMargin.toFixed(1)}%`, + Quick: this.metrics.quickRatio?.toFixed(2) ?? 'N/A', + 'D/E': this.metrics.debtToEquity.toFixed(2), }; } } diff --git a/src/core/engine/ScoringEngine.js b/src/core/engine/ScoringEngine.js index d03ff7e..3e240b1 100644 --- a/src/core/engine/ScoringEngine.js +++ b/src/core/engine/ScoringEngine.js @@ -17,14 +17,33 @@ export const ScoringEngine = { * @param {Object} context - Optional market context (for bonds) */ // In ScoringEngine.js - evaluate(type, data, context = {}) { + evaluate(type, assetInstance, marketContext = {}) { const scorer = this._scorers[type]; - const rules = ScoringRules[type]; // This returns ScoringRules.STOCK - if (!scorer) throw new Error(`No scorer found: ${type}`); - if (!rules) throw new Error(`No configuration rules found: ${type}`); + // 1. Get the metrics (this assumes assetInstance has the metrics object) + const metrics = assetInstance.metrics; - // IMPORTANT: Ensure you pass the specific rules set - return scorer.score(data, rules, context); + // 2. MERGE: Get sector-specific, merged rules + const finalRules = RuleMerger.getRulesForAsset(type, metrics); + + // 3. ADAPT: Apply market context (Yields, etc.) + const adaptedRules = this._applyMarketContext(finalRules, marketContext); + + // 4. SCORE: Pass the adapted rules to the scorer + return scorer.score(metrics, adaptedRules, marketContext); + }, + + _applyMarketContext(rules, context) { + if (context.tenYearYield > 4.0) { + // Tighten valuation expectations when rates are high + return { + ...rules, + gates: { + ...rules.gates, + maxPERatio: Math.floor(rules.gates.maxPERatio * 0.8), + }, + }; + } + return rules; }, }; diff --git a/src/core/engine/ScreenerEngine.js b/src/core/engine/ScreenerEngine.js index 85576c7..4130c90 100644 --- a/src/core/engine/ScreenerEngine.js +++ b/src/core/engine/ScreenerEngine.js @@ -5,6 +5,10 @@ import { Etf } from '../assets/Etf.js'; import { Bond } from '../assets/Bond.js'; import { chunkArray } from '../../utils/Chunker.js'; import { BenchmarkProvider } from '../../api/BenchmarkProvider.js'; +import { RuleMerger } from '../../utils/RulesMerger.js'; +import { StockScorer } from '../scorers/StockScorer.js'; +import { EtfScorer } from '../scorers/EtfScorer.js'; +import { BondScorer } from '../scorers/BondScorer.js'; export class ScreenerEngine { constructor() { @@ -41,45 +45,50 @@ 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}%`, + `📊 Market Context Loaded: 10Y Yield at ${marketContext.riskFreeRate}%`, ); + // Map types to their respective Scorers + const scorers = { STOCK: StockScorer, ETF: EtfScorer, BOND: BondScorer }; const chunks = chunkArray(tickerList, 5); - // 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) { if (!results['ERROR']) results['ERROR'] = []; results['ERROR'].push(data); return; } - // Instantiate specific asset (Stock, ETF, or Bond) + // 1. Instantiate the lean Data Container (Stock, Etf, or Bond) const asset = this._createAssetInstance(data); + const type = asset.type; - // 2. PASS the marketContext to the evaluation logic - // Ensure your Asset classes accept this in their evaluate() method - const evaluated = asset.evaluate(marketContext); + // 2. Merge rules (Utility handles sector overrides) + const rules = RuleMerger.getRulesForAsset(type, asset.metrics); - // 3. Dynamically collect results by Category (STOCK, ETF, BOND) - const category = (evaluated.Type || 'UNKNOWN').toUpperCase(); - if (!results[category]) results[category] = []; - results[category].push(evaluated); + // 3. Direct Scoring (Bypassing the old circular evaluate() call) + const scorer = scorers[type]; + const scoreResult = scorer.score(asset.metrics, rules, marketContext); + + // 4. Combine display data with the final verdict + const finalResult = { + asset: asset, + Verdict: scoreResult.label, + 'G/O/R': scoreResult.scoreSummary, + }; + + if (!results[type]) results[type] = []; + results[type].push(finalResult); }); - // Throttling to respect API rate limits await new Promise((resolve) => setTimeout(resolve, 1000)); } @@ -87,49 +96,27 @@ export class ScreenerEngine { } _display(results) { - const formatters = { - STOCK: (data) => - data.map((item) => ({ - Ticker: item.Ticker, - Sector: item.Sector, // New: See which override rule was applied - Verdict: item.Verdict, // Now includes "High Conviction" vs "Speculative" - Score: item['G/O/R'], - PE: item['PE Ratio'], - PEG: item['PEG/Fee'], - Rev: item['Rev%'], - Marg: item['Marg%'], - FCF: item['FCF%'], // Added to see the context-aware FCF score - })), - - 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]) { - // Pro-Tip: You can now sort your data before printing to show "High Conviction" first - const sortedData = [...results[type]].sort((a, b) => - b.Verdict.localeCompare(a.Verdict), - ); - - console.log(`\n--- ${type} MATRIX ---`); - console.table(formatters[type](sortedData)); + Object.keys(results).forEach((type) => { + if (type === 'ERROR') { + console.log('--- ERRORS ---', results.ERROR); + return; } + + // Sort the raw objects (not the mapped objects) + const sortedData = [...results[type]].sort((a, b) => + b.Verdict.localeCompare(a.Verdict), + ); + + console.log(`\n--- ${type} MATRIX ---`); + + // Use the class method to get the display-ready object + const tableData = sortedData.map((item) => ({ + ...item.asset.getDisplayMetrics(), // <--- THIS RE-USES YOUR CLASS METHOD + Verdict: item.Verdict, // Injected from the Engine result + 'G/O/R': item['G/O/R'], // Injected from the Engine result + })); + + console.table(tableData); }); } } diff --git a/src/utils/DataMapper.js b/src/utils/DataMapper.js index c498a89..6199b70 100644 --- a/src/utils/DataMapper.js +++ b/src/utils/DataMapper.js @@ -21,7 +21,6 @@ export const mapToStandardFormat = (ticker, summary) => { ...mapEtfData(summary), }; } - // Default to STOCK (covers 'EQUITY' or missing types) return { type: 'STOCK', @@ -38,7 +37,9 @@ const mapStockData = (summary) => ({ revenueGrowth: (summary.financialData?.revenueGrowth ?? 0) * 100, netProfitMargin: (summary.financialData?.profitMargins ?? 0) * 100, pegRatio: summary.defaultKeyStatistics?.pegRatio ?? 0, + peRatio: summary.defaultKeyStatistics?.forwardPE ?? 0, currentPrice: summary.price?.regularMarketPrice ?? 0, + assetProfile: summary.assetProfile || {}, }); const mapEtfData = (summary) => ({ diff --git a/src/utils/RulesMerger.js b/src/utils/RulesMerger.js new file mode 100644 index 0000000..eed20d4 --- /dev/null +++ b/src/utils/RulesMerger.js @@ -0,0 +1,37 @@ +import { ScoringRules } from '../config/ScoringConfig.js'; + +/** + * RuleMerger ensures that we apply sector-specific overrides + * to base asset rules without polluting the individual Asset or Scorer logic. + */ +export const RuleMerger = { + getRulesForAsset(type, metrics) { + // 1. Start with a deep clone of the base rules for this asset type (STOCK, ETF, etc.) + const baseRules = ScoringRules[type]; + if (!baseRules) throw new Error(`No configuration found for type: ${type}`); + + let finalRules = JSON.parse(JSON.stringify(baseRules)); + + // 2. If it's a stock and we have a sector, merge the overrides + if (type === 'STOCK' && metrics.sector) { + const sectorKey = metrics.sector.toUpperCase(); + const overrides = baseRules.SECTOR_OVERRIDE?.[sectorKey]; + + if (overrides) { + // Merge gates, weights, and thresholds deeply + finalRules.gates = { ...finalRules.gates, ...overrides.gates }; + finalRules.weights = { ...finalRules.weights, ...overrides.weights }; + finalRules.thresholds = { + ...finalRules.thresholds, + ...overrides.thresholds, + }; + } + } + + // 3. Cleanup: Remove the override configuration from the final object + // so the Scorer works with a clean, flat rule set. + delete finalRules.SECTOR_OVERRIDE; + + return finalRules; + }, +};