phase-6: typescript introduction

This commit is contained in:
Kazuma
2026-06-04 22:16:48 -04:00
committed by Kazuma
parent de8427d578
commit 2b785aa861
69 changed files with 2323 additions and 1036 deletions
@@ -1,16 +1,32 @@
import { YahooClient } from '../market/YahooClient.js';
import type { Logger } from '../types.js';
interface Story {
title: string;
publisher: string;
link: string;
relatedTickers: string[];
}
interface CatalystResult {
tickers: string[];
stories: Story[];
}
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 } = {}) {
private client: YahooClient;
private logger: Pick<Logger, 'write'>;
constructor({ logger }: { logger?: Pick<Logger, 'write'> } = {}) {
this.client = new YahooClient();
this.logger = logger ?? { write: (msg) => process.stdout.write(msg) };
this.logger = logger ?? { write: (msg: string) => process.stdout.write(msg) };
}
async run() {
async run(): Promise<CatalystResult> {
this.logger.write('🔍 Fetching market news...');
const stories = await this._fetchNews();
const tickers = this._extractTickers(stories);
@@ -18,12 +34,15 @@ export class CatalystAnalyst {
return { tickers, stories };
}
async _fetchNews() {
const seen = new Map();
private async _fetchNews(): Promise<Story[]> {
const seen = new Map<string, Story>();
for (const query of NEWS_QUERIES) {
try {
const { news = [] } = await this.client.yf.search(query, { newsCount: 8, quotesCount: 0 });
for (const s of news) {
const { news = [] } = await (this.client as any).yf.search(query, {
newsCount: 8,
quotesCount: 0,
});
for (const s of news as any[]) {
if (!seen.has(s.title)) {
seen.set(s.title, {
title: s.title,
@@ -40,8 +59,8 @@ export class CatalystAnalyst {
return [...seen.values()].slice(0, MAX_STORIES);
}
_extractTickers(stories) {
const tickers = new Set();
private _extractTickers(stories: Story[]): string[] {
const tickers = new Set<string>();
for (const { relatedTickers } of stories) {
for (const t of relatedTickers) {
const clean = t.split(':')[0].toUpperCase();
@@ -1,15 +1,10 @@
import Anthropic from '@anthropic-ai/sdk';
import type { Logger, LLMAnalysis } from '../types.js';
// 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.
interface Story {
title: string;
publisher?: string;
}
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.
@@ -32,21 +27,21 @@ Return ONLY valid JSON in this exact shape — no markdown, no explanation:
}`;
export class LLMAnalyst {
constructor({ logger } = {}) {
private logger: Pick<Logger, 'log' | 'warn'>;
private client: Anthropic | null;
constructor({ logger }: { logger?: Pick<Logger, 'log' | 'warn'> } = {}) {
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 = []) {
async analyze(stories: Story[], existingTickers: string[] = []): Promise<LLMAnalysis | null> {
if (!this.client) {
this.logger.warn('LLMAnalyst: ANTHROPIC_API_KEY not set — skipping analysis');
return null;
}
if (!stories?.length) return null;
const headlines = stories
@@ -64,14 +59,14 @@ export class LLMAnalyst {
messages: [{ role: 'user', content: userMessage }],
});
const raw = response.content[0]?.text ?? '';
const raw = (response.content[0] as { text?: string })?.text ?? '';
const cleaned = raw
.replace(/^```(?:json)?\s*/i, '')
.replace(/```\s*$/i, '')
.trim();
return JSON.parse(cleaned);
return JSON.parse(cleaned) as LLMAnalysis;
} catch (err) {
this.logger.warn('LLMAnalyst: analysis failed —', err.message);
this.logger.warn('LLMAnalyst: analysis failed —', (err as Error).message);
return null;
}
}
-80
View File
@@ -1,80 +0,0 @@
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;
}
}
+76
View File
@@ -0,0 +1,76 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { randomUUID } from 'crypto';
import type { MarketCall, Signal, TickerSnapshot } from '../types.js';
const STORE_PATH = './market-calls.json';
interface StoreData {
calls: (MarketCall & { createdAt: string })[];
}
interface CreateCallInput {
title: string;
quarter: string;
date?: string;
thesis: string;
tickers: string[];
snapshot?: Record<string, TickerSnapshot>;
}
export class MarketCallStore {
private _load(): StoreData {
if (!existsSync(STORE_PATH)) return { calls: [] };
try {
return JSON.parse(readFileSync(STORE_PATH, 'utf8')) as StoreData;
} catch {
return { calls: [] };
}
}
private _save(data: StoreData): void {
writeFileSync(STORE_PATH, JSON.stringify(data, null, 2), 'utf8');
}
list(): (MarketCall & { createdAt: string })[] {
return this._load().calls.sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
}
get(id: string): (MarketCall & { createdAt: string }) | null {
return this._load().calls.find((c) => c.id === id) ?? null;
}
create({
title,
quarter,
date,
thesis,
tickers,
snapshot,
}: CreateCallInput): MarketCall & { createdAt: string } {
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: string): boolean {
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;
}
}
@@ -1,7 +1,9 @@
// Credit rating scale (S&P convention).
// Bond.js converts letter ratings to these numbers; BondScorer uses them for gate checks.
import type { Sector } from './constants.js';
// ── Credit rating scale (S&P convention) ─────────────────────────────────
// Bond.ts converts letter ratings to these numbers; BondScorer uses them for gate checks.
// Investment grade = BBB (7) and above.
export const CREDIT_RATING_SCALE = {
export const CREDIT_RATING_SCALE: Record<string, number> = {
AAA: 10,
AA: 9,
A: 8,
@@ -14,16 +16,38 @@ export const CREDIT_RATING_SCALE = {
D: 1,
};
// ── Scoring rule shape ────────────────────────────────────────────────────
interface GateSet extends Record<string, number> {}
interface WeightSet extends Record<string, number> {}
interface ThresholdSet extends Record<string, number> {}
interface RuleBlock {
gates: GateSet;
weights: WeightSet;
thresholds: ThresholdSet;
}
interface StockRules extends RuleBlock {
SECTOR_OVERRIDE: Partial<Record<Sector, Partial<RuleBlock>>>;
}
interface ScoringRulesShape {
STOCK: StockRules;
ETF: RuleBlock;
BOND: RuleBlock;
}
// ─────────────────────────────────────────────────────────────────────────────
// Fundamental baseline — Graham / value-investing style.
// MarketRegime.js overrides the valuation gates for INFLATED-mode analysis.
// MarketRegime.ts overrides the valuation gates for INFLATED-mode analysis.
// Sector overrides are structural — they apply in both modes.
// ─────────────────────────────────────────────────────────────────────────────
export const ScoringRules = {
export const ScoringRules: ScoringRulesShape = {
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
maxDebtToEquity: 1.5, // Graham ceiling; most distress starts above 2x
minQuickRatio: 0.8, // 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)
},
@@ -33,48 +57,36 @@ export const ScoringRules = {
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
fcf: 3, // 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
marginHigh: 15, // 15% net margin is genuinely excellent across most sectors
marginMed: 8, // 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
roeHigh: 15, // sustainable 15% ROE is Buffett-quality; 20% is rare/fleeting
roeMed: 10, // 10% is the cost-of-equity floor for most businesses
pegHigh: 0.75, // 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
revHigh: 10, // 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,
@@ -87,9 +99,6 @@ export const ScoringRules = {
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 },
@@ -103,8 +112,6 @@ export const ScoringRules = {
},
},
// 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 },
@@ -120,11 +127,6 @@ export const ScoringRules = {
},
},
// 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 },
@@ -144,10 +146,6 @@ export const ScoringRules = {
},
},
// 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 },
@@ -167,10 +165,6 @@ export const ScoringRules = {
},
},
// 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 },
@@ -193,24 +187,17 @@ export const ScoringRules = {
},
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
weights: { yield: 2, lowCost: 4, fiveYearReturn: 2 },
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
maxExpense: 0.05,
minVolume: 1_000_000,
minFiveYearReturn: 8.0,
},
},
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 },
@@ -1,17 +1,19 @@
import type { Signal, AssetType, RateRegime } from '../types.js';
export const SIGNAL = {
STRONG_BUY: '✅ Strong Buy',
MOMENTUM: '⚡ Momentum',
SPECULATION: '⚠️ Speculation',
NEUTRAL: '🔄 Neutral',
AVOID: '❌ Avoid',
};
STRONG_BUY: '✅ Strong Buy' as Signal,
MOMENTUM: '⚡ Momentum' as Signal,
SPECULATION: '⚠️ Speculation' as Signal,
NEUTRAL: '🔄 Neutral' as Signal,
AVOID: '❌ Avoid' as Signal,
} as const;
export const ASSET_TYPE = {
STOCK: 'STOCK',
ETF: 'ETF',
BOND: 'BOND',
STOCK: 'STOCK' as AssetType,
ETF: 'ETF' as AssetType,
BOND: 'BOND' as AssetType,
CRYPTO: 'crypto',
};
} as const;
export const SECTOR = {
TECHNOLOGY: 'TECHNOLOGY',
@@ -23,20 +25,22 @@ export const SECTOR = {
CONSUMER_STAPLES: 'CONSUMER_STAPLES',
CONSUMER_DISCRETIONARY: 'CONSUMER_DISCRETIONARY',
GENERAL: 'GENERAL',
};
} as const;
export type Sector = (typeof SECTOR)[keyof typeof SECTOR];
export const SCORE_MODE = {
FUNDAMENTAL: 'FUNDAMENTAL',
INFLATED: 'INFLATED',
};
} as const;
export const REGIME = {
LOW: 'LOW',
NORMAL: 'NORMAL',
HIGH: 'HIGH',
};
LOW: 'LOW' as RateRegime,
NORMAL: 'NORMAL' as RateRegime,
HIGH: 'HIGH' as RateRegime,
} as const;
export const YAHOO_MODULES = [
export const YAHOO_MODULES: string[] = [
'assetProfile',
'financialData',
'defaultKeyStatistics',
@@ -44,7 +48,7 @@ export const YAHOO_MODULES = [
'summaryDetail',
];
export const SIGNAL_ORDER = {
export const SIGNAL_ORDER: Record<string, number> = {
[SIGNAL.STRONG_BUY]: 0,
[SIGNAL.MOMENTUM]: 1,
[SIGNAL.NEUTRAL]: 2,
@@ -1,14 +1,38 @@
// 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
interface Transaction {
amount: number;
category: string;
}
interface Account {
type: string;
balance: number;
transactions: Transaction[];
[key: string]: unknown;
}
interface CategoryBreakdown {
category: string;
amount: number;
pct: string;
}
interface FinanceAnalysis {
netWorth: number;
totalAssets: number;
totalLiabilities: number;
totalCash: number;
totalInvestments: number;
cashPct: string;
investPct: string;
totalIncome: number;
totalSpend: number;
savingsRate: string | null;
categoryBreakdown: CategoryBreakdown[];
accounts: Account[];
}
export class PersonalFinanceAnalyzer {
analyse(accounts) {
analyse(accounts: Account[]): FinanceAnalysis {
const assets = accounts.filter((a) => !['CREDIT', 'LOAN'].includes(a.type));
const liabilities = accounts.filter((a) => ['CREDIT', 'LOAN'].includes(a.type));
@@ -21,21 +45,19 @@ export class PersonalFinanceAnalyzer {
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 = {};
const byCategory: Record<string, number> = {};
for (const tx of spending) {
byCategory[tx.category] = (byCategory[tx.category] ?? 0) + Math.abs(tx.amount);
}
const categoryBreakdown = Object.entries(byCategory)
const categoryBreakdown: CategoryBreakdown[] = Object.entries(byCategory)
.sort((a, b) => b[1] - a[1])
.map(([category, amount]) => ({
category,
@@ -1,24 +1,52 @@
import { SIGNAL } from '../config/constants.js';
import { YahooClient } from '../market/YahooClient.js';
import type { PortfolioHolding, Signal, ScreenerResult, AssetResult } from '../types.js';
interface PositionCalc {
totalCost: string;
marketValue: string | null;
gainLossPct: string | null;
}
interface AdviceOutput {
action: string;
reason: string;
}
interface AdviceRow {
ticker: string;
type: string;
source: string;
shares: number;
costBasis: number;
currentPrice: number | null;
marketValue: string | null;
totalCost: string;
gainLossPct: string | null;
signal: Signal | '—';
inflated: string;
fundamental: string;
advice: string;
reason: string;
}
export class PortfolioAdvisor {
private client: YahooClient;
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 ?? []),
]) {
async advise(
holdings: PortfolioHolding[],
screenedResults: ScreenerResult,
): Promise<AdviceRow[]> {
const resultMap: Record<string, AssetResult> = {};
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
resultMap[t.replace(/-/g, '.')] = r;
resultMap[t.replace(/\./g, '-')] = r;
}
const cryptoPrices = await this._cryptoPrices(holdings.filter((h) => h.type === 'crypto'));
@@ -26,9 +54,9 @@ export class PortfolioAdvisor {
return holdings.map((holding) => {
const type = (holding.type ?? 'stock').toLowerCase();
const source = holding.source ?? '—';
const price =
const price: number | null =
type === 'crypto'
? cryptoPrices[holding.ticker.toUpperCase()]
? (cryptoPrices[holding.ticker.toUpperCase()] ?? null)
: (resultMap[holding.ticker.toUpperCase()]?.asset?.currentPrice ?? null);
return type === 'crypto'
@@ -37,7 +65,12 @@ export class PortfolioAdvisor {
});
}
_stockRow(holding, price, source, result) {
private _stockRow(
holding: PortfolioHolding,
price: number | null,
source: string,
result: AssetResult | undefined,
): AdviceRow {
if (!result) {
return this._row(holding, price, source, '—', '—', '—', {
action: '⚪ Not screened',
@@ -55,7 +88,15 @@ export class PortfolioAdvisor {
);
}
_row(holding, currentPrice, source, signal, inflated, fundamental, { action, reason }) {
private _row(
holding: PortfolioHolding,
currentPrice: number | null,
source: string,
signal: Signal | '—',
inflated: string,
fundamental: string,
{ action, reason }: AdviceOutput,
): AdviceRow {
const { marketValue, totalCost, gainLossPct } = this._position(holding, currentPrice);
return {
ticker: holding.ticker,
@@ -75,19 +116,20 @@ export class PortfolioAdvisor {
};
}
_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 };
private _position(holding: PortfolioHolding, currentPrice: number | null): PositionCalc {
return {
totalCost: (holding.costBasis * holding.shares).toFixed(2),
marketValue: currentPrice != null ? (currentPrice * holding.shares).toFixed(2) : null,
gainLossPct:
currentPrice != null && holding.costBasis > 0
? (((currentPrice - holding.costBasis) / holding.costBasis) * 100).toFixed(1)
: null,
};
}
_cryptoAdvice(holding, price) {
private _cryptoAdvice(holding: PortfolioHolding, price: number | null): AdviceOutput {
const { gainLossPct } = this._position(holding, price);
const g = parseFloat(gainLossPct);
const g = parseFloat(gainLossPct ?? 'NaN');
if (gainLossPct == null)
return {
action: '⚪ No price data',
@@ -109,15 +151,12 @@ export class PortfolioAdvisor {
};
}
_advice(signal, holding, price) {
private _advice(signal: Signal, holding: PortfolioHolding, price: number | null): AdviceOutput {
const { gainLossPct } = this._position(holding, price);
const gain = parseFloat(gainLossPct);
const gain = parseFloat(gainLossPct ?? '0');
switch (signal) {
case SIGNAL.STRONG_BUY:
return {
action: '🟢 Hold & Add',
reason: 'Passes both analyses. Strong conviction.',
};
return { action: '🟢 Hold & Add', reason: 'Passes both analyses. Strong conviction.' };
case SIGNAL.MOMENTUM:
return {
action: '🟡 Hold',
@@ -135,10 +174,7 @@ export class PortfolioAdvisor {
: 'Overvalued fundamentally. Keep position small.',
};
case SIGNAL.NEUTRAL:
return {
action: '🟡 Hold',
reason: 'No clear edge. Review on any catalyst.',
};
return { action: '🟡 Hold', reason: 'No clear edge. Review on any catalyst.' };
case SIGNAL.AVOID:
return {
action: gain > 0 ? '🔴 Sell (Take Profits)' : '🔴 Sell (Cut Loss)',
@@ -152,12 +188,14 @@ export class PortfolioAdvisor {
}
}
async _cryptoPrices(cryptoHoldings) {
const prices = {};
for (const h of cryptoHoldings) {
private async _cryptoPrices(
holdings: PortfolioHolding[],
): Promise<Record<string, number | null>> {
const prices: Record<string, number | null> = {};
for (const h of holdings) {
try {
const summary = await this.client.fetchSummary(h.ticker);
prices[h.ticker.toUpperCase()] = summary.price?.regularMarketPrice ?? null;
prices[h.ticker.toUpperCase()] = summary?.price?.regularMarketPrice ?? null;
} catch {
prices[h.ticker.toUpperCase()] = null;
}
@@ -1,22 +1,48 @@
import fs from 'fs';
import https from 'https';
import http from 'http';
import type { Logger } from '../../types.js';
// 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
interface SimpleFINOptions {
logger?: Logger;
onAccessUrlClaimed?: (url: string) => Promise<void> | void;
}
interface GetAccountsOptions {
startDate?: number;
endDate?: number;
}
interface Transaction {
id: string;
date: string;
amount: number;
description: string;
category: string;
}
interface Account {
id: string;
name: string;
currency: string;
balance: number;
balanceDate: string;
org: string;
type: string;
transactions: Transaction[];
}
interface SimpleFINData {
accounts: Account[];
errors: string[];
}
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 } = {}) {
private accessUrl: string | null;
private logger: Logger;
private onAccessUrlClaimed: ((url: string) => Promise<void> | void) | null;
constructor({ logger, onAccessUrlClaimed }: SimpleFINOptions = {}) {
this.accessUrl = null;
this.logger = logger ?? {
write: (msg) => process.stdout.write(msg),
@@ -26,37 +52,28 @@ export class SimpleFINClient {
this.onAccessUrlClaimed = onAccessUrlClaimed ?? null;
}
async init() {
async init(): Promise<void> {
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);
}
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.',
'SimpleFIN not configured.\nAdd to .env:\n SIMPLEFIN_SETUP_TOKEN=<your setup token from https://beta-bridge.simplefin.org>\nThe Access URL will be saved automatically on first run.',
);
}
async getAccounts(options = {}) {
async getAccounts(options: GetAccountsOptions = {}): Promise<SimpleFINData> {
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 parsed = new URL(this.accessUrl!);
const auth = parsed.username
? 'Basic ' + Buffer.from(`${parsed.username}:${parsed.password}`).toString('base64')
: null;
@@ -65,43 +82,34 @@ export class SimpleFINClient {
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 } : {},
});
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();
const data = (await response.json()) as { accounts?: unknown[]; errors?: string[] };
if (data.errors?.length) {
data.errors.forEach((e) => this.logger.warn(` ⚠ SimpleFIN: ${e}`));
}
return this._normalise(data);
return this._normalise(data as { accounts: unknown[]; errors: string[] });
}
// ── Auth ─────────────────────────────────────────────────────────────────────
async _claimAccessUrl(setupToken) {
private async _claimAccessUrl(setupToken: string): Promise<string> {
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',
`Unexpected response from SimpleFIN: "${accessUrl}"\nSetup 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) {
private _post(url: string): Promise<string> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const lib = parsed.protocol === 'https:' ? https : http;
@@ -112,30 +120,23 @@ export class SimpleFINClient {
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) => {
res.on('data', (chunk: string) => {
body += chunk;
});
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(body.trim());
} else {
reject(new Error(`HTTP ${res.statusCode}: ${body.trim()}`));
}
if ((res.statusCode ?? 0) >= 200 && (res.statusCode ?? 0) < 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) => ({
private _normalise(data: { accounts: unknown[]; errors: string[] }): SimpleFINData {
const accounts = (data.accounts ?? []).map((acc: any) => ({
id: acc.id,
name: acc.name,
currency: acc.currency ?? 'USD',
@@ -143,7 +144,7 @@ export class SimpleFINClient {
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) => ({
transactions: (acc.transactions ?? []).map((tx: any) => ({
id: tx.id,
date: new Date(tx.posted * 1000).toISOString().slice(0, 10),
amount: parseFloat(tx.amount) ?? 0,
@@ -151,11 +152,10 @@ export class SimpleFINClient {
category: this._categorise(tx.description ?? ''),
})),
}));
return { accounts, errors: data.errors ?? [] };
}
_classifyAccount(name) {
private _classifyAccount(name: string): string {
const n = name.toLowerCase();
if (n.includes('checking') || n.includes('current')) return 'CHECKING';
if (n.includes('saving')) return 'SAVINGS';
@@ -166,7 +166,7 @@ export class SimpleFINClient {
return 'OTHER';
}
_categorise(description) {
private _categorise(description: string): string {
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';
@@ -181,14 +181,12 @@ export class SimpleFINClient {
return 'Other';
}
_daysAgo(n) {
private _daysAgo(n: number): number {
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) {
export function saveAccessUrlToEnv(accessUrl: string): void {
try {
const existing = fs.existsSync('.env') ? fs.readFileSync('.env', 'utf8') : '';
if (!existing.includes('SIMPLEFIN_ACCESS_URL')) {
-73
View File
@@ -1,73 +0,0 @@
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;
}
}
}
+87
View File
@@ -0,0 +1,87 @@
import { YahooClient } from './YahooClient.js';
import { REGIME } from '../config/constants.js';
import type { MarketContext, Logger } from '../types.js';
const TTL_MS = 60 * 60 * 1000;
const DEFAULTS: MarketContext = {
sp500Price: 5000,
riskFreeRate: 4.5,
vixLevel: 20,
rateRegime: 'HIGH',
volatilityRegime: 'NORMAL',
benchmarks: { marketPE: 22, techPE: 30, reitYield: 3.5, igSpread: 1.0 },
};
const rateRegime = (rate: number): MarketContext['rateRegime'] =>
rate < 2 ? REGIME.LOW : rate <= 5 ? REGIME.NORMAL : REGIME.HIGH;
const volRegime = (vix: number): MarketContext['volatilityRegime'] =>
vix < 15 ? REGIME.LOW : vix <= 25 ? REGIME.NORMAL : REGIME.HIGH;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pe = (summary: any): number | null =>
summary?.summaryDetail?.trailingPE ?? summary?.defaultKeyStatistics?.forwardPE ?? null;
interface BenchmarkProviderOptions {
logger?: Logger;
}
export class BenchmarkProvider {
private client: YahooClient;
private cache: { data: MarketContext | null; expiresAt: number };
private logger: Logger;
constructor({ logger }: BenchmarkProviderOptions = {}) {
this.client = new YahooClient();
this.cache = { data: null, expiresAt: 0 };
this.logger = logger ?? (console as unknown as Logger);
}
async getMarketContext(): Promise<MarketContext> {
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 =
(sp500 as any)?.price?.regularMarketPrice !== undefined
? ((tn10y as any)?.price?.regularMarketPrice ?? 0)
: 0;
const sp500Price = (sp500 as any)?.price?.regularMarketPrice ?? 0;
const vixLevel = (vix as any)?.price?.regularMarketPrice ?? 0;
if (!sp500Price || !riskFreeRate) throw new Error('Invalid market data (zero values)');
const lqdYield = ((lqd as any)?.summaryDetail?.trailingAnnualDividendYield ?? 0) * 100;
const context: MarketContext = {
sp500Price,
riskFreeRate,
vixLevel,
rateRegime: rateRegime(riskFreeRate),
volatilityRegime: volRegime(vixLevel),
benchmarks: {
marketPE: pe(spy) ?? 22,
techPE: pe(xlk) ?? 30,
reitYield: ((xlre as any)?.summaryDetail?.trailingAnnualDividendYield ?? 0.035) * 100,
igSpread: Math.max(0.1, lqdYield - riskFreeRate),
},
};
this.cache = { data: context, expiresAt: Date.now() + TTL_MS };
return context;
} catch (err) {
this.logger.warn('Market data fetch failed, using defaults:', (err as Error).message);
return this.cache.data ?? DEFAULTS;
}
}
}
@@ -1,8 +1,21 @@
import { SECTOR, ASSET_TYPE, REGIME } from '../config/constants.js';
import type { MarketContext, AssetType } from '../types.js';
interface InflatedOverrides {
gates: Record<string, number>;
thresholds: Record<string, number>;
}
export class MarketRegime {
constructor(marketContext) {
const b = marketContext?.benchmarks ?? {};
private marketPE: number;
private techPE: number;
private reitYield: number;
private igSpread: number;
private rateRegime: string;
private volatilityRegime: string;
constructor(marketContext: Partial<MarketContext>) {
const b = marketContext?.benchmarks ?? ({} as MarketContext['benchmarks']);
this.marketPE = b.marketPE ?? 22;
this.techPE = b.techPE ?? 30;
this.reitYield = b.reitYield ?? 3.5;
@@ -11,18 +24,17 @@ export class MarketRegime {
this.volatilityRegime = marketContext?.volatilityRegime ?? REGIME.NORMAL;
}
getInflatedOverrides(type, sector) {
getInflatedOverrides(type: AssetType, sector?: string): InflatedOverrides {
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) {
private _stock(sector?: string): InflatedOverrides {
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,
@@ -38,8 +50,6 @@ export class MarketRegime {
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: {
@@ -50,14 +60,15 @@ export class MarketRegime {
};
}
_etf() {
private _etf(): InflatedOverrides {
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.
private _bond(): InflatedOverrides {
const spreadMultiplier = this.rateRegime === REGIME.HIGH ? 0.9 : 0.8;
return { gates: {}, thresholds: { minSpread: +(this.igSpread * spreadMultiplier).toFixed(2) } };
return {
gates: {},
thresholds: { minSpread: +(this.igSpread * spreadMultiplier).toFixed(2) },
};
}
}
-40
View File
@@ -1,40 +0,0 @@
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;
}
}
}
+42
View File
@@ -0,0 +1,42 @@
import YahooFinance from 'yahoo-finance2';
export class YahooClient {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private yf: any;
constructor() {
this.yf = new (YahooFinance as unknown as new (opts: object) => unknown)({
suppressNotices: ['yahooSurvey'],
});
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async fetchSummary(ticker: string, retries = 3, backoff = 1000): Promise<any> {
for (let i = 0; i < retries; i++) {
try {
return await (this.yf as any).quoteSummary(ticker, {
modules: [
'assetProfile',
'financialData',
'defaultKeyStatistics',
'price',
'summaryDetail',
],
});
} catch (error) {
if (i === retries - 1) throw error;
await new Promise<void>((res) => setTimeout(res, backoff * (i + 1)));
}
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async fetchCalendarEvents(ticker: string): Promise<any | null> {
try {
const r = await (this.yf as any).quoteSummary(ticker, { modules: ['calendarEvents'] });
return r.calendarEvents ?? null;
} catch {
return null;
}
}
}
@@ -1,20 +1,24 @@
import fs from 'fs';
import path from 'path';
import type { MarketContext } from '../types.js';
export class FinanceReporter {
// Returns the HTML string — useful for server responses.
render(advice, personalFinance, marketContext) {
render(advice: unknown[], personalFinance: unknown, marketContext: MarketContext): string {
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') {
generate(
advice: unknown[],
personalFinance: unknown,
marketContext: MarketContext,
outputPath = './finance-report.html',
): string {
const html = this._build(advice, personalFinance, marketContext);
fs.writeFileSync(outputPath, html, 'utf8');
return path.resolve(outputPath);
}
_build(advice, pf, ctx) {
_build(advice: unknown, pf: unknown, ctx: unknown) {
const date = new Date().toISOString().slice(0, 10);
return `<!DOCTYPE html>
<html lang="en">
@@ -1,17 +1,25 @@
import fs from 'fs';
import path from 'path';
import type { MarketContext } from '../types.js';
// 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) {
render(
results: Record<string, unknown[]>,
marketContext: MarketContext,
personalFinance: unknown = null,
): string {
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') {
generate(
results: Record<string, unknown[]>,
marketContext: MarketContext,
personalFinance: unknown = null,
outputPath = './screener-report.html',
): string {
const html = this._buildHtml(results, marketContext, personalFinance);
fs.writeFileSync(outputPath, html, 'utf8');
return path.resolve(outputPath);
@@ -1,4 +1,4 @@
export const chunkArray = (array, size) =>
export const chunkArray = <T>(array: T[], size: number): T[][] =>
Array.from({ length: Math.ceil(array.length / size) }, (_, i) =>
array.slice(i * size, i * size + size),
);
-153
View File
@@ -1,153 +0,0 @@
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,
});
+137
View File
@@ -0,0 +1,137 @@
import type { AssetType } from '../types.js';
// Shape of the raw Yahoo Finance summary payload (loosely typed — fields vary by asset)
type YahooSummary = Record<string, Record<string, unknown>>;
interface MappedData {
type: AssetType;
ticker: string;
[key: string]: unknown;
}
export const mapToStandardFormat = (ticker: string, summary: YahooSummary): MappedData => {
const quoteType = summary.price?.quoteType as string | undefined;
const category = ((summary.assetProfile?.category as string) || '').toLowerCase();
const yieldVal = (summary.summaryDetail?.trailingAnnualDividendYield as number) ?? 0;
const isBond =
category.includes('bond') ||
category.includes('fixed income') ||
category.includes('treasury') ||
(quoteType === 'ETF' && yieldVal > 0.02 && category === '');
if (quoteType === 'ETF') {
return isBond
? { type: 'BOND', ticker, ...mapBondData(summary) }
: { type: 'ETF', ticker, ...mapEtfData(summary) };
}
return { type: 'STOCK', ticker, ...mapStockData(summary) };
};
const mapStockData = (summary: YahooSummary) => {
const fd = (summary.financialData ?? {}) as Record<string, number | null>;
const ks = (summary.defaultKeyStatistics ?? {}) as Record<string, number | null>;
const sd = (summary.summaryDetail ?? {}) as Record<string, number | null>;
const pr = (summary.price ?? {}) as Record<string, number | null>;
const currentPrice = pr.regularMarketPrice ?? 0;
const sharesOutstanding = ks.sharesOutstanding ?? 0;
const operatingCashflow = fd.operatingCashflow ?? 0;
const freeCashflow = fd.freeCashflow ?? 0;
// P/FFO proxy — used for REIT scoring
const pFFO =
operatingCashflow != null &&
operatingCashflow > 0 &&
sharesOutstanding != null &&
sharesOutstanding > 0
? (currentPrice as number) / (operatingCashflow / sharesOutstanding)
: null;
// FCF yield — negative FCF preserved so cash-burning companies fail the gate
const fcfYield =
freeCashflow !== 0 &&
sharesOutstanding != null &&
sharesOutstanding > 0 &&
currentPrice != null &&
currentPrice > 0
? ((freeCashflow as number) / (sharesOutstanding as number) / (currentPrice as number)) * 100
: null;
// PEG: prefer Yahoo's value, fall back to trailingPE / earningsGrowth
const yahoosPEG = ks.pegRatio ?? null;
const trailingPE = sd.trailingPE ?? null;
const earningsGrowth = fd.earningsGrowth != null ? (fd.earningsGrowth as number) * 100 : null;
const computedPEG =
trailingPE != null && earningsGrowth != null && earningsGrowth > 0
? +((trailingPE as number) / earningsGrowth).toFixed(2)
: null;
const pegRatio = yahoosPEG ?? computedPEG;
// Quick ratio — fall back to currentRatio when missing
const quickRatio = fd.quickRatio ?? fd.currentRatio ?? null;
return {
peRatio: trailingPE ?? ks.forwardPE,
trailingPE,
pegRatio,
priceToBook: ks.priceToBook ?? null,
evToEbitda: ks.enterpriseToEbitda ?? null,
netProfitMargin: fd.profitMargins != null ? (fd.profitMargins as number) * 100 : null,
operatingMargin: fd.operatingMargins != null ? (fd.operatingMargins as number) * 100 : null,
returnOnEquity: fd.returnOnEquity != null ? (fd.returnOnEquity as number) * 100 : null,
revenueGrowth: fd.revenueGrowth != null ? (fd.revenueGrowth as number) * 100 : null,
earningsGrowth,
debtToEquity: fd.debtToEquity != null ? (fd.debtToEquity as number) / 100 : null,
quickRatio,
fcfYield,
pFFO,
dividendYield:
sd.trailingAnnualDividendYield != null
? (sd.trailingAnnualDividendYield as number) * 100
: null,
beta: sd.beta ?? null,
week52High: sd.fiftyTwoWeekHigh ?? null,
week52Low: sd.fiftyTwoWeekLow ?? null,
currentPrice,
assetProfile: summary.assetProfile || {},
};
};
const mapEtfData = (summary: YahooSummary) => ({
expenseRatio: ((summary.summaryDetail?.expenseRatio as number) ?? 0) * 100,
totalAssets: (summary.summaryDetail?.totalAssets as number) ?? 0,
yield: ((summary.summaryDetail?.trailingAnnualDividendYield as number) ?? 0) * 100,
fiveYearReturn: ((summary.defaultKeyStatistics?.fiveYearAverageReturn as number) ?? 0) * 100,
volume:
(summary.summaryDetail?.averageVolume as number) ??
(summary.price?.averageVolume as number) ??
0,
currentPrice: (summary.price?.regularMarketPrice as number) ?? 0,
});
const inferCreditRating = (category: string | undefined): string => {
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';
};
const inferDuration = (category: string | undefined): number => {
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;
};
const mapBondData = (summary: YahooSummary) => ({
yieldToMaturity: ((summary.summaryDetail?.yield as number) ?? 0) * 100,
duration: inferDuration(summary.assetProfile?.category as string),
creditRating: inferCreditRating(summary.assetProfile?.category as string),
currentPrice: (summary.price?.regularMarketPrice as number) ?? 0,
});
-33
View File
@@ -1,33 +0,0 @@
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;
},
};
+49
View File
@@ -0,0 +1,49 @@
import { ScoringRules } from '../config/ScoringConfig.js';
import { MarketRegime } from '../market/MarketRegime.js';
import { SCORE_MODE } from '../config/constants.js';
import type { AssetType, MarketContext } from '../types.js';
interface RuleSet {
gates: Record<string, number>;
weights: Record<string, number>;
thresholds: Record<string, number>;
}
export const RuleMerger = {
getRulesForAsset(
type: AssetType,
metrics: { sector?: string },
marketContext: Partial<MarketContext> = {},
mode: string = SCORE_MODE.FUNDAMENTAL,
): RuleSet {
const base = ScoringRules[type as keyof typeof ScoringRules];
if (!base) throw new Error(`No rules configured for asset type: ${type}`);
// Deep clone to avoid mutating the source config
const rules: RuleSet & { SECTOR_OVERRIDE?: unknown } = JSON.parse(JSON.stringify(base));
if (type === 'STOCK' && metrics.sector) {
const stockBase = ScoringRules.STOCK;
const override =
stockBase.SECTOR_OVERRIDE?.[
metrics.sector.toUpperCase() as keyof typeof stockBase.SECTOR_OVERRIDE
];
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 as MarketContext,
).getInflatedOverrides(type, metrics.sector);
rules.gates = { ...rules.gates, ...gates };
rules.thresholds = { ...rules.thresholds, ...thresholds };
}
return rules;
},
};
@@ -10,45 +10,74 @@ 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';
import type { Logger, MarketContext, Signal, AssetType, ScreenerResult } from '../types.js';
const SCORERS = {
const SCORERS: Record<AssetType, typeof StockScorer | typeof EtfScorer | typeof BondScorer> = {
[ASSET_TYPE.STOCK]: StockScorer,
[ASSET_TYPE.ETF]: EtfScorer,
[ASSET_TYPE.BOND]: BondScorer,
};
interface ScreenerEngineOptions {
logger?: Logger;
}
interface ErrorResult {
isError: true;
ticker: string;
message: string;
}
type FetchResult = ReturnType<typeof mapToStandardFormat> | ErrorResult;
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 } = {}) {
private client: YahooClient;
private benchmarkProvider: BenchmarkProvider;
private logger: Logger;
constructor({ logger }: ScreenerEngineOptions = {}) {
this.client = new YahooClient();
this.benchmarkProvider = new BenchmarkProvider({ logger: logger ?? console });
this.benchmarkProvider = new BenchmarkProvider({
logger: logger ?? (console as unknown as Logger),
});
this.logger = logger ?? {
write: (msg) => process.stdout.write(msg),
log: (...args) => console.log(...args),
write: (msg: string) => process.stdout.write(msg),
log: (...args: unknown[]) => console.log(...args),
warn: (...args: unknown[]) => console.warn(...args),
};
}
// Pure data method — returns structured results. Safe to use in a server route.
async screenTickers(tickers) {
async screenTickers(tickers: string[]): Promise<ScreenerResult> {
const marketContext = await this.benchmarkProvider.getMarketContext();
const results = { STOCK: [], ETF: [], BOND: [], ERROR: [] };
const results: Omit<ScreenerResult, 'marketContext'> = {
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));
await new Promise<void>((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) {
async screenWithProgress(tickers: string[]): Promise<ScreenerResult> {
this.logger.write('⏳ Fetching market context...');
const marketContext = await this.benchmarkProvider.getMarketContext();
this.logger.write(' done\n');
const results = { STOCK: [], ETF: [], BOND: [], ERROR: [] };
const results: Omit<ScreenerResult, 'marketContext'> = {
STOCK: [],
ETF: [],
BOND: [],
ERROR: [],
};
const chunks = chunkArray(tickers, 5);
let processed = 0;
@@ -57,50 +86,60 @@ export class ScreenerEngine {
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));
await new Promise<void>((r) => setTimeout(r, 1000));
}
this.logger.write('\n');
return { ...results, marketContext };
}
async _fetch(ticker) {
private async _fetch(ticker: string): Promise<FetchResult> {
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 };
return { isError: true, ticker: ticker.toUpperCase(), message: (err as Error).message };
}
}
_process(data, marketContext, results) {
if (data.isError) {
results.ERROR.push(data);
private _process(
data: FetchResult,
marketContext: MarketContext,
results: Omit<ScreenerResult, 'marketContext'>,
): void {
if ('isError' in data && data.isError) {
results.ERROR.push({ ticker: data.ticker, message: data.message });
return;
}
try {
const asset = this._buildAsset(data);
const scorer = SCORERS[asset.type];
const asset = this._buildAsset(data as ReturnType<typeof mapToStandardFormat>);
const scorer = SCORERS[asset.type as AssetType];
if (!scorer) throw new Error(`No scorer for type: ${asset.type}`);
const fundamental = scorer.score(
asset.metrics,
asset.metrics as never,
RuleMerger.getRulesForAsset(
asset.type,
asset.metrics,
asset.type as AssetType,
asset.metrics as { sector?: string },
marketContext,
SCORE_MODE.FUNDAMENTAL,
),
marketContext,
);
const inflated = scorer.score(
asset.metrics,
RuleMerger.getRulesForAsset(asset.type, asset.metrics, marketContext, SCORE_MODE.INFLATED),
asset.metrics as never,
RuleMerger.getRulesForAsset(
asset.type as AssetType,
asset.metrics as { sector?: string },
marketContext,
SCORE_MODE.INFLATED,
),
marketContext,
);
results[asset.type].push({
(results[asset.type as AssetType] as unknown[]).push({
asset,
fundamental,
inflated,
@@ -108,26 +147,26 @@ export class ScreenerEngine {
});
} catch (err) {
results.ERROR.push({
ticker: (data.ticker || 'UNKNOWN').toUpperCase(),
message: err.message,
ticker: ((data as { ticker?: string }).ticker || 'UNKNOWN').toUpperCase(),
message: (err as Error).message,
});
}
}
_buildAsset(data) {
switch ((data.type || ASSET_TYPE.STOCK).toUpperCase()) {
private _buildAsset(data: Record<string, unknown>): Stock | Etf | Bond {
switch (((data.type as string) || ASSET_TYPE.STOCK).toUpperCase()) {
case ASSET_TYPE.BOND:
return new Bond(data);
return new Bond(data as never);
case ASSET_TYPE.ETF:
return new Etf(data);
return new Etf(data as never);
default:
return new Stock(data);
return new Stock(data as never);
}
}
_signal(fundamentalLabel, inflatedLabel) {
const green = (l) => l.startsWith('🟢');
const yellow = (l) => l.startsWith('🟡');
private _signal(fundamentalLabel: string, inflatedLabel: string): Signal {
const green = (l: string) => l.startsWith('🟢');
const yellow = (l: string) => 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;
@@ -135,7 +174,7 @@ export class ScreenerEngine {
return SIGNAL.AVOID;
}
signalOrder(signal) {
signalOrder(signal: Signal): number {
return SIGNAL_ORDER[signal] ?? 5;
}
}
-19
View File
@@ -1,19 +0,0 @@
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();
}
}
+32
View File
@@ -0,0 +1,32 @@
import type { AssetType } from '../../types.js';
interface AssetData {
ticker?: string;
currentPrice?: number;
type?: string;
[key: string]: unknown;
}
export class Asset {
ticker: string;
currentPrice: number;
type: AssetType;
constructor(data: AssetData) {
this.ticker = (data.ticker || 'UNKNOWN').toUpperCase();
this.currentPrice = (data.currentPrice as number) || 0;
this.type = (data.type || 'STOCK').toUpperCase() as AssetType;
}
formatCurrency(val: number | null | undefined): string {
return val ? `$${val.toFixed(2)}` : 'N/A';
}
formatLargeNumber(num: number | null | undefined): string {
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();
}
}
@@ -1,22 +1,40 @@
import { CREDIT_RATING_SCALE } from '../../config/ScoringConfig.js';
import { Asset } from './Asset.js';
interface BondData {
ticker?: string;
currentPrice?: number;
creditRating?: string;
yieldToMaturity?: string | number;
duration?: string | number;
[key: string]: unknown;
}
export interface BondMetrics {
ytm: number;
duration: number;
creditRating: string;
creditRatingNumeric: number;
}
export class Bond extends Asset {
constructor(data) {
metrics: BondMetrics;
constructor(data: BondData) {
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,
ytm: parseFloat(String(data.yieldToMaturity)) || 0,
duration: parseFloat(String(data.duration)) || 0,
creditRating,
creditRatingNumeric,
};
}
getDisplayMetrics() {
getDisplayMetrics(): Record<string, string> {
return {
Ticker: this.ticker,
Type: 'BOND',
-26
View File
@@ -1,26 +0,0 @@
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)}%`,
};
}
}
+47
View File
@@ -0,0 +1,47 @@
import { Asset } from './Asset.js';
interface EtfData {
ticker?: string;
currentPrice?: number;
expenseRatio?: string | number;
totalAssets?: string | number;
yield?: string | number;
volume?: string | number;
fiveYearReturn?: string | number;
[key: string]: unknown;
}
export interface EtfMetrics {
expenseRatio: number;
totalAssets: number;
yield: number;
volume: number;
fiveYearReturn: number;
}
export class Etf extends Asset {
metrics: EtfMetrics;
constructor(data: EtfData) {
super(data);
this.metrics = {
expenseRatio: parseFloat(String(data.expenseRatio)) || 0,
totalAssets: parseFloat(String(data.totalAssets)) || 0,
yield: parseFloat(String(data.yield)) || 0,
volume: parseFloat(String(data.volume)) || 0,
fiveYearReturn: parseFloat(String(data.fiveYearReturn)) || 0,
};
}
getDisplayMetrics(): Record<string, string> {
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)}%`,
};
}
}
@@ -1,48 +1,86 @@
import { Asset } from './Asset.js';
import type { Sector } from '../../config/constants.js';
interface StockData {
ticker?: string;
currentPrice?: number;
assetProfile?: { industry?: string; sector?: string };
peRatio?: number | null;
pegRatio?: number | null;
priceToBook?: number | null;
netProfitMargin?: number | null;
operatingMargin?: number | null;
returnOnEquity?: number | null;
revenueGrowth?: number | null;
earningsGrowth?: number | null;
debtToEquity?: number | null;
quickRatio?: number | null;
fcfYield?: number | null;
pFFO?: number | null;
dividendYield?: number | null;
beta?: number | null;
week52High?: number | null;
week52Low?: number | null;
[key: string]: unknown;
}
export interface StockMetrics {
sector: Sector;
peRatio: number | null;
pegRatio: number | null;
priceToBook: number | null;
netProfitMargin: number | null;
operatingMargin: number | null;
returnOnEquity: number | null;
revenueGrowth: number | null;
earningsGrowth: number | null;
debtToEquity: number | null;
quickRatio: number | null;
fcfYield: number | null;
pFFO: number | null;
dividendYield: number | null;
beta: number | null;
week52High: number | null;
week52Low: number | null;
currentPrice: number;
}
export class Stock extends Asset {
constructor(data) {
sector: Sector;
metrics: StockMetrics;
constructor(data: StockData) {
super(data);
// console.log('Data:', data);
this.sector = this._mapToStandardSector(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,
currentPrice: (data.currentPrice as number) || 0,
};
}
_mapToStandardSector(data) {
const profile = data.assetProfile || {};
_mapToStandardSector(data: StockData): Sector {
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') ||
@@ -72,7 +110,6 @@ export class Stock extends Asset {
combined.includes('medical')
)
return 'HEALTHCARE';
// Yahoo calls this "Communication Services" — covers META, GOOGL, NFLX, DIS, T
if (
combined.includes('communication') ||
combined.includes('media') ||
@@ -100,20 +137,22 @@ export class Stock extends Asset {
return 'GENERAL';
}
getDisplayMetrics() {
const fmt = (v, dec = 1, suffix = '') => (v != null ? `${v.toFixed(dec)}${suffix}` : null);
getDisplayMetrics(): Record<string, string | null> {
const fmt = (v: number | null, 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.week52High != null && 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 = {
const display: Record<string, string | null> = {
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);
@@ -1,5 +1,29 @@
import type { BondMetrics } from '../assets/Bond.js';
import type { MarketContext } from '../../types.js';
interface SanitizedBondMetrics {
ytm: number;
duration: number;
creditRating: string;
creditRatingNumeric: number;
}
interface ScoreOutput {
label: string;
scoreSummary: string;
audit: Record<string, unknown>;
}
export const BondScorer = {
score(m, rules, context) {
score(
m: BondMetrics,
rules: {
gates: Record<string, number>;
weights: Record<string, number>;
thresholds: Record<string, number>;
},
context?: MarketContext | null,
): ScoreOutput {
const { gates, weights, thresholds } = rules;
const metrics = this._sanitize(m);
const riskFreeRate = (context?.riskFreeRate ?? 4.0) / 100;
@@ -12,10 +36,9 @@ export const BondScorer = {
};
}
// Convert spread to percentage to match minSpread threshold (e.g. 1.0 = 1%)
const spreadPct = (metrics.ytm - riskFreeRate) * 100;
const breakdown = {
const breakdown: Record<string, number> = {
spread: spreadPct >= thresholds.minSpread ? weights.yieldSpread : -2,
duration: metrics.duration <= thresholds.maxDuration ? weights.duration : -1,
};
@@ -28,11 +51,12 @@ export const BondScorer = {
};
},
_sanitize(m) {
const pct = (v) => parseFloat(typeof v === 'string' ? v.replace('%', '') : v) / 100 || 0;
_sanitize(m: BondMetrics): SanitizedBondMetrics {
const pct = (v: unknown): number =>
parseFloat(typeof v === 'string' ? v.replace('%', '') : String(v)) / 100 || 0;
return {
ytm: pct(m.ytm),
duration: parseFloat(m.duration) || 0,
duration: parseFloat(String(m.duration)) || 0,
creditRating: m.creditRating || 'BBB',
creditRatingNumeric: m.creditRatingNumeric ?? 7,
};
@@ -1,22 +1,36 @@
import type { EtfMetrics } from '../assets/Etf.js';
interface ScoreOutput {
label: string;
scoreSummary: string;
audit?: Record<string, unknown>;
}
export const EtfScorer = {
score(m, rules) {
score(
m: EtfMetrics,
rules: {
gates: Record<string, number>;
weights: Record<string, number>;
thresholds: Record<string, number>;
},
): ScoreOutput {
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,
expenseRatio: parseFloat(String(m.expenseRatio)) || 0,
yield: parseFloat(String(m.yield)) || 0,
volume: parseFloat(String(m.volume)) || 0,
fiveYearReturn: parseFloat(String(m.fiveYearReturn)) || 0,
};
if (metrics.expenseRatio > gates.maxExpenseRatio) {
return { label: '🔴 REJECT', scoreSummary: 'Gate failed: High Expense Ratio' };
}
const breakdown = {
const breakdown: Record<string, number> = {
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
vol: metrics.volume >= (thresholds.minVolume ?? 1_000_000) ? 0 : -2,
fiveYearReturn:
thresholds.minFiveYearReturn != null
? metrics.fiveYearReturn >= thresholds.minFiveYearReturn
@@ -1,15 +1,51 @@
import { SIGNAL } from '../../config/constants.js';
import type { StockMetrics } from '../assets/Stock.js';
const n = (v) => {
const f = parseFloat(v);
type NumVal = number | null;
const n = (v: unknown): NumVal => {
const f = parseFloat(String(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);
const scoreValue = (val: number, high: number, med: number, weight: number): number =>
val >= high ? weight : val >= med ? 1 : -1;
const scorePeg = (val: number, high: number, med: number, weight: number): number =>
val <= high ? weight : val <= med ? 1 : -1;
interface SanitizedMetrics {
debtToEquity: NumVal;
quickRatio: NumVal;
peRatio: NumVal;
pegRatio: NumVal;
priceToBook: NumVal;
netProfitMargin: NumVal;
operatingMargin: NumVal;
returnOnEquity: NumVal;
revenueGrowth: NumVal;
fcfYield: NumVal;
dividendYield: NumVal;
pFFO: NumVal;
beta: NumVal;
week52Position: NumVal;
}
interface ScoreOutput {
label: string;
scoreSummary: string;
audit: Record<string, unknown>;
}
export const StockScorer = {
score(metrics, rules) {
score(
metrics: StockMetrics,
rules: {
gates: Record<string, number>;
weights: Record<string, number>;
thresholds: Record<string, number>;
},
): ScoreOutput {
const { gates, weights, thresholds } = rules;
const m = this._sanitize(metrics);
@@ -30,7 +66,7 @@ export const StockScorer = {
gates.maxPriceToBook &&
m.priceToBook > gates.maxPriceToBook &&
`P/B ${m.priceToBook.toFixed(1)} > ${gates.maxPriceToBook}`,
].filter(Boolean);
].filter(Boolean) as string[];
if (failures.length > 0) {
return {
@@ -44,14 +80,14 @@ export const StockScorer = {
{
key: 'roe',
active: weights.roe > 0 && m.returnOnEquity != null,
fn: () => scoreValue(m.returnOnEquity, thresholds.roeHigh, thresholds.roeMed, weights.roe),
fn: () => scoreValue(m.returnOnEquity!, thresholds.roeHigh, thresholds.roeMed, weights.roe),
},
{
key: 'opMargin',
active: weights.opMargin > 0 && m.operatingMargin != null,
fn: () =>
scoreValue(
m.operatingMargin,
m.operatingMargin!,
thresholds.opMarginHigh,
thresholds.opMarginMed,
weights.opMargin,
@@ -62,7 +98,7 @@ export const StockScorer = {
active: weights.margin > 0 && m.netProfitMargin != null,
fn: () =>
scoreValue(
m.netProfitMargin,
m.netProfitMargin!,
thresholds.marginHigh,
thresholds.marginMed,
weights.margin,
@@ -71,41 +107,41 @@ export const StockScorer = {
{
key: 'peg',
active: weights.peg > 0 && m.pegRatio != null,
fn: () => scorePeg(m.pegRatio, thresholds.pegHigh, thresholds.pegMed, weights.peg),
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),
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),
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),
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),
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),
fn: () => scoreValue(1 / m.priceToBook!, 1 / 1.0, 1 / 2.0, weights.priceToBook),
},
];
const breakdown = {};
const breakdown: Record<string, number> = {};
const totalScore = factors.reduce((sum, f) => {
if (!f.active) return sum;
breakdown[f.key] = f.fn();
breakdown[f.key] = f.fn() as number;
return sum + breakdown[f.key];
}, 0);
@@ -116,7 +152,7 @@ export const StockScorer = {
m.week52Position != null &&
m.week52Position < 0.1 &&
'Near 52-week low — potential opportunity',
].filter(Boolean);
].filter(Boolean) as string[];
return {
label: this._label(totalScore),
@@ -125,16 +161,16 @@ export const StockScorer = {
};
},
_label(score) {
_label(score: number): string {
if (score >= 8) return '🟢 BUY (High Conviction)';
if (score >= 4) return '🟢 BUY (Speculative)';
if (score >= 0) return '🟡 HOLD';
return '🔴 REJECT';
},
_sanitize(m) {
_sanitize(m: StockMetrics): SanitizedMetrics {
const w52 =
m.week52High > 0 && m.week52Low != null && m.currentPrice > 0
m.week52High != null && m.week52High > 0 && m.week52Low != null && m.currentPrice > 0
? (m.currentPrice - m.week52Low) / (m.week52High - m.week52Low)
: null;
return {
+22 -15
View File
@@ -7,18 +7,22 @@ import { YahooClient } from '../market/YahooClient.js';
import { LLMAnalyst } from '../analyst/LLMAnalyst.js';
import { noopLogger } from './utils/logger.js';
export async function buildApp({ logger = true } = {}) {
interface BuildAppOptions {
logger?: boolean;
}
export async function buildApp({ logger = true }: BuildAppOptions = {}) {
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);
await app.register(screenerRoutes as any);
await app.register(financeRoutes as any);
await app.register(callsRoutes as any);
// POST /api/analyze — fetch Yahoo news for tickers and run LLM analysis
// POST /api/analyze
app.post('/api/analyze', {
schema: {
body: {
@@ -29,21 +33,27 @@ export async function buildApp({ logger = true } = {}) {
},
},
},
handler: async (req, reply) => {
handler: async (req: any, reply: any) => {
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 tickers = (req.body as { tickers: string[] }).tickers.map((t) => t.toUpperCase());
const client = new YahooClient();
const llm = new LLMAnalyst({ logger: noopLogger });
const seen = new Map();
const seen = new Map<
string,
{ title: string; publisher: string; link: string; relatedTickers: string[] }
>();
await Promise.all(
tickers.slice(0, 10).map(async (ticker) => {
tickers.slice(0, 10).map(async (ticker: string) => {
try {
const { news = [] } = await client.yf.search(ticker, { newsCount: 3, quotesCount: 0 });
for (const s of news) {
const { news = [] } = await (client as any).yf.search(ticker, {
newsCount: 3,
quotesCount: 0,
});
for (const s of news as any[]) {
if (!seen.has(s.title)) {
seen.set(s.title, {
title: s.title,
@@ -60,10 +70,7 @@ export async function buildApp({ logger = true } = {}) {
);
const stories = [...seen.values()].slice(0, 15);
if (!stories.length) {
return reply.code(200).send({ analysis: null, reason: 'no_stories' });
}
if (!stories.length) return reply.code(200).send({ analysis: null, reason: 'no_stories' });
const analysis = await llm.analyze(stories, tickers);
return { analysis };
@@ -3,10 +3,20 @@ 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) => {
interface SnapshotEntry {
price: number | null;
signal: string | null;
inflatedVerdict: string | null;
fundamentalVerdict: string | null;
pe: string | null;
roe: string | null;
fcf: string | null;
}
const toSnapshot = (r: any): SnapshotEntry | null => {
if (!r) return null;
const m = r.asset?.displayMetrics ?? r.asset?.getDisplayMetrics?.() ?? {};
return {
@@ -20,36 +30,31 @@ const toSnapshot = (r) => {
};
};
export default async function callsRoutes(app) {
// GET /api/calls — list all market calls (newest first)
app.get('/api/calls', async () => {
return { calls: store.list() };
});
export default async function callsRoutes(app: any) {
// GET /api/calls
app.get('/api/calls', async () => ({ 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);
// GET /api/calls/:id
app.get('/api/calls/:id', async (req: any, reply: any) => {
const call = store.get((req.params as { id: string }).id);
if (!call) return reply.code(404).send({ error: 'Call not found' });
// Re-screen the tickers to get current prices for comparison
let current = {};
const current: Record<string, SnapshotEntry | null> = {};
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) {
for (const r of [...results.STOCK, ...results.ETF, ...results.BOND]) {
current[r.asset.ticker] = toSnapshot(r);
}
} catch {
// Non-fatal — return call without current prices
/* non-fatal */
}
}
return { ...call, current };
});
// POST /api/calls — create a new market call and snapshot current prices
// POST /api/calls
app.post('/api/calls', {
schema: {
body: {
@@ -64,58 +69,64 @@ export default async function callsRoutes(app) {
},
},
},
handler: async (req, reply) => {
const { title, quarter, date, thesis, tickers } = req.body;
const upperTickers = tickers.map((t) => t.toUpperCase());
handler: async (req: any, reply: any) => {
const { title, quarter, date, thesis, tickers } = req.body as {
title: string;
quarter: string;
date?: string;
thesis: string;
tickers: string[];
};
const upperTickers = tickers.map((t: string) => t.toUpperCase());
// Snapshot current screener data for each ticker
let snapshot = {};
const snapshot: Record<string, SnapshotEntry | null> = {};
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) {
for (const r of [...results.STOCK, ...results.ETF, ...results.BOND]) {
snapshot[r.asset.ticker] = toSnapshot(r);
}
} catch (err) {
app.log.warn('Could not snapshot prices for market call:', err.message);
app.log.warn('Could not snapshot prices for market call:', (err as Error).message);
}
const call = store.create({ title, quarter, date, thesis, tickers: upperTickers, snapshot });
const call = store.create({
title,
quarter,
date,
thesis,
tickers: upperTickers,
snapshot: snapshot as any,
});
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);
app.delete('/api/calls/:id', async (req: any, reply: any) => {
const deleted = store.delete((req.params as { id: string }).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) => {
// GET /api/calls/calendar
app.get('/api/calls/calendar', async (req: any) => {
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
let tickers: string[];
if ((req.query as any).tickers) {
tickers = String((req.query as any).tickers)
.split(',')
.map((t) => t.trim().toUpperCase())
.filter(Boolean);
} else {
const allCalls = store.list();
const set = new Set(allCalls.flatMap((c) => c.tickers));
const set = new Set(store.list().flatMap((c) => c.tickers));
tickers = [...set];
}
if (tickers.length === 0) return { events: [] };
// Fetch calendarEvents in parallel batches
const results = {};
const results: Record<string, any> = {};
for (const batch of chunkArray(tickers, 5)) {
await Promise.all(
batch.map(async (ticker) => {
@@ -123,17 +134,15 @@ export default async function callsRoutes(app) {
if (cal) results[ticker] = cal;
}),
);
await new Promise((r) => setTimeout(r, 500));
await new Promise<void>((r) => setTimeout(r, 500));
}
// Flatten into a sorted event list
const events = [];
const events: any[] = [];
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);
const d = new Date(dateVal as string);
events.push({
ticker,
type: 'earnings',
@@ -145,8 +154,6 @@ export default async function callsRoutes(app) {
isPast: d.getTime() < now,
});
}
// Ex-dividend date
if (cal.exDividendDate) {
const d = new Date(cal.exDividendDate);
events.push({
@@ -158,8 +165,6 @@ export default async function callsRoutes(app) {
isPast: d.getTime() < now,
});
}
// Dividend payment date
if (cal.dividendDate) {
const d = new Date(cal.dividendDate);
events.push({
@@ -173,12 +178,11 @@ export default async function callsRoutes(app) {
}
}
// 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
? new Date(b.date).getTime() - new Date(a.date).getTime()
: new Date(a.date).getTime() - new Date(b.date).getTime();
});
return { events, tickers };
@@ -4,19 +4,22 @@ import { PersonalFinanceAnalyzer } from '../../finance/PersonalFinanceAnalyzer.j
import { PortfolioAdvisor } from '../../finance/PortfolioAdvisor.js';
import { SimpleFINClient } from '../../finance/clients/SimpleFINClient.js';
import { noopLogger } from '../utils/logger.js';
import type { PortfolioHolding } from '../../types.js';
const PORTFOLIO_PATH = './portfolio.json';
export default async function financeRoutes(app) {
const normalizeYahoo = (t: string) => t.toUpperCase().replace(/\./g, '-');
export default async function financeRoutes(app: any) {
// GET /api/finance/portfolio
// Returns: { advice, personalFinance, marketContext }
app.get('/api/finance/portfolio', async (req, reply) => {
if (!existsSync(PORTFOLIO_PATH)) {
app.get('/api/finance/portfolio', async (req: any, reply: any) => {
if (!existsSync(PORTFOLIO_PATH))
return reply.code(404).send({ error: 'portfolio.json not found' });
}
const { holdings } = JSON.parse(readFileSync(PORTFOLIO_PATH, 'utf8'));
const { holdings } = JSON.parse(readFileSync(PORTFOLIO_PATH, 'utf8')) as {
holdings: PortfolioHolding[];
};
// SimpleFIN is optional — omit if not configured
let personalFinance = null;
if (process.env.SIMPLEFIN_ACCESS_URL) {
const client = new SimpleFINClient({ logger: noopLogger });
@@ -24,9 +27,6 @@ export default async function financeRoutes(app) {
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));
@@ -35,16 +35,13 @@ export default async function financeRoutes(app) {
const results =
screenable.length > 0
? await engine.screenTickers(screenable)
: { STOCK: [], ETF: [], BOND: [], ERROR: [], marketContext: {} };
: { STOCK: [], ETF: [], BOND: [], ERROR: [], marketContext: {} as any };
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: {
@@ -59,23 +56,25 @@ export default async function financeRoutes(app) {
},
},
},
handler: async (req, reply) => {
const { ticker, shares, costBasis = 0, type = 'stock', source = 'Manual' } = req.body;
handler: async (req: any, reply: any) => {
const {
ticker,
shares,
costBasis = 0,
type = 'stock',
source = 'Manual',
} = req.body as PortfolioHolding;
const normalized = ticker.toUpperCase().trim();
const portfolio = existsSync(PORTFOLIO_PATH)
? JSON.parse(readFileSync(PORTFOLIO_PATH, 'utf8'))
: { holdings: [] };
? (JSON.parse(readFileSync(PORTFOLIO_PATH, 'utf8')) as { holdings: PortfolioHolding[] })
: { holdings: [] as PortfolioHolding[] };
const idx = portfolio.holdings.findIndex((h) => h.ticker.toUpperCase() === normalized);
const entry: PortfolioHolding = { ticker: normalized, shares, costBasis, type, source };
const entry = { ticker: normalized, shares, costBasis, type, source };
if (idx >= 0) {
portfolio.holdings[idx] = entry; // update existing
} else {
portfolio.holdings.push(entry); // add new
}
if (idx >= 0) portfolio.holdings[idx] = entry;
else portfolio.holdings.push(entry);
writeFileSync(PORTFOLIO_PATH, JSON.stringify(portfolio, null, 2), 'utf8');
return reply.code(201).send(entry);
@@ -83,14 +82,14 @@ export default async function financeRoutes(app) {
});
// 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();
app.delete('/api/finance/holdings/:ticker', async (req: any, reply: any) => {
const ticker = (req.params as { ticker: string }).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 portfolio = JSON.parse(readFileSync(PORTFOLIO_PATH, 'utf8')) as {
holdings: PortfolioHolding[];
};
const before = portfolio.holdings.length;
portfolio.holdings = portfolio.holdings.filter((h) => h.ticker.toUpperCase() !== ticker);
@@ -102,9 +101,8 @@ export default async function financeRoutes(app) {
});
// 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();
return engine['benchmarkProvider'].getMarketContext();
});
}
@@ -1,9 +1,13 @@
import { ScreenerEngine } from '../../screener/ScreenerEngine.js';
import { noopLogger } from '../utils/logger.js';
import type { AssetResult } from '../../types.js';
// Class instances don't survive JSON.stringify — call getDisplayMetrics() on the
// server so the browser receives plain serializable objects.
const serializeAssets = (arr) =>
type AnyAsset = AssetResult['asset'] & {
getDisplayMetrics: () => Record<string, unknown>;
metrics: unknown;
};
const serializeAssets = (arr: (AssetResult & { asset: AnyAsset })[]) =>
arr.map((r) => ({
...r,
asset: {
@@ -15,13 +19,9 @@ const serializeAssets = (arr) =>
},
}));
export default async function screenerRoutes(app) {
// Shared engine — BenchmarkProvider caches for 1 hour across requests.
export default async function screenerRoutes(app: any) {
const engine = new ScreenerEngine({ logger: noopLogger });
// POST /api/screen
// Body: { tickers: string[] }
// Returns: { STOCK, ETF, BOND, ERROR, marketContext }
app.post('/api/screen', {
schema: {
body: {
@@ -32,27 +32,24 @@ export default async function screenerRoutes(app) {
},
},
},
handler: async (req) => {
const tickers = req.body.tickers.map((t) => t.toUpperCase());
handler: async (req: any) => {
const tickers = (req.body as { tickers: string[] }).tickers.map((t: string) =>
t.toUpperCase(),
);
const results = await engine.screenTickers(tickers);
return {
...results,
STOCK: serializeAssets(results.STOCK),
ETF: serializeAssets(results.ETF),
BOND: serializeAssets(results.BOND),
STOCK: serializeAssets(results.STOCK as any),
ETF: serializeAssets(results.ETF as any),
BOND: serializeAssets(results.BOND as any),
};
},
});
// 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 };
});
}
@@ -1,3 +1,5 @@
import type { Logger } from '../../types.js';
/**
* Shared server-side logger utilities.
*
@@ -6,7 +8,7 @@
* Pass as { logger: noopLogger } to ScreenerEngine, BenchmarkProvider,
* CatalystAnalyst, SimpleFINClient, LLMAnalyst.
*/
export const noopLogger = {
export const noopLogger: Logger = {
write: () => {},
log: () => {},
warn: () => {},
+135
View File
@@ -0,0 +1,135 @@
// ── Shared domain types ───────────────────────────────────────────────────
// Single source of truth for all cross-cutting interfaces and type aliases.
// Server classes import from here; UI imports from $lib/types.ts (mirrored subset).
// ── Primitives ────────────────────────────────────────────────────────────
export type Signal =
| '✅ Strong Buy'
| '⚡ Momentum'
| '⚠️ Speculation'
| '🔄 Neutral'
| '❌ Avoid';
export type AssetType = 'STOCK' | 'ETF' | 'BOND';
export type ScoreMode = 'inflated' | 'fundamental';
export type RateRegime = 'HIGH' | 'NORMAL' | 'LOW';
export type VolatilityRegime = 'HIGH' | 'NORMAL' | 'LOW';
// ── Market context (live benchmarks from BenchmarkProvider) ───────────────
export interface Benchmarks {
marketPE: number | null;
techPE: number | null;
reitYield: number | null;
igSpread: number | null;
}
export interface MarketContext {
sp500Price: number | null;
riskFreeRate: number | null;
vixLevel: number | null;
rateRegime: RateRegime;
volatilityRegime: VolatilityRegime;
benchmarks: Benchmarks;
}
// ── Scoring ───────────────────────────────────────────────────────────────
export interface ScoringRules {
gates: Record<string, number>;
weights: Record<string, number>;
thresholds: Record<string, number>;
}
export interface ScoreResult {
label: string;
score: number;
scoreSummary: string;
audit: {
gatesPassed: string[];
gatesFailed: string[];
riskFlags: string[];
};
}
// ── Screener results ──────────────────────────────────────────────────────
export interface AssetResult {
asset: {
ticker: string;
currentPrice: number;
type: AssetType;
displayMetrics: Record<string, string | number | null>;
};
signal: Signal;
inflated: ScoreResult;
fundamental: ScoreResult;
}
export interface ScreenerResult {
STOCK: AssetResult[];
ETF: AssetResult[];
BOND: AssetResult[];
ERROR: Array<{ ticker: string; message: string }>;
marketContext: MarketContext;
}
// ── LLM analysis ──────────────────────────────────────────────────────────
export interface AffectedIndustry {
name: string;
reason: string;
}
export interface RelatedTicker {
ticker: string;
reason: string;
}
export interface LLMAnalysis {
summary: string;
sentiment: 'BULLISH' | 'BEARISH' | 'NEUTRAL';
affectedIndustries: AffectedIndustry[];
relatedTickers: RelatedTicker[];
}
// ── Market calls ──────────────────────────────────────────────────────────
export interface TickerSnapshot {
price: number | null;
signal: Signal | null;
}
export interface MarketCall {
id: string;
title: string;
quarter: string;
date: string;
thesis: string;
tickers: string[];
snapshot: Record<string, TickerSnapshot>;
}
// ── Portfolio ─────────────────────────────────────────────────────────────
export type HoldingType = 'stock' | 'etf' | 'bond' | 'crypto';
export interface PortfolioHolding {
ticker: string;
shares: number;
costBasis: number;
source: string;
type: HoldingType;
}
// ── Logger ────────────────────────────────────────────────────────────────
export interface Logger {
write: (msg: string) => void;
log: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
}