phase-2: extract shared utils

This commit is contained in:
Sai Kiran Vella
2026-06-04 11:06:30 -04:00
parent d87f0b8427
commit dc7ee22135
49 changed files with 299 additions and 120 deletions
+53
View File
@@ -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];
}
}
+78
View File
@@ -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;
}
}
}
+80
View File
@@ -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;
}
}
+218
View File
@@ -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 },
},
};
+53
View File
@@ -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,
};
+62
View File
@@ -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,
};
}
}
+167
View File
@@ -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;
}
}
+201
View File
@@ -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`);
}
}
+73
View File
@@ -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;
}
}
}
+63
View File
@@ -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) } };
}
}
+40
View File
@@ -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;
}
}
}
+304
View File
@@ -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 &amp; 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';
}
}
+392
View File
@@ -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
);
}
}
+4
View File
@@ -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),
);
+153
View File
@@ -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,
});
+33
View File
@@ -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;
},
};
+141
View File
@@ -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;
}
}
+19
View File
@@ -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();
}
}
+29
View File
@@ -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})`,
};
}
}
+26
View File
@@ -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)}%`,
};
}
}
+134
View File
@@ -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;
}
}
+40
View File
@@ -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,
};
},
};
+36
View File
@@ -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 },
};
},
};
+157
View File
@@ -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,
};
},
};
+76
View File
@@ -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;
}
+186
View File
@@ -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 };
});
}
+110
View File
@@ -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();
});
}
+58
View File
@@ -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 };
});
}
+13
View File
@@ -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: () => {},
};