import { getUniverseAsOf } from './universe.js'; import { getFundamentalsAsOf } from './fundamentals.js'; import { standardizeCrossSection, FACTORS } from './factors.js'; import { spearman, calculateTurnover } from './metrics.js'; // Global reference for forward returns in our testing environment // In production, this would query a price DB let globalReturnsMap = new Map(); export function setForwardReturnsMap(map) { globalReturnsMap = map; } function getForwardReturn(ticker, dateStr, horizonMonths, allDates) { // Find the index of the current date const idx = allDates.indexOf(dateStr); if (idx === -1 || idx + horizonMonths >= allDates.length) return null; // Not enough forward data let cumulativeRet = 0; // Simple sum for log returns, or compound. We'll use simple compound: (1+r1)*(1+r2) - 1 let mult = 1.0; for (let m = 0; m < horizonMonths; m++) { const nextDate = allDates[idx + m]; const r = globalReturnsMap.get(`${ticker}_${nextDate}`); if (r === undefined || r === null) return null; // missing data mult *= (1 + r); } return mult - 1.0; } function bucketIntoDeciles(rankedScores) { const deciles = Array(10).fill(null).map(() => []); const n = rankedScores.length; if (n === 0) return deciles; for (let i = 0; i < n; i++) { const d = Math.min(9, Math.floor((i / n) * 10)); deciles[d].push(rankedScores[i].ticker); } return deciles; // deciles[0] = highest scores (Top Decile), deciles[9] = lowest scores } export function runFactor(factorName, allDates, horizonMonths = 1, costPerSideBps = 5) { const results = []; let prevD10Weights = null; let prevD1Weights = null; const costPct = costPerSideBps / 10000.0; for (const date of allDates) { const members = getUniverseAsOf(date); if (!members || members.length === 0) continue; const rawScores = {}; for (const t of members) { const fund = getFundamentalsAsOf(t, date); if (!fund) continue; const score = FACTORS[factorName](fund); if (score !== null && !isNaN(score)) { rawScores[t] = score; } } // Standardize cross-sectionally const zScores = standardizeCrossSection(rawScores, false); // Sort descending (High Score = Best) const ranked = Object.keys(zScores) .map(t => ({ ticker: t, score: zScores[t] })) .sort((a, b) => b.score - a.score); if (ranked.length < 10) continue; // need enough names for deciles const deciles = bucketIntoDeciles(ranked); const d10Tickers = deciles[0]; // Top decile const d1Tickers = deciles[9]; // Bottom decile // Calculate equal-weight portfolio weights for this month const curD10Weights = {}; d10Tickers.forEach(t => curD10Weights[t] = 1.0 / d10Tickers.length); const curD1Weights = {}; d1Tickers.forEach(t => curD1Weights[t] = 1.0 / d1Tickers.length); // Calculate turnover const d10Turnover = calculateTurnover(prevD10Weights, curD10Weights); const d1Turnover = calculateTurnover(prevD1Weights, curD1Weights); // Forward returns const decileReturns = []; let allFwdReturns = []; let allZScores = []; for (let d = 0; d < 10; d++) { const decTickers = deciles[d]; let sumRet = 0; let count = 0; for (const t of decTickers) { const ret = getForwardReturn(t, date, horizonMonths, allDates); if (ret !== null) { sumRet += ret; count++; allFwdReturns.push(ret); allZScores.push(zScores[t]); } } decileReturns.push(count > 0 ? sumRet / count : 0); } if (allFwdReturns.length > 0) { const ic = spearman(allZScores, allFwdReturns); const universeMeanRet = allFwdReturns.reduce((a, b) => a + b, 0) / allFwdReturns.length; // Gross Returns const d10Gross = decileReturns[0]; const d1Gross = decileReturns[9]; // Net Returns const d10Net = d10Gross - (d10Turnover * costPct); const d1Net = d1Gross - (d1Turnover * costPct); results.push({ date, ic, decileReturns, // Gross decile returns for the staircase plot d10Gross, d1Gross, d10Net, d1Net, universeMeanRet, d10Turnover, d1Turnover }); } prevD10Weights = curD10Weights; prevD1Weights = curD1Weights; } return results; }