import type { FastifyInstance, FastifyRequest } from 'fastify'; import { ScreenerEngine } from './ScreenerEngine'; import { CatalystCache, SignalSnapshotRepository, YahooFinanceClient } from '../../domains/shared'; import type { DataHealth, LiveAssetResult, ScreenerResult } from '../../domains/shared'; import type { NewsRepository } from '../news/NewsRepository'; import { screenSchema } from '../../domains/shared/types/schemas'; export class ScreenerController { /** Company profiles change rarely — cache for an hour. */ private static readonly PROFILE_TTL_MS = 60 * 60 * 1000; private profileCache = new Map(); /** Sector pulse — SPDR sector ETFs as the standard proxy, cached 15 min. */ private static readonly SECTOR_TTL_MS = 15 * 60 * 1000; private static readonly SECTOR_ETFS: Array<{ etf: string; sector: string; name: string }> = [ { etf: 'XLK', sector: 'TECHNOLOGY', name: 'Technology' }, { etf: 'XLF', sector: 'FINANCIAL', name: 'Financials' }, { etf: 'XLE', sector: 'ENERGY', name: 'Energy' }, { etf: 'XLV', sector: 'HEALTHCARE', name: 'Healthcare' }, { etf: 'XLC', sector: 'COMMUNICATION', name: 'Communication' }, { etf: 'XLP', sector: 'CONSUMER_STAPLES', name: 'Staples' }, { etf: 'XLY', sector: 'CONSUMER_DISCRETIONARY', name: 'Discretionary' }, { etf: 'XLRE', sector: 'REIT', name: 'Real Estate' }, { etf: 'XLI', sector: 'GENERAL', name: 'Industrials' }, { etf: 'XLU', sector: 'GENERAL', name: 'Utilities' }, ]; private sectorCache: { data: unknown; expiresAt: number } | null = null; /** Sector drill-down (holdings + screen + news) — cached 30 min per sector. */ private static readonly SECTOR_DETAIL_TTL_MS = 30 * 60 * 1000; private sectorDetailCache = new Map(); constructor( private readonly engine: ScreenerEngine, private readonly catalystCache: CatalystCache, // Optional so tests and minimal setups work without a database. private readonly snapshots?: SignalSnapshotRepository, private readonly yahoo?: YahooFinanceClient, private readonly news?: NewsRepository, ) {} 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), ); app.get('/api/screen/history/:ticker', this.history.bind(this)); app.get('/api/screen/profile/:ticker', this.profile.bind(this)); app.get('/api/screen/chart/:ticker', this.chart.bind(this)); app.get('/api/screen/sectors', this.sectors.bind(this)); app.get('/api/screen/sector/:sector', this.sectorDetail.bind(this)); } /** * Sector drill-down: the sector ETF's top 10 holdings, freshly screened * (signal + advice-ready rows), plus recent news for those tickers and * macro stories — "what's in this sector and why is it moving". */ private async sectorDetail(req: FastifyRequest) { const sector = (req.params as { sector: string }).sector.toUpperCase(); const entry = ScreenerController.SECTOR_ETFS.find((s) => s.sector === sector); if (!entry || !this.yahoo) return { sector, etf: null, stocks: [], news: [] }; const cached = this.sectorDetailCache.get(sector); if (cached && Date.now() < cached.expiresAt) return cached.data; const holdings = await this.yahoo.fetchTopHoldings(entry.etf, 10); const results = holdings.length > 0 ? await this.engine.screenTickers(holdings) : null; const stocks = results ? ScreenerController.serializeAssets(results.STOCK as LiveAssetResult[]) : []; // News: stored stories for these tickers (last 3 days), deduped by URL const newsSince = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); const byUrl = new Map(); if (this.news) { for (const ticker of holdings) { for (const row of this.news.newsForTicker(ticker, newsSince)) { byUrl.set(row.url, { headline: row.headline, tickers: JSON.parse(row.ticker_list), source: row.source, catalyst: row.catalyst, url: row.url, publishedAt: row.published_at, }); } } } const data = { sector, etf: entry.etf, name: entry.name, stocks, news: [...byUrl.values()], }; this.sectorDetailCache.set(sector, { data, expiresAt: Date.now() + ScreenerController.SECTOR_DETAIL_TTL_MS, }); return data; } /** * Sector pulse — today's % change per sector via SPDR sector ETFs (the * standard proxy). Returns sectors sorted best→worst plus the leader. */ private async sectors() { if (this.sectorCache && Date.now() < this.sectorCache.expiresAt) { return this.sectorCache.data; } if (!this.yahoo) return { asOf: null, leader: null, sectors: [] }; const results = await Promise.all( ScreenerController.SECTOR_ETFS.map(async ({ etf, sector, name }) => { try { const summary = await this.yahoo!.fetchSummary(etf); const pr = summary?.price ?? {}; const price = pr.regularMarketPrice ?? null; const prev = pr.regularMarketPreviousClose ?? null; const changePct = price != null && prev != null && prev > 0 ? +(((price - prev) / prev) * 100).toFixed(2) : null; return { etf, sector, name, changePct }; } catch { return { etf, sector, name, changePct: null }; } }), ); const sectors = results .filter((s) => s.changePct != null) .sort((a, b) => (b.changePct as number) - (a.changePct as number)); const data = { asOf: new Date().toISOString(), leader: sectors[0] ?? null, sectors, }; this.sectorCache = { data, expiresAt: Date.now() + ScreenerController.SECTOR_TTL_MS }; return data; } /** Company profile for the ticker modal — name, description, sector. */ private async profile(req: FastifyRequest) { const ticker = (req.params as { ticker: string }).ticker.toUpperCase(); if (!this.yahoo) return { ticker, profile: null }; const cached = this.profileCache.get(ticker); if (cached && Date.now() < cached.expiresAt) return cached.data; try { const summary = await this.yahoo.fetchSummary(ticker); const ap = summary?.assetProfile ?? {}; const pr = summary?.price ?? {}; const fd = summary?.financialData ?? {}; const price = pr.regularMarketPrice ?? null; const targetMean = fd.targetMeanPrice ?? null; const data = { ticker, profile: { name: pr.longName ?? pr.shortName ?? ticker, summary: ap.longBusinessSummary ?? null, sector: ap.sector ?? null, industry: ap.industry ?? null, website: ap.website ?? null, employees: ap.fullTimeEmployees ?? null, marketCap: pr.marketCap ?? null, currentPrice: price, // Analyst price targets (Yahoo sell-side consensus) targets: { mean: targetMean, high: fd.targetHighPrice ?? null, low: fd.targetLowPrice ?? null, analysts: fd.numberOfAnalystOpinions ?? null, recommendationMean: fd.recommendationMean ?? null, // 1=Strong Buy … 5=Strong Sell upsidePct: targetMean != null && price != null && price > 0 ? +(((targetMean - price) / price) * 100).toFixed(1) : null, }, }, }; this.profileCache.set(ticker, { data, expiresAt: Date.now() + ScreenerController.PROFILE_TTL_MS, }); return data; } catch { return { ticker, profile: null }; } } /** Closes for the ticker modal chart. ?range=1d|5d|1mo|3mo|6mo|1y. */ private async chart(req: FastifyRequest) { const ticker = (req.params as { ticker: string }).ticker.toUpperCase(); const raw = (req.query as { range?: string }).range ?? '6mo'; const range = raw in YahooFinanceClient.CHART_RANGES ? raw : '6mo'; if (!this.yahoo) return { ticker, range, points: [] }; return { ticker, range, points: await this.yahoo.fetchCloses(ticker, range) }; } /** Signal snapshot history for one ticker (P0.1 ledger read side). */ private async history(req: FastifyRequest) { if (!this.snapshots) return { ticker: null, snapshots: [] }; const { ticker } = req.params as { ticker: string }; return { ticker: ticker.toUpperCase(), snapshots: this.snapshots.history(ticker).map((row) => ({ date: row.snapshot_date, signal: row.signal, price: row.price, fundamental: { tier: row.fundamental_tier, score: row.fundamental_score }, inflated: { tier: row.inflated_tier, score: row.inflated_score }, coverage: row.coverage_active != null ? { active: row.coverage_active, total: row.coverage_total } : null, riskFlags: row.risk_flags ? JSON.parse(row.risk_flags) : [], rateRegime: row.rate_regime, })), }; } 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); this.recordSnapshots(results, req); this.flagTurnarounds(results); const dataHealth = ScreenerController.assessDataHealth(results); if (dataHealth.degraded) { req.log?.warn?.({ dataHealth }, 'screen batch returned degraded fundamentals data'); } return { ...results, STOCK: ScreenerController.serializeAssets(results.STOCK as LiveAssetResult[]), ETF: ScreenerController.serializeAssets(results.ETF as LiveAssetResult[]), BOND: ScreenerController.serializeAssets(results.BOND as LiveAssetResult[]), dataHealth, }; } /** * Turnaround-watch (candidate flag, NOT a prediction): the stock's style is * already Turnaround (earnings down, revenue holding) AND its fundamental * score improved vs the previous snapshot in the ledger. Both legs must * hold — style alone is static, improvement alone is noise. */ private flagTurnarounds(results: ScreenerResult): void { if (!this.snapshots) return; for (const row of results.STOCK as LiveAssetResult[]) { const metrics = row.asset.metrics as { growthCategory?: string }; if (metrics?.growthCategory !== 'Turnaround') continue; if (row.fundamental.tier === 'REJECT' || row.fundamental.score == null) continue; try { // History includes today's snapshot (recorded just above) — compare // today's score against the most recent prior day with a score. const history = this.snapshots.history(row.asset.ticker); const prior = [...history] .reverse() .find((h) => h.snapshot_date < history[history.length - 1]?.snapshot_date); if (prior?.fundamental_score != null && row.fundamental.score > prior.fundamental_score) { row.turnaroundWatch = true; } } catch { // best-effort — never fail the screen for a highlight } } } /** * P0.4 data-sanity sentinel — if a large share of screened stocks come back * with null core fundamentals (P/E, ROE), the upstream source has likely * changed schema or is throttling. Surface it loudly instead of letting * everything silently degrade to "No Data" rows. */ private static assessDataHealth(results: ScreenerResult): DataHealth { const THRESHOLD = 0.3; // >30% nulls = degraded const MIN_SAMPLE = 3; // don't alarm on tiny batches const stocks = results.STOCK as LiveAssetResult[]; const metrics = stocks.map( (r) => r.asset.metrics as { peRatio?: number | null; returnOnEquity?: number | null }, ); const nullPeRatio = metrics.filter((m) => m.peRatio == null).length; const nullRoe = metrics.filter((m) => m.returnOnEquity == null).length; const total = metrics.length; const degraded = total >= MIN_SAMPLE && (nullPeRatio / total > THRESHOLD || nullRoe / total > THRESHOLD); return { degraded, stocksChecked: total, nullPeRatio, nullRoe, message: degraded ? `${Math.max(nullPeRatio, nullRoe)} of ${total} stocks returned no core fundamentals — data source may be degraded; treat this screen with caution` : null, }; } /** * P0.1 signal track record — persist one snapshot per asset per day. * Best-effort: a snapshot failure must never fail the screen response. */ private recordSnapshots(results: ScreenerResult, req: FastifyRequest): void { if (!this.snapshots) return; try { const rateRegime = results.marketContext?.rateRegime ?? null; const inputs = [...results.STOCK, ...results.ETF, ...results.BOND].map((r) => ({ ticker: r.asset.ticker, assetType: r.asset.type, price: r.asset.currentPrice ?? null, signal: r.signal, fundamental: r.fundamental, inflated: r.inflated, rateRegime, })); this.snapshots.recordBatch(inputs); } catch (err) { req.log?.warn?.({ err }, 'signal snapshot recording failed'); } } private async catalysts() { const { tickers, stories } = await this.catalystCache.get(); return { tickers, stories }; } }