institutional-trader/backend/src/factorlab/crossSection.js

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;
}