import type { FastifyInstance, FastifyRequest } from 'fastify'; import { ScreenerEngine } from './ScreenerEngine'; import { CatalystCache, SignalSnapshotRepository } from '../../domains/shared'; import type { DataHealth, LiveAssetResult, ScreenerResult } from '../../domains/shared'; import { screenSchema } from '../../domains/shared/types/schemas'; export class ScreenerController { constructor( private readonly engine: ScreenerEngine, private readonly catalystCache: CatalystCache, // Optional so tests and minimal setups work without a database. private readonly snapshots?: SignalSnapshotRepository, ) {} 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)); } /** 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); 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, }; } /** * 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 }; } }