initial commit

This commit is contained in:
Kazuma
2026-06-02 00:34:42 -04:00
commit 74e6797dcc
14 changed files with 1939 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
// src/api/YahooClient.js
import YahooFinance from 'yahoo-finance2';
export class YahooClient {
constructor() {
// Instantiate the client as required by v3
this.yf = new YahooFinance();
}
async fetchSummary(ticker, retries = 3, backoff = 1000) {
for (let i = 0; i < retries; i++) {
try {
// Use the instance (this.yf) instead of the static import
return await this.yf.quoteSummary(ticker, {
modules: [
'assetProfile',
'financialData',
'defaultKeyStatistics',
'price',
'summaryDetail',
],
});
} catch (error) {
if (i === retries - 1) throw error;
await new Promise((res) => setTimeout(res, backoff * (i + 1)));
}
}
}
}
+31
View File
@@ -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.");
}
}
+35
View File
@@ -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',
};
}
}
+57
View File
@@ -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',
};
}
}
+88
View File
@@ -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);
}
}
+103
View File
@@ -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,
};
}
}
+7
View File
@@ -0,0 +1,7 @@
export const chunkArray = (array, size) => {
const result = [];
for (let i = 0; i < array.length; i += size) {
result.push(array.slice(i, i + size));
}
return result;
};
+59
View File
@@ -0,0 +1,59 @@
// src/utils/DataMapper.js
export const mapToStandardFormat = (ticker, summary) => {
const quoteType = summary.price?.quoteType;
const category = (summary.assetProfile?.category || '').toLowerCase();
const yieldVal = summary.summaryDetail?.trailingAnnualDividendYield ?? 0;
// Logic to determine type
const isBond =
category.includes('bond') ||
category.includes('fixed income') ||
category.includes('treasury') ||
(quoteType === 'ETF' && yieldVal > 0.02 && category === ''); // Heuristic fallback
if (quoteType === 'ETF') {
return isBond
? {
type: 'BOND',
ticker,
...mapBondData(summary),
}
: {
type: 'ETF',
ticker,
...mapEtfData(summary),
};
}
// Default to STOCK (covers 'EQUITY' or missing types)
return {
type: 'STOCK',
ticker,
...mapStockData(summary),
};
};
const mapStockData = (summary) => ({
quickRatio: summary.financialData?.quickRatio ?? 0,
debtToEquity: (summary.financialData?.debtToEquity ?? 0) / 100,
fcfGrowth:
(summary.financialData?.freeCashflow ?? 0) > 0 ? 'positive' : 'negative',
revenueGrowth: (summary.financialData?.revenueGrowth ?? 0) * 100,
netProfitMargin: (summary.financialData?.profitMargins ?? 0) * 100,
pegRatio: summary.defaultKeyStatistics?.pegRatio ?? 0,
currentPrice: summary.price?.regularMarketPrice ?? 0,
});
const mapEtfData = (summary) => ({
expenseRatio: (summary.summaryDetail?.expenseRatio ?? 0) * 100,
totalAssets: summary.summaryDetail?.totalAssets ?? 0,
yield: (summary.summaryDetail?.trailingAnnualDividendYield ?? 0) * 100,
fiveYearReturn: summary.defaultKeyStatistics?.fiveYearAverageReturn ?? 0,
currentPrice: summary.price?.regularMarketPrice ?? 0,
});
const mapBondData = (summary) => ({
yieldToMaturity: (summary.summaryDetail?.yield ?? 0) * 100,
duration: summary.defaultKeyStatistics?.fiveYearAverageReturn ?? 0,
creditRating: summary.assetProfile?.governanceEpochDate ? 'Rated' : 'N/A',
currentPrice: summary.price?.regularMarketPrice ?? 0,
});