initial commit
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
// src/core/Asset.js
|
||||
export class Asset {
|
||||
constructor(data) {
|
||||
this.ticker = (data.ticker || 'UNKNOWN').toUpperCase();
|
||||
this.currentPrice = data.currentPrice || 0;
|
||||
this.type = data.type || 'stock';
|
||||
}
|
||||
|
||||
// Helper: Format currency safely
|
||||
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 >= 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() {
|
||||
throw new Error("Method 'evaluate()' must be implemented by subclass.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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';
|
||||
|
||||
export class ScreenerEngine {
|
||||
constructor() {
|
||||
this.client = new YahooClient();
|
||||
}
|
||||
|
||||
_createAssetInstance(data) {
|
||||
const type = (data.type || 'STOCK').toUpperCase();
|
||||
switch (type) {
|
||||
case 'BOND':
|
||||
return new Bond(data);
|
||||
case 'ETF':
|
||||
return new Etf(data);
|
||||
default:
|
||||
return new Stock(data);
|
||||
}
|
||||
}
|
||||
|
||||
async _fetchAndProcess(ticker) {
|
||||
try {
|
||||
const summary = await this.client.fetchSummary(ticker);
|
||||
if (!summary?.price) throw new Error('Invalid Payload');
|
||||
return mapToStandardFormat(ticker, summary);
|
||||
} catch (error) {
|
||||
// Return a structured error object that mimics the successful data format
|
||||
return {
|
||||
isError: true,
|
||||
Ticker: ticker.toUpperCase(),
|
||||
Type: 'STOCK',
|
||||
Verdict: `🔴 ${error.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async runParallelScreener(tickerList) {
|
||||
const chunks = chunkArray(tickerList, 5);
|
||||
const results = { STOCK: [], ETF: [], BOND: [] };
|
||||
|
||||
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) => {
|
||||
if (data.isError) {
|
||||
results.STOCK.push(data);
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
this._display(results);
|
||||
}
|
||||
|
||||
_display(results) {
|
||||
console.log('\n--- EQUITY MATRIX ---\n');
|
||||
console.table(results.STOCK);
|
||||
console.log('\n--- ETF MATRIX ---\n');
|
||||
console.table(results.ETF);
|
||||
console.log('\n--- BOND MATRIX ---\n');
|
||||
console.table(results.BOND);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Asset } from './Asset.js';
|
||||
|
||||
export class Stock extends Asset {
|
||||
constructor(data) {
|
||||
super(data);
|
||||
this.summaryData = data.summaryData;
|
||||
this.industry = data.industry || this._detectIndustryType(data.summaryData);
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
_detectIndustryType(summary = {}) {
|
||||
const profile = summary.assetProfile || {};
|
||||
const industry = (profile.industry || '').toLowerCase();
|
||||
const grossMargin = (summary.financialData?.grossMargins ?? 0) * 100;
|
||||
const marketCap = summary.price?.marketCap || 0;
|
||||
|
||||
if (
|
||||
grossMargin > 70 ||
|
||||
industry.includes('software') ||
|
||||
industry.includes('cloud')
|
||||
)
|
||||
return 'SaaS';
|
||||
if (marketCap > 100_000_000_000) return 'Mega-Cap';
|
||||
if (['telecom', 'utility', 'railroad'].some((i) => industry.includes(i)))
|
||||
return 'Capital-Heavy';
|
||||
|
||||
return 'General';
|
||||
}
|
||||
|
||||
// Extracted scoring rules for cleaner 'evaluate' method
|
||||
_scoreMetric(value, thresholds, isGreen, isOrange) {
|
||||
if (value === null || value === undefined) return 0; // Neutral
|
||||
if (isGreen(value)) return 1; // Green
|
||||
if (isOrange(value)) return -1; // Orange
|
||||
return -2; // Red
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
return {
|
||||
Ticker: this.ticker,
|
||||
Type: 'STOCK',
|
||||
Price: this.formatCurrency(this.currentPrice),
|
||||
'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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user