UI enhancemnts

This commit is contained in:
Kazuma
2026-06-09 19:34:31 -04:00
parent fbadd7fb6e
commit 5655cde6bf
55 changed files with 6226 additions and 465 deletions
+8 -8
View File
@@ -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 },
};
+51 -18
View File
@@ -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 } },
};
}
}
+55 -6
View File
@@ -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),
+51 -2
View File
@@ -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 };
+25 -13
View File
@@ -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,
};
}