Files
market_screener/server/domains/screener/screener.controller.ts
T
2026-06-09 20:12:37 -04:00

141 lines
5.1 KiB
TypeScript

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 };
}
}