210 lines
6.6 KiB
JavaScript
210 lines
6.6 KiB
JavaScript
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."
|
|
};
|
|
}
|