phase-7: code restructure
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
import { YahooFinanceClient } from '../clients/YahooFinanceClient';
|
||||
import { REGIME } from '../config/constants';
|
||||
import type { MarketContext, Logger, BenchmarkProviderOptions } from '../types';
|
||||
|
||||
export class BenchmarkProvider {
|
||||
private static readonly TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
private static readonly DEFAULTS: MarketContext = {
|
||||
sp500Price: 5000,
|
||||
riskFreeRate: 4.5,
|
||||
vixLevel: 20,
|
||||
rateRegime: 'HIGH',
|
||||
volatilityRegime: 'NORMAL',
|
||||
benchmarks: { marketPE: 22, techPE: 30, reitYield: 3.5, igSpread: 1.0 },
|
||||
};
|
||||
|
||||
private static rateRegime(rate: number): MarketContext['rateRegime'] {
|
||||
return rate < 2 ? REGIME.LOW : rate <= 5 ? REGIME.NORMAL : REGIME.HIGH;
|
||||
}
|
||||
|
||||
private static volRegime(vix: number): MarketContext['volatilityRegime'] {
|
||||
return vix < 15 ? REGIME.LOW : vix <= 25 ? REGIME.NORMAL : REGIME.HIGH;
|
||||
}
|
||||
|
||||
private static pe(summary: any): number | null {
|
||||
return summary?.summaryDetail?.trailingPE ?? summary?.defaultKeyStatistics?.forwardPE ?? null;
|
||||
}
|
||||
private client: YahooFinanceClient;
|
||||
private cache: { data: MarketContext | null; expiresAt: number };
|
||||
private logger: Logger;
|
||||
|
||||
constructor({ logger }: BenchmarkProviderOptions = {}) {
|
||||
this.client = new YahooFinanceClient();
|
||||
this.cache = { data: null, expiresAt: 0 };
|
||||
this.logger = logger ?? (console as unknown as Logger);
|
||||
}
|
||||
|
||||
async getMarketContext(): Promise<MarketContext> {
|
||||
if (this.cache.data && Date.now() < this.cache.expiresAt) return this.cache.data;
|
||||
|
||||
try {
|
||||
const [sp500, tn10y, vix, spy, xlk, xlre, lqd] = await Promise.all([
|
||||
this.client.fetchSummary('^GSPC'),
|
||||
this.client.fetchSummary('^TNX'),
|
||||
this.client.fetchSummary('^VIX'),
|
||||
this.client.fetchSummary('SPY'),
|
||||
this.client.fetchSummary('XLK'),
|
||||
this.client.fetchSummary('XLRE'),
|
||||
this.client.fetchSummary('LQD'),
|
||||
]);
|
||||
|
||||
const riskFreeRate =
|
||||
(sp500 as any)?.price?.regularMarketPrice !== undefined
|
||||
? ((tn10y as any)?.price?.regularMarketPrice ?? 0)
|
||||
: 0;
|
||||
const sp500Price = (sp500 as any)?.price?.regularMarketPrice ?? 0;
|
||||
const vixLevel = (vix as any)?.price?.regularMarketPrice ?? 0;
|
||||
|
||||
if (!sp500Price || !riskFreeRate) throw new Error('Invalid market data (zero values)');
|
||||
|
||||
const lqdYield = ((lqd as any)?.summaryDetail?.trailingAnnualDividendYield ?? 0) * 100;
|
||||
|
||||
const context: MarketContext = {
|
||||
sp500Price,
|
||||
riskFreeRate,
|
||||
vixLevel,
|
||||
rateRegime: 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),
|
||||
},
|
||||
};
|
||||
|
||||
this.cache = { data: context, expiresAt: Date.now() + BenchmarkProvider.TTL_MS };
|
||||
return context;
|
||||
} catch (err) {
|
||||
this.logger.warn('Market data fetch failed, using defaults:', (err as Error).message);
|
||||
return this.cache.data ?? BenchmarkProvider.DEFAULTS;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { YahooFinanceClient } from '../clients/YahooFinanceClient';
|
||||
import type { Logger, CatalystResult, Story, YahooNewsItem } from '../types';
|
||||
|
||||
export class CatalystAnalyst {
|
||||
private static readonly NEWS_QUERIES = [
|
||||
'stock market today',
|
||||
'earnings report today',
|
||||
'market news catalyst',
|
||||
'federal reserve interest rates',
|
||||
'stock upgrade downgrade analyst',
|
||||
];
|
||||
private static readonly MAX_STORIES = 20;
|
||||
private static readonly TICKER_REGEX = /^[A-Z]{1,6}$/;
|
||||
private client: YahooFinanceClient;
|
||||
private logger: Pick<Logger, 'write'>;
|
||||
|
||||
constructor({ logger }: { logger?: Pick<Logger, 'write'> } = {}) {
|
||||
this.client = new YahooFinanceClient();
|
||||
this.logger = logger ?? { write: (msg: string) => process.stdout.write(msg) };
|
||||
}
|
||||
|
||||
async run(): Promise<CatalystResult> {
|
||||
this.logger.write('🔍 Fetching market news...');
|
||||
const rawStories = await this._fetchNews();
|
||||
|
||||
if (!rawStories.length) {
|
||||
this.logger.write(' ⚠ all news queries failed — check network or Yahoo rate limit\n');
|
||||
return { tickers: [], tickerFrequency: {}, stories: [] };
|
||||
}
|
||||
|
||||
const stories = rawStories.map((s) => ({
|
||||
title: s.title,
|
||||
link: s.link ?? '',
|
||||
source: s.publisher ?? 'unknown',
|
||||
tickers: (s.relatedTickers ?? [])
|
||||
.map((t) => t.split(':')[0].toUpperCase())
|
||||
.filter((t) => CatalystAnalyst.TICKER_REGEX.test(t)),
|
||||
}));
|
||||
|
||||
const { tickers, tickerFrequency } = CatalystAnalyst.rankTickers(stories);
|
||||
this.logger.write(` ${stories.length} stories, ${tickers.length} tickers\n`);
|
||||
return { tickers, tickerFrequency, stories };
|
||||
}
|
||||
|
||||
// Search by specific ticker for the /api/analyze endpoint.
|
||||
async fetchStoriesForTickers(tickers: string[]): Promise<Story[]> {
|
||||
const seen = new Map<string, YahooNewsItem>();
|
||||
await Promise.all(
|
||||
tickers.slice(0, 10).map(async (ticker) => {
|
||||
try {
|
||||
const news = await this.client.search(ticker, { newsCount: 3, quotesCount: 0 });
|
||||
for (const item of news) {
|
||||
if (!seen.has(item.title)) seen.set(item.title, item);
|
||||
}
|
||||
} catch {
|
||||
/* skip tickers Yahoo can't resolve */
|
||||
}
|
||||
}),
|
||||
);
|
||||
return [...seen.values()].slice(0, 15).map((s) => ({
|
||||
title: s.title,
|
||||
link: s.link ?? '',
|
||||
source: s.publisher ?? 'unknown',
|
||||
tickers: (s.relatedTickers ?? [])
|
||||
.map((t) => t.split(':')[0].toUpperCase())
|
||||
.filter((t) => CatalystAnalyst.TICKER_REGEX.test(t)),
|
||||
}));
|
||||
}
|
||||
|
||||
private async _fetchNews(): Promise<YahooNewsItem[]> {
|
||||
const seen = new Map<string, YahooNewsItem>();
|
||||
let successCount = 0;
|
||||
for (const query of CatalystAnalyst.NEWS_QUERIES) {
|
||||
try {
|
||||
const news = await this.client.search(query, { newsCount: 8, quotesCount: 0 });
|
||||
successCount++;
|
||||
for (const s of news) {
|
||||
if (!seen.has(s.title)) {
|
||||
seen.set(s.title, {
|
||||
title: s.title,
|
||||
publisher: s.publisher,
|
||||
link: s.link,
|
||||
relatedTickers: s.relatedTickers ?? [],
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* skip failed query — tracked via successCount */
|
||||
}
|
||||
}
|
||||
if (successCount === 0) return [];
|
||||
return [...seen.values()].slice(0, CatalystAnalyst.MAX_STORIES);
|
||||
}
|
||||
|
||||
static rankTickers(stories: Story[]): {
|
||||
tickers: string[];
|
||||
tickerFrequency: Record<string, number>;
|
||||
} {
|
||||
const freq: Record<string, number> = {};
|
||||
for (const { tickers } of stories) {
|
||||
for (const t of tickers) {
|
||||
freq[t] = (freq[t] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
const tickers = Object.keys(freq).sort((a, b) => freq[b] - freq[a]);
|
||||
return { tickers, tickerFrequency: freq };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import type { MappedData } from '../types';
|
||||
|
||||
// Internal: Yahoo Finance API response shape
|
||||
type YahooSummary = Record<string, Record<string, unknown>>;
|
||||
|
||||
export class DataMapper {
|
||||
// ── Public entry point ────────────────────────────────────────────────────
|
||||
static mapToStandardFormat(ticker: string, summary: YahooSummary): MappedData {
|
||||
const quoteType = summary.price?.quoteType as string | undefined;
|
||||
const category = ((summary.assetProfile?.category as string) || '').toLowerCase();
|
||||
const yieldVal = (summary.summaryDetail?.trailingAnnualDividendYield as number) ?? 0;
|
||||
|
||||
const isBond =
|
||||
category.includes('bond') ||
|
||||
category.includes('fixed income') ||
|
||||
category.includes('treasury') ||
|
||||
(quoteType === 'ETF' && yieldVal > 0.02 && category === '');
|
||||
|
||||
if (quoteType === 'ETF') {
|
||||
return isBond
|
||||
? { type: 'BOND', ticker, ...DataMapper.mapBondData(summary) }
|
||||
: { type: 'ETF', ticker, ...DataMapper.mapEtfData(summary) };
|
||||
}
|
||||
|
||||
return { type: 'STOCK', ticker, ...DataMapper.mapStockData(summary) };
|
||||
}
|
||||
|
||||
// ── Stock ─────────────────────────────────────────────────────────────────
|
||||
private static mapStockData(summary: YahooSummary) {
|
||||
const fd = (summary.financialData ?? {}) as Record<string, number | null>;
|
||||
const ks = (summary.defaultKeyStatistics ?? {}) as Record<string, number | null>;
|
||||
const sd = (summary.summaryDetail ?? {}) as Record<string, number | null>;
|
||||
const pr = (summary.price ?? {}) as Record<string, number | null>;
|
||||
|
||||
const currentPrice = pr.regularMarketPrice ?? 0;
|
||||
const sharesOutstanding = ks.sharesOutstanding ?? 0;
|
||||
const operatingCashflow = fd.operatingCashflow ?? 0;
|
||||
const freeCashflow = fd.freeCashflow ?? 0;
|
||||
|
||||
// P/FFO proxy — used for REIT scoring
|
||||
const pFFO =
|
||||
operatingCashflow > 0 && sharesOutstanding > 0
|
||||
? (currentPrice as number) / (operatingCashflow / sharesOutstanding)
|
||||
: null;
|
||||
|
||||
// FCF yield — negative FCF preserved so cash-burning companies fail the gate
|
||||
const fcfYield =
|
||||
freeCashflow !== 0 && sharesOutstanding > 0 && (currentPrice as number) > 0
|
||||
? ((freeCashflow as number) / (sharesOutstanding as number) / (currentPrice as number)) *
|
||||
100
|
||||
: null;
|
||||
|
||||
// PEG: prefer Yahoo's value, fall back to trailingPE / earningsGrowth
|
||||
const yahoosPEG = ks.pegRatio ?? null;
|
||||
const trailingPE = sd.trailingPE ?? null;
|
||||
const earningsGrowth = fd.earningsGrowth != null ? (fd.earningsGrowth as number) * 100 : null;
|
||||
const computedPEG =
|
||||
trailingPE != null && earningsGrowth != null && earningsGrowth > 0
|
||||
? +((trailingPE as number) / earningsGrowth).toFixed(2)
|
||||
: null;
|
||||
const pegRatio = yahoosPEG ?? computedPEG;
|
||||
|
||||
// Quick ratio — fall back to currentRatio when missing
|
||||
const quickRatio = fd.quickRatio ?? fd.currentRatio ?? null;
|
||||
|
||||
// ── 52-week movement ──────────────────────────────────────────────────
|
||||
const week52High = sd.fiftyTwoWeekHigh ?? null;
|
||||
const week52Low = sd.fiftyTwoWeekLow ?? null;
|
||||
const week52Change =
|
||||
ks['52WeekChange'] != null ? +((ks['52WeekChange'] as number) * 100).toFixed(1) : null;
|
||||
const week52FromHigh =
|
||||
week52High != null && week52High > 0 && (currentPrice as number) > 0
|
||||
? +(((currentPrice - week52High) / week52High) * 100).toFixed(1)
|
||||
: null;
|
||||
const week52FromLow =
|
||||
week52Low != null && week52Low > 0 && (currentPrice as number) > 0
|
||||
? +(((currentPrice - week52Low) / week52Low) * 100).toFixed(1)
|
||||
: null;
|
||||
|
||||
// ── Analyst consensus ─────────────────────────────────────────────────
|
||||
const analystRating = fd.recommendationMean ?? null;
|
||||
const analystTargetPrice = fd.targetMeanPrice ?? null;
|
||||
const numberOfAnalysts =
|
||||
fd.numberOfAnalystOpinions != null ? Math.round(fd.numberOfAnalystOpinions as number) : null;
|
||||
const analystUpside =
|
||||
analystTargetPrice != null && (currentPrice as number) > 0
|
||||
? +(((analystTargetPrice - currentPrice) / currentPrice) * 100).toFixed(1)
|
||||
: null;
|
||||
|
||||
// ── Gross margin ──────────────────────────────────────────────────────
|
||||
const grossMargin =
|
||||
fd.grossMargins != null ? +((fd.grossMargins as number) * 100).toFixed(1) : null;
|
||||
|
||||
// ── DCF intrinsic value ───────────────────────────────────────────────
|
||||
const revenueGrowthDecimal = fd.revenueGrowth != null ? (fd.revenueGrowth as number) : null;
|
||||
const earningsGrowthDecimal = fd.earningsGrowth != null ? (fd.earningsGrowth as number) : null;
|
||||
const dcfGrowthRate =
|
||||
earningsGrowthDecimal ?? (revenueGrowthDecimal != null ? revenueGrowthDecimal * 0.7 : null);
|
||||
|
||||
const dcf = DataMapper.computeDCF(
|
||||
freeCashflow as number,
|
||||
sharesOutstanding as number,
|
||||
currentPrice as number,
|
||||
dcfGrowthRate,
|
||||
);
|
||||
|
||||
return {
|
||||
peRatio: trailingPE ?? ks.forwardPE,
|
||||
trailingPE,
|
||||
pegRatio,
|
||||
priceToBook: ks.priceToBook ?? null,
|
||||
evToEbitda: ks.enterpriseToEbitda ?? null,
|
||||
grossMargin,
|
||||
netProfitMargin: fd.profitMargins != null ? (fd.profitMargins as number) * 100 : null,
|
||||
operatingMargin: fd.operatingMargins != null ? (fd.operatingMargins as number) * 100 : null,
|
||||
returnOnEquity: fd.returnOnEquity != null ? (fd.returnOnEquity as number) * 100 : null,
|
||||
revenueGrowth: fd.revenueGrowth != null ? (fd.revenueGrowth as number) * 100 : null,
|
||||
earningsGrowth,
|
||||
debtToEquity: fd.debtToEquity != null ? (fd.debtToEquity as number) / 100 : null,
|
||||
quickRatio,
|
||||
fcfYield,
|
||||
pFFO,
|
||||
dividendYield:
|
||||
sd.trailingAnnualDividendYield != null
|
||||
? (sd.trailingAnnualDividendYield as number) * 100
|
||||
: null,
|
||||
beta: sd.beta ?? null,
|
||||
week52High,
|
||||
week52Low,
|
||||
week52Change,
|
||||
week52FromHigh,
|
||||
week52FromLow,
|
||||
marketCap: pr.marketCap ?? null,
|
||||
analystRating,
|
||||
analystTargetPrice,
|
||||
analystUpside,
|
||||
numberOfAnalysts,
|
||||
dcfIntrinsicValue: dcf?.intrinsicValue ?? null,
|
||||
dcfMarginOfSafety: dcf?.marginOfSafety ?? null,
|
||||
currentPrice,
|
||||
assetProfile: summary.assetProfile || {},
|
||||
};
|
||||
}
|
||||
|
||||
// ── ETF ───────────────────────────────────────────────────────────────────
|
||||
private static mapEtfData(summary: YahooSummary) {
|
||||
return {
|
||||
expenseRatio: ((summary.summaryDetail?.expenseRatio as number) ?? 0) * 100,
|
||||
totalAssets: (summary.summaryDetail?.totalAssets as number) ?? 0,
|
||||
yield: ((summary.summaryDetail?.trailingAnnualDividendYield as number) ?? 0) * 100,
|
||||
fiveYearReturn: ((summary.defaultKeyStatistics?.fiveYearAverageReturn as number) ?? 0) * 100,
|
||||
volume:
|
||||
(summary.summaryDetail?.averageVolume as number) ??
|
||||
(summary.price?.averageVolume as number) ??
|
||||
0,
|
||||
currentPrice: (summary.price?.regularMarketPrice as number) ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Bond ──────────────────────────────────────────────────────────────────
|
||||
private static mapBondData(summary: YahooSummary) {
|
||||
return {
|
||||
yieldToMaturity: ((summary.summaryDetail?.yield as number) ?? 0) * 100,
|
||||
duration: DataMapper.inferDuration(summary.assetProfile?.category as string),
|
||||
creditRating: DataMapper.inferCreditRating(summary.assetProfile?.category as string),
|
||||
currentPrice: (summary.price?.regularMarketPrice as number) ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
private static inferCreditRating(category: string | undefined): string {
|
||||
const cat = (category || '').toLowerCase();
|
||||
if (cat.includes('government') || cat.includes('treasury')) return 'AAA';
|
||||
if (cat.includes('muni')) return 'AA';
|
||||
if (cat.includes('high yield') || cat.includes('junk')) return 'BB';
|
||||
if (cat.includes('corporate') || cat.includes('investment grade')) return 'A';
|
||||
return 'BBB';
|
||||
}
|
||||
|
||||
private static inferDuration(category: string | undefined): number {
|
||||
const cat = (category || '').toLowerCase();
|
||||
if (cat.includes('short') || cat.includes('ultrashort') || cat.includes('1-3')) return 2;
|
||||
if (cat.includes('intermediate') || cat.includes('3-7') || cat.includes('3-10')) return 5;
|
||||
if (cat.includes('long') || cat.includes('10+') || cat.includes('20+')) return 18;
|
||||
if (cat.includes('target maturity') || cat.includes('defined maturity')) return 4;
|
||||
return 6;
|
||||
}
|
||||
|
||||
// ── DCF ───────────────────────────────────────────────────────────────────
|
||||
// Two-stage model:
|
||||
// Stage 1 — FCF/share grows at `growthRate` for 5 years, discounted at 9.5% WACC.
|
||||
// Stage 2 — Terminal value via Gordon Growth Model at 2.5% perpetuity rate.
|
||||
// Only fires when TTM FCF per share is positive.
|
||||
private static computeDCF(
|
||||
freeCashflow: number,
|
||||
sharesOutstanding: number,
|
||||
currentPrice: number,
|
||||
growthRate: number | null,
|
||||
riskFreeRate = 0.04,
|
||||
): { intrinsicValue: number; marginOfSafety: number } | null {
|
||||
if (!freeCashflow || freeCashflow <= 0) return null;
|
||||
if (!sharesOutstanding || sharesOutstanding <= 0) return null;
|
||||
if (!currentPrice || currentPrice <= 0) return null;
|
||||
|
||||
const fcfPerShare = freeCashflow / sharesOutstanding;
|
||||
if (fcfPerShare <= 0) return null;
|
||||
|
||||
const discountRate = riskFreeRate + 0.055; // WACC proxy
|
||||
const terminalGrowth = 0.025; // long-run GDP growth
|
||||
const years = 5;
|
||||
const g = Math.min(Math.max(growthRate ?? 0.08, -0.05), 0.3);
|
||||
|
||||
let pv = 0;
|
||||
let fcfT = fcfPerShare;
|
||||
for (let t = 1; t <= years; t++) {
|
||||
fcfT *= 1 + g;
|
||||
pv += fcfT / Math.pow(1 + discountRate, t);
|
||||
}
|
||||
|
||||
const terminalValue = (fcfT * (1 + terminalGrowth)) / (discountRate - terminalGrowth);
|
||||
pv += terminalValue / Math.pow(1 + discountRate, years);
|
||||
|
||||
const intrinsicValue = +pv.toFixed(2);
|
||||
const marginOfSafety = +(((intrinsicValue - currentPrice) / intrinsicValue) * 100).toFixed(1);
|
||||
|
||||
return { intrinsicValue, marginOfSafety };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { AnthropicClient } from '../clients/AnthropicClient';
|
||||
import type { Logger, LLMAnalysis, Story } from '../types';
|
||||
|
||||
export class LLMAnalyst {
|
||||
private logger: Pick<Logger, 'log' | 'warn'>;
|
||||
private client: AnthropicClient;
|
||||
|
||||
constructor({ logger }: { logger?: Pick<Logger, 'log' | 'warn'> } = {}) {
|
||||
this.logger = logger ?? { log: console.log, warn: console.warn };
|
||||
this.client = new AnthropicClient();
|
||||
}
|
||||
|
||||
get isAvailable(): boolean {
|
||||
return this.client.isAvailable;
|
||||
}
|
||||
|
||||
async analyze(
|
||||
stories: Story[],
|
||||
existingTickers: string[] = [],
|
||||
tickerFrequency: Record<string, number> = {},
|
||||
): Promise<LLMAnalysis | null> {
|
||||
if (!this.client.isAvailable) {
|
||||
this.logger.warn('LLMAnalyst: ANTHROPIC_API_KEY not set — skipping analysis');
|
||||
return null;
|
||||
}
|
||||
if (!stories?.length) return null;
|
||||
|
||||
const headlines = stories
|
||||
.slice(0, 15)
|
||||
.map((s, i) => {
|
||||
const tickers = s.tickers.length ? ` [${s.tickers.join(', ')}]` : '';
|
||||
return `${i + 1}. ${s.title} (${s.source})${tickers}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const freqLines = Object.entries(tickerFrequency)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 10)
|
||||
.map(([t, n]) => ` ${t}: ${n} ${n === 1 ? 'story' : 'stories'}`)
|
||||
.join('\n');
|
||||
|
||||
const freqSection = freqLines ? `\nTicker mention frequency (ranked):\n${freqLines}\n` : '';
|
||||
|
||||
const userMessage = `Today's market news headlines:\n\n${headlines}\n${freqSection}\nAlready identified catalyst tickers: ${existingTickers.join(', ') || 'none'}`;
|
||||
|
||||
try {
|
||||
const PROMPT_FILE = '../../prompts/llm-analyst.md';
|
||||
const PROMPT_PATH = join(fileURLToPath(import.meta.url), PROMPT_FILE);
|
||||
const SYSTEM_PROMPT = readFileSync(PROMPT_PATH, 'utf8');
|
||||
|
||||
const raw = await this.client.complete(SYSTEM_PROMPT, userMessage);
|
||||
if (!raw) return null;
|
||||
const cleaned = raw
|
||||
.replace(/^```(?:json)?\s*/i, '')
|
||||
.replace(/```\s*$/i, '')
|
||||
.trim();
|
||||
return JSON.parse(cleaned) as LLMAnalysis;
|
||||
} catch (err) {
|
||||
this.logger.warn('LLMAnalyst: analysis failed —', (err as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,53 @@
|
||||
import type { CategoryBreakdown, FinanceAnalysis, SimpleFINAccount } from '../types';
|
||||
|
||||
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,185 @@
|
||||
import { SIGNAL } from '../config/constants';
|
||||
import { YahooFinanceClient } from '../clients/YahooFinanceClient';
|
||||
import type {
|
||||
PortfolioHolding,
|
||||
Signal,
|
||||
ScreenerResult,
|
||||
AssetResult,
|
||||
AdviceRow,
|
||||
PositionCalc,
|
||||
AdviceOutput,
|
||||
} from '../types';
|
||||
|
||||
export class PortfolioAdvisor {
|
||||
private client: YahooFinanceClient;
|
||||
|
||||
constructor() {
|
||||
this.client = new 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,43 @@
|
||||
import { ScoringRules } from '../config/ScoringConfig';
|
||||
import { MarketRegime } from './MarketRegime';
|
||||
import { SCORE_MODE } from '../config/constants';
|
||||
import type { AssetType, MarketContext, RuleSet } from '../types';
|
||||
|
||||
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,198 @@
|
||||
import { YahooFinanceClient } from '../clients/YahooFinanceClient';
|
||||
import { BenchmarkProvider } from './BenchmarkProvider';
|
||||
import { DataMapper } from './DataMapper';
|
||||
import { chunkArray } from '../utils/Chunker';
|
||||
import { RuleMerger } from './RuleMerger';
|
||||
import { Stock } from '../models/Stock';
|
||||
import { Etf } from '../models/Etf';
|
||||
import { Bond } from '../models/Bond';
|
||||
import { StockScorer } from '../scorers/StockScorer';
|
||||
import { EtfScorer } from '../scorers/EtfScorer';
|
||||
import { BondScorer } from '../scorers/BondScorer';
|
||||
import { SIGNAL, SIGNAL_ORDER, SCORE_MODE, ASSET_TYPE } from '../config/constants';
|
||||
import type {
|
||||
Logger,
|
||||
MarketContext,
|
||||
Signal,
|
||||
AssetType,
|
||||
ScoreResult,
|
||||
ScreenerResult,
|
||||
ScreenerEngineOptions,
|
||||
ErrorResult,
|
||||
MappedData,
|
||||
StockData,
|
||||
EtfData,
|
||||
BondData,
|
||||
} from '../types';
|
||||
|
||||
export class ScreenerEngine {
|
||||
private static readonly BATCH_SIZE = 5;
|
||||
private static readonly BATCH_DELAY_MS = 1000;
|
||||
|
||||
private client: YahooFinanceClient;
|
||||
private benchmarkProvider: BenchmarkProvider;
|
||||
private logger: Logger;
|
||||
|
||||
constructor({ logger }: ScreenerEngineOptions = {}) {
|
||||
this.client = new YahooFinanceClient();
|
||||
this.benchmarkProvider = new BenchmarkProvider({
|
||||
logger: logger ?? (console as unknown as Logger),
|
||||
});
|
||||
this.logger = logger ?? {
|
||||
write: (msg: string) => process.stdout.write(msg),
|
||||
log: (...args: unknown[]) => console.log(...args),
|
||||
warn: (...args: unknown[]) => console.warn(...args),
|
||||
};
|
||||
}
|
||||
|
||||
async screenTickers(tickers: string[]): Promise<ScreenerResult> {
|
||||
return this._screenInternal(tickers, false);
|
||||
}
|
||||
|
||||
async screenWithProgress(tickers: string[]): Promise<ScreenerResult> {
|
||||
return this._screenInternal(tickers, true);
|
||||
}
|
||||
|
||||
private async _screenInternal(tickers: string[], showProgress: boolean): Promise<ScreenerResult> {
|
||||
const marketContext = await this._fetchMarketContext(showProgress);
|
||||
const results = this._initializeResults();
|
||||
const chunks = chunkArray(tickers, ScreenerEngine.BATCH_SIZE);
|
||||
let processed = 0;
|
||||
|
||||
for (const chunk of chunks) {
|
||||
await this._processBatch(chunk, marketContext, results);
|
||||
processed += chunk.length;
|
||||
this._logProgress(showProgress, processed, tickers.length);
|
||||
await this._rateLimitDelay();
|
||||
}
|
||||
|
||||
if (showProgress) {
|
||||
this.logger.write('\n');
|
||||
}
|
||||
|
||||
return { ...results, marketContext };
|
||||
}
|
||||
|
||||
private async _fetchMarketContext(showProgress: boolean): Promise<MarketContext> {
|
||||
if (showProgress) {
|
||||
this.logger.write('⏳ Fetching market context...');
|
||||
}
|
||||
const context = await this.benchmarkProvider.getMarketContext();
|
||||
if (showProgress) {
|
||||
this.logger.write(' done\n');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
private _initializeResults(): Omit<ScreenerResult, 'marketContext'> {
|
||||
return { STOCK: [], ETF: [], BOND: [], ERROR: [] };
|
||||
}
|
||||
|
||||
private async _processBatch(
|
||||
tickers: string[],
|
||||
marketContext: MarketContext,
|
||||
results: Omit<ScreenerResult, 'marketContext'>,
|
||||
): Promise<void> {
|
||||
const batch = await Promise.all(tickers.map((t) => this._fetch(t)));
|
||||
batch.forEach((data) => this._process(data, marketContext, results));
|
||||
}
|
||||
|
||||
private _logProgress(showProgress: boolean, processed: number, total: number): void {
|
||||
if (showProgress) {
|
||||
this.logger.write(`\r⏳ Screening tickers... ${processed}/${total}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async _rateLimitDelay(): Promise<void> {
|
||||
await new Promise<void>((r) => setTimeout(r, ScreenerEngine.BATCH_DELAY_MS));
|
||||
}
|
||||
|
||||
private async _fetch(ticker: string): Promise<MappedData | ErrorResult> {
|
||||
try {
|
||||
const summary = await this.client.fetchSummary(ticker);
|
||||
if (!summary?.price) throw new Error('Empty response from Yahoo');
|
||||
return DataMapper.mapToStandardFormat(ticker, summary);
|
||||
} catch (err) {
|
||||
return { isError: true, ticker: ticker.toUpperCase(), message: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
private _process(
|
||||
data: MappedData | ErrorResult,
|
||||
marketContext: MarketContext,
|
||||
results: Omit<ScreenerResult, 'marketContext'>,
|
||||
): void {
|
||||
if ('isError' in data && data.isError) {
|
||||
const e = data as ErrorResult;
|
||||
results.ERROR.push({ ticker: e.ticker, message: e.message });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const asset = this._buildAsset(data as MappedData);
|
||||
const fundamental = this._score(asset, marketContext, SCORE_MODE.FUNDAMENTAL);
|
||||
const inflated = this._score(asset, marketContext, SCORE_MODE.INFLATED);
|
||||
|
||||
(results[asset.type as AssetType] as unknown[]).push({
|
||||
asset,
|
||||
fundamental,
|
||||
inflated,
|
||||
signal: this._signal(fundamental.label, inflated.label),
|
||||
});
|
||||
} catch (err) {
|
||||
results.ERROR.push({
|
||||
ticker: ((data as { ticker?: string }).ticker || 'UNKNOWN').toUpperCase(),
|
||||
message: (err as Error).message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Typed scorer dispatch — instanceof narrows the asset so each scorer receives
|
||||
// its exact metrics type. No `as never` or unsafe casts required.
|
||||
private _score(
|
||||
asset: Stock | Etf | Bond,
|
||||
marketContext: MarketContext,
|
||||
mode: string,
|
||||
): ScoreResult {
|
||||
const rules = RuleMerger.getRulesForAsset(
|
||||
asset.type as AssetType,
|
||||
asset.metrics as { sector?: string },
|
||||
marketContext,
|
||||
mode,
|
||||
);
|
||||
if (asset instanceof Stock) return StockScorer.score(asset.metrics, rules);
|
||||
if (asset instanceof Etf) return EtfScorer.score(asset.metrics, rules);
|
||||
if (asset instanceof Bond) return BondScorer.score(asset.metrics, rules, marketContext);
|
||||
// TypeScript exhaustive check: all three branches are handled above.
|
||||
throw new Error('No scorer for unknown asset type');
|
||||
}
|
||||
|
||||
private _buildAsset(data: Record<string, unknown>): Stock | Etf | Bond {
|
||||
switch (((data.type as string) || ASSET_TYPE.STOCK).toUpperCase()) {
|
||||
case ASSET_TYPE.BOND:
|
||||
return new Bond(data as BondData);
|
||||
case ASSET_TYPE.ETF:
|
||||
return new Etf(data as EtfData);
|
||||
default:
|
||||
return new Stock(data as StockData);
|
||||
}
|
||||
}
|
||||
|
||||
private _signal(fundamentalLabel: string, inflatedLabel: string): Signal {
|
||||
const green = (l: string) => l.startsWith('🟢');
|
||||
const yellow = (l: string) => l.startsWith('🟡');
|
||||
if (green(fundamentalLabel)) return SIGNAL.STRONG_BUY;
|
||||
if (green(inflatedLabel) && yellow(fundamentalLabel)) return SIGNAL.MOMENTUM;
|
||||
if (green(inflatedLabel) && !green(fundamentalLabel)) return SIGNAL.SPECULATION;
|
||||
if (yellow(fundamentalLabel) || yellow(inflatedLabel)) return SIGNAL.NEUTRAL;
|
||||
return SIGNAL.AVOID;
|
||||
}
|
||||
|
||||
signalOrder(signal: Signal): number {
|
||||
return SIGNAL_ORDER[signal] ?? 5;
|
||||
}
|
||||
|
||||
getMarketContext(): Promise<MarketContext> {
|
||||
return this.benchmarkProvider.getMarketContext();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Barrel — re-exports every service so callers import from one path.
|
||||
export * from './BenchmarkProvider';
|
||||
export * from './CatalystAnalyst';
|
||||
export * from './DataMapper';
|
||||
export * from './LLMAnalyst';
|
||||
export * from './MarketRegime';
|
||||
export * from './PersonalFinanceAnalyzer';
|
||||
export * from './PortfolioAdvisor';
|
||||
export * from './RuleMerger';
|
||||
export * from './ScreenerEngine';
|
||||
Reference in New Issue
Block a user