Segmentational Analysis

This commit is contained in:
Saki
2026-06-02 04:58:07 -04:00
committed by saikiranvella
parent 341c816e61
commit 225b88ea4f
9 changed files with 237 additions and 179 deletions
+58
View File
@@ -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_
+6 -14
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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),
}; };
} }
} }
+25 -6
View File
@@ -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;
}, },
}; };
+44 -57
View File
@@ -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);
}); });
} }
} }
+2 -1
View File
@@ -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) => ({
+37
View File
@@ -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;
},
};