Segmentational Analysis
This commit is contained in:
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
+13
-22
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+14
-21
@@ -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)}%`,
|
||||
};
|
||||
}
|
||||
|
||||
+38
-58
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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