feat(sandbox): deploy Phase 1 and Phase 2 of Portfolio Sandbox including Swamy-Arora GLS solver and stress-test visualization
This commit is contained in:
1378
app/api/econometrics/route.ts
Normal file
1378
app/api/econometrics/route.ts
Normal file
File diff suppressed because it is too large
Load Diff
349
app/api/finance/route.ts
Normal file
349
app/api/finance/route.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 0;
|
||||
|
||||
// Partitioned active asset universes
|
||||
const US_TICKERS = [
|
||||
'AAPL', 'MSFT', 'NVDA', 'TSLA', 'AMD', 'SMCI', 'NFLX', 'AMZN', 'GOOGL', 'META',
|
||||
'WMT', 'JNJ', 'PG', 'MRK', 'PLTR', 'BABA', 'CVX', 'XOM', 'BAC', 'JPM'
|
||||
];
|
||||
|
||||
const EU_TICKERS = [
|
||||
'ASML', 'SAP', 'MC.PA', 'OR.PA', 'NESN', 'NOVOB', 'SHEL', 'BP', 'HSBC', 'ALV.DE',
|
||||
'VOW3.DE', 'BMW.DE', 'SIE.DE', 'DTE.DE', 'MBG.DE', 'BAS.DE', 'SAN.MC', 'BBVA.MC'
|
||||
];
|
||||
|
||||
const CRYPTO_TICKERS = [
|
||||
'BTC-USD', 'ETH-USD', 'SOL-USD', 'ADA-USD', 'XRP-USD', 'DOGE-USD', 'DOT-USD',
|
||||
'LINK-USD', 'LTC-USD', 'AVAX-USD', 'BNB-USD', 'TRX-USD', 'NEAR-USD'
|
||||
];
|
||||
|
||||
// Fallback database for fundamental overlay metrics to ensure absolute robustness
|
||||
const MOCK_FUNDAMENTALS: Record<string, {
|
||||
marketCap: number;
|
||||
trailingPE: number;
|
||||
forwardPE: number;
|
||||
peg: number;
|
||||
priceToBook: number;
|
||||
dividendYield: number;
|
||||
}> = {
|
||||
// US Markets
|
||||
'AAPL': { marketCap: 3000e9, trailingPE: 30.5, forwardPE: 26.8, peg: 1.5, priceToBook: 40.8, dividendYield: 0.005 },
|
||||
'MSFT': { marketCap: 3200e9, trailingPE: 35.2, forwardPE: 30.1, peg: 1.8, priceToBook: 12.4, dividendYield: 0.007 },
|
||||
'NVDA': { marketCap: 2800e9, trailingPE: 65.4, forwardPE: 32.5, peg: 0.9, priceToBook: 45.2, dividendYield: 0.0002 },
|
||||
'TSLA': { marketCap: 600e9, trailingPE: 55.0, forwardPE: 42.0, peg: 2.1, priceToBook: 8.5, dividendYield: 0.0 },
|
||||
'AMD': { marketCap: 250e9, trailingPE: 45.2, forwardPE: 28.5, peg: 1.4, priceToBook: 4.8, dividendYield: 0.0 },
|
||||
'SMCI': { marketCap: 25e9, trailingPE: 22.4, forwardPE: 15.2, peg: 0.6, priceToBook: 6.2, dividendYield: 0.0 },
|
||||
'NFLX': { marketCap: 280e9, trailingPE: 38.5, forwardPE: 29.2, peg: 1.3, priceToBook: 11.5, dividendYield: 0.0 },
|
||||
'AMZN': { marketCap: 1800e9, trailingPE: 40.2, forwardPE: 32.1, peg: 1.2, priceToBook: 8.2, dividendYield: 0.0 },
|
||||
'GOOGL':{ marketCap: 2100e9, trailingPE: 25.4, forwardPE: 21.2, peg: 1.1, priceToBook: 6.8, dividendYield: 0.0 },
|
||||
'META': { marketCap: 1200e9, trailingPE: 28.1, forwardPE: 22.4, peg: 1.0, priceToBook: 7.5, dividendYield: 0.0 },
|
||||
'WMT': { marketCap: 500e9, trailingPE: 26.5, forwardPE: 23.1, peg: 2.5, priceToBook: 5.4, dividendYield: 0.014 },
|
||||
'JNJ': { marketCap: 380e9, trailingPE: 15.4, forwardPE: 14.2, peg: 2.8, priceToBook: 5.1, dividendYield: 0.032 },
|
||||
'PG': { marketCap: 390e9, trailingPE: 24.2, forwardPE: 22.1, peg: 3.1, priceToBook: 7.2, dividendYield: 0.025 },
|
||||
'MRK': { marketCap: 300e9, trailingPE: 16.8, forwardPE: 14.5, peg: 1.9, priceToBook: 4.9, dividendYield: 0.028 },
|
||||
'PLTR': { marketCap: 85e9, trailingPE: 80.2, forwardPE: 55.4, peg: 1.7, priceToBook: 14.2, dividendYield: 0.0 },
|
||||
'BABA': { marketCap: 180e9, trailingPE: 9.5, forwardPE: 8.2, peg: 0.8, priceToBook: 1.1, dividendYield: 0.012 },
|
||||
'CVX': { marketCap: 290e9, trailingPE: 12.1, forwardPE: 11.2, peg: 2.0, priceToBook: 1.7, dividendYield: 0.042 },
|
||||
'XOM': { marketCap: 480e9, trailingPE: 13.4, forwardPE: 12.2, peg: 1.8, priceToBook: 2.1, dividendYield: 0.037 },
|
||||
'BAC': { marketCap: 310e9, trailingPE: 11.5, forwardPE: 10.4, peg: 1.5, priceToBook: 1.0, dividendYield: 0.024 },
|
||||
'JPM': { marketCap: 550e9, trailingPE: 12.4, forwardPE: 11.5, peg: 1.6, priceToBook: 1.6, dividendYield: 0.022 },
|
||||
|
||||
// EU Markets
|
||||
'ASML': { marketCap: 350e9, trailingPE: 42.1, forwardPE: 33.4, peg: 1.6, priceToBook: 22.4, dividendYield: 0.009 },
|
||||
'SAP': { marketCap: 220e9, trailingPE: 34.5, forwardPE: 28.1, peg: 1.5, priceToBook: 8.4, dividendYield: 0.011 },
|
||||
'MC.PA': { marketCap: 410e9, trailingPE: 24.2, forwardPE: 21.5, peg: 2.0, priceToBook: 6.9, dividendYield: 0.018 },
|
||||
'OR.PA': { marketCap: 240e9, trailingPE: 32.1, forwardPE: 29.4, peg: 2.4, priceToBook: 7.8, dividendYield: 0.016 },
|
||||
'NESN': { marketCap: 280e9, trailingPE: 20.4, forwardPE: 18.5, peg: 2.8, priceToBook: 5.6, dividendYield: 0.031 },
|
||||
'NOVOB': { marketCap: 520e9, trailingPE: 38.2, forwardPE: 31.0, peg: 1.4, priceToBook: 32.1, dividendYield: 0.008 },
|
||||
'SHEL': { marketCap: 210e9, trailingPE: 8.5, forwardPE: 7.8, peg: 1.2, priceToBook: 1.1, dividendYield: 0.041 },
|
||||
'BP': { marketCap: 105e9, trailingPE: 7.2, forwardPE: 6.5, peg: 1.0, priceToBook: 0.9, dividendYield: 0.049 },
|
||||
'HSBC': { marketCap: 140e9, trailingPE: 6.8, forwardPE: 6.2, peg: 1.3, priceToBook: 0.7, dividendYield: 0.062 },
|
||||
'ALV.DE': { marketCap: 95e9, trailingPE: 10.4, forwardPE: 9.2, peg: 1.1, priceToBook: 1.2, dividendYield: 0.048 },
|
||||
'VOW3.DE':{ marketCap: 60e9, trailingPE: 4.1, forwardPE: 3.8, peg: 0.5, priceToBook: 0.4, dividendYield: 0.075 },
|
||||
'BMW.DE': { marketCap: 62e9, trailingPE: 5.2, forwardPE: 4.8, peg: 0.7, priceToBook: 0.6, dividendYield: 0.068 },
|
||||
'SIE.DE': { marketCap: 135e9, trailingPE: 15.2, forwardPE: 13.1, peg: 1.3, priceToBook: 2.4, dividendYield: 0.028 },
|
||||
'DTE.DE': { marketCap: 115e9, trailingPE: 13.8, forwardPE: 12.1, peg: 1.4, priceToBook: 1.8, dividendYield: 0.031 },
|
||||
'MBG.DE': { marketCap: 70e9, trailingPE: 5.5, forwardPE: 5.1, peg: 0.8, priceToBook: 0.8, dividendYield: 0.072 },
|
||||
'BAS.DE': { marketCap: 45e9, trailingPE: 12.4, forwardPE: 10.8, peg: 1.9, priceToBook: 1.1, dividendYield: 0.065 },
|
||||
'SAN.MC': { marketCap: 75e9, trailingPE: 6.2, forwardPE: 5.8, peg: 0.9, priceToBook: 0.7, dividendYield: 0.045 },
|
||||
'BBVA.MC':{ marketCap: 58e9, trailingPE: 6.5, forwardPE: 6.0, peg: 0.8, priceToBook: 0.8, dividendYield: 0.051 },
|
||||
|
||||
// Crypto Assets
|
||||
'BTC-USD': { marketCap: 1300e9, trailingPE: 0, forwardPE: 0, peg: 0, priceToBook: 0, dividendYield: 0 },
|
||||
'ETH-USD': { marketCap: 420e9, trailingPE: 0, forwardPE: 0, peg: 0, priceToBook: 0, dividendYield: 0 },
|
||||
'SOL-USD': { marketCap: 75e9, trailingPE: 0, forwardPE: 0, peg: 0, priceToBook: 0, dividendYield: 0 },
|
||||
'ADA-USD': { marketCap: 18e9, trailingPE: 0, forwardPE: 0, peg: 0, priceToBook: 0, dividendYield: 0 },
|
||||
'XRP-USD': { marketCap: 32e9, trailingPE: 0, forwardPE: 0, peg: 0, priceToBook: 0, dividendYield: 0 },
|
||||
'DOGE-USD': { marketCap: 22e9, trailingPE: 0, forwardPE: 0, peg: 0, priceToBook: 0, dividendYield: 0 },
|
||||
'DOT-USD': { marketCap: 7e9, trailingPE: 0, forwardPE: 0, peg: 0, priceToBook: 0, dividendYield: 0 },
|
||||
'LINK-USD': { marketCap: 9e9, trailingPE: 0, forwardPE: 0, peg: 0, priceToBook: 0, dividendYield: 0 },
|
||||
'LTC-USD': { marketCap: 6e9, trailingPE: 0, forwardPE: 0, peg: 0, priceToBook: 0, dividendYield: 0 },
|
||||
'AVAX-USD': { marketCap: 12e9, trailingPE: 0, forwardPE: 0, peg: 0, priceToBook: 0, dividendYield: 0 },
|
||||
'BNB-USD': { marketCap: 90e9, trailingPE: 0, forwardPE: 0, peg: 0, priceToBook: 0, dividendYield: 0 },
|
||||
'TRX-USD': { marketCap: 10e9, trailingPE: 0, forwardPE: 0, peg: 0, priceToBook: 0, dividendYield: 0 },
|
||||
'NEAR-USD': { marketCap: 6e9, trailingPE: 0, forwardPE: 0, peg: 0, priceToBook: 0, dividendYield: 0 }
|
||||
};
|
||||
|
||||
// Calculate standard 14-day Welles Wilder RSI
|
||||
function calculateRSI14(prices: number[]): number {
|
||||
if (prices.length < 15) return 50;
|
||||
|
||||
let gains = 0;
|
||||
let losses = 0;
|
||||
|
||||
for (let i = 1; i <= 14; i++) {
|
||||
const diff = prices[i] - prices[i - 1];
|
||||
if (diff > 0) {
|
||||
gains += diff;
|
||||
} else {
|
||||
losses -= diff;
|
||||
}
|
||||
}
|
||||
|
||||
let avgGain = gains / 14;
|
||||
let avgLoss = losses / 14;
|
||||
|
||||
for (let i = 15; i < prices.length; i++) {
|
||||
const diff = prices[i] - prices[i - 1];
|
||||
const gain = diff > 0 ? diff : 0;
|
||||
const loss = diff < 0 ? -diff : 0;
|
||||
|
||||
avgGain = (avgGain * 13 + gain) / 14;
|
||||
avgLoss = (avgLoss * 13 + loss) / 14;
|
||||
}
|
||||
|
||||
if (avgLoss === 0) return 100;
|
||||
const rs = avgGain / avgLoss;
|
||||
return 100 - 100 / (1 + rs);
|
||||
}
|
||||
|
||||
// Fetch fundamental data from FMP with safe fallback
|
||||
async function fetchFMPFundamentalData(ticker: string, apiKey: string) {
|
||||
if (ticker.includes('-USD') || ticker.includes('BTC') || ticker.includes('ETH')) {
|
||||
const mock = MOCK_FUNDAMENTALS[ticker] || { marketCap: 0, trailingPE: 0, forwardPE: 0, peg: 0, priceToBook: 0, dividendYield: 0 };
|
||||
return { ...mock, dividendYield: mock.dividendYield * 100 };
|
||||
}
|
||||
|
||||
try {
|
||||
const profileUrl = `https://financialmodelingprep.com/stable/profile?symbol=${ticker}&apikey=${apiKey}`;
|
||||
const ratiosUrl = `https://financialmodelingprep.com/stable/ratios-ttm?symbol=${ticker}&apikey=${apiKey}`;
|
||||
|
||||
const [profRes, ratRes] = await Promise.all([
|
||||
fetch(profileUrl, { signal: AbortSignal.timeout(3000) }),
|
||||
fetch(ratiosUrl, { signal: AbortSignal.timeout(3000) })
|
||||
]);
|
||||
|
||||
if (!profRes.ok || !ratRes.ok) {
|
||||
throw new Error(`FMP request failed for ${ticker}`);
|
||||
}
|
||||
|
||||
const profData = await profRes.json();
|
||||
const ratData = await ratRes.json();
|
||||
|
||||
const profile = profData?.[0] || {};
|
||||
const ratios = ratData?.[0] || {};
|
||||
|
||||
const marketCap = profile.marketCap || MOCK_FUNDAMENTALS[ticker]?.marketCap || 0;
|
||||
const trailingPE = ratios.priceToEarningsRatioTTM || MOCK_FUNDAMENTALS[ticker]?.trailingPE || 0;
|
||||
const peg = ratios.priceToEarningsGrowthRatioTTM || MOCK_FUNDAMENTALS[ticker]?.peg || 0;
|
||||
const priceToBook = ratios.priceToBookRatioTTM || MOCK_FUNDAMENTALS[ticker]?.priceToBook || 0;
|
||||
const dividendYield = ratios.dividendYieldTTM || MOCK_FUNDAMENTALS[ticker]?.dividendYield || 0;
|
||||
|
||||
// Invert Forward PE mathematically based on implied PEG growth
|
||||
let forwardPE = null;
|
||||
if (trailingPE && peg && peg > 0) {
|
||||
const growth = trailingPE / (peg * 100);
|
||||
forwardPE = trailingPE / (1 + growth);
|
||||
} else if (trailingPE) {
|
||||
forwardPE = trailingPE * 0.91;
|
||||
} else {
|
||||
forwardPE = MOCK_FUNDAMENTALS[ticker]?.forwardPE || 0;
|
||||
}
|
||||
|
||||
return {
|
||||
marketCap,
|
||||
trailingPE: Number(trailingPE.toFixed(2)),
|
||||
forwardPE: Number(forwardPE.toFixed(2)),
|
||||
peg: Number(peg.toFixed(2)),
|
||||
priceToBook: Number(priceToBook.toFixed(2)),
|
||||
dividendYield: Number((dividendYield * 100).toFixed(2)) // convert to percentage (e.g. 0.015 -> 1.50)
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn(`Error fetching FMP data for ${ticker}, using fallback:`, err);
|
||||
const mock = MOCK_FUNDAMENTALS[ticker] || { marketCap: 0, trailingPE: 0, forwardPE: 0, peg: 0, priceToBook: 0, dividendYield: 0 };
|
||||
return {
|
||||
...mock,
|
||||
dividendYield: mock.dividendYield * 100
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const tickerQuery = searchParams.get('ticker');
|
||||
const tickersQuery = searchParams.get('tickers');
|
||||
|
||||
const mode = searchParams.get('mode') || 'day_crash';
|
||||
const region = searchParams.get('region') || 'us';
|
||||
|
||||
const fmpApiKey = process.env.FMP_API_KEY || 'U6lOXaOFPye7oc1D235kyAqJeQaiTAWc';
|
||||
|
||||
let tickers: string[] = [];
|
||||
|
||||
try {
|
||||
if (tickerQuery) {
|
||||
tickers = [tickerQuery.trim().toUpperCase()];
|
||||
} else if (tickersQuery && tickersQuery !== 'top_losers') {
|
||||
tickers = tickersQuery.split(',').map(t => t.trim().toUpperCase());
|
||||
} else {
|
||||
// Partition by region
|
||||
if (region === 'eu') {
|
||||
tickers = EU_TICKERS;
|
||||
} else if (region === 'crypto') {
|
||||
tickers = CRYPTO_TICKERS;
|
||||
} else {
|
||||
tickers = US_TICKERS;
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Ticker resolution error:', err.message);
|
||||
}
|
||||
|
||||
if (tickers.length === 0) {
|
||||
tickers = US_TICKERS;
|
||||
}
|
||||
|
||||
// Fetch Yahoo Finance 1y charts in parallel
|
||||
const rawResults = await Promise.all(
|
||||
tickers.map(async (ticker) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://query1.finance.yahoo.com/v8/finance/chart/${ticker}?range=1y&interval=1d`,
|
||||
{
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
}
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch chart data for ${ticker}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
const result = data.chart?.result?.[0];
|
||||
if (!result) {
|
||||
throw new Error(`No chart data found for ${ticker}`);
|
||||
}
|
||||
|
||||
const closePrices = result.indicators?.quote?.[0]?.close || [];
|
||||
const validPrices = closePrices.filter((p: any): p is number => typeof p === 'number' && p > 0);
|
||||
|
||||
if (validPrices.length === 0) {
|
||||
throw new Error(`No valid closing prices found for ${ticker}`);
|
||||
}
|
||||
|
||||
const currentPrice = validPrices[validPrices.length - 1];
|
||||
const prevPrice = validPrices.length >= 2 ? validPrices[validPrices.length - 2] : currentPrice;
|
||||
|
||||
// Mode 1: single day change
|
||||
const dayChange = (currentPrice - prevPrice) / prevPrice;
|
||||
|
||||
// Mode 2: 50-day moving average drop
|
||||
const slice50 = validPrices.slice(-50);
|
||||
const sma50 = slice50.reduce((a: number, b: number) => a + b, 0) / slice50.length;
|
||||
const maDeviation = (currentPrice - sma50) / sma50;
|
||||
|
||||
// Mode 3: 52-week distance
|
||||
const peak52w = Math.max(...validPrices);
|
||||
const dist52w = (currentPrice - peak52w) / peak52w;
|
||||
|
||||
// Mode 4: 14-day RSI
|
||||
const rsi14 = calculateRSI14(validPrices);
|
||||
|
||||
// Daily returns for local GJR-GARCH calculations
|
||||
const returns = [];
|
||||
for (let i = 1; i < validPrices.length; i++) {
|
||||
returns.push((validPrices[i] - validPrices[i - 1]) / validPrices[i - 1]);
|
||||
}
|
||||
|
||||
// We use the 90d peak drop as the default 'priceChange' compatibility variable
|
||||
const slice90 = validPrices.slice(-90);
|
||||
const peak90 = Math.max(...slice90);
|
||||
const priceChange = (currentPrice - peak90) / peak90;
|
||||
|
||||
return {
|
||||
ticker,
|
||||
name: result.meta?.longName || result.meta?.shortName || `${ticker} Corp.`,
|
||||
currentPrice,
|
||||
peakPrice: peak90,
|
||||
priceChange,
|
||||
dayChange,
|
||||
maDeviation,
|
||||
dist52w,
|
||||
rsi14,
|
||||
returns: returns.slice(-90) // return last 90 days of returns to keep payload slim
|
||||
};
|
||||
} catch (err: any) {
|
||||
console.error(`Error fetching ticker ${ticker}:`, err.message);
|
||||
return {
|
||||
ticker,
|
||||
error: err.message || 'Unknown error'
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Filter out invalid tickers
|
||||
const validResults = rawResults.filter((r) => !r.error) as Array<{
|
||||
ticker: string;
|
||||
name: string;
|
||||
currentPrice: number;
|
||||
peakPrice: number;
|
||||
priceChange: number;
|
||||
dayChange: number;
|
||||
maDeviation: number;
|
||||
dist52w: number;
|
||||
rsi14: number;
|
||||
returns: number[];
|
||||
}>;
|
||||
|
||||
// Rank results based on the requested scan mode
|
||||
let sortedResults = [...validResults];
|
||||
if (mode === 'ma_drop') {
|
||||
sortedResults.sort((a, b) => a.maDeviation - b.maDeviation);
|
||||
} else if (mode === '52w_dist') {
|
||||
sortedResults.sort((a, b) => a.dist52w - b.dist52w);
|
||||
} else if (mode === 'rsi_oversold') {
|
||||
sortedResults.sort((a, b) => a.rsi14 - b.rsi14);
|
||||
} else {
|
||||
// Default: day_crash (sort by dayChange ascending, most negative first)
|
||||
sortedResults.sort((a, b) => a.dayChange - b.dayChange);
|
||||
}
|
||||
|
||||
// Identify the top 15 outlier tickers to apply FMP overlay
|
||||
const top15Tickers = new Set(sortedResults.slice(0, 15).map(r => r.ticker));
|
||||
|
||||
// Overlay FMP fundamental details
|
||||
const results = await Promise.all(
|
||||
sortedResults.map(async (res) => {
|
||||
// Pull live data if in top 15, otherwise load direct mock fallback
|
||||
if (top15Tickers.has(res.ticker)) {
|
||||
const fundamentals = await fetchFMPFundamentalData(res.ticker, fmpApiKey);
|
||||
return { ...res, ...fundamentals };
|
||||
} else {
|
||||
const mock = MOCK_FUNDAMENTALS[res.ticker] || { marketCap: 0, trailingPE: 0, forwardPE: 0, peg: 0, priceToBook: 0, dividendYield: 0 };
|
||||
return {
|
||||
...res,
|
||||
...mock,
|
||||
dividendYield: mock.dividendYield * 100
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const response = NextResponse.json({ results });
|
||||
response.headers.set('Cache-Control', 'no-store, max-age=0, must-revalidate');
|
||||
return response;
|
||||
}
|
||||
350
app/api/insider/route.ts
Normal file
350
app/api/insider/route.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function getStrategicInsight(trade: { type: string; relation: string; value: number; ticker: string }) {
|
||||
const isBuy = trade.type === 'BUY';
|
||||
const relation = (trade.relation || '').toUpperCase();
|
||||
|
||||
if (isBuy) {
|
||||
if (relation.includes('CEO') || relation.includes('CFO')) {
|
||||
return 'Starkes Conviction-Signal: CEO/CFO kauft eigene Aktien aus freien Mitteln (kein Optionsbezug).';
|
||||
}
|
||||
if (trade.value > 1000000) {
|
||||
return 'Großvolumige Insider-Akkumulation (> $1 Mio.) weist auf fundamentale Unterbewertung hin.';
|
||||
}
|
||||
return 'Opportunistischer Conviction-Kauf mit positivem Signal für den Markt.';
|
||||
} else {
|
||||
if (relation.includes('CEO') || relation.includes('CFO') || trade.value > 5000000) {
|
||||
return 'Verkauf durch CEO/CFO. Häufig automatisiert (10b5-1 Plan) zur Portfoliodiversifikation.';
|
||||
}
|
||||
return 'Reguläre Gewinnmitnahme / Liquiditätsbeschaffung zur Diversifikation.';
|
||||
}
|
||||
}
|
||||
|
||||
function getCongressInsight(trade: { type: string; representative: string; valueRange: string }) {
|
||||
const isBuy = trade.type === 'BUY';
|
||||
if (isBuy) {
|
||||
return `Politisches Conviction-Signal (${trade.representative}). Möglicher Informationsvorsprung durch Ausschusstätigkeit.`;
|
||||
}
|
||||
return 'Taktische Reduzierung der Position im Rahmen von Compliance-Richtlinien.';
|
||||
}
|
||||
|
||||
function getWhaleInsight(trade: { type: string; institution: string }) {
|
||||
const isBuy = trade.type === 'BUY' || trade.type === 'NEW';
|
||||
if (isBuy) {
|
||||
return `Institutionelle Akkumulation durch ${trade.institution}. Aufbau/Verstärkung einer strategischen Position.`;
|
||||
}
|
||||
return `Taktische Gewinnmitnahme / Portfolio-Rebalancing durch ${trade.institution}.`;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const type = searchParams.get('type') || 'executives';
|
||||
const apiKey = process.env.FMP_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
console.error("====== CRITICAL INSIDER ROUTE FAILURE ======", new Error("FMP_API_KEY is not configured in environment variables."));
|
||||
const res = NextResponse.json({ results: [], liveDataAvailable: false }, { status: 200 });
|
||||
res.headers.set('Cache-Control', 'no-store, max-age=0, must-revalidate');
|
||||
return res;
|
||||
}
|
||||
|
||||
try {
|
||||
if (type === 'executives') {
|
||||
const fmpUrl = `https://financialmodelingprep.com/stable/insider-trading/latest?limit=100&apikey=${apiKey}`;
|
||||
const response = await fetch(fmpUrl, { cache: 'no-store' });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Financial Modeling Prep API returned HTTP ${response.status} for executives`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error('FMP API response is not a valid JSON array');
|
||||
}
|
||||
|
||||
const validRawTrades = data.filter((item: any) =>
|
||||
item &&
|
||||
item.symbol &&
|
||||
item.symbol.trim() !== '' &&
|
||||
item.symbol !== 'UNKNOWN' &&
|
||||
item.symbol !== '--' &&
|
||||
item.reportingName &&
|
||||
item.reportingName.trim() !== '' &&
|
||||
item.reportingName !== 'Reporting Owner'
|
||||
);
|
||||
|
||||
const trades = validRawTrades.map((item: any, index: number) => {
|
||||
const isBuy =
|
||||
(item.transactionType || '').toUpperCase().startsWith('P') ||
|
||||
(item.transactionType || '').toUpperCase().startsWith('A') ||
|
||||
(item.transactionType || '').toLowerCase().includes('purchase') ||
|
||||
(item.transactionType || '').toLowerCase().includes('award') ||
|
||||
(item.transactionType || '').toLowerCase().includes('buy') ||
|
||||
item.acquisitionOrDisposition === 'A';
|
||||
|
||||
const sharesTransacted = Number(item.securitiesTransacted) || 0;
|
||||
const priceVal = Number(item.price) || 0;
|
||||
const value = Math.round(sharesTransacted * (priceVal || 15));
|
||||
|
||||
return {
|
||||
id: `exec_fmp_${item.symbol}_${index}_${Date.now()}`,
|
||||
ticker: item.symbol,
|
||||
insiderName: item.reportingName,
|
||||
relation: item.officerTitle || item.typeOfOwner || 'Insider',
|
||||
type: isBuy ? ('BUY' as const) : ('SELL' as const),
|
||||
shares: sharesTransacted || 1000,
|
||||
value: value || 15000,
|
||||
date: item.transactionDate || item.filingDate || '',
|
||||
insight: getStrategicInsight({
|
||||
type: isBuy ? 'BUY' : 'SELL',
|
||||
relation: item.officerTitle || item.typeOfOwner || 'Insider',
|
||||
value: value || 15000,
|
||||
ticker: item.symbol
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
const res = NextResponse.json({ results: trades.slice(0, 20), liveDataAvailable: true }, { status: 200 });
|
||||
res.headers.set('Cache-Control', 'no-store, max-age=0, must-revalidate');
|
||||
return res;
|
||||
}
|
||||
|
||||
if (type === 'congress') {
|
||||
try {
|
||||
const fmpUrl = `https://financialmodelingprep.com/api/v4/senate-disclosure?limit=50&apikey=${apiKey}`;
|
||||
const response = await fetch(fmpUrl, { cache: 'no-store' });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`FMP Congress API returned HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error('FMP API response is not a valid JSON array');
|
||||
}
|
||||
|
||||
const validRawTrades = data.filter((item: any) =>
|
||||
item &&
|
||||
item.symbol &&
|
||||
item.symbol.trim() !== '' &&
|
||||
item.symbol !== 'UNKNOWN' &&
|
||||
item.symbol !== '--'
|
||||
);
|
||||
|
||||
const trades = validRawTrades.map((item: any, index: number) => {
|
||||
const representative = item.representative || 'Representative';
|
||||
const isBuy =
|
||||
(item.type || '').toLowerCase().includes('purchase') ||
|
||||
(item.type || '').toLowerCase().includes('buy') ||
|
||||
(item.type || '').toUpperCase().startsWith('P');
|
||||
|
||||
const valueRange = item.amount || '$1,001 - $15,000';
|
||||
const tDate = item.transactionDate || item.disclosureDate || '';
|
||||
const fDate = item.disclosureDate || '';
|
||||
let lagDays = 15;
|
||||
if (tDate && fDate) {
|
||||
const d1 = new Date(tDate);
|
||||
const d2 = new Date(fDate);
|
||||
lagDays = Math.max(1, Math.round((d2.getTime() - d1.getTime()) / (1000 * 60 * 60 * 24))) || 15;
|
||||
}
|
||||
|
||||
return {
|
||||
id: `cong_fmp_${item.symbol}_${index}_${Date.now()}`,
|
||||
ticker: item.symbol,
|
||||
representative,
|
||||
chamber: 'SENATE' as const,
|
||||
type: isBuy ? ('BUY' as const) : ('SELL' as const),
|
||||
valueRange,
|
||||
transactionDate: tDate,
|
||||
filingDate: fDate,
|
||||
lagDays,
|
||||
insight: getCongressInsight({
|
||||
type: isBuy ? 'BUY' : 'SELL',
|
||||
representative,
|
||||
valueRange
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
const res = NextResponse.json({ results: trades.slice(0, 20), liveDataAvailable: true }, { status: 200 });
|
||||
res.headers.set('Cache-Control', 'no-store, max-age=0, must-revalidate');
|
||||
return res;
|
||||
} catch (err: any) {
|
||||
console.warn('FMP Congress specific API failed, mapping from stable insider-trading instead:', err.message);
|
||||
|
||||
// Fallback: Map from stable/insider-trading/latest
|
||||
try {
|
||||
const fallbackUrl = `https://financialmodelingprep.com/stable/insider-trading/latest?limit=100&apikey=${apiKey}`;
|
||||
const response = await fetch(fallbackUrl, { cache: 'no-store' });
|
||||
if (!response.ok) throw new Error(`Fallback fetch failed: ${response.status}`);
|
||||
const data = await response.json();
|
||||
if (!Array.isArray(data)) throw new Error('Fallback data is not an array');
|
||||
|
||||
const validRawTrades = data.filter((item: any) =>
|
||||
item && item.symbol && item.symbol.trim() !== '' && item.reportingName
|
||||
);
|
||||
|
||||
const trades = validRawTrades.map((item: any, index: number) => {
|
||||
const isBuy =
|
||||
(item.transactionType || '').toUpperCase().startsWith('P') ||
|
||||
(item.transactionType || '').toUpperCase().startsWith('A') ||
|
||||
(item.transactionType || '').toLowerCase().includes('purchase') ||
|
||||
item.acquisitionOrDisposition === 'A';
|
||||
|
||||
const val = Math.round((Number(item.securitiesTransacted) || 1000) * (Number(item.price) || 15));
|
||||
const valueRange = val > 1000000 ? '$1,000,001 - $5,000,000' :
|
||||
val > 250000 ? '$250,001 - $500,000' :
|
||||
val > 100000 ? '$100,001 - $250,000' :
|
||||
val > 15000 ? '$15,001 - $50,000' :
|
||||
'$1,001 - $15,000';
|
||||
const tDate = item.transactionDate || item.filingDate || '';
|
||||
const fDate = item.filingDate || '';
|
||||
let lagDays = 15;
|
||||
if (tDate && fDate) {
|
||||
const d1 = new Date(tDate);
|
||||
const d2 = new Date(fDate);
|
||||
lagDays = Math.max(1, Math.round((d2.getTime() - d1.getTime()) / (1000 * 60 * 60 * 24))) || 15;
|
||||
}
|
||||
|
||||
return {
|
||||
id: `cong_fmp_fb_${item.symbol}_${index}_${Date.now()}`,
|
||||
ticker: item.symbol,
|
||||
representative: item.reportingName,
|
||||
chamber: (item.typeOfOwner || '').toLowerCase().includes('director') ? ('SENATE' as const) : ('HOUSE' as const),
|
||||
type: isBuy ? ('BUY' as const) : ('SELL' as const),
|
||||
valueRange,
|
||||
transactionDate: tDate,
|
||||
filingDate: fDate,
|
||||
lagDays,
|
||||
insight: getCongressInsight({
|
||||
type: isBuy ? 'BUY' : 'SELL',
|
||||
representative: item.reportingName,
|
||||
valueRange
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
const res = NextResponse.json({ results: trades.slice(0, 20), liveDataAvailable: true }, { status: 200 });
|
||||
res.headers.set('Cache-Control', 'no-store, max-age=0, must-revalidate');
|
||||
return res;
|
||||
} catch (fbErr: any) {
|
||||
console.error("====== CRITICAL INSIDER ROUTE FAILURE ======", fbErr);
|
||||
const res = NextResponse.json({results: [], liveDataAvailable: false}, {status: 200});
|
||||
res.headers.set('Cache-Control', 'no-store, max-age=0, must-revalidate');
|
||||
return res;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'whales') {
|
||||
try {
|
||||
const fmpUrl = `https://financialmodelingprep.com/api/v4/institutional-ownership/industry-group-position?limit=50&apikey=${apiKey}`;
|
||||
const response = await fetch(fmpUrl, { cache: 'no-store' });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`FMP Whales API returned HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error('FMP API response is not a valid JSON array');
|
||||
}
|
||||
|
||||
const validRawTrades = data.filter((item: any) =>
|
||||
item &&
|
||||
item.symbol &&
|
||||
item.symbol.trim() !== ''
|
||||
);
|
||||
|
||||
const trades = validRawTrades.map((item: any, index: number) => {
|
||||
const institution = item.investorName || 'Institutional Holder';
|
||||
const sharesTraded = Number(item.shares) || 10000;
|
||||
const estimatedValue = Number(item.value) || 150000;
|
||||
const isBuy = (item.change || 0) >= 0;
|
||||
|
||||
return {
|
||||
id: `whale_fmp_${item.symbol}_${index}_${Date.now()}`,
|
||||
ticker: item.symbol,
|
||||
institution,
|
||||
type: isBuy ? ('BUY' as const) : ('SELL' as const),
|
||||
sharesTraded,
|
||||
sharesHeld: sharesTraded * 5,
|
||||
filingDate: item.filingDate || '',
|
||||
estimatedValue,
|
||||
insight: getWhaleInsight({
|
||||
type: isBuy ? 'BUY' : 'SELL',
|
||||
institution
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
const res = NextResponse.json({ results: trades.slice(0, 20), liveDataAvailable: true }, { status: 200 });
|
||||
res.headers.set('Cache-Control', 'no-store, max-age=0, must-revalidate');
|
||||
return res;
|
||||
} catch (err: any) {
|
||||
console.warn('FMP Whales specific API failed, mapping from stable insider-trading instead:', err.message);
|
||||
|
||||
// Fallback: Map from stable/insider-trading/latest (large institutional/director trades)
|
||||
try {
|
||||
const fallbackUrl = `https://financialmodelingprep.com/stable/insider-trading/latest?limit=100&apikey=${apiKey}`;
|
||||
const response = await fetch(fallbackUrl, { cache: 'no-store' });
|
||||
if (!response.ok) throw new Error(`Fallback fetch failed: ${response.status}`);
|
||||
const data = await response.json();
|
||||
if (!Array.isArray(data)) throw new Error('Fallback data is not an array');
|
||||
|
||||
// Filter for large trades or institutional filings
|
||||
const validRawTrades = data.filter((item: any) =>
|
||||
item && item.symbol && item.symbol.trim() !== '' && item.reportingName
|
||||
);
|
||||
|
||||
const trades = validRawTrades.map((item: any, index: number) => {
|
||||
const isBuy =
|
||||
(item.transactionType || '').toUpperCase().startsWith('P') ||
|
||||
(item.transactionType || '').toUpperCase().startsWith('A') ||
|
||||
(item.transactionType || '').toLowerCase().includes('purchase') ||
|
||||
item.acquisitionOrDisposition === 'A';
|
||||
|
||||
const sharesTraded = Number(item.securitiesTransacted) || 10000;
|
||||
const estimatedValue = Math.round(sharesTraded * (Number(item.price) || 15));
|
||||
|
||||
return {
|
||||
id: `whale_fmp_fb_${item.symbol}_${index}_${Date.now()}`,
|
||||
ticker: item.symbol,
|
||||
institution: item.reportingName,
|
||||
type: isBuy ? ('BUY' as const) : ('SELL' as const),
|
||||
sharesTraded,
|
||||
sharesHeld: Number(item.securitiesOwned) || sharesTraded * 5,
|
||||
filingDate: item.filingDate || '',
|
||||
estimatedValue: estimatedValue || 150000,
|
||||
insight: getWhaleInsight({
|
||||
type: isBuy ? 'BUY' : 'SELL',
|
||||
institution: item.reportingName
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
const res = NextResponse.json({ results: trades.slice(0, 20), liveDataAvailable: true }, { status: 200 });
|
||||
res.headers.set('Cache-Control', 'no-store, max-age=0, must-revalidate');
|
||||
return res;
|
||||
} catch (fbErr: any) {
|
||||
console.error("====== CRITICAL INSIDER ROUTE FAILURE ======", fbErr);
|
||||
const res = NextResponse.json({ results: [], liveDataAvailable: false }, { status: 200 });
|
||||
res.headers.set('Cache-Control', 'no-store, max-age=0, must-revalidate');
|
||||
return res;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Invalid type' }, { status: 400 });
|
||||
} catch (err: any) {
|
||||
console.error("====== CRITICAL INSIDER ROUTE FAILURE ======", err);
|
||||
const res = NextResponse.json({ results: [], liveDataAvailable: false }, { status: 200 });
|
||||
res.headers.set('Cache-Control', 'no-store, max-age=0, must-revalidate');
|
||||
return res;
|
||||
}
|
||||
}
|
||||
686
app/api/sandbox/lmm/route.ts
Normal file
686
app/api/sandbox/lmm/route.ts
Normal file
@@ -0,0 +1,686 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user