diff --git a/backend/src/factorlab/descriptiveLens.js b/backend/src/factorlab/descriptiveLens.js index f968cb3..0294dd5 100644 --- a/backend/src/factorlab/descriptiveLens.js +++ b/backend/src/factorlab/descriptiveLens.js @@ -43,6 +43,10 @@ function assignPercentiles(raw, factorKey) { * Generates the descriptive Factor Profile for the entire universe based on CURRENT data. * Does NOT generate forward predictions. */ +let _cachedRawProfiles = null; +let _cachedZScoreMaps = null; +let _cachedRawTime = 0; + export async function buildFactorProfiles(injectedUniverseRecords = null) { const rawProfiles = []; @@ -107,6 +111,13 @@ export async function buildFactorProfiles(injectedUniverseRecords = null) { // Cross-sectional standardization and percentile assignment [...factors, 'composite'].forEach(f => assignPercentiles(rawProfiles, f)); + // Cache the raw profiles for single-stock comparisons + if (!injectedUniverseRecords) { + _cachedRawProfiles = rawProfiles; + _cachedZScoreMaps = zScoreMaps; + _cachedRawTime = Date.now(); + } + // If this is a test injection, return the raw profiles with percentiles for testing if (injectedUniverseRecords) return rawProfiles.reduce((acc, p) => { acc[p.ticker] = p; return acc; }, {}); @@ -123,3 +134,76 @@ export async function buildFactorProfiles(injectedUniverseRecords = null) { return cleanedProfiles; } + +/** + * Computes a single stock's factor profile relative to the cached universe. + */ +export async function getSingleFactorProfile(symbol) { + if (!_cachedRawProfiles || (Date.now() - _cachedRawTime > 120000)) { + await buildFactorProfiles(); + } + + // If it's already in the universe, just return it + const existing = _cachedRawProfiles.find(p => p.ticker === symbol); + if (existing) { + return { + ticker: existing.ticker, + value: existing.valuePercentile, + quality: existing.qualityPercentile, + lowVol: existing.lowVolPercentile, + momentum: existing.momentumPercentile, + composite: existing.compositePercentile, + _disclaimer: "Descriptive only. Shows where this stock ranks vs. the core universe." + }; + } + + // Fetch out-of-universe data + const data = await pullRawRecord(symbol); + if (!data) return null; + + const raw = { + ticker: symbol, + value: FACTORS.value(data), + quality: FACTORS.quality(data), + lowVol: FACTORS.lowVol(data), + momentum: FACTORS.momentum(data) + }; + + // Standardize single value using universe mean/std from zScoreMaps... wait, standardizer only gives cross-sectional z-scores. + // We can just re-run standardizer with the universe + this one stock. + const tempRaw = [..._cachedRawProfiles, raw]; + + const factors = ['value', 'quality', 'lowVol', 'momentum']; + const zScoreMaps = {}; + + for (const f of factors) { + const rawByTicker = {}; + for (const r of tempRaw) { + if (r[f] !== null && r[f] !== undefined) rawByTicker[r.ticker] = r[f]; + } + zScoreMaps[f] = standardizeCrossSection(rawByTicker, false); + } + + let sum = 0; + let count = 0; + for (const f of factors) { + const z = zScoreMaps[f][raw.ticker]; + if (z !== undefined) { + sum += z; + count++; + } + } + raw.composite = count > 0 ? (sum / count) : null; + + [...factors, 'composite'].forEach(f => assignPercentiles(tempRaw, f)); + + return { + ticker: raw.ticker, + value: raw.valuePercentile, + quality: raw.qualityPercentile, + lowVol: raw.lowVolPercentile, + momentum: raw.momentumPercentile, + composite: raw.compositePercentile, + _disclaimer: "Descriptive only. Shows where this out-of-universe stock ranks vs. the core universe today." + }; +} diff --git a/backend/src/routes/marketAnalysis.js b/backend/src/routes/marketAnalysis.js index f11fe25..ed61bf4 100644 --- a/backend/src/routes/marketAnalysis.js +++ b/backend/src/routes/marketAnalysis.js @@ -102,12 +102,15 @@ router.get('/stock/:symbol', async (req, res) => { const meta = getSymbolMeta(symbol); const analysis = await fetchFullAnalysis(symbol); + const profile = await getSingleFactorProfile(symbol); + res.json({ success: true, data: { ...analysis, meta, capTier: classifyCapTier(analysis.quote?.market_cap ?? null, meta?.isETF ?? false), + profile, } }); } catch (err) { @@ -214,7 +217,7 @@ router.get('/leaderboard', async (req, res) => { } }); -import { buildFactorProfiles } from '../factorlab/descriptiveLens.js'; +import { buildFactorProfiles, getSingleFactorProfile } from '../factorlab/descriptiveLens.js'; /** * GET /api/market/screener @@ -270,9 +273,11 @@ router.get('/search/:symbol', async (req, res) => { meta?.isETF ?? false ); + const profile = await getSingleFactorProfile(symbol); + res.json({ success: true, - data: { ...analysis, meta, capTier, suitability }, + data: { ...analysis, meta, capTier, suitability, profile }, }); } catch (err) { console.error(`[market/search/${req.params.symbol}]`, err); diff --git a/frontend/src/components/dashboard/StockDetailPanel.jsx b/frontend/src/components/dashboard/StockDetailPanel.jsx index 4f04c03..33a7341 100644 --- a/frontend/src/components/dashboard/StockDetailPanel.jsx +++ b/frontend/src/components/dashboard/StockDetailPanel.jsx @@ -55,6 +55,73 @@ function DirectionArrow({ dir }) { return —; } +function pctBar(pct) { + if (pct == null) return Missing; + + let colorClass = 'bg-yellow-500'; // middle 50% + if (pct >= 75) colorClass = 'bg-emerald-500'; // top 25% + else if (pct < 25) colorClass = 'bg-red-500'; // bottom 25% + + return ( +