phase-9: domain-driven architecture complete
- Restructured server layer with 5 domains: shared, screener, portfolio, calls, finance - Migrated 58 TypeScript files to domain-driven structure - Updated CLAUDE.md with new architecture documentation - Added .gitignore rules for .md files (except CLAUDE.md) - Removed unused CatalystAnalyst import from app.ts - Fixed lint errors: removed unused imports, fixed regex escape, added console suppressions - Verified no sensitive data in git history - Server code compiles cleanly with TypeScript strict mode
This commit is contained in:
committed by
saikiranvella
parent
83116baa3c
commit
96a752ecf7
@@ -0,0 +1,82 @@
|
||||
import { YahooFinanceClient, chunkArray } from '../../domains/shared';
|
||||
import type { CalendarEvent } from '../../domains/shared';
|
||||
|
||||
export class CalendarService {
|
||||
constructor(private readonly yahoo: YahooFinanceClient) {}
|
||||
|
||||
async getEvents(tickers: string[]): Promise<{ events: CalendarEvent[]; tickers: string[] }> {
|
||||
if (tickers.length === 0) return { events: [], tickers: [] };
|
||||
|
||||
const raw: 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) raw[ticker] = cal;
|
||||
}),
|
||||
);
|
||||
await new Promise<void>((r) => setTimeout(r, 500));
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const events = CalendarService.buildEvents(raw, now);
|
||||
CalendarService.sortEvents(events);
|
||||
|
||||
return { events, tickers };
|
||||
}
|
||||
|
||||
private static buildEvents(raw: Record<string, any>, now: number): CalendarEvent[] {
|
||||
const events: CalendarEvent[] = [];
|
||||
|
||||
for (const [ticker, cal] of Object.entries(raw)) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
private static sortEvents(events: CalendarEvent[]): void {
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { MarketCallRepository } from '../../domains/shared';
|
||||
import { CalendarService } from './CalendarService';
|
||||
import { ScreenerEngine } from '../screener';
|
||||
import type { SnapshotEntry } from '../../domains/shared';
|
||||
import { callSchema } from '../../domains/shared/types/schemas';
|
||||
|
||||
export class CallsController {
|
||||
constructor(
|
||||
private readonly repo: MarketCallRepository,
|
||||
private readonly engine: ScreenerEngine,
|
||||
private readonly calendar: CalendarService,
|
||||
) {}
|
||||
|
||||
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.handleCalendar.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 handleCalendar(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 {
|
||||
tickers = [...new Set(this.repo.list().flatMap((c) => c.tickers))];
|
||||
}
|
||||
|
||||
return this.calendar.getEvents(tickers);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Calls domain — market call tracking and calendar
|
||||
export { CallsController } from './calls.controller';
|
||||
export { CalendarService } from './CalendarService';
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { SimpleFINClient, PortfolioRepository, noopLogger } from '../../domains/shared';
|
||||
import { PersonalFinanceAnalyzer, ScreenerEngine } from '../screener';
|
||||
import { PortfolioAdvisor } from '../portfolio/PortfolioAdvisor';
|
||||
import type { PortfolioHolding } from '../../domains/shared';
|
||||
import { holdingSchema } from '../../domains/shared/types/schemas';
|
||||
|
||||
export class FinanceController {
|
||||
constructor(
|
||||
private readonly engine: ScreenerEngine,
|
||||
private readonly repo: PortfolioRepository,
|
||||
private readonly advisor: PortfolioAdvisor,
|
||||
) {}
|
||||
|
||||
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) => h.ticker.toUpperCase());
|
||||
|
||||
const results =
|
||||
screenable.length > 0
|
||||
? await this.engine.screenTickers(screenable)
|
||||
: { STOCK: [], ETF: [], BOND: [], ERROR: [], marketContext: {} as any };
|
||||
|
||||
const advice = await this.advisor.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,2 @@
|
||||
// Finance domain — portfolio metrics and reporting
|
||||
export { FinanceController } from './finance.controller';
|
||||
@@ -0,0 +1,178 @@
|
||||
import { SIGNAL, YahooFinanceClient } from '../../domains/shared';
|
||||
import type {
|
||||
PortfolioHolding,
|
||||
Signal,
|
||||
ScreenerResult,
|
||||
AssetResult,
|
||||
AdviceRow,
|
||||
PositionCalc,
|
||||
AdviceOutput,
|
||||
} from '../../domains/shared';
|
||||
|
||||
export class PortfolioAdvisor {
|
||||
constructor(private readonly client: YahooFinanceClient) {}
|
||||
|
||||
async advise(
|
||||
holdings: PortfolioHolding[],
|
||||
screenedResults: ScreenerResult,
|
||||
): Promise<AdviceRow[]> {
|
||||
const resultMap: Record<string, AssetResult> = {};
|
||||
for (const r of [...screenedResults.STOCK, ...screenedResults.ETF, ...screenedResults.BOND]) {
|
||||
const t = r.asset.ticker;
|
||||
resultMap[t] = r;
|
||||
resultMap[t.replace(/-/g, '.')] = r;
|
||||
resultMap[t.replace(/\./g, '-')] = r;
|
||||
}
|
||||
|
||||
const cryptoPrices = await this.cryptoPrices(holdings.filter((h) => h.type === 'crypto'));
|
||||
|
||||
return holdings.map((holding) => {
|
||||
const type = (holding.type ?? 'stock').toLowerCase();
|
||||
const source = holding.source ?? '—';
|
||||
const price: number | null =
|
||||
type === 'crypto'
|
||||
? (cryptoPrices[holding.ticker.toUpperCase()] ?? null)
|
||||
: (resultMap[holding.ticker.toUpperCase()]?.asset?.currentPrice ?? null);
|
||||
|
||||
return type === 'crypto'
|
||||
? this.row(holding, price, source, '—', '—', '—', this.cryptoAdvice(holding, price))
|
||||
: this.stockRow(holding, price, source, resultMap[holding.ticker.toUpperCase()]);
|
||||
});
|
||||
}
|
||||
|
||||
private stockRow(
|
||||
holding: PortfolioHolding,
|
||||
price: number | null,
|
||||
source: string,
|
||||
result: AssetResult | undefined,
|
||||
): AdviceRow {
|
||||
if (!result) {
|
||||
return this.row(holding, price, source, '—', '—', '—', {
|
||||
action: '⚪ Not screened',
|
||||
reason: 'No screener data available — Yahoo Finance may not support this ticker.',
|
||||
});
|
||||
}
|
||||
return this.row(
|
||||
holding,
|
||||
price,
|
||||
source,
|
||||
result.signal,
|
||||
result.inflated.label,
|
||||
result.fundamental.label,
|
||||
this.advice(result.signal, holding, price),
|
||||
);
|
||||
}
|
||||
|
||||
private row(
|
||||
holding: PortfolioHolding,
|
||||
currentPrice: number | null,
|
||||
source: string,
|
||||
signal: Signal | '—',
|
||||
inflated: string,
|
||||
fundamental: string,
|
||||
{ action, reason }: AdviceOutput,
|
||||
): AdviceRow {
|
||||
const { marketValue, totalCost, gainLossPct } = this.position(holding, currentPrice);
|
||||
return {
|
||||
ticker: holding.ticker,
|
||||
type: holding.type ?? 'stock',
|
||||
source,
|
||||
shares: holding.shares,
|
||||
costBasis: holding.costBasis,
|
||||
currentPrice,
|
||||
marketValue,
|
||||
totalCost,
|
||||
gainLossPct,
|
||||
signal,
|
||||
inflated,
|
||||
fundamental,
|
||||
advice: action,
|
||||
reason,
|
||||
};
|
||||
}
|
||||
|
||||
private position(holding: PortfolioHolding, currentPrice: number | null): PositionCalc {
|
||||
return {
|
||||
totalCost: (holding.costBasis * holding.shares).toFixed(2),
|
||||
marketValue: currentPrice != null ? (currentPrice * holding.shares).toFixed(2) : null,
|
||||
gainLossPct:
|
||||
currentPrice != null && holding.costBasis > 0
|
||||
? (((currentPrice - holding.costBasis) / holding.costBasis) * 100).toFixed(1)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
private cryptoAdvice(holding: PortfolioHolding, price: number | null): AdviceOutput {
|
||||
const { gainLossPct } = this.position(holding, price);
|
||||
const g = parseFloat(gainLossPct ?? 'NaN');
|
||||
if (gainLossPct == null)
|
||||
return {
|
||||
action: '⚪ No price data',
|
||||
reason: 'Crypto — track price and manage risk manually.',
|
||||
};
|
||||
if (g > 100)
|
||||
return {
|
||||
action: '🟠 Consider taking profits',
|
||||
reason: 'Up significantly — no fundamental analysis for crypto.',
|
||||
};
|
||||
if (g < -30)
|
||||
return {
|
||||
action: '🔴 Review position',
|
||||
reason: 'Down significantly — no fundamental analysis for crypto.',
|
||||
};
|
||||
return {
|
||||
action: '🟡 Hold',
|
||||
reason: 'Crypto — no fundamental analysis. Track price and manage risk manually.',
|
||||
};
|
||||
}
|
||||
|
||||
private advice(signal: Signal, holding: PortfolioHolding, price: number | null): AdviceOutput {
|
||||
const { gainLossPct } = this.position(holding, price);
|
||||
const gain = parseFloat(gainLossPct ?? '0');
|
||||
switch (signal) {
|
||||
case SIGNAL.STRONG_BUY:
|
||||
return { action: '🟢 Hold & Add', reason: 'Passes both analyses. Strong conviction.' };
|
||||
case SIGNAL.MOMENTUM:
|
||||
return {
|
||||
action: '🟡 Hold',
|
||||
reason:
|
||||
gain > 30
|
||||
? 'Up on momentum — consider partial profit-taking.'
|
||||
: 'Set a stop-loss — not fundamentally justified.',
|
||||
};
|
||||
case SIGNAL.SPECULATION:
|
||||
return {
|
||||
action: gain > 20 ? '🟠 Reduce Position' : '🟡 Hold (small size)',
|
||||
reason:
|
||||
gain > 20
|
||||
? 'In profit on speculation — take partial profits.'
|
||||
: 'Overvalued fundamentally. Keep position small.',
|
||||
};
|
||||
case SIGNAL.NEUTRAL:
|
||||
return { action: '🟡 Hold', reason: 'No clear edge. Review on any catalyst.' };
|
||||
case SIGNAL.AVOID:
|
||||
return {
|
||||
action: gain > 0 ? '🔴 Sell (Take Profits)' : '🔴 Sell (Cut Loss)',
|
||||
reason:
|
||||
gain > 0
|
||||
? "Fails both analyses — you're in profit, take it."
|
||||
: 'Fails both analyses — stop the loss from growing.',
|
||||
};
|
||||
default:
|
||||
return { action: '⚪ Review', reason: 'Signal unclear.' };
|
||||
}
|
||||
}
|
||||
|
||||
private async cryptoPrices(holdings: PortfolioHolding[]): Promise<Record<string, number | null>> {
|
||||
const prices: Record<string, number | null> = {};
|
||||
for (const h of holdings) {
|
||||
try {
|
||||
const summary = await this.client.fetchSummary(h.ticker);
|
||||
prices[h.ticker.toUpperCase()] = summary?.price?.regularMarketPrice ?? null;
|
||||
} catch {
|
||||
prices[h.ticker.toUpperCase()] = null;
|
||||
}
|
||||
}
|
||||
return prices;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { SimpleFINClient, PortfolioRepository, noopLogger } from '../../domains/shared';
|
||||
import { PersonalFinanceAnalyzer, ScreenerEngine } from '../screener';
|
||||
import { PortfolioAdvisor } from './PortfolioAdvisor';
|
||||
import type { PortfolioHolding } from '../../domains/shared';
|
||||
import { holdingSchema } from '../../domains/shared/types/schemas';
|
||||
|
||||
export class FinanceController {
|
||||
constructor(
|
||||
private readonly engine: ScreenerEngine,
|
||||
private readonly repo: PortfolioRepository,
|
||||
private readonly advisor: PortfolioAdvisor,
|
||||
) {}
|
||||
|
||||
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) => h.ticker.toUpperCase());
|
||||
|
||||
const results =
|
||||
screenable.length > 0
|
||||
? await this.engine.screenTickers(screenable)
|
||||
: { STOCK: [], ETF: [], BOND: [], ERROR: [], marketContext: {} as any };
|
||||
|
||||
const advice = await this.advisor.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,3 @@
|
||||
// Portfolio domain — holdings management and advice
|
||||
export { FinanceController } from './finance.controller';
|
||||
export { PortfolioAdvisor } from './PortfolioAdvisor';
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { CategoryBreakdown, FinanceAnalysis, SimpleFINAccount } from '../../domains/shared';
|
||||
|
||||
export class PersonalFinanceAnalyzer {
|
||||
analyze(accounts: SimpleFINAccount[]): FinanceAnalysis {
|
||||
const assets = accounts.filter((a) => !['CREDIT', 'LOAN'].includes(a.type));
|
||||
const liabilities = accounts.filter((a) => ['CREDIT', 'LOAN'].includes(a.type));
|
||||
|
||||
const totalAssets = assets.reduce((s, a) => s + Math.max(0, a.balance), 0);
|
||||
const totalLiabilities = liabilities.reduce((s, a) => s + Math.abs(Math.min(0, a.balance)), 0);
|
||||
const netWorth = totalAssets - totalLiabilities;
|
||||
|
||||
const cash = accounts.filter((a) => ['CHECKING', 'SAVINGS'].includes(a.type));
|
||||
const investments = accounts.filter((a) => a.type === 'INVESTMENT');
|
||||
const totalCash = cash.reduce((s, a) => s + Math.max(0, a.balance), 0);
|
||||
const totalInvest = investments.reduce((s, a) => s + Math.max(0, a.balance), 0);
|
||||
|
||||
const allTx = accounts.flatMap((a) => a.transactions);
|
||||
const spending = allTx.filter((tx) => tx.amount < 0 && tx.category !== 'Transfer');
|
||||
const income = allTx.filter((tx) => tx.amount > 0 && tx.category === 'Income');
|
||||
|
||||
const totalSpend = spending.reduce((s, tx) => s + Math.abs(tx.amount), 0);
|
||||
const totalIncome = income.reduce((s, tx) => s + tx.amount, 0);
|
||||
|
||||
const byCategory: Record<string, number> = {};
|
||||
for (const tx of spending) {
|
||||
byCategory[tx.category] = (byCategory[tx.category] ?? 0) + Math.abs(tx.amount);
|
||||
}
|
||||
|
||||
const categoryBreakdown: CategoryBreakdown[] = Object.entries(byCategory)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([category, amount]) => ({
|
||||
category,
|
||||
amount,
|
||||
pct: totalSpend > 0 ? ((amount / totalSpend) * 100).toFixed(1) : '0',
|
||||
}));
|
||||
|
||||
return {
|
||||
netWorth,
|
||||
totalAssets,
|
||||
totalLiabilities,
|
||||
totalCash,
|
||||
totalInvestments: totalInvest,
|
||||
cashPct: totalAssets > 0 ? ((totalCash / totalAssets) * 100).toFixed(1) : '0',
|
||||
investPct: totalAssets > 0 ? ((totalInvest / totalAssets) * 100).toFixed(1) : '0',
|
||||
totalIncome,
|
||||
totalSpend,
|
||||
savingsRate:
|
||||
totalIncome > 0 ? (((totalIncome - totalSpend) / totalIncome) * 100).toFixed(1) : null,
|
||||
categoryBreakdown,
|
||||
accounts,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import {
|
||||
YahooFinanceClient,
|
||||
BenchmarkProvider,
|
||||
chunkArray,
|
||||
Stock,
|
||||
Etf,
|
||||
Bond,
|
||||
SIGNAL,
|
||||
SIGNAL_ORDER,
|
||||
SCORE_MODE,
|
||||
ASSET_TYPE,
|
||||
} from '../../domains/shared';
|
||||
import { DataMapper } from './transform/DataMapper';
|
||||
import { RuleMerger } from './transform/RuleMerger';
|
||||
import { StockScorer } from './scorers/StockScorer';
|
||||
import { EtfScorer } from './scorers/EtfScorer';
|
||||
import { BondScorer } from './scorers/BondScorer';
|
||||
import type {
|
||||
Logger,
|
||||
MarketContext,
|
||||
Signal,
|
||||
AssetType,
|
||||
ScoreResult,
|
||||
ScreenerResult,
|
||||
ScreenerEngineOptions,
|
||||
ErrorResult,
|
||||
MappedData,
|
||||
StockData,
|
||||
EtfData,
|
||||
BondData,
|
||||
} from '../../domains/shared';
|
||||
|
||||
export class ScreenerEngine {
|
||||
private static readonly BATCH_SIZE = 5;
|
||||
private static readonly BATCH_DELAY_MS = 1000;
|
||||
|
||||
private logger: Logger;
|
||||
|
||||
constructor(
|
||||
private readonly client: YahooFinanceClient,
|
||||
private readonly benchmarkProvider: BenchmarkProvider,
|
||||
{ logger }: ScreenerEngineOptions = {},
|
||||
) {
|
||||
// eslint-disable-next-line no-console
|
||||
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,39 @@
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import type { LLMAnalyst } from '../../domains/shared';
|
||||
import { CatalystCache, CatalystAnalyst } from '../../domains/shared';
|
||||
import { analyzeSchema } from '../../domains/shared/types/schemas';
|
||||
|
||||
export class AnalyzeController {
|
||||
private readonly catalystAnalyst: CatalystAnalyst;
|
||||
|
||||
constructor(
|
||||
private readonly catalystCache: CatalystCache,
|
||||
private readonly llm: LLMAnalyst,
|
||||
) {
|
||||
// Create a fresh instance for per-ticker story fetching (not cached)
|
||||
this.catalystAnalyst = new CatalystAnalyst();
|
||||
}
|
||||
|
||||
register(app: FastifyInstance): void {
|
||||
app.post(
|
||||
'/api/analyze',
|
||||
{ schema: analyzeSchema, config: { rateLimit: { max: 10, timeWindow: '1 minute' } } },
|
||||
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.catalystAnalyst.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,18 @@
|
||||
// Screener domain — stock/ETF/bond filtering and scoring
|
||||
|
||||
// Controllers
|
||||
export { ScreenerController } from './screener.controller';
|
||||
export { AnalyzeController } from './analyze.controller';
|
||||
|
||||
// Services
|
||||
export { ScreenerEngine } from './ScreenerEngine';
|
||||
export { PersonalFinanceAnalyzer } from './PersonalFinanceAnalyzer';
|
||||
|
||||
// Scorers
|
||||
export { StockScorer } from './scorers/StockScorer';
|
||||
export { EtfScorer } from './scorers/EtfScorer';
|
||||
export { BondScorer } from './scorers/BondScorer';
|
||||
|
||||
// Transform utilities
|
||||
export { DataMapper } from './transform/DataMapper';
|
||||
export { RuleMerger } from './transform/RuleMerger';
|
||||
@@ -0,0 +1,55 @@
|
||||
import type {
|
||||
BondMetrics,
|
||||
MarketContext,
|
||||
ScoreResult,
|
||||
SanitizedBondMetrics,
|
||||
} from '../../../domains/shared';
|
||||
|
||||
export class BondScorer {
|
||||
static score(
|
||||
m: BondMetrics,
|
||||
rules: {
|
||||
gates: Record<string, number>;
|
||||
weights: Record<string, number>;
|
||||
thresholds: Record<string, number>;
|
||||
},
|
||||
context?: MarketContext | null,
|
||||
): ScoreResult {
|
||||
const { gates, weights, thresholds } = rules;
|
||||
const metrics = BondScorer.sanitize(m);
|
||||
const riskFreeRate = (context?.riskFreeRate ?? 4.0) / 100;
|
||||
|
||||
if (metrics.creditRatingNumeric < gates.minCreditRating) {
|
||||
return {
|
||||
label: '🔴 Avoid',
|
||||
scoreSummary: `Gate failed: ${metrics.creditRating} (${metrics.creditRatingNumeric}) < ${gates.minCreditRating}`,
|
||||
audit: { passedGates: false },
|
||||
};
|
||||
}
|
||||
|
||||
const spreadPct = (metrics.ytm - riskFreeRate) * 100;
|
||||
|
||||
const breakdown: Record<string, number> = {
|
||||
spread: spreadPct >= thresholds.minSpread ? weights.yieldSpread : -2,
|
||||
duration: metrics.duration <= thresholds.maxDuration ? weights.duration : -1,
|
||||
};
|
||||
const score = Object.values(breakdown).reduce((a, b) => a + b, 0);
|
||||
|
||||
return {
|
||||
label: score >= 4 ? '🟢 Attractive' : score >= 1 ? '🟡 Neutral' : '🔴 Avoid',
|
||||
scoreSummary: `Score: ${score}`,
|
||||
audit: { passedGates: true, breakdown },
|
||||
};
|
||||
}
|
||||
|
||||
private static sanitize(m: BondMetrics): SanitizedBondMetrics {
|
||||
const pct = (v: unknown): number =>
|
||||
parseFloat(typeof v === 'string' ? v.replace('%', '') : String(v)) / 100 || 0;
|
||||
return {
|
||||
ytm: pct(m.ytm),
|
||||
duration: parseFloat(String(m.duration)) || 0,
|
||||
creditRating: m.creditRating || 'BBB',
|
||||
creditRatingNumeric: m.creditRatingNumeric ?? 7,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { EtfMetrics, ScoreResult } from '../../../domains/shared';
|
||||
|
||||
export class EtfScorer {
|
||||
static score(
|
||||
m: EtfMetrics,
|
||||
rules: {
|
||||
gates: Record<string, number>;
|
||||
weights: Record<string, number>;
|
||||
thresholds: Record<string, number>;
|
||||
},
|
||||
): ScoreResult {
|
||||
const { gates, weights, thresholds } = rules;
|
||||
const metrics = {
|
||||
expenseRatio: parseFloat(String(m.expenseRatio)) || 0,
|
||||
yield: parseFloat(String(m.yield)) || 0,
|
||||
volume: parseFloat(String(m.volume)) || 0,
|
||||
fiveYearReturn: parseFloat(String(m.fiveYearReturn)) || 0,
|
||||
};
|
||||
|
||||
if (metrics.expenseRatio > gates.maxExpenseRatio) {
|
||||
return {
|
||||
label: '🔴 REJECT',
|
||||
scoreSummary: 'Gate failed: High Expense Ratio',
|
||||
audit: { passedGates: false },
|
||||
};
|
||||
}
|
||||
|
||||
const breakdown: Record<string, number> = {
|
||||
cost: metrics.expenseRatio <= thresholds.maxExpense ? weights.lowCost : -3,
|
||||
yield: metrics.yield >= thresholds.minYield ? weights.yield : -1,
|
||||
vol: metrics.volume >= (thresholds.minVolume ?? 1_000_000) ? 0 : -2,
|
||||
fiveYearReturn:
|
||||
thresholds.minFiveYearReturn != null
|
||||
? metrics.fiveYearReturn >= thresholds.minFiveYearReturn
|
||||
? (weights.fiveYearReturn ?? 1)
|
||||
: -1
|
||||
: 0,
|
||||
};
|
||||
|
||||
const score = Object.values(breakdown).reduce((a, b) => a + b, 0);
|
||||
|
||||
return {
|
||||
label: score >= 3 ? '🟢 Efficient' : score >= 0 ? '🟡 Neutral' : '🔴 Expensive/Low Yield',
|
||||
scoreSummary: `Score: ${score}`,
|
||||
audit: { passedGates: true, breakdown },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import type { NumVal, SanitizedMetrics, ScoreResult, StockMetrics } from '../../../domains/shared';
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { ScreenerEngine } from './ScreenerEngine';
|
||||
import { CatalystCache } from '../../domains/shared';
|
||||
import type { LiveAssetResult } from '../../domains/shared';
|
||||
import { screenSchema } from '../../domains/shared/types/schemas';
|
||||
|
||||
export class ScreenerController {
|
||||
constructor(
|
||||
private readonly engine: ScreenerEngine,
|
||||
private readonly catalystCache: CatalystCache,
|
||||
) {}
|
||||
|
||||
register(app: FastifyInstance): void {
|
||||
app.post(
|
||||
'/api/screen',
|
||||
{ schema: screenSchema, config: { rateLimit: { max: 10, timeWindow: '1 minute' } } },
|
||||
this.screen.bind(this),
|
||||
);
|
||||
app.get(
|
||||
'/api/screen/catalysts',
|
||||
{ config: { rateLimit: { max: 10, timeWindow: '1 minute' } } },
|
||||
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 { tickers, stories } = await this.catalystCache.get();
|
||||
return { tickers, stories };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import type { MappedData } from '../../../domains/shared';
|
||||
|
||||
// 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,69 @@
|
||||
import { ASSET_TYPE, REGIME, SECTOR } from '../../shared';
|
||||
import type { MarketContext, AssetType, InflatedOverrides } from '../../shared';
|
||||
|
||||
export class MarketRegime {
|
||||
private marketPE: number;
|
||||
private techPE: number;
|
||||
private reitYield: number;
|
||||
private igSpread: number;
|
||||
private rateRegime: string;
|
||||
private volatilityRegime: string;
|
||||
|
||||
constructor(marketContext: Partial<MarketContext>) {
|
||||
const b = marketContext?.benchmarks ?? ({} as MarketContext['benchmarks']);
|
||||
this.marketPE = b.marketPE ?? 22;
|
||||
this.techPE = b.techPE ?? 30;
|
||||
this.reitYield = b.reitYield ?? 3.5;
|
||||
this.igSpread = b.igSpread ?? 1.0;
|
||||
this.rateRegime = marketContext?.rateRegime ?? REGIME.NORMAL;
|
||||
this.volatilityRegime = marketContext?.volatilityRegime ?? REGIME.NORMAL;
|
||||
}
|
||||
|
||||
getInflatedOverrides(type: AssetType, sector?: string): InflatedOverrides {
|
||||
if (type === ASSET_TYPE.STOCK) return this.stock(sector);
|
||||
if (type === ASSET_TYPE.ETF) return this.etf();
|
||||
if (type === ASSET_TYPE.BOND) return this.bond();
|
||||
return { gates: {}, thresholds: {} };
|
||||
}
|
||||
|
||||
private stock(sector?: string): InflatedOverrides {
|
||||
if (sector === SECTOR.REIT) {
|
||||
return {
|
||||
gates: {},
|
||||
thresholds: {
|
||||
minYield: +(this.reitYield * (this.rateRegime === REGIME.HIGH ? 0.95 : 0.85)).toFixed(2),
|
||||
maxPFFO: 20,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (sector === SECTOR.TECHNOLOGY) {
|
||||
return {
|
||||
gates: {
|
||||
maxPERatio: Math.round(this.techPE * 1.3),
|
||||
maxPegGate: +(this.techPE / 15).toFixed(1),
|
||||
},
|
||||
thresholds: {},
|
||||
};
|
||||
}
|
||||
const peMultiplier = this.rateRegime === REGIME.HIGH ? 1.2 : 1.5;
|
||||
return {
|
||||
gates: {
|
||||
maxPERatio: Math.round(this.marketPE * peMultiplier),
|
||||
maxPegGate: +(this.marketPE / 12).toFixed(1),
|
||||
},
|
||||
thresholds: {},
|
||||
};
|
||||
}
|
||||
|
||||
private etf(): InflatedOverrides {
|
||||
return { gates: { maxExpenseRatio: 0.75 }, thresholds: { minYield: 0.5 } };
|
||||
}
|
||||
|
||||
private bond(): InflatedOverrides {
|
||||
const spreadMultiplier = this.rateRegime === REGIME.HIGH ? 0.9 : 0.8;
|
||||
return {
|
||||
gates: {},
|
||||
thresholds: { minSpread: +(this.igSpread * spreadMultiplier).toFixed(2) },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ScoringRules } from '../../../domains/shared/scoring/ScoringConfig';
|
||||
import { MarketRegime } from '../../../domains/shared/scoring/MarketRegime';
|
||||
import { SCORE_MODE } from '../../../domains/shared';
|
||||
import type { AssetType, MarketContext, RuleSet } from '../../../domains/shared';
|
||||
|
||||
export class RuleMerger {
|
||||
static getRulesForAsset(
|
||||
type: AssetType,
|
||||
metrics: { sector?: string },
|
||||
marketContext: Partial<MarketContext> = {},
|
||||
mode: string = SCORE_MODE.FUNDAMENTAL,
|
||||
): RuleSet {
|
||||
const base = ScoringRules[type as keyof typeof ScoringRules];
|
||||
if (!base) throw new Error(`No rules configured for asset type: ${type}`);
|
||||
|
||||
// Deep clone to avoid mutating the source config
|
||||
const rules: RuleSet & { SECTOR_OVERRIDE?: unknown } = JSON.parse(JSON.stringify(base));
|
||||
|
||||
if (type === 'STOCK' && metrics.sector) {
|
||||
const stockBase = ScoringRules.STOCK;
|
||||
const override =
|
||||
stockBase.SECTOR_OVERRIDE?.[
|
||||
metrics.sector.toUpperCase() as keyof typeof stockBase.SECTOR_OVERRIDE
|
||||
];
|
||||
if (override) {
|
||||
rules.gates = { ...rules.gates, ...override.gates };
|
||||
rules.weights = { ...rules.weights, ...override.weights };
|
||||
rules.thresholds = { ...rules.thresholds, ...override.thresholds };
|
||||
}
|
||||
}
|
||||
delete rules.SECTOR_OVERRIDE;
|
||||
|
||||
if (mode === SCORE_MODE.INFLATED) {
|
||||
const { gates, thresholds } = new MarketRegime(
|
||||
marketContext as MarketContext,
|
||||
).getInflatedOverrides(type, metrics.sector);
|
||||
rules.gates = { ...rules.gates, ...gates };
|
||||
rules.thresholds = { ...rules.thresholds, ...thresholds };
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import fs from 'fs';
|
||||
import https from 'https';
|
||||
import http from 'http';
|
||||
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;
|
||||
|
||||
constructor({ logger, onAccessUrlClaimed }: SimpleFINOptions = {}) {
|
||||
this.accessUrl = null;
|
||||
// eslint-disable-next-line no-console
|
||||
this.logger = logger ?? {
|
||||
write: (msg) => process.stdout.write(msg),
|
||||
log: (...args) => console.log(...args),
|
||||
warn: (...args) => console.warn(...args),
|
||||
};
|
||||
this.onAccessUrlClaimed = onAccessUrlClaimed ?? null;
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
if (process.env.SIMPLEFIN_ACCESS_URL) {
|
||||
this.accessUrl = process.env.SIMPLEFIN_ACCESS_URL.replace(/\/$/, '');
|
||||
return;
|
||||
}
|
||||
if (process.env.SIMPLEFIN_SETUP_TOKEN) {
|
||||
this.accessUrl = await this.claimAccessUrl(process.env.SIMPLEFIN_SETUP_TOKEN);
|
||||
if (this.onAccessUrlClaimed) await this.onAccessUrlClaimed(this.accessUrl);
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
'SimpleFIN not configured.\nAdd to .env:\n SIMPLEFIN_SETUP_TOKEN=<your setup token from https://beta-bridge.simplefin.org>\nThe Access URL will be saved automatically on first run.',
|
||||
);
|
||||
}
|
||||
|
||||
async getAccounts(options: GetAccountsOptions = {}): Promise<SimpleFINData> {
|
||||
if (!this.accessUrl) await this.init();
|
||||
|
||||
const startDate = options.startDate ?? this.daysAgo(30);
|
||||
const endDate = options.endDate ?? Math.floor(Date.now() / 1000);
|
||||
|
||||
const parsed = new URL(this.accessUrl!);
|
||||
const auth = parsed.username
|
||||
? 'Basic ' + Buffer.from(`${parsed.username}:${parsed.password}`).toString('base64')
|
||||
: null;
|
||||
parsed.username = '';
|
||||
parsed.password = '';
|
||||
const cleanBase = parsed.toString().replace(/\/$/, '');
|
||||
|
||||
const url = `${cleanBase}/accounts?version=2&start-date=${startDate}&end-date=${endDate}`;
|
||||
const response = await fetch(url, { headers: auth ? { Authorization: auth } : {} });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`SimpleFIN error ${response.status}: ${await response.text()}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { accounts?: unknown[]; errors?: string[] };
|
||||
if (data.errors?.length) {
|
||||
data.errors.forEach((e) => this.logger.warn(` ⚠ SimpleFIN: ${e}`));
|
||||
}
|
||||
|
||||
return this.normalise(data as { accounts: unknown[]; errors: string[] });
|
||||
}
|
||||
|
||||
private async claimAccessUrl(setupToken: string): Promise<string> {
|
||||
const claimUrl = Buffer.from(setupToken.trim(), 'base64').toString('utf8').trim();
|
||||
this.logger.write(`\n🔑 Claiming SimpleFIN access URL...\n → ${claimUrl}\n`);
|
||||
const accessUrl = await this.post(claimUrl);
|
||||
if (!accessUrl || !accessUrl.startsWith('http')) {
|
||||
throw new Error(
|
||||
`Unexpected response from SimpleFIN: "${accessUrl}"\nSetup tokens are one-time use — if already claimed, generate a new one at https://beta-bridge.simplefin.org`,
|
||||
);
|
||||
}
|
||||
this.logger.write('✅ Access URL received\n');
|
||||
return accessUrl.trim();
|
||||
}
|
||||
|
||||
private post(url: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const lib = parsed.protocol === 'https:' ? https : http;
|
||||
const options = {
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Length': '0', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
};
|
||||
const req = lib.request(options, (res) => {
|
||||
let body = '';
|
||||
res.on('data', (chunk: string) => {
|
||||
body += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
if ((res.statusCode ?? 0) >= 200 && (res.statusCode ?? 0) < 300) resolve(body.trim());
|
||||
else reject(new Error(`HTTP ${res.statusCode}: ${body.trim()}`));
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
private normalise(data: { accounts: unknown[]; errors: string[] }): SimpleFINData {
|
||||
const accounts = (data.accounts ?? []).map((acc: any) => ({
|
||||
id: acc.id,
|
||||
name: acc.name,
|
||||
currency: acc.currency ?? 'USD',
|
||||
balance: parseFloat(acc.balance) ?? 0,
|
||||
balanceDate: new Date(acc['balance-date'] * 1000).toISOString().slice(0, 10),
|
||||
org: acc.org?.name ?? 'Unknown',
|
||||
type: this.classifyAccount(acc.name),
|
||||
transactions: (acc.transactions ?? []).map((tx: any) => ({
|
||||
id: tx.id,
|
||||
date: new Date(tx.posted * 1000).toISOString().slice(0, 10),
|
||||
amount: parseFloat(tx.amount) ?? 0,
|
||||
description: tx.description ?? '',
|
||||
category: this.categorise(tx.description ?? ''),
|
||||
})),
|
||||
}));
|
||||
return { accounts, errors: data.errors ?? [] };
|
||||
}
|
||||
|
||||
private classifyAccount(name: string): string {
|
||||
const n = name.toLowerCase();
|
||||
if (n.includes('checking') || n.includes('current')) return 'CHECKING';
|
||||
if (n.includes('saving')) return 'SAVINGS';
|
||||
if (n.includes('credit') || n.includes('card')) return 'CREDIT';
|
||||
if (n.includes('invest') || n.includes('brokerage') || n.includes('401k') || n.includes('ira'))
|
||||
return 'INVESTMENT';
|
||||
if (n.includes('loan') || n.includes('mortgage')) return 'LOAN';
|
||||
return 'OTHER';
|
||||
}
|
||||
|
||||
private categorise(description: string): string {
|
||||
const d = description.toLowerCase();
|
||||
if (d.match(/amazon|walmart|target|costco|grocery|whole foods|trader joe/)) return 'Shopping';
|
||||
if (d.match(/uber eats|doordash|grubhub|postmates|instacart/)) return 'Delivery';
|
||||
if (d.match(/netflix|spotify|apple|disney|hulu|youtube/)) return 'Subscriptions';
|
||||
if (d.match(/restaurant|cafe|coffee|starbucks|chipotle|mcdonald/)) return 'Dining';
|
||||
if (d.match(/shell|chevron|bp|exxon|fuel|gas station/)) return 'Gas';
|
||||
if (d.match(/uber|lyft|transit|mta|bart|metro/)) return 'Transport';
|
||||
if (d.match(/rent|mortgage|hoa|property/)) return 'Housing';
|
||||
if (d.match(/electric|water|internet|phone|at&t|verizon|comcast/)) return 'Utilities';
|
||||
if (d.match(/payroll|salary|direct deposit/)) return 'Income';
|
||||
if (d.match(/transfer|zelle|venmo|paypal/)) return 'Transfer';
|
||||
return 'Other';
|
||||
}
|
||||
|
||||
private daysAgo(n: number): number {
|
||||
return Math.floor((Date.now() - n * 24 * 60 * 60 * 1000) / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
export function saveAccessUrlToEnv(accessUrl: string): void {
|
||||
try {
|
||||
const existing = fs.existsSync('.env') ? fs.readFileSync('.env', 'utf8') : '';
|
||||
if (!existing.includes('SIMPLEFIN_ACCESS_URL')) {
|
||||
fs.appendFileSync('.env', `\nSIMPLEFIN_ACCESS_URL=${accessUrl}\n`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('✅ Access URL saved to .env — you can remove SIMPLEFIN_SETUP_TOKEN\n');
|
||||
}
|
||||
} catch {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`\nSave this to .env:\nSIMPLEFIN_ACCESS_URL=${accessUrl}\n`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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'],
|
||||
});
|
||||
}
|
||||
|
||||
/** Normalise ticker before hitting Yahoo: BRK.B → BRK-B */
|
||||
private static normalise(ticker: string): string {
|
||||
return ticker.toUpperCase().replace(/\./g, '-');
|
||||
}
|
||||
|
||||
async fetchSummary(ticker: string, retries = 3, backoff = 1000): Promise<any> {
|
||||
const normalised = YahooFinanceClient.normalise(ticker);
|
||||
for (let attempt = 0; attempt < retries; attempt++) {
|
||||
try {
|
||||
return await this.lib.quoteSummary(
|
||||
normalised,
|
||||
{ modules: YAHOO_MODULES },
|
||||
{ validateResult: false },
|
||||
);
|
||||
} 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(
|
||||
YahooFinanceClient.normalise(ticker),
|
||||
{ modules: ['calendarEvents'] },
|
||||
{ validateResult: false },
|
||||
);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { Signal, AssetType, RateRegime } from '../types';
|
||||
|
||||
export const SIGNAL = {
|
||||
STRONG_BUY: '✅ Strong Buy' as Signal,
|
||||
MOMENTUM: '⚡ Momentum' as Signal,
|
||||
SPECULATION: '⚠️ Speculation' as Signal,
|
||||
NEUTRAL: '🔄 Neutral' as Signal,
|
||||
AVOID: '❌ Avoid' as Signal,
|
||||
};
|
||||
|
||||
export const ASSET_TYPE = {
|
||||
STOCK: 'STOCK' as AssetType,
|
||||
ETF: 'ETF' as AssetType,
|
||||
BOND: 'BOND' as AssetType,
|
||||
CRYPTO: 'crypto',
|
||||
};
|
||||
|
||||
// ── 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',
|
||||
REIT: 'REIT',
|
||||
FINANCIAL: 'FINANCIAL',
|
||||
ENERGY: 'ENERGY',
|
||||
HEALTHCARE: 'HEALTHCARE',
|
||||
COMMUNICATION: 'COMMUNICATION',
|
||||
CONSUMER_STAPLES: 'CONSUMER_STAPLES',
|
||||
CONSUMER_DISCRETIONARY: 'CONSUMER_DISCRETIONARY',
|
||||
GENERAL: 'GENERAL',
|
||||
} as const;
|
||||
|
||||
export type Sector = (typeof SECTOR)[keyof typeof SECTOR];
|
||||
|
||||
export const SCORE_MODE = {
|
||||
FUNDAMENTAL: 'FUNDAMENTAL',
|
||||
INFLATED: 'INFLATED',
|
||||
} as const;
|
||||
|
||||
export const REGIME = {
|
||||
LOW: 'LOW' as RateRegime,
|
||||
NORMAL: 'NORMAL' as RateRegime,
|
||||
HIGH: 'HIGH' as RateRegime,
|
||||
};
|
||||
|
||||
export const YAHOO_MODULES: string[] = [
|
||||
'assetProfile',
|
||||
'financialData',
|
||||
'defaultKeyStatistics',
|
||||
'price',
|
||||
'summaryDetail',
|
||||
];
|
||||
|
||||
export const SIGNAL_ORDER: Record<string, number> = {
|
||||
[SIGNAL.STRONG_BUY]: 0,
|
||||
[SIGNAL.MOMENTUM]: 1,
|
||||
[SIGNAL.NEUTRAL]: 2,
|
||||
[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,196 @@
|
||||
/**
|
||||
* DatabaseConnection — High-level database abstraction.
|
||||
*
|
||||
* Wraps better-sqlite3 with:
|
||||
* - QueryBuilder for type-safe, injection-proof queries
|
||||
* - QueryAudit for logging and compliance
|
||||
* - Statement caching for performance
|
||||
* - Transaction support
|
||||
*
|
||||
* Usage:
|
||||
* const db = new DatabaseConnection(betterSqlite3Db, options);
|
||||
* const qb = new QueryBuilder('holdings').select(['ticker', 'shares']).where('type = ?', ['stock']);
|
||||
* const rows = db.all(qb);
|
||||
* const row = db.get(qb);
|
||||
* db.run(qb);
|
||||
*/
|
||||
|
||||
import type BetterSqlite3 from 'better-sqlite3';
|
||||
import type { DatabaseOptions } from '../types/index';
|
||||
import { AuditAction } from '../types/index';
|
||||
import { QueryBuilder } from '../utils/QueryBuilder';
|
||||
import { QueryAudit } from './QueryAudit';
|
||||
|
||||
/**
|
||||
* DatabaseConnection — Safe, auditable, performant SQLite wrapper.
|
||||
*/
|
||||
export class DatabaseConnection {
|
||||
private db: BetterSqlite3.Database;
|
||||
private audit: QueryAudit;
|
||||
private logSlowQueries: number;
|
||||
private statementCache = new Map<string, BetterSqlite3.Statement>();
|
||||
|
||||
constructor(db: BetterSqlite3.Database, options: DatabaseOptions = {}) {
|
||||
this.db = db;
|
||||
this.audit = options.audit ?? new QueryAudit();
|
||||
this.logSlowQueries = options.logSlowQueries ?? 100; // 100ms default
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a SELECT query and return all rows.
|
||||
* Logs the query to the audit trail.
|
||||
*/
|
||||
all<T = Record<string, unknown>>(qb: QueryBuilder): T[] {
|
||||
const sql = qb.sql;
|
||||
const params = qb.queryParams;
|
||||
const startMs = performance.now();
|
||||
|
||||
try {
|
||||
const stmt = this.getOrCacheStatement(sql);
|
||||
const rows = stmt.all(...params) as T[];
|
||||
|
||||
const durationMs = performance.now() - startMs;
|
||||
this.audit.log(sql, params, AuditAction.READ, durationMs, rows.length);
|
||||
this.logIfSlow(sql, durationMs);
|
||||
|
||||
return rows;
|
||||
} catch (err) {
|
||||
const durationMs = performance.now() - startMs;
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
this.audit.log(sql, params, AuditAction.READ, durationMs, undefined, errorMsg);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a SELECT query and return the first row only.
|
||||
* Returns null if no rows match.
|
||||
* Logs the query to the audit trail.
|
||||
*/
|
||||
get<T = Record<string, unknown>>(qb: QueryBuilder): T | null {
|
||||
const sql = qb.sql;
|
||||
const params = qb.queryParams;
|
||||
const startMs = performance.now();
|
||||
|
||||
try {
|
||||
const stmt = this.getOrCacheStatement(sql);
|
||||
const row = stmt.get(...params) as T | undefined;
|
||||
|
||||
const durationMs = performance.now() - startMs;
|
||||
this.audit.log(sql, params, AuditAction.READ, durationMs, row ? 1 : 0);
|
||||
this.logIfSlow(sql, durationMs);
|
||||
|
||||
return row ?? null;
|
||||
} catch (err) {
|
||||
const durationMs = performance.now() - startMs;
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
this.audit.log(sql, params, AuditAction.READ, durationMs, undefined, errorMsg);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an INSERT, UPDATE, or DELETE query.
|
||||
* Returns the number of rows affected.
|
||||
* Logs the query to the audit trail.
|
||||
*/
|
||||
run(qb: QueryBuilder): number {
|
||||
const sql = qb.sql;
|
||||
const params = qb.queryParams;
|
||||
const startMs = performance.now();
|
||||
|
||||
// Determine audit action from SQL
|
||||
const sqlUpper = sql.toUpperCase().trim();
|
||||
const action = sqlUpper.startsWith('DELETE')
|
||||
? AuditAction.DELETE
|
||||
: sqlUpper.startsWith('INSERT')
|
||||
? AuditAction.WRITE
|
||||
: AuditAction.WRITE;
|
||||
|
||||
try {
|
||||
const stmt = this.getOrCacheStatement(sql);
|
||||
const result = stmt.run(...params);
|
||||
|
||||
const durationMs = performance.now() - startMs;
|
||||
this.audit.log(sql, params, action, durationMs, result.changes);
|
||||
this.logIfSlow(sql, durationMs);
|
||||
|
||||
return result.changes;
|
||||
} catch (err) {
|
||||
const durationMs = performance.now() - startMs;
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
this.audit.log(sql, params, action, durationMs, 0, errorMsg);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a transaction — multiple queries as an atomic unit.
|
||||
* All queries must succeed, or all are rolled back.
|
||||
*
|
||||
* Usage:
|
||||
* db.transaction(() => {
|
||||
* db.run(qb1);
|
||||
* db.run(qb2);
|
||||
* });
|
||||
*/
|
||||
transaction<T>(fn: () => T): T {
|
||||
const txn = this.db.transaction(fn);
|
||||
return txn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw better-sqlite3 Db instance (for advanced use only).
|
||||
* Prefer the DatabaseConnection methods.
|
||||
*/
|
||||
raw(): BetterSqlite3.Database {
|
||||
return this.db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the audit trail instance.
|
||||
*/
|
||||
getAudit(): QueryAudit {
|
||||
return this.audit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the statement cache (for testing or extreme memory pressure).
|
||||
*/
|
||||
clearStatementCache(): void {
|
||||
this.statementCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the audit trail instance.
|
||||
* Call db.printAudit() to see the most recent 100 queries.
|
||||
*/
|
||||
printAudit(): void {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(this.audit.report());
|
||||
}
|
||||
|
||||
// ── Private helpers ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get or create a cached prepared statement.
|
||||
* Reduces compilation overhead for frequently-run queries.
|
||||
*/
|
||||
private getOrCacheStatement(sql: string): BetterSqlite3.Statement {
|
||||
let stmt = this.statementCache.get(sql);
|
||||
if (!stmt) {
|
||||
stmt = this.db.prepare(sql);
|
||||
this.statementCache.set(sql, stmt);
|
||||
}
|
||||
return stmt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log slow queries to console.
|
||||
*/
|
||||
private logIfSlow(sql: string, durationMs: number): void {
|
||||
if (durationMs > this.logSlowQueries) {
|
||||
console.warn(`[SLOW QUERY] ${durationMs.toFixed(2)}ms\n ${sql}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Database initialization and migration.
|
||||
*
|
||||
* Handles:
|
||||
* - Creating/opening SQLite database
|
||||
* - Running DDL schema setup
|
||||
* - Migrating legacy JSON files (one-time)
|
||||
*/
|
||||
|
||||
import BetterSqlite3 from 'better-sqlite3';
|
||||
import { existsSync, readFileSync, renameSync } from 'fs';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { DDL } from './queries.constant';
|
||||
import { QueryBuilder } from '../utils/QueryBuilder';
|
||||
|
||||
export type Db = BetterSqlite3.Database;
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface LegacyHolding {
|
||||
ticker: string;
|
||||
shares: number;
|
||||
costBasis: number;
|
||||
type: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
interface LegacyCall {
|
||||
id?: string;
|
||||
title: string;
|
||||
quarter: string;
|
||||
date: string;
|
||||
thesis: string;
|
||||
tickers: string[];
|
||||
snapshot: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// ── Main Export ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Initialize and open the SQLite database.
|
||||
*
|
||||
* Steps:
|
||||
* 1. Create/open database file
|
||||
* 2. Enable WAL mode (concurrent read safety)
|
||||
* 3. Enable foreign keys
|
||||
* 4. Run DDL (create tables if missing)
|
||||
* 5. Migrate legacy JSON files (one-time)
|
||||
*
|
||||
* @param path Path to database file (default: ./market-screener.db)
|
||||
* @returns Opened database instance (wrap in DatabaseConnection for safe access)
|
||||
*/
|
||||
export function createDb(path = './market-screener.db'): Db {
|
||||
const db = new BetterSqlite3(path);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
db.exec(DDL);
|
||||
migrateJson(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
// ── Migration Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Migrate legacy JSON files to SQLite (one-time, non-fatal).
|
||||
* Called automatically during database initialization.
|
||||
*/
|
||||
function migrateJson(db: Db): void {
|
||||
migratePortfolio(db);
|
||||
migrateCalls(db);
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate portfolio.json → holdings table.
|
||||
* If portfolio.json exists, import all holdings and rename to portfolio.json.migrated.
|
||||
* If import fails, leave portfolio.json in place (non-fatal).
|
||||
*/
|
||||
function migratePortfolio(db: Db): void {
|
||||
const src = './portfolio.json';
|
||||
if (!existsSync(src)) return;
|
||||
|
||||
try {
|
||||
const { holdings } = JSON.parse(readFileSync(src, 'utf8')) as {
|
||||
holdings: LegacyHolding[];
|
||||
};
|
||||
|
||||
const insertAll = db.transaction((rows: LegacyHolding[]) => {
|
||||
for (const h of rows) {
|
||||
const qb = new QueryBuilder('MIGRATION_QUERIES.HOLDINGS_INSERT_OR_IGNORE', [
|
||||
h.ticker.toUpperCase(),
|
||||
h.shares,
|
||||
h.costBasis ?? 0,
|
||||
h.type ?? 'stock',
|
||||
h.source ?? 'Manual',
|
||||
]);
|
||||
db.prepare(qb.sql).run(...qb.queryParams);
|
||||
}
|
||||
});
|
||||
|
||||
insertAll(holdings);
|
||||
renameSync(src, `${src}.migrated`);
|
||||
} catch {
|
||||
// Non-fatal: leave portfolio.json in place if migration fails
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate market-calls.json → market_calls table.
|
||||
* If market-calls.json exists, import all calls and rename to market-calls.json.migrated.
|
||||
* If import fails, leave market-calls.json in place (non-fatal).
|
||||
*/
|
||||
function migrateCalls(db: Db): void {
|
||||
const src = './market-calls.json';
|
||||
if (!existsSync(src)) return;
|
||||
|
||||
try {
|
||||
const { calls } = JSON.parse(readFileSync(src, 'utf8')) as {
|
||||
calls: LegacyCall[];
|
||||
};
|
||||
|
||||
const insertAll = db.transaction((rows: LegacyCall[]) => {
|
||||
for (const c of rows) {
|
||||
const qb = new QueryBuilder('MIGRATION_QUERIES.MARKET_CALLS_INSERT_OR_IGNORE', [
|
||||
c.id ?? randomUUID(),
|
||||
c.title,
|
||||
c.quarter,
|
||||
c.date,
|
||||
c.thesis,
|
||||
JSON.stringify(c.tickers ?? []),
|
||||
JSON.stringify(c.snapshot ?? {}),
|
||||
c.createdAt,
|
||||
]);
|
||||
db.prepare(qb.sql).run(...qb.queryParams);
|
||||
}
|
||||
});
|
||||
|
||||
insertAll(calls);
|
||||
renameSync(src, `${src}.migrated`);
|
||||
} catch {
|
||||
// Non-fatal: leave market-calls.json in place if migration fails
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Query audit logging — tracks all database mutations.
|
||||
*
|
||||
* Usage:
|
||||
* const audit = new QueryAudit();
|
||||
* audit.log('SELECT * FROM holdings', [], AuditAction.READ, 1.5);
|
||||
* audit.log('UPDATE holdings SET shares = ? WHERE ticker = ?', [100, 'AAPL'], AuditAction.WRITE, 0.8, 1);
|
||||
*
|
||||
* Provides:
|
||||
* - Audit trail of all queries executed
|
||||
* - Timing information (for performance monitoring)
|
||||
* - Clear distinction between READ/WRITE operations
|
||||
* - Optional persistent storage for compliance
|
||||
*/
|
||||
|
||||
import type { AuditAction, AuditEntry } from '../types/index';
|
||||
|
||||
/**
|
||||
* QueryAudit — in-memory audit trail with optional callbacks.
|
||||
*/
|
||||
export class QueryAudit {
|
||||
private entries: AuditEntry[] = [];
|
||||
private onLog?: (entry: AuditEntry) => void | Promise<void>;
|
||||
|
||||
constructor(onLog?: (entry: AuditEntry) => void | Promise<void>) {
|
||||
this.onLog = onLog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a query execution.
|
||||
* @param sql The SQL string (with ? placeholders intact)
|
||||
* @param params The parameter array (safe to log; no raw values in SQL)
|
||||
* @param action The operation type (READ, WRITE, DELETE)
|
||||
* @param durationMs Execution time in milliseconds
|
||||
* @param rowsAffected Number of rows affected (for INSERT/UPDATE/DELETE)
|
||||
* @param error If execution failed, the error message
|
||||
*/
|
||||
log(
|
||||
sql: string,
|
||||
params: unknown[],
|
||||
action: AuditAction,
|
||||
durationMs: number,
|
||||
rowsAffected?: number,
|
||||
error?: string,
|
||||
): void {
|
||||
const entry: AuditEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
action,
|
||||
sql,
|
||||
params,
|
||||
durationMs,
|
||||
rowsAffected,
|
||||
error,
|
||||
};
|
||||
|
||||
this.entries.push(entry);
|
||||
|
||||
// Call the optional callback (could write to file, logger, or remote service)
|
||||
if (this.onLog) {
|
||||
const result = this.onLog(entry);
|
||||
if (result instanceof Promise) {
|
||||
result.catch((err) => {
|
||||
console.error('QueryAudit callback failed:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all audit entries.
|
||||
*/
|
||||
all(): AuditEntry[] {
|
||||
return [...this.entries];
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter audit entries by action type.
|
||||
*/
|
||||
byAction(action: AuditAction): AuditEntry[] {
|
||||
return this.entries.filter((e) => e.action === action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most recent N entries.
|
||||
*/
|
||||
recent(count: number = 100): AuditEntry[] {
|
||||
return this.entries.slice(-count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the audit trail.
|
||||
* (Typically not needed unless for testing or cleanup.)
|
||||
*/
|
||||
clear(): void {
|
||||
this.entries = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a human-readable audit report.
|
||||
*/
|
||||
report(limitEntries: number = 100): string {
|
||||
const recent = this.recent(limitEntries);
|
||||
let report = `\n=== Query Audit Report ===\n`;
|
||||
report += `Total entries: ${this.entries.length}\n`;
|
||||
report += `Showing last ${recent.length} entries:\n\n`;
|
||||
|
||||
for (const entry of recent) {
|
||||
report += `[${entry.timestamp}] ${entry.action}`;
|
||||
if (entry.error) {
|
||||
report += ` ❌ (${entry.error})`;
|
||||
} else {
|
||||
report += ` ✓ (${entry.durationMs}ms)`;
|
||||
if (entry.rowsAffected !== undefined) {
|
||||
report += ` — ${entry.rowsAffected} rows`;
|
||||
}
|
||||
}
|
||||
report += `\n SQL: ${entry.sql}\n`;
|
||||
if (entry.params.length > 0) {
|
||||
report += ` Params: [${entry.params.map((p) => JSON.stringify(p)).join(', ')}]\n`;
|
||||
}
|
||||
report += '\n';
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Database layer — barrel export (ONLY re-exports, no logic).
|
||||
*
|
||||
* This file is the SINGLE public API for all database functionality.
|
||||
* All imports should come from here, not from individual files.
|
||||
*
|
||||
* USAGE:
|
||||
* import { createDb, DatabaseConnection, QueryAudit } from './db/index.js';
|
||||
* import type { AuditEntry } from './db/index.js';
|
||||
*
|
||||
* FILE ORGANIZATION:
|
||||
* - DatabaseInitializer.ts: createDb() function + migrations (pure functions)
|
||||
* - QueryAudit.ts: class QueryAudit (logging service)
|
||||
* - DatabaseConnection.ts: class DatabaseConnection (data access service)
|
||||
* - index.ts: THIS FILE (barrel re-exports only)
|
||||
*
|
||||
* SECURITY:
|
||||
* - All queries use parameterized statements (QueryBuilder + DatabaseConnection)
|
||||
* - No SQL injection possible via table/column/parameter names
|
||||
* - Audit trail tracks all mutations for compliance
|
||||
*/
|
||||
|
||||
// Initialization
|
||||
export { createDb, type Db } from './DatabaseInitializer';
|
||||
|
||||
// Data access
|
||||
export { DatabaseConnection } from './DatabaseConnection';
|
||||
export { QueryAudit } from './QueryAudit';
|
||||
|
||||
// Types
|
||||
export { AuditAction } from '../types/database.model';
|
||||
export type { AuditEntry, DatabaseOptions } from '../types/database.model';
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* SQL Query Constants
|
||||
*
|
||||
* All SQL queries used in the application.
|
||||
* Repositories reference these by name (e.g., MARKET_CALLS_QUERIES.SELECT_ALL).
|
||||
* QueryBuilder looks them up and binds parameters.
|
||||
*
|
||||
* All queries use parameterized statements (?) for security.
|
||||
* User input NEVER goes into the SQL string.
|
||||
*/
|
||||
|
||||
// ── Holdings Table Queries ───────────────────────────────────────────────────
|
||||
|
||||
export const HOLDINGS_QUERIES = {
|
||||
// Check if any holdings exist
|
||||
EXISTS: 'SELECT COUNT(*) AS n FROM holdings',
|
||||
|
||||
// Get all holdings, sorted by ticker
|
||||
SELECT_ALL: 'SELECT ticker, shares, cost_basis, type, source FROM holdings ORDER BY ticker ASC',
|
||||
|
||||
// Insert or update a holding (UPSERT)
|
||||
UPSERT: `
|
||||
INSERT INTO holdings (ticker, shares, cost_basis, type, source)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(ticker) DO UPDATE SET
|
||||
shares = excluded.shares,
|
||||
cost_basis = excluded.cost_basis,
|
||||
type = excluded.type,
|
||||
source = excluded.source
|
||||
`,
|
||||
|
||||
// Delete a holding by ticker
|
||||
DELETE_BY_TICKER: 'DELETE FROM holdings WHERE ticker = ?',
|
||||
};
|
||||
|
||||
// ── Market Calls Table Queries ───────────────────────────────────────────────
|
||||
|
||||
export const MARKET_CALLS_QUERIES = {
|
||||
// Get all market calls, newest first
|
||||
SELECT_ALL: `
|
||||
SELECT id, title, quarter, date, thesis, tickers, snapshot, created_at
|
||||
FROM market_calls
|
||||
ORDER BY created_at DESC
|
||||
`,
|
||||
|
||||
// Get a single market call by ID
|
||||
SELECT_BY_ID: `
|
||||
SELECT id, title, quarter, date, thesis, tickers, snapshot, created_at
|
||||
FROM market_calls
|
||||
WHERE id = ?
|
||||
`,
|
||||
|
||||
// Insert a new market call
|
||||
INSERT: `
|
||||
INSERT INTO market_calls (id, title, quarter, date, thesis, tickers, snapshot, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
|
||||
// Delete a market call by ID
|
||||
DELETE_BY_ID: 'DELETE FROM market_calls WHERE id = ?',
|
||||
};
|
||||
|
||||
// ── Migration Queries (for DatabaseInitializer) ──────────────────────────────
|
||||
|
||||
export const MIGRATION_QUERIES = {
|
||||
// Insert holdings during migration
|
||||
HOLDINGS_INSERT_OR_IGNORE: `
|
||||
INSERT OR IGNORE INTO holdings (ticker, shares, cost_basis, type, source)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`,
|
||||
|
||||
// Insert market calls during migration
|
||||
MARKET_CALLS_INSERT_OR_IGNORE: `
|
||||
INSERT OR IGNORE INTO market_calls (id, title, quarter, date, thesis, tickers, snapshot, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
};
|
||||
|
||||
// ── Schema Definition (DDL) ──────────────────────────────────────────────────
|
||||
|
||||
export const DDL = `
|
||||
CREATE TABLE IF NOT EXISTS holdings (
|
||||
ticker TEXT PRIMARY KEY,
|
||||
shares REAL NOT NULL,
|
||||
cost_basis REAL NOT NULL DEFAULT 0,
|
||||
type TEXT NOT NULL DEFAULT 'stock',
|
||||
source TEXT NOT NULL DEFAULT 'Manual'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS market_calls (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
quarter TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
thesis TEXT NOT NULL,
|
||||
tickers TEXT NOT NULL, -- JSON array
|
||||
snapshot TEXT NOT NULL, -- JSON object
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
`;
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { AssetType } from '../types';
|
||||
import type { AssetData } from '../types/models.model';
|
||||
|
||||
export class Asset {
|
||||
ticker: string;
|
||||
currentPrice: number;
|
||||
type: AssetType;
|
||||
|
||||
constructor(data: AssetData) {
|
||||
this.ticker = (data.ticker || 'UNKNOWN').toUpperCase();
|
||||
this.currentPrice = (data.currentPrice as number) || 0;
|
||||
this.type = (data.type || 'STOCK').toUpperCase() as AssetType;
|
||||
}
|
||||
|
||||
formatCurrency(val: number | null | undefined): string {
|
||||
return val ? `$${val.toFixed(2)}` : 'N/A';
|
||||
}
|
||||
|
||||
formatLargeNumber(num: number | null | undefined): string {
|
||||
if (!num) return 'N/A';
|
||||
if (num >= 1e12) return `${(num / 1e12).toFixed(2)}T`;
|
||||
if (num >= 1e9) return `${(num / 1e9).toFixed(2)}B`;
|
||||
if (num >= 1e6) return `${(num / 1e6).toFixed(2)}M`;
|
||||
return num.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { CREDIT_RATING_SCALE } from '../scoring/ScoringConfig';
|
||||
import { Asset } from './Asset';
|
||||
import type { BondData, BondMetrics } from '../types/index';
|
||||
|
||||
export class Bond extends Asset {
|
||||
metrics: BondMetrics;
|
||||
|
||||
constructor(data: BondData) {
|
||||
super(data);
|
||||
|
||||
const creditRating = data.creditRating || 'BBB';
|
||||
const creditRatingNumeric = CREDIT_RATING_SCALE[creditRating] ?? 7;
|
||||
|
||||
this.metrics = {
|
||||
ytm: parseFloat(String(data.yieldToMaturity)) || 0,
|
||||
duration: parseFloat(String(data.duration)) || 0,
|
||||
creditRating,
|
||||
creditRatingNumeric,
|
||||
};
|
||||
}
|
||||
|
||||
getDisplayMetrics(): Record<string, string> {
|
||||
return {
|
||||
Ticker: this.ticker,
|
||||
Type: 'BOND',
|
||||
Price: this.formatCurrency(this.currentPrice),
|
||||
'YTM%': `${this.metrics.ytm.toFixed(2)}%`,
|
||||
Duration: this.metrics.duration.toFixed(1),
|
||||
Rating: `${this.metrics.creditRating} (${this.metrics.creditRatingNumeric})`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Asset } from './Asset';
|
||||
import type { EtfData, EtfMetrics } from '../types/models.model';
|
||||
|
||||
export class Etf extends Asset {
|
||||
metrics: EtfMetrics;
|
||||
|
||||
constructor(data: EtfData) {
|
||||
super(data);
|
||||
this.metrics = {
|
||||
expenseRatio: parseFloat(String(data.expenseRatio)) || 0,
|
||||
totalAssets: parseFloat(String(data.totalAssets)) || 0,
|
||||
yield: parseFloat(String(data.yield)) || 0,
|
||||
volume: parseFloat(String(data.volume)) || 0,
|
||||
fiveYearReturn: parseFloat(String(data.fiveYearReturn)) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
getDisplayMetrics(): Record<string, string> {
|
||||
return {
|
||||
Ticker: this.ticker,
|
||||
Type: 'ETF',
|
||||
Price: this.formatCurrency(this.currentPrice),
|
||||
'Exp Ratio%': `${this.metrics.expenseRatio.toFixed(2)}%`,
|
||||
'Yield%': `${this.metrics.yield.toFixed(2)}%`,
|
||||
AUM: this.formatLargeNumber(this.metrics.totalAssets),
|
||||
'5Y Return%': `${this.metrics.fiveYearReturn.toFixed(1)}%`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
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;
|
||||
metrics: StockMetrics;
|
||||
|
||||
constructor(data: StockData) {
|
||||
super(data);
|
||||
this.sector = this.mapToStandardSector(data);
|
||||
|
||||
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,
|
||||
revenueGrowth: data.revenueGrowth ?? null,
|
||||
earningsGrowth: data.earningsGrowth ?? null,
|
||||
debtToEquity: data.debtToEquity ?? null,
|
||||
quickRatio: data.quickRatio ?? null,
|
||||
fcfYield: data.fcfYield ?? null,
|
||||
pFFO: data.pFFO ?? null,
|
||||
dividendYield: data.dividendYield ?? null,
|
||||
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();
|
||||
const sector = (profile.sector || '').toLowerCase();
|
||||
const combined = `${industry} ${sector}`;
|
||||
|
||||
if (
|
||||
combined.includes('technology') ||
|
||||
combined.includes('electronic') ||
|
||||
combined.includes('semiconductor') ||
|
||||
combined.includes('software')
|
||||
)
|
||||
return 'TECHNOLOGY';
|
||||
if (combined.includes('real estate') || combined.includes('reit')) return 'REIT';
|
||||
if (
|
||||
combined.includes('financial') ||
|
||||
combined.includes('bank') ||
|
||||
combined.includes('insurance') ||
|
||||
combined.includes('asset management')
|
||||
)
|
||||
return 'FINANCIAL';
|
||||
if (
|
||||
combined.includes('energy') ||
|
||||
combined.includes('oil') ||
|
||||
combined.includes('gas') ||
|
||||
combined.includes('petroleum')
|
||||
)
|
||||
return 'ENERGY';
|
||||
if (
|
||||
combined.includes('health') ||
|
||||
combined.includes('biotech') ||
|
||||
combined.includes('pharmaceutical') ||
|
||||
combined.includes('medical')
|
||||
)
|
||||
return 'HEALTHCARE';
|
||||
if (
|
||||
combined.includes('communication') ||
|
||||
combined.includes('media') ||
|
||||
combined.includes('entertainment') ||
|
||||
combined.includes('telecom')
|
||||
)
|
||||
return 'COMMUNICATION';
|
||||
if (
|
||||
combined.includes('consumer defensive') ||
|
||||
combined.includes('consumer staples') ||
|
||||
combined.includes('household') ||
|
||||
combined.includes('beverage') ||
|
||||
combined.includes('food')
|
||||
)
|
||||
return 'CONSUMER_STAPLES';
|
||||
if (
|
||||
combined.includes('consumer cyclical') ||
|
||||
combined.includes('consumer discretionary') ||
|
||||
combined.includes('retail') ||
|
||||
combined.includes('apparel') ||
|
||||
combined.includes('auto')
|
||||
)
|
||||
return 'CONSUMER_DISCRETIONARY';
|
||||
|
||||
return 'GENERAL';
|
||||
}
|
||||
|
||||
getDisplayMetrics(): 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 =
|
||||
m.week52High != null && m.week52High > 0 && m.week52Low != null && m.currentPrice > 0
|
||||
? (((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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Shared domain — re-exports all shared infrastructure
|
||||
// Import from here, not from individual subdirectories
|
||||
|
||||
// Entities
|
||||
export { Asset } from './entities/Asset';
|
||||
export { Stock } from './entities/Stock';
|
||||
export { Etf } from './entities/Etf';
|
||||
export { Bond } from './entities/Bond';
|
||||
|
||||
// Adapters (external API clients)
|
||||
export { YahooFinanceClient } from './adapters/YahooFinanceClient';
|
||||
export { AnthropicClient } from './adapters/AnthropicClient';
|
||||
export { SimpleFINClient } from './adapters/SimpleFINClient';
|
||||
|
||||
// Services
|
||||
export { BenchmarkProvider } from './services/BenchmarkProvider';
|
||||
export { CatalystAnalyst } from './services/CatalystAnalyst';
|
||||
export { CatalystCache } from './services/CatalystCache';
|
||||
export { LLMAnalyst } from './services/LLMAnalyst';
|
||||
|
||||
// Scoring
|
||||
export { CREDIT_RATING_SCALE } from './scoring/ScoringConfig';
|
||||
export { MarketRegime } from './scoring/MarketRegime';
|
||||
|
||||
// Persistence (repositories)
|
||||
export { MarketCallRepository } from './persistence/MarketCallRepository';
|
||||
export { PortfolioRepository } from './persistence/PortfolioRepository';
|
||||
export { DatabaseConnection, QueryAudit, createDb } from './db/index';
|
||||
|
||||
// Config & Constants
|
||||
export {
|
||||
SIGNAL,
|
||||
SIGNAL_ORDER,
|
||||
SCORE_MODE,
|
||||
ASSET_TYPE,
|
||||
REGIME,
|
||||
CAP_CATEGORY,
|
||||
GROWTH_CATEGORY,
|
||||
SECTOR,
|
||||
} from './config/constants';
|
||||
|
||||
// Types — re-export everything from types barrel
|
||||
export type * from './types/index';
|
||||
|
||||
// Utils
|
||||
export { noopLogger } from './utils/logger';
|
||||
export { chunkArray } from './utils/Chunker';
|
||||
@@ -0,0 +1,96 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { DatabaseConnection } from '../db/index';
|
||||
import { QueryBuilder } from '../utils/QueryBuilder';
|
||||
import { sanitizeString, sanitizeDate } from '../utils/sanitizer';
|
||||
import type { MarketCall, CreateCallInput, MarketCallRow } from '../types';
|
||||
|
||||
export class MarketCallRepository {
|
||||
constructor(private readonly db: DatabaseConnection) {}
|
||||
|
||||
/**
|
||||
* Get all market calls, newest first.
|
||||
*/
|
||||
list(): (MarketCall & { createdAt: string })[] {
|
||||
const qb = new QueryBuilder('MARKET_CALLS_QUERIES.SELECT_ALL');
|
||||
const rows = this.db.all<MarketCallRow>(qb);
|
||||
return rows.map(MarketCallRepository.toCall);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single market call by ID.
|
||||
*/
|
||||
get(id: string): (MarketCall & { createdAt: string }) | null {
|
||||
const qb = new QueryBuilder('MARKET_CALLS_QUERIES.SELECT_BY_ID', [id]);
|
||||
const row = this.db.get<MarketCallRow>(qb);
|
||||
return row ? MarketCallRepository.toCall(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new market call with snapshot of current prices.
|
||||
*/
|
||||
create({
|
||||
title,
|
||||
quarter,
|
||||
date,
|
||||
thesis,
|
||||
tickers,
|
||||
snapshot,
|
||||
}: CreateCallInput): MarketCall & { createdAt: string } {
|
||||
// Sanitize inputs
|
||||
const sanitizedTitle = sanitizeString(title, 'title', 255);
|
||||
const sanitizedQuarter = sanitizeString(quarter, 'quarter', 10);
|
||||
const sanitizedThesis = sanitizeString(thesis, 'thesis', 2000);
|
||||
const sanitizedDate = date ? sanitizeDate(date, 'date') : new Date().toISOString().slice(0, 10);
|
||||
|
||||
const call = {
|
||||
id: randomUUID(),
|
||||
title: sanitizedTitle,
|
||||
quarter: sanitizedQuarter,
|
||||
date: sanitizedDate,
|
||||
thesis: sanitizedThesis,
|
||||
tickers: tickers ?? [],
|
||||
snapshot: snapshot ?? {},
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const qb = new QueryBuilder('MARKET_CALLS_QUERIES.INSERT', [
|
||||
call.id,
|
||||
call.title,
|
||||
call.quarter,
|
||||
call.date,
|
||||
call.thesis,
|
||||
JSON.stringify(call.tickers),
|
||||
JSON.stringify(call.snapshot),
|
||||
call.createdAt,
|
||||
]);
|
||||
|
||||
this.db.run(qb);
|
||||
return call as MarketCall & { createdAt: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a market call by ID.
|
||||
* Returns true if the call existed and was deleted, false otherwise.
|
||||
*/
|
||||
delete(id: string): boolean {
|
||||
const qb = new QueryBuilder('MARKET_CALLS_QUERIES.DELETE_BY_ID', [id]);
|
||||
const changes = this.db.run(qb);
|
||||
return changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert database row to domain object.
|
||||
*/
|
||||
private static toCall(row: MarketCallRow): MarketCall & { createdAt: string } {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
quarter: row.quarter,
|
||||
date: row.date,
|
||||
thesis: row.thesis,
|
||||
tickers: JSON.parse(row.tickers),
|
||||
snapshot: JSON.parse(row.snapshot),
|
||||
createdAt: row.created_at,
|
||||
} as MarketCall & { createdAt: string };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { DatabaseConnection } from '../db/index';
|
||||
import { QueryBuilder } from '../utils/QueryBuilder';
|
||||
import { sanitizeTicker, sanitizeNumber } from '../utils/sanitizer';
|
||||
import type { PortfolioData, PortfolioHolding, HoldingRow } from '../types';
|
||||
|
||||
export class PortfolioRepository {
|
||||
constructor(private readonly db: DatabaseConnection) {}
|
||||
|
||||
/**
|
||||
* Check if portfolio has any holdings.
|
||||
*/
|
||||
exists(): boolean {
|
||||
const qb = new QueryBuilder('HOLDINGS_QUERIES.EXISTS');
|
||||
const row = this.db.get<{ n: number }>(qb);
|
||||
return row ? row.n > 0 : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all holdings.
|
||||
*/
|
||||
read(): PortfolioData {
|
||||
const qb = new QueryBuilder('HOLDINGS_QUERIES.SELECT_ALL');
|
||||
const rows = this.db.all<HoldingRow>(qb);
|
||||
return { holdings: rows.map(PortfolioRepository.toHolding) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert or update a holding (UPSERT).
|
||||
*/
|
||||
upsert(entry: PortfolioHolding): PortfolioHolding {
|
||||
// Sanitize inputs
|
||||
const ticker = sanitizeTicker(entry.ticker);
|
||||
const shares = sanitizeNumber(entry.shares, 'shares', { min: 0 });
|
||||
const costBasis = sanitizeNumber(entry.costBasis ?? 0, 'costBasis', { min: 0 });
|
||||
const type = entry.type ?? 'stock';
|
||||
const source = entry.source ?? 'Manual';
|
||||
|
||||
const qb = new QueryBuilder('HOLDINGS_QUERIES.UPSERT', [
|
||||
ticker,
|
||||
shares,
|
||||
costBasis,
|
||||
type,
|
||||
source,
|
||||
]);
|
||||
|
||||
this.db.run(qb);
|
||||
return { ...entry, ticker };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a holding by ticker.
|
||||
*/
|
||||
remove(ticker: string): boolean {
|
||||
// Sanitize input
|
||||
const sanitizedTicker = sanitizeTicker(ticker);
|
||||
const qb = new QueryBuilder('HOLDINGS_QUERIES.DELETE_BY_TICKER', [sanitizedTicker]);
|
||||
|
||||
const changes = this.db.run(qb);
|
||||
return changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert database row to domain object.
|
||||
*/
|
||||
private static toHolding(row: HoldingRow): PortfolioHolding {
|
||||
return {
|
||||
ticker: row.ticker,
|
||||
shares: row.shares,
|
||||
costBasis: row.cost_basis,
|
||||
type: row.type as PortfolioHolding['type'],
|
||||
source: row.source,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { SECTOR, ASSET_TYPE, REGIME } from '../config/constants';
|
||||
import type { MarketContext, AssetType, InflatedOverrides } from '../types';
|
||||
|
||||
export class MarketRegime {
|
||||
private marketPE: number;
|
||||
private techPE: number;
|
||||
private reitYield: number;
|
||||
private igSpread: number;
|
||||
private rateRegime: string;
|
||||
private volatilityRegime: string;
|
||||
|
||||
constructor(marketContext: Partial<MarketContext>) {
|
||||
const b = marketContext?.benchmarks ?? ({} as MarketContext['benchmarks']);
|
||||
this.marketPE = b.marketPE ?? 22;
|
||||
this.techPE = b.techPE ?? 30;
|
||||
this.reitYield = b.reitYield ?? 3.5;
|
||||
this.igSpread = b.igSpread ?? 1.0;
|
||||
this.rateRegime = marketContext?.rateRegime ?? REGIME.NORMAL;
|
||||
this.volatilityRegime = marketContext?.volatilityRegime ?? REGIME.NORMAL;
|
||||
}
|
||||
|
||||
getInflatedOverrides(type: AssetType, sector?: string): InflatedOverrides {
|
||||
if (type === ASSET_TYPE.STOCK) return this.stock(sector);
|
||||
if (type === ASSET_TYPE.ETF) return this.etf();
|
||||
if (type === ASSET_TYPE.BOND) return this.bond();
|
||||
return { gates: {}, thresholds: {} };
|
||||
}
|
||||
|
||||
private stock(sector?: string): InflatedOverrides {
|
||||
if (sector === SECTOR.REIT) {
|
||||
return {
|
||||
gates: {},
|
||||
thresholds: {
|
||||
minYield: +(this.reitYield * (this.rateRegime === REGIME.HIGH ? 0.95 : 0.85)).toFixed(2),
|
||||
maxPFFO: 20,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (sector === SECTOR.TECHNOLOGY) {
|
||||
return {
|
||||
gates: {
|
||||
maxPERatio: Math.round(this.techPE * 1.3),
|
||||
maxPegGate: +(this.techPE / 15).toFixed(1),
|
||||
},
|
||||
thresholds: {},
|
||||
};
|
||||
}
|
||||
const peMultiplier = this.rateRegime === REGIME.HIGH ? 1.2 : 1.5;
|
||||
return {
|
||||
gates: {
|
||||
maxPERatio: Math.round(this.marketPE * peMultiplier),
|
||||
maxPegGate: +(this.marketPE / 12).toFixed(1),
|
||||
},
|
||||
thresholds: {},
|
||||
};
|
||||
}
|
||||
|
||||
private etf(): InflatedOverrides {
|
||||
return { gates: { maxExpenseRatio: 0.75 }, thresholds: { minYield: 0.5 } };
|
||||
}
|
||||
|
||||
private bond(): InflatedOverrides {
|
||||
const spreadMultiplier = this.rateRegime === REGIME.HIGH ? 0.9 : 0.8;
|
||||
return {
|
||||
gates: {},
|
||||
thresholds: { minSpread: +(this.igSpread * spreadMultiplier).toFixed(2) },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
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.
|
||||
// Investment grade = BBB (7) and above.
|
||||
export const CREDIT_RATING_SCALE: Record<string, number> = {
|
||||
AAA: 10,
|
||||
AA: 9,
|
||||
A: 8,
|
||||
BBB: 7,
|
||||
BB: 6,
|
||||
B: 5,
|
||||
CCC: 4,
|
||||
CC: 3,
|
||||
C: 2,
|
||||
D: 1,
|
||||
};
|
||||
|
||||
// ── Scoring rule shape ────────────────────────────────────────────────────
|
||||
// Structural shapes (GateSet/WeightSet/ThresholdSet/RuleBlock/StockRules/
|
||||
// ScoringRulesShape) live in server/types/asset.model.ts.
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Fundamental baseline — Graham / value-investing style.
|
||||
// MarketRegime.ts overrides the valuation gates for INFLATED-mode analysis.
|
||||
// Sector overrides are structural — they apply in both modes.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export const ScoringRules: ScoringRulesShape = {
|
||||
STOCK: {
|
||||
gates: {
|
||||
maxDebtToEquity: 1.5, // Graham ceiling; most distress starts above 2x
|
||||
minQuickRatio: 0.8, // below 0.8 signals real liquidity stress in non-tech
|
||||
maxPERatio: 15, // Graham's actual rule: never pay more than 15x trailing earnings
|
||||
maxPegGate: 1.0, // PEG > 1.0 means you're paying full price for growth (Lynch standard)
|
||||
},
|
||||
weights: {
|
||||
margin: 2, // net profit margin
|
||||
opMargin: 2, // operating margin (pricing power)
|
||||
roe: 3, // return on equity — Buffett's primary quality metric
|
||||
peg: 2, // valuation relative to growth
|
||||
revenue: 2, // revenue growth
|
||||
fcf: 3, // 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
|
||||
marginMed: 8, // 8% is the realistic mid-tier for industrials/retail
|
||||
opMarginHigh: 20,
|
||||
opMarginMed: 10,
|
||||
roeHigh: 15, // sustainable 15% ROE is Buffett-quality; 20% is rare/fleeting
|
||||
roeMed: 10, // 10% is the cost-of-equity floor for most businesses
|
||||
pegHigh: 0.75, // PEG < 0.75 is genuinely cheap relative to growth
|
||||
pegMed: 1.0,
|
||||
revHigh: 10, // 10% organic revenue growth is strong for mature cos
|
||||
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: {
|
||||
TECHNOLOGY: {
|
||||
gates: { maxDebtToEquity: 2.0, minQuickRatio: 0.8, maxPERatio: 35, maxPegGate: 1.5 },
|
||||
weights: { margin: 1, opMargin: 3, roe: 3, peg: 3, revenue: 4, fcf: 3 },
|
||||
thresholds: { marginHigh: 25, opMarginHigh: 25, roeHigh: 20, pegHigh: 1.0, revHigh: 20 },
|
||||
},
|
||||
|
||||
REIT: {
|
||||
gates: { maxDebtToEquity: 6.0, minQuickRatio: 0.1, maxPERatio: 9999, maxPegGate: 9999 },
|
||||
weights: { margin: 0, opMargin: 0, roe: 0, peg: 0, revenue: 0, fcf: 0, yield: 5, pFFO: 3 },
|
||||
thresholds: { minYield: 4.5, maxPFFO: 20 },
|
||||
},
|
||||
|
||||
FINANCIAL: {
|
||||
gates: {
|
||||
maxDebtToEquity: 9999,
|
||||
minQuickRatio: 0.1,
|
||||
maxPERatio: 9999,
|
||||
maxPegGate: 9999,
|
||||
maxPriceToBook: 1.5,
|
||||
},
|
||||
weights: { margin: 0, opMargin: 0, peg: 0, roe: 5, revenue: 1, fcf: 1, priceToBook: 3 },
|
||||
thresholds: { roeHigh: 15, roeMed: 12, revHigh: 10, revMed: 5 },
|
||||
},
|
||||
|
||||
ENERGY: {
|
||||
gates: { maxDebtToEquity: 1.5, minQuickRatio: 0.6, maxPERatio: 15, maxPegGate: 1.5 },
|
||||
weights: { margin: 0, opMargin: 3, roe: 2, peg: 1, revenue: 2, fcf: 4, yield: 3 },
|
||||
thresholds: {
|
||||
opMarginHigh: 20,
|
||||
opMarginMed: 10,
|
||||
roeHigh: 15,
|
||||
roeMed: 8,
|
||||
fcfHigh: 8,
|
||||
fcfMed: 4,
|
||||
},
|
||||
},
|
||||
|
||||
HEALTHCARE: {
|
||||
gates: { maxDebtToEquity: 1.5, minQuickRatio: 1.0, maxPERatio: 25, maxPegGate: 1.5 },
|
||||
weights: { margin: 1, opMargin: 2, roe: 2, peg: 2, revenue: 4, fcf: 3 },
|
||||
thresholds: {
|
||||
marginHigh: 20,
|
||||
marginMed: 10,
|
||||
roeHigh: 20,
|
||||
roeMed: 12,
|
||||
revHigh: 15,
|
||||
revMed: 8,
|
||||
fcfHigh: 8,
|
||||
fcfMed: 3,
|
||||
},
|
||||
},
|
||||
|
||||
COMMUNICATION: {
|
||||
gates: { maxDebtToEquity: 2.0, minQuickRatio: 0.8, maxPERatio: 25, maxPegGate: 1.5 },
|
||||
weights: { margin: 2, opMargin: 3, roe: 2, peg: 2, revenue: 3, fcf: 4 },
|
||||
thresholds: {
|
||||
marginHigh: 25,
|
||||
marginMed: 12,
|
||||
opMarginHigh: 30,
|
||||
opMarginMed: 15,
|
||||
roeHigh: 20,
|
||||
roeMed: 12,
|
||||
pegHigh: 1.0,
|
||||
pegMed: 1.5,
|
||||
revHigh: 15,
|
||||
revMed: 5,
|
||||
fcfHigh: 8,
|
||||
fcfMed: 3,
|
||||
},
|
||||
},
|
||||
|
||||
CONSUMER_STAPLES: {
|
||||
gates: { maxDebtToEquity: 1.5, minQuickRatio: 0.5, maxPERatio: 22, maxPegGate: 2.0 },
|
||||
weights: { margin: 3, opMargin: 3, roe: 3, peg: 1, revenue: 1, fcf: 3 },
|
||||
thresholds: {
|
||||
marginHigh: 12,
|
||||
marginMed: 7,
|
||||
opMarginHigh: 18,
|
||||
opMarginMed: 10,
|
||||
roeHigh: 20,
|
||||
roeMed: 12,
|
||||
pegHigh: 1.5,
|
||||
pegMed: 2.0,
|
||||
revHigh: 5,
|
||||
revMed: 2,
|
||||
fcfHigh: 5,
|
||||
fcfMed: 2,
|
||||
},
|
||||
},
|
||||
|
||||
CONSUMER_DISCRETIONARY: {
|
||||
gates: { maxDebtToEquity: 2.0, minQuickRatio: 0.5, maxPERatio: 25, maxPegGate: 1.5 },
|
||||
weights: { margin: 2, opMargin: 2, roe: 2, peg: 2, revenue: 4, fcf: 3 },
|
||||
thresholds: {
|
||||
marginHigh: 10,
|
||||
marginMed: 5,
|
||||
opMarginHigh: 15,
|
||||
opMarginMed: 8,
|
||||
roeHigh: 20,
|
||||
roeMed: 12,
|
||||
pegHigh: 1.0,
|
||||
pegMed: 1.5,
|
||||
revHigh: 12,
|
||||
revMed: 5,
|
||||
fcfHigh: 5,
|
||||
fcfMed: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
ETF: {
|
||||
gates: { maxExpenseRatio: 0.2 },
|
||||
weights: { yield: 2, lowCost: 4, fiveYearReturn: 2 },
|
||||
thresholds: {
|
||||
minYield: 1.5,
|
||||
maxExpense: 0.05,
|
||||
minVolume: 1_000_000,
|
||||
minFiveYearReturn: 8.0,
|
||||
},
|
||||
},
|
||||
|
||||
BOND: {
|
||||
gates: { minCreditRating: 7 }, // BBB = investment-grade floor
|
||||
weights: { yieldSpread: 3, duration: 2 },
|
||||
thresholds: { minSpread: 1.5, maxDuration: 7 },
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import { YahooFinanceClient } from '../adapters/YahooFinanceClient';
|
||||
import { REGIME } from '../config/constants';
|
||||
import type { MarketContext, Logger, BenchmarkProviderOptions } from '../types/index';
|
||||
|
||||
interface CacheFile {
|
||||
data: MarketContext;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export class BenchmarkProvider {
|
||||
private static readonly TTL_MS = 60 * 60 * 1000;
|
||||
private static readonly CACHE_PATH = '.benchmark-cache.json';
|
||||
|
||||
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 cache: { data: MarketContext | null; expiresAt: number };
|
||||
private logger: Logger;
|
||||
|
||||
constructor(
|
||||
private readonly client: YahooFinanceClient,
|
||||
{ logger }: BenchmarkProviderOptions = {},
|
||||
) {
|
||||
this.cache = this.loadDiskCache();
|
||||
this.logger = logger ?? (console as unknown as Logger);
|
||||
}
|
||||
|
||||
private loadDiskCache(): { data: MarketContext | null; expiresAt: number } {
|
||||
try {
|
||||
if (!existsSync(BenchmarkProvider.CACHE_PATH)) return { data: null, expiresAt: 0 };
|
||||
const file = JSON.parse(readFileSync(BenchmarkProvider.CACHE_PATH, 'utf8')) as CacheFile;
|
||||
if (Date.now() < file.expiresAt) return { data: file.data, expiresAt: file.expiresAt };
|
||||
} catch {
|
||||
// corrupt or missing — ignore
|
||||
}
|
||||
return { data: null, expiresAt: 0 };
|
||||
}
|
||||
|
||||
private saveDiskCache(data: MarketContext, expiresAt: number): void {
|
||||
try {
|
||||
writeFileSync(
|
||||
BenchmarkProvider.CACHE_PATH,
|
||||
JSON.stringify({ data, expiresAt } satisfies CacheFile, null, 2),
|
||||
'utf8',
|
||||
);
|
||||
} catch {
|
||||
// non-fatal — in-memory cache still works
|
||||
}
|
||||
}
|
||||
|
||||
async getMarketContext(): Promise<MarketContext> {
|
||||
if (this.cache.data && Date.now() < this.cache.expiresAt) return this.cache.data;
|
||||
|
||||
try {
|
||||
const [sp500, tn10y, vix, spy, xlk, xlre, lqd] = await Promise.all([
|
||||
this.client.fetchSummary('^GSPC'),
|
||||
this.client.fetchSummary('^TNX'),
|
||||
this.client.fetchSummary('^VIX'),
|
||||
this.client.fetchSummary('SPY'),
|
||||
this.client.fetchSummary('XLK'),
|
||||
this.client.fetchSummary('XLRE'),
|
||||
this.client.fetchSummary('LQD'),
|
||||
]);
|
||||
|
||||
const riskFreeRate =
|
||||
(sp500 as any)?.price?.regularMarketPrice !== undefined
|
||||
? ((tn10y as any)?.price?.regularMarketPrice ?? 0)
|
||||
: 0;
|
||||
const sp500Price = (sp500 as any)?.price?.regularMarketPrice ?? 0;
|
||||
const vixLevel = (vix as any)?.price?.regularMarketPrice ?? 0;
|
||||
|
||||
if (!sp500Price || !riskFreeRate) throw new Error('Invalid market data (zero values)');
|
||||
|
||||
const lqdYield = ((lqd as any)?.summaryDetail?.trailingAnnualDividendYield ?? 0) * 100;
|
||||
|
||||
const context: MarketContext = {
|
||||
sp500Price,
|
||||
riskFreeRate,
|
||||
vixLevel,
|
||||
rateRegime: BenchmarkProvider.rateRegime(riskFreeRate),
|
||||
volatilityRegime: BenchmarkProvider.volRegime(vixLevel),
|
||||
benchmarks: {
|
||||
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),
|
||||
},
|
||||
};
|
||||
|
||||
const expiresAt = Date.now() + BenchmarkProvider.TTL_MS;
|
||||
this.cache = { data: context, expiresAt };
|
||||
this.saveDiskCache(context, expiresAt);
|
||||
return context;
|
||||
} catch (err) {
|
||||
this.logger.warn('Market data fetch failed, using defaults:', (err as Error).message);
|
||||
return this.cache.data ?? BenchmarkProvider.DEFAULTS;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { YahooFinanceClient } from '../adapters/YahooFinanceClient';
|
||||
import type { Logger, CatalystResult, Story, YahooNewsItem } from '../types/index';
|
||||
|
||||
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,71 @@
|
||||
import type { CatalystResult, Logger } from '../types/index';
|
||||
import { CatalystAnalyst } from './CatalystAnalyst';
|
||||
|
||||
export class CatalystCache {
|
||||
private static readonly TTL_MS = 15 * 60 * 1000; // 15 minutes
|
||||
private cached: CatalystResult | null = null;
|
||||
private cachedAt: number | null = null;
|
||||
private isRefreshing = false;
|
||||
private analyst: CatalystAnalyst;
|
||||
private logger: Pick<Logger, 'write'>;
|
||||
|
||||
constructor({ logger }: { logger?: Pick<Logger, 'write'> } = {}) {
|
||||
this.analyst = new CatalystAnalyst({ logger });
|
||||
this.logger = logger ?? { write: (msg: string) => process.stdout.write(msg) };
|
||||
}
|
||||
|
||||
async get(): Promise<CatalystResult> {
|
||||
const now = Date.now();
|
||||
const isStale = !this.cachedAt || now - this.cachedAt > CatalystCache.TTL_MS;
|
||||
|
||||
if (!isStale && this.cached) {
|
||||
return this.cached;
|
||||
}
|
||||
|
||||
if (this.isRefreshing) {
|
||||
// Return stale cache while refresh in progress
|
||||
if (this.cached) {
|
||||
return this.cached;
|
||||
}
|
||||
// If no cache exists yet, wait for refresh to complete
|
||||
return new Promise((resolve) => {
|
||||
const checkInterval = setInterval(() => {
|
||||
if (!this.isRefreshing && this.cached) {
|
||||
clearInterval(checkInterval);
|
||||
resolve(this.cached!);
|
||||
}
|
||||
}, 100);
|
||||
// Timeout after 30s
|
||||
setTimeout(() => clearInterval(checkInterval), 30000);
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger refresh
|
||||
this.isRefreshing = true;
|
||||
try {
|
||||
this.logger.write('📡 Refreshing catalyst cache...\n');
|
||||
this.cached = await this.analyst.run();
|
||||
this.cachedAt = now;
|
||||
} catch (error) {
|
||||
this.logger.write(`⚠️ Catalyst refresh failed: ${error}\n`);
|
||||
// Return stale cache on error
|
||||
if (!this.cached) {
|
||||
this.cached = { tickers: [], tickerFrequency: {}, stories: [] };
|
||||
}
|
||||
} finally {
|
||||
this.isRefreshing = false;
|
||||
}
|
||||
|
||||
return this.cached;
|
||||
}
|
||||
|
||||
isExpired(): boolean {
|
||||
if (!this.cachedAt) return true;
|
||||
return Date.now() - this.cachedAt > CatalystCache.TTL_MS;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cached = null;
|
||||
this.cachedAt = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { AnthropicClient } from '../adapters/AnthropicClient';
|
||||
import type { Logger, LLMAnalysis, Story } from '../types/index';
|
||||
|
||||
export class LLMAnalyst {
|
||||
private logger: Pick<Logger, 'log' | 'warn'>;
|
||||
private client: AnthropicClient;
|
||||
|
||||
constructor({ logger }: { logger?: Pick<Logger, 'log' | 'warn'> } = {}) {
|
||||
// eslint-disable-next-line no-console
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,25 @@
|
||||
/**
|
||||
* Database layer types.
|
||||
* Defines interfaces for query building, auditing, and data access.
|
||||
*/
|
||||
|
||||
export enum AuditAction {
|
||||
READ = 'READ',
|
||||
WRITE = 'WRITE',
|
||||
DELETE = 'DELETE',
|
||||
}
|
||||
|
||||
export interface AuditEntry {
|
||||
timestamp: string; // ISO 8601
|
||||
action: AuditAction;
|
||||
sql: string;
|
||||
params: unknown[];
|
||||
durationMs: number;
|
||||
rowsAffected?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface DatabaseOptions {
|
||||
audit?: import('../db/QueryAudit').QueryAudit;
|
||||
logSlowQueries?: number; // milliseconds
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// ── 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[] },
|
||||
queryOpts?: { validateResult?: boolean },
|
||||
): 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,67 @@
|
||||
// ── 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, MarketCallRow, HoldingRow } 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';
|
||||
export type { AuditEntry, DatabaseOptions } from './database.model';
|
||||
export { AuditAction } from './database.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,48 @@
|
||||
/**
|
||||
* Repository model types.
|
||||
*
|
||||
* Defines:
|
||||
* - Row shapes: how data comes FROM the database (snake_case, as-is)
|
||||
* - Persistence shapes: collection types returned by repositories
|
||||
*/
|
||||
|
||||
import type { MarketCall, PortfolioHolding } from './index';
|
||||
|
||||
// ── Database Row Shapes (internal to repositories) ──────────────────────────
|
||||
|
||||
/**
|
||||
* Raw database row from market_calls table.
|
||||
* Uses snake_case columns exactly as they exist in SQLite.
|
||||
*/
|
||||
export interface MarketCallRow {
|
||||
id: string;
|
||||
title: string;
|
||||
quarter: string;
|
||||
date: string;
|
||||
thesis: string;
|
||||
tickers: string; // JSON array stringified
|
||||
snapshot: string; // JSON object stringified
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw database row from holdings table.
|
||||
* Uses snake_case columns exactly as they exist in SQLite.
|
||||
*/
|
||||
export interface HoldingRow {
|
||||
ticker: string;
|
||||
shares: number;
|
||||
cost_basis: number;
|
||||
type: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
// ── Persistence Shapes (returned by repositories) ───────────────────────────
|
||||
|
||||
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);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as queries from '../db/queries.constant';
|
||||
|
||||
export class QueryBuilder {
|
||||
readonly sql: string;
|
||||
readonly queryParams: unknown[];
|
||||
|
||||
/**
|
||||
* Create a QueryBuilder from a query constant path.
|
||||
*
|
||||
* @param queryPath Path to query in queries.constant.ts (e.g., 'MARKET_CALLS_QUERIES.SELECT_ALL')
|
||||
* @param params Parameters to bind (? placeholders in SQL)
|
||||
*/
|
||||
constructor(queryPath: string, params: unknown[] = []) {
|
||||
this.sql = this.lookupQuery(queryPath);
|
||||
this.queryParams = params;
|
||||
|
||||
// Validate parameter count matches placeholders
|
||||
const placeholderCount = (this.sql.match(/\?/g) || []).length;
|
||||
if (this.queryParams.length !== placeholderCount) {
|
||||
throw new Error(
|
||||
`Parameter mismatch for query "${queryPath}": expected ${placeholderCount}, got ${this.queryParams.length}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a query from queries.constant.ts.
|
||||
* Supports nested paths like "MARKET_CALLS_QUERIES.SELECT_ALL".
|
||||
*
|
||||
* @param queryPath Path to query (e.g., 'MARKET_CALLS_QUERIES.SELECT_ALL')
|
||||
* @returns The SQL query string
|
||||
* @throws Error if query not found
|
||||
*/
|
||||
private lookupQuery(queryPath: string): string {
|
||||
const parts = queryPath.split('.');
|
||||
|
||||
// Navigate through the nested objects
|
||||
let current: any = queries;
|
||||
for (const part of parts) {
|
||||
if (!(part in current)) {
|
||||
throw new Error(
|
||||
`Query not found: "${queryPath}". Make sure it exists in queries.constant.ts`,
|
||||
);
|
||||
}
|
||||
current = current[part];
|
||||
}
|
||||
|
||||
if (typeof current !== 'string') {
|
||||
throw new Error(`Invalid query: "${queryPath}" must be a string, got ${typeof current}`);
|
||||
}
|
||||
|
||||
// Clean up the SQL (remove extra whitespace)
|
||||
return current.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Logger } from '../types';
|
||||
|
||||
/**
|
||||
* Shared server-side logger utilities.
|
||||
*
|
||||
* noopLogger — silent logger for use in API server context where stdout
|
||||
* output from screener/analyst classes would pollute the request log.
|
||||
* Pass as { logger: noopLogger } to ScreenerEngine, BenchmarkProvider,
|
||||
* CatalystAnalyst, SimpleFINClient, LLMAnalyst.
|
||||
*/
|
||||
export const noopLogger: Logger = {
|
||||
write: () => {},
|
||||
log: () => {},
|
||||
warn: () => {},
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Sanitize a ticker symbol.
|
||||
* - Converts to uppercase
|
||||
* - Trims whitespace
|
||||
* - Validates non-empty
|
||||
*
|
||||
* @param ticker The ticker symbol (e.g. "aapl", " MSFT ", "BRK.B")
|
||||
* @returns Normalized ticker (e.g. "AAPL", "MSFT", "BRK.B")
|
||||
* @throws Error if ticker is empty or invalid
|
||||
*/
|
||||
export function sanitizeTicker(ticker: string): string {
|
||||
if (!ticker || typeof ticker !== 'string') {
|
||||
throw new Error('Invalid ticker: must be a non-empty string');
|
||||
}
|
||||
|
||||
const normalized = ticker.trim().toUpperCase();
|
||||
|
||||
if (!normalized) {
|
||||
throw new Error('Invalid ticker: cannot be empty or whitespace');
|
||||
}
|
||||
|
||||
// Optional: validate ticker format (alphanumeric + dots/hyphens)
|
||||
if (!/^[A-Z0-9-.]+$/.test(normalized)) {
|
||||
throw new Error(`Invalid ticker format: ${normalized}`);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize an array of tickers.
|
||||
*
|
||||
* @param tickers Array of ticker symbols
|
||||
* @returns Array of normalized tickers
|
||||
* @throws Error if any ticker is invalid
|
||||
*/
|
||||
export function sanitizeTickers(tickers: unknown): string[] {
|
||||
if (!Array.isArray(tickers)) {
|
||||
throw new Error('Invalid tickers: must be an array');
|
||||
}
|
||||
|
||||
if (tickers.length === 0) {
|
||||
throw new Error('Invalid tickers: array cannot be empty');
|
||||
}
|
||||
|
||||
return tickers.map((t) => {
|
||||
if (typeof t !== 'string') {
|
||||
throw new Error(`Invalid ticker in array: ${t} (expected string)`);
|
||||
}
|
||||
return sanitizeTicker(t);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a string field.
|
||||
* - Trims whitespace
|
||||
* - Validates non-empty
|
||||
* - Optional: enforces max length
|
||||
*
|
||||
* @param value The string value
|
||||
* @param fieldName Name of the field (for error messages)
|
||||
* @param maxLength Maximum allowed length (optional)
|
||||
* @returns Trimmed string
|
||||
* @throws Error if value is invalid
|
||||
*/
|
||||
export function sanitizeString(value: unknown, fieldName: string, maxLength?: number): string {
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`Invalid ${fieldName}: must be a string`);
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
throw new Error(`Invalid ${fieldName}: cannot be empty or whitespace`);
|
||||
}
|
||||
|
||||
if (maxLength && trimmed.length > maxLength) {
|
||||
throw new Error(`Invalid ${fieldName}: exceeds max length of ${maxLength} characters`);
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a number field.
|
||||
* - Validates it's a number
|
||||
* - Optional: enforces min/max bounds
|
||||
*
|
||||
* @param value The numeric value
|
||||
* @param fieldName Name of the field (for error messages)
|
||||
* @param min Minimum allowed value (optional)
|
||||
* @param max Maximum allowed value (optional)
|
||||
* @returns The validated number
|
||||
* @throws Error if value is invalid
|
||||
*/
|
||||
export function sanitizeNumber(
|
||||
value: unknown,
|
||||
fieldName: string,
|
||||
options?: { min?: number; max?: number },
|
||||
): number {
|
||||
const num = typeof value === 'number' ? value : Number(value);
|
||||
|
||||
if (isNaN(num)) {
|
||||
throw new Error(`Invalid ${fieldName}: must be a valid number`);
|
||||
}
|
||||
|
||||
if (options?.min !== undefined && num < options.min) {
|
||||
throw new Error(`Invalid ${fieldName}: must be at least ${options.min}`);
|
||||
}
|
||||
|
||||
if (options?.max !== undefined && num > options.max) {
|
||||
throw new Error(`Invalid ${fieldName}: must be at most ${options.max}`);
|
||||
}
|
||||
|
||||
return num;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize an ISO date string.
|
||||
* - Validates it's a valid ISO date
|
||||
* - Converts to string format YYYY-MM-DD
|
||||
*
|
||||
* @param value The date value (ISO string or Date)
|
||||
* @param fieldName Name of the field (for error messages)
|
||||
* @returns Date as YYYY-MM-DD string
|
||||
* @throws Error if date is invalid
|
||||
*/
|
||||
export function sanitizeDate(value: unknown, fieldName: string): string {
|
||||
let date: Date | null = null;
|
||||
|
||||
if (typeof value === 'string') {
|
||||
date = new Date(value);
|
||||
} else if (value instanceof Date) {
|
||||
date = value;
|
||||
}
|
||||
|
||||
if (!date || isNaN(date.getTime())) {
|
||||
throw new Error(`Invalid ${fieldName}: must be a valid date`);
|
||||
}
|
||||
|
||||
return date.toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
}
|
||||
Reference in New Issue
Block a user