UI enhancemnts
This commit is contained in:
@@ -143,7 +143,7 @@ export class ScreenerEngine {
|
||||
asset,
|
||||
fundamental,
|
||||
inflated,
|
||||
signal: this.signal(fundamental.label, inflated.label),
|
||||
signal: this.signal(fundamental, inflated),
|
||||
});
|
||||
} catch (err) {
|
||||
results.ERROR.push({
|
||||
@@ -184,13 +184,13 @@ export class ScreenerEngine {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
// Signal derives from the structured verdict tier — never from label strings.
|
||||
// Rewording a display label can no longer silently corrupt signals.
|
||||
private signal(fundamental: ScoreResult, inflated: ScoreResult): Signal {
|
||||
if (fundamental.tier === 'PASS') return SIGNAL.STRONG_BUY;
|
||||
if (inflated.tier === 'PASS' && fundamental.tier === 'HOLD') return SIGNAL.MOMENTUM;
|
||||
if (inflated.tier === 'PASS') return SIGNAL.SPECULATION;
|
||||
if (fundamental.tier === 'HOLD' || inflated.tier === 'HOLD') return SIGNAL.NEUTRAL;
|
||||
return SIGNAL.AVOID;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ export class BondScorer {
|
||||
if (metrics.creditRatingNumeric < gates.minCreditRating) {
|
||||
return {
|
||||
label: '🔴 REJECT',
|
||||
tier: 'REJECT',
|
||||
score: null,
|
||||
scoreSummary: `Credit rating gate failed: ${metrics.creditRating} (${metrics.creditRatingNumeric}) < ${gates.minCreditRating}`,
|
||||
audit: {
|
||||
passedGates: false,
|
||||
@@ -42,6 +44,8 @@ export class BondScorer {
|
||||
|
||||
return {
|
||||
label: score >= 4 ? '🟢 Attractive' : score >= 1 ? '🟡 Neutral' : '🔴 Avoid',
|
||||
tier: score >= 4 ? 'PASS' : score >= 1 ? 'HOLD' : 'REJECT',
|
||||
score,
|
||||
scoreSummary: `Score: ${score}`,
|
||||
audit: { passedGates: true, breakdown },
|
||||
};
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import type { EtfMetrics, ScoreResult } from '../../../domains/shared';
|
||||
|
||||
export class EtfScorer {
|
||||
/** Parse to a finite number, preserving null for missing data. */
|
||||
private static n(v: unknown): number | null {
|
||||
if (v == null) return null;
|
||||
const f = parseFloat(String(v));
|
||||
return Number.isFinite(f) ? f : null;
|
||||
}
|
||||
|
||||
static score(
|
||||
m: EtfMetrics,
|
||||
rules: {
|
||||
@@ -11,51 +18,77 @@ export class EtfScorer {
|
||||
): ScoreResult {
|
||||
const { gates, weights, thresholds } = rules;
|
||||
const metrics = {
|
||||
expenseRatio: parseFloat(String(m.expenseRatio)) || 0,
|
||||
yield: parseFloat(String(m.yield)) || 0,
|
||||
volume: parseFloat(String(m.volume)) || 0,
|
||||
fiveYearReturn: parseFloat(String(m.fiveYearReturn)) || 0,
|
||||
expenseRatio: EtfScorer.n(m.expenseRatio),
|
||||
yield: EtfScorer.n(m.yield),
|
||||
volume: EtfScorer.n(m.volume),
|
||||
fiveYearReturn: EtfScorer.n(m.fiveYearReturn),
|
||||
};
|
||||
|
||||
// Gates are only checked when the underlying data exists — missing data
|
||||
// skips the gate (same convention as StockScorer) instead of auto-failing.
|
||||
const failures: string[] = [];
|
||||
if (metrics.expenseRatio > gates.maxExpenseRatio) {
|
||||
if (metrics.expenseRatio != null && metrics.expenseRatio > gates.maxExpenseRatio) {
|
||||
failures.push(`Expense ratio: ${metrics.expenseRatio} > ${gates.maxExpenseRatio}`);
|
||||
}
|
||||
if (
|
||||
metrics.fiveYearReturn != null &&
|
||||
thresholds.minFiveYearReturn != null &&
|
||||
metrics.fiveYearReturn < thresholds.minFiveYearReturn
|
||||
) {
|
||||
failures.push(`5-year return: ${metrics.fiveYearReturn}% < ${thresholds.minFiveYearReturn}%`);
|
||||
}
|
||||
if (thresholds.minVolume != null && metrics.volume < thresholds.minVolume) {
|
||||
if (
|
||||
metrics.volume != null &&
|
||||
thresholds.minVolume != null &&
|
||||
metrics.volume < thresholds.minVolume
|
||||
) {
|
||||
failures.push(`Volume: ${metrics.volume} < ${thresholds.minVolume}`);
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
return {
|
||||
label: '🔴 REJECT',
|
||||
tier: 'REJECT',
|
||||
score: null,
|
||||
scoreSummary: `Gate failed: ${failures.map((f) => f.split(':')[0]).join(', ')}`,
|
||||
audit: { passedGates: false, failures },
|
||||
};
|
||||
}
|
||||
|
||||
const breakdown: Record<string, number> = {
|
||||
cost: metrics.expenseRatio <= thresholds.maxExpense ? weights.lowCost : -3,
|
||||
yield: metrics.yield >= thresholds.minYield ? weights.yield : -1,
|
||||
vol: metrics.volume >= (thresholds.minVolume ?? 1_000_000) ? 0 : -2,
|
||||
fiveYearReturn:
|
||||
thresholds.minFiveYearReturn != null
|
||||
? metrics.fiveYearReturn >= thresholds.minFiveYearReturn
|
||||
? (weights.fiveYearReturn ?? 1)
|
||||
: -1
|
||||
: 0,
|
||||
};
|
||||
// Factors only fire when the underlying data exists.
|
||||
const breakdown: Record<string, number> = {};
|
||||
if (metrics.expenseRatio != null) {
|
||||
breakdown.cost = metrics.expenseRatio <= thresholds.maxExpense ? weights.lowCost : -3;
|
||||
}
|
||||
if (metrics.yield != null) {
|
||||
breakdown.yield = metrics.yield >= thresholds.minYield ? weights.yield : -1;
|
||||
}
|
||||
if (metrics.volume != null) {
|
||||
breakdown.vol = metrics.volume >= (thresholds.minVolume ?? 1_000_000) ? 0 : -2;
|
||||
}
|
||||
if (metrics.fiveYearReturn != null && thresholds.minFiveYearReturn != null) {
|
||||
breakdown.fiveYearReturn =
|
||||
metrics.fiveYearReturn >= thresholds.minFiveYearReturn ? (weights.fiveYearReturn ?? 1) : -1;
|
||||
}
|
||||
|
||||
const activeFactors = Object.keys(breakdown).length;
|
||||
const score = Object.values(breakdown).reduce((a, b) => a + b, 0);
|
||||
|
||||
if (activeFactors === 0) {
|
||||
return {
|
||||
label: '🟡 Neutral (No Data)',
|
||||
tier: 'HOLD',
|
||||
score: 0,
|
||||
scoreSummary: 'Score: 0 (no metrics available)',
|
||||
audit: { passedGates: true, breakdown, coverage: { active: 0, total: 4 } },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: score >= 3 ? '🟢 Efficient' : score >= 0 ? '🟡 Neutral' : '🔴 Expensive/Low Yield',
|
||||
tier: score >= 3 ? 'PASS' : score >= 0 ? 'HOLD' : 'REJECT',
|
||||
score,
|
||||
scoreSummary: `Score: ${score}`,
|
||||
audit: { passedGates: true, breakdown },
|
||||
audit: { passedGates: true, breakdown, coverage: { active: activeFactors, total: 4 } },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
import type { NumVal, SanitizedMetrics, ScoreResult, StockMetrics } from '../../../domains/shared';
|
||||
|
||||
export class StockScorer {
|
||||
/**
|
||||
* Parse to a finite number, preserving 0 — zero is a real value for metrics
|
||||
* like revenueGrowth (stagnant), debtToEquity (debt-free), or
|
||||
* dcfMarginOfSafety (exactly fair value).
|
||||
*/
|
||||
private static n(v: unknown): NumVal {
|
||||
if (v == null) return null;
|
||||
const f = parseFloat(String(v));
|
||||
return !isNaN(f) && f !== 0 ? f : null;
|
||||
return Number.isFinite(f) ? f : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse to a strictly positive number. Used for ratios where 0 is
|
||||
* impossible and indicates junk/missing data (P/E, PEG, P/B, P/FFO).
|
||||
*/
|
||||
private static pos(v: unknown): NumVal {
|
||||
const f = StockScorer.n(v);
|
||||
return f != null && f > 0 ? f : null;
|
||||
}
|
||||
|
||||
private static scoreValue(val: number, high: number, med: number, weight: number): number {
|
||||
@@ -46,6 +61,8 @@ export class StockScorer {
|
||||
if (failures.length > 0) {
|
||||
return {
|
||||
label: '🔴 REJECT',
|
||||
tier: 'REJECT',
|
||||
score: null,
|
||||
scoreSummary: `Gate failed: ${failures.join(' | ')}`,
|
||||
audit: { passedGates: false, failures },
|
||||
};
|
||||
@@ -172,6 +189,8 @@ export class StockScorer {
|
||||
breakdown[f.key] = f.fn() as number;
|
||||
return sum + breakdown[f.key];
|
||||
}, 0);
|
||||
const activeFactors = Object.keys(breakdown).length;
|
||||
const coverage = { active: activeFactors, total: factors.length };
|
||||
|
||||
const riskFlags = [
|
||||
m.beta != null && m.beta > 1.5 && `High volatility (β ${m.beta.toFixed(2)})`,
|
||||
@@ -207,10 +226,34 @@ export class StockScorer {
|
||||
`DCF: stock trading ${Math.abs(m.dcfMarginOfSafety).toFixed(0)}% above intrinsic value`,
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
// No factor had data — distinguish "insufficient data" from a genuine
|
||||
// neutral score so the UI doesn't present an unknown as a Hold verdict.
|
||||
if (activeFactors === 0) {
|
||||
return {
|
||||
label: '🟡 HOLD (No Data)',
|
||||
tier: 'HOLD',
|
||||
score: 0,
|
||||
scoreSummary: 'Score: 0 (no scoring factors had data)',
|
||||
audit: {
|
||||
passedGates: true,
|
||||
breakdown,
|
||||
riskFlags: riskFlags.length ? riskFlags : null,
|
||||
coverage,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: StockScorer.label(totalScore),
|
||||
tier: StockScorer.tier(totalScore),
|
||||
score: totalScore,
|
||||
scoreSummary: `Score: ${totalScore}`,
|
||||
audit: { passedGates: true, breakdown, riskFlags: riskFlags.length ? riskFlags : null },
|
||||
audit: {
|
||||
passedGates: true,
|
||||
breakdown,
|
||||
riskFlags: riskFlags.length ? riskFlags : null,
|
||||
coverage,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -221,6 +264,12 @@ export class StockScorer {
|
||||
return '🔴 REJECT';
|
||||
}
|
||||
|
||||
private static tier(score: number): 'PASS' | 'HOLD' | 'REJECT' {
|
||||
if (score >= 4) return 'PASS';
|
||||
if (score >= 0) return 'HOLD';
|
||||
return 'REJECT';
|
||||
}
|
||||
|
||||
private static sanitize(m: StockMetrics): SanitizedMetrics {
|
||||
const w52 =
|
||||
m.week52High != null && m.week52High > 0 && m.week52Low != null && m.currentPrice > 0
|
||||
@@ -229,16 +278,16 @@ export class StockScorer {
|
||||
return {
|
||||
debtToEquity: StockScorer.n(m.debtToEquity),
|
||||
quickRatio: StockScorer.n(m.quickRatio),
|
||||
peRatio: StockScorer.n(m.peRatio),
|
||||
pegRatio: StockScorer.n(m.pegRatio),
|
||||
priceToBook: StockScorer.n(m.priceToBook),
|
||||
peRatio: StockScorer.pos(m.peRatio),
|
||||
pegRatio: StockScorer.pos(m.pegRatio),
|
||||
priceToBook: StockScorer.pos(m.priceToBook),
|
||||
netProfitMargin: StockScorer.n(m.netProfitMargin),
|
||||
operatingMargin: StockScorer.n(m.operatingMargin),
|
||||
returnOnEquity: StockScorer.n(m.returnOnEquity),
|
||||
revenueGrowth: StockScorer.n(m.revenueGrowth),
|
||||
fcfYield: StockScorer.n(m.fcfYield),
|
||||
dividendYield: StockScorer.n(m.dividendYield),
|
||||
pFFO: StockScorer.n(m.pFFO),
|
||||
pFFO: StockScorer.pos(m.pFFO),
|
||||
beta: StockScorer.n(m.beta),
|
||||
week52Position: w52,
|
||||
week52Change: StockScorer.n(m.week52Change),
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { ScreenerEngine } from './ScreenerEngine';
|
||||
import { CatalystCache } from '../../domains/shared';
|
||||
import type { LiveAssetResult } from '../../domains/shared';
|
||||
import { CatalystCache, SignalSnapshotRepository } from '../../domains/shared';
|
||||
import type { 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 {
|
||||
@@ -21,6 +23,29 @@ export class ScreenerController {
|
||||
{ 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[]) {
|
||||
@@ -39,6 +64,7 @@ export class ScreenerController {
|
||||
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);
|
||||
return {
|
||||
...results,
|
||||
STOCK: ScreenerController.serializeAssets(results.STOCK as LiveAssetResult[]),
|
||||
@@ -47,6 +73,29 @@ export class ScreenerController {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
|
||||
@@ -7,14 +7,20 @@ 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;
|
||||
// Prefer fundProfile.categoryName (Morningstar category, e.g. "Intermediate
|
||||
// Core Bond") — assetProfile.category is rarely populated for ETFs. A
|
||||
// dividend-yield heuristic is deliberately NOT used: high-yield equity ETFs
|
||||
// (SCHD, VYM) are not bonds.
|
||||
const category = (
|
||||
(summary.fundProfile?.categoryName as string) ||
|
||||
(summary.assetProfile?.category as string) ||
|
||||
''
|
||||
).toLowerCase();
|
||||
|
||||
const isBond =
|
||||
category.includes('bond') ||
|
||||
category.includes('fixed income') ||
|
||||
category.includes('treasury') ||
|
||||
(quoteType === 'ETF' && yieldVal > 0.02 && category === '');
|
||||
category.includes('treasury');
|
||||
|
||||
if (quoteType === 'ETF') {
|
||||
return isBond
|
||||
@@ -143,17 +149,23 @@ export class DataMapper {
|
||||
}
|
||||
|
||||
// ── ETF ───────────────────────────────────────────────────────────────────
|
||||
// Missing fields are preserved as null (not coerced to 0) so EtfScorer can
|
||||
// skip the corresponding gate instead of auto-failing on absent Yahoo data.
|
||||
private static mapEtfData(summary: YahooSummary) {
|
||||
const num = (v: unknown): number | null =>
|
||||
typeof v === 'number' && Number.isFinite(v) ? v : null;
|
||||
|
||||
const expenseRatio = num(summary.summaryDetail?.expenseRatio);
|
||||
const dividendYield = num(summary.summaryDetail?.trailingAnnualDividendYield);
|
||||
const fiveYearReturn = num(summary.defaultKeyStatistics?.fiveYearAverageReturn);
|
||||
|
||||
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,
|
||||
expenseRatio: expenseRatio != null ? expenseRatio * 100 : null,
|
||||
totalAssets: num(summary.summaryDetail?.totalAssets),
|
||||
yield: dividendYield != null ? dividendYield * 100 : null,
|
||||
fiveYearReturn: fiveYearReturn != null ? fiveYearReturn * 100 : null,
|
||||
volume: num(summary.summaryDetail?.averageVolume) ?? num(summary.price?.averageVolume),
|
||||
currentPrice: num(summary.price?.regularMarketPrice) ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user