import { FACTORS, standardizeCrossSection } from './factors.js'; import { pullRawRecord } from './ingest.js'; import { getUniverse } from '../services/stockUniverseService.js'; /** * Assigns percentiles (0-100) to a raw factor array using the standardizer. * @param {Array} raw - Array of objects: { ticker, value: 0.5, quality: 1.2 } * @param {String} factorKey - The factor to assign percentiles for (e.g. 'value') */ function assignPercentiles(raw, factorKey) { const rawByTicker = {}; for (const r of raw) { if (r[factorKey] !== null && r[factorKey] !== undefined) { rawByTicker[r.ticker] = r[factorKey]; } } // Uses winsorization and z-scoring from factors.js const zScores = standardizeCrossSection(rawByTicker, false); // Convert z-scores to percentiles relative to the universe const validTickers = Object.keys(zScores); const sortedZScores = validTickers.map(t => zScores[t]).sort((a, b) => a - b); const n = sortedZScores.length; for (const r of raw) { const z = zScores[r.ticker]; if (z === undefined) { r[`${factorKey}Percentile`] = null; continue; } // Find percentile rank let index = sortedZScores.findIndex(val => val >= z); if (index === -1) index = n - 1; r[`${factorKey}Percentile`] = Math.round((index / n) * 100); } } /** * 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 = []; // Allow test injection if (injectedUniverseRecords) { for (const data of injectedUniverseRecords) { rawProfiles.push({ ticker: data.ticker || 'TEST', value: FACTORS.value(data), quality: FACTORS.quality(data), lowVol: FACTORS.lowVol(data), momentum: FACTORS.momentum(data) }); } } else { const universe = getUniverse().filter(s => !s.isETF).map(s => s.symbol); for (const ticker of universe) { try { const data = await pullRawRecord(ticker); if (!data) continue; rawProfiles.push({ ticker, value: FACTORS.value(data), quality: FACTORS.quality(data), lowVol: FACTORS.lowVol(data), momentum: FACTORS.momentum(data) }); } catch (e) { console.warn(`[DescriptiveLens] Failed for ${ticker}`); } } } // Composite is the equal-weight average of the non-null standardized z-scores // First, we must standardize the individual factors to compute composite z-score const factors = ['value', 'quality', 'lowVol', 'momentum']; const zScoreMaps = {}; for (const f of factors) { const rawByTicker = {}; for (const r of rawProfiles) { if (r[f] !== null && r[f] !== undefined) rawByTicker[r.ticker] = r[f]; } zScoreMaps[f] = standardizeCrossSection(rawByTicker, false); } // Compute composite raw score for (const r of rawProfiles) { let sum = 0; let count = 0; for (const f of factors) { const z = zScoreMaps[f][r.ticker]; if (z !== undefined) { sum += z; count++; } } r.composite = count > 0 ? (sum / count) : 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; }, {}); // Clean up the raw scores, we only return the percentiles for the UI const cleanedProfiles = rawProfiles.map(p => ({ ticker: p.ticker, value: p.valuePercentile, quality: p.qualityPercentile, lowVol: p.lowVolPercentile, momentum: p.momentumPercentile, composite: p.compositePercentile, _disclaimer: "Descriptive only. Shows where this stock ranks vs. peers on each factor today. We have not validated that these ranks predict returns — and our own testing showed standard technical signals do not. We'll only call a factor predictive after it passes out-of-sample validation." })); 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." }; }