benchmarks
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { ScreenerEngine } from './src/core/ScreenerEngine.js';
|
import { ScreenerEngine } from './src/core/engine/ScreenerEngine.js';
|
||||||
|
|
||||||
const tickers = [
|
const tickers = [
|
||||||
'PLTR',
|
'PLTR',
|
||||||
|
|||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
### Request: Optimize Investment Strategy Configuration
|
||||||
|
|
||||||
|
I am updating my investment strategy configuration. You are acting as a Senior Quantitative Financial Strategist. Please analyze my current market thesis and update the configuration parameters to align with this view.
|
||||||
|
|
||||||
|
**Market Thesis:** [INSERT YOUR THESIS HERE]
|
||||||
|
|
||||||
|
### Reasoning Phase (Before the JSON)
|
||||||
|
|
||||||
|
1. Briefly summarize your logic for the changes (e.g., "Raising the `maxDebtToEquity` gate because high-interest environments make capital-intensive businesses riskier").
|
||||||
|
2. Ensure all values are mathematically sound and consistent with the requested thesis.
|
||||||
|
|
||||||
|
### JSON Output Requirements
|
||||||
|
|
||||||
|
- Return a valid JSON object matching the schema below.
|
||||||
|
- Ensure all numbers are appropriate for the asset class (e.g., Debt/Equity usually 0-5, P/E usually 0-100).
|
||||||
|
- **Crucial:** Provide _only_ the JSON inside a single code block. No conversational text after the code block.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"STOCK": {
|
||||||
|
"gates": {
|
||||||
|
"maxDebtToEquity": 0.0,
|
||||||
|
"minQuickRatio": 0.0,
|
||||||
|
"maxPERatio": 0.0
|
||||||
|
},
|
||||||
|
"weights": { "margin": 0, "peg": 0, "revenue": 0, "fcf": 0 },
|
||||||
|
"thresholds": {
|
||||||
|
"marginHigh": 0,
|
||||||
|
"marginMed": 0,
|
||||||
|
"pegHigh": 0,
|
||||||
|
"pegMed": 0,
|
||||||
|
"revHigh": 0,
|
||||||
|
"revMed": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ETF": {
|
||||||
|
"gates": { "maxExpenseRatio": 0.0 },
|
||||||
|
"weights": { "yield": 0, "lowCost": 0 },
|
||||||
|
"thresholds": { "minYield": 0.0, "maxExpense": 0.0 }
|
||||||
|
},
|
||||||
|
"BOND": {
|
||||||
|
"gates": { "minCreditRating": 0 },
|
||||||
|
"weights": { "yieldSpread": 0, "duration": 0 },
|
||||||
|
"thresholds": { "minSpread": 0.0, "maxDuration": 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { YahooClient } from './YahooClient.js';
|
||||||
|
|
||||||
|
export class BenchmarkProvider {
|
||||||
|
constructor() {
|
||||||
|
this.client = new YahooClient();
|
||||||
|
this.cache = { data: null, expiresAt: 0 };
|
||||||
|
this.TTL_MS = 60 * 60 * 1000; // Cache for 1 hour
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMarketContext() {
|
||||||
|
// 1. Return cached data if still valid
|
||||||
|
if (this.cache.data && Date.now() < this.cache.expiresAt) {
|
||||||
|
return this.cache.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [sp500, tn10y] = await Promise.all([
|
||||||
|
this.client.fetchSummary('^GSPC'),
|
||||||
|
this.client.fetchSummary('^TNX'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
sp500Price: sp500.price?.regularMarketPrice ?? 0,
|
||||||
|
riskFreeRate: tn10y.price?.regularMarketPrice ?? 0,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Validate data sanity (prevent 0-value errors)
|
||||||
|
if (context.sp500Price === 0 || context.riskFreeRate === 0) {
|
||||||
|
throw new Error('Invalid market data received (zero values)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Update cache
|
||||||
|
this.cache = { data: context, expiresAt: Date.now() + this.TTL_MS };
|
||||||
|
return context;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
'Market data fetch failed, using last known or empty state:',
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
// If we have stale cache, use it even if expired, otherwise return safe defaults
|
||||||
|
return this.cache.data || { sp500Price: 4500, riskFreeRate: 4.0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
// src/api/YahooClient.js
|
|
||||||
import YahooFinance from 'yahoo-finance2';
|
import YahooFinance from 'yahoo-finance2';
|
||||||
|
|
||||||
export class YahooClient {
|
export class YahooClient {
|
||||||
constructor() {
|
constructor() {
|
||||||
// Instantiate the client as required by v3
|
// Instantiate the client as required by v3
|
||||||
this.yf = new YahooFinance();
|
this.yf = new YahooFinance({
|
||||||
|
suppressNotices: ['yahooSurvey'],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchSummary(ticker, retries = 3, backoff = 1000) {
|
async fetchSummary(ticker, retries = 3, backoff = 1000) {
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export const ScoringRules = {
|
||||||
|
STOCK: {
|
||||||
|
gates: { maxDebtToEquity: 3.0, minQuickRatio: 0.25, maxPERatio: 80 },
|
||||||
|
weights: { margin: 3, peg: 2, revenue: 2, fcf: 1 },
|
||||||
|
thresholds: {
|
||||||
|
marginHigh: 20,
|
||||||
|
marginMed: 10,
|
||||||
|
pegHigh: 1.3,
|
||||||
|
pegMed: 2.0,
|
||||||
|
revHigh: 15,
|
||||||
|
revMed: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ETF: {
|
||||||
|
gates: { maxExpenseRatio: 0.75 }, // Reject if too expensive
|
||||||
|
weights: { yield: 2, lowCost: 3 },
|
||||||
|
thresholds: { minYield: 0.02, maxExpense: 0.2 },
|
||||||
|
},
|
||||||
|
BOND: {
|
||||||
|
gates: { minCreditRating: 5 }, // e.g., 5 = Investment Grade
|
||||||
|
weights: { yieldSpread: 3, duration: 2 },
|
||||||
|
thresholds: { minSpread: 1.5, maxDuration: 10 },
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
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',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
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',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
// src/core/Asset.js
|
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';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: Format currency safely
|
// Helper: Format currency safely
|
||||||
@@ -25,7 +25,7 @@ export class Asset {
|
|||||||
return num.toString();
|
return num.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
evaluate() {
|
evaluate(context) {
|
||||||
throw new Error("Method 'evaluate()' must be implemented by subclass.");
|
throw new Error('Evaluate method must be implemented by subclass');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
return {
|
||||||
|
Type: 'BOND',
|
||||||
|
Ticker: this.ticker,
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Delegate to Engine
|
||||||
|
const scoreResult = ScoringEngine.evaluate('ETF', metrics, marketContext);
|
||||||
|
|
||||||
|
// 3. Return formatted display data
|
||||||
|
return {
|
||||||
|
Type: 'ETF',
|
||||||
|
Ticker: this.ticker,
|
||||||
|
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),
|
||||||
|
'5Y Return%': `${this.fiveYearReturn.toFixed(1)}%`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
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) {
|
||||||
@@ -13,6 +14,7 @@ export class Stock extends Asset {
|
|||||||
this.revenueGrowth = data.revenueGrowth ?? 0;
|
this.revenueGrowth = data.revenueGrowth ?? 0;
|
||||||
this.netProfitMargin = data.netProfitMargin ?? 0;
|
this.netProfitMargin = data.netProfitMargin ?? 0;
|
||||||
this.pegRatio = data.pegRatio ?? null;
|
this.pegRatio = data.pegRatio ?? null;
|
||||||
|
this.peRatio = data.peRatio ?? null; // Ensure this is included
|
||||||
}
|
}
|
||||||
|
|
||||||
_detectIndustryType(summary = {}) {
|
_detectIndustryType(summary = {}) {
|
||||||
@@ -43,61 +45,43 @@ export class Stock extends Asset {
|
|||||||
}
|
}
|
||||||
|
|
||||||
evaluate() {
|
evaluate() {
|
||||||
let green = 0,
|
// 1. Prepare the metrics object
|
||||||
orange = 0,
|
const metrics = {
|
||||||
red = 0;
|
industry: this.industry,
|
||||||
|
quickRatio: this.quickRatio,
|
||||||
const metrics = [
|
debtToEquity: this.debtToEquity,
|
||||||
{
|
revenueGrowth: this.revenueGrowth,
|
||||||
val: this.quickRatio,
|
netProfitMargin: this.netProfitMargin,
|
||||||
green: (v) =>
|
pegRatio: this.pegRatio,
|
||||||
v > 1.0 ||
|
fcfGrowth: this.fcfGrowth,
|
||||||
((this.industry === 'SaaS' || this.industry === 'Mega-Cap') &&
|
peRatio: this.peRatio, // Ensure this is mapped
|
||||||
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);
|
|
||||||
|
|
||||||
|
// 2. Delegate to the Engine
|
||||||
|
// We pass 'this.context' if you have it stored, or null
|
||||||
|
const scoreResult = ScoringEngine.evaluate('STOCK', metrics, this.context);
|
||||||
|
const formatFcf = (fcfStatus) => {
|
||||||
|
const map = {
|
||||||
|
positive: '🟢',
|
||||||
|
neutral: '🟠',
|
||||||
|
negative: '🔴',
|
||||||
|
};
|
||||||
|
return map[fcfStatus] || 'N/A';
|
||||||
|
};
|
||||||
|
// 3. Return the formatted object
|
||||||
return {
|
return {
|
||||||
Ticker: this.ticker,
|
Ticker: this.ticker,
|
||||||
Type: 'STOCK',
|
Type: 'STOCK',
|
||||||
Price: this.formatCurrency(this.currentPrice),
|
Price: this.formatCurrency(this.currentPrice),
|
||||||
|
Verdict: scoreResult.label, // This is your official Engine verdict
|
||||||
|
'G/O/R': scoreResult.scoreSummary, // This is your official Engine summary
|
||||||
|
'PE Ratio': this.peRatio?.toFixed(2) ?? 'N/A',
|
||||||
|
'FCF%': formatFcf(this.fcfGrowth),
|
||||||
'PEG/Fee': this.pegRatio?.toFixed(2) ?? 'N/A',
|
'PEG/Fee': this.pegRatio?.toFixed(2) ?? 'N/A',
|
||||||
'Rev%': `${this.revenueGrowth.toFixed(1)}%`,
|
'Rev%': `${this.revenueGrowth.toFixed(1)}%`,
|
||||||
'Marg%': `${this.netProfitMargin.toFixed(1)}%`,
|
'Marg%': `${this.netProfitMargin.toFixed(1)}%`,
|
||||||
Quick: this.quickRatio?.toFixed(2) ?? 'N/A',
|
Quick: this.quickRatio?.toFixed(2) ?? 'N/A',
|
||||||
'D/E': this.debtToEquity.toFixed(2),
|
'D/E': this.debtToEquity.toFixed(2),
|
||||||
'G/O/R': `${green}/${orange}/${red}`,
|
|
||||||
Verdict: verdict,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { StockScorer } from '../scorers/StockScorer.js';
|
||||||
|
import { EtfScorer } from '../scorers/EtfScorer.js';
|
||||||
|
import { BondScorer } from '../scorers/BondScorer.js';
|
||||||
|
|
||||||
|
export const ScoringEngine = {
|
||||||
|
// Registry of available strategies
|
||||||
|
_scorers: {
|
||||||
|
STOCK: StockScorer,
|
||||||
|
ETF: EtfScorer,
|
||||||
|
BOND: BondScorer,
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} type - 'STOCK', 'ETF', or 'BOND'
|
||||||
|
* @param {Object} data - The raw metric data
|
||||||
|
* @param {Object} context - Optional market context (for bonds)
|
||||||
|
*/
|
||||||
|
evaluate(type, data, context = {}) {
|
||||||
|
const scorer = this._scorers[type];
|
||||||
|
|
||||||
|
if (!scorer) {
|
||||||
|
throw new Error(`No scorer found for asset type: ${type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every scorer now shares the exact same signature: .score()
|
||||||
|
return scorer.score(data, context);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
import { YahooClient } from '../api/YahooClient.js';
|
import { YahooClient } from '../../api/YahooClient.js';
|
||||||
import { mapToStandardFormat } from '../utils/DataMapper.js';
|
import { mapToStandardFormat } from '../../utils/DataMapper.js';
|
||||||
import { Stock } from './Stock.js';
|
import { Stock } from '../assets/Stock.js';
|
||||||
import { Etf } from './Etf.js';
|
import { Etf } from '../assets/Etf.js';
|
||||||
import { Bond } from './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';
|
||||||
|
|
||||||
export class ScreenerEngine {
|
export class ScreenerEngine {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.client = new YahooClient();
|
this.client = new YahooClient();
|
||||||
|
this.benchmarkProvider = new BenchmarkProvider();
|
||||||
}
|
}
|
||||||
|
|
||||||
_createAssetInstance(data) {
|
_createAssetInstance(data) {
|
||||||
@@ -39,38 +41,45 @@ 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();
|
||||||
|
console.log(
|
||||||
|
`📊 Market Context Loaded: S&P500 at ${marketContext.sp500Price}, 10Y Yield at ${marketContext.riskFreeRate}%`,
|
||||||
|
);
|
||||||
|
|
||||||
const chunks = chunkArray(tickerList, 5);
|
const chunks = chunkArray(tickerList, 5);
|
||||||
const results = { STOCK: [], ETF: [], BOND: [] };
|
// Use a flexible results object that populates dynamically
|
||||||
|
const results = {};
|
||||||
|
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
console.log(`🚀 Processing batch: ${chunk.join(', ')}`);
|
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) {
|
||||||
results.STOCK.push(data);
|
if (!results['ERROR']) results['ERROR'] = [];
|
||||||
|
results['ERROR'].push(data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Instantiate specific asset (Stock, ETF, or Bond)
|
||||||
const asset = this._createAssetInstance(data);
|
const asset = this._createAssetInstance(data);
|
||||||
const evaluated = asset.evaluate();
|
|
||||||
|
|
||||||
// --- THE FIX ---
|
// 2. PASS the marketContext to the evaluation logic
|
||||||
// If the evaluated.Type doesn't match a bucket,
|
// Ensure your Asset classes accept this in their evaluate() method
|
||||||
// this console.warn will tell us exactly what key is missing.
|
const evaluated = asset.evaluate(marketContext);
|
||||||
const category = (evaluated.Type || data.type || 'STOCK').toUpperCase();
|
|
||||||
|
|
||||||
if (results[category]) {
|
// 3. Dynamically collect results by Category (STOCK, ETF, BOND)
|
||||||
|
const category = (evaluated.Type || 'UNKNOWN').toUpperCase();
|
||||||
|
if (!results[category]) results[category] = [];
|
||||||
results[category].push(evaluated);
|
results[category].push(evaluated);
|
||||||
} else {
|
|
||||||
console.warn(
|
|
||||||
`WARNING: Data dropped! Ticker ${data.ticker} has Type "${category}" which doesn't match results keys.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Throttling to respect API rate limits
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { ScoringRules } from '../../config/ScoringConfig.js';
|
||||||
|
|
||||||
|
export const BondScorer = {
|
||||||
|
/**
|
||||||
|
* @param {Object} m - Metrics (ytm, duration, creditRating)
|
||||||
|
* @param {Object} context - Market environment (riskFreeRate)
|
||||||
|
*/
|
||||||
|
score(m, context) {
|
||||||
|
const { gates, weights, thresholds } = ScoringRules.BOND;
|
||||||
|
const metrics = this._sanitize(m);
|
||||||
|
|
||||||
|
// Safety check for riskFreeRate (ensure it's a decimal, e.g., 0.04)
|
||||||
|
const riskFreeRate = context?.riskFreeRate ?? 0.04;
|
||||||
|
const spread = metrics.ytm - riskFreeRate;
|
||||||
|
|
||||||
|
let score = 0;
|
||||||
|
const breakdown = {};
|
||||||
|
|
||||||
|
// 1. Spread Logic: If spread is >= 0, it's at least neutral
|
||||||
|
breakdown.spread =
|
||||||
|
spread >= thresholds.minSpread ? weights.yieldSpread : -2;
|
||||||
|
|
||||||
|
// 2. Duration Logic
|
||||||
|
breakdown.duration =
|
||||||
|
metrics.duration <= thresholds.maxDuration ? weights.duration : -1;
|
||||||
|
|
||||||
|
// 3. Credit Rating Logic (Handling 'N/A')
|
||||||
|
if (metrics.creditRating === 'Junk') {
|
||||||
|
score -= 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
score = Object.values(breakdown).reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: this._getLabel(score),
|
||||||
|
scoreSummary: `Score: ${score}`,
|
||||||
|
audit: { breakdown },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
_sanitize(m) {
|
||||||
|
// Convert percentage string '3.95%' to decimal 0.0395
|
||||||
|
const parsePercent = (val) => {
|
||||||
|
if (typeof val === 'string') val = val.replace('%', '');
|
||||||
|
return parseFloat(val) / 100 || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
ytm: parsePercent(m.ytm),
|
||||||
|
duration: parseFloat(m.duration) || 0,
|
||||||
|
creditRating:
|
||||||
|
m.creditRating === 'N/A' ? 'InvestmentGrade' : m.creditRating, // Treat N/A as safe
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
_getLabel(score) {
|
||||||
|
if (score >= 4) return '🟢 Attractive';
|
||||||
|
if (score >= 1) return '🟡 Neutral';
|
||||||
|
return '🔴 Avoid';
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { ScoringRules } from '../../config/ScoringConfig.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EtfScorer: Evaluates ETFs with mandatory fee gates and weighted scoring.
|
||||||
|
*/
|
||||||
|
export const EtfScorer = {
|
||||||
|
score(m) {
|
||||||
|
const { gates, weights, thresholds } = ScoringRules.ETF;
|
||||||
|
const metrics = this._sanitize(m);
|
||||||
|
|
||||||
|
// 1. GATE KEEPING: High fees trigger an automatic reject
|
||||||
|
if (metrics.expenseRatio > gates.maxExpenseRatio) {
|
||||||
|
return {
|
||||||
|
label: '🔴 REJECT',
|
||||||
|
scoreSummary: 'GATE FAILED: High Expense Ratio',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. SCORING REGISTRY
|
||||||
|
const scoringRegistry = [
|
||||||
|
{
|
||||||
|
key: 'cost',
|
||||||
|
fn: () =>
|
||||||
|
metrics.expenseRatio <= thresholds.maxExpense ? weights.lowCost : -3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'yield',
|
||||||
|
fn: () => (metrics.yield >= thresholds.minYield ? weights.yield : -1),
|
||||||
|
},
|
||||||
|
{ key: 'vol', fn: () => (metrics.volume >= 100000 ? 0 : -2) },
|
||||||
|
];
|
||||||
|
|
||||||
|
const breakdown = {};
|
||||||
|
const totalScore = scoringRegistry.reduce((sum, item) => {
|
||||||
|
breakdown[item.key] = item.fn();
|
||||||
|
return sum + breakdown[item.key];
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// 3. RESULT
|
||||||
|
return {
|
||||||
|
label: this._getLabel(totalScore),
|
||||||
|
scoreSummary: `Score: ${totalScore}`,
|
||||||
|
audit: { passedGates: true, breakdown },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
_sanitize(m) {
|
||||||
|
return {
|
||||||
|
expenseRatio: parseFloat(m.expenseRatio) || 0,
|
||||||
|
yield: parseFloat(m.yield) || 0,
|
||||||
|
volume: parseFloat(m.volume) || 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
_getLabel(score) {
|
||||||
|
if (score >= 3) return '🟢 Efficient';
|
||||||
|
if (score >= 0) return '🟡 Neutral';
|
||||||
|
return '🔴 Expensive/Low Yield';
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { ScoringRules } from '../../config/ScoringConfig.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StockScorer: Evaluates stock fundamentals using a gatekeeper and weighted scoring registry.
|
||||||
|
*/
|
||||||
|
export const StockScorer = {
|
||||||
|
score(m) {
|
||||||
|
const { gates, weights, thresholds } = ScoringRules.STOCK;
|
||||||
|
const metrics = this._sanitize(m);
|
||||||
|
|
||||||
|
// 1. GATEKEEPING
|
||||||
|
const gateResult = this._checkGates(metrics, gates);
|
||||||
|
if (!gateResult.passed) {
|
||||||
|
return { label: '🔴 REJECT', scoreSummary: gateResult.reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. SCORING REGISTRY
|
||||||
|
// Add new metrics here to automatically include them in the score and audit
|
||||||
|
const scoringRegistry = [
|
||||||
|
{
|
||||||
|
key: 'margin',
|
||||||
|
fn: () =>
|
||||||
|
this._scoreMargin(metrics.netProfitMargin, thresholds, weights),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'peg',
|
||||||
|
fn: () => this._scorePeg(metrics.pegRatio, thresholds, weights),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'rev',
|
||||||
|
fn: () =>
|
||||||
|
this._scoreRevenue(metrics.revenueGrowth, thresholds, weights),
|
||||||
|
},
|
||||||
|
{ key: 'fcf', fn: () => this._scoreFcf(metrics.fcfGrowth) },
|
||||||
|
];
|
||||||
|
|
||||||
|
const breakdown = {};
|
||||||
|
const totalScore = scoringRegistry.reduce((sum, item) => {
|
||||||
|
breakdown[item.key] = item.fn();
|
||||||
|
return sum + breakdown[item.key];
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// 3. FINAL VERDICT
|
||||||
|
return {
|
||||||
|
label: this._getLabel(totalScore),
|
||||||
|
scoreSummary: `Score: ${totalScore}`,
|
||||||
|
analystRating: m.analystConsensus || 'N/A', // Add this
|
||||||
|
audit: { passedGates: true, breakdown },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
_sanitize(m) {
|
||||||
|
// Helper to handle 'N/A' or nulls
|
||||||
|
const parseMetric = (val, defaultValue) => {
|
||||||
|
if (val === 'N/A' || val === null || val === undefined)
|
||||||
|
return defaultValue;
|
||||||
|
const parsed = parseFloat(val);
|
||||||
|
return isNaN(parsed) ? defaultValue : parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
debtToEquity: parseMetric(m.debtToEquity, 0),
|
||||||
|
quickRatio: parseMetric(m.quickRatio, 0),
|
||||||
|
// If P/E is N/A, we shouldn't treat it as 999 (expensive).
|
||||||
|
// We should treat it as 'Neutral' or 'Missing' for the gate.
|
||||||
|
peRatio: parseMetric(m.peRatio, 0),
|
||||||
|
netProfitMargin: parseMetric(m.netProfitMargin, 0),
|
||||||
|
pegRatio: parseMetric(m.pegRatio, 999),
|
||||||
|
revenueGrowth: parseMetric(m.revenueGrowth, 0),
|
||||||
|
fcfGrowth: m.fcfGrowth ?? 'neutral',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
_checkGates(m, g) {
|
||||||
|
const failures = [];
|
||||||
|
if (m.debtToEquity > g.maxDebtToEquity) failures.push('Debt/Equity');
|
||||||
|
if (m.quickRatio < g.minQuickRatio) failures.push('QuickRatio');
|
||||||
|
if (m.peRatio > g.maxPERatio) failures.push('PERatio');
|
||||||
|
if (m.pegRatio > 5) failures.push('High Valuation (PEG > 5)');
|
||||||
|
if (m.netProfitMargin < 2) failures.push('Low Profit Margin');
|
||||||
|
|
||||||
|
return {
|
||||||
|
passed: failures.length === 0,
|
||||||
|
reason: `GATE FAILED: ${failures.join(', ')}`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// Scoring Methods
|
||||||
|
_scoreMargin: (val, t, w) =>
|
||||||
|
val >= t.marginHigh ? w.margin : val >= t.marginMed ? 1 : -2,
|
||||||
|
_scorePeg: (val, t, w) =>
|
||||||
|
val > 0 && val <= t.pegHigh ? w.peg : val <= t.pegMed ? 0 : -2,
|
||||||
|
_scoreRevenue: (val, t, w) =>
|
||||||
|
val >= t.revHigh ? w.revenue : val >= t.revMed ? 0 : -1,
|
||||||
|
_scoreFcf: (val) => (val === 'positive' ? 2 : val === 'negative' ? -2 : 0),
|
||||||
|
|
||||||
|
_getLabel(score) {
|
||||||
|
if (score >= 5) return '🟢 BUY (High Conviction)';
|
||||||
|
if (score >= 2) return '🟢 BUY (Speculative)';
|
||||||
|
if (score < -2) return '🔴 REJECT';
|
||||||
|
return '🟡 HOLD';
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
// src/utils/DataMapper.js
|
|
||||||
|
|
||||||
export const mapToStandardFormat = (ticker, summary) => {
|
export const mapToStandardFormat = (ticker, summary) => {
|
||||||
const quoteType = summary.price?.quoteType;
|
const quoteType = summary.price?.quoteType;
|
||||||
const category = (summary.assetProfile?.category || '').toLowerCase();
|
const category = (summary.assetProfile?.category || '').toLowerCase();
|
||||||
|
|||||||
Reference in New Issue
Block a user