141 lines
5.1 KiB
TypeScript
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 };
|
|
}
|
|
}
|