phase-7: code restructure

This commit is contained in:
Kazuma
2026-06-05 22:05:55 -04:00
committed by Kazuma
parent 2b785aa861
commit 5b32bd7a04
108 changed files with 8931 additions and 3434 deletions
-72
View File
@@ -1,72 +0,0 @@
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 {
private client: YahooClient;
private logger: Pick<Logger, 'write'>;
constructor({ logger }: { logger?: Pick<Logger, 'write'> } = {}) {
this.client = new YahooClient();
this.logger = logger ?? { write: (msg: string) => process.stdout.write(msg) };
}
async run(): Promise<CatalystResult> {
this.logger.write('🔍 Fetching market news...');
const stories = await this._fetchNews();
const tickers = this._extractTickers(stories);
this.logger.write(` ${stories.length} stories, ${tickers.length} tickers\n`);
return { tickers, stories };
}
private async _fetchNews(): Promise<Story[]> {
const seen = new Map<string, Story>();
for (const query of NEWS_QUERIES) {
try {
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,
publisher: s.publisher,
link: s.link,
relatedTickers: s.relatedTickers ?? [],
});
}
}
} catch {
/* skip failed query */
}
}
return [...seen.values()].slice(0, MAX_STORIES);
}
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();
if (TICKER_REGEX.test(clean)) tickers.add(clean);
}
}
return [...tickers];
}
}
-73
View File
@@ -1,73 +0,0 @@
import Anthropic from '@anthropic-ai/sdk';
import type { Logger, LLMAnalysis } from '../types.js';
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.
Your job is to:
1. Write a 2-3 sentence market summary capturing the dominant theme
2. Identify up to 4 industries that are likely to be secondarily affected (not directly mentioned but impacted by contagion, supply chain, regulation, or macro effects)
3. Suggest up to 5 related ticker symbols worth screening that are NOT already in the provided list
4. Assess overall market sentiment as BULLISH, NEUTRAL, or BEARISH based on the news
Return ONLY valid JSON in this exact shape — no markdown, no explanation:
{
"summary": "string",
"sentiment": "BULLISH" | "NEUTRAL" | "BEARISH",
"affectedIndustries": [
{ "name": "string", "reason": "string (one sentence)" }
],
"relatedTickers": [
{ "ticker": "string", "reason": "string (one sentence)" }
]
}`;
export class LLMAnalyst {
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;
}
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
.slice(0, 15)
.map((s, i) => `${i + 1}. ${s.title} (${s.publisher ?? 'unknown'})`)
.join('\n');
const userMessage = `Today's market news headlines:\n\n${headlines}\n\nAlready identified catalyst tickers: ${existingTickers.join(', ') || 'none'}`;
try {
const response = await this.client.messages.create({
model: 'claude-haiku-4-5',
max_tokens: 1024,
system: SYSTEM_PROMPT,
messages: [{ role: 'user', content: userMessage }],
});
const raw = (response.content[0] as { text?: string })?.text ?? '';
const cleaned = raw
.replace(/^```(?:json)?\s*/i, '')
.replace(/```\s*$/i, '')
.trim();
return JSON.parse(cleaned) as LLMAnalysis;
} catch (err) {
this.logger.warn('LLMAnalyst: analysis failed —', (err as Error).message);
return null;
}
}
}
+46
View File
@@ -0,0 +1,46 @@
import Fastify from 'fastify';
import cors from '@fastify/cors';
import { ScreenerController } from './controllers/screener.controller';
import { FinanceController } from './controllers/finance.controller';
import { CallsController } from './controllers/calls.controller';
import { AnalyzeController } from './controllers/analyze.controller';
import { ScreenerEngine } from './services/ScreenerEngine';
import { LLMAnalyst } from './services/LLMAnalyst';
import { CatalystAnalyst } from './services/CatalystAnalyst';
import { YahooFinanceClient } from './clients/YahooFinanceClient';
import { MarketCallRepository } from './repositories/MarketCallRepository';
import { PortfolioRepository } from './repositories/PortfolioRepository';
import { noopLogger } from './utils/logger';
interface BuildAppOptions {
logger?: boolean;
}
// ── Adding a new domain ───────────────────────────────────────────────────
// 1. server/types/<domain>.model.ts — define request/response shapes
// 2. server/services/<Domain>.ts — business logic
// 3. server/controllers/<domain>.controller.ts — HTTP wiring (class + register)
// 4. Register: new <Domain>Controller(...).register(app) ← add below
// ───────────────────────────────────────────────────────────────────────────
export async function buildApp({ logger = true }: BuildAppOptions = {}) {
const app = Fastify({ logger });
await app.register(cors, {
origin: process.env.CLIENT_ORIGIN ?? 'http://localhost:5173',
});
const engine = new ScreenerEngine({ logger: noopLogger });
const yahoo = new YahooFinanceClient();
const llm = new LLMAnalyst({ logger: noopLogger });
const catalyst = new CatalystAnalyst({ logger: noopLogger });
new ScreenerController(engine).register(app);
new FinanceController(engine, new PortfolioRepository()).register(app);
new CallsController(new MarketCallRepository(), engine, yahoo).register(app);
new AnalyzeController(catalyst, llm).register(app);
app.get('/health', async () => ({ status: 'ok' }));
return app;
}
+31
View File
@@ -0,0 +1,31 @@
import Anthropic from '@anthropic-ai/sdk';
/**
* Thin wrapper around the Anthropic SDK.
* Handles initialisation and raw message completion only —
* prompt construction and response parsing stay in LLMAnalyst (service layer).
*/
export class AnthropicClient {
private client: Anthropic | null;
constructor() {
this.client = process.env.ANTHROPIC_API_KEY
? new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
: null;
}
get isAvailable(): boolean {
return this.client !== null;
}
async complete(system: string, userMessage: string): Promise<string | null> {
if (!this.client) return null;
const response = await this.client.messages.create({
model: 'claude-haiku-4-5',
max_tokens: 1024,
system,
messages: [{ role: 'user', content: userMessage }],
});
return (response.content[0] as { text?: string })?.text ?? null;
}
}
@@ -1,46 +1,12 @@
import fs from 'fs';
import https from 'https';
import http from 'http';
import type { Logger } from '../../types.js';
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[];
}
import type { Logger, GetAccountsOptions, SimpleFINData, SimpleFINOptions } from '../types';
export class SimpleFINClient {
private accessUrl: string | null;
private logger: Logger;
private onAccessUrlClaimed: ((url: string) => Promise<void> | void) | null;
private onAccessUrlClaimed: ((_url: string) => Promise<void> | void) | null;
constructor({ logger, onAccessUrlClaimed }: SimpleFINOptions = {}) {
this.accessUrl = null;
+38
View File
@@ -0,0 +1,38 @@
import YahooFinance from 'yahoo-finance2';
import type { YahooNewsItem, YahooSearchOptions, YahooFinanceLib } from '../types';
import { YAHOO_MODULES } from '../config/constants';
export class YahooFinanceClient {
private lib: YahooFinanceLib;
constructor() {
this.lib = new (YahooFinance as unknown as new (_opts: object) => YahooFinanceLib)({
suppressNotices: ['yahooSurvey'],
});
}
async fetchSummary(ticker: string, retries = 3, backoff = 1000): Promise<any> {
for (let attempt = 0; attempt < retries; attempt++) {
try {
return await this.lib.quoteSummary(ticker, { modules: YAHOO_MODULES });
} catch (error) {
if (attempt === retries - 1) throw error;
await new Promise<void>((resolve) => setTimeout(resolve, backoff * (attempt + 1)));
}
}
}
async fetchCalendarEvents(ticker: string): Promise<any | null> {
try {
const result = await this.lib.quoteSummary(ticker, { modules: ['calendarEvents'] });
return result.calendarEvents ?? null;
} catch {
return null;
}
}
async search(query: string, opts: YahooSearchOptions = {}): Promise<YahooNewsItem[]> {
const { news = [] } = await this.lib.search(query, opts);
return news;
}
}
+11 -21
View File
@@ -1,4 +1,4 @@
import type { Sector } from './constants.js';
import type { ScoringRulesShape } from '../types';
// ── Credit rating scale (S&P convention) ─────────────────────────────────
// Bond.ts converts letter ratings to these numbers; BondScorer uses them for gate checks.
@@ -17,26 +17,8 @@ export const CREDIT_RATING_SCALE: Record<string, number> = {
};
// ── 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;
}
// Structural shapes (GateSet/WeightSet/ThresholdSet/RuleBlock/StockRules/
// ScoringRulesShape) live in server/types/asset.model.ts.
// ─────────────────────────────────────────────────────────────────────────────
// Fundamental baseline — Graham / value-investing style.
@@ -58,6 +40,8 @@ export const ScoringRules: ScoringRulesShape = {
peg: 2, // valuation relative to growth
revenue: 2, // revenue growth
fcf: 3, // FCF is the most manipulation-resistant quality signal
analyst: 2, // Wall Street consensus (1=Strong Buy … 5=Strong Sell, inverted in scorer)
dcf: 2, // DCF margin of safety: positive = undervalued vs intrinsic value
},
thresholds: {
marginHigh: 15, // 15% net margin is genuinely excellent across most sectors
@@ -72,6 +56,12 @@ export const ScoringRules: ScoringRulesShape = {
revMed: 5,
fcfHigh: 5,
fcfMed: 2,
// Analyst consensus thresholds (Yahoo recommendationMean scale: 1=Strong Buy, 5=Strong Sell)
analystBuy: 2.0, // ≤ 2.0 → consensus is Buy or better
analystHold: 3.0, // ≤ 3.0 → consensus is Hold or better
// DCF margin-of-safety thresholds (% undervaluation vs intrinsic value)
dcfUndervalued: 20, // ≥ 20% margin of safety → undervalued
dcfFairValue: 0, // 020% → fairly valued; negative → overvalued
},
SECTOR_OVERRIDE: {
+44 -4
View File
@@ -1,4 +1,4 @@
import type { Signal, AssetType, RateRegime } from '../types.js';
import type { Signal, AssetType, RateRegime } from '../types';
export const SIGNAL = {
STRONG_BUY: '✅ Strong Buy' as Signal,
@@ -6,14 +6,28 @@ export const SIGNAL = {
SPECULATION: '⚠️ Speculation' as Signal,
NEUTRAL: '🔄 Neutral' as Signal,
AVOID: '❌ Avoid' as Signal,
} as const;
};
export const ASSET_TYPE = {
STOCK: 'STOCK' as AssetType,
ETF: 'ETF' as AssetType,
BOND: 'BOND' as AssetType,
CRYPTO: 'crypto',
} as const;
};
// ── Why some constants use `as const` and others don't ────────────────────
//
// SIGNAL / ASSET_TYPE / REGIME — each member is individually cast to its
// named type (e.g. `'✅ Strong Buy' as Signal`). TypeScript already knows
// the exact literal type of each value, so `as const` on the object would
// be redundant.
//
// SECTOR / SCORE_MODE / CAP_CATEGORY / GROWTH_CATEGORY — these use
// `as const` because their public type aliases are *derived* from the
// object itself via `(typeof X)[keyof typeof X]`. Without `as const`,
// TypeScript widens every value to `string`, and the derived union
// collapses to `string` instead of `'TECHNOLOGY' | 'REIT' | ...`.
// ──────────────────────────────────────────────────────────────────────────
export const SECTOR = {
TECHNOLOGY: 'TECHNOLOGY',
@@ -38,7 +52,7 @@ export const REGIME = {
LOW: 'LOW' as RateRegime,
NORMAL: 'NORMAL' as RateRegime,
HIGH: 'HIGH' as RateRegime,
} as const;
};
export const YAHOO_MODULES: string[] = [
'assetProfile',
@@ -55,3 +69,29 @@ export const SIGNAL_ORDER: Record<string, number> = {
[SIGNAL.SPECULATION]: 3,
[SIGNAL.AVOID]: 4,
};
// ── Market capitalisation tiers ───────────────────────────────────────────
// Thresholds follow institutional convention (MSCI/Russell definitions).
export const CAP_CATEGORY = {
MEGA: 'Mega Cap', // > $200B
LARGE: 'Large Cap', // $10B $200B
MID: 'Mid Cap', // $2B $10B
SMALL: 'Small Cap', // $300M $2B
MICRO: 'Micro Cap', // < $300M
} as const;
export type CapCategory = (typeof CAP_CATEGORY)[keyof typeof CAP_CATEGORY];
// ── Growth / style classification ─────────────────────────────────────────
// Derived from revenue growth, earnings growth, and dividend yield.
// Used for display and to contextualise signals within each cap tier.
export const GROWTH_CATEGORY = {
HIGH_GROWTH: 'High Growth', // rev >15% or earnings >20%
MODERATE_GROWTH: 'Growth', // rev 515%
STABLE: 'Stable', // low growth, modest or no dividend
VALUE: 'Value', // low growth + dividend yield ≥ 3%
TURNAROUND: 'Turnaround', // negative earnings, positive revenue
DECLINING: 'Declining', // negative revenue growth
} as const;
export type GrowthCategory = (typeof GROWTH_CATEGORY)[keyof typeof GROWTH_CATEGORY];
+30
View File
@@ -0,0 +1,30 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import type { LLMAnalyst } from '../services/LLMAnalyst';
import { CatalystAnalyst } from '../services/CatalystAnalyst';
import { analyzeSchema } from '../types/schemas';
export class AnalyzeController {
constructor(
private readonly catalyst: CatalystAnalyst,
private readonly llm: LLMAnalyst,
) {}
register(app: FastifyInstance): void {
app.post('/api/analyze', { schema: analyzeSchema }, this.analyze.bind(this));
}
private async analyze(req: FastifyRequest, reply: FastifyReply) {
if (!this.llm.isAvailable) {
return reply.code(400).send({ error: 'ANTHROPIC_API_KEY is not set in .env' });
}
const tickers = (req.body as { tickers: string[] }).tickers.map((t) => t.toUpperCase());
const stories = await this.catalyst.fetchStoriesForTickers(tickers);
if (!stories.length) return reply.code(200).send({ analysis: null, reason: 'no_stories' });
const { tickerFrequency } = CatalystAnalyst.rankTickers(stories);
const analysis = await this.llm.analyze(stories, tickers, tickerFrequency);
return { analysis };
}
}
+172
View File
@@ -0,0 +1,172 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { YahooFinanceClient } from '../clients/YahooFinanceClient';
import { MarketCallRepository } from '../repositories/MarketCallRepository';
import { ScreenerEngine } from '../services/index';
import type { SnapshotEntry } from '../types';
import { callSchema } from '../types/schemas';
import { chunkArray } from '../utils/Chunker';
export class CallsController {
constructor(
private readonly repo: MarketCallRepository,
private readonly engine: ScreenerEngine,
private readonly yahoo: YahooFinanceClient,
) {}
private static toSnapshot(r: any): SnapshotEntry | null {
if (!r) return null;
const m = r.asset?.displayMetrics ?? r.asset?.getDisplayMetrics?.() ?? {};
return {
price: r.asset?.currentPrice ?? null,
signal: r.signal ?? null,
inflatedVerdict: r.inflated?.label ?? null,
fundamentalVerdict: r.fundamental?.label ?? null,
pe: m['P/E'] ?? null,
roe: m['ROE%'] ?? null,
fcf: m['FCF Yld%'] ?? null,
};
}
register(app: FastifyInstance): void {
app.get('/api/calls', this.list.bind(this));
app.get('/api/calls/calendar', this.calendar.bind(this));
app.get('/api/calls/:id', this.get.bind(this));
app.post('/api/calls', { schema: callSchema }, this.create.bind(this));
app.delete('/api/calls/:id', this.remove.bind(this));
}
private async list() {
return { calls: this.repo.list() };
}
private async get(req: FastifyRequest, reply: FastifyReply) {
const call = this.repo.get((req.params as { id: string }).id);
if (!call) return reply.code(404).send({ error: 'Call not found' });
const current: Record<string, SnapshotEntry | null> = {};
if (call.tickers.length > 0) {
try {
const results = await this.engine.screenTickers(call.tickers);
for (const r of [...results.STOCK, ...results.ETF, ...results.BOND]) {
current[r.asset.ticker] = CallsController.toSnapshot(r);
}
} catch {
/* non-fatal */
}
}
return { ...call, current };
}
private async create(req: FastifyRequest, reply: FastifyReply) {
const { title, quarter, date, thesis, tickers } = req.body as {
title: string;
quarter: string;
date?: string;
thesis: string;
tickers: string[];
};
const upperTickers = tickers.map((t) => t.toUpperCase());
const snapshot: Record<string, SnapshotEntry | null> = {};
try {
const results = await this.engine.screenTickers(upperTickers);
for (const r of [...results.STOCK, ...results.ETF, ...results.BOND]) {
snapshot[r.asset.ticker] = CallsController.toSnapshot(r);
}
} catch (err) {
req.log.warn(`Could not snapshot prices for market call: ${(err as Error).message}`);
}
const call = this.repo.create({
title,
quarter,
date,
thesis,
tickers: upperTickers,
snapshot: snapshot as any,
});
return reply.code(201).send(call);
}
private async remove(req: FastifyRequest, reply: FastifyReply) {
const deleted = this.repo.delete((req.params as { id: string }).id);
if (!deleted) return reply.code(404).send({ error: 'Call not found' });
return { ok: true };
}
private async calendar(req: FastifyRequest) {
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 set = new Set(this.repo.list().flatMap((c) => c.tickers));
tickers = [...set];
}
if (tickers.length === 0) return { events: [] };
const results: Record<string, any> = {};
for (const batch of chunkArray(tickers, 5)) {
await Promise.all(
batch.map(async (ticker) => {
const cal = await this.yahoo.fetchCalendarEvents(ticker);
if (cal) results[ticker] = cal;
}),
);
await new Promise<void>((r) => setTimeout(r, 500));
}
const events: any[] = [];
const now = Date.now();
for (const [ticker, cal] of Object.entries(results)) {
for (const dateVal of cal.earnings?.earningsDate ?? []) {
const d = new Date(dateVal as string);
events.push({
ticker,
type: 'earnings',
date: d.toISOString().slice(0, 10),
label: 'Earnings',
detail: cal.earnings.isEarningsDateEstimate ? 'Estimated' : 'Confirmed',
epsEstimate: cal.earnings.earningsAverage ?? null,
revEstimate: cal.earnings.revenueAverage ?? null,
isPast: d.getTime() < now,
});
}
if (cal.exDividendDate) {
const d = new Date(cal.exDividendDate);
events.push({
ticker,
type: 'exdividend',
date: d.toISOString().slice(0, 10),
label: 'Ex-Dividend',
detail: null,
isPast: d.getTime() < now,
});
}
if (cal.dividendDate) {
const d = new Date(cal.dividendDate);
events.push({
ticker,
type: 'dividend',
date: d.toISOString().slice(0, 10),
label: 'Dividend',
detail: null,
isPast: d.getTime() < now,
});
}
}
events.sort((a, b) => {
if (a.isPast !== b.isPast) return a.isPast ? 1 : -1;
return a.isPast
? new Date(b.date).getTime() - new Date(a.date).getTime()
: new Date(a.date).getTime() - new Date(b.date).getTime();
});
return { events, tickers };
}
}
+75
View File
@@ -0,0 +1,75 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { SimpleFINClient } from '../clients/SimpleFINClient';
import { PortfolioRepository } from '../repositories/PortfolioRepository';
import { PersonalFinanceAnalyzer, PortfolioAdvisor, ScreenerEngine } from '../services/index';
import type { PortfolioHolding } from '../types';
import { holdingSchema } from '../types/schemas';
import { noopLogger } from '../utils/logger';
export class FinanceController {
constructor(
private readonly engine: ScreenerEngine,
private readonly repo: PortfolioRepository,
) {}
private static normalizeYahoo(ticker: string): string {
return ticker.toUpperCase().replace(/\./g, '-');
}
register(app: FastifyInstance): void {
app.get('/api/finance/portfolio', this.portfolio.bind(this));
app.post('/api/finance/holdings', { schema: holdingSchema }, this.addHolding.bind(this));
app.delete('/api/finance/holdings/:ticker', this.removeHolding.bind(this));
app.get('/api/finance/market-context', this.marketContext.bind(this));
}
private async portfolio(_req: FastifyRequest, reply: FastifyReply) {
if (!this.repo.exists()) return reply.code(404).send({ error: 'portfolio.json not found' });
const { holdings } = this.repo.read();
let personalFinance = null;
if (process.env.SIMPLEFIN_ACCESS_URL) {
const client = new SimpleFINClient({ logger: noopLogger });
const { accounts } = await client.getAccounts();
personalFinance = new PersonalFinanceAnalyzer().analyze(accounts);
}
const screenable = holdings
.filter((h) => (h.type ?? 'stock') !== 'crypto')
.map((h) => FinanceController.normalizeYahoo(h.ticker));
const results =
screenable.length > 0
? await this.engine.screenTickers(screenable)
: { STOCK: [], ETF: [], BOND: [], ERROR: [], marketContext: {} as any };
const advice = await new PortfolioAdvisor().advise(holdings, results);
return { advice, personalFinance, marketContext: results.marketContext };
}
private async addHolding(req: FastifyRequest, reply: FastifyReply) {
const {
ticker,
shares,
costBasis = 0,
type = 'stock',
source = 'Manual',
} = req.body as PortfolioHolding;
const entry = this.repo.upsert({ ticker, shares, costBasis, type, source });
return reply.code(201).send(entry);
}
private async removeHolding(req: FastifyRequest, reply: FastifyReply) {
const ticker = (req.params as { ticker: string }).ticker.toUpperCase();
if (!this.repo.exists()) return reply.code(404).send({ error: 'portfolio.json not found' });
const removed = this.repo.remove(ticker);
if (!removed) return reply.code(404).send({ error: 'Holding not found' });
return { ok: true };
}
private async marketContext() {
return this.engine.getMarketContext();
}
}
+44
View File
@@ -0,0 +1,44 @@
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { ScreenerEngine, CatalystAnalyst } from '../services/index';
import { noopLogger } from '../utils/logger';
import type { LiveAssetResult } from '../types';
import { screenSchema } from '../types/schemas';
export class ScreenerController {
constructor(private readonly engine: ScreenerEngine) {}
register(app: FastifyInstance): void {
app.post('/api/screen', { schema: screenSchema }, this.screen.bind(this));
app.get('/api/screen/catalysts', this.catalysts.bind(this));
}
private static serializeAssets(arr: LiveAssetResult[]) {
return arr.map((r) => ({
...r,
asset: {
ticker: r.asset.ticker,
type: r.asset.type,
currentPrice: r.asset.currentPrice,
metrics: r.asset.metrics,
displayMetrics: r.asset.getDisplayMetrics(),
},
}));
}
private async screen(req: FastifyRequest) {
const tickers = (req.body as { tickers: string[] }).tickers.map((t) => t.toUpperCase());
const results = await this.engine.screenTickers(tickers);
return {
...results,
STOCK: ScreenerController.serializeAssets(results.STOCK as LiveAssetResult[]),
ETF: ScreenerController.serializeAssets(results.ETF as LiveAssetResult[]),
BOND: ScreenerController.serializeAssets(results.BOND as LiveAssetResult[]),
};
}
private async catalysts() {
const catalyst = new CatalystAnalyst({ logger: noopLogger });
const { tickers, stories } = await catalyst.run();
return { tickers, stories };
}
}
-42
View File
@@ -1,42 +0,0 @@
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,11 +1,5 @@
import type { AssetType } from '../../types.js';
interface AssetData {
ticker?: string;
currentPrice?: number;
type?: string;
[key: string]: unknown;
}
import type { AssetType } from '../types';
import type { AssetData } from '../types/models.model';
export class Asset {
ticker: string;
@@ -1,21 +1,6 @@
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;
}
import { CREDIT_RATING_SCALE } from '../config/ScoringConfig';
import { Asset } from './Asset';
import type { BondData, BondMetrics } from '../types/models.model';
export class Bond extends Asset {
metrics: BondMetrics;
@@ -1,23 +1,5 @@
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;
}
import { Asset } from './Asset';
import type { EtfData, EtfMetrics } from '../types/models.model';
export class Etf extends Asset {
metrics: EtfMetrics;
@@ -1,49 +1,7 @@
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;
}
import { Asset } from './Asset';
import { CAP_CATEGORY, GROWTH_CATEGORY } from '../config/constants';
import type { Sector, CapCategory, GrowthCategory } from '../config/constants';
import type { StockData, StockMetrics } from '../types/models.model';
export class Stock extends Asset {
sector: Sector;
@@ -55,9 +13,16 @@ export class Stock extends Asset {
this.metrics = {
sector: this.sector,
capCategory: this._classifyMarketCap(data.marketCap ?? null),
growthCategory: this._classifyGrowth(
data.revenueGrowth ?? null,
data.earningsGrowth ?? null,
data.dividendYield ?? null,
),
peRatio: data.peRatio ?? null,
pegRatio: data.pegRatio ?? null,
priceToBook: data.priceToBook ?? null,
grossMargin: data.grossMargin ?? null,
netProfitMargin: data.netProfitMargin ?? null,
operatingMargin: data.operatingMargin ?? null,
returnOnEquity: data.returnOnEquity ?? null,
@@ -71,10 +36,51 @@ export class Stock extends Asset {
beta: data.beta ?? null,
week52High: data.week52High ?? null,
week52Low: data.week52Low ?? null,
week52Change: data.week52Change ?? null,
week52FromHigh: data.week52FromHigh ?? null,
week52FromLow: data.week52FromLow ?? null,
marketCap: data.marketCap ?? null,
analystRating: data.analystRating ?? null,
analystTargetPrice: data.analystTargetPrice ?? null,
analystUpside: data.analystUpside ?? null,
numberOfAnalysts: data.numberOfAnalysts ?? null,
dcfIntrinsicValue: data.dcfIntrinsicValue ?? null,
dcfMarginOfSafety: data.dcfMarginOfSafety ?? null,
currentPrice: (data.currentPrice as number) || 0,
};
}
// ── Market cap tier classification ──────────────────────────────────────
// Thresholds follow MSCI/Russell institutional convention.
_classifyMarketCap(marketCap: number | null): CapCategory {
if (marketCap == null) return CAP_CATEGORY.LARGE; // safe default
if (marketCap >= 200e9) return CAP_CATEGORY.MEGA;
if (marketCap >= 10e9) return CAP_CATEGORY.LARGE;
if (marketCap >= 2e9) return CAP_CATEGORY.MID;
if (marketCap >= 300e6) return CAP_CATEGORY.SMALL;
return CAP_CATEGORY.MICRO;
}
// ── Growth / style classification ───────────────────────────────────────
// revenueGrowth and earningsGrowth are in percentage form (e.g. 15 = 15%).
// dividendYield is also in percentage form (e.g. 3.5 = 3.5%).
_classifyGrowth(
revenueGrowth: number | null,
earningsGrowth: number | null,
dividendYield: number | null,
): GrowthCategory {
const rev = revenueGrowth ?? 0;
const earn = earningsGrowth ?? 0;
const div = dividendYield ?? 0;
if (rev < -5) return GROWTH_CATEGORY.DECLINING;
if (earn < 0 && rev >= 0) return GROWTH_CATEGORY.TURNAROUND;
if (rev >= 15 || earn >= 20) return GROWTH_CATEGORY.HIGH_GROWTH;
if (rev >= 5) return GROWTH_CATEGORY.MODERATE_GROWTH;
if (div >= 3 && rev < 5) return GROWTH_CATEGORY.VALUE;
return GROWTH_CATEGORY.STABLE;
}
_mapToStandardSector(data: StockData): Sector {
const profile = data.assetProfile ?? {};
const industry = (profile.industry || '').toLowerCase();
@@ -140,6 +146,8 @@ export class Stock extends Asset {
getDisplayMetrics(): Record<string, string | null> {
const fmt = (v: number | null, dec = 1, suffix = '') =>
v != null ? `${v.toFixed(dec)}${suffix}` : null;
const fmtSign = (v: number | null, suffix = '%') =>
v != null ? `${v >= 0 ? '+' : ''}${v.toFixed(1)}${suffix}` : null;
const m = this.metrics;
const w52pos =
@@ -147,27 +155,68 @@ export class Stock extends Asset {
? (((m.currentPrice - m.week52Low) / (m.week52High - m.week52Low)) * 100).toFixed(0) + '%'
: null;
// Analyst label: convert Yahoo's 15 scale to a readable string
const analystLabel = (rating: number | null): string | null => {
if (rating == null) return null;
if (rating <= 1.5) return 'Strong Buy';
if (rating <= 2.5) return 'Buy';
if (rating <= 3.5) return 'Hold';
if (rating <= 4.5) return 'Sell';
return 'Strong Sell';
};
const display: Record<string, string | null> = {
Ticker: this.ticker,
Price: this.formatCurrency(this.currentPrice),
Sector: this.sector,
'Cap Tier': m.capCategory,
Style: m.growthCategory,
};
// Valuation
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);
// Quality
if (m.grossMargin != null) display['GrossM%'] = fmt(m.grossMargin, 1, '%');
if (m.returnOnEquity != null) display['ROE%'] = fmt(m.returnOnEquity, 1, '%');
if (m.operatingMargin != null) display['OpMgn%'] = fmt(m.operatingMargin, 1, '%');
if (m.netProfitMargin != null) display['NetMgn%'] = fmt(m.netProfitMargin, 1, '%');
if (m.revenueGrowth != null) display['Rev%'] = fmt(m.revenueGrowth, 1, '%');
if (m.fcfYield != null) display['FCF Yld%'] = fmt(m.fcfYield, 1, '%');
if (m.dividendYield != null) display['Div%'] = fmt(m.dividendYield, 2, '%');
// Risk
if (m.debtToEquity != null) display['D/E'] = fmt(m.debtToEquity, 2);
if (m.quickRatio != null) display['Quick'] = fmt(m.quickRatio, 2);
if (m.beta != null) display['Beta'] = fmt(m.beta, 2);
// 52-week movement
if (w52pos != null) display['52W Pos'] = w52pos;
if (m.week52Change != null) display['52W Chg'] = fmtSign(m.week52Change, '%');
if (m.week52FromHigh != null) display['From High'] = fmtSign(m.week52FromHigh, '%');
if (m.week52FromLow != null) display['From Low'] = fmtSign(m.week52FromLow, '%');
// REIT-specific
if (m.pFFO != null) display['P/FFO'] = fmt(m.pFFO, 1);
// Analyst consensus
if (m.analystRating != null) {
display['Analyst'] = analystLabel(m.analystRating);
display['# Analysts'] = m.numberOfAnalysts != null ? String(m.numberOfAnalysts) : null;
display['Target'] =
m.analystTargetPrice != null ? this.formatCurrency(m.analystTargetPrice) : null;
display['Upside'] = fmtSign(m.analystUpside, '%');
}
// DCF
if (m.dcfIntrinsicValue != null) {
display['DCF Value'] = this.formatCurrency(m.dcfIntrinsicValue);
display['DCF Safety'] =
m.dcfMarginOfSafety != null ? fmtSign(m.dcfMarginOfSafety, '%') : null;
}
return display;
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
import fs from 'fs';
import path from 'path';
import type { MarketContext } from '../types.js';
import type { MarketContext } from '../types';
export class FinanceReporter {
render(advice: unknown[], personalFinance: unknown, marketContext: MarketContext): string {
+2 -2
View File
@@ -1,6 +1,6 @@
import fs from 'fs';
import path from 'path';
import type { MarketContext } from '../types.js';
import type { MarketContext } from '../types';
// Generates a self-contained HTML report saved to ./screener-report.html
// Console output shows only the signal summary — full breakdown lives here.
@@ -204,7 +204,7 @@ export class HtmlReporter {
}
// Collect only headers that have at least one non-null value across all items
_headers(type, items, mode) {
_headers(type, items, _mode) {
const base = ['Ticker', 'Price', 'Verdict', 'Score'];
if (type === 'STOCK') {
const metricKeys = [
@@ -1,34 +1,21 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { randomUUID } from 'crypto';
import type { MarketCall, Signal, TickerSnapshot } from '../types.js';
import type { MarketCall, CreateCallInput, StoreData } from '../types';
const STORE_PATH = './market-calls.json';
export class MarketCallRepository {
private static readonly 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: [] };
if (!existsSync(MarketCallRepository.STORE_PATH)) return { calls: [] };
try {
return JSON.parse(readFileSync(STORE_PATH, 'utf8')) as StoreData;
return JSON.parse(readFileSync(MarketCallRepository.STORE_PATH, 'utf8')) as StoreData;
} catch {
return { calls: [] };
}
}
private _save(data: StoreData): void {
writeFileSync(STORE_PATH, JSON.stringify(data, null, 2), 'utf8');
writeFileSync(MarketCallRepository.STORE_PATH, JSON.stringify(data, null, 2), 'utf8');
}
list(): (MarketCall & { createdAt: string })[] {
@@ -0,0 +1,39 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import type { PortfolioData, PortfolioHolding } from '../types';
export class PortfolioRepository {
private static readonly PORTFOLIO_PATH = './portfolio.json';
exists(): boolean {
return existsSync(PortfolioRepository.PORTFOLIO_PATH);
}
read(): PortfolioData {
if (!this.exists()) return { holdings: [] };
return JSON.parse(readFileSync(PortfolioRepository.PORTFOLIO_PATH, 'utf8')) as PortfolioData;
}
write(data: PortfolioData): void {
writeFileSync(PortfolioRepository.PORTFOLIO_PATH, JSON.stringify(data, null, 2), 'utf8');
}
upsert(entry: PortfolioHolding): PortfolioHolding {
const data = this.read();
const normalized = entry.ticker.toUpperCase().trim();
const idx = data.holdings.findIndex((h) => h.ticker.toUpperCase() === normalized);
const record: PortfolioHolding = { ...entry, ticker: normalized };
if (idx >= 0) data.holdings[idx] = record;
else data.holdings.push(record);
this.write(data);
return record;
}
remove(ticker: string): boolean {
const data = this.read();
const before = data.holdings.length;
data.holdings = data.holdings.filter((h) => h.ticker.toUpperCase() !== ticker.toUpperCase());
if (data.holdings.length === before) return false;
this.write(data);
return true;
}
}
@@ -1,21 +1,7 @@
import type { BondMetrics } from '../assets/Bond.js';
import type { MarketContext } from '../../types.js';
import type { BondMetrics, MarketContext, ScoreResult, SanitizedBondMetrics } from '../types';
interface SanitizedBondMetrics {
ytm: number;
duration: number;
creditRating: string;
creditRatingNumeric: number;
}
interface ScoreOutput {
label: string;
scoreSummary: string;
audit: Record<string, unknown>;
}
export const BondScorer = {
score(
export class BondScorer {
static score(
m: BondMetrics,
rules: {
gates: Record<string, number>;
@@ -23,9 +9,9 @@ export const BondScorer = {
thresholds: Record<string, number>;
},
context?: MarketContext | null,
): ScoreOutput {
): ScoreResult {
const { gates, weights, thresholds } = rules;
const metrics = this._sanitize(m);
const metrics = BondScorer._sanitize(m);
const riskFreeRate = (context?.riskFreeRate ?? 4.0) / 100;
if (metrics.creditRatingNumeric < gates.minCreditRating) {
@@ -47,11 +33,11 @@ export const BondScorer = {
return {
label: score >= 4 ? '🟢 Attractive' : score >= 1 ? '🟡 Neutral' : '🔴 Avoid',
scoreSummary: `Score: ${score}`,
audit: { breakdown },
audit: { passedGates: true, breakdown },
};
},
}
_sanitize(m: BondMetrics): SanitizedBondMetrics {
private static _sanitize(m: BondMetrics): SanitizedBondMetrics {
const pct = (v: unknown): number =>
parseFloat(typeof v === 'string' ? v.replace('%', '') : String(v)) / 100 || 0;
return {
@@ -60,5 +46,5 @@ export const BondScorer = {
creditRating: m.creditRating || 'BBB',
creditRatingNumeric: m.creditRatingNumeric ?? 7,
};
},
};
}
}
@@ -1,20 +1,14 @@
import type { EtfMetrics } from '../assets/Etf.js';
import type { EtfMetrics, ScoreResult } from '../types';
interface ScoreOutput {
label: string;
scoreSummary: string;
audit?: Record<string, unknown>;
}
export const EtfScorer = {
score(
export class EtfScorer {
static score(
m: EtfMetrics,
rules: {
gates: Record<string, number>;
weights: Record<string, number>;
thresholds: Record<string, number>;
},
): ScoreOutput {
): ScoreResult {
const { gates, weights, thresholds } = rules;
const metrics = {
expenseRatio: parseFloat(String(m.expenseRatio)) || 0,
@@ -24,7 +18,11 @@ export const EtfScorer = {
};
if (metrics.expenseRatio > gates.maxExpenseRatio) {
return { label: '🔴 REJECT', scoreSummary: 'Gate failed: High Expense Ratio' };
return {
label: '🔴 REJECT',
scoreSummary: 'Gate failed: High Expense Ratio',
audit: { passedGates: false },
};
}
const breakdown: Record<string, number> = {
@@ -46,5 +44,5 @@ export const EtfScorer = {
scoreSummary: `Score: ${score}`,
audit: { passedGates: true, breakdown },
};
},
};
}
}
+251
View File
@@ -0,0 +1,251 @@
import type { NumVal, SanitizedMetrics, ScoreResult, StockMetrics } from '../types';
export class StockScorer {
private static n(v: unknown): NumVal {
const f = parseFloat(String(v));
return !isNaN(f) && f !== 0 ? f : null;
}
private static scoreValue(val: number, high: number, med: number, weight: number): number {
return val >= high ? weight : val >= med ? 1 : -1;
}
private static scorePeg(val: number, high: number, med: number, weight: number): number {
return val <= high ? weight : val <= med ? 1 : -1;
}
static score(
metrics: StockMetrics,
rules: {
gates: Record<string, number>;
weights: Record<string, number>;
thresholds: Record<string, number>;
},
): ScoreResult {
const { gates, weights, thresholds } = rules;
const m = StockScorer._sanitize(metrics);
const failures = [
m.debtToEquity != null &&
m.debtToEquity > gates.maxDebtToEquity &&
`D/E ${m.debtToEquity.toFixed(1)} > ${gates.maxDebtToEquity}`,
m.quickRatio != null &&
m.quickRatio < gates.minQuickRatio &&
`Quick ${m.quickRatio.toFixed(2)} < ${gates.minQuickRatio}`,
m.peRatio != null &&
m.peRatio > gates.maxPERatio &&
`P/E ${m.peRatio.toFixed(0)} > ${gates.maxPERatio}`,
m.pegRatio != null &&
m.pegRatio > gates.maxPegGate &&
`PEG ${m.pegRatio.toFixed(1)} > ${gates.maxPegGate}`,
m.priceToBook != null &&
gates.maxPriceToBook &&
m.priceToBook > gates.maxPriceToBook &&
`P/B ${m.priceToBook.toFixed(1)} > ${gates.maxPriceToBook}`,
].filter(Boolean) as string[];
if (failures.length > 0) {
return {
label: '🔴 REJECT',
scoreSummary: `Gate failed: ${failures.join(' | ')}`,
audit: { passedGates: false, failures },
};
}
const factors = [
{
key: 'roe',
active: weights.roe > 0 && m.returnOnEquity != null,
fn: () =>
StockScorer.scoreValue(
m.returnOnEquity!,
thresholds.roeHigh,
thresholds.roeMed,
weights.roe,
),
},
{
key: 'opMargin',
active: weights.opMargin > 0 && m.operatingMargin != null,
fn: () =>
StockScorer.scoreValue(
m.operatingMargin!,
thresholds.opMarginHigh,
thresholds.opMarginMed,
weights.opMargin,
),
},
{
key: 'margin',
active: weights.margin > 0 && m.netProfitMargin != null,
fn: () =>
StockScorer.scoreValue(
m.netProfitMargin!,
thresholds.marginHigh,
thresholds.marginMed,
weights.margin,
),
},
{
key: 'peg',
active: weights.peg > 0 && m.pegRatio != null,
fn: () =>
StockScorer.scorePeg(m.pegRatio!, thresholds.pegHigh, thresholds.pegMed, weights.peg),
},
{
key: 'revenue',
active: weights.revenue > 0 && m.revenueGrowth != null,
fn: () =>
StockScorer.scoreValue(
m.revenueGrowth!,
thresholds.revHigh,
thresholds.revMed,
weights.revenue,
),
},
{
key: 'fcf',
active: weights.fcf > 0 && m.fcfYield != null,
fn: () =>
StockScorer.scoreValue(
m.fcfYield!,
thresholds.fcfHigh ?? 5,
thresholds.fcfMed ?? 2,
weights.fcf,
),
},
{
key: 'yield',
active: (weights.yield ?? 0) > 0 && m.dividendYield != null,
fn: () => (m.dividendYield! >= (thresholds.minYield ?? 4) ? weights.yield : -1),
},
{
key: 'pFFO',
active: (weights.pFFO ?? 0) > 0 && m.pFFO != null,
fn: () => (m.pFFO! <= (thresholds.maxPFFO ?? 15) ? weights.pFFO : -2),
},
{
key: 'priceToBook',
active: (weights.priceToBook ?? 0) > 0 && m.priceToBook != null,
fn: () => StockScorer.scoreValue(1 / m.priceToBook!, 1 / 1.0, 1 / 2.0, weights.priceToBook),
},
// ── Expert features ────────────────────────────────────────────────
{
// Analyst consensus: Yahoo recommendationMean 1=Strong Buy → 5=Strong Sell.
// We invert and score: ≤ analystBuy gets full weight, ≤ analystHold gets 1pt,
// above Hold loses weight. Requires ≥ 3 analysts to avoid noise from thin coverage.
key: 'analyst',
active:
(weights.analyst ?? 0) > 0 &&
m.analystRating != null &&
(metrics.numberOfAnalysts ?? 0) >= 3,
fn: (): number => {
const r = m.analystRating!;
const buyThreshold = thresholds.analystBuy ?? 2.0;
const holdThreshold = thresholds.analystHold ?? 3.0;
if (r <= buyThreshold) return weights.analyst ?? 2;
if (r <= holdThreshold) return 1;
if (r <= 4.0) return -1;
return -(weights.analyst ?? 2); // Strong Sell
},
},
{
// DCF margin of safety: how undervalued the stock is vs. 2-stage FCF model.
// Positive = undervalued (good), negative = overvalued (bad).
// Only fires when DCF could be computed (positive FCF required).
key: 'dcf',
active: (weights.dcf ?? 0) > 0 && m.dcfMarginOfSafety != null,
fn: (): number => {
const mos = m.dcfMarginOfSafety!;
const undervalued = thresholds.dcfUndervalued ?? 20;
const fairValue = thresholds.dcfFairValue ?? 0;
if (mos >= undervalued) return weights.dcf ?? 2;
if (mos >= fairValue) return 1;
if (mos >= -20) return -1;
return -(weights.dcf ?? 2); // significantly overvalued
},
},
];
const breakdown: Record<string, number> = {};
const totalScore = factors.reduce((sum, f) => {
if (!f.active) return sum;
breakdown[f.key] = f.fn() as number;
return sum + breakdown[f.key];
}, 0);
const riskFlags = [
m.beta != null && m.beta > 1.5 && `High volatility (β ${m.beta.toFixed(2)})`,
m.beta != null && m.beta < 0 && `Inverse market correlation (β ${m.beta.toFixed(2)})`,
// 52-week position flags
m.week52Position != null && m.week52Position > 0.9 && 'Near 52-week high — crowded trade',
m.week52Position != null &&
m.week52Position < 0.1 &&
'Near 52-week low — potential opportunity',
// 52-week momentum flags
m.week52Change != null &&
m.week52Change >= 50 &&
`Strong uptrend: +${m.week52Change.toFixed(0)}% in 52 weeks`,
m.week52Change != null &&
m.week52Change <= -30 &&
`Significant drawdown: ${m.week52Change.toFixed(0)}% in 52 weeks`,
// Distance from 52-week high
m.week52FromHigh != null &&
m.week52FromHigh <= -20 &&
`${Math.abs(m.week52FromHigh).toFixed(0)}% off 52-week high`,
// Analyst/DCF divergence signal
m.analystUpside != null &&
m.analystUpside >= 25 &&
`Analyst consensus: ${m.analystUpside.toFixed(0)}% upside to target`,
m.analystUpside != null &&
m.analystUpside <= -15 &&
`Analyst consensus: target ${Math.abs(m.analystUpside).toFixed(0)}% below current price`,
m.dcfMarginOfSafety != null &&
m.dcfMarginOfSafety >= 30 &&
`DCF: ${m.dcfMarginOfSafety.toFixed(0)}% margin of safety`,
m.dcfMarginOfSafety != null &&
m.dcfMarginOfSafety <= -30 &&
`DCF: stock trading ${Math.abs(m.dcfMarginOfSafety).toFixed(0)}% above intrinsic value`,
].filter(Boolean) as string[];
return {
label: StockScorer._label(totalScore),
scoreSummary: `Score: ${totalScore}`,
audit: { passedGates: true, breakdown, riskFlags: riskFlags.length ? riskFlags : null },
};
}
private static _label(score: number): string {
if (score >= 8) return '🟢 BUY (High Conviction)';
if (score >= 4) return '🟢 BUY (Speculative)';
if (score >= 0) return '🟡 HOLD';
return '🔴 REJECT';
}
private static _sanitize(m: StockMetrics): SanitizedMetrics {
const w52 =
m.week52High != null && m.week52High > 0 && m.week52Low != null && m.currentPrice > 0
? (m.currentPrice - m.week52Low) / (m.week52High - m.week52Low)
: null;
return {
debtToEquity: StockScorer.n(m.debtToEquity),
quickRatio: StockScorer.n(m.quickRatio),
peRatio: StockScorer.n(m.peRatio),
pegRatio: StockScorer.n(m.pegRatio),
priceToBook: StockScorer.n(m.priceToBook),
netProfitMargin: StockScorer.n(m.netProfitMargin),
operatingMargin: StockScorer.n(m.operatingMargin),
returnOnEquity: StockScorer.n(m.returnOnEquity),
revenueGrowth: StockScorer.n(m.revenueGrowth),
fcfYield: StockScorer.n(m.fcfYield),
dividendYield: StockScorer.n(m.dividendYield),
pFFO: StockScorer.n(m.pFFO),
beta: StockScorer.n(m.beta),
week52Position: w52,
week52Change: StockScorer.n(m.week52Change),
week52FromHigh: StockScorer.n(m.week52FromHigh),
analystRating: StockScorer.n(m.analystRating),
analystUpside: StockScorer.n(m.analystUpside),
dcfMarginOfSafety: StockScorer.n(m.dcfMarginOfSafety),
};
}
}
-4
View File
@@ -1,4 +0,0 @@
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),
);
-137
View File
@@ -1,137 +0,0 @@
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,
});
-180
View File
@@ -1,180 +0,0 @@
import { YahooClient } from '../market/YahooClient.js';
import { BenchmarkProvider } from '../market/BenchmarkProvider.js';
import { mapToStandardFormat } from './DataMapper.js';
import { chunkArray } from './Chunker.js';
import { RuleMerger } from './RuleMerger.js';
import { Stock } from './assets/Stock.js';
import { Etf } from './assets/Etf.js';
import { Bond } from './assets/Bond.js';
import { StockScorer } from './scorers/StockScorer.js';
import { EtfScorer } from './scorers/EtfScorer.js';
import { BondScorer } from './scorers/BondScorer.js';
import { SIGNAL, SIGNAL_ORDER, SCORE_MODE, ASSET_TYPE } from '../config/constants.js';
import type { Logger, MarketContext, Signal, AssetType, ScreenerResult } from '../types.js';
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 {
private client: YahooClient;
private benchmarkProvider: BenchmarkProvider;
private logger: Logger;
constructor({ logger }: ScreenerEngineOptions = {}) {
this.client = new YahooClient();
this.benchmarkProvider = new BenchmarkProvider({
logger: logger ?? (console as unknown as Logger),
});
this.logger = logger ?? {
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: string[]): Promise<ScreenerResult> {
const marketContext = await this.benchmarkProvider.getMarketContext();
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<void>((r) => setTimeout(r, 1000));
}
return { ...results, marketContext };
}
// CLI helper — emits progress to logger, returns structured results.
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: Omit<ScreenerResult, 'marketContext'> = {
STOCK: [],
ETF: [],
BOND: [],
ERROR: [],
};
const chunks = chunkArray(tickers, 5);
let processed = 0;
for (const chunk of chunks) {
const batch = await Promise.all(chunk.map((t) => this._fetch(t)));
batch.forEach((data) => this._process(data, marketContext, results));
processed += chunk.length;
this.logger.write(`\r⏳ Screening tickers... ${processed}/${tickers.length}`);
await new Promise<void>((r) => setTimeout(r, 1000));
}
this.logger.write('\n');
return { ...results, marketContext };
}
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 as Error).message };
}
}
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 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 as never,
RuleMerger.getRulesForAsset(
asset.type as AssetType,
asset.metrics as { sector?: string },
marketContext,
SCORE_MODE.FUNDAMENTAL,
),
marketContext,
);
const inflated = scorer.score(
asset.metrics as never,
RuleMerger.getRulesForAsset(
asset.type as AssetType,
asset.metrics as { sector?: string },
marketContext,
SCORE_MODE.INFLATED,
),
marketContext,
);
(results[asset.type as AssetType] as unknown[]).push({
asset,
fundamental,
inflated,
signal: this._signal(fundamental.label, inflated.label),
});
} catch (err) {
results.ERROR.push({
ticker: ((data as { ticker?: string }).ticker || 'UNKNOWN').toUpperCase(),
message: (err as Error).message,
});
}
}
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 as never);
case ASSET_TYPE.ETF:
return new Etf(data as never);
default:
return new Stock(data as never);
}
}
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;
if (yellow(fundamentalLabel) || yellow(inflatedLabel)) return SIGNAL.NEUTRAL;
return SIGNAL.AVOID;
}
signalOrder(signal: Signal): number {
return SIGNAL_ORDER[signal] ?? 5;
}
}
-193
View File
@@ -1,193 +0,0 @@
import { SIGNAL } from '../../config/constants.js';
import type { StockMetrics } from '../assets/Stock.js';
type NumVal = number | null;
const n = (v: unknown): NumVal => {
const f = parseFloat(String(v));
return !isNaN(f) && f !== 0 ? f : null;
};
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: 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);
const failures = [
m.debtToEquity != null &&
m.debtToEquity > gates.maxDebtToEquity &&
`D/E ${m.debtToEquity.toFixed(1)} > ${gates.maxDebtToEquity}`,
m.quickRatio != null &&
m.quickRatio < gates.minQuickRatio &&
`Quick ${m.quickRatio.toFixed(2)} < ${gates.minQuickRatio}`,
m.peRatio != null &&
m.peRatio > gates.maxPERatio &&
`P/E ${m.peRatio.toFixed(0)} > ${gates.maxPERatio}`,
m.pegRatio != null &&
m.pegRatio > gates.maxPegGate &&
`PEG ${m.pegRatio.toFixed(1)} > ${gates.maxPegGate}`,
m.priceToBook != null &&
gates.maxPriceToBook &&
m.priceToBook > gates.maxPriceToBook &&
`P/B ${m.priceToBook.toFixed(1)} > ${gates.maxPriceToBook}`,
].filter(Boolean) as string[];
if (failures.length > 0) {
return {
label: '🔴 REJECT',
scoreSummary: `Gate failed: ${failures.join(' | ')}`,
audit: { passedGates: false, failures },
};
}
const factors = [
{
key: 'roe',
active: weights.roe > 0 && m.returnOnEquity != null,
fn: () => scoreValue(m.returnOnEquity!, thresholds.roeHigh, thresholds.roeMed, weights.roe),
},
{
key: 'opMargin',
active: weights.opMargin > 0 && m.operatingMargin != null,
fn: () =>
scoreValue(
m.operatingMargin!,
thresholds.opMarginHigh,
thresholds.opMarginMed,
weights.opMargin,
),
},
{
key: 'margin',
active: weights.margin > 0 && m.netProfitMargin != null,
fn: () =>
scoreValue(
m.netProfitMargin!,
thresholds.marginHigh,
thresholds.marginMed,
weights.margin,
),
},
{
key: 'peg',
active: weights.peg > 0 && m.pegRatio != null,
fn: () => scorePeg(m.pegRatio!, thresholds.pegHigh, thresholds.pegMed, weights.peg),
},
{
key: 'revenue',
active: weights.revenue > 0 && m.revenueGrowth != null,
fn: () =>
scoreValue(m.revenueGrowth!, thresholds.revHigh, thresholds.revMed, weights.revenue),
},
{
key: 'fcf',
active: weights.fcf > 0 && m.fcfYield != null,
fn: () =>
scoreValue(m.fcfYield!, thresholds.fcfHigh ?? 5, thresholds.fcfMed ?? 2, weights.fcf),
},
{
key: 'yield',
active: (weights.yield ?? 0) > 0 && m.dividendYield != null,
fn: () => (m.dividendYield! >= (thresholds.minYield ?? 4) ? weights.yield : -1),
},
{
key: 'pFFO',
active: (weights.pFFO ?? 0) > 0 && m.pFFO != null,
fn: () => (m.pFFO! <= (thresholds.maxPFFO ?? 15) ? weights.pFFO : -2),
},
{
key: 'priceToBook',
active: (weights.priceToBook ?? 0) > 0 && m.priceToBook != null,
fn: () => scoreValue(1 / m.priceToBook!, 1 / 1.0, 1 / 2.0, weights.priceToBook),
},
];
const breakdown: Record<string, number> = {};
const totalScore = factors.reduce((sum, f) => {
if (!f.active) return sum;
breakdown[f.key] = f.fn() as number;
return sum + breakdown[f.key];
}, 0);
const riskFlags = [
m.beta != null && m.beta > 1.5 && `High volatility (β ${m.beta.toFixed(2)})`,
m.beta != null && m.beta < 0 && `Inverse market correlation (β ${m.beta.toFixed(2)})`,
m.week52Position != null && m.week52Position > 0.9 && 'Near 52-week high — crowded trade',
m.week52Position != null &&
m.week52Position < 0.1 &&
'Near 52-week low — potential opportunity',
].filter(Boolean) as string[];
return {
label: this._label(totalScore),
scoreSummary: `Score: ${totalScore}`,
audit: { passedGates: true, breakdown, riskFlags: riskFlags.length ? riskFlags : null },
};
},
_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: StockMetrics): SanitizedMetrics {
const w52 =
m.week52High != null && m.week52High > 0 && m.week52Low != null && m.currentPrice > 0
? (m.currentPrice - m.week52Low) / (m.week52High - m.week52Low)
: null;
return {
debtToEquity: n(m.debtToEquity),
quickRatio: n(m.quickRatio),
peRatio: n(m.peRatio),
pegRatio: n(m.pegRatio),
priceToBook: n(m.priceToBook),
netProfitMargin: n(m.netProfitMargin),
operatingMargin: n(m.operatingMargin),
returnOnEquity: n(m.returnOnEquity),
revenueGrowth: n(m.revenueGrowth),
fcfYield: n(m.fcfYield),
dividendYield: n(m.dividendYield),
pFFO: n(m.pFFO),
beta: n(m.beta),
week52Position: w52,
};
},
};
-83
View File
@@ -1,83 +0,0 @@
import Fastify from 'fastify';
import cors from '@fastify/cors';
import screenerRoutes from './routes/screener.js';
import financeRoutes from './routes/finance.js';
import callsRoutes from './routes/calls.js';
import { YahooClient } from '../market/YahooClient.js';
import { LLMAnalyst } from '../analyst/LLMAnalyst.js';
import { noopLogger } from './utils/logger.js';
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 as any);
await app.register(financeRoutes as any);
await app.register(callsRoutes as any);
// POST /api/analyze
app.post('/api/analyze', {
schema: {
body: {
type: 'object',
required: ['tickers'],
properties: {
tickers: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 50 },
},
},
},
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 as { tickers: string[] }).tickers.map((t) => t.toUpperCase());
const client = new YahooClient();
const llm = new LLMAnalyst({ logger: noopLogger });
const seen = new Map<
string,
{ title: string; publisher: string; link: string; relatedTickers: string[] }
>();
await Promise.all(
tickers.slice(0, 10).map(async (ticker: string) => {
try {
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,
publisher: s.publisher,
link: s.link,
relatedTickers: s.relatedTickers ?? [],
});
}
}
} catch {
/* skip */
}
}),
);
const stories = [...seen.values()].slice(0, 15);
if (!stories.length) return reply.code(200).send({ analysis: null, reason: 'no_stories' });
const analysis = await llm.analyze(stories, tickers);
return { analysis };
},
});
app.get('/health', async () => ({ status: 'ok' }));
return app;
}
-190
View File
@@ -1,190 +0,0 @@
import { MarketCallStore } from '../../calls/MarketCallStore.js';
import { ScreenerEngine } from '../../screener/ScreenerEngine.js';
import { YahooClient } from '../../market/YahooClient.js';
import { chunkArray } from '../../screener/Chunker.js';
import { noopLogger } from '../utils/logger.js';
const store = new MarketCallStore();
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 {
price: r.asset?.currentPrice ?? null,
signal: r.signal ?? null,
inflatedVerdict: r.inflated?.label ?? null,
fundamentalVerdict: r.fundamental?.label ?? null,
pe: m['P/E'] ?? null,
roe: m['ROE%'] ?? null,
fcf: m['FCF Yld%'] ?? null,
};
};
export default async function callsRoutes(app: any) {
// GET /api/calls
app.get('/api/calls', async () => ({ calls: store.list() }));
// 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' });
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);
for (const r of [...results.STOCK, ...results.ETF, ...results.BOND]) {
current[r.asset.ticker] = toSnapshot(r);
}
} catch {
/* non-fatal */
}
}
return { ...call, current };
});
// POST /api/calls
app.post('/api/calls', {
schema: {
body: {
type: 'object',
required: ['title', 'quarter', 'thesis', 'tickers'],
properties: {
title: { type: 'string', minLength: 3 },
quarter: { type: 'string', minLength: 2 },
date: { type: 'string' },
thesis: { type: 'string', minLength: 10 },
tickers: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 30 },
},
},
},
handler: async (req: 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());
const snapshot: Record<string, SnapshotEntry | null> = {};
try {
const engine = new ScreenerEngine({ logger: noopLogger });
const results = await engine.screenTickers(upperTickers);
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 as Error).message);
}
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: 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
app.get('/api/calls/calendar', async (req: any) => {
const client = new YahooClient();
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 set = new Set(store.list().flatMap((c) => c.tickers));
tickers = [...set];
}
if (tickers.length === 0) return { events: [] };
const results: Record<string, any> = {};
for (const batch of chunkArray(tickers, 5)) {
await Promise.all(
batch.map(async (ticker) => {
const cal = await client.fetchCalendarEvents(ticker);
if (cal) results[ticker] = cal;
}),
);
await new Promise<void>((r) => setTimeout(r, 500));
}
const events: any[] = [];
const now = Date.now();
for (const [ticker, cal] of Object.entries(results)) {
for (const dateVal of cal.earnings?.earningsDate ?? []) {
const d = new Date(dateVal as string);
events.push({
ticker,
type: 'earnings',
date: d.toISOString().slice(0, 10),
label: 'Earnings',
detail: cal.earnings.isEarningsDateEstimate ? 'Estimated' : 'Confirmed',
epsEstimate: cal.earnings.earningsAverage ?? null,
revEstimate: cal.earnings.revenueAverage ?? null,
isPast: d.getTime() < now,
});
}
if (cal.exDividendDate) {
const d = new Date(cal.exDividendDate);
events.push({
ticker,
type: 'exdividend',
date: d.toISOString().slice(0, 10),
label: 'Ex-Dividend',
detail: null,
isPast: d.getTime() < now,
});
}
if (cal.dividendDate) {
const d = new Date(cal.dividendDate);
events.push({
ticker,
type: 'dividend',
date: d.toISOString().slice(0, 10),
label: 'Dividend',
detail: null,
isPast: d.getTime() < now,
});
}
}
events.sort((a, b) => {
if (a.isPast !== b.isPast) return a.isPast ? 1 : -1;
return a.isPast
? new Date(b.date).getTime() - new Date(a.date).getTime()
: new Date(a.date).getTime() - new Date(b.date).getTime();
});
return { events, tickers };
});
}
-108
View File
@@ -1,108 +0,0 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { ScreenerEngine } from '../../screener/ScreenerEngine.js';
import { PersonalFinanceAnalyzer } from '../../finance/PersonalFinanceAnalyzer.js';
import { PortfolioAdvisor } from '../../finance/PortfolioAdvisor.js';
import { SimpleFINClient } from '../../finance/clients/SimpleFINClient.js';
import { noopLogger } from '../utils/logger.js';
import type { PortfolioHolding } from '../../types.js';
const PORTFOLIO_PATH = './portfolio.json';
const normalizeYahoo = (t: string) => t.toUpperCase().replace(/\./g, '-');
export default async function financeRoutes(app: any) {
// GET /api/finance/portfolio
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')) as {
holdings: PortfolioHolding[];
};
let personalFinance = null;
if (process.env.SIMPLEFIN_ACCESS_URL) {
const client = new SimpleFINClient({ logger: noopLogger });
const { accounts } = await client.getAccounts();
personalFinance = new PersonalFinanceAnalyzer().analyse(accounts);
}
const screenable = holdings
.filter((h) => (h.type ?? 'stock') !== 'crypto')
.map((h) => normalizeYahoo(h.ticker));
const engine = new ScreenerEngine({ logger: noopLogger });
const results =
screenable.length > 0
? await engine.screenTickers(screenable)
: { STOCK: [], ETF: [], BOND: [], ERROR: [], marketContext: {} as any };
const advice = await new PortfolioAdvisor().advise(holdings, results);
return { advice, personalFinance, marketContext: results.marketContext };
});
// POST /api/finance/holdings
app.post('/api/finance/holdings', {
schema: {
body: {
type: 'object',
required: ['ticker', 'shares'],
properties: {
ticker: { type: 'string', minLength: 1, maxLength: 10 },
shares: { type: 'number', exclusiveMinimum: 0 },
costBasis: { type: 'number', minimum: 0 },
type: { type: 'string', enum: ['stock', 'etf', 'bond', 'crypto'] },
source: { type: 'string' },
},
},
},
handler: async (req: 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')) 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 };
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);
},
});
// DELETE /api/finance/holdings/:ticker
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')) as {
holdings: PortfolioHolding[];
};
const before = portfolio.holdings.length;
portfolio.holdings = portfolio.holdings.filter((h) => h.ticker.toUpperCase() !== ticker);
if (portfolio.holdings.length === before)
return reply.code(404).send({ error: 'Holding not found' });
writeFileSync(PORTFOLIO_PATH, JSON.stringify(portfolio, null, 2), 'utf8');
return { ok: true };
});
// GET /api/finance/market-context
app.get('/api/finance/market-context', async () => {
const engine = new ScreenerEngine({ logger: noopLogger });
return engine['benchmarkProvider'].getMarketContext();
});
}
-55
View File
@@ -1,55 +0,0 @@
import { ScreenerEngine } from '../../screener/ScreenerEngine.js';
import { noopLogger } from '../utils/logger.js';
import type { AssetResult } from '../../types.js';
type AnyAsset = AssetResult['asset'] & {
getDisplayMetrics: () => Record<string, unknown>;
metrics: unknown;
};
const serializeAssets = (arr: (AssetResult & { asset: AnyAsset })[]) =>
arr.map((r) => ({
...r,
asset: {
ticker: r.asset.ticker,
type: r.asset.type,
currentPrice: r.asset.currentPrice,
metrics: r.asset.metrics,
displayMetrics: r.asset.getDisplayMetrics(),
},
}));
export default async function screenerRoutes(app: any) {
const engine = new ScreenerEngine({ logger: noopLogger });
app.post('/api/screen', {
schema: {
body: {
type: 'object',
required: ['tickers'],
properties: {
tickers: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 50 },
},
},
},
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 as any),
ETF: serializeAssets(results.ETF as any),
BOND: serializeAssets(results.BOND as any),
};
},
});
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,39 +1,36 @@
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;
}
import { YahooFinanceClient } from '../clients/YahooFinanceClient';
import { REGIME } from '../config/constants';
import type { MarketContext, Logger, BenchmarkProviderOptions } from '../types';
export class BenchmarkProvider {
private client: YahooClient;
private static readonly TTL_MS = 60 * 60 * 1000;
private static readonly DEFAULTS: MarketContext = {
sp500Price: 5000,
riskFreeRate: 4.5,
vixLevel: 20,
rateRegime: 'HIGH',
volatilityRegime: 'NORMAL',
benchmarks: { marketPE: 22, techPE: 30, reitYield: 3.5, igSpread: 1.0 },
};
private static rateRegime(rate: number): MarketContext['rateRegime'] {
return rate < 2 ? REGIME.LOW : rate <= 5 ? REGIME.NORMAL : REGIME.HIGH;
}
private static volRegime(vix: number): MarketContext['volatilityRegime'] {
return vix < 15 ? REGIME.LOW : vix <= 25 ? REGIME.NORMAL : REGIME.HIGH;
}
private static pe(summary: any): number | null {
return summary?.summaryDetail?.trailingPE ?? summary?.defaultKeyStatistics?.forwardPE ?? null;
}
private client: YahooFinanceClient;
private cache: { data: MarketContext | null; expiresAt: number };
private logger: Logger;
constructor({ logger }: BenchmarkProviderOptions = {}) {
this.client = new YahooClient();
this.client = new YahooFinanceClient();
this.cache = { data: null, expiresAt: 0 };
this.logger = logger ?? (console as unknown as Logger);
}
@@ -67,21 +64,21 @@ export class BenchmarkProvider {
sp500Price,
riskFreeRate,
vixLevel,
rateRegime: rateRegime(riskFreeRate),
volatilityRegime: volRegime(vixLevel),
rateRegime: BenchmarkProvider.rateRegime(riskFreeRate),
volatilityRegime: BenchmarkProvider.volRegime(vixLevel),
benchmarks: {
marketPE: pe(spy) ?? 22,
techPE: pe(xlk) ?? 30,
marketPE: BenchmarkProvider.pe(spy) ?? 22,
techPE: BenchmarkProvider.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 };
this.cache = { data: context, expiresAt: Date.now() + BenchmarkProvider.TTL_MS };
return context;
} catch (err) {
this.logger.warn('Market data fetch failed, using defaults:', (err as Error).message);
return this.cache.data ?? DEFAULTS;
return this.cache.data ?? BenchmarkProvider.DEFAULTS;
}
}
}
+108
View File
@@ -0,0 +1,108 @@
import { YahooFinanceClient } from '../clients/YahooFinanceClient';
import type { Logger, CatalystResult, Story, YahooNewsItem } from '../types';
export class CatalystAnalyst {
private static readonly NEWS_QUERIES = [
'stock market today',
'earnings report today',
'market news catalyst',
'federal reserve interest rates',
'stock upgrade downgrade analyst',
];
private static readonly MAX_STORIES = 20;
private static readonly TICKER_REGEX = /^[A-Z]{1,6}$/;
private client: YahooFinanceClient;
private logger: Pick<Logger, 'write'>;
constructor({ logger }: { logger?: Pick<Logger, 'write'> } = {}) {
this.client = new YahooFinanceClient();
this.logger = logger ?? { write: (msg: string) => process.stdout.write(msg) };
}
async run(): Promise<CatalystResult> {
this.logger.write('🔍 Fetching market news...');
const rawStories = await this._fetchNews();
if (!rawStories.length) {
this.logger.write(' ⚠ all news queries failed — check network or Yahoo rate limit\n');
return { tickers: [], tickerFrequency: {}, stories: [] };
}
const stories = rawStories.map((s) => ({
title: s.title,
link: s.link ?? '',
source: s.publisher ?? 'unknown',
tickers: (s.relatedTickers ?? [])
.map((t) => t.split(':')[0].toUpperCase())
.filter((t) => CatalystAnalyst.TICKER_REGEX.test(t)),
}));
const { tickers, tickerFrequency } = CatalystAnalyst.rankTickers(stories);
this.logger.write(` ${stories.length} stories, ${tickers.length} tickers\n`);
return { tickers, tickerFrequency, stories };
}
// Search by specific ticker for the /api/analyze endpoint.
async fetchStoriesForTickers(tickers: string[]): Promise<Story[]> {
const seen = new Map<string, YahooNewsItem>();
await Promise.all(
tickers.slice(0, 10).map(async (ticker) => {
try {
const news = await this.client.search(ticker, { newsCount: 3, quotesCount: 0 });
for (const item of news) {
if (!seen.has(item.title)) seen.set(item.title, item);
}
} catch {
/* skip tickers Yahoo can't resolve */
}
}),
);
return [...seen.values()].slice(0, 15).map((s) => ({
title: s.title,
link: s.link ?? '',
source: s.publisher ?? 'unknown',
tickers: (s.relatedTickers ?? [])
.map((t) => t.split(':')[0].toUpperCase())
.filter((t) => CatalystAnalyst.TICKER_REGEX.test(t)),
}));
}
private async _fetchNews(): Promise<YahooNewsItem[]> {
const seen = new Map<string, YahooNewsItem>();
let successCount = 0;
for (const query of CatalystAnalyst.NEWS_QUERIES) {
try {
const news = await this.client.search(query, { newsCount: 8, quotesCount: 0 });
successCount++;
for (const s of news) {
if (!seen.has(s.title)) {
seen.set(s.title, {
title: s.title,
publisher: s.publisher,
link: s.link,
relatedTickers: s.relatedTickers ?? [],
});
}
}
} catch {
/* skip failed query — tracked via successCount */
}
}
if (successCount === 0) return [];
return [...seen.values()].slice(0, CatalystAnalyst.MAX_STORIES);
}
static rankTickers(stories: Story[]): {
tickers: string[];
tickerFrequency: Record<string, number>;
} {
const freq: Record<string, number> = {};
for (const { tickers } of stories) {
for (const t of tickers) {
freq[t] = (freq[t] ?? 0) + 1;
}
}
const tickers = Object.keys(freq).sort((a, b) => freq[b] - freq[a]);
return { tickers, tickerFrequency: freq };
}
}
+227
View File
@@ -0,0 +1,227 @@
import type { MappedData } from '../types';
// Internal: Yahoo Finance API response shape
type YahooSummary = Record<string, Record<string, unknown>>;
export class DataMapper {
// ── Public entry point ────────────────────────────────────────────────────
static 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, ...DataMapper.mapBondData(summary) }
: { type: 'ETF', ticker, ...DataMapper.mapEtfData(summary) };
}
return { type: 'STOCK', ticker, ...DataMapper.mapStockData(summary) };
}
// ── Stock ─────────────────────────────────────────────────────────────────
private static 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 > 0 && 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 > 0 && (currentPrice as number) > 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;
// ── 52-week movement ──────────────────────────────────────────────────
const week52High = sd.fiftyTwoWeekHigh ?? null;
const week52Low = sd.fiftyTwoWeekLow ?? null;
const week52Change =
ks['52WeekChange'] != null ? +((ks['52WeekChange'] as number) * 100).toFixed(1) : null;
const week52FromHigh =
week52High != null && week52High > 0 && (currentPrice as number) > 0
? +(((currentPrice - week52High) / week52High) * 100).toFixed(1)
: null;
const week52FromLow =
week52Low != null && week52Low > 0 && (currentPrice as number) > 0
? +(((currentPrice - week52Low) / week52Low) * 100).toFixed(1)
: null;
// ── Analyst consensus ─────────────────────────────────────────────────
const analystRating = fd.recommendationMean ?? null;
const analystTargetPrice = fd.targetMeanPrice ?? null;
const numberOfAnalysts =
fd.numberOfAnalystOpinions != null ? Math.round(fd.numberOfAnalystOpinions as number) : null;
const analystUpside =
analystTargetPrice != null && (currentPrice as number) > 0
? +(((analystTargetPrice - currentPrice) / currentPrice) * 100).toFixed(1)
: null;
// ── Gross margin ──────────────────────────────────────────────────────
const grossMargin =
fd.grossMargins != null ? +((fd.grossMargins as number) * 100).toFixed(1) : null;
// ── DCF intrinsic value ───────────────────────────────────────────────
const revenueGrowthDecimal = fd.revenueGrowth != null ? (fd.revenueGrowth as number) : null;
const earningsGrowthDecimal = fd.earningsGrowth != null ? (fd.earningsGrowth as number) : null;
const dcfGrowthRate =
earningsGrowthDecimal ?? (revenueGrowthDecimal != null ? revenueGrowthDecimal * 0.7 : null);
const dcf = DataMapper.computeDCF(
freeCashflow as number,
sharesOutstanding as number,
currentPrice as number,
dcfGrowthRate,
);
return {
peRatio: trailingPE ?? ks.forwardPE,
trailingPE,
pegRatio,
priceToBook: ks.priceToBook ?? null,
evToEbitda: ks.enterpriseToEbitda ?? null,
grossMargin,
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,
week52Low,
week52Change,
week52FromHigh,
week52FromLow,
marketCap: pr.marketCap ?? null,
analystRating,
analystTargetPrice,
analystUpside,
numberOfAnalysts,
dcfIntrinsicValue: dcf?.intrinsicValue ?? null,
dcfMarginOfSafety: dcf?.marginOfSafety ?? null,
currentPrice,
assetProfile: summary.assetProfile || {},
};
}
// ── ETF ───────────────────────────────────────────────────────────────────
private static mapEtfData(summary: YahooSummary) {
return {
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,
};
}
// ── Bond ──────────────────────────────────────────────────────────────────
private static mapBondData(summary: YahooSummary) {
return {
yieldToMaturity: ((summary.summaryDetail?.yield as number) ?? 0) * 100,
duration: DataMapper.inferDuration(summary.assetProfile?.category as string),
creditRating: DataMapper.inferCreditRating(summary.assetProfile?.category as string),
currentPrice: (summary.price?.regularMarketPrice as number) ?? 0,
};
}
private static 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';
}
private static 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;
}
// ── DCF ───────────────────────────────────────────────────────────────────
// Two-stage model:
// Stage 1 — FCF/share grows at `growthRate` for 5 years, discounted at 9.5% WACC.
// Stage 2 — Terminal value via Gordon Growth Model at 2.5% perpetuity rate.
// Only fires when TTM FCF per share is positive.
private static computeDCF(
freeCashflow: number,
sharesOutstanding: number,
currentPrice: number,
growthRate: number | null,
riskFreeRate = 0.04,
): { intrinsicValue: number; marginOfSafety: number } | null {
if (!freeCashflow || freeCashflow <= 0) return null;
if (!sharesOutstanding || sharesOutstanding <= 0) return null;
if (!currentPrice || currentPrice <= 0) return null;
const fcfPerShare = freeCashflow / sharesOutstanding;
if (fcfPerShare <= 0) return null;
const discountRate = riskFreeRate + 0.055; // WACC proxy
const terminalGrowth = 0.025; // long-run GDP growth
const years = 5;
const g = Math.min(Math.max(growthRate ?? 0.08, -0.05), 0.3);
let pv = 0;
let fcfT = fcfPerShare;
for (let t = 1; t <= years; t++) {
fcfT *= 1 + g;
pv += fcfT / Math.pow(1 + discountRate, t);
}
const terminalValue = (fcfT * (1 + terminalGrowth)) / (discountRate - terminalGrowth);
pv += terminalValue / Math.pow(1 + discountRate, years);
const intrinsicValue = +pv.toFixed(2);
const marginOfSafety = +(((intrinsicValue - currentPrice) / intrinsicValue) * 100).toFixed(1);
return { intrinsicValue, marginOfSafety };
}
}
+66
View File
@@ -0,0 +1,66 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { fileURLToPath } from 'url';
import { AnthropicClient } from '../clients/AnthropicClient';
import type { Logger, LLMAnalysis, Story } from '../types';
export class LLMAnalyst {
private logger: Pick<Logger, 'log' | 'warn'>;
private client: AnthropicClient;
constructor({ logger }: { logger?: Pick<Logger, 'log' | 'warn'> } = {}) {
this.logger = logger ?? { log: console.log, warn: console.warn };
this.client = new AnthropicClient();
}
get isAvailable(): boolean {
return this.client.isAvailable;
}
async analyze(
stories: Story[],
existingTickers: string[] = [],
tickerFrequency: Record<string, number> = {},
): Promise<LLMAnalysis | null> {
if (!this.client.isAvailable) {
this.logger.warn('LLMAnalyst: ANTHROPIC_API_KEY not set — skipping analysis');
return null;
}
if (!stories?.length) return null;
const headlines = stories
.slice(0, 15)
.map((s, i) => {
const tickers = s.tickers.length ? ` [${s.tickers.join(', ')}]` : '';
return `${i + 1}. ${s.title} (${s.source})${tickers}`;
})
.join('\n');
const freqLines = Object.entries(tickerFrequency)
.sort(([, a], [, b]) => b - a)
.slice(0, 10)
.map(([t, n]) => ` ${t}: ${n} ${n === 1 ? 'story' : 'stories'}`)
.join('\n');
const freqSection = freqLines ? `\nTicker mention frequency (ranked):\n${freqLines}\n` : '';
const userMessage = `Today's market news headlines:\n\n${headlines}\n${freqSection}\nAlready identified catalyst tickers: ${existingTickers.join(', ') || 'none'}`;
try {
const PROMPT_FILE = '../../prompts/llm-analyst.md';
const PROMPT_PATH = join(fileURLToPath(import.meta.url), PROMPT_FILE);
const SYSTEM_PROMPT = readFileSync(PROMPT_PATH, 'utf8');
const raw = await this.client.complete(SYSTEM_PROMPT, userMessage);
if (!raw) return null;
const cleaned = raw
.replace(/^```(?:json)?\s*/i, '')
.replace(/```\s*$/i, '')
.trim();
return JSON.parse(cleaned) as LLMAnalysis;
} catch (err) {
this.logger.warn('LLMAnalyst: analysis failed —', (err as Error).message);
return null;
}
}
}
@@ -1,10 +1,5 @@
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>;
}
import { SECTOR, ASSET_TYPE, REGIME } from '../config/constants';
import type { MarketContext, AssetType, InflatedOverrides } from '../types';
export class MarketRegime {
private marketPE: number;
@@ -1,38 +1,7 @@
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[];
}
import type { CategoryBreakdown, FinanceAnalysis, SimpleFINAccount } from '../types';
export class PersonalFinanceAnalyzer {
analyse(accounts: Account[]): FinanceAnalysis {
analyze(accounts: SimpleFINAccount[]): FinanceAnalysis {
const assets = accounts.filter((a) => !['CREDIT', 'LOAN'].includes(a.type));
const liabilities = accounts.filter((a) => ['CREDIT', 'LOAN'].includes(a.type));
@@ -1,40 +1,20 @@
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;
}
import { SIGNAL } from '../config/constants';
import { YahooFinanceClient } from '../clients/YahooFinanceClient';
import type {
PortfolioHolding,
Signal,
ScreenerResult,
AssetResult,
AdviceRow,
PositionCalc,
AdviceOutput,
} from '../types';
export class PortfolioAdvisor {
private client: YahooClient;
private client: YahooFinanceClient;
constructor() {
this.client = new YahooClient();
this.client = new YahooFinanceClient();
}
async advise(
@@ -1,16 +1,10 @@
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';
import { ScoringRules } from '../config/ScoringConfig';
import { MarketRegime } from './MarketRegime';
import { SCORE_MODE } from '../config/constants';
import type { AssetType, MarketContext, RuleSet } from '../types';
interface RuleSet {
gates: Record<string, number>;
weights: Record<string, number>;
thresholds: Record<string, number>;
}
export const RuleMerger = {
getRulesForAsset(
export class RuleMerger {
static getRulesForAsset(
type: AssetType,
metrics: { sector?: string },
marketContext: Partial<MarketContext> = {},
@@ -45,5 +39,5 @@ export const RuleMerger = {
}
return rules;
},
};
}
}
+198
View File
@@ -0,0 +1,198 @@
import { YahooFinanceClient } from '../clients/YahooFinanceClient';
import { BenchmarkProvider } from './BenchmarkProvider';
import { DataMapper } from './DataMapper';
import { chunkArray } from '../utils/Chunker';
import { RuleMerger } from './RuleMerger';
import { Stock } from '../models/Stock';
import { Etf } from '../models/Etf';
import { Bond } from '../models/Bond';
import { StockScorer } from '../scorers/StockScorer';
import { EtfScorer } from '../scorers/EtfScorer';
import { BondScorer } from '../scorers/BondScorer';
import { SIGNAL, SIGNAL_ORDER, SCORE_MODE, ASSET_TYPE } from '../config/constants';
import type {
Logger,
MarketContext,
Signal,
AssetType,
ScoreResult,
ScreenerResult,
ScreenerEngineOptions,
ErrorResult,
MappedData,
StockData,
EtfData,
BondData,
} from '../types';
export class ScreenerEngine {
private static readonly BATCH_SIZE = 5;
private static readonly BATCH_DELAY_MS = 1000;
private client: YahooFinanceClient;
private benchmarkProvider: BenchmarkProvider;
private logger: Logger;
constructor({ logger }: ScreenerEngineOptions = {}) {
this.client = new YahooFinanceClient();
this.benchmarkProvider = new BenchmarkProvider({
logger: logger ?? (console as unknown as Logger),
});
this.logger = logger ?? {
write: (msg: string) => process.stdout.write(msg),
log: (...args: unknown[]) => console.log(...args),
warn: (...args: unknown[]) => console.warn(...args),
};
}
async screenTickers(tickers: string[]): Promise<ScreenerResult> {
return this._screenInternal(tickers, false);
}
async screenWithProgress(tickers: string[]): Promise<ScreenerResult> {
return this._screenInternal(tickers, true);
}
private async _screenInternal(tickers: string[], showProgress: boolean): Promise<ScreenerResult> {
const marketContext = await this._fetchMarketContext(showProgress);
const results = this._initializeResults();
const chunks = chunkArray(tickers, ScreenerEngine.BATCH_SIZE);
let processed = 0;
for (const chunk of chunks) {
await this._processBatch(chunk, marketContext, results);
processed += chunk.length;
this._logProgress(showProgress, processed, tickers.length);
await this._rateLimitDelay();
}
if (showProgress) {
this.logger.write('\n');
}
return { ...results, marketContext };
}
private async _fetchMarketContext(showProgress: boolean): Promise<MarketContext> {
if (showProgress) {
this.logger.write('⏳ Fetching market context...');
}
const context = await this.benchmarkProvider.getMarketContext();
if (showProgress) {
this.logger.write(' done\n');
}
return context;
}
private _initializeResults(): Omit<ScreenerResult, 'marketContext'> {
return { STOCK: [], ETF: [], BOND: [], ERROR: [] };
}
private async _processBatch(
tickers: string[],
marketContext: MarketContext,
results: Omit<ScreenerResult, 'marketContext'>,
): Promise<void> {
const batch = await Promise.all(tickers.map((t) => this._fetch(t)));
batch.forEach((data) => this._process(data, marketContext, results));
}
private _logProgress(showProgress: boolean, processed: number, total: number): void {
if (showProgress) {
this.logger.write(`\r⏳ Screening tickers... ${processed}/${total}`);
}
}
private async _rateLimitDelay(): Promise<void> {
await new Promise<void>((r) => setTimeout(r, ScreenerEngine.BATCH_DELAY_MS));
}
private async _fetch(ticker: string): Promise<MappedData | ErrorResult> {
try {
const summary = await this.client.fetchSummary(ticker);
if (!summary?.price) throw new Error('Empty response from Yahoo');
return DataMapper.mapToStandardFormat(ticker, summary);
} catch (err) {
return { isError: true, ticker: ticker.toUpperCase(), message: (err as Error).message };
}
}
private _process(
data: MappedData | ErrorResult,
marketContext: MarketContext,
results: Omit<ScreenerResult, 'marketContext'>,
): void {
if ('isError' in data && data.isError) {
const e = data as ErrorResult;
results.ERROR.push({ ticker: e.ticker, message: e.message });
return;
}
try {
const asset = this._buildAsset(data as MappedData);
const fundamental = this._score(asset, marketContext, SCORE_MODE.FUNDAMENTAL);
const inflated = this._score(asset, marketContext, SCORE_MODE.INFLATED);
(results[asset.type as AssetType] as unknown[]).push({
asset,
fundamental,
inflated,
signal: this._signal(fundamental.label, inflated.label),
});
} catch (err) {
results.ERROR.push({
ticker: ((data as { ticker?: string }).ticker || 'UNKNOWN').toUpperCase(),
message: (err as Error).message,
});
}
}
// Typed scorer dispatch — instanceof narrows the asset so each scorer receives
// its exact metrics type. No `as never` or unsafe casts required.
private _score(
asset: Stock | Etf | Bond,
marketContext: MarketContext,
mode: string,
): ScoreResult {
const rules = RuleMerger.getRulesForAsset(
asset.type as AssetType,
asset.metrics as { sector?: string },
marketContext,
mode,
);
if (asset instanceof Stock) return StockScorer.score(asset.metrics, rules);
if (asset instanceof Etf) return EtfScorer.score(asset.metrics, rules);
if (asset instanceof Bond) return BondScorer.score(asset.metrics, rules, marketContext);
// TypeScript exhaustive check: all three branches are handled above.
throw new Error('No scorer for unknown asset type');
}
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 as BondData);
case ASSET_TYPE.ETF:
return new Etf(data as EtfData);
default:
return new Stock(data as StockData);
}
}
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;
if (yellow(fundamentalLabel) || yellow(inflatedLabel)) return SIGNAL.NEUTRAL;
return SIGNAL.AVOID;
}
signalOrder(signal: Signal): number {
return SIGNAL_ORDER[signal] ?? 5;
}
getMarketContext(): Promise<MarketContext> {
return this.benchmarkProvider.getMarketContext();
}
}
+10
View File
@@ -0,0 +1,10 @@
// Barrel — re-exports every service so callers import from one path.
export * from './BenchmarkProvider';
export * from './CatalystAnalyst';
export * from './DataMapper';
export * from './LLMAnalyst';
export * from './MarketRegime';
export * from './PersonalFinanceAnalyzer';
export * from './PortfolioAdvisor';
export * from './RuleMerger';
export * from './ScreenerEngine';
+4 -135
View File
@@ -1,135 +1,4 @@
// ── 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;
}
// ── Barrel re-export ──────────────────────────────────────────────────────
// All types now live in server/types/*.model.ts — import from there directly
// for clarity, or from here for convenience (existing imports still work).
export type * from './types/index';
+83
View File
@@ -0,0 +1,83 @@
// ── Asset & screener domain types ─────────────────────────────────────────
import type { Sector } from '../config/constants';
export type Signal =
| '✅ Strong Buy'
| '⚡ Momentum'
| '⚠️ Speculation'
| '🔄 Neutral'
| '❌ Avoid';
export type AssetType = 'STOCK' | 'ETF' | 'BOND';
export type ScoreMode = 'inflated' | 'fundamental';
export interface ScoringRules {
gates: Record<string, number>;
weights: Record<string, number>;
thresholds: Record<string, number>;
}
// ── ScoringConfig structural shapes (server/config/ScoringConfig.ts) ───────
export type GateSet = Record<string, number>;
export type WeightSet = Record<string, number>;
export type ThresholdSet = Record<string, number>;
export interface RuleBlock {
gates: GateSet;
weights: WeightSet;
thresholds: ThresholdSet;
}
export interface StockRules extends RuleBlock {
SECTOR_OVERRIDE: Partial<Record<Sector, Partial<RuleBlock>>>;
}
export interface ScoringRulesShape {
STOCK: StockRules;
ETF: RuleBlock;
BOND: RuleBlock;
}
export interface ScoreAudit {
passedGates: boolean;
breakdown?: Record<string, number>;
riskFlags?: string[] | null;
failures?: string[];
}
export interface ScoreResult {
label: string;
scoreSummary: string;
audit: ScoreAudit;
}
// AssetResult with runtime methods still attached — used at the HTTP boundary
// before class instances are serialised to plain objects for API responses.
export type LiveAssetResult = AssetResult & {
asset: AssetResult['asset'] & {
getDisplayMetrics: () => Record<string, unknown>;
metrics: unknown;
};
};
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: import('./market.model.js').MarketContext;
}
+39
View File
@@ -0,0 +1,39 @@
// ── Market calls domain types ──────────────────────────────────────────────
import type { Signal } from './asset.model';
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>;
}
// Input shape for MarketCallRepository.create()
export interface CreateCallInput {
title: string;
quarter: string;
date?: string;
thesis: string;
tickers: string[];
snapshot?: Record<string, TickerSnapshot>;
}
// Re-screened snapshot returned by GET /api/calls/:id for price comparison.
export interface SnapshotEntry {
price: number | null;
signal: string | null;
inflatedVerdict: string | null;
fundamentalVerdict: string | null;
pe: string | null;
roe: string | null;
fcf: string | null;
}
+101
View File
@@ -0,0 +1,101 @@
// ── Finance & analyst API response types ──────────────────────────────────
import type { Logger } from './logger.model';
export interface AffectedIndustry {
name: string;
reason: string;
}
export interface RelatedTicker {
ticker: string;
reason: string;
bias: 'BULL' | 'BEAR';
horizon: 'SHORT' | 'MEDIUM' | 'LONG';
sensitivity: 1 | 2 | 3 | 4 | 5;
}
export interface LLMAnalysis {
summary: string;
sentiment: 'BULLISH' | 'BEARISH' | 'NEUTRAL';
affectedIndustries: AffectedIndustry[];
relatedTickers: RelatedTicker[];
}
export interface CatalystStory {
title: string;
link: string;
publisher: string;
publishedAt: string;
relatedTickers: string[];
}
export interface CalendarEvent {
ticker: string;
type: 'earnings' | 'dividend' | 'exdividend';
date: string;
label?: string;
detail?: string | null;
isPast?: boolean;
epsEstimate?: number | null;
revEstimate?: number | null;
}
// ── Yahoo Finance client types ─────────────────────────────────────────────
// Raw shapes returned by the yahoo-finance2 search endpoint.
// Used by YahooFinanceClient, CatalystAnalyst, and AnalyzeController.
export interface YahooNewsItem {
title: string;
publisher: string;
link: string;
relatedTickers?: string[];
}
export interface YahooSearchOptions {
newsCount?: number;
quotesCount?: number;
}
// Narrow interface over the yahoo-finance2 instance — only the methods this
// codebase actually calls. Keeps `any` contained to this one declaration.
export interface YahooFinanceLib {
quoteSummary(ticker: string, opts: { modules: string[] }): Promise<any>;
search(query: string, opts?: YahooSearchOptions): Promise<{ news?: YahooNewsItem[] }>;
}
// ── SimpleFIN client types ─────────────────────────────────────────────────
export interface SimpleFINOptions {
logger?: Logger;
onAccessUrlClaimed?: (url: string) => Promise<void> | void;
}
export interface SimpleFINTransaction {
id: string;
date: string;
amount: number;
description: string;
category: string;
}
export interface SimpleFINAccount {
id: string;
name: string;
currency: string;
balance: number;
balanceDate: string;
org: string;
type: string;
transactions: SimpleFINTransaction[];
}
export interface SimpleFINData {
accounts: SimpleFINAccount[];
errors: string[];
}
export interface GetAccountsOptions {
startDate?: number;
endDate?: number;
}
+65
View File
@@ -0,0 +1,65 @@
// ── Single source of truth for all domain types ───────────────────────────
// Import from specific model files for clarity, or from here for convenience.
export type {
Signal,
AssetType,
ScoreMode,
ScoringRules,
ScoreAudit,
ScoreResult,
AssetResult,
LiveAssetResult,
ScreenerResult,
GateSet,
WeightSet,
ThresholdSet,
RuleBlock,
StockRules,
ScoringRulesShape,
} from './asset.model';
export type { RateRegime, VolatilityRegime, Benchmarks, MarketContext } from './market.model';
export type { HoldingType, PortfolioHolding, PortfolioAdvice, AdviceRow } from './portfolio.model';
export type { TickerSnapshot, MarketCall, SnapshotEntry, CreateCallInput } from './calls.model';
export type {
AffectedIndustry,
RelatedTicker,
LLMAnalysis,
CatalystStory,
CalendarEvent,
YahooNewsItem,
YahooSearchOptions,
YahooFinanceLib,
SimpleFINOptions,
SimpleFINTransaction,
SimpleFINAccount,
SimpleFINData,
GetAccountsOptions,
} from './finance.model';
export type { Logger } from './logger.model';
export type {
AssetData,
StockData,
StockMetrics,
EtfData,
EtfMetrics,
BondData,
BondMetrics,
} from './models.model';
export type { StoreData, PortfolioData } from './repositories.model';
export type { NumVal, SanitizedMetrics, SanitizedBondMetrics } from './scorers.model';
export type {
BenchmarkProviderOptions,
InflatedOverrides,
PositionCalc,
AdviceOutput,
ErrorResult,
Headline,
Story,
CatalystResult,
MappedData,
CategoryBreakdown,
FinanceAnalysis,
RuleSet,
ScreenerEngineOptions,
} from './services.model';
+7
View File
@@ -0,0 +1,7 @@
// ── Logger interface ───────────────────────────────────────────────────────
export interface Logger {
write: (msg: string) => void;
log: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
}
+21
View File
@@ -0,0 +1,21 @@
// ── Market context types ───────────────────────────────────────────────────
export type RateRegime = 'HIGH' | 'NORMAL' | 'LOW';
export type VolatilityRegime = 'HIGH' | 'NORMAL' | 'LOW';
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;
}
+121
View File
@@ -0,0 +1,121 @@
// ── Model data input and metrics shapes ────────────────────────────────────
import type { Sector, CapCategory, GrowthCategory } from '../config/constants';
// ── Asset base ─────────────────────────────────────────────────────────────
export interface AssetData {
ticker?: string;
currentPrice?: number;
type?: string;
[key: string]: unknown;
}
// ── Stock ──────────────────────────────────────────────────────────────────
export interface StockData {
ticker?: string;
currentPrice?: number;
assetProfile?: { industry?: string; sector?: string };
peRatio?: number | null;
pegRatio?: number | null;
priceToBook?: number | null;
grossMargin?: 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;
week52Change?: number | null;
week52FromHigh?: number | null;
week52FromLow?: number | null;
marketCap?: number | null;
analystRating?: number | null;
analystTargetPrice?: number | null;
analystUpside?: number | null;
numberOfAnalysts?: number | null;
dcfIntrinsicValue?: number | null;
dcfMarginOfSafety?: number | null;
[key: string]: unknown;
}
export interface StockMetrics {
sector: Sector;
capCategory: CapCategory;
growthCategory: GrowthCategory;
peRatio: number | null;
pegRatio: number | null;
priceToBook: number | null;
grossMargin: 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;
week52Change: number | null;
week52FromHigh: number | null;
week52FromLow: number | null;
marketCap: number | null;
analystRating: number | null;
analystTargetPrice: number | null;
analystUpside: number | null;
numberOfAnalysts: number | null;
dcfIntrinsicValue: number | null;
dcfMarginOfSafety: number | null;
currentPrice: number;
}
// ── ETF ────────────────────────────────────────────────────────────────────
export 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;
}
// ── Bond ───────────────────────────────────────────────────────────────────
export 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;
}
+40
View File
@@ -0,0 +1,40 @@
// ── Portfolio domain types ─────────────────────────────────────────────────
import type { Signal } from './asset.model';
export type HoldingType = 'stock' | 'etf' | 'bond' | 'crypto';
export interface PortfolioHolding {
ticker: string;
shares: number;
costBasis: number;
source: string;
type: HoldingType;
}
export interface PortfolioAdvice {
ticker: string;
action: 'hold' | 'sell' | 'add' | 'watch';
reason: string;
signal: Signal | null;
currentPrice: number | null;
gainLossPct: number | null;
}
// Public return shape of PortfolioAdvisor.advise() — one row per holding.
export 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;
}
+11
View File
@@ -0,0 +1,11 @@
// ── Repository persistence shapes ────────────────────────────────────────
import type { MarketCall, PortfolioHolding } from './index';
export interface StoreData {
calls: (MarketCall & { createdAt: string })[];
}
export interface PortfolioData {
holdings: PortfolioHolding[];
}
+54
View File
@@ -0,0 +1,54 @@
// ── Fastify request body schemas ──────────────────────────────────────────
// Fastify validates incoming request bodies against these JSON Schemas before
// the handler runs. If validation fails it replies 400 automatically.
// One schema per route that has a body; GET routes need no schema.
import type { FastifySchema } from 'fastify';
export const screenSchema: FastifySchema = {
body: {
type: 'object',
required: ['tickers'],
properties: {
tickers: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 50 },
},
},
};
export const analyzeSchema: FastifySchema = {
body: {
type: 'object',
required: ['tickers'],
properties: {
tickers: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 50 },
},
},
};
export const holdingSchema: FastifySchema = {
body: {
type: 'object',
required: ['ticker', 'shares'],
properties: {
ticker: { type: 'string', minLength: 1, maxLength: 10 },
shares: { type: 'number', exclusiveMinimum: 0 },
costBasis: { type: 'number', minimum: 0 },
type: { type: 'string', enum: ['stock', 'etf', 'bond', 'crypto'] },
source: { type: 'string' },
},
},
};
export const callSchema: FastifySchema = {
body: {
type: 'object',
required: ['title', 'quarter', 'thesis', 'tickers'],
properties: {
title: { type: 'string', minLength: 3 },
quarter: { type: 'string', minLength: 2 },
date: { type: 'string' },
thesis: { type: 'string', minLength: 10 },
tickers: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 30 },
},
},
};
+33
View File
@@ -0,0 +1,33 @@
// ── Scorer internal metric shapes ──────────────────────────────────────────
export type NumVal = number | null;
export 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;
// Expert features
week52Change: NumVal; // % total return over last 52 weeks
week52FromHigh: NumVal; // % below 52-week high (negative = down from high)
analystRating: NumVal; // Yahoo scale: 1=Strong Buy … 5=Strong Sell
analystUpside: NumVal; // % price upside to consensus analyst target
dcfMarginOfSafety: NumVal; // % undervaluation vs DCF intrinsic value
}
export interface SanitizedBondMetrics {
ytm: number;
duration: number;
creditRating: string;
creditRatingNumeric: number;
}
+93
View File
@@ -0,0 +1,93 @@
// ── Services configuration and result shapes ──────────────────────────────
import type { Logger } from './logger.model';
// ── BenchmarkProvider ───────────────────────────────────────────────────────
export interface BenchmarkProviderOptions {
logger?: Logger;
}
// ── MarketRegime ──────────────────────────────────────────────────────────
export interface InflatedOverrides {
gates: Record<string, number>;
thresholds: Record<string, number>;
}
// ── PortfolioAdvisor ────────────────────────────────────────────────────────
export interface PositionCalc {
totalCost: string;
marketValue: string | null;
gainLossPct: string | null;
}
export interface AdviceOutput {
action: string;
reason: string;
}
// ── ScreenerEngine ────────────────────────────────────────────────────────
export interface ErrorResult {
isError: true;
ticker: string;
message: string;
}
// ── CatalystAnalyst ────────────────────────────────────────────────────────
export interface Headline {
title: string;
publisher?: string;
}
export interface Story {
title: string;
link: string;
source: string;
tickers: string[];
}
export interface CatalystResult {
tickers: string[];
tickerFrequency: Record<string, number>;
stories: Story[];
}
// ── DataMapper ─────────────────────────────────────────────────────────────
export interface MappedData {
type: string;
ticker: string;
[key: string]: unknown;
}
// ── PersonalFinanceAnalyzer ────────────────────────────────────────────────
export interface CategoryBreakdown {
category: string;
amount: number;
pct: string;
}
export 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: import('./finance.model').SimpleFINAccount[];
}
// ── RuleMerger ─────────────────────────────────────────────────────────────
export interface RuleSet {
gates: Record<string, number>;
weights: Record<string, number>;
thresholds: Record<string, number>;
}
// ── ScreenerEngine ────────────────────────────────────────────────────────
export interface ScreenerEngineOptions {
logger?: Logger;
}
+15
View File
@@ -0,0 +1,15 @@
/**
* Split an array into smaller chunks of specified size.
* @param array The array to split
* @param size The size of each chunk
* @returns Array of chunks
* @example chunkArray([1,2,3,4,5], 2) → [[1,2], [3,4], [5]]
*/
export const chunkArray = <T>(array: T[], size: number): T[][] => {
const chunkCount = Math.ceil(array.length / size);
return Array.from({ length: chunkCount }, (_, index) => {
const start = index * size;
const end = start + size;
return array.slice(start, end);
});
};
@@ -1,4 +1,4 @@
import type { Logger } from '../../types.js';
import type { Logger } from '../types';
/**
* Shared server-side logger utilities.