phase-7: code restructure
This commit is contained in:
committed by
saikiranvella
parent
c160e65bd6
commit
357b0c0f6e
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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, // 0–20% → fairly valued; negative → overvalued
|
||||
},
|
||||
|
||||
SECTOR_OVERRIDE: {
|
||||
|
||||
@@ -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 5–15%
|
||||
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];
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 1–5 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,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 {
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
+2
-33
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -0,0 +1,7 @@
|
||||
// ── Logger interface ───────────────────────────────────────────────────────
|
||||
|
||||
export interface Logger {
|
||||
write: (msg: string) => void;
|
||||
log: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user