143 lines
4.3 KiB
JavaScript
143 lines
4.3 KiB
JavaScript
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;
|
|
}
|