Segmentational Analysis
This commit is contained in:
@@ -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_
|
||||||
@@ -1,31 +1,23 @@
|
|||||||
import { ScoringEngine } from '../engine/ScoringEngine.js';
|
|
||||||
export class Asset {
|
export class Asset {
|
||||||
constructor(data) {
|
constructor(data) {
|
||||||
this.ticker = (data.ticker || 'UNKNOWN').toUpperCase();
|
this.ticker = (data.ticker || 'UNKNOWN').toUpperCase();
|
||||||
this.currentPrice = data.currentPrice || 0;
|
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) {
|
formatCurrency(val) {
|
||||||
return val ? `$${val.toFixed(2)}` : 'N/A';
|
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) {
|
formatLargeNumber(num) {
|
||||||
|
if (!num) return 'N/A';
|
||||||
if (num >= 1e12) return (num / 1e12).toFixed(2) + 'T';
|
if (num >= 1e12) return (num / 1e12).toFixed(2) + 'T';
|
||||||
if (num >= 1e9) return (num / 1e9).toFixed(2) + 'B';
|
if (num >= 1e9) return (num / 1e9).toFixed(2) + 'B';
|
||||||
if (num >= 1e6) return (num / 1e6).toFixed(2) + 'M';
|
if (num >= 1e6) return (num / 1e6).toFixed(2) + 'M';
|
||||||
return num.toString();
|
return num.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
evaluate(context) {
|
|
||||||
throw new Error('Evaluate method must be implemented by subclass');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-22
@@ -1,35 +1,26 @@
|
|||||||
import { Asset } from './Asset.js';
|
import { Asset } from './Asset.js';
|
||||||
import { ScoringEngine } from '../engine/ScoringEngine.js';
|
|
||||||
|
|
||||||
export class Bond extends Asset {
|
export class Bond extends Asset {
|
||||||
constructor(data) {
|
constructor(data) {
|
||||||
super(data);
|
super(data);
|
||||||
this.yieldToMaturity = data.yieldToMaturity ?? 0;
|
|
||||||
this.duration = data.duration ?? 0;
|
// Store metrics in a flat object for the ScoringEngine
|
||||||
this.creditRating = data.creditRating ?? 'N/A';
|
this.metrics = {
|
||||||
|
ytm: parseFloat(data.yieldToMaturity) || 0,
|
||||||
|
duration: parseFloat(data.duration) || 0,
|
||||||
|
creditRating: data.creditRating || 'N/A',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
evaluate(marketContext) {
|
// Helper for dashboard display
|
||||||
// 1. Map data to the metrics the Engine expects
|
getDisplayMetrics() {
|
||||||
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 {
|
return {
|
||||||
Type: 'BOND',
|
|
||||||
Ticker: this.ticker,
|
Ticker: this.ticker,
|
||||||
|
Type: 'BOND',
|
||||||
Price: this.formatCurrency(this.currentPrice),
|
Price: this.formatCurrency(this.currentPrice),
|
||||||
Verdict: scoreResult.label,
|
'YTM%': `${this.metrics.ytm.toFixed(2)}%`,
|
||||||
'G/O/R': scoreResult.scoreSummary,
|
Duration: this.metrics.duration.toFixed(1),
|
||||||
'YTM%': `${this.yieldToMaturity.toFixed(2)}%`,
|
Rating: this.metrics.creditRating,
|
||||||
Duration: this.duration.toFixed(1),
|
|
||||||
Rating: this.creditRating,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-21
@@ -1,36 +1,29 @@
|
|||||||
import { Asset } from './Asset.js';
|
import { Asset } from './Asset.js';
|
||||||
import { ScoringEngine } from '../engine/ScoringEngine.js'; // Import your engine
|
|
||||||
|
|
||||||
export class Etf extends Asset {
|
export class Etf extends Asset {
|
||||||
constructor(data) {
|
constructor(data) {
|
||||||
super(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) {
|
// Store metrics in a flat object for the ScoringEngine
|
||||||
// 1. Prepare metrics object for the Engine
|
this.metrics = {
|
||||||
const metrics = {
|
expRatio: parseFloat(data.expenseRatio) || 0,
|
||||||
expRatio: this.expenseRatio,
|
totalAssets: parseFloat(data.totalAssets) || 0,
|
||||||
totalAssets: this.totalAssets,
|
yield: parseFloat(data.yield) || 0,
|
||||||
yield: this.yield,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 2. Delegate to Engine
|
// Keep performance metrics for display only
|
||||||
const scoreResult = ScoringEngine.evaluate('ETF', metrics, marketContext);
|
this.fiveYearReturn = parseFloat(data.fiveYearReturn) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Return formatted display data
|
// Helper for dashboard display
|
||||||
|
getDisplayMetrics() {
|
||||||
return {
|
return {
|
||||||
Type: 'ETF',
|
|
||||||
Ticker: this.ticker,
|
Ticker: this.ticker,
|
||||||
|
Type: 'ETF',
|
||||||
Price: this.formatCurrency(this.currentPrice),
|
Price: this.formatCurrency(this.currentPrice),
|
||||||
Verdict: scoreResult.label,
|
'Exp Ratio%': `${this.metrics.expRatio.toFixed(2)}%`,
|
||||||
'G/O/R': scoreResult.scoreSummary,
|
'Yield%': `${this.metrics.yield.toFixed(2)}%`,
|
||||||
'Exp Ratio%': `${this.expenseRatio.toFixed(2)}%`,
|
AUM: this.formatLargeNumber(this.metrics.totalAssets),
|
||||||
'Yield%': `${this.yield.toFixed(2)}%`,
|
|
||||||
AUM: this.formatLargeNumber(this.totalAssets),
|
|
||||||
'5Y Return%': `${this.fiveYearReturn.toFixed(1)}%`,
|
'5Y Return%': `${this.fiveYearReturn.toFixed(1)}%`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-58
@@ -1,80 +1,60 @@
|
|||||||
import { Asset } from './Asset.js';
|
import { Asset } from './Asset.js';
|
||||||
import { ScoringEngine } from '../engine/ScoringEngine.js';
|
|
||||||
|
|
||||||
export class Stock extends Asset {
|
export class Stock extends Asset {
|
||||||
constructor(data) {
|
constructor(data) {
|
||||||
super(data);
|
super(data);
|
||||||
this.summaryData = data.summaryData;
|
// console.log('Data:', data);
|
||||||
// Map industry detection to standard sector keys used in ScoringConfig
|
this.sector = this._mapToStandardSector(data || {});
|
||||||
this.sector = this._mapToStandardSector(data.summaryData);
|
|
||||||
|
|
||||||
// Financial Metrics
|
// Financial Metrics - These are now just "state"
|
||||||
this.quickRatio = data.quickRatio ?? null;
|
this.metrics = {
|
||||||
this.debtToEquity = data.debtToEquity ?? 0;
|
sector: this.sector,
|
||||||
this.fcfGrowth = data.fcfGrowth ?? 'neutral';
|
quickRatio: parseFloat(data.quickRatio) || 0,
|
||||||
this.revenueGrowth = data.revenueGrowth ?? 0;
|
debtToEquity: parseFloat(data.debtToEquity) || 0,
|
||||||
this.netProfitMargin = data.netProfitMargin ?? 0;
|
fcfGrowth: data.fcfGrowth ?? 'neutral',
|
||||||
this.pegRatio = data.pegRatio ?? null;
|
revenueGrowth: parseFloat(data.revenueGrowth) || 0,
|
||||||
this.peRatio = data.peRatio ?? null;
|
netProfitMargin: parseFloat(data.netProfitMargin) || 0,
|
||||||
|
pegRatio: parseFloat(data.pegRatio) || null,
|
||||||
|
peRatio: parseFloat(data.peRatio) || null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
_mapToStandardSector(summary = {}) {
|
_mapToStandardSector(data) {
|
||||||
const profile = summary.assetProfile || {};
|
// 1. Safely grab the profile from the data object
|
||||||
const industry = (profile.industry || '').toLowerCase();
|
const profile = data.assetProfile || {};
|
||||||
|
|
||||||
// Mapping logic to match your ScoringConfig.SECTOR_OVERRIDE keys
|
// 2. Extract values safely
|
||||||
if (
|
const industry = (profile.industry || '').toLowerCase();
|
||||||
industry.includes('software') ||
|
const sector = (profile.sector || '').toLowerCase();
|
||||||
industry.includes('tech') ||
|
const combined = `${industry} ${sector}`;
|
||||||
industry.includes('semiconductor')
|
|
||||||
)
|
// 3. Match logic
|
||||||
|
if (combined.includes('technology') || combined.includes('electronic'))
|
||||||
return 'TECHNOLOGY';
|
return 'TECHNOLOGY';
|
||||||
if (industry.includes('reit') || industry.includes('real estate'))
|
if (combined.includes('real estate') || combined.includes('reit'))
|
||||||
return 'REIT';
|
return 'REIT';
|
||||||
if (
|
if (combined.includes('financial') || combined.includes('bank'))
|
||||||
industry.includes('bank') ||
|
|
||||||
industry.includes('financial') ||
|
|
||||||
industry.includes('insurance')
|
|
||||||
)
|
|
||||||
return 'FINANCIAL';
|
return 'FINANCIAL';
|
||||||
|
|
||||||
return 'GENERAL'; // Maps to your base STOCK rules
|
return 'GENERAL';
|
||||||
}
|
}
|
||||||
|
|
||||||
evaluate(marketContext) {
|
// Helper for dashboard display
|
||||||
// 1. Prepare metrics + the detected sector
|
getDisplayMetrics() {
|
||||||
const metrics = {
|
const formatFcf = (s) =>
|
||||||
sector: this.sector, // Engine uses this to pull the correct override
|
({ positive: '🟢', neutral: '🟠', negative: '🔴' })[s] || 'N/A';
|
||||||
quickRatio: this.quickRatio,
|
|
||||||
debtToEquity: this.debtToEquity,
|
|
||||||
revenueGrowth: this.revenueGrowth,
|
|
||||||
netProfitMargin: this.netProfitMargin,
|
|
||||||
pegRatio: this.pegRatio,
|
|
||||||
fcfGrowth: this.fcfGrowth,
|
|
||||||
peRatio: this.peRatio,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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 {
|
return {
|
||||||
Ticker: this.ticker,
|
Ticker: this.ticker,
|
||||||
Type: 'STOCK',
|
|
||||||
Price: this.formatCurrency(this.currentPrice),
|
Price: this.formatCurrency(this.currentPrice),
|
||||||
Verdict: scoreResult.label,
|
Sector: this.sector,
|
||||||
'G/O/R': scoreResult.scoreSummary,
|
'PE Ratio': this.metrics.peRatio?.toFixed(2) ?? 'N/A',
|
||||||
Sector: this.sector, // Added for visibility
|
'FCF%': formatFcf(this.metrics.fcfGrowth),
|
||||||
'PE Ratio': this.peRatio?.toFixed(2) ?? 'N/A',
|
'PEG/Fee': this.metrics.pegRatio?.toFixed(2) ?? 'N/A',
|
||||||
'FCF%': formatFcf(this.fcfGrowth),
|
'Rev%': `${this.metrics.revenueGrowth.toFixed(1)}%`,
|
||||||
'PEG/Fee': this.pegRatio?.toFixed(2) ?? 'N/A',
|
'Marg%': `${this.metrics.netProfitMargin.toFixed(1)}%`,
|
||||||
'Rev%': `${this.revenueGrowth.toFixed(1)}%`,
|
Quick: this.metrics.quickRatio?.toFixed(2) ?? 'N/A',
|
||||||
'Marg%': `${this.netProfitMargin.toFixed(1)}%`,
|
'D/E': this.metrics.debtToEquity.toFixed(2),
|
||||||
Quick: this.quickRatio?.toFixed(2) ?? 'N/A',
|
|
||||||
'D/E': this.debtToEquity.toFixed(2),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,14 +17,33 @@ export const ScoringEngine = {
|
|||||||
* @param {Object} context - Optional market context (for bonds)
|
* @param {Object} context - Optional market context (for bonds)
|
||||||
*/
|
*/
|
||||||
// In ScoringEngine.js
|
// In ScoringEngine.js
|
||||||
evaluate(type, data, context = {}) {
|
evaluate(type, assetInstance, marketContext = {}) {
|
||||||
const scorer = this._scorers[type];
|
const scorer = this._scorers[type];
|
||||||
const rules = ScoringRules[type]; // This returns ScoringRules.STOCK
|
|
||||||
|
|
||||||
if (!scorer) throw new Error(`No scorer found: ${type}`);
|
// 1. Get the metrics (this assumes assetInstance has the metrics object)
|
||||||
if (!rules) throw new Error(`No configuration rules found: ${type}`);
|
const metrics = assetInstance.metrics;
|
||||||
|
|
||||||
// IMPORTANT: Ensure you pass the specific rules set
|
// 2. MERGE: Get sector-specific, merged rules
|
||||||
return scorer.score(data, rules, context);
|
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;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { Etf } from '../assets/Etf.js';
|
|||||||
import { Bond } from '../assets/Bond.js';
|
import { Bond } from '../assets/Bond.js';
|
||||||
import { chunkArray } from '../../utils/Chunker.js';
|
import { chunkArray } from '../../utils/Chunker.js';
|
||||||
import { BenchmarkProvider } from '../../api/BenchmarkProvider.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 {
|
export class ScreenerEngine {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -41,45 +45,50 @@ export class ScreenerEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async runParallelScreener(tickerList) {
|
async runParallelScreener(tickerList) {
|
||||||
// 1. Fetch market context (the "Ground Truth" for your scoring)
|
|
||||||
const marketContext = await this.benchmarkProvider.getMarketContext();
|
const marketContext = await this.benchmarkProvider.getMarketContext();
|
||||||
console.log(
|
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);
|
const chunks = chunkArray(tickerList, 5);
|
||||||
// Use a flexible results object that populates dynamically
|
|
||||||
const results = {};
|
const results = {};
|
||||||
|
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
console.log(`🚀 Processing batch: ${chunk.join(', ')}`);
|
|
||||||
|
|
||||||
const rawDataBatch = await Promise.all(
|
const rawDataBatch = await Promise.all(
|
||||||
chunk.map((t) => this._fetchAndProcess(t)),
|
chunk.map((t) => this._fetchAndProcess(t)),
|
||||||
);
|
);
|
||||||
|
|
||||||
rawDataBatch.forEach((data) => {
|
rawDataBatch.forEach((data) => {
|
||||||
// Handle failed fetches
|
|
||||||
if (data.isError) {
|
if (data.isError) {
|
||||||
if (!results['ERROR']) results['ERROR'] = [];
|
if (!results['ERROR']) results['ERROR'] = [];
|
||||||
results['ERROR'].push(data);
|
results['ERROR'].push(data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Instantiate specific asset (Stock, ETF, or Bond)
|
// 1. Instantiate the lean Data Container (Stock, Etf, or Bond)
|
||||||
const asset = this._createAssetInstance(data);
|
const asset = this._createAssetInstance(data);
|
||||||
|
const type = asset.type;
|
||||||
|
|
||||||
// 2. PASS the marketContext to the evaluation logic
|
// 2. Merge rules (Utility handles sector overrides)
|
||||||
// Ensure your Asset classes accept this in their evaluate() method
|
const rules = RuleMerger.getRulesForAsset(type, asset.metrics);
|
||||||
const evaluated = asset.evaluate(marketContext);
|
|
||||||
|
|
||||||
// 3. Dynamically collect results by Category (STOCK, ETF, BOND)
|
// 3. Direct Scoring (Bypassing the old circular evaluate() call)
|
||||||
const category = (evaluated.Type || 'UNKNOWN').toUpperCase();
|
const scorer = scorers[type];
|
||||||
if (!results[category]) results[category] = [];
|
const scoreResult = scorer.score(asset.metrics, rules, marketContext);
|
||||||
results[category].push(evaluated);
|
|
||||||
|
// 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));
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,49 +96,27 @@ export class ScreenerEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_display(results) {
|
_display(results) {
|
||||||
const formatters = {
|
Object.keys(results).forEach((type) => {
|
||||||
STOCK: (data) =>
|
if (type === 'ERROR') {
|
||||||
data.map((item) => ({
|
console.log('--- ERRORS ---', results.ERROR);
|
||||||
Ticker: item.Ticker,
|
return;
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ export const mapToStandardFormat = (ticker, summary) => {
|
|||||||
...mapEtfData(summary),
|
...mapEtfData(summary),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default to STOCK (covers 'EQUITY' or missing types)
|
// Default to STOCK (covers 'EQUITY' or missing types)
|
||||||
return {
|
return {
|
||||||
type: 'STOCK',
|
type: 'STOCK',
|
||||||
@@ -38,7 +37,9 @@ const mapStockData = (summary) => ({
|
|||||||
revenueGrowth: (summary.financialData?.revenueGrowth ?? 0) * 100,
|
revenueGrowth: (summary.financialData?.revenueGrowth ?? 0) * 100,
|
||||||
netProfitMargin: (summary.financialData?.profitMargins ?? 0) * 100,
|
netProfitMargin: (summary.financialData?.profitMargins ?? 0) * 100,
|
||||||
pegRatio: summary.defaultKeyStatistics?.pegRatio ?? 0,
|
pegRatio: summary.defaultKeyStatistics?.pegRatio ?? 0,
|
||||||
|
peRatio: summary.defaultKeyStatistics?.forwardPE ?? 0,
|
||||||
currentPrice: summary.price?.regularMarketPrice ?? 0,
|
currentPrice: summary.price?.regularMarketPrice ?? 0,
|
||||||
|
assetProfile: summary.assetProfile || {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapEtfData = (summary) => ({
|
const mapEtfData = (summary) => ({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user