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 { 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 = { '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 = {}; 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 = {}; 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 }); } }