phase-2: extract shared utils
This commit is contained in:
committed by
saikiranvella
parent
5a4b4aa6d1
commit
d5cf3fc31f
@@ -0,0 +1,53 @@
|
||||
import { YahooClient } from '../market/YahooClient.js';
|
||||
|
||||
const NEWS_QUERIES = ['stock market today', 'earnings report', 'market news'];
|
||||
const MAX_STORIES = 15;
|
||||
const TICKER_REGEX = /^[A-Z]{1,6}$/;
|
||||
|
||||
export class CatalystAnalyst {
|
||||
constructor({ logger } = {}) {
|
||||
this.client = new YahooClient();
|
||||
this.logger = logger ?? { write: (msg) => process.stdout.write(msg) };
|
||||
}
|
||||
|
||||
async run() {
|
||||
this.logger.write('🔍 Fetching market news...');
|
||||
const stories = await this._fetchNews();
|
||||
const tickers = this._extractTickers(stories);
|
||||
this.logger.write(` ${stories.length} stories, ${tickers.length} tickers\n`);
|
||||
return { tickers, stories };
|
||||
}
|
||||
|
||||
async _fetchNews() {
|
||||
const seen = new Map();
|
||||
for (const query of NEWS_QUERIES) {
|
||||
try {
|
||||
const { news = [] } = await this.client.yf.search(query, { newsCount: 8, quotesCount: 0 });
|
||||
for (const s of news) {
|
||||
if (!seen.has(s.title)) {
|
||||
seen.set(s.title, {
|
||||
title: s.title,
|
||||
publisher: s.publisher,
|
||||
link: s.link,
|
||||
relatedTickers: s.relatedTickers ?? [],
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* skip failed query */
|
||||
}
|
||||
}
|
||||
return [...seen.values()].slice(0, MAX_STORIES);
|
||||
}
|
||||
|
||||
_extractTickers(stories) {
|
||||
const tickers = new Set();
|
||||
for (const { relatedTickers } of stories) {
|
||||
for (const t of relatedTickers) {
|
||||
const clean = t.split(':')[0].toUpperCase();
|
||||
if (TICKER_REGEX.test(clean)) tickers.add(clean);
|
||||
}
|
||||
}
|
||||
return [...tickers];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
|
||||
// LLMAnalyst — uses Claude Haiku to analyze news catalyst stories.
|
||||
//
|
||||
// Given a list of news headlines and the tickers already identified,
|
||||
// it produces:
|
||||
// - A concise market summary (2-3 sentences)
|
||||
// - Industries likely to be affected (beyond the directly mentioned tickers)
|
||||
// - Up to 5 related tickers worth watching
|
||||
// - A risk sentiment assessment (BULLISH / NEUTRAL / BEARISH)
|
||||
//
|
||||
// Requires ANTHROPIC_API_KEY in environment.
|
||||
|
||||
const SYSTEM_PROMPT = `You are a professional equity analyst. You will be given a list of today's market news headlines and the tickers already identified as catalysts.
|
||||
|
||||
Your job is to:
|
||||
1. Write a 2-3 sentence market summary capturing the dominant theme
|
||||
2. Identify up to 4 industries that are likely to be secondarily affected (not directly mentioned but impacted by contagion, supply chain, regulation, or macro effects)
|
||||
3. Suggest up to 5 related ticker symbols worth screening that are NOT already in the provided list
|
||||
4. Assess overall market sentiment as BULLISH, NEUTRAL, or BEARISH based on the news
|
||||
|
||||
Return ONLY valid JSON in this exact shape — no markdown, no explanation:
|
||||
{
|
||||
"summary": "string",
|
||||
"sentiment": "BULLISH" | "NEUTRAL" | "BEARISH",
|
||||
"affectedIndustries": [
|
||||
{ "name": "string", "reason": "string (one sentence)" }
|
||||
],
|
||||
"relatedTickers": [
|
||||
{ "ticker": "string", "reason": "string (one sentence)" }
|
||||
]
|
||||
}`;
|
||||
|
||||
export class LLMAnalyst {
|
||||
constructor({ logger } = {}) {
|
||||
this.logger = logger ?? { log: console.log, warn: console.warn };
|
||||
this.client = process.env.ANTHROPIC_API_KEY
|
||||
? new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
|
||||
: null;
|
||||
}
|
||||
|
||||
// Analyzes news stories and returns structured market intelligence.
|
||||
// Returns null if ANTHROPIC_API_KEY is not set (graceful degradation).
|
||||
async analyze(stories, existingTickers = []) {
|
||||
if (!this.client) {
|
||||
this.logger.warn('LLMAnalyst: ANTHROPIC_API_KEY not set — skipping analysis');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!stories?.length) return null;
|
||||
|
||||
const headlines = stories
|
||||
.slice(0, 15)
|
||||
.map((s, i) => `${i + 1}. ${s.title} (${s.publisher ?? 'unknown'})`)
|
||||
.join('\n');
|
||||
|
||||
const userMessage = `Today's market news headlines:\n\n${headlines}\n\nAlready identified catalyst tickers: ${existingTickers.join(', ') || 'none'}`;
|
||||
|
||||
try {
|
||||
const response = await this.client.messages.create({
|
||||
model: 'claude-haiku-4-5',
|
||||
max_tokens: 1024,
|
||||
system: SYSTEM_PROMPT,
|
||||
messages: [{ role: 'user', content: userMessage }],
|
||||
});
|
||||
|
||||
const raw = response.content[0]?.text ?? '';
|
||||
const cleaned = raw
|
||||
.replace(/^```(?:json)?\s*/i, '')
|
||||
.replace(/```\s*$/i, '')
|
||||
.trim();
|
||||
return JSON.parse(cleaned);
|
||||
} catch (err) {
|
||||
this.logger.warn('LLMAnalyst: analysis failed —', err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
const STORE_PATH = './market-calls.json';
|
||||
|
||||
// MarketCallStore — persists quarterly market thesis entries to market-calls.json.
|
||||
//
|
||||
// A market call captures:
|
||||
// - A written thesis (the reasoning behind the call)
|
||||
// - Tickers to watch
|
||||
// - A snapshot of each ticker's price + signal at the time of the call
|
||||
// - Performance tracking (current vs snapshot price) computed on read
|
||||
//
|
||||
// Format:
|
||||
// {
|
||||
// "calls": [
|
||||
// {
|
||||
// "id": "uuid",
|
||||
// "title": "Q3 2025 — Rate pivot & tech rotation",
|
||||
// "quarter": "Q3 2025",
|
||||
// "date": "2025-07-01",
|
||||
// "thesis": "The Fed is expected to begin cutting...",
|
||||
// "tickers": ["AAPL", "MSFT", "TLT"],
|
||||
// "snapshot": {
|
||||
// "AAPL": { "price": 195.00, "signal": "✅ Strong Buy", "verdict": "BUY (High Conviction)" }
|
||||
// },
|
||||
// "createdAt": "2025-07-01T14:22:00.000Z"
|
||||
// }
|
||||
// ]
|
||||
// }
|
||||
|
||||
export class MarketCallStore {
|
||||
_load() {
|
||||
if (!existsSync(STORE_PATH)) return { calls: [] };
|
||||
try {
|
||||
return JSON.parse(readFileSync(STORE_PATH, 'utf8'));
|
||||
} catch {
|
||||
return { calls: [] };
|
||||
}
|
||||
}
|
||||
|
||||
_save(data) {
|
||||
writeFileSync(STORE_PATH, JSON.stringify(data, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
list() {
|
||||
return this._load().calls.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||
}
|
||||
|
||||
get(id) {
|
||||
return this._load().calls.find((c) => c.id === id) ?? null;
|
||||
}
|
||||
|
||||
// Create a new call. snapshot is an object keyed by ticker with { price, signal, verdict }.
|
||||
create({ title, quarter, date, thesis, tickers, snapshot }) {
|
||||
const data = this._load();
|
||||
const call = {
|
||||
id: randomUUID(),
|
||||
title,
|
||||
quarter,
|
||||
date: date ?? new Date().toISOString().slice(0, 10),
|
||||
thesis,
|
||||
tickers,
|
||||
snapshot: snapshot ?? {},
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
data.calls.push(call);
|
||||
this._save(data);
|
||||
return call;
|
||||
}
|
||||
|
||||
delete(id) {
|
||||
const data = this._load();
|
||||
const before = data.calls.length;
|
||||
data.calls = data.calls.filter((c) => c.id !== id);
|
||||
if (data.calls.length === before) return false;
|
||||
this._save(data);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
// Credit rating scale (S&P convention).
|
||||
// Bond.js converts letter ratings to these numbers; BondScorer uses them for gate checks.
|
||||
// Investment grade = BBB (7) and above.
|
||||
export const CREDIT_RATING_SCALE = {
|
||||
AAA: 10,
|
||||
AA: 9,
|
||||
A: 8,
|
||||
BBB: 7,
|
||||
BB: 6,
|
||||
B: 5,
|
||||
CCC: 4,
|
||||
CC: 3,
|
||||
C: 2,
|
||||
D: 1,
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Fundamental baseline — Graham / value-investing style.
|
||||
// MarketRegime.js overrides the valuation gates for INFLATED-mode analysis.
|
||||
// Sector overrides are structural — they apply in both modes.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export const ScoringRules = {
|
||||
STOCK: {
|
||||
gates: {
|
||||
maxDebtToEquity: 1.5, // Graham ceiling; 3.0 was too permissive — most distress starts above 2x
|
||||
minQuickRatio: 0.8, // Raised from 0.5: below 0.8 signals real liquidity stress in non-tech
|
||||
maxPERatio: 15, // Graham's actual rule: never pay more than 15x trailing earnings
|
||||
maxPegGate: 1.0, // PEG > 1.0 means you're paying full price for growth (Lynch standard)
|
||||
},
|
||||
weights: {
|
||||
margin: 2, // net profit margin
|
||||
opMargin: 2, // operating margin (pricing power)
|
||||
roe: 3, // return on equity — Buffett's primary quality metric
|
||||
peg: 2, // valuation relative to growth
|
||||
revenue: 2, // revenue growth
|
||||
fcf: 3, // raised: FCF is the most manipulation-resistant quality signal
|
||||
},
|
||||
thresholds: {
|
||||
marginHigh: 15, // lowered from 20: 15% net margin is genuinely excellent across most sectors
|
||||
marginMed: 8, // lowered from 10: 8% is the realistic mid-tier for industrials/retail
|
||||
opMarginHigh: 20,
|
||||
opMarginMed: 10,
|
||||
roeHigh: 15, // lowered from 20: sustainable 15% ROE is Buffett-quality; 20% is rare/fleeting
|
||||
roeMed: 10, // kept — 10% is the cost-of-equity floor for most businesses
|
||||
pegHigh: 0.75, // raised bar: PEG < 0.75 is genuinely cheap relative to growth
|
||||
pegMed: 1.0,
|
||||
revHigh: 10, // lowered from 15: 10% organic revenue growth is strong for mature cos
|
||||
revMed: 5,
|
||||
fcfHigh: 5,
|
||||
fcfMed: 2,
|
||||
},
|
||||
|
||||
SECTOR_OVERRIDE: {
|
||||
// Large-cap tech borrows to fund buybacks — D/E 2.0 is structural, not distress.
|
||||
// AAPL quick ratio runs ~0.9 by design (aggressive working capital management).
|
||||
// Raised maxPERatio from 30→35: mega-cap tech comps (MSFT, GOOG) trade 28-35x sustainably.
|
||||
// Tightened maxPegGate from 2.0→1.5: paying >1.5x PEG for tech rarely ends well long-term.
|
||||
TECHNOLOGY: {
|
||||
gates: { maxDebtToEquity: 2.0, minQuickRatio: 0.8, maxPERatio: 35, maxPegGate: 1.5 },
|
||||
weights: { margin: 1, opMargin: 3, roe: 3, peg: 3, revenue: 4, fcf: 3 },
|
||||
thresholds: { marginHigh: 25, opMarginHigh: 25, roeHigh: 20, pegHigh: 1.0, revHigh: 20 },
|
||||
},
|
||||
|
||||
// REITs: P/E and PEG are distorted by depreciation — score on yield and P/FFO.
|
||||
// Raised minYield from 4.0→4.5: 10Y yield at 4.5%+ means REITs must clear that bar to add value.
|
||||
// Tightened maxPFFO from 15→18: 15 was too tight; well-run REITs (O, VICI) trade 17-22x P/FFO.
|
||||
// Explicitly zero out weights that don't apply to REITs.
|
||||
REIT: {
|
||||
gates: { maxDebtToEquity: 6.0, minQuickRatio: 0.1, maxPERatio: 9999, maxPegGate: 9999 },
|
||||
weights: { margin: 0, opMargin: 0, roe: 0, peg: 0, revenue: 0, fcf: 0, yield: 5, pFFO: 3 },
|
||||
thresholds: { minYield: 4.5, maxPFFO: 20 },
|
||||
},
|
||||
|
||||
// Banks: P/E and PEG are distorted by loan loss provisions.
|
||||
// Price-to-Book is the primary valuation metric.
|
||||
// Lowered maxPriceToBook from 2.0→1.5: P/B > 1.5 for banks outside crisis recovery is expensive.
|
||||
// Tightened ROE threshold: 12% is the realistic cost-of-equity for US banks; 10% is break-even.
|
||||
FINANCIAL: {
|
||||
gates: {
|
||||
maxDebtToEquity: 9999,
|
||||
minQuickRatio: 0.1,
|
||||
maxPERatio: 9999,
|
||||
maxPegGate: 9999,
|
||||
maxPriceToBook: 1.5,
|
||||
},
|
||||
weights: { margin: 0, opMargin: 0, peg: 0, roe: 5, revenue: 1, fcf: 1, priceToBook: 3 },
|
||||
thresholds: { roeHigh: 15, roeMed: 12, revHigh: 10, revMed: 5 },
|
||||
},
|
||||
|
||||
// Energy: capital-heavy, cyclical. D/E up to 1.5 is normal.
|
||||
// FCF yield is the primary quality signal (replaces margin); opMargin matters for integrated cos.
|
||||
// Div yield is scored because energy majors return capital via dividends.
|
||||
ENERGY: {
|
||||
gates: { maxDebtToEquity: 1.5, minQuickRatio: 0.6, maxPERatio: 15, maxPegGate: 1.5 },
|
||||
weights: { margin: 0, opMargin: 3, roe: 2, peg: 1, revenue: 2, fcf: 4, yield: 3 },
|
||||
thresholds: {
|
||||
opMarginHigh: 20,
|
||||
opMarginMed: 10,
|
||||
roeHigh: 15,
|
||||
roeMed: 8,
|
||||
fcfHigh: 8,
|
||||
fcfMed: 4,
|
||||
},
|
||||
},
|
||||
|
||||
// Healthcare: high R&D burn distorts net margin; focus on revenue growth and FCF.
|
||||
// P/E can be elevated for pipeline names — gate loosened slightly.
|
||||
HEALTHCARE: {
|
||||
gates: { maxDebtToEquity: 1.5, minQuickRatio: 1.0, maxPERatio: 25, maxPegGate: 1.5 },
|
||||
weights: { margin: 1, opMargin: 2, roe: 2, peg: 2, revenue: 4, fcf: 3 },
|
||||
thresholds: {
|
||||
marginHigh: 20,
|
||||
marginMed: 10,
|
||||
roeHigh: 20,
|
||||
roeMed: 12,
|
||||
revHigh: 15,
|
||||
revMed: 8,
|
||||
fcfHigh: 8,
|
||||
fcfMed: 3,
|
||||
},
|
||||
},
|
||||
|
||||
// Communication Services: META, GOOGL, NFLX, DIS, T, VZ.
|
||||
// Mix of high-margin platform businesses and capital-heavy telcos/media.
|
||||
// P/E gate at 25: META and GOOGL sustainably trade 20-25x; below 15 is wrong for platforms.
|
||||
// High FCF weight: platform businesses are judged on FCF (ad revenue converts 35-40% to FCF).
|
||||
// Revenue growth matters more than for mature industrials — network effects are the moat.
|
||||
COMMUNICATION: {
|
||||
gates: { maxDebtToEquity: 2.0, minQuickRatio: 0.8, maxPERatio: 25, maxPegGate: 1.5 },
|
||||
weights: { margin: 2, opMargin: 3, roe: 2, peg: 2, revenue: 3, fcf: 4 },
|
||||
thresholds: {
|
||||
marginHigh: 25,
|
||||
marginMed: 12,
|
||||
opMarginHigh: 30,
|
||||
opMarginMed: 15,
|
||||
roeHigh: 20,
|
||||
roeMed: 12,
|
||||
pegHigh: 1.0,
|
||||
pegMed: 1.5,
|
||||
revHigh: 15,
|
||||
revMed: 5,
|
||||
fcfHigh: 8,
|
||||
fcfMed: 3,
|
||||
},
|
||||
},
|
||||
|
||||
// Consumer Staples: KO, PG, WMT, COST, KR. Slow-growth, recession-resistant.
|
||||
// Lower revenue growth expectations (2-5% is good for staples).
|
||||
// Higher margin thresholds — pricing power is the primary moat (not growth).
|
||||
// D/E tolerance is low — staples should be conservatively financed.
|
||||
CONSUMER_STAPLES: {
|
||||
gates: { maxDebtToEquity: 1.5, minQuickRatio: 0.5, maxPERatio: 22, maxPegGate: 2.0 },
|
||||
weights: { margin: 3, opMargin: 3, roe: 3, peg: 1, revenue: 1, fcf: 3 },
|
||||
thresholds: {
|
||||
marginHigh: 12,
|
||||
marginMed: 7,
|
||||
opMarginHigh: 18,
|
||||
opMarginMed: 10,
|
||||
roeHigh: 20,
|
||||
roeMed: 12,
|
||||
pegHigh: 1.5,
|
||||
pegMed: 2.0,
|
||||
revHigh: 5,
|
||||
revMed: 2,
|
||||
fcfHigh: 5,
|
||||
fcfMed: 2,
|
||||
},
|
||||
},
|
||||
|
||||
// Consumer Discretionary: AMZN, HD, MCD, NKE, TSLA. Cyclical, growth-oriented.
|
||||
// Revenue growth is the primary signal — discretionary spending expands with the economy.
|
||||
// Margins are thinner than staples (competitive markets); FCF matters for capital return.
|
||||
// P/E gate relaxed slightly — quality retailers trade at 20-30x on durable FCF.
|
||||
CONSUMER_DISCRETIONARY: {
|
||||
gates: { maxDebtToEquity: 2.0, minQuickRatio: 0.5, maxPERatio: 25, maxPegGate: 1.5 },
|
||||
weights: { margin: 2, opMargin: 2, roe: 2, peg: 2, revenue: 4, fcf: 3 },
|
||||
thresholds: {
|
||||
marginHigh: 10,
|
||||
marginMed: 5,
|
||||
opMarginHigh: 15,
|
||||
opMarginMed: 8,
|
||||
roeHigh: 20,
|
||||
roeMed: 12,
|
||||
pegHigh: 1.0,
|
||||
pegMed: 1.5,
|
||||
revHigh: 12,
|
||||
revMed: 5,
|
||||
fcfHigh: 5,
|
||||
fcfMed: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
ETF: {
|
||||
// Raised expense gate from 0.5→0.2: with so many sub-0.1% index ETFs available,
|
||||
// a 0.5% expense ratio is genuinely hard to justify except for niche/active strategies.
|
||||
gates: { maxExpenseRatio: 0.2 },
|
||||
weights: { yield: 2, lowCost: 4, fiveYearReturn: 2 }, // cost is #1 predictive factor; 5Y return rewards consistency
|
||||
thresholds: {
|
||||
minYield: 1.5,
|
||||
maxExpense: 0.05, // 0.05% is achievable for broad market ETFs
|
||||
minVolume: 1000000, // 1M ADV is the real liquidity floor to avoid slippage
|
||||
minFiveYearReturn: 8.0, // S&P 500 long-run real return ~7-10%; 8% filters underperformers
|
||||
},
|
||||
},
|
||||
|
||||
BOND: {
|
||||
// Kept investment-grade floor at BBB — still correct. Below BBB is speculative.
|
||||
// Raised minSpread from 1.0→1.5: with risk-free at 4.5%, you need >1.5% spread
|
||||
// to be compensated for credit risk vs just buying Treasuries.
|
||||
// Tightened maxDuration from 10→7: in a HIGH rate regime, duration > 7 carries
|
||||
// meaningful rate-sensitivity risk (every 1% rate rise ≈ 7% price loss).
|
||||
gates: { minCreditRating: 7 }, // BBB = investment-grade floor
|
||||
weights: { yieldSpread: 3, duration: 2 },
|
||||
thresholds: { minSpread: 1.5, maxDuration: 7 },
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
export const SIGNAL = {
|
||||
STRONG_BUY: '✅ Strong Buy',
|
||||
MOMENTUM: '⚡ Momentum',
|
||||
SPECULATION: '⚠️ Speculation',
|
||||
NEUTRAL: '🔄 Neutral',
|
||||
AVOID: '❌ Avoid',
|
||||
};
|
||||
|
||||
export const ASSET_TYPE = {
|
||||
STOCK: 'STOCK',
|
||||
ETF: 'ETF',
|
||||
BOND: 'BOND',
|
||||
CRYPTO: 'crypto',
|
||||
};
|
||||
|
||||
export const SECTOR = {
|
||||
TECHNOLOGY: 'TECHNOLOGY',
|
||||
REIT: 'REIT',
|
||||
FINANCIAL: 'FINANCIAL',
|
||||
ENERGY: 'ENERGY',
|
||||
HEALTHCARE: 'HEALTHCARE',
|
||||
COMMUNICATION: 'COMMUNICATION',
|
||||
CONSUMER_STAPLES: 'CONSUMER_STAPLES',
|
||||
CONSUMER_DISCRETIONARY: 'CONSUMER_DISCRETIONARY',
|
||||
GENERAL: 'GENERAL',
|
||||
};
|
||||
|
||||
export const SCORE_MODE = {
|
||||
FUNDAMENTAL: 'FUNDAMENTAL',
|
||||
INFLATED: 'INFLATED',
|
||||
};
|
||||
|
||||
export const REGIME = {
|
||||
LOW: 'LOW',
|
||||
NORMAL: 'NORMAL',
|
||||
HIGH: 'HIGH',
|
||||
};
|
||||
|
||||
export const YAHOO_MODULES = [
|
||||
'assetProfile',
|
||||
'financialData',
|
||||
'defaultKeyStatistics',
|
||||
'price',
|
||||
'summaryDetail',
|
||||
];
|
||||
|
||||
export const SIGNAL_ORDER = {
|
||||
[SIGNAL.STRONG_BUY]: 0,
|
||||
[SIGNAL.MOMENTUM]: 1,
|
||||
[SIGNAL.NEUTRAL]: 2,
|
||||
[SIGNAL.SPECULATION]: 3,
|
||||
[SIGNAL.AVOID]: 4,
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
// PersonalFinanceAnalyzer
|
||||
//
|
||||
// Takes normalised SimpleFIN account data and computes:
|
||||
// - Net worth (assets - liabilities)
|
||||
// - Cash vs investment allocation
|
||||
// - Spending by category (last 30 days)
|
||||
// - Top spending categories
|
||||
// - Income vs expenses summary
|
||||
|
||||
export class PersonalFinanceAnalyzer {
|
||||
analyse(accounts) {
|
||||
const assets = accounts.filter((a) => !['CREDIT', 'LOAN'].includes(a.type));
|
||||
const liabilities = accounts.filter((a) => ['CREDIT', 'LOAN'].includes(a.type));
|
||||
|
||||
const totalAssets = assets.reduce((s, a) => s + Math.max(0, a.balance), 0);
|
||||
const totalLiabilities = liabilities.reduce((s, a) => s + Math.abs(Math.min(0, a.balance)), 0);
|
||||
const netWorth = totalAssets - totalLiabilities;
|
||||
|
||||
const cash = accounts.filter((a) => ['CHECKING', 'SAVINGS'].includes(a.type));
|
||||
const investments = accounts.filter((a) => a.type === 'INVESTMENT');
|
||||
const totalCash = cash.reduce((s, a) => s + Math.max(0, a.balance), 0);
|
||||
const totalInvest = investments.reduce((s, a) => s + Math.max(0, a.balance), 0);
|
||||
|
||||
// Aggregate all transactions across accounts
|
||||
const allTx = accounts.flatMap((a) => a.transactions);
|
||||
|
||||
const spending = allTx.filter((tx) => tx.amount < 0 && tx.category !== 'Transfer');
|
||||
const income = allTx.filter((tx) => tx.amount > 0 && tx.category === 'Income');
|
||||
|
||||
const totalSpend = spending.reduce((s, tx) => s + Math.abs(tx.amount), 0);
|
||||
const totalIncome = income.reduce((s, tx) => s + tx.amount, 0);
|
||||
|
||||
// Spending by category
|
||||
const byCategory = {};
|
||||
for (const tx of spending) {
|
||||
byCategory[tx.category] = (byCategory[tx.category] ?? 0) + Math.abs(tx.amount);
|
||||
}
|
||||
const categoryBreakdown = Object.entries(byCategory)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([category, amount]) => ({
|
||||
category,
|
||||
amount,
|
||||
pct: totalSpend > 0 ? ((amount / totalSpend) * 100).toFixed(1) : '0',
|
||||
}));
|
||||
|
||||
return {
|
||||
netWorth,
|
||||
totalAssets,
|
||||
totalLiabilities,
|
||||
totalCash,
|
||||
totalInvestments: totalInvest,
|
||||
cashPct: totalAssets > 0 ? ((totalCash / totalAssets) * 100).toFixed(1) : '0',
|
||||
investPct: totalAssets > 0 ? ((totalInvest / totalAssets) * 100).toFixed(1) : '0',
|
||||
totalIncome,
|
||||
totalSpend,
|
||||
savingsRate:
|
||||
totalIncome > 0 ? (((totalIncome - totalSpend) / totalIncome) * 100).toFixed(1) : null,
|
||||
categoryBreakdown,
|
||||
accounts,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { SIGNAL } from '../config/constants.js';
|
||||
import { YahooClient } from '../market/YahooClient.js';
|
||||
|
||||
export class PortfolioAdvisor {
|
||||
constructor() {
|
||||
this.client = new YahooClient();
|
||||
}
|
||||
|
||||
async advise(holdings, screenedResults) {
|
||||
// Build result map keyed by both the Yahoo ticker (BRK-B) and the
|
||||
// dot-notation variant (BRK.B) so lookups work regardless of format.
|
||||
const resultMap = {};
|
||||
for (const r of [
|
||||
...(screenedResults.STOCK ?? []),
|
||||
...(screenedResults.ETF ?? []),
|
||||
...(screenedResults.BOND ?? []),
|
||||
]) {
|
||||
const t = r.asset.ticker;
|
||||
resultMap[t] = r;
|
||||
resultMap[t.replace(/-/g, '.')] = r; // BRK-B → BRK.B
|
||||
resultMap[t.replace(/\./g, '-')] = r; // BRK.B → BRK-B
|
||||
}
|
||||
|
||||
const cryptoPrices = await this._cryptoPrices(holdings.filter((h) => h.type === 'crypto'));
|
||||
|
||||
return holdings.map((holding) => {
|
||||
const type = (holding.type ?? 'stock').toLowerCase();
|
||||
const source = holding.source ?? '—';
|
||||
const price =
|
||||
type === 'crypto'
|
||||
? cryptoPrices[holding.ticker.toUpperCase()]
|
||||
: (resultMap[holding.ticker.toUpperCase()]?.asset?.currentPrice ?? null);
|
||||
|
||||
return type === 'crypto'
|
||||
? this._row(holding, price, source, '—', '—', '—', this._cryptoAdvice(holding, price))
|
||||
: this._stockRow(holding, price, source, resultMap[holding.ticker.toUpperCase()]);
|
||||
});
|
||||
}
|
||||
|
||||
_stockRow(holding, price, source, result) {
|
||||
if (!result) {
|
||||
return this._row(holding, price, source, '—', '—', '—', {
|
||||
action: '⚪ Not screened',
|
||||
reason: 'No screener data available — Yahoo Finance may not support this ticker.',
|
||||
});
|
||||
}
|
||||
return this._row(
|
||||
holding,
|
||||
price,
|
||||
source,
|
||||
result.signal,
|
||||
result.inflated.label,
|
||||
result.fundamental.label,
|
||||
this._advice(result.signal, holding, price),
|
||||
);
|
||||
}
|
||||
|
||||
_row(holding, currentPrice, source, signal, inflated, fundamental, { action, reason }) {
|
||||
const { marketValue, totalCost, gainLossPct } = this._position(holding, currentPrice);
|
||||
return {
|
||||
ticker: holding.ticker,
|
||||
type: holding.type ?? 'stock',
|
||||
source,
|
||||
shares: holding.shares,
|
||||
costBasis: holding.costBasis,
|
||||
currentPrice,
|
||||
marketValue,
|
||||
totalCost,
|
||||
gainLossPct,
|
||||
signal,
|
||||
inflated,
|
||||
fundamental,
|
||||
advice: action,
|
||||
reason,
|
||||
};
|
||||
}
|
||||
|
||||
_position(holding, currentPrice) {
|
||||
const totalCost = (holding.costBasis * holding.shares).toFixed(2);
|
||||
const marketValue = currentPrice != null ? (currentPrice * holding.shares).toFixed(2) : null;
|
||||
const gainLossPct =
|
||||
currentPrice != null && holding.costBasis > 0
|
||||
? (((currentPrice - holding.costBasis) / holding.costBasis) * 100).toFixed(1)
|
||||
: null;
|
||||
return { totalCost, marketValue, gainLossPct };
|
||||
}
|
||||
|
||||
_cryptoAdvice(holding, price) {
|
||||
const { gainLossPct } = this._position(holding, price);
|
||||
const g = parseFloat(gainLossPct);
|
||||
if (gainLossPct == null)
|
||||
return {
|
||||
action: '⚪ No price data',
|
||||
reason: 'Crypto — track price and manage risk manually.',
|
||||
};
|
||||
if (g > 100)
|
||||
return {
|
||||
action: '🟠 Consider taking profits',
|
||||
reason: 'Up significantly — no fundamental analysis for crypto.',
|
||||
};
|
||||
if (g < -30)
|
||||
return {
|
||||
action: '🔴 Review position',
|
||||
reason: 'Down significantly — no fundamental analysis for crypto.',
|
||||
};
|
||||
return {
|
||||
action: '🟡 Hold',
|
||||
reason: 'Crypto — no fundamental analysis. Track price and manage risk manually.',
|
||||
};
|
||||
}
|
||||
|
||||
_advice(signal, holding, price) {
|
||||
const { gainLossPct } = this._position(holding, price);
|
||||
const gain = parseFloat(gainLossPct);
|
||||
switch (signal) {
|
||||
case SIGNAL.STRONG_BUY:
|
||||
return {
|
||||
action: '🟢 Hold & Add',
|
||||
reason: 'Passes both analyses. Strong conviction.',
|
||||
};
|
||||
case SIGNAL.MOMENTUM:
|
||||
return {
|
||||
action: '🟡 Hold',
|
||||
reason:
|
||||
gain > 30
|
||||
? 'Up on momentum — consider partial profit-taking.'
|
||||
: 'Set a stop-loss — not fundamentally justified.',
|
||||
};
|
||||
case SIGNAL.SPECULATION:
|
||||
return {
|
||||
action: gain > 20 ? '🟠 Reduce Position' : '🟡 Hold (small size)',
|
||||
reason:
|
||||
gain > 20
|
||||
? 'In profit on speculation — take partial profits.'
|
||||
: 'Overvalued fundamentally. Keep position small.',
|
||||
};
|
||||
case SIGNAL.NEUTRAL:
|
||||
return {
|
||||
action: '🟡 Hold',
|
||||
reason: 'No clear edge. Review on any catalyst.',
|
||||
};
|
||||
case SIGNAL.AVOID:
|
||||
return {
|
||||
action: gain > 0 ? '🔴 Sell (Take Profits)' : '🔴 Sell (Cut Loss)',
|
||||
reason:
|
||||
gain > 0
|
||||
? "Fails both analyses — you're in profit, take it."
|
||||
: 'Fails both analyses — stop the loss from growing.',
|
||||
};
|
||||
default:
|
||||
return { action: '⚪ Review', reason: 'Signal unclear.' };
|
||||
}
|
||||
}
|
||||
|
||||
async _cryptoPrices(cryptoHoldings) {
|
||||
const prices = {};
|
||||
for (const h of cryptoHoldings) {
|
||||
try {
|
||||
const summary = await this.client.fetchSummary(h.ticker);
|
||||
prices[h.ticker.toUpperCase()] = summary.price?.regularMarketPrice ?? null;
|
||||
} catch {
|
||||
prices[h.ticker.toUpperCase()] = null;
|
||||
}
|
||||
}
|
||||
return prices;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import fs from 'fs';
|
||||
import https from 'https';
|
||||
import http from 'http';
|
||||
|
||||
// SimpleFIN auth flow:
|
||||
// 1. You get a Setup Token from https://beta-bridge.simplefin.org
|
||||
// 2. This client decodes it, POSTs once to claim an Access URL
|
||||
// 3. The CLI saves it to .env; a server would store it in its own secret store.
|
||||
// 4. All subsequent requests use the Access URL directly.
|
||||
//
|
||||
// .env configuration:
|
||||
// First run: SIMPLEFIN_SETUP_TOKEN=aHR0cHM6Ly8...
|
||||
// After that: SIMPLEFIN_ACCESS_URL=https://user:pass@beta-bridge.simplefin.org/simplefin
|
||||
|
||||
export class SimpleFINClient {
|
||||
// logger: object with .write() / .log() / .warn() — defaults to console.
|
||||
// onAccessUrlClaimed(url): optional callback so the caller can persist the URL
|
||||
// (CLI uses it to write .env; a server would store it elsewhere).
|
||||
constructor({ logger, onAccessUrlClaimed } = {}) {
|
||||
this.accessUrl = null;
|
||||
this.logger = logger ?? {
|
||||
write: (msg) => process.stdout.write(msg),
|
||||
log: (...args) => console.log(...args),
|
||||
warn: (...args) => console.warn(...args),
|
||||
};
|
||||
this.onAccessUrlClaimed = onAccessUrlClaimed ?? null;
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (process.env.SIMPLEFIN_ACCESS_URL) {
|
||||
this.accessUrl = process.env.SIMPLEFIN_ACCESS_URL.replace(/\/$/, '');
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.env.SIMPLEFIN_SETUP_TOKEN) {
|
||||
this.accessUrl = await this._claimAccessUrl(process.env.SIMPLEFIN_SETUP_TOKEN);
|
||||
if (this.onAccessUrlClaimed) {
|
||||
await this.onAccessUrlClaimed(this.accessUrl);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'SimpleFIN not configured.\n' +
|
||||
'Add to .env:\n' +
|
||||
' SIMPLEFIN_SETUP_TOKEN=<your setup token from https://beta-bridge.simplefin.org>\n' +
|
||||
'The Access URL will be saved automatically on first run.',
|
||||
);
|
||||
}
|
||||
|
||||
async getAccounts(options = {}) {
|
||||
if (!this.accessUrl) await this.init();
|
||||
|
||||
const startDate = options.startDate ?? this._daysAgo(30);
|
||||
const endDate = options.endDate ?? Math.floor(Date.now() / 1000);
|
||||
|
||||
// fetch() rejects URLs with embedded credentials (user:pass@host).
|
||||
// Extract them and send as a Basic Auth header instead.
|
||||
const parsed = new URL(this.accessUrl);
|
||||
const auth = parsed.username
|
||||
? 'Basic ' + Buffer.from(`${parsed.username}:${parsed.password}`).toString('base64')
|
||||
: null;
|
||||
parsed.username = '';
|
||||
parsed.password = '';
|
||||
const cleanBase = parsed.toString().replace(/\/$/, '');
|
||||
|
||||
const url = `${cleanBase}/accounts?version=2&start-date=${startDate}&end-date=${endDate}`;
|
||||
const response = await fetch(url, {
|
||||
headers: auth ? { Authorization: auth } : {},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`SimpleFIN error ${response.status}: ${await response.text()}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.errors?.length) {
|
||||
data.errors.forEach((e) => this.logger.warn(` ⚠ SimpleFIN: ${e}`));
|
||||
}
|
||||
|
||||
return this._normalise(data);
|
||||
}
|
||||
|
||||
// ── Auth ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async _claimAccessUrl(setupToken) {
|
||||
const claimUrl = Buffer.from(setupToken.trim(), 'base64').toString('utf8').trim();
|
||||
this.logger.write(`\n🔑 Claiming SimpleFIN access URL...\n → ${claimUrl}\n`);
|
||||
|
||||
const accessUrl = await this._post(claimUrl);
|
||||
|
||||
if (!accessUrl || !accessUrl.startsWith('http')) {
|
||||
throw new Error(
|
||||
`Unexpected response from SimpleFIN: "${accessUrl}"\n` +
|
||||
'Setup tokens are one-time use — if already claimed, generate a new one at https://beta-bridge.simplefin.org',
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.write('✅ Access URL received\n');
|
||||
return accessUrl.trim();
|
||||
}
|
||||
|
||||
_post(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const lib = parsed.protocol === 'https:' ? https : http;
|
||||
const options = {
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Length': '0', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
};
|
||||
|
||||
const req = lib.request(options, (res) => {
|
||||
let body = '';
|
||||
res.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve(body.trim());
|
||||
} else {
|
||||
reject(new Error(`HTTP ${res.statusCode}: ${body.trim()}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Normalise ────────────────────────────────────────────────────────────────
|
||||
|
||||
_normalise(data) {
|
||||
const accounts = (data.accounts ?? []).map((acc) => ({
|
||||
id: acc.id,
|
||||
name: acc.name,
|
||||
currency: acc.currency ?? 'USD',
|
||||
balance: parseFloat(acc.balance) ?? 0,
|
||||
balanceDate: new Date(acc['balance-date'] * 1000).toISOString().slice(0, 10),
|
||||
org: acc.org?.name ?? 'Unknown',
|
||||
type: this._classifyAccount(acc.name),
|
||||
transactions: (acc.transactions ?? []).map((tx) => ({
|
||||
id: tx.id,
|
||||
date: new Date(tx.posted * 1000).toISOString().slice(0, 10),
|
||||
amount: parseFloat(tx.amount) ?? 0,
|
||||
description: tx.description ?? '',
|
||||
category: this._categorise(tx.description ?? ''),
|
||||
})),
|
||||
}));
|
||||
|
||||
return { accounts, errors: data.errors ?? [] };
|
||||
}
|
||||
|
||||
_classifyAccount(name) {
|
||||
const n = name.toLowerCase();
|
||||
if (n.includes('checking') || n.includes('current')) return 'CHECKING';
|
||||
if (n.includes('saving')) return 'SAVINGS';
|
||||
if (n.includes('credit') || n.includes('card')) return 'CREDIT';
|
||||
if (n.includes('invest') || n.includes('brokerage') || n.includes('401k') || n.includes('ira'))
|
||||
return 'INVESTMENT';
|
||||
if (n.includes('loan') || n.includes('mortgage')) return 'LOAN';
|
||||
return 'OTHER';
|
||||
}
|
||||
|
||||
_categorise(description) {
|
||||
const d = description.toLowerCase();
|
||||
if (d.match(/amazon|walmart|target|costco|grocery|whole foods|trader joe/)) return 'Shopping';
|
||||
if (d.match(/uber eats|doordash|grubhub|postmates|instacart/)) return 'Delivery';
|
||||
if (d.match(/netflix|spotify|apple|disney|hulu|youtube/)) return 'Subscriptions';
|
||||
if (d.match(/restaurant|cafe|coffee|starbucks|chipotle|mcdonald/)) return 'Dining';
|
||||
if (d.match(/shell|chevron|bp|exxon|fuel|gas station/)) return 'Gas';
|
||||
if (d.match(/uber|lyft|transit|mta|bart|metro/)) return 'Transport';
|
||||
if (d.match(/rent|mortgage|hoa|property/)) return 'Housing';
|
||||
if (d.match(/electric|water|internet|phone|at&t|verizon|comcast/)) return 'Utilities';
|
||||
if (d.match(/payroll|salary|direct deposit/)) return 'Income';
|
||||
if (d.match(/transfer|zelle|venmo|paypal/)) return 'Transfer';
|
||||
return 'Other';
|
||||
}
|
||||
|
||||
_daysAgo(n) {
|
||||
return Math.floor((Date.now() - n * 24 * 60 * 60 * 1000) / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// CLI helper — saves the access URL to .env after the setup token is claimed.
|
||||
// Pass this as `onAccessUrlClaimed` when constructing SimpleFINClient in CLI context.
|
||||
export function saveAccessUrlToEnv(accessUrl) {
|
||||
try {
|
||||
const existing = fs.existsSync('.env') ? fs.readFileSync('.env', 'utf8') : '';
|
||||
if (!existing.includes('SIMPLEFIN_ACCESS_URL')) {
|
||||
fs.appendFileSync('.env', `\nSIMPLEFIN_ACCESS_URL=${accessUrl}\n`);
|
||||
console.log('✅ Access URL saved to .env — you can remove SIMPLEFIN_SETUP_TOKEN\n');
|
||||
}
|
||||
} catch {
|
||||
console.log(`\nSave this to .env:\nSIMPLEFIN_ACCESS_URL=${accessUrl}\n`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { YahooClient } from './YahooClient.js';
|
||||
import { REGIME } from '../config/constants.js';
|
||||
|
||||
const TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
const DEFAULTS = {
|
||||
sp500Price: 5000,
|
||||
riskFreeRate: 4.5,
|
||||
vixLevel: 20,
|
||||
rateRegime: REGIME.HIGH,
|
||||
volatilityRegime: REGIME.NORMAL,
|
||||
benchmarks: { marketPE: 22, techPE: 30, reitYield: 3.5, igSpread: 1.0 },
|
||||
};
|
||||
|
||||
const rateRegime = (rate) => (rate < 2 ? REGIME.LOW : rate <= 5 ? REGIME.NORMAL : REGIME.HIGH);
|
||||
const volRegime = (vix) => (vix < 15 ? REGIME.LOW : vix <= 25 ? REGIME.NORMAL : REGIME.HIGH);
|
||||
|
||||
const pe = (summary) =>
|
||||
summary.summaryDetail?.trailingPE ?? summary.defaultKeyStatistics?.forwardPE;
|
||||
|
||||
export class BenchmarkProvider {
|
||||
// logger: object with .warn() — defaults to console so CLI behaviour is unchanged.
|
||||
constructor({ logger = console } = {}) {
|
||||
this.client = new YahooClient();
|
||||
this.cache = { data: null, expiresAt: 0 };
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
async getMarketContext() {
|
||||
if (this.cache.data && Date.now() < this.cache.expiresAt) return this.cache.data;
|
||||
|
||||
try {
|
||||
const [sp500, tn10y, vix, spy, xlk, xlre, lqd] = await Promise.all([
|
||||
this.client.fetchSummary('^GSPC'),
|
||||
this.client.fetchSummary('^TNX'),
|
||||
this.client.fetchSummary('^VIX'),
|
||||
this.client.fetchSummary('SPY'),
|
||||
this.client.fetchSummary('XLK'),
|
||||
this.client.fetchSummary('XLRE'),
|
||||
this.client.fetchSummary('LQD'),
|
||||
]);
|
||||
|
||||
const riskFreeRate = tn10y.price?.regularMarketPrice ?? 0;
|
||||
const sp500Price = sp500.price?.regularMarketPrice ?? 0;
|
||||
const vixLevel = vix.price?.regularMarketPrice ?? 0;
|
||||
|
||||
if (!sp500Price || !riskFreeRate) throw new Error('Invalid market data (zero values)');
|
||||
|
||||
const lqdYield = (lqd.summaryDetail?.trailingAnnualDividendYield ?? 0) * 100;
|
||||
|
||||
const context = {
|
||||
sp500Price,
|
||||
riskFreeRate,
|
||||
vixLevel,
|
||||
rateRegime: rateRegime(riskFreeRate),
|
||||
volatilityRegime: volRegime(vixLevel),
|
||||
benchmarks: {
|
||||
marketPE: pe(spy) ?? 22,
|
||||
techPE: pe(xlk) ?? 30,
|
||||
reitYield: (xlre.summaryDetail?.trailingAnnualDividendYield ?? 0.035) * 100,
|
||||
igSpread: Math.max(0.1, lqdYield - riskFreeRate),
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.cache = { data: context, expiresAt: Date.now() + TTL_MS };
|
||||
return context;
|
||||
} catch (err) {
|
||||
this.logger.warn('Market data fetch failed, using defaults:', err.message);
|
||||
return this.cache.data ?? DEFAULTS;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { SECTOR, ASSET_TYPE, REGIME } from '../config/constants.js';
|
||||
|
||||
export class MarketRegime {
|
||||
constructor(marketContext) {
|
||||
const b = marketContext?.benchmarks ?? {};
|
||||
this.marketPE = b.marketPE ?? 22;
|
||||
this.techPE = b.techPE ?? 30;
|
||||
this.reitYield = b.reitYield ?? 3.5;
|
||||
this.igSpread = b.igSpread ?? 1.0;
|
||||
this.rateRegime = marketContext?.rateRegime ?? REGIME.NORMAL;
|
||||
this.volatilityRegime = marketContext?.volatilityRegime ?? REGIME.NORMAL;
|
||||
}
|
||||
|
||||
getInflatedOverrides(type, sector) {
|
||||
if (type === ASSET_TYPE.STOCK) return this._stock(sector);
|
||||
if (type === ASSET_TYPE.ETF) return this._etf();
|
||||
if (type === ASSET_TYPE.BOND) return this._bond();
|
||||
return { gates: {}, thresholds: {} };
|
||||
}
|
||||
|
||||
_stock(sector) {
|
||||
if (sector === SECTOR.REIT) {
|
||||
return {
|
||||
gates: {},
|
||||
// In HIGH rate environment tighten REIT yield floor — REITs must compete harder with bonds.
|
||||
thresholds: {
|
||||
minYield: +(this.reitYield * (this.rateRegime === REGIME.HIGH ? 0.95 : 0.85)).toFixed(2),
|
||||
maxPFFO: 20,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (sector === SECTOR.TECHNOLOGY) {
|
||||
return {
|
||||
gates: {
|
||||
maxPERatio: Math.round(this.techPE * 1.3),
|
||||
maxPegGate: +(this.techPE / 15).toFixed(1),
|
||||
},
|
||||
thresholds: {},
|
||||
};
|
||||
}
|
||||
// In HIGH rate environment, compress the P/E tolerance — higher rates mean
|
||||
// future earnings are discounted more aggressively (lower DCF valuations).
|
||||
const peMultiplier = this.rateRegime === REGIME.HIGH ? 1.2 : 1.5;
|
||||
return {
|
||||
gates: {
|
||||
maxPERatio: Math.round(this.marketPE * peMultiplier),
|
||||
maxPegGate: +(this.marketPE / 12).toFixed(1),
|
||||
},
|
||||
thresholds: {},
|
||||
};
|
||||
}
|
||||
|
||||
_etf() {
|
||||
return { gates: { maxExpenseRatio: 0.75 }, thresholds: { minYield: 0.5 } };
|
||||
}
|
||||
|
||||
_bond() {
|
||||
// In HIGH rate environment demand a wider spread — the opportunity cost of holding
|
||||
// corporate bonds over Treasuries is higher when risk-free rate is elevated.
|
||||
const spreadMultiplier = this.rateRegime === REGIME.HIGH ? 0.9 : 0.8;
|
||||
return { gates: {}, thresholds: { minSpread: +(this.igSpread * spreadMultiplier).toFixed(2) } };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import YahooFinance from 'yahoo-finance2';
|
||||
|
||||
export class YahooClient {
|
||||
constructor() {
|
||||
// Instantiate the client as required by v3
|
||||
this.yf = new YahooFinance({
|
||||
suppressNotices: ['yahooSurvey'],
|
||||
});
|
||||
}
|
||||
|
||||
async fetchSummary(ticker, retries = 3, backoff = 1000) {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetches upcoming earnings dates, ex-dividend date, and dividend date for a ticker.
|
||||
// Returns null on failure so callers can skip gracefully.
|
||||
async fetchCalendarEvents(ticker) {
|
||||
try {
|
||||
const r = await this.yf.quoteSummary(ticker, { modules: ['calendarEvents'] });
|
||||
return r.calendarEvents ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export class FinanceReporter {
|
||||
// Returns the HTML string — useful for server responses.
|
||||
render(advice, personalFinance, marketContext) {
|
||||
return this._build(advice, personalFinance, marketContext);
|
||||
}
|
||||
|
||||
// Writes to disk and returns the absolute path — used by the CLI.
|
||||
generate(advice, personalFinance, marketContext, outputPath = './finance-report.html') {
|
||||
const html = this._build(advice, personalFinance, marketContext);
|
||||
fs.writeFileSync(outputPath, html, 'utf8');
|
||||
return path.resolve(outputPath);
|
||||
}
|
||||
|
||||
_build(advice, pf, ctx) {
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Personal Finance — ${date}</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e2e8f0; font-size: 13px; }
|
||||
h1 { font-size: 20px; font-weight: 600; }
|
||||
h2 { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #94a3b8; margin-bottom: 12px; }
|
||||
.header { padding: 24px 32px 16px; border-bottom: 1px solid #1e293b; display: flex; align-items: center; gap: 16px; }
|
||||
.pill { background: #1e293b; border-radius: 6px; padding: 4px 12px; font-size: 12px; color: #94a3b8; margin-left: auto; }
|
||||
.pill span { color: #e2e8f0; font-weight: 600; margin-left: 4px; }
|
||||
.content { padding: 24px 32px; }
|
||||
.section { margin-bottom: 40px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; margin-bottom: 24px; }
|
||||
.card { background: #1e293b; border-radius: 8px; padding: 14px 16px; }
|
||||
.card-label { font-size: 11px; color: #64748b; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.card-value { font-size: 20px; font-weight: 700; color: #f1f5f9; margin-top: 4px; }
|
||||
.card-sub { font-size: 11px; color: #64748b; margin-top: 2px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
thead th { text-align: left; padding: 8px 12px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: #64748b; border-bottom: 1px solid #1e293b; white-space: nowrap; }
|
||||
tbody tr { border-bottom: 1px solid #1a2233; }
|
||||
tbody tr:hover { background: #1e293b; }
|
||||
tbody td { padding: 10px 12px; vertical-align: middle; }
|
||||
.ticker { font-weight: 700; font-size: 14px; color: #f1f5f9; }
|
||||
.green { color: #4ade80; }
|
||||
.yellow { color: #facc15; }
|
||||
.orange { color: #fb923c; }
|
||||
.red { color: #f87171; }
|
||||
.gray { color: #64748b; }
|
||||
.advice-green { color: #4ade80; font-weight: 600; }
|
||||
.advice-yellow { color: #facc15; font-weight: 600; }
|
||||
.advice-orange { color: #fb923c; font-weight: 600; }
|
||||
.advice-red { color: #f87171; font-weight: 600; }
|
||||
.reason { color: #94a3b8; font-size: 11px; }
|
||||
.bar-bg { background: #1e293b; border-radius: 4px; height: 8px; }
|
||||
.bar-fill { background: #3b82f6; border-radius: 4px; height: 8px; }
|
||||
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>💰 Personal Finance</h1>
|
||||
<div class="pill">Date <span>${date}</span></div>
|
||||
</div>
|
||||
<div class="content">
|
||||
|
||||
${pf ? this._netWorthSection(pf) : ''}
|
||||
|
||||
${this._portfolioSection(advice, ctx)}
|
||||
|
||||
${pf ? this._spendingSection(pf) : ''}
|
||||
|
||||
${pf ? this._accountsSection(pf) : ''}
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// ── Net worth ──────────────────────────────────────────────────────────────
|
||||
|
||||
_netWorthSection(pf) {
|
||||
const f = (n) =>
|
||||
new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(n);
|
||||
return `
|
||||
<div class="section">
|
||||
<h2>Net Worth</h2>
|
||||
<div class="grid">
|
||||
${this._card('Net Worth', f(pf.netWorth), pf.netWorth >= 0 ? 'green' : 'red')}
|
||||
${this._card('Total Assets', f(pf.totalAssets))}
|
||||
${this._card('Liabilities', f(pf.totalLiabilities), 'red')}
|
||||
${this._card('Cash & Savings', `${f(pf.totalCash)}`, null, `${pf.cashPct}% of assets`)}
|
||||
${this._card('Investments', `${f(pf.totalInvestments)}`, null, `${pf.investPct}% of assets`)}
|
||||
${pf.savingsRate != null ? this._card('Savings Rate', `${pf.savingsRate}%`, parseFloat(pf.savingsRate) > 20 ? 'green' : 'yellow') : ''}
|
||||
${this._card('Monthly Income', f(pf.totalIncome))}
|
||||
${this._card('Monthly Spend', f(pf.totalSpend))}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Portfolio with hold/sell advice ───────────────────────────────────────
|
||||
|
||||
_portfolioSection(advice, ctx) {
|
||||
const f = (n) =>
|
||||
n != null
|
||||
? new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(n)
|
||||
: '—';
|
||||
const f2 = (n) =>
|
||||
new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(n);
|
||||
const b = ctx?.benchmarks ?? {};
|
||||
|
||||
const stocks = advice.filter((a) => a.type !== 'crypto');
|
||||
const crypto = advice.filter((a) => a.type === 'crypto');
|
||||
|
||||
const totalValue = advice.reduce((s, a) => s + (parseFloat(a.marketValue) || 0), 0);
|
||||
const totalCost = advice.reduce((s, a) => s + (a.costBasis ?? 0) * a.shares, 0);
|
||||
const totalGL = totalValue - totalCost;
|
||||
const totalGLPct = totalCost > 0 ? ((totalGL / totalCost) * 100).toFixed(1) : null;
|
||||
|
||||
const sourceColors = {
|
||||
Robinhood: '#22c55e',
|
||||
Vanguard: '#3b82f6',
|
||||
Fidelity: '#f59e0b',
|
||||
Coinbase: '#8b5cf6',
|
||||
};
|
||||
const sourcePill = (s) => {
|
||||
const color = sourceColors[s] ?? '#64748b';
|
||||
return `<span style="background:${color}22;color:${color};padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600">${s}</span>`;
|
||||
};
|
||||
|
||||
const stockRows = stocks
|
||||
.map((a) => {
|
||||
const glClass = parseFloat(a.gainLossPct) >= 0 ? 'green' : 'red';
|
||||
const advClass = this._adviceClass(a.advice);
|
||||
return `<tr>
|
||||
<td class="ticker">${a.ticker}</td>
|
||||
<td>${sourcePill(a.source)}</td>
|
||||
<td><span style="background:#1e293b;padding:2px 8px;border-radius:4px;font-size:11px">${a.type}</span></td>
|
||||
<td>${a.shares}</td>
|
||||
<td>${f(a.costBasis)}</td>
|
||||
<td>${f(parseFloat(a.currentPrice))}</td>
|
||||
<td>${f(parseFloat(a.marketValue))}</td>
|
||||
<td class="${glClass}">${a.gainLossPct != null ? a.gainLossPct + '%' : '—'}</td>
|
||||
<td class="gray" style="font-size:11px">${a.signal ?? '—'}</td>
|
||||
<td class="${advClass}">${a.advice}</td>
|
||||
<td class="reason">${a.reason}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
const cryptoRows = crypto
|
||||
.map((a) => {
|
||||
const glClass = parseFloat(a.gainLossPct) >= 0 ? 'green' : 'red';
|
||||
const advClass = this._adviceClass(a.advice);
|
||||
return `<tr>
|
||||
<td class="ticker">${a.ticker}</td>
|
||||
<td>${sourcePill(a.source)}</td>
|
||||
<td>${a.shares}</td>
|
||||
<td>${f(a.costBasis)}</td>
|
||||
<td>${f(parseFloat(a.currentPrice))}</td>
|
||||
<td>${f(parseFloat(a.marketValue))}</td>
|
||||
<td class="${glClass}">${a.gainLossPct != null ? a.gainLossPct + '%' : '—'}</td>
|
||||
<td class="${advClass}">${a.advice}</td>
|
||||
<td class="reason">${a.reason}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
return `
|
||||
<div class="section">
|
||||
<h2>Portfolio — Hold / Sell / Add Advice</h2>
|
||||
<div class="grid" style="margin-bottom:16px">
|
||||
${this._card('Total Value', f2(totalValue))}
|
||||
${this._card('Total Cost', f2(totalCost))}
|
||||
${this._card('Total G/L', f2(totalGL), totalGL >= 0 ? 'green' : 'red', totalGLPct != null ? totalGLPct + '%' : '')}
|
||||
${this._card('S&P 500 P/E', (b.marketPE?.toFixed(1) ?? '—') + 'x', null, 'Live benchmark')}
|
||||
</div>
|
||||
|
||||
${
|
||||
stocks.length > 0
|
||||
? `
|
||||
<h2 style="margin-bottom:10px">Stocks & ETFs</h2>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Ticker</th><th>Source</th><th>Type</th><th>Shares</th>
|
||||
<th>Cost Basis</th><th>Current</th><th>Value</th>
|
||||
<th>G/L</th><th>Signal</th><th>Advice</th><th>Reason</th>
|
||||
</tr></thead>
|
||||
<tbody>${stockRows}</tbody>
|
||||
</table>`
|
||||
: ''
|
||||
}
|
||||
|
||||
${
|
||||
crypto.length > 0
|
||||
? `
|
||||
<h2 style="margin-top:24px;margin-bottom:10px">Crypto</h2>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Ticker</th><th>Source</th><th>Shares</th>
|
||||
<th>Cost Basis</th><th>Current</th><th>Value</th>
|
||||
<th>G/L</th><th>Advice</th><th>Note</th>
|
||||
</tr></thead>
|
||||
<tbody>${cryptoRows}</tbody>
|
||||
</table>`
|
||||
: ''
|
||||
}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Spending breakdown ─────────────────────────────────────────────────────
|
||||
|
||||
_spendingSection(pf) {
|
||||
const f = (n) =>
|
||||
new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(n);
|
||||
const rows = pf.categoryBreakdown
|
||||
.slice(0, 10)
|
||||
.map(
|
||||
(c) => `
|
||||
<tr>
|
||||
<td>${c.category}</td>
|
||||
<td style="text-align:right">${f(c.amount)}</td>
|
||||
<td style="text-align:right; color:#94a3b8">${c.pct}%</td>
|
||||
<td style="width:120px">
|
||||
<div class="bar-bg"><div class="bar-fill" style="width:${Math.min(c.pct, 100)}%"></div></div>
|
||||
</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
return `
|
||||
<div class="section">
|
||||
<h2>Spending by Category — Last 30 Days</h2>
|
||||
<table>
|
||||
<thead><tr><th>Category</th><th style="text-align:right">Amount</th><th style="text-align:right">Share</th><th></th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Accounts ───────────────────────────────────────────────────────────────
|
||||
|
||||
_accountsSection(pf) {
|
||||
const f = (n) =>
|
||||
new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(n);
|
||||
const rows = pf.accounts
|
||||
.map(
|
||||
(a) => `
|
||||
<tr>
|
||||
<td class="ticker">${a.name}</td>
|
||||
<td><span style="background:#1e293b;padding:2px 8px;border-radius:4px;font-size:11px">${a.type}</span></td>
|
||||
<td class="gray">${a.org}</td>
|
||||
<td style="text-align:right" class="${a.balance >= 0 ? 'green' : 'red'}">${f(a.balance)}</td>
|
||||
<td class="gray" style="text-align:right">${a.balanceDate}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
return `
|
||||
<div class="section">
|
||||
<h2>Accounts</h2>
|
||||
<table>
|
||||
<thead><tr><th>Account</th><th>Type</th><th>Institution</th><th style="text-align:right">Balance</th><th style="text-align:right">Updated</th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
_card(label, value, colorClass = null, sub = null) {
|
||||
return `<div class="card">
|
||||
<div class="card-label">${label}</div>
|
||||
<div class="card-value ${colorClass ? colorClass : ''}">${value}</div>
|
||||
${sub ? `<div class="card-sub">${sub}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_adviceClass(advice) {
|
||||
if (advice?.includes('🟢')) return 'advice-green';
|
||||
if (advice?.includes('🟡')) return 'advice-yellow';
|
||||
if (advice?.includes('🟠')) return 'advice-orange';
|
||||
if (advice?.includes('🔴')) return 'advice-red';
|
||||
return 'gray';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Generates a self-contained HTML report saved to ./screener-report.html
|
||||
// Console output shows only the signal summary — full breakdown lives here.
|
||||
|
||||
export class HtmlReporter {
|
||||
// Returns the HTML string — useful for server responses.
|
||||
render(results, marketContext, personalFinance = null) {
|
||||
return this._buildHtml(results, marketContext, personalFinance);
|
||||
}
|
||||
|
||||
// Writes to disk and returns the absolute path — used by the CLI.
|
||||
generate(results, marketContext, personalFinance = null, outputPath = './screener-report.html') {
|
||||
const html = this._buildHtml(results, marketContext, personalFinance);
|
||||
fs.writeFileSync(outputPath, html, 'utf8');
|
||||
return path.resolve(outputPath);
|
||||
}
|
||||
|
||||
// ── HTML builder ────────────────────────────────────────────────────────────
|
||||
|
||||
_buildHtml(results, ctx, pf = null) {
|
||||
const b = ctx.benchmarks ?? {};
|
||||
const all = [...results.STOCK, ...results.ETF, ...results.BOND];
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Market Screener — ${ctx.timestamp?.slice(0, 10) ?? ''}</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e2e8f0; font-size: 13px; }
|
||||
h1 { font-size: 20px; font-weight: 600; }
|
||||
h2 { font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #94a3b8; margin-bottom: 12px; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
|
||||
.header { padding: 24px 32px 16px; border-bottom: 1px solid #1e293b; display: flex; align-items: center; gap: 16px; }
|
||||
.header-meta { display: flex; gap: 24px; margin-left: auto; }
|
||||
.pill { background: #1e293b; border-radius: 6px; padding: 4px 12px; font-size: 12px; color: #94a3b8; }
|
||||
.pill span { color: #e2e8f0; font-weight: 600; margin-left: 4px; }
|
||||
|
||||
.content { padding: 24px 32px; }
|
||||
|
||||
.ctx-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; margin-bottom: 32px; }
|
||||
.ctx-card { background: #1e293b; border-radius: 8px; padding: 14px 16px; }
|
||||
.ctx-label { font-size: 11px; color: #64748b; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.ctx-value { font-size: 18px; font-weight: 700; color: #f1f5f9; margin-top: 4px; }
|
||||
|
||||
.section { margin-bottom: 40px; }
|
||||
.tabs { display: flex; gap: 0; border-bottom: 1px solid #1e293b; margin-bottom: 16px; }
|
||||
.tab { padding: 8px 20px; cursor: pointer; border-bottom: 2px solid transparent; font-size: 12px; font-weight: 600; color: #64748b; transition: color 0.15s; }
|
||||
.tab.active { color: #e2e8f0; border-bottom-color: #3b82f6; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
thead th { text-align: left; padding: 8px 12px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: #64748b; border-bottom: 1px solid #1e293b; white-space: nowrap; }
|
||||
tbody tr { border-bottom: 1px solid #1a2233; transition: background 0.1s; }
|
||||
tbody tr:hover { background: #1e293b; }
|
||||
tbody td { padding: 10px 12px; vertical-align: middle; white-space: nowrap; }
|
||||
|
||||
.ticker { font-weight: 700; font-size: 14px; color: #f1f5f9; }
|
||||
.price { color: #94a3b8; font-variant-numeric: tabular-nums; }
|
||||
.sector { font-size: 11px; color: #64748b; background: #1e293b; padding: 2px 8px; border-radius: 4px; }
|
||||
.score { font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.verdict-green { color: #4ade80; }
|
||||
.verdict-yellow { color: #facc15; }
|
||||
.verdict-red { color: #f87171; }
|
||||
|
||||
.signal-strong { color: #4ade80; font-weight: 700; }
|
||||
.signal-momentum{ color: #60a5fa; font-weight: 700; }
|
||||
.signal-neutral { color: #94a3b8; }
|
||||
.signal-spec { color: #fb923c; font-weight: 700; }
|
||||
.signal-avoid { color: #f87171; font-weight: 700; }
|
||||
|
||||
.pass { color: #4ade80; }
|
||||
.fail { color: #f87171; }
|
||||
.flag { color: #fb923c; font-size: 11px; display: block; margin-top: 2px; }
|
||||
|
||||
.risk-flags { display: flex; flex-direction: column; gap: 2px; }
|
||||
|
||||
.tab-content { display: none; }
|
||||
.tab-content.active { display: block; }
|
||||
|
||||
.no-data { color: #334155; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<h1>📊 Market Screener</h1>
|
||||
<div class="header-meta">
|
||||
<div class="pill">Date <span>${ctx.timestamp?.slice(0, 10) ?? '—'}</span></div>
|
||||
<div class="pill">Rate <span>${ctx.rateRegime}</span></div>
|
||||
<div class="pill">Volatility <span>${ctx.volatilityRegime}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
|
||||
<div class="ctx-grid">
|
||||
${this._ctxCard('10Y Yield', (ctx.riskFreeRate?.toFixed(2) ?? '—') + '%')}
|
||||
${this._ctxCard('VIX', ctx.vixLevel?.toFixed(1) ?? '—')}
|
||||
${this._ctxCard('S&P 500', ctx.sp500Price?.toLocaleString() ?? '—')}
|
||||
${this._ctxCard('S&P 500 P/E', (b.marketPE?.toFixed(1) ?? '—') + 'x')}
|
||||
${this._ctxCard('Tech P/E', (b.techPE?.toFixed(1) ?? '—') + 'x')}
|
||||
${this._ctxCard('REIT Yield', (b.reitYield?.toFixed(2) ?? '—') + '%')}
|
||||
${this._ctxCard('IG Spread', (b.igSpread?.toFixed(2) ?? '—') + '%')}
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Signal Summary</h2>
|
||||
<table>
|
||||
<thead><tr><th>Ticker</th><th>Type</th><th>Signal</th><th>Inflated Verdict</th><th>Fundamental Verdict</th></tr></thead>
|
||||
<tbody>${all
|
||||
.sort((a, b) => this._sigOrd(a.signal) - this._sigOrd(b.signal))
|
||||
.map((r) => this._summaryRow(r))
|
||||
.join('')}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
${['STOCK', 'ETF', 'BOND']
|
||||
.map((type) => (results[type]?.length ? this._assetSection(type, results[type], b) : ''))
|
||||
.join('')}
|
||||
|
||||
${pf ? this._personalFinanceSection(pf) : ''}
|
||||
|
||||
${
|
||||
results.ERROR?.length
|
||||
? `
|
||||
<div class="section">
|
||||
<h2>Errors</h2>
|
||||
<table>
|
||||
<thead><tr><th>Ticker</th><th>Reason</th></tr></thead>
|
||||
<tbody>${results.ERROR.map((e) => `<tr><td class="ticker">${e.ticker}</td><td class="verdict-red">${e.message}</td></tr>`).join('')}</tbody>
|
||||
</table>
|
||||
</div>`
|
||||
: ''
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('.tabs').forEach((tabs) => {
|
||||
tabs.querySelectorAll('.tab').forEach((tab) => {
|
||||
tab.addEventListener('click', () => {
|
||||
const section = tabs.closest('.section');
|
||||
tabs.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
|
||||
section.querySelectorAll('.tab-content').forEach((c) => c.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
section.querySelector('#' + tab.dataset.target).classList.add('active');
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// ── Section builders ────────────────────────────────────────────────────────
|
||||
|
||||
_assetSection(type, items, benchmarks) {
|
||||
const sorted = [...items].sort((a, b) => this._sigOrd(a.signal) - this._sigOrd(b.signal));
|
||||
const inflatedId = `${type}-inflated`;
|
||||
const fundamentalId = `${type}-fundamental`;
|
||||
|
||||
const inflatedLabel =
|
||||
type === 'STOCK'
|
||||
? `Market-Adjusted (P/E gate: ~${benchmarks.marketPE != null ? Math.round(benchmarks.marketPE * 1.5) : '—'}x from live data)`
|
||||
: 'Market-Adjusted';
|
||||
|
||||
return `
|
||||
<div class="section">
|
||||
<h2>${type}S</h2>
|
||||
<div class="tabs">
|
||||
<div class="tab active" data-target="${inflatedId}">${inflatedLabel}</div>
|
||||
<div class="tab" data-target="${fundamentalId}">Fundamental (Graham-style)</div>
|
||||
</div>
|
||||
<div id="${inflatedId}" class="tab-content active">
|
||||
${this._table(type, sorted, 'inflated')}
|
||||
</div>
|
||||
<div id="${fundamentalId}" class="tab-content">
|
||||
${this._table(type, sorted, 'fundamental')}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_table(type, items, mode) {
|
||||
const headers = this._headers(type, items, mode);
|
||||
const rows = items.map((r) => this._row(type, r, mode, headers)).join('');
|
||||
return `<table>
|
||||
<thead><tr>${headers.map((h) => `<th>${h}</th>`).join('')}</tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
// Collect only headers that have at least one non-null value across all items
|
||||
_headers(type, items, mode) {
|
||||
const base = ['Ticker', 'Price', 'Verdict', 'Score'];
|
||||
if (type === 'STOCK') {
|
||||
const metricKeys = [
|
||||
'Sector',
|
||||
'P/E',
|
||||
'PEG',
|
||||
'P/B',
|
||||
'ROE%',
|
||||
'OpMgn%',
|
||||
'NetMgn%',
|
||||
'Rev%',
|
||||
'FCF Yld%',
|
||||
'Div%',
|
||||
'D/E',
|
||||
'Quick',
|
||||
'Beta',
|
||||
'52W Pos',
|
||||
'P/FFO',
|
||||
];
|
||||
const present = metricKeys.filter((k) =>
|
||||
items.some((r) => r.asset.getDisplayMetrics()[k] != null),
|
||||
);
|
||||
return [...base, ...present, 'Risk Flags'];
|
||||
}
|
||||
if (type === 'ETF') return [...base, 'Expense', 'Yield', 'AUM', '5Y Ret'];
|
||||
if (type === 'BOND') return [...base, 'YTM', 'Duration', 'Rating'];
|
||||
return base;
|
||||
}
|
||||
|
||||
_row(type, result, mode, headers) {
|
||||
const m = result.asset.getDisplayMetrics();
|
||||
const bd = result[mode]?.audit?.breakdown ?? {};
|
||||
const rf = result[mode]?.audit?.riskFlags ?? [];
|
||||
const v = result[mode]?.label ?? '';
|
||||
const s = result[mode]?.scoreSummary ?? '';
|
||||
const p = (key) =>
|
||||
bd[key] != null
|
||||
? `<span class="${bd[key] > 0 ? 'pass' : 'fail'}">${bd[key] > 0 ? '✅' : '❌'}</span>`
|
||||
: '';
|
||||
|
||||
const cells = {
|
||||
Ticker: `<td class="ticker">${m.Ticker}</td>`,
|
||||
Price: `<td class="price">${m.Price}</td>`,
|
||||
Verdict: `<td class="${this._verdictClass(v)}">${v}</td>`,
|
||||
Score: `<td class="score">${s}</td>`,
|
||||
Sector: `<td><span class="sector">${m.Sector ?? ''}</span></td>`,
|
||||
'P/E': `<td>${m['P/E'] ?? '<span class="no-data">—</span>'}</td>`,
|
||||
PEG: `<td>${m.PEG != null ? m.PEG + ' ' + p('peg') : '<span class="no-data">—</span>'}</td>`,
|
||||
'P/B': `<td>${m['P/B'] ?? '<span class="no-data">—</span>'}</td>`,
|
||||
'ROE%': `<td>${m['ROE%'] != null ? m['ROE%'] + ' ' + p('roe') : '<span class="no-data">—</span>'}</td>`,
|
||||
'OpMgn%': `<td>${m['OpMgn%'] != null ? m['OpMgn%'] + ' ' + p('opMargin') : '<span class="no-data">—</span>'}</td>`,
|
||||
'NetMgn%': `<td>${m['NetMgn%'] != null ? m['NetMgn%'] + ' ' + p('margin') : '<span class="no-data">—</span>'}</td>`,
|
||||
'Rev%': `<td>${m['Rev%'] != null ? m['Rev%'] + ' ' + p('revenue') : '<span class="no-data">—</span>'}</td>`,
|
||||
'FCF Yld%': `<td>${m['FCF Yld%'] != null ? m['FCF Yld%'] + ' ' + p('fcf') : '<span class="no-data">—</span>'}</td>`,
|
||||
'Div%': `<td>${m['Div%'] != null ? m['Div%'] + ' ' + p('yield') : '<span class="no-data">—</span>'}</td>`,
|
||||
'D/E': `<td>${m['D/E'] ?? '<span class="no-data">—</span>'}</td>`,
|
||||
Quick: `<td>${m.Quick ?? '<span class="no-data">—</span>'}</td>`,
|
||||
Beta: `<td>${m.Beta ?? '<span class="no-data">—</span>'}</td>`,
|
||||
'52W Pos': `<td>${m['52W Pos'] ?? '<span class="no-data">—</span>'}</td>`,
|
||||
'P/FFO': `<td>${m['P/FFO'] != null ? m['P/FFO'] + ' ' + p('pFFO') : '<span class="no-data">—</span>'}</td>`,
|
||||
'Risk Flags': `<td class="risk-flags">${rf.map((f) => `<span class="flag">⚠ ${f}</span>`).join('') || '<span class="no-data">—</span>'}</td>`,
|
||||
// ETF
|
||||
Expense: `<td>${m['Exp Ratio%'] != null ? m['Exp Ratio%'] + ' ' + p('cost') : '<span class="no-data">—</span>'}</td>`,
|
||||
Yield: `<td>${m['Yield%'] != null ? m['Yield%'] + ' ' + p('yield') : '<span class="no-data">—</span>'}</td>`,
|
||||
AUM: `<td>${m.AUM ?? '<span class="no-data">—</span>'}</td>`,
|
||||
'5Y Ret': `<td>${m['5Y Return%'] ?? '<span class="no-data">—</span>'}</td>`,
|
||||
// BOND
|
||||
YTM: `<td>${m['YTM%'] != null ? m['YTM%'] + ' ' + p('spread') : '<span class="no-data">—</span>'}</td>`,
|
||||
Duration: `<td>${m.Duration != null ? m.Duration + ' ' + p('duration') : '<span class="no-data">—</span>'}</td>`,
|
||||
Rating: `<td>${m.Rating ?? '<span class="no-data">—</span>'}</td>`,
|
||||
};
|
||||
|
||||
return `<tr>${headers.map((h) => cells[h] ?? `<td>—</td>`).join('')}</tr>`;
|
||||
}
|
||||
|
||||
_summaryRow(r) {
|
||||
return `<tr>
|
||||
<td class="ticker">${r.asset.ticker}</td>
|
||||
<td><span class="sector">${r.asset.type}</span></td>
|
||||
<td class="${this._signalClass(r.signal)}">${r.signal}</td>
|
||||
<td class="${this._verdictClass(r.inflated.label)}">${r.inflated.label}</td>
|
||||
<td class="${this._verdictClass(r.fundamental.label)}">${r.fundamental.label}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_ctxCard(label, value) {
|
||||
return `<div class="ctx-card"><div class="ctx-label">${label}</div><div class="ctx-value">${value}</div></div>`;
|
||||
}
|
||||
|
||||
_verdictClass(label) {
|
||||
if (label?.startsWith('🟢')) return 'verdict-green';
|
||||
if (label?.startsWith('🟡')) return 'verdict-yellow';
|
||||
return 'verdict-red';
|
||||
}
|
||||
|
||||
_signalClass(signal) {
|
||||
if (signal?.includes('Strong')) return 'signal-strong';
|
||||
if (signal?.includes('Momentum')) return 'signal-momentum';
|
||||
if (signal?.includes('Neutral')) return 'signal-neutral';
|
||||
if (signal?.includes('Speculation')) return 'signal-spec';
|
||||
return 'signal-avoid';
|
||||
}
|
||||
|
||||
_personalFinanceSection(pf) {
|
||||
const fmt = (n) =>
|
||||
new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(n);
|
||||
const sign = (n) =>
|
||||
n >= 0
|
||||
? `<span class="verdict-green">${fmt(n)}</span>`
|
||||
: `<span class="verdict-red">${fmt(n)}</span>`;
|
||||
|
||||
const accountRows = pf.accounts
|
||||
.map(
|
||||
(a) => `
|
||||
<tr>
|
||||
<td class="ticker">${a.name}</td>
|
||||
<td><span class="sector">${a.type}</span></td>
|
||||
<td class="price">${a.org}</td>
|
||||
<td style="text-align:right">${sign(a.balance)}</td>
|
||||
<td class="price" style="text-align:right">${a.balanceDate}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
const categoryRows = pf.categoryBreakdown
|
||||
.slice(0, 8)
|
||||
.map(
|
||||
(c) => `
|
||||
<tr>
|
||||
<td>${c.category}</td>
|
||||
<td style="text-align:right">${fmt(c.amount)}</td>
|
||||
<td style="text-align:right; color:#94a3b8">${c.pct}%</td>
|
||||
<td>
|
||||
<div style="background:#1e293b;border-radius:4px;height:8px;width:100%;max-width:120px">
|
||||
<div style="background:#3b82f6;border-radius:4px;height:8px;width:${c.pct}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
return `
|
||||
<div class="section">
|
||||
<h2>Personal Finance — SimpleFIN</h2>
|
||||
|
||||
<div class="ctx-grid" style="margin-bottom:24px">
|
||||
${this._ctxCard('Net Worth', fmt(pf.netWorth))}
|
||||
${this._ctxCard('Total Assets', fmt(pf.totalAssets))}
|
||||
${this._ctxCard('Liabilities', fmt(pf.totalLiabilities))}
|
||||
${this._ctxCard('Cash', `${fmt(pf.totalCash)} (${pf.cashPct}%)`)}
|
||||
${this._ctxCard('Investments', `${fmt(pf.totalInvestments)} (${pf.investPct}%)`)}
|
||||
${this._ctxCard('Monthly Income', fmt(pf.totalIncome))}
|
||||
${this._ctxCard('Monthly Spend', fmt(pf.totalSpend))}
|
||||
${pf.savingsRate != null ? this._ctxCard('Savings Rate', `${pf.savingsRate}%`) : ''}
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:24px">
|
||||
<div>
|
||||
<h2 style="margin-bottom:12px">Accounts</h2>
|
||||
<table>
|
||||
<thead><tr><th>Account</th><th>Type</th><th>Institution</th><th style="text-align:right">Balance</th><th style="text-align:right">Updated</th></tr></thead>
|
||||
<tbody>${accountRows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div>
|
||||
<h2 style="margin-bottom:12px">Spending by Category (Last 30 Days)</h2>
|
||||
<table>
|
||||
<thead><tr><th>Category</th><th style="text-align:right">Amount</th><th style="text-align:right">%</th><th>Share</th></tr></thead>
|
||||
<tbody>${categoryRows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_sigOrd(signal) {
|
||||
return (
|
||||
{
|
||||
'✅ Strong Buy': 0,
|
||||
'⚡ Momentum': 1,
|
||||
'🔄 Neutral': 2,
|
||||
'⚠️ Speculation': 3,
|
||||
'❌ Avoid': 4,
|
||||
}[signal] ?? 5
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export const chunkArray = (array, size) =>
|
||||
Array.from({ length: Math.ceil(array.length / size) }, (_, i) =>
|
||||
array.slice(i * size, i * size + size),
|
||||
);
|
||||
@@ -0,0 +1,153 @@
|
||||
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) => {
|
||||
const fd = summary.financialData ?? {};
|
||||
const ks = summary.defaultKeyStatistics ?? {};
|
||||
const sd = summary.summaryDetail ?? {};
|
||||
const pr = summary.price ?? {};
|
||||
|
||||
const currentPrice = pr.regularMarketPrice ?? 0;
|
||||
const sharesOutstanding = ks.sharesOutstanding ?? 0;
|
||||
const operatingCashflow = fd.operatingCashflow ?? 0;
|
||||
const freeCashflow = fd.freeCashflow ?? 0;
|
||||
|
||||
// P/FFO proxy (price / operating cash flow per share) — used for REIT scoring
|
||||
const pFFO =
|
||||
operatingCashflow > 0 && sharesOutstanding > 0
|
||||
? currentPrice / (operatingCashflow / sharesOutstanding)
|
||||
: null;
|
||||
|
||||
// FCF yield = free cash flow per share / price.
|
||||
// Negative FCF is preserved (not nulled) — a company burning cash should fail the gate,
|
||||
// not be silently skipped as "no data".
|
||||
const fcfYield =
|
||||
freeCashflow !== 0 && sharesOutstanding > 0 && currentPrice > 0
|
||||
? (freeCashflow / sharesOutstanding / currentPrice) * 100
|
||||
: null;
|
||||
|
||||
// PEG computation: use Yahoo's value first; fall back to trailingPE / earningsGrowth
|
||||
// earningsGrowth from Yahoo is a decimal (e.g. 0.15 = 15%), convert to whole number first
|
||||
const yahoosPEG = ks.pegRatio ?? null;
|
||||
const trailingPE = sd.trailingPE ?? null;
|
||||
const earningsGrowth = fd.earningsGrowth != null ? fd.earningsGrowth * 100 : null; // now in %
|
||||
const computedPEG =
|
||||
trailingPE != null && earningsGrowth > 0 ? +(trailingPE / earningsGrowth).toFixed(2) : null;
|
||||
const pegRatio = yahoosPEG ?? computedPEG; // prefer Yahoo's, fall back to computed
|
||||
|
||||
// Quick ratio — fall back to currentRatio when quickRatio is missing
|
||||
const quickRatio = fd.quickRatio ?? fd.currentRatio ?? null;
|
||||
|
||||
return {
|
||||
// Valuation — trailing PE is the audited number; forward PE is an analyst estimate
|
||||
// (historically 10-15% optimistic). Use trailing as primary for fundamental mode.
|
||||
peRatio: trailingPE ?? ks.forwardPE,
|
||||
trailingPE,
|
||||
pegRatio,
|
||||
priceToBook: ks.priceToBook ?? null,
|
||||
evToEbitda: ks.enterpriseToEbitda ?? null,
|
||||
|
||||
// Profitability
|
||||
netProfitMargin: fd.profitMargins != null ? fd.profitMargins * 100 : null,
|
||||
operatingMargin: fd.operatingMargins != null ? fd.operatingMargins * 100 : null,
|
||||
returnOnEquity: fd.returnOnEquity != null ? fd.returnOnEquity * 100 : null,
|
||||
|
||||
// Growth
|
||||
revenueGrowth: fd.revenueGrowth != null ? fd.revenueGrowth * 100 : null,
|
||||
earningsGrowth,
|
||||
|
||||
// Financial health
|
||||
debtToEquity: fd.debtToEquity != null ? fd.debtToEquity / 100 : null,
|
||||
quickRatio,
|
||||
|
||||
// Cash flow
|
||||
fcfYield,
|
||||
pFFO,
|
||||
|
||||
// Income
|
||||
dividendYield:
|
||||
sd.trailingAnnualDividendYield != null ? sd.trailingAnnualDividendYield * 100 : null,
|
||||
|
||||
// Risk & momentum
|
||||
beta: sd.beta ?? null,
|
||||
week52High: sd.fiftyTwoWeekHigh ?? null,
|
||||
week52Low: sd.fiftyTwoWeekLow ?? null,
|
||||
|
||||
currentPrice,
|
||||
assetProfile: summary.assetProfile || {},
|
||||
};
|
||||
};
|
||||
|
||||
const mapEtfData = (summary) => ({
|
||||
expenseRatio: (summary.summaryDetail?.expenseRatio ?? 0) * 100,
|
||||
totalAssets: summary.summaryDetail?.totalAssets ?? 0,
|
||||
yield: (summary.summaryDetail?.trailingAnnualDividendYield ?? 0) * 100,
|
||||
// fiveYearAverageReturn is annualised total return — valid proxy for performance vs peers.
|
||||
fiveYearReturn: (summary.defaultKeyStatistics?.fiveYearAverageReturn ?? 0) * 100,
|
||||
// averageVolume from summaryDetail is average daily trading volume — used for liquidity gate.
|
||||
volume: summary.summaryDetail?.averageVolume ?? summary.price?.averageVolume ?? 0,
|
||||
currentPrice: summary.price?.regularMarketPrice ?? 0,
|
||||
});
|
||||
|
||||
/**
|
||||
* Infer credit rating from ETF category string (Yahoo Finance doesn't expose
|
||||
* bond credit ratings directly). Defaults to BBB (investment grade) when unknown.
|
||||
*/
|
||||
const inferCreditRating = (category) => {
|
||||
const cat = (category || '').toLowerCase();
|
||||
if (cat.includes('government') || cat.includes('treasury')) return 'AAA';
|
||||
if (cat.includes('muni')) return 'AA';
|
||||
if (cat.includes('high yield') || cat.includes('junk')) return 'BB';
|
||||
if (cat.includes('corporate') || cat.includes('investment grade')) return 'A';
|
||||
return 'BBB'; // conservative default
|
||||
};
|
||||
|
||||
// Infers approximate effective duration (years) from bond ETF category name.
|
||||
// Buckets match standard industry classifications (short < 3y, intermediate 3-7y, long > 10y).
|
||||
const inferDuration = (category) => {
|
||||
const cat = (category || '').toLowerCase();
|
||||
if (cat.includes('short') || cat.includes('ultrashort') || cat.includes('1-3')) return 2;
|
||||
if (cat.includes('intermediate') || cat.includes('3-7') || cat.includes('3-10')) return 5;
|
||||
if (cat.includes('long') || cat.includes('10+') || cat.includes('20+')) return 18;
|
||||
if (cat.includes('target maturity') || cat.includes('defined maturity')) return 4;
|
||||
return 6; // conservative default — typical aggregate bond fund duration
|
||||
};
|
||||
|
||||
const mapBondData = (summary) => ({
|
||||
yieldToMaturity: (summary.summaryDetail?.yield ?? 0) * 100,
|
||||
// KNOWN LIMITATION: Yahoo Finance does not expose effective duration via the modules
|
||||
// we fetch (assetProfile, financialData, defaultKeyStatistics, price, summaryDetail).
|
||||
// The `fundProfile` module has duration for some funds but requires a separate fetch.
|
||||
// We use the ETF category name to infer a rough duration bucket as a proxy.
|
||||
duration: inferDuration(summary.assetProfile?.category),
|
||||
creditRating: inferCreditRating(summary.assetProfile?.category),
|
||||
currentPrice: summary.price?.regularMarketPrice ?? 0,
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ScoringRules } from '../config/ScoringConfig.js';
|
||||
import { MarketRegime } from '../market/MarketRegime.js';
|
||||
import { SCORE_MODE } from '../config/constants.js';
|
||||
|
||||
export const RuleMerger = {
|
||||
getRulesForAsset(type, metrics, marketContext = {}, mode = SCORE_MODE.FUNDAMENTAL) {
|
||||
const base = ScoringRules[type];
|
||||
if (!base) throw new Error(`No rules configured for asset type: ${type}`);
|
||||
|
||||
let rules = JSON.parse(JSON.stringify(base));
|
||||
|
||||
if (type === 'STOCK' && metrics.sector) {
|
||||
const override = base.SECTOR_OVERRIDE?.[metrics.sector.toUpperCase()];
|
||||
if (override) {
|
||||
rules.gates = { ...rules.gates, ...override.gates };
|
||||
rules.weights = { ...rules.weights, ...override.weights };
|
||||
rules.thresholds = { ...rules.thresholds, ...override.thresholds };
|
||||
}
|
||||
}
|
||||
delete rules.SECTOR_OVERRIDE;
|
||||
|
||||
if (mode === SCORE_MODE.INFLATED) {
|
||||
const { gates, thresholds } = new MarketRegime(marketContext).getInflatedOverrides(
|
||||
type,
|
||||
metrics.sector,
|
||||
);
|
||||
rules.gates = { ...rules.gates, ...gates };
|
||||
rules.thresholds = { ...rules.thresholds, ...thresholds };
|
||||
}
|
||||
|
||||
return rules;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
import { YahooClient } from '../market/YahooClient.js';
|
||||
import { BenchmarkProvider } from '../market/BenchmarkProvider.js';
|
||||
import { mapToStandardFormat } from './DataMapper.js';
|
||||
import { chunkArray } from './Chunker.js';
|
||||
import { RuleMerger } from './RuleMerger.js';
|
||||
import { Stock } from './assets/Stock.js';
|
||||
import { Etf } from './assets/Etf.js';
|
||||
import { Bond } from './assets/Bond.js';
|
||||
import { StockScorer } from './scorers/StockScorer.js';
|
||||
import { EtfScorer } from './scorers/EtfScorer.js';
|
||||
import { BondScorer } from './scorers/BondScorer.js';
|
||||
import { SIGNAL, SIGNAL_ORDER, SCORE_MODE, ASSET_TYPE } from '../config/constants.js';
|
||||
|
||||
const SCORERS = {
|
||||
[ASSET_TYPE.STOCK]: StockScorer,
|
||||
[ASSET_TYPE.ETF]: EtfScorer,
|
||||
[ASSET_TYPE.BOND]: BondScorer,
|
||||
};
|
||||
|
||||
export class ScreenerEngine {
|
||||
// logger: object with .write() / .log() — defaults to a console shim so CLI behaviour is unchanged.
|
||||
// Pass a no-op logger ({ write: () => {}, log: () => {} }) in server context.
|
||||
constructor({ logger } = {}) {
|
||||
this.client = new YahooClient();
|
||||
this.benchmarkProvider = new BenchmarkProvider({ logger: logger ?? console });
|
||||
this.logger = logger ?? {
|
||||
write: (msg) => process.stdout.write(msg),
|
||||
log: (...args) => console.log(...args),
|
||||
};
|
||||
}
|
||||
|
||||
// Pure data method — returns structured results. Safe to use in a server route.
|
||||
async screenTickers(tickers) {
|
||||
const marketContext = await this.benchmarkProvider.getMarketContext();
|
||||
const results = { STOCK: [], ETF: [], BOND: [], ERROR: [] };
|
||||
for (const chunk of chunkArray(tickers, 5)) {
|
||||
const batch = await Promise.all(chunk.map((t) => this._fetch(t)));
|
||||
batch.forEach((data) => this._process(data, marketContext, results));
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
return { ...results, marketContext };
|
||||
}
|
||||
|
||||
// CLI helper — emits progress to logger, returns structured results.
|
||||
// The caller (bin/screen.js) is responsible for writing the report.
|
||||
async screenWithProgress(tickers) {
|
||||
this.logger.write('⏳ Fetching market context...');
|
||||
const marketContext = await this.benchmarkProvider.getMarketContext();
|
||||
this.logger.write(' done\n');
|
||||
|
||||
const results = { STOCK: [], ETF: [], BOND: [], ERROR: [] };
|
||||
const chunks = chunkArray(tickers, 5);
|
||||
let processed = 0;
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const batch = await Promise.all(chunk.map((t) => this._fetch(t)));
|
||||
batch.forEach((data) => this._process(data, marketContext, results));
|
||||
processed += chunk.length;
|
||||
this.logger.write(`\r⏳ Screening tickers... ${processed}/${tickers.length}`);
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
|
||||
this.logger.write('\n');
|
||||
return { ...results, marketContext };
|
||||
}
|
||||
|
||||
async _fetch(ticker) {
|
||||
try {
|
||||
const summary = await this.client.fetchSummary(ticker);
|
||||
if (!summary?.price) throw new Error('Empty response from Yahoo');
|
||||
return mapToStandardFormat(ticker, summary);
|
||||
} catch (err) {
|
||||
return { isError: true, ticker: ticker.toUpperCase(), message: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
_process(data, marketContext, results) {
|
||||
if (data.isError) {
|
||||
results.ERROR.push(data);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const asset = this._buildAsset(data);
|
||||
const scorer = SCORERS[asset.type];
|
||||
if (!scorer) throw new Error(`No scorer for type: ${asset.type}`);
|
||||
|
||||
const fundamental = scorer.score(
|
||||
asset.metrics,
|
||||
RuleMerger.getRulesForAsset(
|
||||
asset.type,
|
||||
asset.metrics,
|
||||
marketContext,
|
||||
SCORE_MODE.FUNDAMENTAL,
|
||||
),
|
||||
marketContext,
|
||||
);
|
||||
const inflated = scorer.score(
|
||||
asset.metrics,
|
||||
RuleMerger.getRulesForAsset(asset.type, asset.metrics, marketContext, SCORE_MODE.INFLATED),
|
||||
marketContext,
|
||||
);
|
||||
|
||||
results[asset.type].push({
|
||||
asset,
|
||||
fundamental,
|
||||
inflated,
|
||||
signal: this._signal(fundamental.label, inflated.label),
|
||||
});
|
||||
} catch (err) {
|
||||
results.ERROR.push({
|
||||
ticker: (data.ticker || 'UNKNOWN').toUpperCase(),
|
||||
message: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_buildAsset(data) {
|
||||
switch ((data.type || ASSET_TYPE.STOCK).toUpperCase()) {
|
||||
case ASSET_TYPE.BOND:
|
||||
return new Bond(data);
|
||||
case ASSET_TYPE.ETF:
|
||||
return new Etf(data);
|
||||
default:
|
||||
return new Stock(data);
|
||||
}
|
||||
}
|
||||
|
||||
_signal(fundamentalLabel, inflatedLabel) {
|
||||
const green = (l) => l.startsWith('🟢');
|
||||
const yellow = (l) => l.startsWith('🟡');
|
||||
if (green(fundamentalLabel)) return SIGNAL.STRONG_BUY;
|
||||
if (green(inflatedLabel) && yellow(fundamentalLabel)) return SIGNAL.MOMENTUM;
|
||||
if (green(inflatedLabel) && !green(fundamentalLabel)) return SIGNAL.SPECULATION;
|
||||
if (yellow(fundamentalLabel) || yellow(inflatedLabel)) return SIGNAL.NEUTRAL;
|
||||
return SIGNAL.AVOID;
|
||||
}
|
||||
|
||||
signalOrder(signal) {
|
||||
return SIGNAL_ORDER[signal] ?? 5;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export class Asset {
|
||||
constructor(data) {
|
||||
this.ticker = (data.ticker || 'UNKNOWN').toUpperCase();
|
||||
this.currentPrice = data.currentPrice || 0;
|
||||
this.type = (data.type || 'STOCK').toUpperCase();
|
||||
}
|
||||
|
||||
formatCurrency(val) {
|
||||
return val ? `$${val.toFixed(2)}` : 'N/A';
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { CREDIT_RATING_SCALE } from '../../config/ScoringConfig.js';
|
||||
import { Asset } from './Asset.js';
|
||||
|
||||
export class Bond extends Asset {
|
||||
constructor(data) {
|
||||
super(data);
|
||||
|
||||
const creditRating = data.creditRating || 'BBB';
|
||||
const creditRatingNumeric = CREDIT_RATING_SCALE[creditRating] ?? 7;
|
||||
|
||||
this.metrics = {
|
||||
ytm: parseFloat(data.yieldToMaturity) || 0,
|
||||
duration: parseFloat(data.duration) || 0,
|
||||
creditRating,
|
||||
creditRatingNumeric,
|
||||
};
|
||||
}
|
||||
|
||||
getDisplayMetrics() {
|
||||
return {
|
||||
Ticker: this.ticker,
|
||||
Type: 'BOND',
|
||||
Price: this.formatCurrency(this.currentPrice),
|
||||
'YTM%': `${this.metrics.ytm.toFixed(2)}%`,
|
||||
Duration: this.metrics.duration.toFixed(1),
|
||||
Rating: `${this.metrics.creditRating} (${this.metrics.creditRatingNumeric})`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Asset } from './Asset.js';
|
||||
|
||||
export class Etf extends Asset {
|
||||
constructor(data) {
|
||||
super(data);
|
||||
this.metrics = {
|
||||
expenseRatio: parseFloat(data.expenseRatio) || 0,
|
||||
totalAssets: parseFloat(data.totalAssets) || 0,
|
||||
yield: parseFloat(data.yield) || 0,
|
||||
volume: parseFloat(data.volume) || 0,
|
||||
fiveYearReturn: parseFloat(data.fiveYearReturn) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
getDisplayMetrics() {
|
||||
return {
|
||||
Ticker: this.ticker,
|
||||
Type: 'ETF',
|
||||
Price: this.formatCurrency(this.currentPrice),
|
||||
'Exp Ratio%': `${this.metrics.expenseRatio.toFixed(2)}%`,
|
||||
'Yield%': `${this.metrics.yield.toFixed(2)}%`,
|
||||
AUM: this.formatLargeNumber(this.metrics.totalAssets),
|
||||
'5Y Return%': `${this.metrics.fiveYearReturn.toFixed(1)}%`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Asset } from './Asset.js';
|
||||
|
||||
export class Stock extends Asset {
|
||||
constructor(data) {
|
||||
super(data);
|
||||
// console.log('Data:', data);
|
||||
this.sector = this._mapToStandardSector(data || {});
|
||||
|
||||
this.metrics = {
|
||||
sector: this.sector,
|
||||
// Valuation
|
||||
peRatio: data.peRatio ?? null,
|
||||
pegRatio: data.pegRatio ?? null,
|
||||
priceToBook: data.priceToBook ?? null,
|
||||
// Profitability
|
||||
netProfitMargin: data.netProfitMargin ?? null,
|
||||
operatingMargin: data.operatingMargin ?? null,
|
||||
returnOnEquity: data.returnOnEquity ?? null,
|
||||
// Growth
|
||||
revenueGrowth: data.revenueGrowth ?? null,
|
||||
earningsGrowth: data.earningsGrowth ?? null,
|
||||
// Financial health
|
||||
debtToEquity: data.debtToEquity ?? null,
|
||||
quickRatio: data.quickRatio ?? null,
|
||||
// Cash flow
|
||||
fcfYield: data.fcfYield ?? null,
|
||||
pFFO: data.pFFO ?? null,
|
||||
// Income
|
||||
dividendYield: data.dividendYield ?? null,
|
||||
// Risk & momentum
|
||||
beta: data.beta ?? null,
|
||||
week52High: data.week52High ?? null,
|
||||
week52Low: data.week52Low ?? null,
|
||||
currentPrice: data.currentPrice ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
_mapToStandardSector(data) {
|
||||
const profile = data.assetProfile || {};
|
||||
const industry = (profile.industry || '').toLowerCase();
|
||||
const sector = (profile.sector || '').toLowerCase();
|
||||
const combined = `${industry} ${sector}`;
|
||||
|
||||
// Yahoo Finance sector/industry strings mapped to our internal sector constants.
|
||||
// Order matters — more specific matches first.
|
||||
if (
|
||||
combined.includes('technology') ||
|
||||
combined.includes('electronic') ||
|
||||
combined.includes('semiconductor') ||
|
||||
combined.includes('software')
|
||||
)
|
||||
return 'TECHNOLOGY';
|
||||
if (combined.includes('real estate') || combined.includes('reit')) return 'REIT';
|
||||
if (
|
||||
combined.includes('financial') ||
|
||||
combined.includes('bank') ||
|
||||
combined.includes('insurance') ||
|
||||
combined.includes('asset management')
|
||||
)
|
||||
return 'FINANCIAL';
|
||||
if (
|
||||
combined.includes('energy') ||
|
||||
combined.includes('oil') ||
|
||||
combined.includes('gas') ||
|
||||
combined.includes('petroleum')
|
||||
)
|
||||
return 'ENERGY';
|
||||
if (
|
||||
combined.includes('health') ||
|
||||
combined.includes('biotech') ||
|
||||
combined.includes('pharmaceutical') ||
|
||||
combined.includes('medical')
|
||||
)
|
||||
return 'HEALTHCARE';
|
||||
// Yahoo calls this "Communication Services" — covers META, GOOGL, NFLX, DIS, T
|
||||
if (
|
||||
combined.includes('communication') ||
|
||||
combined.includes('media') ||
|
||||
combined.includes('entertainment') ||
|
||||
combined.includes('telecom')
|
||||
)
|
||||
return 'COMMUNICATION';
|
||||
if (
|
||||
combined.includes('consumer defensive') ||
|
||||
combined.includes('consumer staples') ||
|
||||
combined.includes('household') ||
|
||||
combined.includes('beverage') ||
|
||||
combined.includes('food')
|
||||
)
|
||||
return 'CONSUMER_STAPLES';
|
||||
if (
|
||||
combined.includes('consumer cyclical') ||
|
||||
combined.includes('consumer discretionary') ||
|
||||
combined.includes('retail') ||
|
||||
combined.includes('apparel') ||
|
||||
combined.includes('auto')
|
||||
)
|
||||
return 'CONSUMER_DISCRETIONARY';
|
||||
|
||||
return 'GENERAL';
|
||||
}
|
||||
|
||||
getDisplayMetrics() {
|
||||
const fmt = (v, dec = 1, suffix = '') => (v != null ? `${v.toFixed(dec)}${suffix}` : null);
|
||||
const m = this.metrics;
|
||||
const w52pos =
|
||||
m.week52High > 0 && m.week52Low != null && m.currentPrice > 0
|
||||
? (((m.currentPrice - m.week52Low) / (m.week52High - m.week52Low)) * 100).toFixed(0) + '%'
|
||||
: null;
|
||||
|
||||
// Only include fields that have actual data — null fields are omitted
|
||||
const display = {
|
||||
Ticker: this.ticker,
|
||||
Price: this.formatCurrency(this.currentPrice),
|
||||
Sector: this.sector,
|
||||
};
|
||||
if (m.peRatio != null) display['P/E'] = fmt(m.peRatio, 1);
|
||||
if (m.pegRatio != null) display['PEG'] = fmt(m.pegRatio, 2);
|
||||
if (m.priceToBook != null) display['P/B'] = fmt(m.priceToBook, 2);
|
||||
if (m.returnOnEquity != null) display['ROE%'] = fmt(m.returnOnEquity, 1, '%');
|
||||
if (m.operatingMargin != null) display['OpMgn%'] = fmt(m.operatingMargin, 1, '%');
|
||||
if (m.netProfitMargin != null) display['NetMgn%'] = fmt(m.netProfitMargin, 1, '%');
|
||||
if (m.revenueGrowth != null) display['Rev%'] = fmt(m.revenueGrowth, 1, '%');
|
||||
if (m.fcfYield != null) display['FCF Yld%'] = fmt(m.fcfYield, 1, '%');
|
||||
if (m.dividendYield != null) display['Div%'] = fmt(m.dividendYield, 2, '%');
|
||||
if (m.debtToEquity != null) display['D/E'] = fmt(m.debtToEquity, 2);
|
||||
if (m.quickRatio != null) display['Quick'] = fmt(m.quickRatio, 2);
|
||||
if (m.beta != null) display['Beta'] = fmt(m.beta, 2);
|
||||
if (w52pos != null) display['52W Pos'] = w52pos;
|
||||
if (m.pFFO != null) display['P/FFO'] = fmt(m.pFFO, 1);
|
||||
|
||||
return display;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export const BondScorer = {
|
||||
score(m, rules, context) {
|
||||
const { gates, weights, thresholds } = rules;
|
||||
const metrics = this._sanitize(m);
|
||||
const riskFreeRate = (context?.riskFreeRate ?? 4.0) / 100;
|
||||
|
||||
if (metrics.creditRatingNumeric < gates.minCreditRating) {
|
||||
return {
|
||||
label: '🔴 Avoid',
|
||||
scoreSummary: `Gate failed: ${metrics.creditRating} (${metrics.creditRatingNumeric}) < ${gates.minCreditRating}`,
|
||||
audit: { passedGates: false },
|
||||
};
|
||||
}
|
||||
|
||||
// Convert spread to percentage to match minSpread threshold (e.g. 1.0 = 1%)
|
||||
const spreadPct = (metrics.ytm - riskFreeRate) * 100;
|
||||
|
||||
const breakdown = {
|
||||
spread: spreadPct >= thresholds.minSpread ? weights.yieldSpread : -2,
|
||||
duration: metrics.duration <= thresholds.maxDuration ? weights.duration : -1,
|
||||
};
|
||||
const score = Object.values(breakdown).reduce((a, b) => a + b, 0);
|
||||
|
||||
return {
|
||||
label: score >= 4 ? '🟢 Attractive' : score >= 1 ? '🟡 Neutral' : '🔴 Avoid',
|
||||
scoreSummary: `Score: ${score}`,
|
||||
audit: { breakdown },
|
||||
};
|
||||
},
|
||||
|
||||
_sanitize(m) {
|
||||
const pct = (v) => parseFloat(typeof v === 'string' ? v.replace('%', '') : v) / 100 || 0;
|
||||
return {
|
||||
ytm: pct(m.ytm),
|
||||
duration: parseFloat(m.duration) || 0,
|
||||
creditRating: m.creditRating || 'BBB',
|
||||
creditRatingNumeric: m.creditRatingNumeric ?? 7,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
export const EtfScorer = {
|
||||
score(m, rules) {
|
||||
const { gates, weights, thresholds } = rules;
|
||||
const metrics = {
|
||||
expenseRatio: parseFloat(m.expenseRatio) || 0,
|
||||
yield: parseFloat(m.yield) || 0,
|
||||
volume: parseFloat(m.volume) || 0,
|
||||
fiveYearReturn: parseFloat(m.fiveYearReturn) || 0,
|
||||
};
|
||||
|
||||
if (metrics.expenseRatio > gates.maxExpenseRatio) {
|
||||
return { label: '🔴 REJECT', scoreSummary: 'Gate failed: High Expense Ratio' };
|
||||
}
|
||||
|
||||
const breakdown = {
|
||||
cost: metrics.expenseRatio <= thresholds.maxExpense ? weights.lowCost : -3,
|
||||
yield: metrics.yield >= thresholds.minYield ? weights.yield : -1,
|
||||
vol: metrics.volume >= (thresholds.minVolume ?? 1000000) ? 0 : -2,
|
||||
// 5Y return: strong long-term performance vs the ~10% S&P average is rewarded
|
||||
fiveYearReturn:
|
||||
thresholds.minFiveYearReturn != null
|
||||
? metrics.fiveYearReturn >= thresholds.minFiveYearReturn
|
||||
? (weights.fiveYearReturn ?? 1)
|
||||
: -1
|
||||
: 0,
|
||||
};
|
||||
|
||||
const score = Object.values(breakdown).reduce((a, b) => a + b, 0);
|
||||
|
||||
return {
|
||||
label: score >= 3 ? '🟢 Efficient' : score >= 0 ? '🟡 Neutral' : '🔴 Expensive/Low Yield',
|
||||
scoreSummary: `Score: ${score}`,
|
||||
audit: { passedGates: true, breakdown },
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
import { SIGNAL } from '../../config/constants.js';
|
||||
|
||||
const n = (v) => {
|
||||
const f = parseFloat(v);
|
||||
return !isNaN(f) && f !== 0 ? f : null;
|
||||
};
|
||||
|
||||
const scoreValue = (val, high, med, weight) => (val >= high ? weight : val >= med ? 1 : -1);
|
||||
const scorePeg = (val, high, med, weight) => (val <= high ? weight : val <= med ? 1 : -1);
|
||||
|
||||
export const StockScorer = {
|
||||
score(metrics, rules) {
|
||||
const { gates, weights, thresholds } = rules;
|
||||
const m = this._sanitize(metrics);
|
||||
|
||||
const failures = [
|
||||
m.debtToEquity != null &&
|
||||
m.debtToEquity > gates.maxDebtToEquity &&
|
||||
`D/E ${m.debtToEquity.toFixed(1)} > ${gates.maxDebtToEquity}`,
|
||||
m.quickRatio != null &&
|
||||
m.quickRatio < gates.minQuickRatio &&
|
||||
`Quick ${m.quickRatio.toFixed(2)} < ${gates.minQuickRatio}`,
|
||||
m.peRatio != null &&
|
||||
m.peRatio > gates.maxPERatio &&
|
||||
`P/E ${m.peRatio.toFixed(0)} > ${gates.maxPERatio}`,
|
||||
m.pegRatio != null &&
|
||||
m.pegRatio > gates.maxPegGate &&
|
||||
`PEG ${m.pegRatio.toFixed(1)} > ${gates.maxPegGate}`,
|
||||
m.priceToBook != null &&
|
||||
gates.maxPriceToBook &&
|
||||
m.priceToBook > gates.maxPriceToBook &&
|
||||
`P/B ${m.priceToBook.toFixed(1)} > ${gates.maxPriceToBook}`,
|
||||
].filter(Boolean);
|
||||
|
||||
if (failures.length > 0) {
|
||||
return {
|
||||
label: '🔴 REJECT',
|
||||
scoreSummary: `Gate failed: ${failures.join(' | ')}`,
|
||||
audit: { passedGates: false, failures },
|
||||
};
|
||||
}
|
||||
|
||||
const factors = [
|
||||
{
|
||||
key: 'roe',
|
||||
active: weights.roe > 0 && m.returnOnEquity != null,
|
||||
fn: () => scoreValue(m.returnOnEquity, thresholds.roeHigh, thresholds.roeMed, weights.roe),
|
||||
},
|
||||
{
|
||||
key: 'opMargin',
|
||||
active: weights.opMargin > 0 && m.operatingMargin != null,
|
||||
fn: () =>
|
||||
scoreValue(
|
||||
m.operatingMargin,
|
||||
thresholds.opMarginHigh,
|
||||
thresholds.opMarginMed,
|
||||
weights.opMargin,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'margin',
|
||||
active: weights.margin > 0 && m.netProfitMargin != null,
|
||||
fn: () =>
|
||||
scoreValue(
|
||||
m.netProfitMargin,
|
||||
thresholds.marginHigh,
|
||||
thresholds.marginMed,
|
||||
weights.margin,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'peg',
|
||||
active: weights.peg > 0 && m.pegRatio != null,
|
||||
fn: () => scorePeg(m.pegRatio, thresholds.pegHigh, thresholds.pegMed, weights.peg),
|
||||
},
|
||||
{
|
||||
key: 'revenue',
|
||||
active: weights.revenue > 0 && m.revenueGrowth != null,
|
||||
fn: () =>
|
||||
scoreValue(m.revenueGrowth, thresholds.revHigh, thresholds.revMed, weights.revenue),
|
||||
},
|
||||
{
|
||||
key: 'fcf',
|
||||
active: weights.fcf > 0 && m.fcfYield != null,
|
||||
fn: () =>
|
||||
scoreValue(m.fcfYield, thresholds.fcfHigh ?? 5, thresholds.fcfMed ?? 2, weights.fcf),
|
||||
},
|
||||
{
|
||||
key: 'yield',
|
||||
active: (weights.yield ?? 0) > 0 && m.dividendYield != null,
|
||||
fn: () => (m.dividendYield >= (thresholds.minYield ?? 4) ? weights.yield : -1),
|
||||
},
|
||||
{
|
||||
key: 'pFFO',
|
||||
active: (weights.pFFO ?? 0) > 0 && m.pFFO != null,
|
||||
fn: () => (m.pFFO <= (thresholds.maxPFFO ?? 15) ? weights.pFFO : -2),
|
||||
},
|
||||
{
|
||||
key: 'priceToBook',
|
||||
active: (weights.priceToBook ?? 0) > 0 && m.priceToBook != null,
|
||||
fn: () => scoreValue(1 / m.priceToBook, 1 / 1.0, 1 / 2.0, weights.priceToBook),
|
||||
},
|
||||
];
|
||||
|
||||
const breakdown = {};
|
||||
const totalScore = factors.reduce((sum, f) => {
|
||||
if (!f.active) return sum;
|
||||
breakdown[f.key] = f.fn();
|
||||
return sum + breakdown[f.key];
|
||||
}, 0);
|
||||
|
||||
const riskFlags = [
|
||||
m.beta != null && m.beta > 1.5 && `High volatility (β ${m.beta.toFixed(2)})`,
|
||||
m.beta != null && m.beta < 0 && `Inverse market correlation (β ${m.beta.toFixed(2)})`,
|
||||
m.week52Position != null && m.week52Position > 0.9 && 'Near 52-week high — crowded trade',
|
||||
m.week52Position != null &&
|
||||
m.week52Position < 0.1 &&
|
||||
'Near 52-week low — potential opportunity',
|
||||
].filter(Boolean);
|
||||
|
||||
return {
|
||||
label: this._label(totalScore),
|
||||
scoreSummary: `Score: ${totalScore}`,
|
||||
audit: { passedGates: true, breakdown, riskFlags: riskFlags.length ? riskFlags : null },
|
||||
};
|
||||
},
|
||||
|
||||
_label(score) {
|
||||
if (score >= 8) return '🟢 BUY (High Conviction)';
|
||||
if (score >= 4) return '🟢 BUY (Speculative)';
|
||||
if (score >= 0) return '🟡 HOLD';
|
||||
return '🔴 REJECT';
|
||||
},
|
||||
|
||||
_sanitize(m) {
|
||||
const w52 =
|
||||
m.week52High > 0 && m.week52Low != null && m.currentPrice > 0
|
||||
? (m.currentPrice - m.week52Low) / (m.week52High - m.week52Low)
|
||||
: null;
|
||||
return {
|
||||
debtToEquity: n(m.debtToEquity),
|
||||
quickRatio: n(m.quickRatio),
|
||||
peRatio: n(m.peRatio),
|
||||
pegRatio: n(m.pegRatio),
|
||||
priceToBook: n(m.priceToBook),
|
||||
netProfitMargin: n(m.netProfitMargin),
|
||||
operatingMargin: n(m.operatingMargin),
|
||||
returnOnEquity: n(m.returnOnEquity),
|
||||
revenueGrowth: n(m.revenueGrowth),
|
||||
fcfYield: n(m.fcfYield),
|
||||
dividendYield: n(m.dividendYield),
|
||||
pFFO: n(m.pFFO),
|
||||
beta: n(m.beta),
|
||||
week52Position: w52,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import screenerRoutes from './routes/screener.js';
|
||||
import financeRoutes from './routes/finance.js';
|
||||
import callsRoutes from './routes/calls.js';
|
||||
import { YahooClient } from '../market/YahooClient.js';
|
||||
import { LLMAnalyst } from '../analyst/LLMAnalyst.js';
|
||||
import { noopLogger } from './utils/logger.js';
|
||||
|
||||
export async function buildApp({ logger = true } = {}) {
|
||||
const app = Fastify({ logger });
|
||||
|
||||
await app.register(cors, {
|
||||
origin: process.env.CLIENT_ORIGIN ?? 'http://localhost:5173',
|
||||
});
|
||||
|
||||
await app.register(screenerRoutes);
|
||||
await app.register(financeRoutes);
|
||||
await app.register(callsRoutes);
|
||||
|
||||
// POST /api/analyze — fetch Yahoo news for tickers and run LLM analysis
|
||||
app.post('/api/analyze', {
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['tickers'],
|
||||
properties: {
|
||||
tickers: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 50 },
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (req, reply) => {
|
||||
if (!process.env.ANTHROPIC_API_KEY) {
|
||||
return reply.code(400).send({ error: 'ANTHROPIC_API_KEY is not set in .env' });
|
||||
}
|
||||
|
||||
const tickers = req.body.tickers.map((t) => t.toUpperCase());
|
||||
const client = new YahooClient();
|
||||
const llm = new LLMAnalyst({ logger: noopLogger });
|
||||
|
||||
const seen = new Map();
|
||||
await Promise.all(
|
||||
tickers.slice(0, 10).map(async (ticker) => {
|
||||
try {
|
||||
const { news = [] } = await client.yf.search(ticker, { newsCount: 3, quotesCount: 0 });
|
||||
for (const s of news) {
|
||||
if (!seen.has(s.title)) {
|
||||
seen.set(s.title, {
|
||||
title: s.title,
|
||||
publisher: s.publisher,
|
||||
link: s.link,
|
||||
relatedTickers: s.relatedTickers ?? [],
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const stories = [...seen.values()].slice(0, 15);
|
||||
|
||||
if (!stories.length) {
|
||||
return reply.code(200).send({ analysis: null, reason: 'no_stories' });
|
||||
}
|
||||
|
||||
const analysis = await llm.analyze(stories, tickers);
|
||||
return { analysis };
|
||||
},
|
||||
});
|
||||
|
||||
app.get('/health', async () => ({ status: 'ok' }));
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { MarketCallStore } from '../../calls/MarketCallStore.js';
|
||||
import { ScreenerEngine } from '../../screener/ScreenerEngine.js';
|
||||
import { YahooClient } from '../../market/YahooClient.js';
|
||||
import { chunkArray } from '../../screener/Chunker.js';
|
||||
import { noopLogger } from '../utils/logger.js';
|
||||
const store = new MarketCallStore();
|
||||
|
||||
// Takes a screener result entry and flattens it to a snapshot record
|
||||
const toSnapshot = (r) => {
|
||||
if (!r) return null;
|
||||
const m = r.asset?.displayMetrics ?? r.asset?.getDisplayMetrics?.() ?? {};
|
||||
return {
|
||||
price: r.asset?.currentPrice ?? null,
|
||||
signal: r.signal ?? null,
|
||||
inflatedVerdict: r.inflated?.label ?? null,
|
||||
fundamentalVerdict: r.fundamental?.label ?? null,
|
||||
pe: m['P/E'] ?? null,
|
||||
roe: m['ROE%'] ?? null,
|
||||
fcf: m['FCF Yld%'] ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
export default async function callsRoutes(app) {
|
||||
// GET /api/calls — list all market calls (newest first)
|
||||
app.get('/api/calls', async () => {
|
||||
return { calls: store.list() };
|
||||
});
|
||||
|
||||
// GET /api/calls/:id — get one call + enrich with current prices for comparison
|
||||
app.get('/api/calls/:id', async (req, reply) => {
|
||||
const call = store.get(req.params.id);
|
||||
if (!call) return reply.code(404).send({ error: 'Call not found' });
|
||||
|
||||
// Re-screen the tickers to get current prices for comparison
|
||||
let current = {};
|
||||
if (call.tickers.length > 0) {
|
||||
try {
|
||||
const engine = new ScreenerEngine({ logger: noopLogger });
|
||||
const results = await engine.screenTickers(call.tickers);
|
||||
const all = [...results.STOCK, ...results.ETF, ...results.BOND];
|
||||
for (const r of all) {
|
||||
current[r.asset.ticker] = toSnapshot(r);
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — return call without current prices
|
||||
}
|
||||
}
|
||||
|
||||
return { ...call, current };
|
||||
});
|
||||
|
||||
// POST /api/calls — create a new market call and snapshot current prices
|
||||
app.post('/api/calls', {
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['title', 'quarter', 'thesis', 'tickers'],
|
||||
properties: {
|
||||
title: { type: 'string', minLength: 3 },
|
||||
quarter: { type: 'string', minLength: 2 },
|
||||
date: { type: 'string' },
|
||||
thesis: { type: 'string', minLength: 10 },
|
||||
tickers: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 30 },
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (req, reply) => {
|
||||
const { title, quarter, date, thesis, tickers } = req.body;
|
||||
const upperTickers = tickers.map((t) => t.toUpperCase());
|
||||
|
||||
// Snapshot current screener data for each ticker
|
||||
let snapshot = {};
|
||||
try {
|
||||
const engine = new ScreenerEngine({ logger: noopLogger });
|
||||
const results = await engine.screenTickers(upperTickers);
|
||||
const all = [...results.STOCK, ...results.ETF, ...results.BOND];
|
||||
for (const r of all) {
|
||||
snapshot[r.asset.ticker] = toSnapshot(r);
|
||||
}
|
||||
} catch (err) {
|
||||
app.log.warn('Could not snapshot prices for market call:', err.message);
|
||||
}
|
||||
|
||||
const call = store.create({ title, quarter, date, thesis, tickers: upperTickers, snapshot });
|
||||
return reply.code(201).send(call);
|
||||
},
|
||||
});
|
||||
|
||||
// DELETE /api/calls/:id
|
||||
app.delete('/api/calls/:id', async (req, reply) => {
|
||||
const deleted = store.delete(req.params.id);
|
||||
if (!deleted) return reply.code(404).send({ error: 'Call not found' });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// GET /api/calls/calendar?tickers=AAPL,MSFT (or omit to use all call tickers)
|
||||
// Returns upcoming earnings dates, ex-dividend dates and dividend dates per ticker.
|
||||
// Fetched in parallel batches of 5 with rate-limit delay.
|
||||
app.get('/api/calls/calendar', async (req) => {
|
||||
const client = new YahooClient();
|
||||
|
||||
// Resolve tickers: from query param, or aggregate all unique tickers across all calls
|
||||
let tickers;
|
||||
if (req.query.tickers) {
|
||||
tickers = req.query.tickers
|
||||
.split(',')
|
||||
.map((t) => t.trim().toUpperCase())
|
||||
.filter(Boolean);
|
||||
} else {
|
||||
const allCalls = store.list();
|
||||
const set = new Set(allCalls.flatMap((c) => c.tickers));
|
||||
tickers = [...set];
|
||||
}
|
||||
|
||||
if (tickers.length === 0) return { events: [] };
|
||||
|
||||
// Fetch calendarEvents in parallel batches
|
||||
const results = {};
|
||||
for (const batch of chunkArray(tickers, 5)) {
|
||||
await Promise.all(
|
||||
batch.map(async (ticker) => {
|
||||
const cal = await client.fetchCalendarEvents(ticker);
|
||||
if (cal) results[ticker] = cal;
|
||||
}),
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
|
||||
// Flatten into a sorted event list
|
||||
const events = [];
|
||||
const now = Date.now();
|
||||
|
||||
for (const [ticker, cal] of Object.entries(results)) {
|
||||
// Upcoming earnings dates
|
||||
for (const dateVal of cal.earnings?.earningsDate ?? []) {
|
||||
const d = new Date(dateVal);
|
||||
events.push({
|
||||
ticker,
|
||||
type: 'earnings',
|
||||
date: d.toISOString().slice(0, 10),
|
||||
label: 'Earnings',
|
||||
detail: cal.earnings.isEarningsDateEstimate ? 'Estimated' : 'Confirmed',
|
||||
epsEstimate: cal.earnings.earningsAverage ?? null,
|
||||
revEstimate: cal.earnings.revenueAverage ?? null,
|
||||
isPast: d.getTime() < now,
|
||||
});
|
||||
}
|
||||
|
||||
// Ex-dividend date
|
||||
if (cal.exDividendDate) {
|
||||
const d = new Date(cal.exDividendDate);
|
||||
events.push({
|
||||
ticker,
|
||||
type: 'exdividend',
|
||||
date: d.toISOString().slice(0, 10),
|
||||
label: 'Ex-Dividend',
|
||||
detail: null,
|
||||
isPast: d.getTime() < now,
|
||||
});
|
||||
}
|
||||
|
||||
// Dividend payment date
|
||||
if (cal.dividendDate) {
|
||||
const d = new Date(cal.dividendDate);
|
||||
events.push({
|
||||
ticker,
|
||||
type: 'dividend',
|
||||
date: d.toISOString().slice(0, 10),
|
||||
label: 'Dividend',
|
||||
detail: null,
|
||||
isPast: d.getTime() < now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: upcoming first, then past
|
||||
events.sort((a, b) => {
|
||||
if (a.isPast !== b.isPast) return a.isPast ? 1 : -1;
|
||||
return a.isPast
|
||||
? new Date(b.date) - new Date(a.date) // most recent past first
|
||||
: new Date(a.date) - new Date(b.date); // soonest upcoming first
|
||||
});
|
||||
|
||||
return { events, tickers };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import { ScreenerEngine } from '../../screener/ScreenerEngine.js';
|
||||
import { PersonalFinanceAnalyzer } from '../../finance/PersonalFinanceAnalyzer.js';
|
||||
import { PortfolioAdvisor } from '../../finance/PortfolioAdvisor.js';
|
||||
import { SimpleFINClient } from '../../finance/clients/SimpleFINClient.js';
|
||||
import { noopLogger } from '../utils/logger.js';
|
||||
const PORTFOLIO_PATH = './portfolio.json';
|
||||
|
||||
export default async function financeRoutes(app) {
|
||||
// GET /api/finance/portfolio
|
||||
// Returns: { advice, personalFinance, marketContext }
|
||||
app.get('/api/finance/portfolio', async (req, reply) => {
|
||||
if (!existsSync(PORTFOLIO_PATH)) {
|
||||
return reply.code(404).send({ error: 'portfolio.json not found' });
|
||||
}
|
||||
|
||||
const { holdings } = JSON.parse(readFileSync(PORTFOLIO_PATH, 'utf8'));
|
||||
|
||||
// SimpleFIN is optional — omit if not configured
|
||||
let personalFinance = null;
|
||||
if (process.env.SIMPLEFIN_ACCESS_URL) {
|
||||
const client = new SimpleFINClient({ logger: noopLogger });
|
||||
const { accounts } = await client.getAccounts();
|
||||
personalFinance = new PersonalFinanceAnalyzer().analyse(accounts);
|
||||
}
|
||||
|
||||
// Normalize dot-notation tickers to Yahoo Finance format (BRK.B → BRK-B)
|
||||
const normalizeYahoo = (t) => t.toUpperCase().replace(/\./g, '-');
|
||||
|
||||
const screenable = holdings
|
||||
.filter((h) => (h.type ?? 'stock') !== 'crypto')
|
||||
.map((h) => normalizeYahoo(h.ticker));
|
||||
|
||||
const engine = new ScreenerEngine({ logger: noopLogger });
|
||||
const results =
|
||||
screenable.length > 0
|
||||
? await engine.screenTickers(screenable)
|
||||
: { STOCK: [], ETF: [], BOND: [], ERROR: [], marketContext: {} };
|
||||
|
||||
const advice = await new PortfolioAdvisor().advise(holdings, results);
|
||||
|
||||
return { advice, personalFinance, marketContext: results.marketContext };
|
||||
});
|
||||
|
||||
// POST /api/finance/holdings
|
||||
// Add or update a single holding in portfolio.json.
|
||||
// Body: { ticker, shares, costBasis, type, source }
|
||||
app.post('/api/finance/holdings', {
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['ticker', 'shares'],
|
||||
properties: {
|
||||
ticker: { type: 'string', minLength: 1, maxLength: 10 },
|
||||
shares: { type: 'number', exclusiveMinimum: 0 },
|
||||
costBasis: { type: 'number', minimum: 0 },
|
||||
type: { type: 'string', enum: ['stock', 'etf', 'bond', 'crypto'] },
|
||||
source: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (req, reply) => {
|
||||
const { ticker, shares, costBasis = 0, type = 'stock', source = 'Manual' } = req.body;
|
||||
const normalized = ticker.toUpperCase().trim();
|
||||
|
||||
const portfolio = existsSync(PORTFOLIO_PATH)
|
||||
? JSON.parse(readFileSync(PORTFOLIO_PATH, 'utf8'))
|
||||
: { holdings: [] };
|
||||
|
||||
const idx = portfolio.holdings.findIndex((h) => h.ticker.toUpperCase() === normalized);
|
||||
|
||||
const entry = { ticker: normalized, shares, costBasis, type, source };
|
||||
|
||||
if (idx >= 0) {
|
||||
portfolio.holdings[idx] = entry; // update existing
|
||||
} else {
|
||||
portfolio.holdings.push(entry); // add new
|
||||
}
|
||||
|
||||
writeFileSync(PORTFOLIO_PATH, JSON.stringify(portfolio, null, 2), 'utf8');
|
||||
return reply.code(201).send(entry);
|
||||
},
|
||||
});
|
||||
|
||||
// DELETE /api/finance/holdings/:ticker
|
||||
// Remove a holding from portfolio.json.
|
||||
app.delete('/api/finance/holdings/:ticker', async (req, reply) => {
|
||||
const ticker = req.params.ticker.toUpperCase();
|
||||
|
||||
if (!existsSync(PORTFOLIO_PATH))
|
||||
return reply.code(404).send({ error: 'portfolio.json not found' });
|
||||
|
||||
const portfolio = JSON.parse(readFileSync(PORTFOLIO_PATH, 'utf8'));
|
||||
const before = portfolio.holdings.length;
|
||||
portfolio.holdings = portfolio.holdings.filter((h) => h.ticker.toUpperCase() !== ticker);
|
||||
|
||||
if (portfolio.holdings.length === before)
|
||||
return reply.code(404).send({ error: 'Holding not found' });
|
||||
|
||||
writeFileSync(PORTFOLIO_PATH, JSON.stringify(portfolio, null, 2), 'utf8');
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// GET /api/finance/market-context
|
||||
// Returns live benchmark data without running a full screen
|
||||
app.get('/api/finance/market-context', async () => {
|
||||
const engine = new ScreenerEngine({ logger: noopLogger });
|
||||
return engine.benchmarkProvider.getMarketContext();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { ScreenerEngine } from '../../screener/ScreenerEngine.js';
|
||||
import { noopLogger } from '../utils/logger.js';
|
||||
|
||||
// Class instances don't survive JSON.stringify — call getDisplayMetrics() on the
|
||||
// server so the browser receives plain serializable objects.
|
||||
const serializeAssets = (arr) =>
|
||||
arr.map((r) => ({
|
||||
...r,
|
||||
asset: {
|
||||
ticker: r.asset.ticker,
|
||||
type: r.asset.type,
|
||||
currentPrice: r.asset.currentPrice,
|
||||
metrics: r.asset.metrics,
|
||||
displayMetrics: r.asset.getDisplayMetrics(),
|
||||
},
|
||||
}));
|
||||
|
||||
export default async function screenerRoutes(app) {
|
||||
// Shared engine — BenchmarkProvider caches for 1 hour across requests.
|
||||
const engine = new ScreenerEngine({ logger: noopLogger });
|
||||
|
||||
// POST /api/screen
|
||||
// Body: { tickers: string[] }
|
||||
// Returns: { STOCK, ETF, BOND, ERROR, marketContext }
|
||||
app.post('/api/screen', {
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['tickers'],
|
||||
properties: {
|
||||
tickers: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 50 },
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (req) => {
|
||||
const tickers = req.body.tickers.map((t) => t.toUpperCase());
|
||||
const results = await engine.screenTickers(tickers);
|
||||
return {
|
||||
...results,
|
||||
STOCK: serializeAssets(results.STOCK),
|
||||
ETF: serializeAssets(results.ETF),
|
||||
BOND: serializeAssets(results.BOND),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// GET /api/screen/catalysts
|
||||
// Returns: { tickers, stories, analysis? }
|
||||
// analysis is present only when ANTHROPIC_API_KEY is set.
|
||||
app.get('/api/screen/catalysts', async () => {
|
||||
const { CatalystAnalyst } = await import('../../analyst/CatalystAnalyst.js');
|
||||
|
||||
const catalyst = new CatalystAnalyst({ logger: noopLogger });
|
||||
const { tickers, stories } = await catalyst.run();
|
||||
|
||||
return { tickers, stories };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Shared server-side logger utilities.
|
||||
*
|
||||
* noopLogger — silent logger for use in API server context where stdout
|
||||
* output from screener/analyst classes would pollute the request log.
|
||||
* Pass as { logger: noopLogger } to ScreenerEngine, BenchmarkProvider,
|
||||
* CatalystAnalyst, SimpleFINClient, LLMAnalyst.
|
||||
*/
|
||||
export const noopLogger = {
|
||||
write: () => {},
|
||||
log: () => {},
|
||||
warn: () => {},
|
||||
};
|
||||
Reference in New Issue
Block a user