Files

687 lines
23 KiB
TypeScript

import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
interface PortfolioAsset {
ticker: string;
shares: number;
entryPrice: number;
}
// Helper to handle fetch timeouts
async function fetchWithTimeout(url: string, options: RequestInit = {}, timeoutMs = 5000): Promise<Response> {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
...options,
signal: controller.signal
});
clearTimeout(id);
return response;
} catch (error) {
clearTimeout(id);
throw error;
}
}
// Simple date offset helper
function getOffsetDate(dateStr: string, offsetDays: number): string {
const d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr;
d.setDate(d.getDate() + offsetDays);
return d.toISOString().split('T')[0];
}
// Deterministic LCG random generator based on a seed
function createRandom(seed: number) {
let s = seed;
return function() {
const x = Math.sin(s++) * 10000;
return x - Math.floor(x);
};
}
// Fallback base prices for mock asset curves
const BASE_PRICES: Record<string, number> = {
'AAPL': 180.0,
'MSFT': 400.0,
'NVDA': 920.0,
'BTC-USD': 62000.0,
'ETH-USD': 3300.0,
'SOL-USD': 140.0,
'VIX': 16.0,
'^VIX': 16.0,
'^IXIC': 16000.0 // NASDAQ
};
// Generates a deterministic historical price curve from a seed
function getDeterministicPrices(ticker: string, fromDateStr: string, toDateStr: string) {
const basePrice = BASE_PRICES[ticker] || 100.0;
const prices: { date: string; close: number }[] = [];
const start = new Date(fromDateStr);
const end = new Date(toDateStr);
let hash = 0;
for (let i = 0; i < ticker.length; i++) {
hash += ticker.charCodeAt(i) * (i + 1);
}
const random = createRandom(hash);
let currentPrice = basePrice;
const totalDays = Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
const d = new Date(start);
for (let i = 0; i <= totalDays; i++) {
const dateStr = d.toISOString().split('T')[0];
// 0.0002 daily upward bias + random daily return
const vol = ticker.includes('VIX') ? 0.06 : ticker.includes('BTC') ? 0.03 : 0.015;
const drift = ticker.includes('VIX') ? -0.0001 : 0.0004;
const dailyReturn = drift + (random() - 0.49) * vol;
currentPrice = currentPrice * (1 + dailyReturn);
if (ticker.includes('VIX')) {
currentPrice = Math.max(9.0, Math.min(65.0, currentPrice));
} else {
currentPrice = Math.max(0.1, currentPrice);
}
prices.push({
date: dateStr,
close: Math.round(currentPrice * 100) / 100
});
d.setDate(d.getDate() + 1);
}
// Sort date ascending
return prices.sort((a, b) => a.date.localeCompare(b.date));
}
// Generates fallback event dates programmatically
function getDeterministicEconomicCalendar(eventType: string): string[] {
const dates: string[] = [];
const start = new Date('2023-06-12');
const end = new Date('2026-06-12');
if (eventType === 'FOMC Rates') {
// Specific FOMC dates
return [
'2023-06-14', '2023-07-26', '2023-09-20', '2023-11-01', '2023-12-13',
'2024-01-31', '2024-03-20', '2024-05-01', '2024-06-12', '2024-07-31', '2024-09-18', '2024-11-07', '2024-12-18',
'2025-01-29', '2025-03-19', '2025-04-30', '2025-06-18', '2025-07-30', '2025-09-17', '2025-11-05', '2025-12-17',
'2026-01-28', '2026-03-18', '2026-05-06'
];
} else if (eventType === 'CPI Inflation') {
// Monthly dates around the 12th
const d = new Date(start);
while (d <= end) {
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const tempDate = new Date(`${year}-${month}-12`);
// Adjust weekend to weekday
const day = tempDate.getDay();
if (day === 6) {
tempDate.setDate(tempDate.getDate() - 1); // Friday
} else if (day === 0) {
tempDate.setDate(tempDate.getDate() + 1); // Monday
}
dates.push(tempDate.toISOString().split('T')[0]);
d.setMonth(d.getMonth() + 1);
}
} else if (eventType === 'Labor Market') {
// Weekly Thursdays
const d = new Date(start);
// Find first Thursday
while (d.getDay() !== 4) {
d.setDate(d.getDate() + 1);
}
while (d <= end) {
dates.push(d.toISOString().split('T')[0]);
d.setDate(d.getDate() + 7);
}
}
return dates;
}
// Invert matrix for regression using Gaussian elimination
function invertMatrix(A: number[][]): number[][] | null {
const n = A.length;
// Initialize augmented matrix [A | I]
const M: number[][] = A.map((row, i) => {
const iRow = new Array(n).fill(0);
iRow[i] = 1;
return [...row, ...iRow];
});
for (let i = 0; i < n; i++) {
// Pivot search
let maxEl = Math.abs(M[i][i]);
let maxRow = i;
for (let r = i + 1; r < n; r++) {
if (Math.abs(M[r][i]) > maxEl) {
maxEl = Math.abs(M[r][i]);
maxRow = r;
}
}
if (maxEl < 1e-12) return null; // Singular matrix
// Swap rows
const temp = M[maxRow];
M[maxRow] = M[i];
M[i] = temp;
// Normalize pivot row
const pivot = M[i][i];
for (let c = i; c < 2 * n; c++) {
M[i][c] /= pivot;
}
// Eliminate other rows
for (let r = 0; r < n; r++) {
if (r !== i) {
const factor = M[r][i];
for (let c = i; c < 2 * n; c++) {
M[r][c] -= factor * M[i][c];
}
}
}
}
// Extract inverse
return M.map(row => row.slice(n));
}
// Swamy-Arora GLS Random Effects Panel Regression Solver
function solveRandomEffectsPanel(
y: number[], // flat list of length M * T
X: number[][], // array of length M * T, each element is [1, Pre, Post, Vix]
groupIds: number[], // array of length M * T indicating the event instance index
numGroups: number,
obsPerGroup: number
) {
const n = y.length;
const k = X[0].length; // number of regressors (4)
// 1. Solve Pooled OLS to get initial residuals
// X^T * X
const XtX = Array.from({ length: k }, () => new Array(k).fill(0));
const XtY = new Array(k).fill(0);
for (let i = 0; i < n; i++) {
for (let r = 0; r < k; r++) {
for (let c = 0; c < k; c++) {
XtX[r][c] += X[i][r] * X[i][c];
}
XtY[r] += X[i][r] * y[i];
}
}
// Add small ridge factor for matrix stability
for (let j = 0; j < k; j++) XtX[j][j] += 1e-6;
const XtXInv = invertMatrix(XtX);
if (!XtXInv) throw new Error("Singular matrix in Pooled OLS step.");
// Beta pooled
const betaPooled = new Array(k).fill(0);
for (let r = 0; r < k; r++) {
for (let c = 0; c < k; c++) {
betaPooled[r] += XtXInv[r][c] * XtY[c];
}
}
// Pooled residuals
const residualsPooled = new Array(n).fill(0);
for (let i = 0; i < n; i++) {
let fitted = 0;
for (let j = 0; j < k; j++) fitted += X[i][j] * betaPooled[j];
residualsPooled[i] = y[i] - fitted;
}
// 2. Estimate variance components (Swamy-Arora ANOVA approach)
// Compute group mean residuals
const groupMeanRes = new Array(numGroups).fill(0);
const groupSizes = new Array(numGroups).fill(0);
for (let i = 0; i < n; i++) {
const g = groupIds[i];
groupMeanRes[g] += residualsPooled[i];
groupSizes[g]++;
}
for (let g = 0; g < numGroups; g++) {
groupMeanRes[g] /= groupSizes[g] || 1;
}
// Within group residuals variance (sigma_e^2)
let sumSqWithin = 0;
for (let i = 0; i < n; i++) {
const g = groupIds[i];
const dev = residualsPooled[i] - groupMeanRes[g];
sumSqWithin += dev * dev;
}
const dfWithin = Math.max(1, n - numGroups - k + 1);
const sigma_e_sq = sumSqWithin / dfWithin;
// Between group variance
let meanOfGroupMeans = groupMeanRes.reduce((a, b) => a + b, 0) / numGroups;
let sumSqBetween = groupMeanRes.reduce((sum, val) => sum + (val - meanOfGroupMeans) * (val - meanOfGroupMeans), 0);
const dfBetween = Math.max(1, numGroups - 1);
const s_between_sq = sumSqBetween / dfBetween;
// Random intercept variance (sigma_u^2)
const T_avg = obsPerGroup; // balanced panel size (61)
const sigma_u_sq = Math.max(0.000001, s_between_sq - sigma_e_sq / T_avg);
// GLS weight theta
const theta = 1 - Math.sqrt(sigma_e_sq) / Math.sqrt(sigma_e_sq + T_avg * sigma_u_sq);
// 3. Demean the panel data using theta
// Group means of y and X
const groupMeanY = new Array(numGroups).fill(0);
const groupMeanX = Array.from({ length: numGroups }, () => new Array(k).fill(0));
for (let i = 0; i < n; i++) {
const g = groupIds[i];
groupMeanY[g] += y[i];
for (let j = 0; j < k; j++) groupMeanX[g][j] += X[i][j];
}
for (let g = 0; g < numGroups; g++) {
groupMeanY[g] /= groupSizes[g] || 1;
for (let j = 0; j < k; j++) groupMeanX[g][j] /= groupSizes[g] || 1;
}
// Transformed variables
const yStar = new Array(n).fill(0);
const XStar = Array.from({ length: n }, () => new Array(k).fill(0));
for (let i = 0; i < n; i++) {
const g = groupIds[i];
yStar[i] = y[i] - theta * groupMeanY[g];
for (let j = 0; j < k; j++) {
XStar[i][j] = X[i][j] - theta * groupMeanX[g][j];
}
}
// 4. Run OLS on transformed variables (GLS estimate)
const XstXs = Array.from({ length: k }, () => new Array(k).fill(0));
const XstYs = new Array(k).fill(0);
for (let i = 0; i < n; i++) {
for (let r = 0; r < k; r++) {
for (let c = 0; c < k; c++) {
XstXs[r][c] += XStar[i][r] * XStar[i][c];
}
XstYs[r] += XStar[i][r] * yStar[i];
}
}
for (let j = 0; j < k; j++) XstXs[j][j] += 1e-6; // Ridge for stability
const XstXsInv = invertMatrix(XstXs);
if (!XstXsInv) throw new Error("Singular matrix in GLS regression step.");
const betaGLS = new Array(k).fill(0);
for (let r = 0; r < k; r++) {
for (let c = 0; c < k; c++) {
betaGLS[r] += XstXsInv[r][c] * XstYs[c];
}
}
// GLS Residuals
const residualsGLS = new Array(n).fill(0);
let sumSqResGLS = 0;
for (let i = 0; i < n; i++) {
let fitted = 0;
for (let j = 0; j < k; j++) fitted += XStar[i][j] * betaGLS[j];
residualsGLS[i] = yStar[i] - fitted;
sumSqResGLS += residualsGLS[i] * residualsGLS[i];
}
const dfGLS = Math.max(1, n - k);
const s2GLS = sumSqResGLS / dfGLS;
// Covariance of estimates
const covGLS = Array.from({ length: k }, () => new Array(k).fill(0));
for (let r = 0; r < k; r++) {
for (let c = 0; c < k; c++) {
covGLS[r][c] = s2GLS * XstXsInv[r][c];
}
}
// Assemble outputs
const names = ['Intercept', 'Pre-Event Drift', 'Post-Event Impact', 'Beta_VIX'];
const fixedEffects = names.map((name, idx) => {
const est = betaGLS[idx];
const se = Math.sqrt(Math.max(0, covGLS[idx][idx])) || 1e-4;
const tStat = est / se;
const z = Math.abs(tStat);
// Gaussian p-value approximation
const pVal = 2 * (1 - (1 / (1 + Math.exp(-0.07056 * z * z * z - 1.5976 * z))));
const finalP = isNaN(pVal) ? 0.05 : Math.max(0.00001, Math.min(1.0, pVal));
let sig = '';
if (finalP < 0.001) sig = '***';
else if (finalP < 0.01) sig = '**';
else if (finalP < 0.05) sig = '*';
else if (finalP < 0.1) sig = '.';
return {
name,
estimate: Math.round(est * 10000) / 10000,
se: Math.round(se * 10000) / 10000,
pVal: Math.round(finalP * 10000) / 10000,
sig,
ciLower: Math.round((est - 1.96 * se) * 10000) / 10000,
ciUpper: Math.round((est + 1.96 * se) * 10000) / 10000
};
});
// R-squared
const meanY = yStar.reduce((a, b) => a + b, 0) / n;
const totalSS = yStar.reduce((sum, val) => sum + (val - meanY) * (val - meanY), 0) || 1e-4;
const rSquared = Math.max(0, Math.min(0.99, 1 - sumSqResGLS / totalSS));
const aic = n * Math.log(sumSqResGLS / n) + 2 * (k + 2); // k parameters + sigma_u + sigma_e
const bic = n * Math.log(sumSqResGLS / n) + Math.log(n) * (k + 2);
return {
fixedEffects,
randomEffectsVariance: {
interceptVar: Math.round(sigma_u_sq * 100000) / 100000,
residualVar: Math.round(sigma_e_sq * 100000) / 100000
},
rSquared: Math.round(rSquared * 1000) / 1000,
aic: Math.round(aic * 10) / 10,
bic: Math.round(bic * 10) / 10
};
}
export async function POST(request: Request) {
try {
const body = await request.json();
const { portfolio, eventType } = body as { portfolio: PortfolioAsset[]; eventType: string };
// Check if portfolio is empty, load seed portfolio as a fallback
const activePortfolio = (portfolio && portfolio.length > 0)
? portfolio
: [
{ ticker: 'AAPL', shares: 150, entryPrice: 172.5 },
{ ticker: 'MSFT', shares: 80, entryPrice: 388.0 },
{ ticker: 'BTC-USD', shares: 1.5, entryPrice: 62000.0 }
];
const apiKey = process.env.FMP_API_KEY;
const fromDate = '2023-05-01';
const toDate = '2026-06-30';
// 1. Gather event dates for selected type (last 36 months)
let eventDates: string[] = [];
if (apiKey) {
try {
const calEvent = eventType === 'FOMC Rates' ? 'Fed Interest Rate Decision' :
eventType === 'CPI Inflation' ? 'CPI MoM' : 'Initial Jobless Claims';
const response = await fetchWithTimeout(
`https://financialmodelingprep.com/api/v3/economic-calendar?from=2023-06-12&to=2026-06-12&apikey=${apiKey}`
);
if (response.ok) {
const calendarData = await response.json();
if (Array.isArray(calendarData)) {
eventDates = calendarData
.filter((item: any) => item.country === 'US' && item.event?.includes(calEvent))
.map((item: any) => item.date.split(' ')[0]);
}
}
} catch (err) {
console.error("FMP Economic Calendar fetch error:", err);
}
}
// Fallback if calendar fetch returned nothing or key is missing
if (eventDates.length === 0) {
eventDates = getDeterministicEconomicCalendar(eventType);
}
// De-duplicate and sort event dates ascending
eventDates = Array.from(new Set(eventDates)).sort((a, b) => a.localeCompare(b));
// 2. Fetch full 3-year historical close prices for all assets + VIX + NASDAQ benchmark (^IXIC)
const uniqueTickers = Array.from(new Set([
...activePortfolio.map(p => p.ticker),
'VIX',
'^VIX',
'^IXIC'
]));
const priceHistoryMap: Record<string, { date: string; close: number }[]> = {};
await Promise.all(
uniqueTickers.map(async (ticker) => {
let prices: { date: string; close: number }[] = [];
const fmpTicker = ticker === 'VIX' || ticker === '^VIX' ? '%5EVIX' : ticker;
if (apiKey) {
try {
const res = await fetchWithTimeout(
`https://financialmodelingprep.com/api/v3/historical-price-full/${fmpTicker}?from=${fromDate}&to=${toDate}&apikey=${apiKey}`
);
if (res.ok) {
const resData = await res.json();
if (Array.isArray(resData.historical)) {
prices = resData.historical.map((h: any) => ({
date: h.date,
close: Number(h.close) || 0
}));
}
}
} catch (_) {}
}
if (prices.length === 0) {
prices = getDeterministicPrices(ticker, fromDate, toDate);
}
// Map as both raw ticker and parsed ticker for lookup convenience
priceHistoryMap[ticker] = prices;
if (ticker === '^VIX') priceHistoryMap['VIX'] = prices;
if (ticker === 'VIX') priceHistoryMap['^VIX'] = prices;
})
);
// 3. Resolve active portfolio weight vector
// Calculate weights based on the latest available close prices in the history map
const latestPrices: Record<string, number> = {};
activePortfolio.forEach((asset) => {
const hist = priceHistoryMap[asset.ticker] || [];
const latestClose = hist.length > 0 ? hist[hist.length - 1].close : asset.entryPrice;
latestPrices[asset.ticker] = latestClose;
});
const values = activePortfolio.map(asset => asset.shares * latestPrices[asset.ticker]);
const totalVal = values.reduce((a, b) => a + b, 0) || 1e-4;
const weights = activePortfolio.map((asset, idx) => ({
ticker: asset.ticker,
weight: values[idx] / totalVal
}));
// 4. Construct synthetic portfolio panel observations for the regression
// Slices daily price tracks in window [-30, +30] around each event date
const y: number[] = [];
const X: number[][] = [];
const groupIds: number[] = [];
const M = eventDates.length;
const T = 61; // relative day -30 to +30 is 61 days
let validGroupCount = 0;
// We will collect cumulative return tracks for charting
const cumReturnsPortfolio: number[][] = [];
const cumReturnsBenchmark: number[][] = [];
const vixTracks: number[][] = [];
for (let j = 0; j < M; j++) {
const eventDate = eventDates[j];
const portfolioTrack: number[] = [];
const benchmarkTrack: number[] = [];
const vixTrack: number[] = [];
let isWindowValid = true;
// Slicing calendar dates from offset -31 to +30
// We need index -31 to calculate the return at index -30
for (let offset = -31; offset <= 30; offset++) {
const offsetDateStr = getOffsetDate(eventDate, offset);
// Look up prices
const vixPrices = priceHistoryMap['^VIX'] || [];
const benchPrices = priceHistoryMap['^IXIC'] || [];
const findCloseOnOrBefore = (history: { date: string; close: number }[], dateStr: string) => {
if (history.length === 0) return 0;
// Find exact match
const exact = history.find(h => h.date === dateStr);
if (exact) return exact.close;
// Find closest preceding date
let closest = history[0];
for (const h of history) {
if (h.date <= dateStr) {
closest = h;
} else {
break;
}
}
return closest.close;
};
const vixClose = findCloseOnOrBefore(vixPrices, offsetDateStr) || 15.0;
const benchClose = findCloseOnOrBefore(benchPrices, offsetDateStr) || 16000.0;
const assetCloses = activePortfolio.map(asset => {
const hist = priceHistoryMap[asset.ticker] || [];
return findCloseOnOrBefore(hist, offsetDateStr) || asset.entryPrice;
});
// Check if we retrieved valid prices
if (benchClose === 0 || assetCloses.some(p => p === 0)) {
isWindowValid = false;
break;
}
portfolioTrack.push(
weights.reduce((sum, w, idx) => sum + w.weight * assetCloses[idx], 0)
);
benchmarkTrack.push(benchClose);
vixTrack.push(vixClose);
}
if (!isWindowValid || portfolioTrack.length < 62) {
continue;
}
const currentCumP: number[] = [];
const currentCumB: number[] = [];
let cumP = 0;
let cumB = 0;
// Compute log returns for offset -30 to +30 (indices 1 to 61 in track arrays)
for (let idx = 1; idx <= 60; idx++) {
const retP = Math.log(portfolioTrack[idx] / portfolioTrack[idx - 1]);
const retB = Math.log(benchmarkTrack[idx] / benchmarkTrack[idx - 1]);
const relativeDay = idx - 31; // -30 to +29 (offset -30 corresponds to idx = 1)
cumP += retP;
cumB += retB;
currentCumP.push(cumP);
currentCumB.push(cumB);
// Append to panel data matrix
const pre = relativeDay < 0 ? 1 : 0;
const post = relativeDay > 0 ? 1 : 0;
const vixVal = vixTrack[idx];
y.push(retP);
X.push([1, pre, post, vixVal]);
groupIds.push(validGroupCount);
}
// Append last element to match length 61 (offset +30)
const lastIdx = 61;
const retP = Math.log(portfolioTrack[lastIdx] / portfolioTrack[lastIdx - 1]);
const retB = Math.log(benchmarkTrack[lastIdx] / benchmarkTrack[lastIdx - 1]);
cumP += retP;
cumB += retB;
currentCumP.push(cumP);
currentCumB.push(cumB);
const pre = 0;
const post = 1;
const vixVal = vixTrack[lastIdx];
y.push(retP);
X.push([1, pre, post, vixVal]);
groupIds.push(validGroupCount);
cumReturnsPortfolio.push(currentCumP);
cumReturnsBenchmark.push(currentCumB);
vixTracks.push(vixTrack.slice(1)); // exclude offset -31
validGroupCount++;
}
if (validGroupCount === 0) {
return NextResponse.json({ error: "Could not reconstruct daily price window arrays around event dates." }, { status: 400 });
}
// 5. Solve Swamy-Arora panel regression
const regressionResults = solveRandomEffectsPanel(y, X, groupIds, validGroupCount, T);
// 6. Compute averaged cumulative return series for charting (length 61)
const avgCumPortfolio = new Array(T).fill(0);
const avgCumBenchmark = new Array(T).fill(0);
const avgVix = new Array(T).fill(0);
for (let t = 0; t < T; t++) {
for (let g = 0; g < validGroupCount; g++) {
avgCumPortfolio[t] += cumReturnsPortfolio[g][t] || 0;
avgCumBenchmark[t] += cumReturnsBenchmark[g][t] || 0;
avgVix[t] += vixTracks[g][t] || 0;
}
avgCumPortfolio[t] /= validGroupCount;
avgCumBenchmark[t] /= validGroupCount;
avgVix[t] /= validGroupCount;
}
// Get regression coefficients
const fe = regressionResults.fixedEffects;
const beta0 = fe.find(f => f.name === 'Intercept')?.estimate || 0;
const betaDrift = fe.find(f => f.name === 'Pre-Event Drift')?.estimate || 0;
const betaImpact = fe.find(f => f.name === 'Post-Event Impact')?.estimate || 0;
const betaVix = fe.find(f => f.name === 'Beta_VIX')?.estimate || 0;
// Calculate LMM Fitted cumulative return
let cumFitted = 0;
const avgCumFitted = new Array(T).fill(0);
for (let t = 0; t < T; t++) {
const relativeDay = t - 30;
const pre = relativeDay < 0 ? 1 : 0;
const post = relativeDay > 0 ? 1 : 0;
const fitRet = beta0 + betaDrift * pre + betaImpact * post + betaVix * avgVix[t];
cumFitted += fitRet;
avgCumFitted[t] = cumFitted;
}
// Standardize chart coordinates into an array of objects
const chartData = Array.from({ length: T }, (_, idx) => {
const relativeDay = idx - 30;
return {
relativeDay,
'Mein Portfolio (%)': parseFloat((avgCumPortfolio[idx] * 100).toFixed(4)),
'NASDAQ Benchmark (%)': parseFloat((avgCumBenchmark[idx] * 100).toFixed(4)),
'LMM Trend (%)': parseFloat((avgCumFitted[idx] * 100).toFixed(4)),
'Avg VIX': parseFloat(avgVix[idx].toFixed(2))
};
});
return NextResponse.json({
weights,
regressionResults,
chartData,
eventCount: validGroupCount
}, { status: 200 });
} catch (err: any) {
console.error("====== SANDBOX LMM ROUTE FAILURE ======", err);
return NextResponse.json({ error: err.message || "An unexpected error occurred during stress-testing calculations." }, { status: 500 });
}
}