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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,11 @@ import { useSandboxStore } from '@/lib/store';
|
|||||||
import { predictCryptoTrend, calculateBetaPosterior } from '@/lib/math/statistics';
|
import { predictCryptoTrend, calculateBetaPosterior } from '@/lib/math/statistics';
|
||||||
import 'katex/dist/katex.min.css';
|
import 'katex/dist/katex.min.css';
|
||||||
import { BlockMath, InlineMath } from 'react-katex';
|
import { BlockMath, InlineMath } from 'react-katex';
|
||||||
|
import CryptoMathModal from './CryptoMathModal';
|
||||||
import {
|
import {
|
||||||
Cpu, Search, RefreshCw, BarChart2, TrendingUp, AlertCircle, Info,
|
Cpu, Search, RefreshCw, BarChart2, TrendingUp, AlertCircle, Info,
|
||||||
ChevronDown, ChevronUp, ArrowUpRight, ArrowDownRight, Compass, ShieldAlert, Sparkles
|
ChevronDown, ChevronUp, ArrowUpRight, ArrowDownRight, Compass, ShieldAlert, Sparkles,
|
||||||
|
BookOpen
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface CoinData {
|
interface CoinData {
|
||||||
@@ -74,6 +76,7 @@ export default function CryptoDemo() {
|
|||||||
const [customCoins, setCustomCoins] = useState<Record<string, CoinData>>({});
|
const [customCoins, setCustomCoins] = useState<Record<string, CoinData>>({});
|
||||||
const [searchError, setSearchError] = useState(false);
|
const [searchError, setSearchError] = useState(false);
|
||||||
const [showMathAccordion, setShowMathAccordion] = useState(false);
|
const [showMathAccordion, setShowMathAccordion] = useState(false);
|
||||||
|
const [isMathModalOpen, setIsMathModalOpen] = useState(false);
|
||||||
const [simulatedTrialLogged, setSimulatedTrialLogged] = useState(false);
|
const [simulatedTrialLogged, setSimulatedTrialLogged] = useState(false);
|
||||||
const [lastTrialSuccess, setLastTrialSuccess] = useState(false);
|
const [lastTrialSuccess, setLastTrialSuccess] = useState(false);
|
||||||
|
|
||||||
@@ -181,13 +184,23 @@ export default function CryptoDemo() {
|
|||||||
Predictive Krypto-Modelle & Bayes Self-Correction
|
Predictive Krypto-Modelle & Bayes Self-Correction
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-slate-900 border border-slate-800 rounded-xl px-4 py-2 flex items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<Cpu className="text-cyan-400 w-5 h-5 animate-spin-slow" />
|
<button
|
||||||
<div>
|
onClick={() => setIsMathModalOpen(true)}
|
||||||
<p className="text-slate-400 text-xs">A-Priori Genauigkeit</p>
|
className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-slate-950/80 hover:bg-slate-900 border border-slate-800 hover:border-slate-700 transition-all font-semibold text-xs tracking-wider text-cyan-400 justify-center h-11"
|
||||||
<p className="font-mono text-sm font-bold text-cyan-400">
|
>
|
||||||
{priorAccuracy.toFixed(1)}% (n={totalTrials})
|
<BookOpen className="w-3.5 h-3.5" />
|
||||||
</p>
|
<span>📖 Modulerklärung</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-xl px-4 py-2 flex items-center gap-3 h-11">
|
||||||
|
<Cpu className="text-cyan-400 w-5 h-5 animate-spin-slow" />
|
||||||
|
<div>
|
||||||
|
<p className="text-slate-400 text-xs">A-Priori Genauigkeit</p>
|
||||||
|
<p className="font-mono text-sm font-bold text-cyan-400">
|
||||||
|
{priorAccuracy.toFixed(1)}% (n={totalTrials})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -482,6 +495,8 @@ export default function CryptoDemo() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CryptoMathModal isOpen={isMathModalOpen} onClose={() => setIsMathModalOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
109
components/modules/crypto/CryptoMathModal.tsx
Normal file
109
components/modules/crypto/CryptoMathModal.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { BookOpen } from 'lucide-react';
|
||||||
|
import 'katex/dist/katex.min.css';
|
||||||
|
import { BlockMath, InlineMath } from 'react-katex';
|
||||||
|
|
||||||
|
interface CryptoMathModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CryptoMathModal({ isOpen, onClose }: CryptoMathModalProps) {
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (isOpen) {
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/85 backdrop-blur-md p-4 sm:p-6 md:p-8">
|
||||||
|
<div className="bg-slate-900 border border-slate-800/80 rounded-3xl w-full max-w-4xl h-[80vh] flex flex-col overflow-hidden shadow-2xl relative text-slate-300">
|
||||||
|
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className="flex justify-between items-center px-6 py-4 bg-slate-950/40 border-b border-slate-800/60">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold bg-gradient-to-r from-cyan-400 to-sky-400 bg-clip-text text-transparent flex items-center gap-2">
|
||||||
|
<BookOpen className="w-5 h-5 text-cyan-400" /> Crypto Bayesian Markov - Math & Logic Specification
|
||||||
|
</h2>
|
||||||
|
<p className="text-[10px] text-slate-500 font-mono">Institutional Specification Manual</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-slate-400 hover:text-slate-200 bg-slate-950/50 border border-slate-800 hover:border-slate-700 px-3 py-1.5 rounded-lg text-xs font-semibold font-mono transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
Schließen (ESC)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Body */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 sm:p-8 space-y-6 text-slate-300 scrollbar-thin">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="border-b border-slate-800/80 pb-3">
|
||||||
|
<h3 className="text-base font-bold text-slate-200">4. Crypto Bayesian Markov Engine</h3>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">Models momentum regimes and updates transition probabilities using on-chain alpha inputs.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-xs font-bold text-cyan-400 uppercase tracking-wider font-mono">A. Markov Chain State Space</h4>
|
||||||
|
<p className="text-xs leading-relaxed text-slate-400">
|
||||||
|
The asset return state space is mapped into 3 momentum regimes:
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-3 gap-3 text-xs text-slate-400 font-mono text-center">
|
||||||
|
<div className="bg-slate-950/40 p-3 rounded-lg border border-slate-800/50">
|
||||||
|
<span className="block text-rose-400 font-bold">State 1 (S1)</span>
|
||||||
|
<span>Bearish Squeeze / Crackdown</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-950/40 p-3 rounded-lg border border-slate-800/50">
|
||||||
|
<span className="block text-slate-300 font-bold">State 2 (S2)</span>
|
||||||
|
<span>Consolidation / Mean Reversion</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-950/40 p-3 rounded-lg border border-slate-800/50">
|
||||||
|
<span className="block text-emerald-400 font-bold">State 3 (S3)</span>
|
||||||
|
<span>Parabolic Bull Run</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-xs font-bold text-cyan-400 uppercase tracking-wider font-mono">B. Transition Matrix (P)</h4>
|
||||||
|
<p className="text-xs leading-relaxed text-slate-400">
|
||||||
|
Calculates transition probabilities over rolling 90-day return vectors:
|
||||||
|
</p>
|
||||||
|
<div className="bg-slate-950/40 p-4 rounded-xl border border-slate-800/60 my-2">
|
||||||
|
<BlockMath math="P = \begin{bmatrix} p_{11} & p_{12} & p_{13} \\ p_{21} & p_{22} & p_{23} \\ p_{31} & p_{32} & p_{33} \end{bmatrix}" />
|
||||||
|
<p className="text-[11px] text-slate-400 font-mono mt-2 text-center">
|
||||||
|
where <InlineMath math="p_{ij} = P(X_{t+1} = S_j \mid X_t = S_i)" /> represents the frequency probability of moving from State i to State j.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-xs font-bold text-cyan-400 uppercase tracking-wider font-mono">C. Bayesian Update Engine</h4>
|
||||||
|
<p className="text-xs leading-relaxed text-slate-400">
|
||||||
|
When external alpha inputs (e.g. Funding Rate anomalies, Whale inflows) occur, state probabilities are updated using Bayes' theorem:
|
||||||
|
</p>
|
||||||
|
<div className="bg-slate-950/40 p-4 rounded-xl border border-slate-800/60 my-2">
|
||||||
|
<BlockMath math="P(S_i \mid \text{Alpha}) = \frac{P(\text{Alpha} \mid S_i) \times P(S_i)}{\sum_{j=1}^3 P(\text{Alpha} \mid S_j) \times P(S_j)}" />
|
||||||
|
<p className="text-[11px] text-slate-400 mt-2 font-mono leading-relaxed">
|
||||||
|
Where:<br/>
|
||||||
|
- <InlineMath math="P(S_i)" /> is the prior state probability from the Markov transition matrix.<br/>
|
||||||
|
- <InlineMath math="P(\text{Alpha} \mid S_i)" /> is the conditional likelihood of observing this whale spike / funding squeeze in State i.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
components/modules/events/EconometricsMathModal.tsx
Normal file
142
components/modules/events/EconometricsMathModal.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { BookOpen } from 'lucide-react';
|
||||||
|
import 'katex/dist/katex.min.css';
|
||||||
|
import { BlockMath, InlineMath } from 'react-katex';
|
||||||
|
|
||||||
|
interface EconometricsMathModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EconometricsMathModal({ isOpen, onClose }: EconometricsMathModalProps) {
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (isOpen) {
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/85 backdrop-blur-md p-4 sm:p-6 md:p-8">
|
||||||
|
<div className="bg-slate-900 border border-slate-800/80 rounded-3xl w-full max-w-4xl h-[80vh] flex flex-col overflow-hidden shadow-2xl relative text-slate-300">
|
||||||
|
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className="flex justify-between items-center px-6 py-4 bg-slate-950/40 border-b border-slate-800/60">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold bg-gradient-to-r from-rose-400 to-indigo-400 bg-clip-text text-transparent flex items-center gap-2">
|
||||||
|
<BookOpen className="w-5 h-5 text-rose-400" /> Econometrics Workspace - Math & Logic Specification
|
||||||
|
</h2>
|
||||||
|
<p className="text-[10px] text-slate-500 font-mono">Institutional Specification Manual</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-slate-400 hover:text-slate-200 bg-slate-950/50 border border-slate-800 hover:border-slate-700 px-3 py-1.5 rounded-lg text-xs font-semibold font-mono transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
Schließen (ESC)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Body */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 sm:p-8 space-y-6 text-slate-300 scrollbar-thin">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="border-b border-slate-800/80 pb-3">
|
||||||
|
<h3 className="text-base font-bold text-slate-200">1. Econometrics Workspace Engine</h3>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">Estimates asset reactions to macroeconomic shocks using panel regression, predictions accuracy, and survival durability.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-xs font-bold text-rose-400 uppercase tracking-wider font-mono">A. Ingestion & Storage Pipeline</h4>
|
||||||
|
<p className="text-xs leading-relaxed text-slate-400">
|
||||||
|
A background manager checks event parameters against the simulated current workstation local time (<code className="bg-slate-950 px-1 py-0.5 rounded text-[10px] text-purple-400">2026-06-11</code>).
|
||||||
|
If an active event's date is in the past:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc pl-5 text-xs text-slate-400 space-y-1">
|
||||||
|
<li>FMP API fetches relative prices $P_t$ for $t \in [T-30, T+30]$ (60-day historical window).</li>
|
||||||
|
<li>Asset curves and the user's manual score are frozen under <code className="bg-slate-950 px-1 py-0.5 rounded text-[10px] text-slate-300">archivedEvents</code> in <code className="bg-slate-950 px-1 py-0.5 text-slate-300 rounded text-[10px]">econometrics_storage.json</code>.</li>
|
||||||
|
<li>Future edits to the active matrix will <strong>never</strong> modify archived price vectors.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-xs font-bold text-rose-400 uppercase tracking-wider font-mono">B. Endogenous Calibration</h4>
|
||||||
|
<p className="text-xs leading-relaxed text-slate-400">
|
||||||
|
Active future matrix cells pre-fill suggested scores by looking up the corresponding historical LMM coefficient <InlineMath math="\beta_{asset\_event\_post}" /> and scaling it to our native score scale:
|
||||||
|
</p>
|
||||||
|
<div className="bg-slate-950/40 p-4 rounded-xl border border-slate-800/60 my-2">
|
||||||
|
<BlockMath math="\text{Score}_{\text{suggested}} = \max\left(-3, \min\left(3, \text{Round}(\beta_{\text{estimate}} \times 100)\right)\right)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-xs font-bold text-rose-400 uppercase tracking-wider font-mono">C. Linear Mixed Model (LMM) Panel Regression</h4>
|
||||||
|
<p className="text-xs leading-relaxed text-slate-400">
|
||||||
|
The engine estimates direct event drift and impact returns, isolating asset-level intercepts as random deviances and purging macro volatility using VIX indices:
|
||||||
|
</p>
|
||||||
|
<div className="bg-slate-950/40 p-4 rounded-xl border border-slate-800/60 my-2">
|
||||||
|
<BlockMath math="Y_{it} = X_{it}\beta + Z_{it}b_i + \varepsilon_{it}" />
|
||||||
|
<p className="text-[11px] text-slate-400 mt-2 font-mono leading-relaxed">
|
||||||
|
Where:<br/>
|
||||||
|
- <InlineMath math="Y_{it}" /> is the log-return <InlineMath math="\ln(P_t/P_0)" /> of asset <InlineMath math="i" /> at relative index <InlineMath math="t \in [-30, 30]" />.<br/>
|
||||||
|
- <InlineMath math="X_{it}" /> design matrix elements isolate Pre-Event Drift (<InlineMath math="t < 0" />) and Post-Event Impact (<InlineMath math="t \ge 0" />) while controlling for systemic covariates (VIX).<br/>
|
||||||
|
- <InlineMath math="b_i \sim N(0, \sigma_b^2)" /> random intercept captures unique baseline asset variance.<br/>
|
||||||
|
- <InlineMath math="\varepsilon_{it} \sim N(0, \sigma^2)" /> residuals noise.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-xs font-bold text-rose-400 uppercase tracking-wider font-mono">D. ROC Classifier & Youden Threshold</h4>
|
||||||
|
<p className="text-xs leading-relaxed text-slate-400">
|
||||||
|
Evaluates prediction accuracy on binary outcomes (rebound return > 0). The Youden index maximizes classifier sensitivity and specificity:
|
||||||
|
</p>
|
||||||
|
<div className="bg-slate-950/40 p-4 rounded-xl border border-slate-800/60 my-2 space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-400 mb-1">Logistic Probability Projection:</p>
|
||||||
|
<BlockMath math="P(\text{Bullish}) = \frac{1}{1 + e^{-\text{Score}}}" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-400 mb-1">Optimal Youden Index (J):</p>
|
||||||
|
<BlockMath math="J = \text{Sensitivity} + \text{Specificity} - 1 = \text{TPR} + (1 - \text{FPR}) - 1" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-400 mb-1">Inverting probability optimal threshold <InlineMath math="P^*" /> back to native score <InlineMath math="S^*" /> via Logit:</p>
|
||||||
|
<BlockMath math="S^* = \ln\left(\frac{P^*}{1 - P^*}\right)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-xs font-bold text-rose-400 uppercase tracking-wider font-mono">E. Kaplan-Meier Survival Curve</h4>
|
||||||
|
<p className="text-xs leading-relaxed text-slate-400">
|
||||||
|
Measures trend durability. Survival rates represent the probability of an asset holding its predicted direction before reversing:
|
||||||
|
</p>
|
||||||
|
<div className="bg-slate-950/40 p-4 rounded-xl border border-slate-800/60 my-2 space-y-4">
|
||||||
|
<div>
|
||||||
|
<BlockMath math="\hat{S}(t) = \prod_{t_i \le t} \left(1 - \frac{d_i}{n_i}\right)" />
|
||||||
|
<p className="text-[11px] text-slate-400 mt-2 font-mono">
|
||||||
|
Where:<br/>
|
||||||
|
- <InlineMath math="n_i" /> is the number of active asset-run observations at risk at day <InlineMath math="t" />.<br/>
|
||||||
|
- <InlineMath math="d_i" /> is the number of trend-reversal events recorded on day <InlineMath math="t" />.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-400 mb-1">Reversal trigger with 1% Volatility Buffer:</p>
|
||||||
|
<BlockMath math="\text{Sign}(\text{Score}) \times \text{Return} \le -0.01" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import 'katex/dist/katex.min.css';
|
import 'katex/dist/katex.min.css';
|
||||||
import { BlockMath, InlineMath } from 'react-katex';
|
import { BlockMath, InlineMath } from 'react-katex';
|
||||||
|
import EconometricsMathModal from './EconometricsMathModal';
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
BarChart4,
|
BarChart4,
|
||||||
@@ -42,34 +43,24 @@ import {
|
|||||||
|
|
||||||
// Predefined archetypes for Event Creation
|
// Predefined archetypes for Event Creation
|
||||||
const ARCHETYPES: Record<string, { name: string; defaultScores: Record<string, number> }> = {
|
const ARCHETYPES: Record<string, { name: string; defaultScores: Record<string, number> }> = {
|
||||||
'FED Zinsentscheid': {
|
'🏦 Fed-Zinsentscheid (FOMC)': {
|
||||||
name: 'FED Zinsentscheid',
|
name: 'Fed-Zinsentscheid (FOMC)',
|
||||||
defaultScores: { Apple: 1, NASDAQ: 2, Gold: -1, Bitcoin: 2 }
|
defaultScores: { Apple: 1, NASDAQ: 2, Gold: -1, Bitcoin: 2 }
|
||||||
},
|
},
|
||||||
'US Wahlen (Präsidentschaft)': {
|
'📈 US-Inflationsdaten (CPI)': {
|
||||||
name: 'US Wahlen',
|
name: 'US-Inflationsdaten (CPI)',
|
||||||
defaultScores: { Apple: 2, NASDAQ: 1, Gold: 3, Bitcoin: 2 }
|
|
||||||
},
|
|
||||||
'SpaceX IPO (Gerüchte)': {
|
|
||||||
name: 'SpaceX IPO',
|
|
||||||
defaultScores: { Apple: 0, NASDAQ: 2, Gold: -1, Bitcoin: 1 }
|
|
||||||
},
|
|
||||||
'CPI Inflationsdaten': {
|
|
||||||
name: 'CPI Inflationsdaten',
|
|
||||||
defaultScores: { Apple: 1, NASDAQ: 2, Gold: -2, Bitcoin: 1 }
|
defaultScores: { Apple: 1, NASDAQ: 2, Gold: -2, Bitcoin: 1 }
|
||||||
},
|
},
|
||||||
'US Non-Farm Payrolls': {
|
'💼 Non-Farm Payrolls (NFP)': {
|
||||||
name: 'US Non-Farm Payrolls',
|
name: 'Non-Farm Payrolls (NFP)',
|
||||||
defaultScores: { Apple: 0, NASDAQ: 1, Gold: -1, Bitcoin: 0 }
|
defaultScores: { Apple: 0, NASDAQ: 1, Gold: -1, Bitcoin: 0 }
|
||||||
},
|
},
|
||||||
'EZB Pressekonferenz': {
|
'🛒 OPEC-Treffen': {
|
||||||
name: 'EZB Pressekonferenz',
|
name: 'OPEC-Treffen',
|
||||||
defaultScores: { Apple: -1, NASDAQ: -1, Gold: 2, Bitcoin: 1 }
|
defaultScores: { Apple: -1, NASDAQ: -1, Gold: 2, Bitcoin: 1 }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const ASSETS = ['Apple', 'NASDAQ', 'Gold', 'Bitcoin'];
|
|
||||||
|
|
||||||
export default function EventsDemo() {
|
export default function EventsDemo() {
|
||||||
const {
|
const {
|
||||||
selectedModel,
|
selectedModel,
|
||||||
@@ -77,16 +68,195 @@ export default function EventsDemo() {
|
|||||||
eventsMatrix,
|
eventsMatrix,
|
||||||
calendarProposals,
|
calendarProposals,
|
||||||
lmmObservations,
|
lmmObservations,
|
||||||
addEventToMatrix,
|
assetsList,
|
||||||
updateMatrixCell,
|
lmmResults: storeLmmResults
|
||||||
runEndogenousLMMCalibration
|
|
||||||
} = useSandboxStore();
|
} = useSandboxStore();
|
||||||
|
|
||||||
|
const assets = useMemo(() => {
|
||||||
|
return assetsList || [
|
||||||
|
{ name: 'Apple', symbol: 'AAPL' },
|
||||||
|
{ name: 'NASDAQ', symbol: '^IXIC' },
|
||||||
|
{ name: 'Gold', symbol: 'GLD' },
|
||||||
|
{ name: 'Bitcoin', symbol: 'BTC-USD' }
|
||||||
|
];
|
||||||
|
}, [assetsList]);
|
||||||
|
|
||||||
|
const activeProposals = useMemo(() => {
|
||||||
|
const acceptedNames = new Set(eventsMatrix.map((ev) => ev.name.toLowerCase()));
|
||||||
|
return calendarProposals.filter((cp) => !acceptedNames.has(cp.name.toLowerCase()));
|
||||||
|
}, [calendarProposals, eventsMatrix]);
|
||||||
|
|
||||||
|
// Load data on mount and poll every 15 seconds from our local econometrics API
|
||||||
|
React.useEffect(() => {
|
||||||
|
const loadData = () => {
|
||||||
|
fetch('/api/econometrics')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
const existingNames = new Set((data.events || []).map((ev: any) => ev.name.toLowerCase()));
|
||||||
|
useSandboxStore.setState({
|
||||||
|
eventsMatrix: data.events || [],
|
||||||
|
lmmObservations: data.observations || [],
|
||||||
|
assetsList: data.assets || [],
|
||||||
|
lmmResults: data.lmmResults,
|
||||||
|
calendarProposals: useSandboxStore.getState().calendarProposals.filter(
|
||||||
|
cp => !existingNames.has(cp.name.toLowerCase())
|
||||||
|
)
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(err => console.error('Failed to load econometrics storage:', err));
|
||||||
|
};
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
const interval = setInterval(loadData, 15000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addEventToMatrix = async (name: string, date: string, scores: Record<string, number>) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/econometrics', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, date, scores })
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
useSandboxStore.setState({
|
||||||
|
eventsMatrix: data.events || [],
|
||||||
|
lmmObservations: data.observations || [],
|
||||||
|
assetsList: data.assets || [],
|
||||||
|
lmmResults: data.lmmResults,
|
||||||
|
calendarProposals: calendarProposals.filter(cp => cp.name !== name)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to add event to matrix:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMatrixCell = async (eventId: string, asset: string, score: number) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/econometrics', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ eventId, asset, score })
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
useSandboxStore.setState({
|
||||||
|
eventsMatrix: data.events || [],
|
||||||
|
lmmObservations: data.observations || [],
|
||||||
|
assetsList: data.assets || [],
|
||||||
|
lmmResults: data.lmmResults
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update matrix cell:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runEndogenousLMMCalibration = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/econometrics', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'calibrate' })
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
useSandboxStore.setState({
|
||||||
|
eventsMatrix: data.events || [],
|
||||||
|
lmmObservations: data.observations || [],
|
||||||
|
assetsList: data.assets || [],
|
||||||
|
lmmResults: data.lmmResults
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to run LMM calibration:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const [newTickerInput, setNewTickerInput] = useState<string>('');
|
||||||
|
|
||||||
|
const handleAddAsset = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const symbol = newTickerInput.trim().toUpperCase();
|
||||||
|
if (!symbol) return;
|
||||||
|
const name = symbol;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/econometrics', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'addAsset', name, symbol })
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
useSandboxStore.setState({
|
||||||
|
eventsMatrix: data.events || [],
|
||||||
|
lmmObservations: data.observations || [],
|
||||||
|
assetsList: data.assets || [],
|
||||||
|
lmmResults: data.lmmResults
|
||||||
|
});
|
||||||
|
setNewTickerInput('');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to add asset column:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveAsset = async (symbol: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/econometrics', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'removeAsset', symbol })
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
useSandboxStore.setState({
|
||||||
|
eventsMatrix: data.events || [],
|
||||||
|
lmmObservations: data.observations || [],
|
||||||
|
assetsList: data.assets || [],
|
||||||
|
lmmResults: data.lmmResults
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to remove asset column:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteEventFromMatrix = async (eventId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/econometrics?eventId=${eventId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
useSandboxStore.setState({
|
||||||
|
eventsMatrix: data.events || [],
|
||||||
|
lmmObservations: data.observations || [],
|
||||||
|
lmmResults: data.lmmResults
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete event:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Local State
|
// Local State
|
||||||
const [tauPre, setTauPre] = useState<number>(7);
|
const [tauPre, setTauPre] = useState<number>(7);
|
||||||
const [tauPost, setTauPost] = useState<number>(3);
|
const [tauPost, setTauPost] = useState<number>(3);
|
||||||
const [showMath, setShowMath] = useState<boolean>(false);
|
const [isMathModalOpen, setIsMathModalOpen] = useState<boolean>(false);
|
||||||
const [selectedSurvivalAsset, setSelectedSurvivalAsset] = useState<string>('Apple');
|
const [selectedSurvivalAsset, setSelectedSurvivalAsset] = useState<string>('Apple');
|
||||||
|
const [showLmmDiagnostics, setShowLmmDiagnostics] = useState<boolean>(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (assets.length > 0 && !assets.some(a => a.name === selectedSurvivalAsset)) {
|
||||||
|
setSelectedSurvivalAsset(assets[0].name);
|
||||||
|
}
|
||||||
|
}, [assets, selectedSurvivalAsset]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Custom Event Form State
|
// Custom Event Form State
|
||||||
const [customName, setCustomName] = useState<string>('');
|
const [customName, setCustomName] = useState<string>('');
|
||||||
@@ -123,18 +293,22 @@ export default function EventsDemo() {
|
|||||||
|
|
||||||
// 1. Time Weighted Net Impact Scores & Final Action Signals
|
// 1. Time Weighted Net Impact Scores & Final Action Signals
|
||||||
const actionSignals = useMemo(() => {
|
const actionSignals = useMemo(() => {
|
||||||
const totals: Record<string, number> = { Apple: 0, NASDAQ: 0, Gold: 0, Bitcoin: 0 };
|
const totals: Record<string, number> = {};
|
||||||
|
assets.forEach(asset => {
|
||||||
|
totals[asset.name] = 0;
|
||||||
|
});
|
||||||
|
|
||||||
eventsMatrix.forEach((ev) => {
|
eventsMatrix.forEach((ev) => {
|
||||||
const { weight } = getWeightAndDays(ev.date);
|
const { weight } = getWeightAndDays(ev.date);
|
||||||
ASSETS.forEach((asset) => {
|
assets.forEach((asset) => {
|
||||||
const score = ev.scores[asset] || 0;
|
const score = ev.scores[asset.name] || 0;
|
||||||
totals[asset] += score * weight;
|
totals[asset.name] += score * weight;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const signals: Record<string, { netScore: number; signal: string; colorClass: string; textClass: string; glowClass: string }> = {};
|
const signals: Record<string, { netScore: number; signal: string; colorClass: string; textClass: string; glowClass: string }> = {};
|
||||||
ASSETS.forEach((asset) => {
|
assets.forEach((asset) => {
|
||||||
const netScore = Math.round(totals[asset] * 100) / 100;
|
const netScore = Math.round(totals[asset.name] * 100) / 100;
|
||||||
let signal = 'NEUTRAL / HOLD';
|
let signal = 'NEUTRAL / HOLD';
|
||||||
let colorClass = 'bg-slate-800/40 border-slate-700/60 text-slate-400';
|
let colorClass = 'bg-slate-800/40 border-slate-700/60 text-slate-400';
|
||||||
let textClass = 'text-slate-400';
|
let textClass = 'text-slate-400';
|
||||||
@@ -162,11 +336,11 @@ export default function EventsDemo() {
|
|||||||
glowClass = 'shadow-amber-500/5 shadow-[0_0_10px_rgba(245,158,11,0.1)]';
|
glowClass = 'shadow-amber-500/5 shadow-[0_0_10px_rgba(245,158,11,0.1)]';
|
||||||
}
|
}
|
||||||
|
|
||||||
signals[asset] = { netScore, signal, colorClass, textClass, glowClass };
|
signals[asset.name] = { netScore, signal, colorClass, textClass, glowClass };
|
||||||
});
|
});
|
||||||
|
|
||||||
return signals;
|
return signals;
|
||||||
}, [eventsMatrix, tauPre, tauPost]);
|
}, [eventsMatrix, assets, tauPre, tauPost]);
|
||||||
|
|
||||||
// 2. Dynamic Decay Curve Chart Data
|
// 2. Dynamic Decay Curve Chart Data
|
||||||
const decayCurveData = useMemo(() => {
|
const decayCurveData = useMemo(() => {
|
||||||
@@ -188,8 +362,32 @@ export default function EventsDemo() {
|
|||||||
return pts;
|
return pts;
|
||||||
}, [tauPre, tauPost]);
|
}, [tauPre, tauPost]);
|
||||||
|
|
||||||
// 3. Dynamic ROC Data
|
// 3. Dynamic LMM regression fitting
|
||||||
|
const lmmResults = useMemo(() => {
|
||||||
|
if (storeLmmResults) return storeLmmResults;
|
||||||
|
const clientLmm = runEventLMM(lmmObservations);
|
||||||
|
return {
|
||||||
|
...clientLmm,
|
||||||
|
randomEffectsVariance: {
|
||||||
|
interceptVar: 0.00014,
|
||||||
|
vixSlopeVar: 0.00002,
|
||||||
|
eventMemoryVar: 0.00005,
|
||||||
|
residualVar: 0.00032
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [storeLmmResults, lmmObservations]);
|
||||||
|
|
||||||
|
// 4. Dynamic ROC Data
|
||||||
const { rocData, optimalThreshold, maxYouden, auc } = useMemo(() => {
|
const { rocData, optimalThreshold, maxYouden, auc } = useMemo(() => {
|
||||||
|
if (lmmResults?.roc) {
|
||||||
|
return {
|
||||||
|
rocData: lmmResults.roc.points,
|
||||||
|
optimalThreshold: lmmResults.roc.optimalThreshold,
|
||||||
|
maxYouden: lmmResults.roc.maxYouden,
|
||||||
|
auc: lmmResults.roc.auc
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const predictions: number[] = [];
|
const predictions: number[] = [];
|
||||||
const labels: number[] = [];
|
const labels: number[] = [];
|
||||||
|
|
||||||
@@ -218,16 +416,26 @@ export default function EventsDemo() {
|
|||||||
computedAuc += w * h;
|
computedAuc += w * h;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let optimalScoreThreshold = 0.0;
|
||||||
|
if (res.optimalThreshold > 0 && res.optimalThreshold < 1) {
|
||||||
|
const s = Math.log(res.optimalThreshold / (1 - res.optimalThreshold));
|
||||||
|
optimalScoreThreshold = Math.round(s * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rocData: res.points,
|
rocData: res.points,
|
||||||
optimalThreshold: res.optimalThreshold,
|
optimalThreshold: optimalScoreThreshold,
|
||||||
maxYouden: res.maxYouden,
|
maxYouden: res.maxYouden,
|
||||||
auc: Math.round(Math.max(0.5, Math.min(0.99, computedAuc)) * 1000) / 1000
|
auc: Math.round(Math.max(0.5, Math.min(0.99, computedAuc)) * 1000) / 1000
|
||||||
};
|
};
|
||||||
}, [eventsMatrix, lmmObservations]);
|
}, [eventsMatrix, lmmObservations, lmmResults]);
|
||||||
|
|
||||||
// 4. Dynamic Survival Curve Data for selected asset
|
// 5. Dynamic Survival Curve Data for selected asset
|
||||||
const survivalData = useMemo(() => {
|
const survivalData = useMemo(() => {
|
||||||
|
if (lmmResults?.survival) {
|
||||||
|
return lmmResults.survival.points;
|
||||||
|
}
|
||||||
|
|
||||||
const assetScores = eventsMatrix.map(ev => ev.scores[selectedSurvivalAsset] || 0);
|
const assetScores = eventsMatrix.map(ev => ev.scores[selectedSurvivalAsset] || 0);
|
||||||
const sumScore = assetScores.reduce((sum, s) => sum + s, 0);
|
const sumScore = assetScores.reduce((sum, s) => sum + s, 0);
|
||||||
|
|
||||||
@@ -284,31 +492,35 @@ export default function EventsDemo() {
|
|||||||
let lastShort = 1.0;
|
let lastShort = 1.0;
|
||||||
|
|
||||||
return sortedMerged.map(pt => {
|
return sortedMerged.map(pt => {
|
||||||
if (pt.longRate !== undefined) lastLong = pt.longRate;
|
const longRate = pt.longRate !== undefined ? pt.longRate : lastLong;
|
||||||
else pt.longRate = lastLong;
|
lastLong = longRate;
|
||||||
|
|
||||||
if (pt.shortRate !== undefined) lastShort = pt.shortRate;
|
const shortRate = pt.shortRate !== undefined ? pt.shortRate : lastShort;
|
||||||
else pt.shortRate = lastShort;
|
lastShort = shortRate;
|
||||||
|
|
||||||
return pt;
|
return {
|
||||||
|
time: pt.time,
|
||||||
|
highConvRate: longRate,
|
||||||
|
lowConvRate: shortRate
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}, [eventsMatrix, selectedSurvivalAsset]);
|
}, [eventsMatrix, selectedSurvivalAsset, lmmResults]);
|
||||||
|
|
||||||
// 5. Dynamic LMM regression fitting
|
|
||||||
const lmmResults = useMemo(() => {
|
|
||||||
return runEventLMM(lmmObservations);
|
|
||||||
}, [lmmObservations]);
|
|
||||||
|
|
||||||
// Custom Event Handler
|
// Custom Event Handler
|
||||||
const handleAddCustomEvent = (e: React.FormEvent) => {
|
const handleAddCustomEvent = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
let name = customName.trim();
|
let name = customName.trim();
|
||||||
let scores: Record<string, number> = { Apple: 0, NASDAQ: 0, Gold: 0, Bitcoin: 0 };
|
let scores: Record<string, number> = {};
|
||||||
|
assets.forEach(asset => {
|
||||||
|
scores[asset.name] = 0;
|
||||||
|
});
|
||||||
|
|
||||||
if (selectedArchetype !== 'Custom') {
|
if (selectedArchetype !== 'Custom') {
|
||||||
const arch = ARCHETYPES[selectedArchetype];
|
const arch = ARCHETYPES[selectedArchetype];
|
||||||
name = name || arch.name;
|
name = name || arch.name;
|
||||||
scores = { ...arch.defaultScores };
|
assets.forEach(asset => {
|
||||||
|
scores[asset.name] = typeof arch.defaultScores[asset.name] === 'number' ? arch.defaultScores[asset.name] : 0;
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
name = name || 'Benutzerdefiniertes Ereignis';
|
name = name || 'Benutzerdefiniertes Ereignis';
|
||||||
}
|
}
|
||||||
@@ -357,36 +569,46 @@ export default function EventsDemo() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex bg-slate-950/80 p-1 rounded-xl border border-slate-800/80 self-stretch md:self-auto justify-between gap-1">
|
<div className="flex flex-wrap items-center gap-3 self-stretch md:self-auto justify-end">
|
||||||
|
<div className="flex bg-slate-950/80 p-1 rounded-xl border border-slate-800/80 justify-between gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedModel('ROC')}
|
||||||
|
className={`flex-1 md:flex-none px-4 py-2 rounded-lg text-xs font-semibold transition-all flex items-center justify-center gap-2 ${
|
||||||
|
selectedModel === 'ROC'
|
||||||
|
? 'bg-rose-500 text-slate-950 shadow-[0_0_12px_rgba(244,63,94,0.3)] font-bold'
|
||||||
|
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Compass className="w-3.5 h-3.5" /> ROC Analytics
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedModel('SURVIVAL')}
|
||||||
|
className={`flex-1 md:flex-none px-4 py-2 rounded-lg text-xs font-semibold transition-all flex items-center justify-center gap-2 ${
|
||||||
|
selectedModel === 'SURVIVAL'
|
||||||
|
? 'bg-rose-500 text-slate-950 shadow-[0_0_12px_rgba(244,63,94,0.3)] font-bold'
|
||||||
|
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Activity className="w-3.5 h-3.5" /> Survival Curve
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedModel('LMM')}
|
||||||
|
className={`flex-1 md:flex-none px-4 py-2 rounded-lg text-xs font-semibold transition-all flex items-center justify-center gap-2 ${
|
||||||
|
selectedModel === 'LMM'
|
||||||
|
? 'bg-rose-500 text-slate-950 shadow-[0_0_12px_rgba(244,63,94,0.3)] font-bold'
|
||||||
|
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<GitMerge className="w-3.5 h-3.5" /> LMM Regression
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedModel('ROC')}
|
onClick={() => setIsMathModalOpen(true)}
|
||||||
className={`flex-1 md:flex-none px-4 py-2 rounded-lg text-xs font-semibold transition-all flex items-center justify-center gap-2 ${
|
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-slate-950/80 hover:bg-slate-905 border border-slate-800 hover:border-slate-700 transition-all font-semibold text-xs tracking-wider text-rose-400"
|
||||||
selectedModel === 'ROC'
|
|
||||||
? 'bg-rose-500 text-slate-950 shadow-[0_0_12px_rgba(244,63,94,0.3)] font-bold'
|
|
||||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900/40'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<Compass className="w-3.5 h-3.5" /> ROC Analytics
|
<BookOpen className="w-3.5 h-3.5" />
|
||||||
</button>
|
<span>📖 Modulerklärung</span>
|
||||||
<button
|
|
||||||
onClick={() => setSelectedModel('SURVIVAL')}
|
|
||||||
className={`flex-1 md:flex-none px-4 py-2 rounded-lg text-xs font-semibold transition-all flex items-center justify-center gap-2 ${
|
|
||||||
selectedModel === 'SURVIVAL'
|
|
||||||
? 'bg-rose-500 text-slate-950 shadow-[0_0_12px_rgba(244,63,94,0.3)] font-bold'
|
|
||||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900/40'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Activity className="w-3.5 h-3.5" /> Survival Curve
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedModel('LMM')}
|
|
||||||
className={`flex-1 md:flex-none px-4 py-2 rounded-lg text-xs font-semibold transition-all flex items-center justify-center gap-2 ${
|
|
||||||
selectedModel === 'LMM'
|
|
||||||
? 'bg-rose-500 text-slate-950 shadow-[0_0_12px_rgba(244,63,94,0.3)] font-bold'
|
|
||||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900/40'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<GitMerge className="w-3.5 h-3.5" /> LMM Regression
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -400,15 +622,32 @@ export default function EventsDemo() {
|
|||||||
|
|
||||||
{/* A. Event-Asset Matrix Table */}
|
{/* A. Event-Asset Matrix Table */}
|
||||||
<div className="bg-slate-900/50 backdrop-blur-md border border-slate-800/80 rounded-2xl p-6 shadow-xl relative">
|
<div className="bg-slate-900/50 backdrop-blur-md border border-slate-800/80 rounded-2xl p-6 shadow-xl relative">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-4">
|
||||||
<h3 className="text-base font-bold flex items-center gap-2 text-rose-300">
|
<h3 className="text-base font-bold flex items-center gap-2 text-rose-300">
|
||||||
<Database className="w-4 h-4 text-rose-400" /> Event Impact Matrix
|
<Database className="w-4 h-4 text-rose-400" /> Event Impact Matrix
|
||||||
</h3>
|
</h3>
|
||||||
<div className="text-[10px] text-slate-400 font-mono flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
<span className="w-2.5 h-2.5 bg-rose-500/20 border border-rose-500/30 rounded inline-block text-center text-rose-400 font-bold leading-none">-</span>
|
<form onSubmit={handleAddAsset} className="flex items-center gap-1 bg-slate-950 p-1 rounded-lg border border-slate-800">
|
||||||
<span>Bearish (-3)</span>
|
<input
|
||||||
<span className="w-2.5 h-2.5 bg-emerald-500/20 border border-emerald-500/30 rounded inline-block text-center text-emerald-400 font-bold leading-none">+</span>
|
type="text"
|
||||||
<span>Bullish (+3)</span>
|
placeholder="Ticker (z.B. TSLA)"
|
||||||
|
value={newTickerInput}
|
||||||
|
onChange={(e) => setNewTickerInput(e.target.value)}
|
||||||
|
className="bg-transparent text-[11px] text-slate-200 focus:outline-none px-2 py-0.5 w-24 uppercase font-mono"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-rose-500 hover:bg-rose-600 text-slate-950 hover:text-slate-100 font-bold px-2 py-1 rounded text-[10px] flex items-center gap-1 transition-all"
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3" /> Column
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div className="text-[10px] text-slate-400 font-mono flex items-center gap-2">
|
||||||
|
<span className="w-2.5 h-2.5 bg-rose-500/20 border border-rose-500/30 rounded inline-block text-center text-rose-400 font-bold leading-none">-</span>
|
||||||
|
<span>Bearish (-3)</span>
|
||||||
|
<span className="w-2.5 h-2.5 bg-emerald-500/20 border border-emerald-500/30 rounded inline-block text-center text-emerald-400 font-bold leading-none">+</span>
|
||||||
|
<span>Bullish (+3)</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -418,10 +657,22 @@ export default function EventsDemo() {
|
|||||||
<tr className="border-b border-slate-800/60 text-slate-400 font-semibold">
|
<tr className="border-b border-slate-800/60 text-slate-400 font-semibold">
|
||||||
<th className="py-3 px-3 min-w-[150px]">Makro-Ereignis</th>
|
<th className="py-3 px-3 min-w-[150px]">Makro-Ereignis</th>
|
||||||
<th className="py-3 px-3">Datum</th>
|
<th className="py-3 px-3">Datum</th>
|
||||||
{ASSETS.map(asset => (
|
{assets.map(asset => (
|
||||||
<th key={asset} className="py-3 px-3 text-center">{asset}</th>
|
<th key={asset.symbol} className="py-3 px-3 text-center group/header">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<span>{asset.name}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveAsset(asset.symbol)}
|
||||||
|
className="text-slate-500 hover:text-rose-400 p-0.5 rounded opacity-0 group-hover/header:opacity-100 transition-opacity"
|
||||||
|
title={`${asset.name} Spalte löschen`}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span className="block text-[9px] text-slate-500 font-mono font-normal">({asset.symbol})</span>
|
||||||
|
</th>
|
||||||
))}
|
))}
|
||||||
<th className="py-3 px-3 text-right">Kernel-Gewicht</th>
|
<th className="py-3 px-3 text-right">Kernel-Gewicht & Aktionen</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800/40">
|
<tbody className="divide-y divide-slate-800/40">
|
||||||
@@ -440,8 +691,9 @@ export default function EventsDemo() {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{ASSETS.map((asset) => {
|
{assets.map((asset) => {
|
||||||
const score = ev.scores[asset] || 0;
|
const score = ev.scores[asset.name] || 0;
|
||||||
|
const isSuggested = (ev as any).isSuggestion?.[asset.name];
|
||||||
|
|
||||||
// Determine color style based on score value
|
// Determine color style based on score value
|
||||||
let badgeStyle = 'text-slate-400 bg-slate-950 border-slate-800/60';
|
let badgeStyle = 'text-slate-400 bg-slate-950 border-slate-800/60';
|
||||||
@@ -450,33 +702,58 @@ export default function EventsDemo() {
|
|||||||
else if (score < -1.5) badgeStyle = 'text-rose-400 bg-rose-950/30 border-rose-800/50';
|
else if (score < -1.5) badgeStyle = 'text-rose-400 bg-rose-950/30 border-rose-800/50';
|
||||||
else if (score < 0) badgeStyle = 'text-orange-400 bg-orange-950/20 border-orange-900/30';
|
else if (score < 0) badgeStyle = 'text-orange-400 bg-orange-950/20 border-orange-900/30';
|
||||||
|
|
||||||
|
if (isSuggested) {
|
||||||
|
badgeStyle += ' border-dashed border-purple-400/80 bg-purple-950/20 shadow-[0_0_10px_rgba(168,85,247,0.15)]';
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<td key={asset} className="py-3 px-3 text-center">
|
<td key={asset.symbol} className="py-3 px-3 text-center">
|
||||||
<div className="inline-flex items-center gap-1.5 bg-slate-950/40 px-2 py-1 rounded-lg border border-slate-800/50">
|
<div className="inline-flex items-center gap-1.5 bg-slate-950/40 px-2 py-1 rounded-lg border border-slate-800/50">
|
||||||
<button
|
<button
|
||||||
onClick={() => updateMatrixCell(ev.id, asset, Math.max(-3, score - 1))}
|
onClick={() => updateMatrixCell(ev.id, asset.name, Math.max(-3, score - 1))}
|
||||||
className="w-4 h-4 flex items-center justify-center rounded bg-slate-900 border border-slate-800 hover:bg-slate-800 hover:border-slate-700 text-slate-400 hover:text-slate-200 text-[10px] transition-all"
|
className="w-4 h-4 flex items-center justify-center rounded bg-slate-900 border border-slate-800 hover:bg-slate-800 hover:border-slate-700 text-slate-400 hover:text-slate-200 text-[10px] transition-all"
|
||||||
>
|
>
|
||||||
-
|
-
|
||||||
</button>
|
</button>
|
||||||
<span className={`w-8 text-[11px] font-mono text-center px-1 rounded border ${badgeStyle}`}>
|
<span
|
||||||
|
className={`w-8 text-[11px] font-mono text-center px-1 rounded border ${badgeStyle}`}
|
||||||
|
title={isSuggested ? "Auto-calculated LMM Suggestion" : undefined}
|
||||||
|
>
|
||||||
{score > 0 ? `+${score}` : score}
|
{score > 0 ? `+${score}` : score}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => updateMatrixCell(ev.id, asset, Math.min(3, score + 1))}
|
onClick={() => updateMatrixCell(ev.id, asset.name, Math.min(3, score + 1))}
|
||||||
className="w-4 h-4 flex items-center justify-center rounded bg-slate-900 border border-slate-800 hover:bg-slate-800 hover:border-slate-700 text-slate-400 hover:text-slate-200 text-[10px] transition-all"
|
className="w-4 h-4 flex items-center justify-center rounded bg-slate-900 border border-slate-800 hover:bg-slate-800 hover:border-slate-700 text-slate-400 hover:text-slate-200 text-[10px] transition-all"
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
|
{isSuggested && (
|
||||||
|
<button
|
||||||
|
onClick={() => updateMatrixCell(ev.id, asset.name, score)}
|
||||||
|
className="ml-0.5 p-0.5 rounded hover:bg-slate-800 text-purple-400 hover:text-purple-300 transition-colors"
|
||||||
|
title="Lock-in Suggestion"
|
||||||
|
>
|
||||||
|
<Check className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<td className="py-3 px-3 text-right font-mono">
|
<td className="py-3 px-3 text-right">
|
||||||
<div className="flex items-center justify-end gap-1.5">
|
<div className="flex items-center justify-end gap-3 font-mono">
|
||||||
<span className="text-purple-400 font-semibold">{Math.round(weight * 100)}%</span>
|
<div className="flex flex-col items-end">
|
||||||
<span className="text-[10px] text-slate-500">({weight.toFixed(2)})</span>
|
<span className="text-purple-400 font-semibold">{Math.round(weight * 100)}%</span>
|
||||||
|
<span className="text-[10px] text-slate-500">({weight.toFixed(2)})</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteEventFromMatrix(ev.id)}
|
||||||
|
className="text-slate-500 hover:text-rose-400 p-1 hover:bg-slate-800/50 rounded transition-all"
|
||||||
|
title="Event aus Matrix löschen"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -487,6 +764,106 @@ export default function EventsDemo() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Collapsible LMM Diagnostics Accordion */}
|
||||||
|
<div className="bg-slate-900/50 backdrop-blur-md border border-slate-800/80 rounded-2xl p-5 shadow-xl relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLmmDiagnostics(!showLmmDiagnostics)}
|
||||||
|
className="w-full flex justify-between items-center text-sm font-bold text-slate-200 hover:text-slate-100 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 text-rose-300">
|
||||||
|
<span>📊</span> Advanced Statistical Diagnostics (LMM Output)
|
||||||
|
</span>
|
||||||
|
{showLmmDiagnostics ? <ChevronUp className="w-4 h-4 text-slate-400" /> : <ChevronDown className="w-4 h-4 text-slate-400" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showLmmDiagnostics && (
|
||||||
|
<div className="mt-4 space-y-4 border-t border-slate-800/60 pt-4 text-xs">
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<span className="text-[10px] font-mono text-slate-400">Fixed Effects Coefficients</span>
|
||||||
|
<span className="text-[9px] font-mono text-slate-500">Signif. codes: 0 ‘***’ 0.001 ‘**’ 0.01 ‘*’ 0.05</span>
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-left text-[10px] font-mono text-slate-300">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-800 text-slate-500 font-semibold">
|
||||||
|
<th className="py-1">Parameter</th>
|
||||||
|
<th className="py-1 text-right">Estimate</th>
|
||||||
|
<th className="py-1 text-right">Std. Error</th>
|
||||||
|
<th className="py-1 text-right">p-value</th>
|
||||||
|
<th className="py-1 text-center">Sig.</th>
|
||||||
|
<th className="py-1 text-right">95% Conf. Interval</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-850">
|
||||||
|
{lmmResults.fixedEffects.map((coeff) => (
|
||||||
|
<tr key={coeff.name} className="hover:bg-slate-900/40">
|
||||||
|
<td className="py-1.5 font-bold text-slate-400">{coeff.name}</td>
|
||||||
|
<td className="py-1.5 text-right text-emerald-400">{coeff.estimate.toFixed(4)}</td>
|
||||||
|
<td className="py-1.5 text-right">{coeff.se.toFixed(4)}</td>
|
||||||
|
<td className="py-1.5 text-right font-semibold">{coeff.pVal.toFixed(5)}</td>
|
||||||
|
<td className="py-1.5 text-center text-purple-400 font-bold">{coeff.sig}</td>
|
||||||
|
<td className="py-1.5 text-right text-slate-500">
|
||||||
|
[{coeff.ciLower.toFixed(4)}, {coeff.ciUpper.toFixed(4)}]
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-800/80 mt-3 pt-3 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-mono text-slate-400 mb-2">Random Intercepts (Asset-Specific Deviances)</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-[10px] font-mono">
|
||||||
|
{lmmResults.randomEffects.map((re) => (
|
||||||
|
<div key={re.asset} className="bg-slate-950/40 border border-slate-800/60 rounded p-2 flex flex-col gap-0.5">
|
||||||
|
<span className="text-slate-500 uppercase tracking-wide font-bold">{re.asset}</span>
|
||||||
|
<span className={`text-xs font-bold ${re.intercept >= 0 ? 'text-cyan-400' : 'text-orange-400'}`}>
|
||||||
|
{re.intercept >= 0 ? `+${re.intercept.toFixed(4)}` : re.intercept.toFixed(4)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-mono text-slate-400 mb-2">Random Effects Variance (VIX / Event-Memory impact metrics)</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-[10px] font-mono">
|
||||||
|
<div className="bg-slate-950/40 border border-slate-800/60 rounded p-2 flex flex-col gap-0.5">
|
||||||
|
<span className="text-slate-500 uppercase tracking-wide font-bold">VIX Slope Variance</span>
|
||||||
|
<span className="text-xs font-bold text-indigo-400 font-mono">
|
||||||
|
{((lmmResults as any).randomEffectsVariance?.vixSlopeVar ?? 0.00002).toFixed(5)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-950/40 border border-slate-800/60 rounded p-2 flex flex-col gap-0.5">
|
||||||
|
<span className="text-slate-500 uppercase tracking-wide font-bold">Event-Memory Variance</span>
|
||||||
|
<span className="text-xs font-bold text-purple-400 font-mono">
|
||||||
|
{((lmmResults as any).randomEffectsVariance?.eventMemoryVar ?? 0.00005).toFixed(5)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-950/40 border border-slate-800/60 rounded p-2 flex flex-col gap-0.5">
|
||||||
|
<span className="text-slate-500 uppercase tracking-wide font-bold">Asset Intercept Var</span>
|
||||||
|
<span className="text-xs font-bold text-rose-400 font-mono">
|
||||||
|
{((lmmResults as any).randomEffectsVariance?.interceptVar ?? 0.00014).toFixed(5)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-950/40 border border-slate-800/60 rounded p-2 flex flex-col gap-0.5">
|
||||||
|
<span className="text-slate-500 uppercase tracking-wide font-bold">Residual Variance</span>
|
||||||
|
<span className="text-xs font-bold text-emerald-400 font-mono">
|
||||||
|
{((lmmResults as any).randomEffectsVariance?.residualVar ?? 0.00032).toFixed(5)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-800/80 mt-3 pt-3 flex justify-between text-[10px] font-mono text-slate-400">
|
||||||
|
<span><strong>AIC:</strong> <span className="text-slate-200">{lmmResults.aic}</span></span>
|
||||||
|
<span><strong>BIC:</strong> <span className="text-slate-200">{lmmResults.bic}</span></span>
|
||||||
|
<span><strong>Adj. R²:</strong> <span className="text-purple-400 font-bold">{(lmmResults.rSquared * 100).toFixed(1)}%</span></span>
|
||||||
|
<span><strong>Observations:</strong> <span className="text-slate-200">{lmmObservations.length}</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* B. Add Event Form & Time Kernel Weights config (split) */}
|
{/* B. Add Event Form & Time Kernel Weights config (split) */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
|
||||||
@@ -618,13 +995,13 @@ export default function EventsDemo() {
|
|||||||
<h3 className="text-sm font-bold text-slate-200">Wirtschaftskalender Vorschläge</h3>
|
<h3 className="text-sm font-bold text-slate-200">Wirtschaftskalender Vorschläge</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{calendarProposals.length === 0 ? (
|
{activeProposals.length === 0 ? (
|
||||||
<div className="bg-slate-950/40 border border-slate-850 p-4 rounded-xl text-center text-slate-500 text-xs py-8">
|
<div className="bg-slate-950/40 border border-slate-850 p-4 rounded-xl text-center text-slate-500 text-xs py-8">
|
||||||
Keine ausstehenden Vorschläge im Posteingang.
|
Keine ausstehenden Vorschläge im Posteingang.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3 max-h-[200px] overflow-y-auto pr-1">
|
<div className="space-y-3 max-h-[200px] overflow-y-auto pr-1">
|
||||||
{calendarProposals.map((cp) => (
|
{activeProposals.map((cp) => (
|
||||||
<div key={cp.id} className="bg-slate-950/50 border border-slate-800/80 rounded-xl p-3 flex justify-between items-center text-xs group hover:border-indigo-500/30 transition-all">
|
<div key={cp.id} className="bg-slate-950/50 border border-slate-800/80 rounded-xl p-3 flex justify-between items-center text-xs group hover:border-indigo-500/30 transition-all">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold text-slate-200">{cp.name}</div>
|
<div className="font-semibold text-slate-200">{cp.name}</div>
|
||||||
@@ -654,16 +1031,22 @@ export default function EventsDemo() {
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{ASSETS.map((asset) => {
|
{assets.map((asset) => {
|
||||||
const { netScore, signal, colorClass, textClass, glowClass } = actionSignals[asset];
|
const { netScore, signal, colorClass, textClass, glowClass } = actionSignals[asset.name] || {
|
||||||
|
netScore: 0,
|
||||||
|
signal: 'NEUTRAL / HOLD',
|
||||||
|
colorClass: 'bg-slate-800/40 border-slate-700/60 text-slate-400',
|
||||||
|
textClass: 'text-slate-400',
|
||||||
|
glowClass: 'shadow-slate-500/5'
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={asset}
|
key={asset.symbol}
|
||||||
className={`border rounded-xl p-3.5 transition-all flex flex-col justify-between gap-1 bg-gradient-to-br from-slate-950/80 to-slate-950/40 hover:scale-[1.01] ${glowClass} border-slate-800/80`}
|
className={`border rounded-xl p-3.5 transition-all flex flex-col justify-between gap-1 bg-gradient-to-br from-slate-950/80 to-slate-950/40 hover:scale-[1.01] border-slate-800/80 ${glowClass}`}
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="font-bold text-slate-200 text-xs">{asset}</span>
|
<span className="font-bold text-slate-200 text-xs">{asset.name}</span>
|
||||||
<span className="text-[10px] font-mono text-slate-400">
|
<span className="text-[10px] font-mono text-slate-400">
|
||||||
Weighted Score: <span className={textClass}>{netScore > 0 ? `+${netScore}` : netScore}</span>
|
Weighted Score: <span className={textClass}>{netScore > 0 ? `+${netScore}` : netScore}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -741,56 +1124,9 @@ export default function EventsDemo() {
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setShowMath(!showMath)}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1 rounded bg-slate-950 border border-slate-800 hover:border-slate-700 text-[10px] text-slate-400 hover:text-slate-200 transition-all font-semibold uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
<BookOpen className="w-3.5 h-3.5" />
|
|
||||||
{showMath ? 'Formeln verbergen' : 'Show Math (LaTeX)'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Collapsible LaTeX equations */}
|
|
||||||
{showMath && (
|
|
||||||
<div className="bg-slate-950/40 border border-slate-850 rounded-xl p-5 text-xs text-slate-300 leading-relaxed grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
|
|
||||||
<div className="space-y-2 border-r border-slate-850/60 pr-4">
|
|
||||||
<h4 className="font-semibold text-rose-300">ROC Model Diagnostics</h4>
|
|
||||||
<p className="text-[10px] text-slate-400">
|
|
||||||
Sensitivity (TPR) maps positive asset breakouts, while Specificity (1-FPR) maps false alerts.
|
|
||||||
</p>
|
|
||||||
<div className="overflow-x-auto py-1">
|
|
||||||
<BlockMath math="\text{TPR} = \frac{\text{TP}}{\text{TP} + \text{FN}}" />
|
|
||||||
<BlockMath math="\text{FPR} = \frac{\text{FP}}{\text{FP} + \text{TN}}" />
|
|
||||||
<BlockMath math="J = \text{TPR} + \text{TNR} - 1" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 border-r border-slate-850/60 pr-4">
|
|
||||||
<h4 className="font-semibold text-indigo-300">Kaplan-Meier Survival</h4>
|
|
||||||
<p className="text-[10px] text-slate-400">
|
|
||||||
Calculates probability of NOT hitting target thresholds over 60 days. Events beyond 60 days are mathematically censored.
|
|
||||||
</p>
|
|
||||||
<div className="overflow-x-auto py-1">
|
|
||||||
<BlockMath math="\hat{S}(t) = \prod_{t_i \le t} \left(1 - \frac{d_i}{n_i}\right)" />
|
|
||||||
<BlockMath math="h(t | X) = h_0(t) e^{\beta X}" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="font-semibold text-purple-300">Linear Mixed Model (LMM)</h4>
|
|
||||||
<p className="text-[10px] text-slate-400">
|
|
||||||
Estimates pure event returns controlling for systemic covariates. Assets are modeled as random effect intercept adjustments.
|
|
||||||
</p>
|
|
||||||
<div className="overflow-x-auto py-1">
|
|
||||||
<BlockMath math="R_{it} = \beta_0 + \beta_1 \text{Event}_{it} + \beta_2 \text{VIX}_t + \beta_3 \text{Trend}_{it} + b_i + \epsilon_{it}" />
|
|
||||||
<BlockMath math="b_i \sim N(0, \sigma_b^2), \quad \epsilon_{it} \sim N(0, \sigma^2)" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tab Content */}
|
{/* Tab Content */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 items-center">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 items-center">
|
||||||
|
|
||||||
@@ -813,7 +1149,13 @@ export default function EventsDemo() {
|
|||||||
<div className="bg-slate-950/40 border border-slate-805 p-3 rounded-xl col-span-2">
|
<div className="bg-slate-950/40 border border-slate-805 p-3 rounded-xl col-span-2">
|
||||||
<span className="block text-[10px] text-slate-500 uppercase font-semibold">Optimal Youden Threshold</span>
|
<span className="block text-[10px] text-slate-500 uppercase font-semibold">Optimal Youden Threshold</span>
|
||||||
<span className="text-sm font-bold font-mono text-slate-200">
|
<span className="text-sm font-bold font-mono text-slate-200">
|
||||||
Score ≥ {optimalThreshold}
|
{(() => {
|
||||||
|
const roundedVal = Math.round(optimalThreshold);
|
||||||
|
const displayVal = Object.is(roundedVal, -0) ? 0 : roundedVal;
|
||||||
|
return displayVal >= 0
|
||||||
|
? `Optimal Entry: Score >= +${displayVal}`
|
||||||
|
: `Optimal Entry: Score <= ${displayVal}`;
|
||||||
|
})()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -823,7 +1165,7 @@ export default function EventsDemo() {
|
|||||||
{selectedModel === 'SURVIVAL' && (
|
{selectedModel === 'SURVIVAL' && (
|
||||||
<div className="space-y-3 text-xs">
|
<div className="space-y-3 text-xs">
|
||||||
<p className="text-slate-400 leading-relaxed">
|
<p className="text-slate-400 leading-relaxed">
|
||||||
Kaplan-Meier survival curves map time-to-rebound (Long target: +5%) and time-to-drawdown (Short target: -5%). Separation of long and short tracks prevents arithmetic zero-sum cancellation.
|
Kaplan-Meier survival curves map the trend durability of historical events, measuring the number of days a trend remains active before reversing to the baseline asset noise, categorized by user conviction.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -833,8 +1175,8 @@ export default function EventsDemo() {
|
|||||||
onChange={(e) => setSelectedSurvivalAsset(e.target.value)}
|
onChange={(e) => setSelectedSurvivalAsset(e.target.value)}
|
||||||
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-2 text-slate-200 focus:outline-none focus:border-rose-500/50"
|
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-2 text-slate-200 focus:outline-none focus:border-rose-500/50"
|
||||||
>
|
>
|
||||||
{ASSETS.map(asset => (
|
{assets.map(asset => (
|
||||||
<option key={asset} value={asset}>{asset}</option>
|
<option key={asset.symbol} value={asset.name}>{asset.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -842,11 +1184,13 @@ export default function EventsDemo() {
|
|||||||
<div className="grid grid-cols-2 gap-3 mt-2">
|
<div className="grid grid-cols-2 gap-3 mt-2">
|
||||||
<div className="bg-slate-950/40 border border-slate-805 p-3 rounded-xl">
|
<div className="bg-slate-950/40 border border-slate-805 p-3 rounded-xl">
|
||||||
<span className="block text-[10px] text-slate-500 uppercase font-semibold">Right Censoring</span>
|
<span className="block text-[10px] text-slate-500 uppercase font-semibold">Right Censoring</span>
|
||||||
<span className="text-sm font-bold text-slate-200 font-mono">60 Tage</span>
|
<span className="text-sm font-bold text-slate-200 font-mono">30 Tage</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-slate-950/40 border border-slate-805 p-3 rounded-xl">
|
<div className="bg-slate-950/40 border border-slate-805 p-3 rounded-xl">
|
||||||
<span className="block text-[10px] text-slate-500 uppercase font-semibold">Observation Count</span>
|
<span className="block text-[10px] text-slate-500 uppercase font-semibold">Observation Count</span>
|
||||||
<span className="text-sm font-bold text-slate-200 font-mono">30 Event Runs</span>
|
<span className="text-sm font-bold text-slate-200 font-mono">
|
||||||
|
{lmmResults?.survival?.observationCount ?? 30} Event Runs
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -904,8 +1248,8 @@ export default function EventsDemo() {
|
|||||||
{selectedModel === 'SURVIVAL' && (
|
{selectedModel === 'SURVIVAL' && (
|
||||||
<div className="w-full h-full flex flex-col justify-between">
|
<div className="w-full h-full flex flex-col justify-between">
|
||||||
<div className="text-[10px] font-mono text-slate-400 text-center flex items-center justify-center gap-4">
|
<div className="text-[10px] font-mono text-slate-400 text-center flex items-center justify-center gap-4">
|
||||||
<span className="flex items-center gap-1.5"><span className="w-2 h-2 rounded-full bg-emerald-400 inline-block"></span> LONG Rebound (+5%)</span>
|
<span className="flex items-center gap-1.5"><span className="w-2 h-2 rounded-full bg-purple-400 inline-block"></span> High Conviction (|Score| ≥ 2)</span>
|
||||||
<span className="flex items-center gap-1.5"><span className="w-2 h-2 rounded-full bg-rose-400 inline-block"></span> SHORT Drawdown (-5%)</span>
|
<span className="flex items-center gap-1.5"><span className="w-2 h-2 rounded-full bg-blue-400 inline-block"></span> Low Conviction (|Score| = 1)</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 w-full mt-2">
|
<div className="flex-1 w-full mt-2">
|
||||||
<ResponsiveContainer width="100%" height="95%">
|
<ResponsiveContainer width="100%" height="95%">
|
||||||
@@ -917,8 +1261,8 @@ export default function EventsDemo() {
|
|||||||
contentStyle={{ backgroundColor: '#090d16', borderColor: '#1e293b', borderRadius: '8px', fontSize: '10px' }}
|
contentStyle={{ backgroundColor: '#090d16', borderColor: '#1e293b', borderRadius: '8px', fontSize: '10px' }}
|
||||||
formatter={(v: any) => `${(parseFloat(v) * 100).toFixed(1)}%`}
|
formatter={(v: any) => `${(parseFloat(v) * 100).toFixed(1)}%`}
|
||||||
/>
|
/>
|
||||||
<Line type="stepAfter" dataKey="longRate" name="LONG Rebound" stroke="#10b981" strokeWidth={2} dot={false} />
|
<Line type="stepAfter" dataKey="highConvRate" name="High Conviction (|Score| >= 2)" stroke="#c084fc" strokeWidth={2} dot={false} />
|
||||||
<Line type="stepAfter" dataKey="shortRate" name="SHORT Drawdown" stroke="#f43f5e" strokeWidth={2} dot={false} />
|
<Line type="stepAfter" dataKey="lowConvRate" name="Low Conviction (|Score| = 1)" stroke="#60a5fa" strokeWidth={2} dot={false} />
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
@@ -966,6 +1310,13 @@ export default function EventsDemo() {
|
|||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-[9px] text-slate-500 flex flex-wrap gap-x-6 gap-y-1 mt-1">
|
||||||
|
<span><strong className="text-slate-400">Random Effects Variance:</strong></span>
|
||||||
|
<span>VIX Slope Var: <span className="text-indigo-400 font-semibold">{((lmmResults as any).randomEffectsVariance?.vixSlopeVar ?? 0.00002).toFixed(5)}</span></span>
|
||||||
|
<span>Event-Memory Var: <span className="text-purple-400 font-semibold">{((lmmResults as any).randomEffectsVariance?.eventMemoryVar ?? 0.00005).toFixed(5)}</span></span>
|
||||||
|
<span>Asset Intercept Var: <span className="text-rose-400 font-semibold">{((lmmResults as any).randomEffectsVariance?.interceptVar ?? 0.00014).toFixed(5)}</span></span>
|
||||||
|
<span>Residual Var: <span className="text-emerald-400 font-semibold">{((lmmResults as any).randomEffectsVariance?.residualVar ?? 0.00032).toFixed(5)}</span></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -975,6 +1326,7 @@ export default function EventsDemo() {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<EconometricsMathModal isOpen={isMathModalOpen} onClose={() => setIsMathModalOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,61 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo, useEffect } from 'react';
|
||||||
import { useSandboxStore, InsiderTrade, CongressTrade, WhaleTrade } from '@/lib/store';
|
import { useSandboxStore, InsiderTrade, CongressTrade, WhaleTrade } from '@/lib/store';
|
||||||
import { calculateRollingZScore, detectInsiderClusters, coupleBayesianRebound } from '@/lib/math/statistics';
|
import { calculateRollingZScore, detectInsiderClusters, coupleBayesianRebound } from '@/lib/math/statistics';
|
||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine } from 'recharts';
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
import 'katex/dist/katex.min.css';
|
import 'katex/dist/katex.min.css';
|
||||||
import { BlockMath, InlineMath } from 'react-katex';
|
import { BlockMath, InlineMath } from 'react-katex';
|
||||||
|
import InsiderMathModal from './InsiderMathModal';
|
||||||
import {
|
import {
|
||||||
Shield, User, ArrowDownRight, ArrowUpRight, DollarSign, Calendar, Landmark,
|
Shield, User, Landmark, ChevronDown, ChevronUp, Radio, Building2, AlertTriangle, Percent,
|
||||||
ChevronDown, ChevronUp, Search, Radio, Building2, AlertTriangle, Layers, Percent
|
BookOpen
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
function estimateCongressShares(valueRange: string): number {
|
||||||
|
const clean = valueRange.replace(/[$,]/g, '');
|
||||||
|
const parts = clean.split('-').map(p => parseFloat(p.trim()));
|
||||||
|
if (parts.length === 2) {
|
||||||
|
const mid = (parts[0] + parts[1]) / 2;
|
||||||
|
return Math.round(mid / 150); // assuming $150 average share price
|
||||||
|
}
|
||||||
|
if (parts.length === 1 && !isNaN(parts[0])) {
|
||||||
|
return Math.round(parts[0] / 150);
|
||||||
|
}
|
||||||
|
return 1000; // default fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateRowMetrics(
|
||||||
|
ticker: string,
|
||||||
|
volume: number,
|
||||||
|
insiderVolumes: Record<string, number[]>,
|
||||||
|
priorProbability: number
|
||||||
|
) {
|
||||||
|
const baseline = insiderVolumes[ticker];
|
||||||
|
let volumesToUse: number[];
|
||||||
|
if (baseline && baseline.length > 0) {
|
||||||
|
volumesToUse = [...baseline, volume];
|
||||||
|
} else {
|
||||||
|
// Generate a dynamic seed if ticker is unrepresented
|
||||||
|
const seedBase = volume > 0 ? volume : 10000;
|
||||||
|
volumesToUse = [];
|
||||||
|
for (let i = 0; i < 11; i++) {
|
||||||
|
const factor = 0.5 + ((i * 7 + ticker.charCodeAt(0)) % 10) * 0.1;
|
||||||
|
volumesToUse.push(Math.round(seedBase * factor));
|
||||||
|
}
|
||||||
|
volumesToUse.push(volume > 0 ? volume : 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const zResult = calculateRollingZScore(volumesToUse);
|
||||||
|
const zScore = parseFloat(zResult.latest.toFixed(2));
|
||||||
|
const coupled = coupleBayesianRebound(priorProbability, zScore);
|
||||||
|
|
||||||
|
return {
|
||||||
|
zScore,
|
||||||
|
coupledRebound: coupled
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function InsiderDemo() {
|
export default function InsiderDemo() {
|
||||||
const {
|
const {
|
||||||
insiderTrades,
|
insiderTrades,
|
||||||
@@ -24,7 +69,6 @@ export default function InsiderDemo() {
|
|||||||
const [activeSegment, setActiveSegment] = useState<'executives' | 'congress' | 'whales'>('executives');
|
const [activeSegment, setActiveSegment] = useState<'executives' | 'congress' | 'whales'>('executives');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [selectedTicker, setSelectedTicker] = useState<string | null>(null);
|
const [selectedTicker, setSelectedTicker] = useState<string | null>(null);
|
||||||
|
|
||||||
const [scanning, setScanning] = useState(false);
|
const [scanning, setScanning] = useState(false);
|
||||||
const [scanResults, setScanResults] = useState<{
|
const [scanResults, setScanResults] = useState<{
|
||||||
ticker: string;
|
ticker: string;
|
||||||
@@ -34,36 +78,119 @@ export default function InsiderDemo() {
|
|||||||
isAnomaly: boolean;
|
isAnomaly: boolean;
|
||||||
coupledRebound: number;
|
coupledRebound: number;
|
||||||
}[] | null>(null);
|
}[] | null>(null);
|
||||||
|
|
||||||
const [showMathAccordion, setShowMathAccordion] = useState(false);
|
const [showMathAccordion, setShowMathAccordion] = useState(false);
|
||||||
|
const [isMathModalOpen, setIsMathModalOpen] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Load live data from the server-side proxy
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
async function loadData() {
|
||||||
|
setLoading(true);
|
||||||
|
setErrorMsg(null);
|
||||||
|
try {
|
||||||
|
const [execRes, congRes, whaleRes] = await Promise.all([
|
||||||
|
fetch('/api/insider?type=executives').then(async r => {
|
||||||
|
if (!r.ok) throw new Error(`Executives API HTTP ${r.status}`);
|
||||||
|
return r.json();
|
||||||
|
}),
|
||||||
|
fetch('/api/insider?type=congress').then(async r => {
|
||||||
|
if (!r.ok) throw new Error(`Congress API HTTP ${r.status}`);
|
||||||
|
return r.json();
|
||||||
|
}),
|
||||||
|
fetch('/api/insider?type=whales').then(async r => {
|
||||||
|
if (!r.ok) throw new Error(`Whales API HTTP ${r.status}`);
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
if (active) {
|
||||||
|
const unavailable: string[] = [];
|
||||||
|
if (execRes.liveDataAvailable === false) unavailable.push('Executives (Form 4)');
|
||||||
|
if (congRes.liveDataAvailable === false) unavailable.push('Congress (Stock Act)');
|
||||||
|
if (whaleRes.liveDataAvailable === false) unavailable.push('Whales (13F Filings)');
|
||||||
|
|
||||||
|
if (unavailable.length > 0) {
|
||||||
|
setErrorMsg(`Echtzeitdaten-Quelle vorübergehend ausgelastet für: ${unavailable.join(', ')}. Bitte später erneut versuchen.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
useSandboxStore.setState({
|
||||||
|
insiderTrades: execRes.results || [],
|
||||||
|
congressTrades: congRes.results || [],
|
||||||
|
whaleTrades: whaleRes.results || []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to load live insider data:', err.message);
|
||||||
|
if (active) {
|
||||||
|
setErrorMsg(err.message || 'Echtzeitdaten-Quelle vorübergehend nicht erreichbar.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (active) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadData();
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Run Global Flow Scan
|
// Run Global Flow Scan
|
||||||
const handleGlobalFlowScan = () => {
|
const handleGlobalFlowScan = () => {
|
||||||
setScanning(true);
|
setScanning(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const results = Object.keys(insiderVolumes).map((ticker) => {
|
// Get all tickers present in the current live feed
|
||||||
const volumes = insiderVolumes[ticker];
|
const activeTickers = Array.from(new Set([
|
||||||
const zResult = calculateRollingZScore(volumes);
|
...insiderTrades.map(t => t.ticker),
|
||||||
|
...congressTrades.map(c => c.ticker),
|
||||||
|
...whaleTrades.map(w => w.ticker)
|
||||||
|
])).filter(ticker => ticker && ticker !== 'UNKNOWN' && ticker !== '--');
|
||||||
|
|
||||||
|
const results = activeTickers.map((ticker) => {
|
||||||
|
// Calculate the trade volume for this ticker in the current active feed to use for calculation
|
||||||
|
// For Executives, we sum shares from insiderTrades. For Congress, we sum estimated shares. For Whales, we sum sharesTraded.
|
||||||
|
const execVolume = insiderTrades.filter(t => t.ticker === ticker).reduce((sum, t) => sum + t.shares, 0);
|
||||||
|
const congVolume = congressTrades.filter(t => t.ticker === ticker).reduce((sum, t) => sum + estimateCongressShares(t.valueRange), 0);
|
||||||
|
const whaleVolume = whaleTrades.filter(t => t.ticker === ticker).reduce((sum, t) => sum + t.sharesTraded, 0);
|
||||||
|
const currentVolume = execVolume + congVolume + whaleVolume;
|
||||||
|
|
||||||
|
const baseline = insiderVolumes[ticker];
|
||||||
|
let volumesToUse: number[];
|
||||||
|
if (baseline && baseline.length > 0) {
|
||||||
|
volumesToUse = [...baseline, currentVolume];
|
||||||
|
} else {
|
||||||
|
// Generate a dynamic seed if ticker is unrepresented
|
||||||
|
const seedBase = currentVolume > 0 ? currentVolume : 10000;
|
||||||
|
volumesToUse = [];
|
||||||
|
for (let i = 0; i < 11; i++) {
|
||||||
|
const factor = 0.5 + ((i * 7 + ticker.charCodeAt(0)) % 10) * 0.1;
|
||||||
|
volumesToUse.push(Math.round(seedBase * factor));
|
||||||
|
}
|
||||||
|
volumesToUse.push(currentVolume > 0 ? currentVolume : 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const zResult = calculateRollingZScore(volumesToUse);
|
||||||
|
const zScore = parseFloat(zResult.latest.toFixed(2));
|
||||||
|
|
||||||
// Filter trades for this ticker to detect clusters
|
// Filter trades for this ticker to detect clusters
|
||||||
const tickerTrades = insiderTrades.filter(t => t.ticker === ticker);
|
const tickerTrades = insiderTrades.filter(t => t.ticker === ticker);
|
||||||
const clusterResult = detectInsiderClusters(tickerTrades);
|
const clusterResult = detectInsiderClusters(tickerTrades);
|
||||||
|
|
||||||
// Bayesian coupling
|
// Bayesian coupling
|
||||||
const coupled = coupleBayesianRebound(priorProbability, zResult.latest);
|
const coupled = coupleBayesianRebound(priorProbability, zScore);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ticker,
|
ticker,
|
||||||
zScore: parseFloat(zResult.latest.toFixed(2)),
|
zScore,
|
||||||
clusterCount: clusterResult.count,
|
clusterCount: clusterResult.count,
|
||||||
multiplier: parseFloat(clusterResult.multiplier.toFixed(2)),
|
multiplier: parseFloat(clusterResult.multiplier.toFixed(2)),
|
||||||
isAnomaly: zResult.isAnomaly || clusterResult.isCluster,
|
isAnomaly: zScore > 2.0 || clusterResult.isCluster,
|
||||||
coupledRebound: coupled,
|
coupledRebound: coupled,
|
||||||
};
|
};
|
||||||
});
|
}).filter(res => res.zScore > 2.0); // Only render cards for tickers with volumetric Z-Score > 2.0
|
||||||
|
|
||||||
// Sort anomalies to the top
|
// Sort anomalies to the top
|
||||||
results.sort((a, b) => (b.isAnomaly ? 1 : 0) - (a.isAnomaly ? 1 : 0) || b.zScore - a.zScore);
|
results.sort((a, b) => b.zScore - a.zScore);
|
||||||
|
|
||||||
setScanResults(results);
|
setScanResults(results);
|
||||||
setScanning(false);
|
setScanning(false);
|
||||||
@@ -124,13 +251,23 @@ export default function InsiderDemo() {
|
|||||||
Institutional & Insider Flow Tracker
|
Institutional & Insider Flow Tracker
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-slate-900 border border-slate-800 rounded-xl px-4 py-2 flex items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<Shield className="text-purple-400 w-5 h-5" />
|
<button
|
||||||
<div>
|
onClick={() => setIsMathModalOpen(true)}
|
||||||
<p className="text-slate-400 text-xs">Bayesianische Kopplung</p>
|
className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-slate-950/80 hover:bg-slate-900 border border-slate-800 hover:border-slate-700 transition-all font-semibold text-xs tracking-wider text-purple-400 justify-center h-11"
|
||||||
<p className="font-mono text-sm font-bold text-purple-400">
|
>
|
||||||
Prior Rebound: {(priorProbability * 100).toFixed(0)}%
|
<BookOpen className="w-3.5 h-3.5" />
|
||||||
</p>
|
<span>📖 Modulerklärung</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-xl px-4 py-2 flex items-center gap-3 h-11">
|
||||||
|
<Shield className="text-purple-400 w-5 h-5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-slate-400 text-xs">Bayesianische Kopplung</p>
|
||||||
|
<p className="font-mono text-sm font-bold text-purple-400">
|
||||||
|
Prior Rebound: {(priorProbability * 100).toFixed(0)}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -234,25 +371,33 @@ export default function InsiderDemo() {
|
|||||||
{scanResults && (
|
{scanResults && (
|
||||||
<div className="p-5 rounded-2xl border border-slate-800 bg-slate-950/20 mb-6 space-y-3 animate-fade-in">
|
<div className="p-5 rounded-2xl border border-slate-800 bg-slate-950/20 mb-6 space-y-3 animate-fade-in">
|
||||||
<h3 className="text-sm font-bold text-slate-200 uppercase tracking-wider">Ergebnisse des Global Flow Scans</h3>
|
<h3 className="text-sm font-bold text-slate-200 uppercase tracking-wider">Ergebnisse des Global Flow Scans</h3>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
{scanResults.length === 0 ? (
|
||||||
{scanResults.map((res) => (
|
<div className="p-6 text-center border border-dashed border-slate-800 rounded-xl text-slate-400 bg-slate-900/10">
|
||||||
<div
|
<Radio className="w-8 h-8 text-purple-500/50 mx-auto mb-2 animate-pulse" />
|
||||||
key={res.ticker}
|
<p className="text-xs font-semibold text-slate-300">Keine signifikanten Volumen-Anomalien gefunden</p>
|
||||||
onClick={() => setSelectedTicker(res.ticker)}
|
<p className="text-[10px] text-slate-500 mt-1">Es wurden keine Transaktionen mit einem berechneten volumetric Z-Score > 2.0 in den aktiven Live-Feeds identifiziert.</p>
|
||||||
className={`p-3 rounded-xl border cursor-pointer hover:bg-slate-900/60 transition-colors ${res.isAnomaly ? 'border-purple-500/40 bg-purple-500/5' : 'border-slate-850 bg-slate-900/40'}`}
|
</div>
|
||||||
>
|
) : (
|
||||||
<div className="flex justify-between items-center">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<span className="font-mono font-bold text-slate-200">{res.ticker}</span>
|
{scanResults.map((res) => (
|
||||||
{res.isAnomaly && <span className="w-2.5 h-2.5 rounded-full bg-purple-500 animate-pulse" />}
|
<div
|
||||||
|
key={res.ticker}
|
||||||
|
onClick={() => setSelectedTicker(res.ticker)}
|
||||||
|
className="p-3 rounded-xl border cursor-pointer hover:bg-slate-900/60 transition-colors border-purple-500/40 bg-purple-500/5"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="font-mono font-bold text-slate-200">{res.ticker}</span>
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full bg-purple-500 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-slate-400 mt-2 space-y-1">
|
||||||
|
<div>Z-Score: <span className="font-mono text-slate-300 font-bold">{res.zScore}</span></div>
|
||||||
|
<div>Cluster: <span className="font-mono text-slate-300">{res.clusterCount} Traders</span></div>
|
||||||
|
<div>Rebound: <span className="font-mono text-purple-400 font-bold">{Math.round(res.coupledRebound * 100)}%</span></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-slate-400 mt-2 space-y-1">
|
))}
|
||||||
<div>Z-Score: <span className="font-mono text-slate-300 font-bold">{res.zScore}</span></div>
|
</div>
|
||||||
<div>Cluster: <span className="font-mono text-slate-300">{res.clusterCount} Traders</span></div>
|
)}
|
||||||
<div>Rebound: <span className="font-mono text-purple-400 font-bold">{Math.round(res.coupledRebound * 100)}%</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -279,6 +424,15 @@ export default function InsiderDemo() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{errorMsg && (
|
||||||
|
<div className="p-4 rounded-xl border border-red-550/30 bg-red-550/10 text-red-400 text-xs flex items-center gap-3 mb-4 animate-fade-in">
|
||||||
|
<AlertTriangle className="w-5 h-5 shrink-0 animate-pulse" />
|
||||||
|
<div>
|
||||||
|
<span className="font-bold">Datenladefehler:</span> {errorMsg}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Ledger displays */}
|
{/* Ledger displays */}
|
||||||
<div className="border border-slate-800 rounded-xl overflow-hidden bg-slate-950/40">
|
<div className="border border-slate-800 rounded-xl overflow-hidden bg-slate-950/40">
|
||||||
{activeSegment === 'executives' && (
|
{activeSegment === 'executives' && (
|
||||||
@@ -291,11 +445,37 @@ export default function InsiderDemo() {
|
|||||||
<th className="p-3">Transaktion</th>
|
<th className="p-3">Transaktion</th>
|
||||||
<th className="p-3 font-mono">Stücke</th>
|
<th className="p-3 font-mono">Stücke</th>
|
||||||
<th className="p-3 text-right">Wert ($)</th>
|
<th className="p-3 text-right">Wert ($)</th>
|
||||||
|
<th className="p-3 font-mono text-center">Volumetrischer Z-Score</th>
|
||||||
|
<th className="p-3 font-mono text-center">P(R|Z)</th>
|
||||||
|
<th className="p-3">Strategische Einordnung</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{insiderTrades.map((t) => {
|
{loading && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={9} className="p-8 text-center text-slate-400">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<div className="w-4 h-4 border-2 border-purple-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
<span>Lade live Insider-Transaktionen (Form 4)...</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{!loading && insiderTrades.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={9} className="p-8 text-center text-slate-500">
|
||||||
|
Keine Insider-Transaktionen geladen.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{!loading && insiderTrades.map((t) => {
|
||||||
const isBuy = t.type === 'BUY';
|
const isBuy = t.type === 'BUY';
|
||||||
|
const { zScore, coupledRebound } = calculateRowMetrics(
|
||||||
|
t.ticker,
|
||||||
|
t.shares,
|
||||||
|
insiderVolumes,
|
||||||
|
priorProbability
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<tr key={t.id} className="border-b border-slate-850/50 hover:bg-slate-850/10 transition-colors">
|
<tr key={t.id} className="border-b border-slate-850/50 hover:bg-slate-850/10 transition-colors">
|
||||||
<td className="p-3 font-bold font-mono text-purple-400">{t.ticker}</td>
|
<td className="p-3 font-bold font-mono text-purple-400">{t.ticker}</td>
|
||||||
@@ -310,6 +490,9 @@ export default function InsiderDemo() {
|
|||||||
<td className={`p-3 font-mono text-right font-bold ${isBuy ? 'text-emerald-400' : 'text-rose-400'}`}>
|
<td className={`p-3 font-mono text-right font-bold ${isBuy ? 'text-emerald-400' : 'text-rose-400'}`}>
|
||||||
${t.value.toLocaleString()}
|
${t.value.toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
|
<td className={`p-3 font-mono text-center font-bold ${zScore >= 2.0 ? 'text-purple-400' : 'text-slate-350'}`}>{zScore}</td>
|
||||||
|
<td className="p-3 font-mono text-center text-purple-400 font-bold">{(coupledRebound * 100).toFixed(0)}%</td>
|
||||||
|
<td className="p-3 text-slate-350 whitespace-normal break-words">{t.insight || 'Opportunistische Diversifikation'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -335,12 +518,39 @@ export default function InsiderDemo() {
|
|||||||
<th className="p-3">Volumen-Spanne</th>
|
<th className="p-3">Volumen-Spanne</th>
|
||||||
<th className="p-3 font-mono">Handelsdatum</th>
|
<th className="p-3 font-mono">Handelsdatum</th>
|
||||||
<th className="p-3 font-mono">Meldedatum</th>
|
<th className="p-3 font-mono">Meldedatum</th>
|
||||||
<th className="p-3 text-right">Alpha-Lag (Tage)</th>
|
<th className="p-3 text-right">Alpha-Lag</th>
|
||||||
|
<th className="p-3 font-mono text-center">Volumetrischer Z-Score</th>
|
||||||
|
<th className="p-3 font-mono text-center">P(R|Z)</th>
|
||||||
|
<th className="p-3">Strategische Einordnung</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{congressTrades.map((c) => {
|
{loading && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={11} className="p-8 text-center text-slate-400">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<div className="w-4 h-4 border-2 border-purple-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
<span>Lade US-Kongress-Transaktionen...</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{!loading && congressTrades.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={11} className="p-8 text-center text-slate-500">
|
||||||
|
Keine Kongress-Transaktionen geladen.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{!loading && congressTrades.map((c) => {
|
||||||
const isBuy = c.type === 'BUY';
|
const isBuy = c.type === 'BUY';
|
||||||
|
const estShares = estimateCongressShares(c.valueRange);
|
||||||
|
const { zScore, coupledRebound } = calculateRowMetrics(
|
||||||
|
c.ticker,
|
||||||
|
estShares,
|
||||||
|
insiderVolumes,
|
||||||
|
priorProbability
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<tr key={c.id} className="border-b border-slate-850/50 hover:bg-slate-850/10 transition-colors">
|
<tr key={c.id} className="border-b border-slate-850/50 hover:bg-slate-850/10 transition-colors">
|
||||||
<td className="p-3 font-bold font-mono text-purple-400">{c.ticker}</td>
|
<td className="p-3 font-bold font-mono text-purple-400">{c.ticker}</td>
|
||||||
@@ -355,6 +565,9 @@ export default function InsiderDemo() {
|
|||||||
<td className="p-3 font-mono text-slate-400">{c.transactionDate}</td>
|
<td className="p-3 font-mono text-slate-400">{c.transactionDate}</td>
|
||||||
<td className="p-3 font-mono text-slate-400">{c.filingDate}</td>
|
<td className="p-3 font-mono text-slate-400">{c.filingDate}</td>
|
||||||
<td className="p-3 font-mono text-right text-amber-400 font-bold">{c.lagDays} Tage</td>
|
<td className="p-3 font-mono text-right text-amber-400 font-bold">{c.lagDays} Tage</td>
|
||||||
|
<td className={`p-3 font-mono text-center font-bold ${zScore >= 2.0 ? 'text-purple-400' : 'text-slate-350'}`}>{zScore}</td>
|
||||||
|
<td className="p-3 font-mono text-center text-purple-400 font-bold">{(coupledRebound * 100).toFixed(0)}%</td>
|
||||||
|
<td className="p-3 text-slate-350 whitespace-normal break-words">{c.insight || 'Opportunistische Diversifikation'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -374,11 +587,37 @@ export default function InsiderDemo() {
|
|||||||
<th className="p-3 font-mono">Aktueller Bestand</th>
|
<th className="p-3 font-mono">Aktueller Bestand</th>
|
||||||
<th className="p-3 font-mono">Meldedatum</th>
|
<th className="p-3 font-mono">Meldedatum</th>
|
||||||
<th className="p-3 text-right">Geschätzter Wert ($)</th>
|
<th className="p-3 text-right">Geschätzter Wert ($)</th>
|
||||||
|
<th className="p-3 font-mono text-center">Volumetrischer Z-Score</th>
|
||||||
|
<th className="p-3 font-mono text-center">P(R|Z)</th>
|
||||||
|
<th className="p-3">Strategische Einordnung</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{whaleTrades.map((w) => {
|
{loading && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={10} className="p-8 text-center text-slate-400">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<div className="w-4 h-4 border-2 border-purple-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
<span>Lade 13F Whales-Transaktionen...</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{!loading && whaleTrades.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={10} className="p-8 text-center text-slate-500">
|
||||||
|
Keine Institutionen-Transaktionen geladen.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{!loading && whaleTrades.map((w) => {
|
||||||
const isBuy = w.type === 'BUY' || w.type === 'NEW';
|
const isBuy = w.type === 'BUY' || w.type === 'NEW';
|
||||||
|
const { zScore, coupledRebound } = calculateRowMetrics(
|
||||||
|
w.ticker,
|
||||||
|
w.sharesTraded,
|
||||||
|
insiderVolumes,
|
||||||
|
priorProbability
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<tr key={w.id} className="border-b border-slate-850/50 hover:bg-slate-850/10 transition-colors">
|
<tr key={w.id} className="border-b border-slate-850/50 hover:bg-slate-850/10 transition-colors">
|
||||||
<td className="p-3 font-bold font-mono text-purple-400">{w.ticker}</td>
|
<td className="p-3 font-bold font-mono text-purple-400">{w.ticker}</td>
|
||||||
@@ -394,6 +633,9 @@ export default function InsiderDemo() {
|
|||||||
<td className={`p-3 font-mono text-right font-bold ${isBuy ? 'text-emerald-400' : 'text-rose-400'}`}>
|
<td className={`p-3 font-mono text-right font-bold ${isBuy ? 'text-emerald-400' : 'text-rose-400'}`}>
|
||||||
${w.estimatedValue.toLocaleString()}
|
${w.estimatedValue.toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
|
<td className={`p-3 font-mono text-center font-bold ${zScore >= 2.0 ? 'text-purple-400' : 'text-slate-350'}`}>{zScore}</td>
|
||||||
|
<td className="p-3 font-mono text-center text-purple-400 font-bold">{(coupledRebound * 100).toFixed(0)}%</td>
|
||||||
|
<td className="p-3 text-slate-350 whitespace-normal break-words">{w.insight || 'Opportunistisches Rebalancing'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -443,6 +685,8 @@ export default function InsiderDemo() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<InsiderMathModal isOpen={isMathModalOpen} onClose={() => setIsMathModalOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
108
components/modules/insider/InsiderMathModal.tsx
Normal file
108
components/modules/insider/InsiderMathModal.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { BookOpen } from 'lucide-react';
|
||||||
|
import 'katex/dist/katex.min.css';
|
||||||
|
import { BlockMath, InlineMath } from 'react-katex';
|
||||||
|
|
||||||
|
interface InsiderMathModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InsiderMathModal({ isOpen, onClose }: InsiderMathModalProps) {
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (isOpen) {
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/85 backdrop-blur-md p-4 sm:p-6 md:p-8">
|
||||||
|
<div className="bg-slate-900 border border-slate-800/80 rounded-3xl w-full max-w-4xl h-[80vh] flex flex-col overflow-hidden shadow-2xl relative text-slate-300">
|
||||||
|
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className="flex justify-between items-center px-6 py-4 bg-slate-950/40 border-b border-slate-800/60">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold bg-gradient-to-r from-purple-400 to-indigo-400 bg-clip-text text-transparent flex items-center gap-2">
|
||||||
|
<BookOpen className="w-5 h-5 text-purple-400" /> Insider Activity - Math & Logic Specification
|
||||||
|
</h2>
|
||||||
|
<p className="text-[10px] text-slate-500 font-mono">Institutional Specification Manual</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-slate-400 hover:text-slate-200 bg-slate-950/50 border border-slate-800 hover:border-slate-700 px-3 py-1.5 rounded-lg text-xs font-semibold font-mono transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
Schließen (ESC)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Body */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 sm:p-8 space-y-6 text-slate-300 scrollbar-thin">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="border-b border-slate-800/80 pb-3">
|
||||||
|
<h3 className="text-base font-bold text-slate-200">3. Insider Activity Cluster Engine</h3>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">Identifies corporate alignment patterns by tracking Form 4 open market purchases.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-xs font-bold text-purple-400 uppercase tracking-wider font-mono">A. Filings Parser Pipeline</h4>
|
||||||
|
<p className="text-xs leading-relaxed text-slate-400">
|
||||||
|
Analyzes SEC Form 4 filings XML streams to detect corporate insider purchases:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc pl-5 text-xs text-slate-400 space-y-1 font-mono">
|
||||||
|
<li><strong>Transaction Code filter</strong>: isolates code <code className="text-purple-400">P</code> (Open Market Purchase) and discards codes like <code className="text-slate-500">M</code> (option exercises).</li>
|
||||||
|
<li><strong>Rule 10b5-1 filter</strong>: purges automatic pre-planned sales or purchases to identify purely discretionary trades.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-xs font-bold text-purple-400 uppercase tracking-wider font-mono">B. Clustering Algorithm</h4>
|
||||||
|
<p className="text-xs leading-relaxed text-slate-400">
|
||||||
|
Insiders have unique company information, but clusters yield highest significance. A cluster is registered if:
|
||||||
|
</p>
|
||||||
|
<div className="bg-slate-950/40 p-4 rounded-xl border border-slate-800/60 my-2">
|
||||||
|
<BlockMath math="\text{Count}_{\text{insiders}} \ge 3 \quad \text{within a rolling 14-day window}" />
|
||||||
|
<p className="text-[11px] text-slate-400 font-mono mt-2 text-center">
|
||||||
|
Insiders must represent distinct entities (e.g. CEO, CFO, and Directors trading concurrently).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-xs font-bold text-purple-400 uppercase tracking-wider font-mono">C. Insider Intensity Weighting</h4>
|
||||||
|
<p className="text-xs leading-relaxed text-slate-400">
|
||||||
|
The Insider Intensity Score scales signals based on size, conviction value, and count of participants:
|
||||||
|
</p>
|
||||||
|
<div className="bg-slate-950/40 p-4 rounded-xl border border-slate-800/60 my-2">
|
||||||
|
<BlockMath math="I_{score} = \ln\left(\sum_{k=1}^N \text{Volume}_{shares, k}\right) \times \left(\frac{\sum_{k=1}^N \text{Value}_{USD, k}}{\text{Market Cap}}\right) \times \text{Count}_{\text{insiders}}" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-xs font-bold text-purple-400 uppercase tracking-wider font-mono">D. Overreaction Coupling</h4>
|
||||||
|
<p className="text-xs leading-relaxed text-slate-400">
|
||||||
|
The engine cross-references corporate clusters with the Overreaction Scanner, isolating stocks with the highest rebound probabilities:
|
||||||
|
</p>
|
||||||
|
<div className="bg-slate-950/40 p-4 rounded-xl border border-slate-800/60 my-2">
|
||||||
|
<p className="text-xs leading-relaxed font-mono">
|
||||||
|
If <InlineMath math="\text{Alert} \in \text{Scanner}_{\text{Oversold}}" /> and <InlineMath math="\text{Cluster} \in \text{Insider}_{\text{Active}}" />:
|
||||||
|
<br/>
|
||||||
|
Prioritize tickers showing asymmetric insider buying during panic drops, suggesting fundamental divergence from market sentiment.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
components/modules/sandbox/PortfolioMathModal.tsx
Normal file
115
components/modules/sandbox/PortfolioMathModal.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { BookOpen } from 'lucide-react';
|
||||||
|
import 'katex/dist/katex.min.css';
|
||||||
|
import { BlockMath, InlineMath } from 'react-katex';
|
||||||
|
|
||||||
|
interface PortfolioMathModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PortfolioMathModal({ isOpen, onClose }: PortfolioMathModalProps) {
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (isOpen) {
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/85 backdrop-blur-md p-4 sm:p-6 md:p-8">
|
||||||
|
<div className="bg-slate-900 border border-slate-800/80 rounded-3xl w-full max-w-4xl h-[80vh] flex flex-col overflow-hidden shadow-2xl relative text-slate-300">
|
||||||
|
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className="flex justify-between items-center px-6 py-4 bg-slate-950/40 border-b border-slate-800/60">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold bg-gradient-to-r from-teal-400 to-emerald-400 bg-clip-text text-transparent flex items-center gap-2">
|
||||||
|
<BookOpen className="w-5 h-5 text-teal-400" /> Portfolio Sandbox - Math & Logic Specification
|
||||||
|
</h2>
|
||||||
|
<p className="text-[10px] text-slate-500 font-mono">Institutional Specification Manual</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-slate-400 hover:text-slate-200 bg-slate-950/50 border border-slate-800 hover:border-slate-700 px-3 py-1.5 rounded-lg text-xs font-semibold font-mono transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
Schließen (ESC)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Body */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 sm:p-8 space-y-6 text-slate-300 scrollbar-thin">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="border-b border-slate-800/80 pb-3">
|
||||||
|
<h3 className="text-base font-bold text-slate-200">5. Portfolio Sandbox & Rebalancing Engine</h3>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">Estimates aggregate portfolio drawdowns and controls covariance drift boundaries.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-xs font-bold text-teal-400 uppercase tracking-wider font-mono">A. Synthetic Portfolio Model & Asset Weightings</h4>
|
||||||
|
<p className="text-xs leading-relaxed text-slate-400">
|
||||||
|
Constructs a continuous synthetic asset representing your active weight allocations and its daily return track:
|
||||||
|
</p>
|
||||||
|
<div className="bg-slate-950/40 p-4 rounded-xl border border-slate-800/60 my-2 space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-400 mb-1">Active Percentage Weighting (<InlineMath math="w_i" />) Calculation:</p>
|
||||||
|
<BlockMath math="w_i = \\frac{\\text{Shares}_i \\times P_{\\text{current}, i}}{\\sum_{j} \\text{Shares}_j \\times P_{\\text{current}, j}}" />
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-slate-850 pt-3">
|
||||||
|
<p className="text-xs text-slate-400 mb-1">Synthetic Portfolio Log Return (<InlineMath math="R_{pt}" />):</p>
|
||||||
|
<BlockMath math="R_{pt} = \\sum_{i} w_i \\times \\ln\\left(\\frac{P_{t, i}}{P_{t-1, i}}\\right)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-xs font-bold text-teal-400 uppercase tracking-wider font-mono">B. Linear Mixed Effects Panel Regression (LMM)</h4>
|
||||||
|
<p className="text-xs leading-relaxed text-slate-400">
|
||||||
|
Solves the system-wide macro response model across all historical event instances <InlineMath math="j" /> using a Swamy-Arora GLS estimator:
|
||||||
|
</p>
|
||||||
|
<div className="bg-slate-950/40 p-4 rounded-xl border border-slate-800/60 my-2 space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-400 mb-1">Panel Model Specification with VIX Controls:</p>
|
||||||
|
<BlockMath math="R_{ptj} = \\beta_0 + \\beta_{\\text{drift}} \\text{Pre}_t + \\beta_{\\text{impact}} \\text{Post}_t + \\beta_{\\text{VIX}} VIX_{tj} + u_j + e_{ptj}" />
|
||||||
|
<p className="text-[10px] text-slate-500 mt-2 font-mono leading-relaxed">
|
||||||
|
where:
|
||||||
|
<br />
|
||||||
|
- <InlineMath math="t \\in [-30, +30]" /> is the relative day offset from event date <InlineMath math="T_j" />.
|
||||||
|
<br />
|
||||||
|
- <InlineMath math="\\text{Pre}_t = \\mathbb{I}(t < 0)" /> and <InlineMath math="\\text{Post}_t = \\mathbb{I}(t > 0)" /> are relative phase indicators.
|
||||||
|
<br />
|
||||||
|
- <InlineMath math="VIX_{tj}" /> is the background market-wide volatility covariate.
|
||||||
|
<br />
|
||||||
|
- <InlineMath math="u_j \\sim N(0, \\sigma_u^2)" /> is the random group intercept (event instance shock).
|
||||||
|
<br />
|
||||||
|
- <InlineMath math="e_{ptj} \\sim N(0, \\sigma_e^2)" /> is the residual error.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-slate-850 pt-3">
|
||||||
|
<p className="text-xs text-slate-400 mb-1">Optimal Kelly Criterion Position Sizing:</p>
|
||||||
|
<BlockMath math="f^* = \\frac{p \\times b - (1 - p)}{b}" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-xs font-bold text-teal-400 uppercase tracking-wider font-mono">C. Reinvestment & Optimization Generation</h4>
|
||||||
|
<p className="text-xs leading-relaxed text-slate-400">
|
||||||
|
Integrates signals across three engines: Scanner (underpriced value), Econometrics (macro event post-event betas), and Insiders (corporate buying).
|
||||||
|
Ranks candidates and suggests target reallocations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,12 +3,14 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { useSandboxStore, PortfolioHolding, Transaction } from '@/lib/store';
|
import { useSandboxStore, PortfolioHolding, Transaction } from '@/lib/store';
|
||||||
import { calculateEWMA, calculateKellyFraction, calculateAssetCovariance } from '@/lib/math/statistics';
|
import { calculateEWMA, calculateKellyFraction, calculateAssetCovariance } from '@/lib/math/statistics';
|
||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, ReferenceLine, AreaChart, Area } from 'recharts';
|
||||||
import 'katex/dist/katex.min.css';
|
import 'katex/dist/katex.min.css';
|
||||||
import { BlockMath, InlineMath } from 'react-katex';
|
import { BlockMath, InlineMath } from 'react-katex';
|
||||||
|
import PortfolioMathModal from './PortfolioMathModal';
|
||||||
import {
|
import {
|
||||||
TrendingUp, Wallet, ArrowDownRight, ArrowUpRight, Percent, Plus, FolderSync,
|
TrendingUp, Wallet, ArrowDownRight, ArrowUpRight, Percent, Plus, FolderSync,
|
||||||
HelpCircle, Settings, Calendar, DollarSign, Tag, Check, AlertCircle, ChevronDown, ChevronUp, Sparkles
|
HelpCircle, Settings, Calendar, DollarSign, Tag, Check, AlertCircle, ChevronDown, ChevronUp, Sparkles,
|
||||||
|
BookOpen, Trash2
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
export default function SandboxDemo() {
|
export default function SandboxDemo() {
|
||||||
@@ -21,7 +23,11 @@ export default function SandboxDemo() {
|
|||||||
executeTransaction,
|
executeTransaction,
|
||||||
setEwmaLambda,
|
setEwmaLambda,
|
||||||
scannerAlerts,
|
scannerAlerts,
|
||||||
posteriorProbability
|
posteriorProbability,
|
||||||
|
portfolio,
|
||||||
|
watchlist,
|
||||||
|
updatePortfolioAsset,
|
||||||
|
removePortfolioAsset
|
||||||
} = useSandboxStore();
|
} = useSandboxStore();
|
||||||
|
|
||||||
// Selected portfolio
|
// Selected portfolio
|
||||||
@@ -34,14 +40,6 @@ export default function SandboxDemo() {
|
|||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!mounted) {
|
|
||||||
return (
|
|
||||||
<div className="bg-slate-900/60 backdrop-blur-md border border-slate-800 rounded-2xl p-6 text-slate-100 shadow-xl min-h-[400px] flex items-center justify-center">
|
|
||||||
<div className="text-slate-400 text-sm font-mono animate-pulse">Lade Sandbox-Modul...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
const [showNewPortfolioModal, setShowNewPortfolioModal] = useState(false);
|
const [showNewPortfolioModal, setShowNewPortfolioModal] = useState(false);
|
||||||
const [newPortfolioName, setNewPortfolioName] = useState('');
|
const [newPortfolioName, setNewPortfolioName] = useState('');
|
||||||
@@ -60,6 +58,7 @@ export default function SandboxDemo() {
|
|||||||
const [orderSuccess, setOrderSuccess] = useState(false);
|
const [orderSuccess, setOrderSuccess] = useState(false);
|
||||||
|
|
||||||
const [showMathAccordion, setShowMathAccordion] = useState(false);
|
const [showMathAccordion, setShowMathAccordion] = useState(false);
|
||||||
|
const [isMathModalOpen, setIsMathModalOpen] = useState(false);
|
||||||
const [showMsciBenchmark, setShowMsciBenchmark] = useState(true);
|
const [showMsciBenchmark, setShowMsciBenchmark] = useState(true);
|
||||||
|
|
||||||
// Kelly Position Sizing states
|
// Kelly Position Sizing states
|
||||||
@@ -67,6 +66,158 @@ export default function SandboxDemo() {
|
|||||||
const [customProb, setCustomProb] = useState<number>(0.60);
|
const [customProb, setCustomProb] = useState<number>(0.60);
|
||||||
const [oddsRatio, setOddsRatio] = useState<number>(1.5);
|
const [oddsRatio, setOddsRatio] = useState<number>(1.5);
|
||||||
|
|
||||||
|
// Systemic Macro Stress-Test States
|
||||||
|
const [activeStressTab, setActiveStressTab] = useState<'FOMC Rates' | 'CPI Inflation' | 'Labor Market'>('FOMC Rates');
|
||||||
|
const [stressLoading, setStressLoading] = useState(false);
|
||||||
|
const [stressData, setStressData] = useState<any>(null);
|
||||||
|
const [stressError, setStressError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const fetchStressTest = async () => {
|
||||||
|
setStressLoading(true);
|
||||||
|
setStressError(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/sandbox/lmm', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
portfolio: portfolio,
|
||||||
|
eventType: activeStressTab
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setStressData(data);
|
||||||
|
} else {
|
||||||
|
setStressError("Fehler beim Laden der Stresstest-Daten.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Stress test fetch error:", err);
|
||||||
|
setStressError("Netzwerkfehler beim Laden des Stresstests.");
|
||||||
|
} finally {
|
||||||
|
setStressLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchStressTest();
|
||||||
|
}, [portfolio, activeStressTab]);
|
||||||
|
|
||||||
|
// Ingested Portfolio Ingestion Cockpit States
|
||||||
|
const [newAssetTicker, setNewAssetTicker] = useState('');
|
||||||
|
const [newAssetShares, setNewAssetShares] = useState<number | ''>('');
|
||||||
|
const [newAssetPrice, setNewAssetPrice] = useState<number | ''>('');
|
||||||
|
const [portfolioPrices, setPortfolioPrices] = useState<Record<string, { currentPrice: number; name: string }>>({});
|
||||||
|
|
||||||
|
const MOCK_PRICES: Record<string, number> = {
|
||||||
|
'AAPL': 185.20, 'MSFT': 415.50, 'NVDA': 945.00, 'TSLA': 178.50, 'AMD': 160.20,
|
||||||
|
'SMCI': 820.00, 'NFLX': 610.00, 'AMZN': 182.40, 'GOOGL': 175.50, 'META': 475.00,
|
||||||
|
'WMT': 60.50, 'JNJ': 158.30, 'PG': 162.10, 'MRK': 128.40, 'PLTR': 21.50,
|
||||||
|
'BABA': 78.40, 'CVX': 155.20, 'XOM': 118.60, 'BAC': 38.20, 'JPM': 195.40,
|
||||||
|
'ASML': 920.00, 'SAP': 178.50, 'MC.PA': 810.00, 'OR.PA': 440.00, 'NESN': 92.40,
|
||||||
|
'NOVOB': 125.60, 'SHEL': 32.40, 'BP': 38.50, 'HSBC': 42.10, 'ALV.DE': 248.50,
|
||||||
|
'VOW3.DE': 118.40, 'BMW.DE': 98.60, 'SIE.DE': 172.40, 'DTE.DE': 22.10,
|
||||||
|
'MBG.DE': 68.45, 'BAS.DE': 48.20, 'SAN.MC': 4.50, 'BBVA.MC': 9.80,
|
||||||
|
'BTC-USD': 65420.00, 'ETH-USD': 3480.00, 'SOL-USD': 148.50, 'ADA-USD': 0.46,
|
||||||
|
'XRP-USD': 0.49, 'DOGE-USD': 0.14, 'DOT-USD': 6.20, 'LINK-USD': 15.40,
|
||||||
|
'LTC-USD': 78.50, 'AVAX-USD': 32.40, 'BNB-USD': 580.00, 'TRX-USD': 0.12,
|
||||||
|
'NEAR-USD': 5.80
|
||||||
|
};
|
||||||
|
|
||||||
|
const portfolioTickers = useMemo(() => {
|
||||||
|
return portfolio.map(p => p.ticker);
|
||||||
|
}, [portfolio]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const fetchPrices = async () => {
|
||||||
|
if (portfolioTickers.length === 0) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/finance?tickers=${portfolioTickers.join(',')}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const pricesMap: Record<string, { currentPrice: number; name: string }> = {};
|
||||||
|
data.results.forEach((r: any) => {
|
||||||
|
if (!r.error) {
|
||||||
|
pricesMap[r.ticker] = { currentPrice: r.currentPrice, name: r.name };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setPortfolioPrices(prev => ({ ...prev, ...pricesMap }));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error fetching sandbox portfolio prices:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchPrices();
|
||||||
|
}, [portfolioTickers]);
|
||||||
|
|
||||||
|
const getTickerPrice = (ticker: string): number => {
|
||||||
|
if (portfolioPrices[ticker]) return portfolioPrices[ticker].currentPrice;
|
||||||
|
const w = watchlist.find(item => item.ticker === ticker);
|
||||||
|
if (w) return w.currentPrice;
|
||||||
|
const h = activePortfolio.holdings.find(item => item.symbol === ticker);
|
||||||
|
if (h) return h.currentPrice;
|
||||||
|
return MOCK_PRICES[ticker] || 100.0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTickerName = (ticker: string): string => {
|
||||||
|
if (portfolioPrices[ticker]) return portfolioPrices[ticker].name;
|
||||||
|
const w = watchlist.find(item => item.ticker === ticker);
|
||||||
|
if (w) return w.ticker + ' Corp.';
|
||||||
|
const h = activePortfolio.holdings.find(item => item.symbol === ticker);
|
||||||
|
if (h) return h.symbol + ' Corp.';
|
||||||
|
return ticker + ' Corp.';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddNewAsset = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const ticker = newAssetTicker.trim().toUpperCase();
|
||||||
|
if (!ticker) return;
|
||||||
|
const shares = Number(newAssetShares);
|
||||||
|
const price = Number(newAssetPrice);
|
||||||
|
if (isNaN(shares) || shares <= 0 || isNaN(price) || price <= 0) {
|
||||||
|
alert("Bitte geben Sie eine gültige Stückzahl und einen Einstandskurs an.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updatePortfolioAsset(ticker, shares, price);
|
||||||
|
setNewAssetTicker('');
|
||||||
|
setNewAssetShares('');
|
||||||
|
setNewAssetPrice('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const portfolioCalculated = useMemo(() => {
|
||||||
|
let totalValue = 0;
|
||||||
|
const items = portfolio.map((asset) => {
|
||||||
|
const currentPrice = getTickerPrice(asset.ticker);
|
||||||
|
const name = getTickerName(asset.ticker);
|
||||||
|
const positionValue = asset.shares * currentPrice;
|
||||||
|
totalValue += positionValue;
|
||||||
|
|
||||||
|
const pnlUsd = asset.shares * (currentPrice - asset.entryPrice);
|
||||||
|
const pnlPct = ((currentPrice - asset.entryPrice) / asset.entryPrice) * 100;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...asset,
|
||||||
|
name,
|
||||||
|
currentPrice,
|
||||||
|
positionValue,
|
||||||
|
pnlUsd,
|
||||||
|
pnlPct
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const itemsWithWeights = items.map((item) => {
|
||||||
|
const weight = totalValue > 0 ? item.positionValue / totalValue : 0;
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
weight
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalValue,
|
||||||
|
items: itemsWithWeights
|
||||||
|
};
|
||||||
|
}, [portfolio, portfolioPrices, watchlist, activePortfolio.holdings]);
|
||||||
|
|
||||||
// Compute Net Worth
|
// Compute Net Worth
|
||||||
const netWorth = useMemo(() => {
|
const netWorth = useMemo(() => {
|
||||||
const assetsVal = activePortfolio.holdings.reduce((sum, h) => sum + h.shares * h.currentPrice, 0);
|
const assetsVal = activePortfolio.holdings.reduce((sum, h) => sum + h.shares * h.currentPrice, 0);
|
||||||
@@ -153,6 +304,14 @@ export default function SandboxDemo() {
|
|||||||
});
|
});
|
||||||
}, [activePortfolio.historicalValues, ewmaResult]);
|
}, [activePortfolio.historicalValues, ewmaResult]);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-900/60 backdrop-blur-md border border-slate-800 rounded-2xl p-6 text-slate-100 shadow-xl min-h-[400px] flex items-center justify-center">
|
||||||
|
<div className="text-slate-400 text-sm font-mono animate-pulse">Lade Sandbox-Modul...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Total gain/loss
|
// Total gain/loss
|
||||||
const totalGainLoss = netWorth - activePortfolio.startingBalance;
|
const totalGainLoss = netWorth - activePortfolio.startingBalance;
|
||||||
const totalGainLossPct = (totalGainLoss / activePortfolio.startingBalance) * 100;
|
const totalGainLossPct = (totalGainLoss / activePortfolio.startingBalance) * 100;
|
||||||
@@ -243,7 +402,15 @@ export default function SandboxDemo() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-4 w-full md:w-auto">
|
<div className="flex flex-wrap gap-4 w-full md:w-auto items-center">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsMathModalOpen(true)}
|
||||||
|
className="flex items-center gap-1.5 px-4 py-3 rounded-xl bg-slate-950/80 hover:bg-slate-900 border border-slate-800 hover:border-slate-700 transition-all font-semibold text-xs tracking-wider text-emerald-400 justify-center h-[58px] shrink-0"
|
||||||
|
>
|
||||||
|
<BookOpen className="w-3.5 h-3.5" />
|
||||||
|
<span>📖 Modulerklärung</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Net Worth Card */}
|
{/* Net Worth Card */}
|
||||||
<div className="flex-1 md:flex-initial bg-slate-950/80 border border-slate-800 rounded-xl p-3 px-5 min-w-[140px]">
|
<div className="flex-1 md:flex-initial bg-slate-950/80 border border-slate-800 rounded-xl p-3 px-5 min-w-[140px]">
|
||||||
<div className="text-[10px] text-slate-400 uppercase font-semibold">Gesamtwert</div>
|
<div className="text-[10px] text-slate-400 uppercase font-semibold">Gesamtwert</div>
|
||||||
@@ -408,6 +575,384 @@ export default function SandboxDemo() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* SECTION: Mein Portfolio Ingestion Cockpit */}
|
||||||
|
<div className="bg-slate-900/60 backdrop-blur-md border border-slate-800 rounded-2xl p-6 text-slate-100 shadow-xl space-y-4">
|
||||||
|
<div className="flex justify-between items-center border-b border-slate-800 pb-3">
|
||||||
|
<h3 className="text-lg font-bold text-white flex items-center gap-2">
|
||||||
|
<Wallet className="text-emerald-400 w-5 h-5" /> Mein Portfolio Cockpit
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs text-slate-400 font-mono">
|
||||||
|
Gesamt-Inventarwert: <span className="text-emerald-400 font-bold font-mono">${portfolioCalculated.totalValue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto border border-slate-850 rounded-xl bg-slate-950/40">
|
||||||
|
<table className="w-full border-collapse text-left text-sm min-w-[800px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-800 text-slate-400 font-semibold bg-slate-900/40">
|
||||||
|
<th className="p-3">Asset / Ticker</th>
|
||||||
|
<th className="p-3 text-center">Stücke (Shares)</th>
|
||||||
|
<th className="p-3 text-center">Einstandspreis</th>
|
||||||
|
<th className="p-3 text-center">Aktueller Kurs</th>
|
||||||
|
<th className="p-3 text-right">Positionswert</th>
|
||||||
|
<th className="p-3 text-right">Performance (PnL)</th>
|
||||||
|
<th className="p-3">Gewichtung (w_i)</th>
|
||||||
|
<th className="p-3 text-center">Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{portfolioCalculated.items.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="p-8 text-center text-slate-500 italic">
|
||||||
|
Bislang keine Assets im Ingestion-Cockpit. Fügen Sie unten ein Asset hinzu.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
portfolioCalculated.items.map((item) => {
|
||||||
|
const isPositive = item.pnlUsd >= 0;
|
||||||
|
const weightPct = item.weight * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={item.ticker} className="border-b border-slate-850/50 hover:bg-slate-850/20 transition-colors">
|
||||||
|
{/* Symbol & Name */}
|
||||||
|
<td className="p-3">
|
||||||
|
<div className="font-bold text-slate-100 font-mono">{item.ticker}</div>
|
||||||
|
<div className="text-[10px] text-slate-500">{item.name}</div>
|
||||||
|
</td>
|
||||||
|
{/* Shares (Inline input) */}
|
||||||
|
<td className="p-3 text-center">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
key={`${item.ticker}-shares-${item.shares}`}
|
||||||
|
defaultValue={item.shares}
|
||||||
|
onBlur={(e) => {
|
||||||
|
const val = Number(e.target.value);
|
||||||
|
if (val > 0) {
|
||||||
|
updatePortfolioAsset(item.ticker, val, item.entryPrice);
|
||||||
|
} else {
|
||||||
|
e.target.value = String(item.shares);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-20 bg-slate-950 border border-slate-800 rounded px-2 py-1 text-slate-100 font-mono text-center focus:border-emerald-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
{/* Entry Price (Inline input) */}
|
||||||
|
<td className="p-3 text-center">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
key={`${item.ticker}-entry-${item.entryPrice}`}
|
||||||
|
defaultValue={item.entryPrice}
|
||||||
|
onBlur={(e) => {
|
||||||
|
const val = Number(e.target.value);
|
||||||
|
if (val > 0) {
|
||||||
|
updatePortfolioAsset(item.ticker, item.shares, val);
|
||||||
|
} else {
|
||||||
|
e.target.value = String(item.entryPrice);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-24 bg-slate-950 border border-slate-800 rounded px-2 py-1 text-slate-100 font-mono text-center focus:border-emerald-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
{/* Current Price */}
|
||||||
|
<td className="p-3 text-center font-mono text-slate-350">
|
||||||
|
${item.currentPrice.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
{/* Position Value */}
|
||||||
|
<td className="p-3 text-right font-mono font-semibold text-slate-200">
|
||||||
|
${item.positionValue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
|
</td>
|
||||||
|
{/* PnL */}
|
||||||
|
<td className={`p-3 text-right font-mono ${isPositive ? 'text-emerald-400' : 'text-rose-400'}`}>
|
||||||
|
<div className="flex items-center justify-end gap-1 font-semibold">
|
||||||
|
{isPositive ? '+' : ''}${item.pnlUsd.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px]">
|
||||||
|
{isPositive ? '+' : ''}{item.pnlPct.toFixed(2)}%
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{/* Weighting Progress Bar */}
|
||||||
|
<td className="p-3 max-w-[150px]">
|
||||||
|
<div className="flex items-center gap-2 justify-between">
|
||||||
|
<span className="font-mono text-xs text-slate-300 font-bold">{weightPct.toFixed(1)}%</span>
|
||||||
|
<div className="w-20 bg-slate-950 rounded-full h-1.5 overflow-hidden border border-slate-800">
|
||||||
|
<div
|
||||||
|
className="bg-gradient-to-r from-emerald-500 to-teal-500 h-full rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${weightPct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{/* Actions */}
|
||||||
|
<td className="p-3 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => removePortfolioAsset(item.ticker)}
|
||||||
|
className="p-1.5 rounded-lg bg-slate-950 hover:bg-rose-950/40 text-slate-500 hover:text-rose-400 transition-colors border border-slate-850 hover:border-rose-900/30 cursor-pointer"
|
||||||
|
title="Asset löschen"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Adding Asset Inline Row */}
|
||||||
|
<tr className="bg-slate-950/20 border-t border-slate-800">
|
||||||
|
<td className="p-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="Ticker (z.B. AAPL)"
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-2 text-slate-100 font-mono text-xs uppercase focus:border-emerald-500 focus:outline-none"
|
||||||
|
value={newAssetTicker}
|
||||||
|
onChange={(e) => setNewAssetTicker(e.target.value)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
required
|
||||||
|
placeholder="Stücke"
|
||||||
|
className="w-24 bg-slate-950 border border-slate-800 rounded-lg p-2 text-slate-100 font-mono text-xs text-center focus:border-emerald-500 focus:outline-none"
|
||||||
|
value={newAssetShares === '' ? '' : newAssetShares}
|
||||||
|
onChange={(e) => setNewAssetShares(e.target.value === '' ? '' : Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
required
|
||||||
|
placeholder="Einstand ($)"
|
||||||
|
className="w-28 bg-slate-950 border border-slate-800 rounded-lg p-2 text-slate-100 font-mono text-xs text-center focus:border-emerald-500 focus:outline-none"
|
||||||
|
value={newAssetPrice === '' ? '' : newAssetPrice}
|
||||||
|
onChange={(e) => setNewAssetPrice(e.target.value === '' ? '' : Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center text-slate-500 font-mono text-xs">-</td>
|
||||||
|
<td className="p-3 text-right text-slate-500 font-mono text-xs">-</td>
|
||||||
|
<td className="p-3 text-right text-slate-500 font-mono text-xs">-</td>
|
||||||
|
<td className="p-3 text-slate-500 font-mono text-xs">-</td>
|
||||||
|
<td className="p-3 text-center">
|
||||||
|
<button
|
||||||
|
onClick={handleAddNewAsset}
|
||||||
|
className="bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-1.5 px-3 rounded-lg text-xs shadow-md shadow-emerald-500/10 flex items-center justify-center gap-1 mx-auto transition-all active:scale-[0.96] cursor-pointer animate-pulse hover:animate-none"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" /> Hinzufügen
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SECTION: Systemischer Makro-Stresstest (Portfolio-LMM) */}
|
||||||
|
<div className="bg-slate-900/60 backdrop-blur-md border border-slate-800 rounded-2xl p-6 text-slate-100 shadow-xl space-y-6">
|
||||||
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 border-b border-slate-800 pb-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-lg font-bold text-white flex items-center gap-2">
|
||||||
|
<TrendingUp className="text-purple-400 w-5 h-5" /> Systemischer Makro-Stresstest (Portfolio-LMM)
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Analysiert die historische Sensitivität des Portfolios gegenüber Kern-Makro-Ereignissen über die letzten 36 Monate.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/* Event type tabs */}
|
||||||
|
<div className="flex bg-slate-950 p-1 rounded-xl border border-slate-850 w-full sm:w-auto shrink-0">
|
||||||
|
{(['FOMC Rates', 'CPI Inflation', 'Labor Market'] as const).map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
onClick={() => setActiveStressTab(tab)}
|
||||||
|
className={`flex-1 sm:flex-none px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${
|
||||||
|
activeStressTab === tab
|
||||||
|
? 'bg-purple-600 text-white font-bold shadow-md shadow-purple-500/20'
|
||||||
|
: 'text-slate-400 hover:text-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab === 'FOMC Rates' ? '🏦 FOMC Rates' : tab === 'CPI Inflation' ? '🎯 CPI Inflation' : '💼 Labor Market'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stressLoading ? (
|
||||||
|
<div className="h-80 flex flex-col items-center justify-center space-y-3">
|
||||||
|
<div className="w-8 h-8 rounded-full border-2 border-purple-500 border-t-transparent animate-spin" />
|
||||||
|
<span className="text-xs text-slate-400 font-mono animate-pulse">Kalkuliere Swamy-Arora GLS-Schätzer...</span>
|
||||||
|
</div>
|
||||||
|
) : stressError || !stressData ? (
|
||||||
|
<div className="h-80 flex items-center justify-center text-slate-500 italic">
|
||||||
|
{stressError || 'Keine Daten geladen.'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* LMM Summary Statistics */}
|
||||||
|
<div className="bg-slate-950/40 rounded-xl p-4 border border-slate-850 flex flex-col justify-between space-y-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-purple-400 font-bold block mb-2">Regressions-Koeffizienten (GLS)</span>
|
||||||
|
|
||||||
|
{/* Fixed Effects list */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{stressData.regressionResults?.fixedEffects.map((fe: any) => {
|
||||||
|
const isPositive = fe.estimate >= 0;
|
||||||
|
const isImpact = fe.name === 'Post-Event Impact';
|
||||||
|
return (
|
||||||
|
<div key={fe.name} className={`flex justify-between items-center p-2 rounded-lg border ${
|
||||||
|
isImpact ? 'bg-purple-950/20 border-purple-900/40' : 'bg-slate-950/60 border-slate-900'
|
||||||
|
}`}>
|
||||||
|
<div>
|
||||||
|
<div className={`text-xs font-semibold ${isImpact ? 'text-purple-300 font-bold' : 'text-slate-350'}`}>
|
||||||
|
{fe.name === 'Intercept' ? 'Basisschnittstelle (Intercept)' :
|
||||||
|
fe.name === 'Pre-Event Drift' ? 'Pre-Event Trend (Drift)' :
|
||||||
|
fe.name === 'Post-Event Impact' ? 'Systemisches Portfolio Beta' :
|
||||||
|
'VIX-Volatilitäts-Sensitivität'}
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] text-slate-500">
|
||||||
|
SE: {fe.se.toFixed(4)} | p-Wert: {fe.pVal.toFixed(4)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<span className={`font-mono text-sm font-bold ${
|
||||||
|
isImpact ? 'text-purple-400 text-base' :
|
||||||
|
isPositive ? 'text-emerald-400' : 'text-rose-400'
|
||||||
|
}`}>
|
||||||
|
{isPositive ? '+' : ''}{fe.estimate.toFixed(4)}
|
||||||
|
</span>
|
||||||
|
<span className="text-purple-400 text-xs font-bold font-mono ml-1">{fe.sig}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Model Fit metrics */}
|
||||||
|
<div className="border-t border-slate-850 pt-3 space-y-2">
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span className="text-slate-400">R-Quadrat (Bestimmtheitsmaß):</span>
|
||||||
|
<span className="font-mono font-bold text-slate-200">{(stressData.regressionResults?.rSquared * 100).toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-[11px] text-slate-500 font-mono">
|
||||||
|
<span>AIC: {stressData.regressionResults?.aic}</span>
|
||||||
|
<span>BIC: {stressData.regressionResults?.bic}</span>
|
||||||
|
<span>Events: {stressData.eventCount}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recharts Area/Line Chart (2/3 width) */}
|
||||||
|
<div className="lg:col-span-2 bg-slate-950/30 rounded-xl p-4 border border-slate-850 space-y-3">
|
||||||
|
<div className="flex justify-between items-center text-xs">
|
||||||
|
<span className="text-slate-400 font-mono">Durchschnittlicher kumulierter Ertrag im Zeitfenster [-30, +30] Tage</span>
|
||||||
|
<span className="text-[9px] text-slate-500 font-mono">Akkumulierte Log-Renditen</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-64 w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={stressData.chartData} margin={{ top: 10, right: 10, left: -20, bottom: 5 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="colorPortfolio" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#c084fc" stopOpacity={0.2}/>
|
||||||
|
<stop offset="95%" stopColor="#c084fc" stopOpacity={0.0}/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="colorBenchmark" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#60a5fa" stopOpacity={0.1}/>
|
||||||
|
<stop offset="95%" stopColor="#60a5fa" stopOpacity={0.0}/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="relativeDay"
|
||||||
|
stroke="#64748b"
|
||||||
|
fontSize={10}
|
||||||
|
tickFormatter={(v) => `T${v >= 0 ? '+' : ''}${v}`}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
stroke="#64748b"
|
||||||
|
fontSize={10}
|
||||||
|
tickFormatter={(v) => `${v.toFixed(1)}%`}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ backgroundColor: '#090d16', borderColor: '#1e293b', borderRadius: '8px', fontSize: '11px' }}
|
||||||
|
labelFormatter={(label) => `Relativer Tag: T${label >= 0 ? '+' : ''}${label}`}
|
||||||
|
/>
|
||||||
|
<Legend verticalAlign="top" height={36} iconType="circle" />
|
||||||
|
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="Mein Portfolio (%)"
|
||||||
|
stroke="#c084fc"
|
||||||
|
fillOpacity={1}
|
||||||
|
fill="url(#colorPortfolio)"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="NASDAQ Benchmark (%)"
|
||||||
|
stroke="#60a5fa"
|
||||||
|
fillOpacity={1}
|
||||||
|
fill="url(#colorBenchmark)"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="LMM Trend (%)"
|
||||||
|
name="Purged LMM Trend (%)"
|
||||||
|
stroke="#f43f5e"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
<ReferenceLine
|
||||||
|
x={0}
|
||||||
|
stroke="#ef4444"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
label={{ value: 'Stichtag (T0)', fill: '#ef4444', fontSize: 9, position: 'top' }}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quantitative Commentary Card */}
|
||||||
|
{stressData && !stressLoading && (
|
||||||
|
<div className="bg-purple-950/20 border border-purple-900/40 rounded-xl p-4 flex gap-3 items-start text-xs text-purple-300">
|
||||||
|
<Sparkles className="w-5 h-5 text-purple-400 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<span className="font-bold uppercase tracking-wider block mb-1">Quantitative Analyse-Auswertung</span>
|
||||||
|
<p className="leading-relaxed">
|
||||||
|
{(() => {
|
||||||
|
const impactBeta = stressData.regressionResults?.fixedEffects.find((f: any) => f.name === 'Post-Event Impact')?.estimate || 0;
|
||||||
|
const isNegative = impactBeta < 0;
|
||||||
|
const absBeta = Math.abs(impactBeta).toFixed(2);
|
||||||
|
const pVal = stressData.regressionResults?.fixedEffects.find((f: any) => f.name === 'Post-Event Impact')?.pVal || 0;
|
||||||
|
const isSignificant = pVal < 0.05;
|
||||||
|
|
||||||
|
let eventNameText = activeStressTab === 'FOMC Rates' ? 'FOMC-Zinsentscheiden' :
|
||||||
|
activeStressTab === 'CPI Inflation' ? 'CPI-Inflationsdaten' : 'Arbeitsmarktdaten';
|
||||||
|
|
||||||
|
let significanceText = isSignificant
|
||||||
|
? `Dieser Effekt ist mit einem p-Wert von ${pVal.toFixed(4)} statistisch signifikant.`
|
||||||
|
: `Dieser Effekt ist mit einem p-Wert von ${pVal.toFixed(4)} statistisch nicht hochgradig signifikant (Rauscheinfluss möglich).`;
|
||||||
|
|
||||||
|
if (isNegative) {
|
||||||
|
return `Historische Reaktivität: Bei ${eventNameText} zeigt dein Depot ein negatives Beta von -${absBeta}. ${significanceText} Eine Absicherung über defensive Sektoren (z.B. Erhöhung der Bargeldquote oder defensive Consumer-Titel) senkt das Volatilitätsrisiko in dieser Post-Event-Phase um ca. ${Math.round(Math.abs(impactBeta) * 35)}%.`;
|
||||||
|
} else {
|
||||||
|
return `Historische Reaktivität: Bei ${eventNameText} zeigt dein Depot ein positives Beta von +${absBeta}. ${significanceText} Dein Portfolio profitiert tendenziell von der anschließenden Marktdynamik. Sie können erwägen, die Hebelwirkung durch Zukäufe in Momentum-Aktien zu erhöhen.`;
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* SECTION 2: Chart / Analytics & Order Form */}
|
{/* SECTION 2: Chart / Analytics & Order Form */}
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
|
||||||
|
|
||||||
@@ -810,6 +1355,7 @@ export default function SandboxDemo() {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<PortfolioMathModal isOpen={isMathModalOpen} onClose={() => setIsMathModalOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import { calculateGJRGARCH } from '@/lib/math/statistics';
|
|||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||||
import 'katex/dist/katex.min.css';
|
import 'katex/dist/katex.min.css';
|
||||||
import { BlockMath, InlineMath } from 'react-katex';
|
import { BlockMath, InlineMath } from 'react-katex';
|
||||||
|
import ScannerMathModal from './ScannerMathModal';
|
||||||
import {
|
import {
|
||||||
ShieldAlert, Sparkles, RefreshCw, Flame, Search, Plus, Trash2,
|
ShieldAlert, Sparkles, RefreshCw, Flame, Search, Plus, Trash2,
|
||||||
ChevronDown, ChevronUp, AlertTriangle, CheckCircle2, XCircle, Info, Clock, Play
|
ChevronDown, ChevronUp, AlertTriangle, CheckCircle2, XCircle, Info, Clock, Play,
|
||||||
|
BookOpen
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
// Predefined mock database for deep-check searches
|
// Predefined mock database for deep-check searches (Removed mock database)
|
||||||
interface SearchResult {
|
interface SearchResult {
|
||||||
ticker: string;
|
ticker: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -21,159 +23,208 @@ interface SearchResult {
|
|||||||
gjrGarchVol: number;
|
gjrGarchVol: number;
|
||||||
reboundScore: number;
|
reboundScore: number;
|
||||||
returns: number[];
|
returns: number[];
|
||||||
|
currentPrice?: number;
|
||||||
|
peakPrice?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockSearchDatabase: Record<string, SearchResult> = {
|
|
||||||
'RACE': {
|
|
||||||
ticker: 'RACE',
|
|
||||||
name: 'Ferrari N.V.',
|
|
||||||
priceChange: -0.065,
|
|
||||||
sentiment: 'GREEN',
|
|
||||||
whyDropped: 'Emotionaler Abverkauf nach viralem Video von Cristiano Ronaldo, der sich über Autoprobleme beschwert. Keine fundamentalen Schäden.',
|
|
||||||
gjrGarchVol: 0.048,
|
|
||||||
reboundScore: 88,
|
|
||||||
returns: [0.01, -0.005, 0.012, -0.008, 0.003, -0.065]
|
|
||||||
},
|
|
||||||
'KO': {
|
|
||||||
ticker: 'KO',
|
|
||||||
name: 'The Coca-Cola Co.',
|
|
||||||
priceChange: -0.052,
|
|
||||||
sentiment: 'GREEN',
|
|
||||||
whyDropped: 'Berühmter Influencer entfernt Coca-Cola Flasche während Pressekonferenz. Reine Social-Media-Hype Reaktion.',
|
|
||||||
gjrGarchVol: 0.021,
|
|
||||||
reboundScore: 82,
|
|
||||||
returns: [0.002, 0.005, -0.003, 0.001, -0.002, -0.052]
|
|
||||||
},
|
|
||||||
'TSLA': {
|
|
||||||
ticker: 'TSLA',
|
|
||||||
name: 'Tesla Inc.',
|
|
||||||
priceChange: -0.084,
|
|
||||||
sentiment: 'YELLOW',
|
|
||||||
whyDropped: 'Auslieferungszahlen leicht unter Analystenschätzungen. Margenentwicklung bleibt jedoch stabil.',
|
|
||||||
gjrGarchVol: 0.062,
|
|
||||||
reboundScore: 65,
|
|
||||||
returns: [-0.012, 0.008, -0.025, 0.015, -0.005, -0.084]
|
|
||||||
},
|
|
||||||
'SMCI': {
|
|
||||||
ticker: 'SMCI',
|
|
||||||
name: 'Super Micro Computer',
|
|
||||||
priceChange: -0.124,
|
|
||||||
sentiment: 'RED',
|
|
||||||
whyDropped: 'Hindenburg Research Short-Seller-Report bezüglich mutmaßlicher Bilanzmanipulationen veröffentlicht.',
|
|
||||||
gjrGarchVol: 0.085,
|
|
||||||
reboundScore: 24,
|
|
||||||
returns: [0.035, -0.018, 0.042, -0.051, 0.012, -0.124]
|
|
||||||
},
|
|
||||||
'BA': {
|
|
||||||
ticker: 'BA',
|
|
||||||
name: 'Boeing Co.',
|
|
||||||
priceChange: -0.071,
|
|
||||||
sentiment: 'RED',
|
|
||||||
whyDropped: 'FAA verhängt vorübergehendes Flugverbot nach erneutem technischen Zwischenfall mit Rumpftür.',
|
|
||||||
gjrGarchVol: 0.058,
|
|
||||||
reboundScore: 18,
|
|
||||||
returns: [-0.005, -0.012, 0.005, -0.021, -0.008, -0.071]
|
|
||||||
},
|
|
||||||
'NFLX': {
|
|
||||||
ticker: 'NFLX',
|
|
||||||
name: 'Netflix Inc.',
|
|
||||||
priceChange: -0.058,
|
|
||||||
sentiment: 'GREEN',
|
|
||||||
whyDropped: 'Gerüchte über angebliche Preissenkungen in Schwellenländern belasten Kurs kurzfristig.',
|
|
||||||
gjrGarchVol: 0.038,
|
|
||||||
reboundScore: 78,
|
|
||||||
returns: [0.015, -0.002, 0.008, 0.005, -0.011, -0.058]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ScannerDemo() {
|
export default function ScannerDemo() {
|
||||||
const { watchlist, addToWatchlist, removeFromWatchlist, simulateWatchlistTick } = useSandboxStore();
|
const { watchlist, addToWatchlist, removeFromWatchlist, simulateWatchlistTick, updateScannerAlerts } = useSandboxStore();
|
||||||
|
|
||||||
// Component local states
|
// Component local states
|
||||||
const [scanning, setScanning] = useState(false);
|
const [scanning, setScanning] = useState(false);
|
||||||
const [scanProgress, setScanProgress] = useState('');
|
const [scanProgress, setScanProgress] = useState('');
|
||||||
const [activeAlerts, setActiveAlerts] = useState<ScannerAlert[]>([
|
const [activeAlerts, setActiveAlerts] = useState<ScannerAlert[]>([]);
|
||||||
{ id: 'sa1', ticker: 'TSLA', priceChange: -0.084, gjrGarchVol: 0.062, overreactionScore: 65, status: 'UNDEREVALUATED' },
|
|
||||||
{ id: 'sa2', ticker: 'SMCI', priceChange: -0.124, gjrGarchVol: 0.085, overreactionScore: 24, status: 'FAIR' },
|
const [scanMode, setScanMode] = useState<'day_crash' | 'ma_drop' | '52w_dist' | 'rsi_oversold'>('day_crash');
|
||||||
]);
|
const [marketRegion, setMarketRegion] = useState<'us' | 'eu' | 'crypto'>('us');
|
||||||
|
const [scanResults, setScanResults] = useState<any[]>([]);
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [searchResult, setSearchResult] = useState<SearchResult | null>(null);
|
const [searchResult, setSearchResult] = useState<SearchResult | null>(null);
|
||||||
const [searchError, setSearchError] = useState(false);
|
const [searchError, setSearchError] = useState(false);
|
||||||
|
const [checkingDeep, setCheckingDeep] = useState(false);
|
||||||
|
|
||||||
const [showMathAccordion, setShowMathAccordion] = useState(false);
|
const [showMathAccordion, setShowMathAccordion] = useState(false);
|
||||||
|
const [isMathModalOpen, setIsMathModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// Cache for metadata and prices retrieved dynamically
|
||||||
|
const [alertsMetadata, setAlertsMetadata] = useState<Record<string, { name: string; whyDropped: string; sentiment: 'GREEN' | 'YELLOW' | 'RED' }>>({});
|
||||||
|
const [alertsPrices, setAlertsPrices] = useState<Record<string, { peakPrice: number; currentPrice: number }>>({});
|
||||||
|
|
||||||
// Run market scan simulator
|
// Run market scan simulator querying real live API
|
||||||
const handleMarketScan = () => {
|
const handleMarketScan = async () => {
|
||||||
setScanning(true);
|
setScanning(true);
|
||||||
setScanProgress('Verbinde mit Börsenfeeds...');
|
setScanProgress('Verbinde mit Börsenfeeds...');
|
||||||
|
|
||||||
setTimeout(() => {
|
try {
|
||||||
setScanProgress('Berechne historische Volatilitätsmatrizen...');
|
setScanProgress(`Rufe die ${marketRegion.toUpperCase()} Marktdaten ab...`);
|
||||||
setTimeout(() => {
|
const response = await fetch(`/api/finance?mode=${scanMode}®ion=${marketRegion}`);
|
||||||
setScanProgress('Filtere abnormale Abweichungen (Asset > -5%, Index stabil)...');
|
if (!response.ok) {
|
||||||
setTimeout(() => {
|
throw new Error('Failed to fetch scanner tickers');
|
||||||
// Scan isolated anomalies
|
}
|
||||||
setActiveAlerts([
|
const data = await response.json();
|
||||||
{ id: 'sa3', ticker: 'RACE', priceChange: -0.065, gjrGarchVol: 0.048, overreactionScore: 88, status: 'UNDEREVALUATED' },
|
const results = data.results || [];
|
||||||
{ id: 'sa4', ticker: 'KO', priceChange: -0.052, gjrGarchVol: 0.021, overreactionScore: 82, status: 'UNDEREVALUATED' },
|
|
||||||
{ id: 'sa5', ticker: 'TSLA', priceChange: -0.084, gjrGarchVol: 0.062, overreactionScore: 65, status: 'UNDEREVALUATED' },
|
setScanProgress('Berechne GJR-GARCH Volatilitäten...');
|
||||||
{ id: 'sa6', ticker: 'SMCI', priceChange: -0.124, gjrGarchVol: 0.085, overreactionScore: 24, status: 'FAIR' },
|
|
||||||
{ id: 'sa7', ticker: 'BA', priceChange: -0.071, gjrGarchVol: 0.058, overreactionScore: 18, status: 'OVERVALUATED' },
|
setScanResults(results);
|
||||||
]);
|
|
||||||
setScanning(false);
|
const newAlerts: ScannerAlert[] = [];
|
||||||
setScanProgress('');
|
const newMetadata: Record<string, { name: string; whyDropped: string; sentiment: 'GREEN' | 'YELLOW' | 'RED' }> = {};
|
||||||
}, 600);
|
const newPrices: Record<string, { peakPrice: number; currentPrice: number }> = {};
|
||||||
}, 500);
|
|
||||||
}, 400);
|
results.forEach((result: any) => {
|
||||||
|
if (result.error) return;
|
||||||
|
|
||||||
|
// Calculate dynamic volatility from return series
|
||||||
|
const gjrResult = calculateGJRGARCH(result.returns);
|
||||||
|
const gjrGarchVol = gjrResult.forecast / 100;
|
||||||
|
|
||||||
|
// Calculate overreaction ratio
|
||||||
|
let dropAbs = Math.abs(result.priceChange);
|
||||||
|
if (scanMode === 'day_crash') dropAbs = Math.abs(result.dayChange);
|
||||||
|
else if (scanMode === 'ma_drop') dropAbs = Math.abs(result.maDeviation);
|
||||||
|
else if (scanMode === '52w_dist') dropAbs = Math.abs(result.dist52w);
|
||||||
|
else if (scanMode === 'rsi_oversold') dropAbs = Math.max(0, (50 - result.rsi14) / 100);
|
||||||
|
|
||||||
|
const ratio = dropAbs / (gjrGarchVol || 0.01);
|
||||||
|
let overreactionScore = Math.round(ratio * 30 + 30);
|
||||||
|
overreactionScore = Math.max(10, Math.min(95, overreactionScore));
|
||||||
|
|
||||||
|
const status: 'UNDEREVALUATED' | 'FAIR' | 'OVERVALUATED' =
|
||||||
|
overreactionScore > 70 ? 'UNDEREVALUATED' : (overreactionScore < 40 ? 'OVERVALUATED' : 'FAIR');
|
||||||
|
|
||||||
|
const sentiment: 'GREEN' | 'YELLOW' | 'RED' =
|
||||||
|
status === 'UNDEREVALUATED' ? 'GREEN' : (status === 'FAIR' ? 'YELLOW' : 'RED');
|
||||||
|
|
||||||
|
const whyDropped = `Wert liegt bei $${result.currentPrice.toFixed(2)}. Modus: ${scanMode.toUpperCase()}. GJR-GARCH(1,1) Volatilitätsschätzung liegt bei ${(gjrGarchVol * 100).toFixed(1)}%.`;
|
||||||
|
|
||||||
|
newAlerts.push({
|
||||||
|
id: `sa_${result.ticker}_${Date.now()}`,
|
||||||
|
ticker: result.ticker,
|
||||||
|
priceChange: result.priceChange,
|
||||||
|
gjrGarchVol,
|
||||||
|
overreactionScore,
|
||||||
|
status
|
||||||
|
});
|
||||||
|
|
||||||
|
newMetadata[result.ticker] = {
|
||||||
|
name: result.name,
|
||||||
|
whyDropped,
|
||||||
|
sentiment
|
||||||
|
};
|
||||||
|
|
||||||
|
newPrices[result.ticker] = {
|
||||||
|
peakPrice: result.peakPrice,
|
||||||
|
currentPrice: result.currentPrice
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setAlertsMetadata(prev => ({ ...prev, ...newMetadata }));
|
||||||
|
setAlertsPrices(prev => ({ ...prev, ...newPrices }));
|
||||||
|
setActiveAlerts(newAlerts);
|
||||||
|
|
||||||
|
// Update global store alerts for Sandbox module use
|
||||||
|
updateScannerAlerts(newAlerts);
|
||||||
|
|
||||||
|
setScanProgress('');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setScanProgress('Fehler beim Scannen der Live-Daten.');
|
||||||
|
} finally {
|
||||||
|
setScanning(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Perform a manual deep check
|
// Trigger scan automatically when scan mode or region toggles change
|
||||||
const handleDeepCheck = (e: React.FormEvent) => {
|
React.useEffect(() => {
|
||||||
|
handleMarketScan();
|
||||||
|
}, [scanMode, marketRegion]);
|
||||||
|
|
||||||
|
// Perform a manual deep check using real live API
|
||||||
|
const handleDeepCheck = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSearchError(false);
|
setSearchError(false);
|
||||||
setSearchResult(null);
|
setSearchResult(null);
|
||||||
const query = searchQuery.trim().toUpperCase();
|
const query = searchQuery.trim().toUpperCase();
|
||||||
if (!query) return;
|
if (!query) return;
|
||||||
|
|
||||||
if (mockSearchDatabase[query]) {
|
setCheckingDeep(true);
|
||||||
setSearchResult(mockSearchDatabase[query]);
|
try {
|
||||||
} else {
|
const response = await fetch(`/api/finance?ticker=${query}`);
|
||||||
// Simulate dynamic result for unknown assets
|
if (!response.ok) {
|
||||||
const simulatedVol = 0.03 + Math.random() * 0.04;
|
throw new Error('Failed to fetch');
|
||||||
const simulatedScore = Math.floor(40 + Math.random() * 50);
|
}
|
||||||
const isNegative = Math.random() > 0.4;
|
const data = await response.json();
|
||||||
const simulatedChange = -0.05 - Math.random() * 0.06;
|
const result = data.results?.[0];
|
||||||
|
if (!result || result.error) {
|
||||||
|
throw new Error(result?.error || 'Invalid result');
|
||||||
|
}
|
||||||
|
|
||||||
|
const gjrResult = calculateGJRGARCH(result.returns);
|
||||||
|
const gjrGarchVol = gjrResult.forecast / 100;
|
||||||
|
|
||||||
|
const dropAbs = Math.abs(result.priceChange);
|
||||||
|
const ratio = dropAbs / (gjrGarchVol || 0.01);
|
||||||
|
let overreactionScore = Math.round(ratio * 30 + 30);
|
||||||
|
overreactionScore = Math.max(10, Math.min(95, overreactionScore));
|
||||||
|
if (result.priceChange > -0.03) {
|
||||||
|
overreactionScore = Math.round(overreactionScore * 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sentiment = overreactionScore > 70 ? 'GREEN' : (overreactionScore >= 40 ? 'YELLOW' : 'RED');
|
||||||
|
const whyDropped = `Mathematischer Ausbruchspunkt: Der Kurs verzeichnet einen Rückgang von ${Math.round(Math.abs(result.priceChange) * 100)}% ausgehend vom 90-Tage-Hoch von $${result.peakPrice.toFixed(2)}. GJR-GARCH-Volatilitätsschätzung liegt bei ${(gjrGarchVol * 100).toFixed(1)}%.`;
|
||||||
|
|
||||||
const res: SearchResult = {
|
const res: SearchResult = {
|
||||||
ticker: query,
|
ticker: query,
|
||||||
name: `${query} Corp.`,
|
name: result.name,
|
||||||
priceChange: simulatedChange,
|
priceChange: result.priceChange,
|
||||||
sentiment: isNegative ? (simulatedScore > 75 ? 'GREEN' : 'YELLOW') : 'RED',
|
sentiment,
|
||||||
whyDropped: 'Simulierte Marktabweichung basierend auf automatischem Sentiment-Scanning der Finanzberichte.',
|
whyDropped,
|
||||||
gjrGarchVol: simulatedVol,
|
gjrGarchVol,
|
||||||
reboundScore: simulatedScore,
|
reboundScore: overreactionScore,
|
||||||
returns: [0.005, -0.008, 0.012, -0.015, 0.004, simulatedChange]
|
returns: result.returns,
|
||||||
|
currentPrice: result.currentPrice,
|
||||||
|
peakPrice: result.peakPrice
|
||||||
};
|
};
|
||||||
|
|
||||||
|
setAlertsMetadata(prev => ({
|
||||||
|
...prev,
|
||||||
|
[query]: { name: result.name, whyDropped, sentiment }
|
||||||
|
}));
|
||||||
|
|
||||||
|
setAlertsPrices(prev => ({
|
||||||
|
...prev,
|
||||||
|
[query]: { peakPrice: result.peakPrice, currentPrice: result.currentPrice }
|
||||||
|
}));
|
||||||
|
|
||||||
setSearchResult(res);
|
setSearchResult(res);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setSearchError(true);
|
||||||
|
} finally {
|
||||||
|
setCheckingDeep(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddToWatchlist = (ticker: string, priceChange: number, sentiment: 'GREEN' | 'YELLOW' | 'RED', whyDropped: string) => {
|
const handleAddToWatchlist = (
|
||||||
// Determine a mock initial price based on ticker
|
ticker: string,
|
||||||
let initialPrice = 150;
|
priceChange: number,
|
||||||
if (ticker === 'RACE') initialPrice = 380;
|
sentiment: 'GREEN' | 'YELLOW' | 'RED',
|
||||||
if (ticker === 'KO') initialPrice = 60;
|
whyDropped: string,
|
||||||
if (ticker === 'TSLA') initialPrice = 175;
|
peakPrice?: number,
|
||||||
if (ticker === 'NFLX') initialPrice = 610;
|
currentPrice?: number
|
||||||
|
) => {
|
||||||
|
const realInitial = peakPrice || 100;
|
||||||
|
const realCurrent = currentPrice || (realInitial * (1 + priceChange));
|
||||||
|
|
||||||
addToWatchlist({
|
addToWatchlist({
|
||||||
ticker,
|
ticker,
|
||||||
priceChange,
|
priceChange,
|
||||||
sentiment,
|
sentiment,
|
||||||
whyDropped,
|
whyDropped,
|
||||||
initialPrice,
|
initialPrice: realInitial,
|
||||||
currentPrice: initialPrice * (1 + priceChange), // current price after drop
|
currentPrice: realCurrent,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -190,6 +241,192 @@ export default function ScannerDemo() {
|
|||||||
}));
|
}));
|
||||||
}, [gjrGarchMathResult]);
|
}, [gjrGarchMathResult]);
|
||||||
|
|
||||||
|
const categorizedResults = useMemo(() => {
|
||||||
|
const mega: any[] = [];
|
||||||
|
const mid: any[] = [];
|
||||||
|
const small: any[] = [];
|
||||||
|
|
||||||
|
scanResults.forEach((result: any) => {
|
||||||
|
// Calculate dynamic volatility from return series
|
||||||
|
const gjrResult = calculateGJRGARCH(result.returns || []);
|
||||||
|
const gjrGarchVol = gjrResult.forecast / 100;
|
||||||
|
|
||||||
|
// Calculate overreaction ratio based on selected mode
|
||||||
|
let dropAbs = Math.abs(result.priceChange);
|
||||||
|
if (scanMode === 'day_crash') dropAbs = Math.abs(result.dayChange);
|
||||||
|
else if (scanMode === 'ma_drop') dropAbs = Math.abs(result.maDeviation);
|
||||||
|
else if (scanMode === '52w_dist') dropAbs = Math.abs(result.dist52w);
|
||||||
|
else if (scanMode === 'rsi_oversold') dropAbs = Math.max(0, (50 - result.rsi14) / 100);
|
||||||
|
|
||||||
|
const ratio = dropAbs / (gjrGarchVol || 0.01);
|
||||||
|
let overreactionScore = Math.round(ratio * 30 + 30);
|
||||||
|
overreactionScore = Math.max(10, Math.min(95, overreactionScore));
|
||||||
|
|
||||||
|
const status: 'UNDEREVALUATED' | 'FAIR' | 'OVERVALUATED' =
|
||||||
|
overreactionScore > 70 ? 'UNDEREVALUATED' : (overreactionScore < 40 ? 'OVERVALUATED' : 'FAIR');
|
||||||
|
|
||||||
|
const sentiment: 'GREEN' | 'YELLOW' | 'RED' =
|
||||||
|
status === 'UNDEREVALUATED' ? 'GREEN' : (status === 'FAIR' ? 'YELLOW' : 'RED');
|
||||||
|
|
||||||
|
const whyDropped = `Kurs liegt bei $${result.currentPrice.toFixed(2)}. Modus: ${scanMode.toUpperCase()}. GJR-GARCH Volatilitätsschätzung liegt bei ${(gjrGarchVol * 100).toFixed(1)}%.`;
|
||||||
|
|
||||||
|
const enriched = {
|
||||||
|
...result,
|
||||||
|
gjrGarchVol,
|
||||||
|
overreactionScore,
|
||||||
|
status,
|
||||||
|
sentiment,
|
||||||
|
whyDropped
|
||||||
|
};
|
||||||
|
|
||||||
|
const mcap = result.marketCap || 0;
|
||||||
|
if (mcap >= 100e9) {
|
||||||
|
mega.push(enriched);
|
||||||
|
} else if (mcap >= 10e9) {
|
||||||
|
mid.push(enriched);
|
||||||
|
} else {
|
||||||
|
small.push(enriched);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortByMode = (list: any[]) => {
|
||||||
|
return list.sort((a, b) => {
|
||||||
|
if (scanMode === 'ma_drop') return a.maDeviation - b.maDeviation;
|
||||||
|
if (scanMode === '52w_dist') return a.dist52w - b.dist52w;
|
||||||
|
if (scanMode === 'rsi_oversold') return a.rsi14 - b.rsi14;
|
||||||
|
return a.dayChange - b.dayChange; // day_crash
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
mega: sortByMode(mega).slice(0, 5),
|
||||||
|
mid: sortByMode(mid).slice(0, 5),
|
||||||
|
small: sortByMode(small).slice(0, 5)
|
||||||
|
};
|
||||||
|
}, [scanResults, scanMode]);
|
||||||
|
|
||||||
|
const renderCategoryTable = (title: string, description: string, assets: any[]) => {
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-950/40 border border-slate-850 rounded-2xl p-5 space-y-3">
|
||||||
|
<div className="flex justify-between items-center border-b border-slate-900 pb-2">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-bold text-white text-sm">{title}</h4>
|
||||||
|
<p className="text-[10px] text-slate-400 font-mono">{description}</p>
|
||||||
|
</div>
|
||||||
|
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-slate-900 border border-slate-800 text-slate-300">
|
||||||
|
{assets.length} Assets
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{assets.length === 0 ? (
|
||||||
|
<div className="py-6 text-center text-slate-500 text-xs italic">
|
||||||
|
Keine Assets in dieser Kategorie unter den Scanner-Ergebnissen.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto scrollbar-thin">
|
||||||
|
<table className="w-full text-left text-xs border-collapse min-w-[700px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-900 text-slate-500 font-mono text-[10px] uppercase tracking-wider">
|
||||||
|
<th className="py-2.5 px-3">Asset</th>
|
||||||
|
<th className="py-2.5 px-3">Preis</th>
|
||||||
|
<th className="py-2.5 px-3 text-right">Abweichung</th>
|
||||||
|
<th className="py-2.5 px-3 text-right">KGV (T)</th>
|
||||||
|
<th className="py-2.5 px-3 text-right">KGV (F)</th>
|
||||||
|
<th className="py-2.5 px-3 text-right">PEG</th>
|
||||||
|
<th className="py-2.5 px-3 text-right">KBV</th>
|
||||||
|
<th className="py-2.5 px-3 text-right">Rendite</th>
|
||||||
|
<th className="py-2.5 px-3 text-center">Score</th>
|
||||||
|
<th className="py-2.5 px-3 text-center">Aktion</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-900/60">
|
||||||
|
{assets.map((asset) => {
|
||||||
|
const isGreen = asset.sentiment === 'GREEN';
|
||||||
|
const isYellow = asset.sentiment === 'YELLOW';
|
||||||
|
|
||||||
|
// Format deviation based on mode
|
||||||
|
let devText = '';
|
||||||
|
let devColor = 'text-slate-300';
|
||||||
|
if (scanMode === 'day_crash') {
|
||||||
|
devText = `${(asset.dayChange * 100).toFixed(2)}%`;
|
||||||
|
devColor = asset.dayChange < 0 ? 'text-rose-400 font-bold' : 'text-emerald-400';
|
||||||
|
} else if (scanMode === 'ma_drop') {
|
||||||
|
devText = `${(asset.maDeviation * 100).toFixed(2)}%`;
|
||||||
|
devColor = asset.maDeviation < 0 ? 'text-rose-400 font-bold' : 'text-emerald-400';
|
||||||
|
} else if (scanMode === '52w_dist') {
|
||||||
|
devText = `${(asset.dist52w * 100).toFixed(2)}%`;
|
||||||
|
devColor = asset.dist52w < 0 ? 'text-rose-400 font-bold' : 'text-emerald-400';
|
||||||
|
} else if (scanMode === 'rsi_oversold') {
|
||||||
|
devText = asset.rsi14.toFixed(1);
|
||||||
|
devColor = asset.rsi14 < 30 ? 'text-amber-400 font-bold font-mono' : 'text-slate-400';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight valuation multipliers
|
||||||
|
const peColor = asset.trailingPE && asset.trailingPE > 0 && asset.trailingPE < 15 ? 'text-emerald-400 font-semibold' : 'text-slate-300';
|
||||||
|
const fpeColor = asset.forwardPE && asset.forwardPE > 0 && asset.forwardPE < 12 ? 'text-emerald-400 font-semibold' : 'text-slate-300';
|
||||||
|
const pegColor = asset.peg && asset.peg > 0 && asset.peg < 1.0 ? 'text-emerald-400 font-semibold' : 'text-slate-300';
|
||||||
|
const pbColor = asset.priceToBook && asset.priceToBook > 0 && asset.priceToBook < 1.5 ? 'text-emerald-400 font-semibold' : 'text-slate-300';
|
||||||
|
const divColor = asset.dividendYield && asset.dividendYield > 3.0 ? 'text-emerald-400 font-semibold' : 'text-slate-450';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={asset.ticker} className="hover:bg-slate-900/30 transition-colors group">
|
||||||
|
<td className="py-3 px-3">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-mono font-bold text-slate-100 text-sm">{asset.ticker}</span>
|
||||||
|
<span className="text-[10px] text-slate-500 max-w-[120px] truncate" title={asset.name}>{asset.name}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-3 font-mono font-semibold text-slate-200">
|
||||||
|
${asset.currentPrice.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
|
</td>
|
||||||
|
<td className={`py-3 px-3 text-right font-mono ${devColor}`}>
|
||||||
|
{devText}
|
||||||
|
</td>
|
||||||
|
<td className={`py-3 px-3 text-right font-mono ${peColor}`}>
|
||||||
|
{asset.trailingPE && asset.trailingPE > 0 ? asset.trailingPE.toFixed(1) : 'N/A'}
|
||||||
|
</td>
|
||||||
|
<td className={`py-3 px-3 text-right font-mono ${fpeColor}`}>
|
||||||
|
{asset.forwardPE && asset.forwardPE > 0 ? asset.forwardPE.toFixed(1) : 'N/A'}
|
||||||
|
</td>
|
||||||
|
<td className={`py-3 px-3 text-right font-mono ${pegColor}`}>
|
||||||
|
{asset.peg && asset.peg > 0 ? asset.peg.toFixed(2) : 'N/A'}
|
||||||
|
</td>
|
||||||
|
<td className={`py-3 px-3 text-right font-mono ${pbColor}`}>
|
||||||
|
{asset.priceToBook && asset.priceToBook > 0 ? asset.priceToBook.toFixed(2) : 'N/A'}
|
||||||
|
</td>
|
||||||
|
<td className={`py-3 px-3 text-right font-mono ${divColor}`}>
|
||||||
|
{asset.dividendYield && asset.dividendYield > 0 ? `${asset.dividendYield.toFixed(2)}%` : '0.00%'}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-3 text-center">
|
||||||
|
<span className={`font-mono font-bold text-xs ${isGreen ? 'text-emerald-400' : (isYellow ? 'text-yellow-400' : 'text-rose-400')}`}>
|
||||||
|
{asset.overreactionScore}%
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-3 text-center">
|
||||||
|
{(isGreen || isYellow) ? (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
handleAddToWatchlist(asset.ticker, asset.priceChange, asset.sentiment, asset.whyDropped, asset.peakPrice, asset.currentPrice);
|
||||||
|
}}
|
||||||
|
className="bg-slate-900 hover:bg-slate-850 hover:border-emerald-500/50 text-emerald-400 hover:text-emerald-300 border border-slate-800 text-[10px] font-bold py-1 px-2.5 rounded-md transition-all active:scale-[0.96] cursor-pointer"
|
||||||
|
>
|
||||||
|
Track
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] text-slate-600 font-mono">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
||||||
@@ -208,7 +445,15 @@ export default function ScannerDemo() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full md:w-auto flex flex-col items-stretch md:items-end gap-2 shrink-0">
|
<div className="w-full md:w-auto flex flex-col sm:flex-row md:flex-row items-stretch sm:items-center gap-3 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsMathModalOpen(true)}
|
||||||
|
className="flex items-center gap-1.5 px-4 py-3 rounded-xl bg-slate-950/80 hover:bg-slate-900 border border-slate-800 hover:border-slate-700 transition-all font-semibold text-xs tracking-wider text-amber-400 justify-center"
|
||||||
|
>
|
||||||
|
<BookOpen className="w-3.5 h-3.5" />
|
||||||
|
<span>📖 Modulerklärung</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleMarketScan}
|
onClick={handleMarketScan}
|
||||||
disabled={scanning}
|
disabled={scanning}
|
||||||
@@ -223,6 +468,58 @@ export default function ScannerDemo() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Core Scan Modes & Region Toggles */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-slate-850 pt-5 mt-5">
|
||||||
|
{/* Mode Toggles */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<span className="text-[10px] text-slate-400 uppercase tracking-wider font-semibold block">Screener-Modus</span>
|
||||||
|
<div className="flex flex-wrap gap-1.5 bg-slate-950/60 p-1 rounded-xl border border-slate-800/80 w-fit">
|
||||||
|
{[
|
||||||
|
{ id: 'day_crash', label: 'Day-Crashs' },
|
||||||
|
{ id: 'ma_drop', label: 'MA-Drop (SMA50)' },
|
||||||
|
{ id: '52w_dist', label: '52W-Distance' },
|
||||||
|
{ id: 'rsi_oversold', label: 'RSI-Oversold' }
|
||||||
|
].map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
onClick={() => setScanMode(m.id as any)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-all cursor-pointer ${
|
||||||
|
scanMode === m.id
|
||||||
|
? 'bg-amber-500 text-slate-950 shadow-md shadow-amber-500/10'
|
||||||
|
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Region Toggles */}
|
||||||
|
<div className="space-y-2 md:text-right">
|
||||||
|
<span className="text-[10px] text-slate-400 uppercase tracking-wider font-semibold block md:pr-1">Markt-Region</span>
|
||||||
|
<div className="flex flex-wrap gap-1.5 bg-slate-950/60 p-1 rounded-xl border border-slate-800/80 w-fit md:ml-auto">
|
||||||
|
{[
|
||||||
|
{ id: 'us', label: 'US Markets' },
|
||||||
|
{ id: 'eu', label: 'EU Markets' },
|
||||||
|
{ id: 'crypto', label: 'Crypto Assets' }
|
||||||
|
].map((r) => (
|
||||||
|
<button
|
||||||
|
key={r.id}
|
||||||
|
onClick={() => setMarketRegion(r.id as any)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-all cursor-pointer ${
|
||||||
|
marketRegion === r.id
|
||||||
|
? 'bg-orange-500 text-slate-950 shadow-md shadow-orange-500/10'
|
||||||
|
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{r.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Collapsible Math Accordion */}
|
{/* Collapsible Math Accordion */}
|
||||||
<div className="border-t border-slate-850 pt-4 mt-6">
|
<div className="border-t border-slate-850 pt-4 mt-6">
|
||||||
<button
|
<button
|
||||||
@@ -278,94 +575,37 @@ export default function ScannerDemo() {
|
|||||||
{/* SECTION 2: Scanned Anomalies & Sentiment Traffic Light */}
|
{/* SECTION 2: Scanned Anomalies & Sentiment Traffic Light */}
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
|
||||||
|
|
||||||
{/* Left 2 Columns: Scanned anomalies details */}
|
{/* Left 2 Columns: 3-Tier Capacity Grid (Top 5 per tier) */}
|
||||||
<div className="xl:col-span-2 space-y-6">
|
<div className="xl:col-span-2 space-y-6">
|
||||||
<div className="bg-slate-900/60 backdrop-blur-md border border-slate-800 rounded-2xl p-6 text-slate-100 shadow-xl space-y-4">
|
<div className="bg-slate-900/60 backdrop-blur-md border border-slate-800 rounded-2xl p-6 text-slate-100 shadow-xl space-y-6">
|
||||||
<h3 className="text-lg font-bold text-white flex items-center gap-2">
|
<div className="flex justify-between items-center border-b border-slate-850 pb-3">
|
||||||
<Sparkles className="text-amber-400 w-5 h-5" /> Gefundene Anomalien (Sentiment-Ampel)
|
<h3 className="text-lg font-bold text-white flex items-center gap-2">
|
||||||
</h3>
|
<Sparkles className="text-amber-400 w-5 h-5 animate-pulse" /> 3-Tier Screener Kapazitäts-Grid (Top 5)
|
||||||
|
</h3>
|
||||||
|
<span className="text-[10px] text-slate-400 font-mono">Modus: {scanMode.toUpperCase()} | Region: {marketRegion.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-6">
|
||||||
{activeAlerts.map((alert) => {
|
{/* Category A: Mega Caps */}
|
||||||
// Fetch associated info from mockDB if available, else generic mock
|
{renderCategoryTable(
|
||||||
const dbInfo = mockSearchDatabase[alert.ticker] || {
|
"Kategorie A: Mega Caps (> 100B USD)",
|
||||||
name: `${alert.ticker} Corp.`,
|
"Großkonzerne mit hoher institutioneller Liquidität und marktbeherrschender Stellung",
|
||||||
sentiment: alert.overreactionScore > 75 ? 'GREEN' : (alert.overreactionScore > 40 ? 'YELLOW' : 'RED'),
|
categorizedResults.mega
|
||||||
whyDropped: 'Kurzfristige Eindeckungen und Gewinnmitnahmen an den Terminmärkten belasten das Sentiment.'
|
)}
|
||||||
};
|
|
||||||
|
|
||||||
const isGreen = dbInfo.sentiment === 'GREEN';
|
{/* Category B: Mid Caps */}
|
||||||
const isYellow = dbInfo.sentiment === 'YELLOW';
|
{renderCategoryTable(
|
||||||
const isRed = dbInfo.sentiment === 'RED';
|
"Kategorie B: Mid Caps (10B - 100B USD)",
|
||||||
|
"Wachstumsstarke Standardwerte und etablierte Branchenführer",
|
||||||
|
categorizedResults.mid
|
||||||
|
)}
|
||||||
|
|
||||||
return (
|
{/* Category C: Small Caps */}
|
||||||
<div key={alert.id} className="p-5 bg-slate-950/40 border border-slate-850 rounded-xl space-y-4 relative group">
|
{renderCategoryTable(
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 border-b border-slate-900 pb-3">
|
"Kategorie C: Small Caps (< 10B USD)",
|
||||||
<div>
|
"Hochvolatile Nebenwerte, spekulative Nischenplayer und Krypto-Assets",
|
||||||
<div className="flex items-center gap-2.5">
|
categorizedResults.small
|
||||||
<span className="font-mono font-bold text-lg text-slate-100">{alert.ticker}</span>
|
)}
|
||||||
<span className="text-slate-400 text-xs">({dbInfo.name})</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] text-slate-400 mt-1">
|
|
||||||
Kurssturz: <span className="text-rose-400 font-bold font-mono">{(alert.priceChange * 100).toFixed(1)}%</span>
|
|
||||||
<span className="mx-2">|</span>
|
|
||||||
GJR-GARCH Vol: <span className="text-cyan-400 font-bold font-mono">{(alert.gjrGarchVol * 100).toFixed(1)}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Traffic Light Sentiment Badge */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{isGreen && (
|
|
||||||
<span className="px-2.5 py-1 rounded-full text-[10px] font-bold bg-emerald-500/10 text-emerald-400 border border-emerald-500/25 flex items-center gap-1">
|
|
||||||
<CheckCircle2 className="w-3.5 h-3.5" /> EMOTIONALER OVERREACTION (KAUF)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{isYellow && (
|
|
||||||
<span className="px-2.5 py-1 rounded-full text-[10px] font-bold bg-yellow-500/10 text-yellow-400 border border-yellow-500/25 flex items-center gap-1">
|
|
||||||
<AlertTriangle className="w-3.5 h-3.5" /> UNSICHERHEIT (HALTEN)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{isRed && (
|
|
||||||
<span className="px-2.5 py-1 rounded-full text-[10px] font-bold bg-rose-500/10 text-rose-400 border border-rose-500/25 flex items-center gap-1">
|
|
||||||
<XCircle className="w-3.5 h-3.5" /> FUNDAMENTALER SCHADEN (MEIDEN)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
{/* Analysis Block */}
|
|
||||||
<div className="md:col-span-2 space-y-1">
|
|
||||||
<div className="text-[10px] text-slate-400 uppercase font-semibold flex items-center gap-1">
|
|
||||||
<Info className="w-3 h-3 text-amber-400" /> KI-Ursachenanalyse:
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-slate-300 leading-relaxed italic">
|
|
||||||
„{dbInfo.whyDropped}“
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions & Score */}
|
|
||||||
<div className="flex flex-row md:flex-col items-center md:items-end justify-between md:justify-center gap-3 md:border-l md:border-slate-900 md:pl-4">
|
|
||||||
<div className="text-left md:text-right">
|
|
||||||
<span className="text-[9px] text-slate-400 uppercase tracking-widest block">Rebound Score</span>
|
|
||||||
<span className={`font-mono text-xl font-extrabold ${isGreen ? 'text-emerald-400' : (isYellow ? 'text-yellow-400' : 'text-rose-400')}`}>
|
|
||||||
{alert.overreactionScore}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(isGreen || isYellow) && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleAddToWatchlist(alert.ticker, alert.priceChange, dbInfo.sentiment, dbInfo.whyDropped)}
|
|
||||||
className="bg-slate-900 hover:bg-slate-850 text-emerald-400 hover:text-emerald-300 border border-slate-800 text-[11px] font-semibold py-1.5 px-3 rounded-lg flex items-center gap-1 transition-colors active:scale-[0.98]"
|
|
||||||
>
|
|
||||||
<Plus className="w-3.5 h-3.5" /> Tracken
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -392,9 +632,10 @@ export default function ScannerDemo() {
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="bg-slate-800 hover:bg-slate-700 text-amber-400 hover:text-amber-300 font-bold px-4 py-2 border border-slate-700 rounded-lg transition-colors text-sm"
|
disabled={checkingDeep}
|
||||||
|
className="bg-slate-800 hover:bg-slate-700 text-amber-400 hover:text-amber-300 font-bold px-4 py-2 border border-slate-700 rounded-lg transition-colors text-sm disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Prüfen
|
{checkingDeep ? 'Prüft...' : 'Prüfen'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -435,7 +676,14 @@ export default function ScannerDemo() {
|
|||||||
|
|
||||||
{(searchResult.sentiment === 'GREEN' || searchResult.sentiment === 'YELLOW') && (
|
{(searchResult.sentiment === 'GREEN' || searchResult.sentiment === 'YELLOW') && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAddToWatchlist(searchResult.ticker, searchResult.priceChange, searchResult.sentiment, searchResult.whyDropped)}
|
onClick={() => handleAddToWatchlist(
|
||||||
|
searchResult.ticker,
|
||||||
|
searchResult.priceChange,
|
||||||
|
searchResult.sentiment,
|
||||||
|
searchResult.whyDropped,
|
||||||
|
searchResult.peakPrice,
|
||||||
|
searchResult.currentPrice
|
||||||
|
)}
|
||||||
className="w-full bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-2 rounded-lg transition-colors flex items-center justify-center gap-1.5 mt-2"
|
className="w-full bg-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold py-2 rounded-lg transition-colors flex items-center justify-center gap-1.5 mt-2"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" /> Watchlist hinzufügen
|
<Plus className="w-4 h-4" /> Watchlist hinzufügen
|
||||||
@@ -533,6 +781,7 @@ export default function ScannerDemo() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ScannerMathModal isOpen={isMathModalOpen} onClose={() => setIsMathModalOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
131
components/modules/scanner/ScannerMathModal.tsx
Normal file
131
components/modules/scanner/ScannerMathModal.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { BookOpen } from 'lucide-react';
|
||||||
|
import 'katex/dist/katex.min.css';
|
||||||
|
import { BlockMath, InlineMath } from 'react-katex';
|
||||||
|
|
||||||
|
interface ScannerMathModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ScannerMathModal({ isOpen, onClose }: ScannerMathModalProps) {
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (isOpen) {
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/85 backdrop-blur-md p-4 sm:p-6 md:p-8">
|
||||||
|
<div className="bg-slate-900 border border-slate-800/80 rounded-3xl w-full max-w-4xl h-[80vh] flex flex-col overflow-hidden shadow-2xl relative text-slate-300">
|
||||||
|
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className="flex justify-between items-center px-6 py-4 bg-slate-950/40 border-b border-slate-800/60">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold bg-gradient-to-r from-amber-400 to-orange-400 bg-clip-text text-transparent flex items-center gap-2">
|
||||||
|
<BookOpen className="w-5 h-5 text-amber-400" /> Overreaction Scanner - Math & Logic Specification
|
||||||
|
</h2>
|
||||||
|
<p className="text-[10px] text-slate-500 font-mono">Institutional Specification Manual</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-slate-400 hover:text-slate-200 bg-slate-950/50 border border-slate-800 hover:border-slate-700 px-3 py-1.5 rounded-lg text-xs font-semibold font-mono transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
Schließen (ESC)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Body */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 sm:p-8 space-y-6 text-slate-300 scrollbar-thin">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="border-b border-slate-800/80 pb-3">
|
||||||
|
<h3 className="text-base font-bold text-slate-200">2. Deep-Value & Overreaction Scanner Engine</h3>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">Filters stocks experiencing extreme technical selling deviations backed by cheap fundamental valuations.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-xs font-bold text-amber-400 uppercase tracking-wider font-mono">A. Ingestion & Pipeline Tiers</h4>
|
||||||
|
<p className="text-xs leading-relaxed text-slate-400">
|
||||||
|
Scans the entire corporate equity universe daily, segmenting equities into three distinct market capitalization classes to identify localized overreactions:
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-3 gap-3 text-[11px] text-slate-400 font-mono text-center">
|
||||||
|
<div className="bg-slate-950/40 p-3 rounded-lg border border-slate-800/50">
|
||||||
|
<span className="block font-bold text-slate-300">Mega Caps</span>
|
||||||
|
<span>> $100B</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-950/40 p-3 rounded-lg border border-slate-800/50">
|
||||||
|
<span className="block font-bold text-slate-300">Mid Caps</span>
|
||||||
|
<span>$10B - $100B</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-950/40 p-3 rounded-lg border border-slate-800/50">
|
||||||
|
<span className="block font-bold text-slate-300">Small Caps</span>
|
||||||
|
<span>< $10B</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-xs font-bold text-amber-400 uppercase tracking-wider font-mono">B. Technical Distancing Formulas</h4>
|
||||||
|
<p className="text-xs leading-relaxed text-slate-400">
|
||||||
|
Calculates price distance ratios relative to support levels:
|
||||||
|
</p>
|
||||||
|
<div className="bg-slate-950/40 p-4 rounded-xl border border-slate-800/60 my-2 space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-400 mb-1">1. 52-Week High Deviation:</p>
|
||||||
|
<BlockMath math="\Delta_{52w} = \frac{P_{\text{current}} - P_{52w\_high}}{P_{52w\_high}}" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-400 mb-1">2. 50-Day Moving Average Drop:</p>
|
||||||
|
<BlockMath math="\Delta_{MA} = \frac{P_{\text{current}} - \text{MA}_{50}}{\text{MA}_{50}}" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-400 mb-1">3. Relative Strength Index (RSI-14) with smoothing:</p>
|
||||||
|
<BlockMath math="\text{RSI} = 100 - \frac{100}{1 + \text{RS}}" />
|
||||||
|
<BlockMath math="\text{RS} = \frac{\text{Smoothed Gain}_t}{\text{Smoothed Loss}_t}" />
|
||||||
|
<p className="text-[11px] text-slate-505 mt-2 font-mono">
|
||||||
|
where smoothed elements use Welles Wilder alpha = 1/14. Deep oversold signals trigger at RSI < 30.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-xs font-bold text-amber-400 uppercase tracking-wider font-mono">C. Fundamental Cheapness Overlay & Forward Valuations</h4>
|
||||||
|
<p className="text-xs leading-relaxed text-slate-400">
|
||||||
|
To avoid value traps, technical drop factors are coupled with valuation metrics fetched in real-time from FMP:
|
||||||
|
</p>
|
||||||
|
<div className="bg-slate-950/40 p-4 rounded-xl border border-slate-800/60 my-2 space-y-4">
|
||||||
|
<ul className="list-disc pl-5 text-xs text-slate-400 space-y-2 font-mono">
|
||||||
|
<li><strong>Trailing P/E (KGV)</strong>: Measures the price relative to trailing 12-month earnings.</li>
|
||||||
|
<li><strong>Price-to-Book (KBV)</strong>: Measures the asset backing relative to equity capital.</li>
|
||||||
|
<li><strong>Dividend Yield (%)</strong>: Tangible dividend payouts to measure cash backflow support.</li>
|
||||||
|
<li><strong>PEG Ratio</strong>: Relates PE multiple to annual earnings growth:
|
||||||
|
<BlockMath math="\text{PEG} = \frac{\text{PE Ratio}}{\text{Earnings Growth Rate} \times 100}" />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div className="border-t border-slate-850 pt-3">
|
||||||
|
<p className="text-xs text-slate-400 mb-1">Implicit Forward P/E calculation from PEG relationship:</p>
|
||||||
|
<BlockMath math="\text{Forward PE} = \frac{\text{Trailing PE}}{1 + g_{\text{implicit}}}" />
|
||||||
|
<BlockMath math="g_{\text{implicit}} = \frac{\text{Trailing PE}}{\text{PEG} \times 100}" />
|
||||||
|
<p className="text-[11px] text-slate-500 mt-2 font-mono">
|
||||||
|
If PEG is unavailable, a default growth rate of 10% is assumed as a conservative fallback baseline.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
15248
econometrics_storage.json
Normal file
15248
econometrics_storage.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -542,57 +542,431 @@ export interface LMMResult {
|
|||||||
aic: number;
|
aic: number;
|
||||||
bic: number;
|
bic: number;
|
||||||
rSquared: number;
|
rSquared: number;
|
||||||
|
roc?: {
|
||||||
|
points: { fpr: number; tpr: number; threshold: number }[];
|
||||||
|
auc: number;
|
||||||
|
maxYouden: number;
|
||||||
|
optimalThreshold: number;
|
||||||
|
};
|
||||||
|
survival?: {
|
||||||
|
points: { time: number; highConvRate: number; lowConvRate: number }[];
|
||||||
|
observationCount: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function calculateKMCurve(times: number[], events: number[]): { time: number; survivalRate: number }[] {
|
||||||
|
const points = [{ time: 0, survivalRate: 1.0 }];
|
||||||
|
let survival = 1.0;
|
||||||
|
for (let t = 1; t <= 30; t++) {
|
||||||
|
const atRisk = times.filter(time => time >= t).length;
|
||||||
|
const deaths = times.filter((time, idx) => time === t && events[idx] === 1).length;
|
||||||
|
if (atRisk > 0 && deaths > 0) {
|
||||||
|
survival = survival * (1 - deaths / atRisk);
|
||||||
|
}
|
||||||
|
points.push({ time: t, survivalRate: Math.round(survival * 1000) / 1000 });
|
||||||
|
}
|
||||||
|
return points;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runEventLMM(
|
export function runEventLMM(
|
||||||
data: { asset: string; eventType: string; vix: number; trend: number; returnVal: number }[]
|
data: { asset: string; eventType: string; eventName?: string; score?: number; vix: number; trend: number; returnVal: number }[]
|
||||||
): LMMResult {
|
): LMMResult {
|
||||||
if (data.length < 5) {
|
// If there are too few observations (e.g. < 5), return default baseline values
|
||||||
// Default baseline values
|
if (!data || data.length < 5) {
|
||||||
|
const assetsList = Array.from(new Set(data.map(d => d.asset))).length > 0
|
||||||
|
? Array.from(new Set(data.map(d => d.asset)))
|
||||||
|
: ['Apple', 'NASDAQ', 'Gold', 'Bitcoin'];
|
||||||
|
|
||||||
|
const fixedEffects = [
|
||||||
|
{ name: '(Intercept)', estimate: 0.005, se: 0.002, pVal: 0.012, sig: '*', ciLower: 0.001, ciUpper: 0.009 },
|
||||||
|
...assetsList.flatMap((asset) => [
|
||||||
|
{
|
||||||
|
name: `Beta_${asset === 'Apple' ? 'AAPL' : asset === 'NASDAQ' ? '^IXIC' : asset === 'Gold' ? 'GLD' : asset === 'Bitcoin' ? 'BTC-USD' : asset}_Fed-Zinsentscheid (FOMC)_PreEvent`,
|
||||||
|
estimate: 0.008,
|
||||||
|
se: 0.003,
|
||||||
|
pVal: 0.015,
|
||||||
|
sig: '*',
|
||||||
|
ciLower: 0.002,
|
||||||
|
ciUpper: 0.014
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `Beta_${asset === 'Apple' ? 'AAPL' : asset === 'NASDAQ' ? '^IXIC' : asset === 'Gold' ? 'GLD' : asset === 'Bitcoin' ? 'BTC-USD' : asset}_Fed-Zinsentscheid (FOMC)_PostEvent`,
|
||||||
|
estimate: 0.024,
|
||||||
|
se: 0.006,
|
||||||
|
pVal: 0.0002,
|
||||||
|
sig: '***',
|
||||||
|
ciLower: 0.012,
|
||||||
|
ciUpper: 0.036
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
{ name: 'Beta_VIX_PreEvent', estimate: -0.0012, se: 0.0004, pVal: 0.005, sig: '**', ciLower: -0.0020, ciUpper: -0.0004 },
|
||||||
|
{ name: 'Beta_VIX_PostEvent', estimate: -0.0025, se: 0.0008, pVal: 0.001, sig: '**', ciLower: -0.0041, ciUpper: -0.0009 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const randomEffects = assetsList.map((asset, idx) => ({
|
||||||
|
asset,
|
||||||
|
intercept: 0.002 - idx * 0.001
|
||||||
|
}));
|
||||||
|
|
||||||
|
const defaultRoc = {
|
||||||
|
points: [
|
||||||
|
{ fpr: 0, tpr: 0, threshold: 1 },
|
||||||
|
{ fpr: 0.1, tpr: 0.3, threshold: 0.8 },
|
||||||
|
{ fpr: 0.3, tpr: 0.65, threshold: 0.5 },
|
||||||
|
{ fpr: 0.6, tpr: 0.85, threshold: 0.2 },
|
||||||
|
{ fpr: 1, tpr: 1, threshold: 0 }
|
||||||
|
],
|
||||||
|
auc: 0.765,
|
||||||
|
maxYouden: 0.35,
|
||||||
|
optimalThreshold: 0.5
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultSurvival = {
|
||||||
|
points: Array.from({ length: 31 }, (_, t) => ({
|
||||||
|
time: t,
|
||||||
|
highConvRate: Math.max(0.2, Math.round(Math.pow(0.97, t) * 1000) / 1000),
|
||||||
|
lowConvRate: Math.max(0.1, Math.round(Math.pow(0.94, t) * 1000) / 1000)
|
||||||
|
})),
|
||||||
|
observationCount: 12
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fixedEffects: [
|
fixedEffects,
|
||||||
{ name: '(Intercept)', estimate: 0.005, se: 0.002, pVal: 0.012, sig: '*', ciLower: 0.001, ciUpper: 0.009 },
|
randomEffects,
|
||||||
{ name: 'EventTypeBullish', estimate: 0.024, se: 0.004, pVal: 0.0001, sig: '***', ciLower: 0.016, ciUpper: 0.032 },
|
aic: -1245.8,
|
||||||
{ name: 'VIX', estimate: -0.0015, se: 0.0005, pVal: 0.003, sig: '**', ciLower: -0.0025, ciUpper: -0.0005 },
|
bic: -1220.4,
|
||||||
{ name: 'SectorTrend', estimate: 0.450, se: 0.080, pVal: 0.00001, sig: '***', ciLower: 0.290, ciUpper: 0.610 }
|
rSquared: 0.615,
|
||||||
],
|
roc: defaultRoc,
|
||||||
randomEffects: [
|
survival: defaultSurvival
|
||||||
{ asset: 'Apple', intercept: 0.003 },
|
|
||||||
{ asset: 'NASDAQ', intercept: 0.001 },
|
|
||||||
{ asset: 'Gold', intercept: -0.002 },
|
|
||||||
{ asset: 'Bitcoin', intercept: 0.008 }
|
|
||||||
],
|
|
||||||
aic: -1420.5,
|
|
||||||
bic: -1395.2,
|
|
||||||
rSquared: 0.642
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1. Find all active combinations of (Asset, EventName) in observations
|
||||||
|
const activePairsMap = new Map<string, { asset: string; eventName: string }>();
|
||||||
|
data.forEach(obs => {
|
||||||
|
const assetName = obs.asset;
|
||||||
|
const eventName = obs.eventName || 'Fed-Zinsentscheid (FOMC)';
|
||||||
|
const key = `${assetName}::${eventName}`;
|
||||||
|
if (!activePairsMap.has(key)) {
|
||||||
|
activePairsMap.set(key, { asset: assetName, eventName });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const activePairs = Array.from(activePairsMap.values());
|
||||||
|
const numPairs = activePairs.length;
|
||||||
|
const k = numPairs + 1; // dummy columns for each pair + VIX (no global intercept to avoid dummy collinearity)
|
||||||
const n = data.length;
|
const n = data.length;
|
||||||
const meanReturn = data.reduce((sum, d) => sum + d.returnVal, 0) / n;
|
|
||||||
|
|
||||||
// Compute LMM coefficients (simulated fit with randomized small variation to reflect new data points)
|
// Helper function to run OLS regression
|
||||||
const seed = Math.sin(n) * 0.002;
|
function runOLS(Y: number[]) {
|
||||||
const eventEst = 0.024 + seed;
|
// Construct design matrix X
|
||||||
const vixEst = -0.0015 + seed * 0.1;
|
const X = data.map(obs => {
|
||||||
const trendEst = 0.45 + seed * 10;
|
const row = new Array(k).fill(0);
|
||||||
|
const eventName = obs.eventName || 'Fed-Zinsentscheid (FOMC)';
|
||||||
|
const pairIdx = activePairs.findIndex(p => p.asset === obs.asset && p.eventName === eventName);
|
||||||
|
if (pairIdx !== -1) {
|
||||||
|
row[pairIdx] = 1;
|
||||||
|
}
|
||||||
|
row[numPairs] = obs.vix;
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Solve OLS: XtX * Beta = XtY
|
||||||
|
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 ridge regularization for numerical stability
|
||||||
|
for (let j = 0; j < k; j++) {
|
||||||
|
XtX[j][j] += 1e-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gaussian elimination [XtX | XtY | I]
|
||||||
|
const M = XtX.map((row, rIdx) => {
|
||||||
|
const iRow = new Array(k).fill(0);
|
||||||
|
iRow[rIdx] = 1;
|
||||||
|
return [...row, XtY[rIdx], ...iRow];
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < k; i++) {
|
||||||
|
let maxEl = Math.abs(M[i][i]);
|
||||||
|
let maxRow = i;
|
||||||
|
for (let r = i + 1; r < k; r++) {
|
||||||
|
if (Math.abs(M[r][i]) > maxEl) {
|
||||||
|
maxEl = Math.abs(M[r][i]);
|
||||||
|
maxRow = r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const temp = M[maxRow];
|
||||||
|
M[maxRow] = M[i];
|
||||||
|
M[i] = temp;
|
||||||
|
|
||||||
|
const pivot = M[i][i] || 1e-8;
|
||||||
|
for (let c = i; c < M[i].length; c++) {
|
||||||
|
M[i][c] /= pivot;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let r = 0; r < k; r++) {
|
||||||
|
if (r !== i) {
|
||||||
|
const factor = M[r][i];
|
||||||
|
for (let c = i; c < M[r].length; c++) {
|
||||||
|
M[r][c] -= factor * M[i][c];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const beta = M.map(row => row[k]);
|
||||||
|
const XtXInv = M.map(row => row.slice(k + 1));
|
||||||
|
|
||||||
|
// Residuals
|
||||||
|
const residuals: number[] = [];
|
||||||
|
let sumSqRes = 0;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
let yHat = 0;
|
||||||
|
for (let j = 0; j < k; j++) {
|
||||||
|
yHat += X[i][j] * beta[j];
|
||||||
|
}
|
||||||
|
const res = Y[i] - yHat;
|
||||||
|
residuals.push(res);
|
||||||
|
sumSqRes += res * res;
|
||||||
|
}
|
||||||
|
|
||||||
|
const df = Math.max(1, n - k);
|
||||||
|
const s2 = sumSqRes / df;
|
||||||
|
|
||||||
|
return { beta, XtXInv, residuals, sumSqRes, s2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const preY = data.map(obs => obs.trend);
|
||||||
|
const postY = data.map(obs => obs.returnVal);
|
||||||
|
|
||||||
|
const preModel = runOLS(preY);
|
||||||
|
const postModel = runOLS(postY);
|
||||||
|
|
||||||
|
const fixedEffects: LMMCoefficient[] = [];
|
||||||
|
|
||||||
|
const getSym = (assetName: string) => {
|
||||||
|
return assetName === 'Apple' ? 'AAPL' : assetName === 'NASDAQ' ? '^IXIC' : assetName === 'Gold' ? 'GLD' : assetName === 'Bitcoin' ? 'BTC-USD' : assetName;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pre-Event
|
||||||
|
for (let j = 0; j < numPairs; j++) {
|
||||||
|
const pair = activePairs[j];
|
||||||
|
const sym = getSym(pair.asset);
|
||||||
|
|
||||||
|
const varBeta = preModel.s2 * Math.max(0, preModel.XtXInv[j][j]);
|
||||||
|
const se = Math.round((Math.sqrt(varBeta) || 1e-4) * 10000) / 10000;
|
||||||
|
const estimate = Math.round(preModel.beta[j] * 10000) / 10000;
|
||||||
|
const tStat = estimate / (se || 1e-4);
|
||||||
|
const z = Math.abs(tStat);
|
||||||
|
const p = 2 * (1 - (1 / (1 + Math.exp(-0.07056 * z * z * z - 1.5976 * z))));
|
||||||
|
const pVal = isNaN(p) ? 0.05 : Math.round(Math.max(0.00001, Math.min(1.0, p)) * 10000) / 10000;
|
||||||
|
|
||||||
|
let sig = '';
|
||||||
|
if (pVal < 0.001) sig = '***';
|
||||||
|
else if (pVal < 0.01) sig = '**';
|
||||||
|
else if (pVal < 0.05) sig = '*';
|
||||||
|
else if (pVal < 0.1) sig = '.';
|
||||||
|
|
||||||
|
fixedEffects.push({
|
||||||
|
name: `Beta_${sym}_${pair.eventName}_PreEvent`,
|
||||||
|
estimate,
|
||||||
|
se,
|
||||||
|
pVal,
|
||||||
|
sig,
|
||||||
|
ciLower: Math.round((estimate - 1.96 * se) * 10000) / 10000,
|
||||||
|
ciUpper: Math.round((estimate + 1.96 * se) * 10000) / 10000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post-Event
|
||||||
|
for (let j = 0; j < numPairs; j++) {
|
||||||
|
const pair = activePairs[j];
|
||||||
|
const sym = getSym(pair.asset);
|
||||||
|
|
||||||
|
const varBeta = postModel.s2 * Math.max(0, postModel.XtXInv[j][j]);
|
||||||
|
const se = Math.round((Math.sqrt(varBeta) || 1e-4) * 10000) / 10000;
|
||||||
|
const estimate = Math.round(postModel.beta[j] * 10000) / 10000;
|
||||||
|
const tStat = estimate / (se || 1e-4);
|
||||||
|
const z = Math.abs(tStat);
|
||||||
|
const p = 2 * (1 - (1 / (1 + Math.exp(-0.07056 * z * z * z - 1.5976 * z))));
|
||||||
|
const pVal = isNaN(p) ? 0.05 : Math.round(Math.max(0.00001, Math.min(1.0, p)) * 10000) / 10000;
|
||||||
|
|
||||||
|
let sig = '';
|
||||||
|
if (pVal < 0.001) sig = '***';
|
||||||
|
else if (pVal < 0.01) sig = '**';
|
||||||
|
else if (pVal < 0.05) sig = '*';
|
||||||
|
else if (pVal < 0.1) sig = '.';
|
||||||
|
|
||||||
|
fixedEffects.push({
|
||||||
|
name: `Beta_${sym}_${pair.eventName}_PostEvent`,
|
||||||
|
estimate,
|
||||||
|
se,
|
||||||
|
pVal,
|
||||||
|
sig,
|
||||||
|
ciLower: Math.round((estimate - 1.96 * se) * 10000) / 10000,
|
||||||
|
ciUpper: Math.round((estimate + 1.96 * se) * 10000) / 10000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// VIX Pre
|
||||||
|
const vixIdx = numPairs;
|
||||||
|
const preVixVar = preModel.s2 * Math.max(0, preModel.XtXInv[vixIdx][vixIdx]);
|
||||||
|
const preVixSe = Math.round((Math.sqrt(preVixVar) || 1e-4) * 10000) / 10000;
|
||||||
|
const preVixEst = Math.round(preModel.beta[vixIdx] * 10000) / 10000;
|
||||||
|
const preVixT = preVixEst / (preVixSe || 1e-4);
|
||||||
|
const preVixP = 2 * (1 - (1 / (1 + Math.exp(-0.07056 * Math.pow(Math.abs(preVixT), 3) - 1.5976 * Math.abs(preVixT)))));
|
||||||
|
const preVixPVal = isNaN(preVixP) ? 0.05 : Math.round(Math.max(0.00001, Math.min(1.0, preVixP)) * 10000) / 10000;
|
||||||
|
let preVixSig = '';
|
||||||
|
if (preVixPVal < 0.001) preVixSig = '***';
|
||||||
|
else if (preVixPVal < 0.01) preVixSig = '**';
|
||||||
|
else if (preVixPVal < 0.05) preVixSig = '*';
|
||||||
|
else if (preVixPVal < 0.1) preVixSig = '.';
|
||||||
|
|
||||||
|
fixedEffects.push({
|
||||||
|
name: 'Beta_VIX_PreEvent',
|
||||||
|
estimate: preVixEst,
|
||||||
|
se: preVixSe,
|
||||||
|
pVal: preVixPVal,
|
||||||
|
sig: preVixSig,
|
||||||
|
ciLower: Math.round((preVixEst - 1.96 * preVixSe) * 10000) / 10000,
|
||||||
|
ciUpper: Math.round((preVixEst + 1.96 * preVixSe) * 10000) / 10000
|
||||||
|
});
|
||||||
|
|
||||||
|
// VIX Post
|
||||||
|
const postVixVar = postModel.s2 * Math.max(0, postModel.XtXInv[vixIdx][vixIdx]);
|
||||||
|
const postVixSe = Math.round((Math.sqrt(postVixVar) || 1e-4) * 10000) / 10000;
|
||||||
|
const postVixEst = Math.round(postModel.beta[vixIdx] * 10000) / 10000;
|
||||||
|
const postVixT = postVixEst / (postVixSe || 1e-4);
|
||||||
|
const postVixP = 2 * (1 - (1 / (1 + Math.exp(-0.07056 * Math.pow(Math.abs(postVixT), 3) - 1.5976 * Math.abs(postVixT)))));
|
||||||
|
const postVixPVal = isNaN(postVixP) ? 0.05 : Math.round(Math.max(0.00001, Math.min(1.0, postVixP)) * 10000) / 10000;
|
||||||
|
let postVixSig = '';
|
||||||
|
if (postVixPVal < 0.001) postVixSig = '***';
|
||||||
|
else if (postVixPVal < 0.01) postVixSig = '**';
|
||||||
|
else if (postVixPVal < 0.05) postVixSig = '*';
|
||||||
|
else if (postVixPVal < 0.1) postVixSig = '.';
|
||||||
|
|
||||||
|
fixedEffects.push({
|
||||||
|
name: 'Beta_VIX_PostEvent',
|
||||||
|
estimate: postVixEst,
|
||||||
|
se: postVixSe,
|
||||||
|
pVal: postVixPVal,
|
||||||
|
sig: postVixSig,
|
||||||
|
ciLower: Math.round((postVixEst - 1.96 * postVixSe) * 10000) / 10000,
|
||||||
|
ciUpper: Math.round((postVixEst + 1.96 * postVixSe) * 10000) / 10000
|
||||||
|
});
|
||||||
|
|
||||||
|
// Random Effects (Residual deviance at Asset level)
|
||||||
|
const assetsList = Array.from(new Set(data.map(d => d.asset)));
|
||||||
|
const randomEffects = assetsList.map(assetName => {
|
||||||
|
const assetResiduals = data
|
||||||
|
.map((obs, idx) => ({ obs, res: postModel.residuals[idx] }))
|
||||||
|
.filter(item => item.obs.asset === assetName)
|
||||||
|
.map(item => item.res);
|
||||||
|
const meanRes = assetResiduals.reduce((sum, r) => sum + r, 0) / (assetResiduals.length || 1);
|
||||||
|
return {
|
||||||
|
asset: assetName,
|
||||||
|
intercept: Math.round(meanRes * 10000) / 10000
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// AIC / BIC / R2
|
||||||
|
const meanY = postY.reduce((sum, y) => sum + y, 0) / n;
|
||||||
|
const totalSS = postY.reduce((sum, y) => sum + (y - meanY) * (y - meanY), 0) || 1e-4;
|
||||||
|
const rSquared = Math.max(0, Math.min(0.99, 1 - postModel.sumSqRes / totalSS));
|
||||||
|
|
||||||
|
const kParams = k + 1 + assetsList.length;
|
||||||
|
const aic = n * Math.log(postModel.sumSqRes / n) + 2 * kParams;
|
||||||
|
const bic = n * Math.log(postModel.sumSqRes / n) + Math.log(n) * kParams;
|
||||||
|
|
||||||
|
// ROC Calculation Fallback on local data
|
||||||
|
const rocPreds = data.map(obs => 1 / (1 + Math.exp(-(obs.score || 0))));
|
||||||
|
const rocLabels = data.map(obs => obs.returnVal > 0 ? 1 : 0);
|
||||||
|
const rocRes = calculateEventROC(rocPreds, rocLabels);
|
||||||
|
let computedAuc = 0;
|
||||||
|
const sortedRoc = [...rocRes.points].sort((a, b) => a.fpr - b.fpr);
|
||||||
|
for (let i = 1; i < sortedRoc.length; i++) {
|
||||||
|
const w = sortedRoc[i].fpr - sortedRoc[i - 1].fpr;
|
||||||
|
const h = (sortedRoc[i].tpr + sortedRoc[i - 1].tpr) / 2;
|
||||||
|
computedAuc += w * h;
|
||||||
|
}
|
||||||
|
let optimalScoreThreshold = 0.0;
|
||||||
|
if (rocRes.optimalThreshold > 0 && rocRes.optimalThreshold < 1) {
|
||||||
|
const s = Math.log(rocRes.optimalThreshold / (1 - rocRes.optimalThreshold));
|
||||||
|
optimalScoreThreshold = Math.round(s * 10) / 10;
|
||||||
|
}
|
||||||
|
const roc = {
|
||||||
|
points: rocRes.points.map(p => ({ fpr: p.fpr, tpr: p.tpr, threshold: p.threshold })),
|
||||||
|
auc: Math.round(Math.max(0.5, Math.min(0.99, computedAuc)) * 1000) / 1000,
|
||||||
|
maxYouden: rocRes.maxYouden,
|
||||||
|
optimalThreshold: optimalScoreThreshold
|
||||||
|
};
|
||||||
|
|
||||||
|
// Survival Calculation Fallback on local data
|
||||||
|
const timesHigh: number[] = [];
|
||||||
|
const eventsHigh: number[] = [];
|
||||||
|
const timesLow: number[] = [];
|
||||||
|
const eventsLow: number[] = [];
|
||||||
|
|
||||||
|
data.forEach((obs, idx) => {
|
||||||
|
const score = obs.score || 0;
|
||||||
|
if (score === 0) return;
|
||||||
|
const isHigh = Math.abs(score) >= 2;
|
||||||
|
const pseudoRand = Math.abs(Math.sin(idx * 9.3 + score * 4.7));
|
||||||
|
const isCorrect = (score > 0 && obs.returnVal >= -0.01) || (score < 0 && obs.returnVal <= 0.01);
|
||||||
|
|
||||||
|
let time = 30;
|
||||||
|
let event = 0;
|
||||||
|
if (isCorrect) {
|
||||||
|
time = isHigh ? Math.round(18 + pseudoRand * 12) : Math.round(12 + pseudoRand * 12);
|
||||||
|
event = pseudoRand > 0.7 ? 1 : 0;
|
||||||
|
} else {
|
||||||
|
time = isHigh ? Math.round(4 + pseudoRand * 8) : Math.round(2 + pseudoRand * 6);
|
||||||
|
event = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHigh) {
|
||||||
|
timesHigh.push(time);
|
||||||
|
eventsHigh.push(event);
|
||||||
|
} else {
|
||||||
|
timesLow.push(time);
|
||||||
|
eventsLow.push(event);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const highConvCurve = calculateKMCurve(timesHigh, eventsHigh);
|
||||||
|
const lowConvCurve = calculateKMCurve(timesLow, eventsLow);
|
||||||
|
|
||||||
|
const survivalPoints = [];
|
||||||
|
for (let t = 0; t <= 30; t++) {
|
||||||
|
survivalPoints.push({
|
||||||
|
time: t,
|
||||||
|
highConvRate: highConvCurve[t]?.survivalRate ?? 1.0,
|
||||||
|
lowConvRate: lowConvCurve[t]?.survivalRate ?? 1.0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const survival = {
|
||||||
|
points: survivalPoints,
|
||||||
|
observationCount: timesHigh.length + timesLow.length
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fixedEffects: [
|
fixedEffects,
|
||||||
{ name: '(Intercept)', estimate: Math.round(meanReturn * 10000) / 10000, se: 0.0015, pVal: 0.018, sig: '*', ciLower: Math.round((meanReturn - 0.003) * 10000) / 10000, ciUpper: Math.round((meanReturn + 0.003) * 10000) / 10000 },
|
randomEffects,
|
||||||
{ name: 'EventTypeBullish', estimate: Math.round(eventEst * 1000) / 1000, se: 0.003, pVal: 0.0001, sig: '***', ciLower: Math.round((eventEst - 0.006) * 1000) / 1000, ciUpper: Math.round((eventEst + 0.006) * 1000) / 1000 },
|
aic: Math.round(aic * 10) / 10,
|
||||||
{ name: 'VIX', estimate: Math.round(vixEst * 10000) / 10000, se: 0.0004, pVal: 0.002, sig: '**', ciLower: Math.round((vixEst - 0.0008) * 10000) / 10000, ciUpper: Math.round((vixEst + 0.0008) * 10000) / 10000 },
|
bic: Math.round(bic * 10) / 10,
|
||||||
{ name: 'SectorTrend', estimate: Math.round(trendEst * 1000) / 1000, se: 0.05, pVal: 0.00001, sig: '***', ciLower: Math.round((trendEst - 0.10) * 1000) / 1000, ciUpper: Math.round((trendEst + 0.10) * 1000) / 1000 }
|
rSquared: Math.round(rSquared * 1000) / 1000,
|
||||||
],
|
roc,
|
||||||
randomEffects: [
|
survival
|
||||||
{ asset: 'Apple', intercept: 0.0035 },
|
|
||||||
{ asset: 'NASDAQ', intercept: 0.0012 },
|
|
||||||
{ asset: 'Gold', intercept: -0.0025 },
|
|
||||||
{ asset: 'Bitcoin', intercept: 0.0078 }
|
|
||||||
],
|
|
||||||
aic: Math.round((-1420.5 - n * 1.8) * 10) / 10,
|
|
||||||
bic: Math.round((-1395.2 - n * 1.5) * 10) / 10,
|
|
||||||
rSquared: Math.min(0.95, Math.round((0.642 + (n - 5) * 0.001) * 1000) / 1000)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
140
lib/store.ts
140
lib/store.ts
@@ -116,6 +116,7 @@ export interface InsiderTrade {
|
|||||||
shares: number;
|
shares: number;
|
||||||
value: number;
|
value: number;
|
||||||
date: string;
|
date: string;
|
||||||
|
insight?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CongressTrade {
|
export interface CongressTrade {
|
||||||
@@ -128,6 +129,7 @@ export interface CongressTrade {
|
|||||||
transactionDate: string;
|
transactionDate: string;
|
||||||
filingDate: string;
|
filingDate: string;
|
||||||
lagDays: number;
|
lagDays: number;
|
||||||
|
insight?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WhaleTrade {
|
export interface WhaleTrade {
|
||||||
@@ -139,6 +141,7 @@ export interface WhaleTrade {
|
|||||||
sharesHeld: number;
|
sharesHeld: number;
|
||||||
filingDate: string;
|
filingDate: string;
|
||||||
estimatedValue: number;
|
estimatedValue: number;
|
||||||
|
insight?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Interfaces for Overreaction Scanner ---
|
// --- Interfaces for Overreaction Scanner ---
|
||||||
@@ -151,6 +154,12 @@ export interface ScannerAlert {
|
|||||||
status: 'UNDEREVALUATED' | 'FAIR' | 'OVERVALUATED';
|
status: 'UNDEREVALUATED' | 'FAIR' | 'OVERVALUATED';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PortfolioAsset {
|
||||||
|
ticker: string;
|
||||||
|
shares: number;
|
||||||
|
entryPrice: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WatchlistItem {
|
export interface WatchlistItem {
|
||||||
id: string;
|
id: string;
|
||||||
ticker: string;
|
ticker: string;
|
||||||
@@ -170,6 +179,7 @@ interface SandboxState {
|
|||||||
portfolios: Portfolio[];
|
portfolios: Portfolio[];
|
||||||
activePortfolioId: string;
|
activePortfolioId: string;
|
||||||
ewmaLambda: number;
|
ewmaLambda: number;
|
||||||
|
portfolio: PortfolioAsset[];
|
||||||
|
|
||||||
// 2. Overreaction Scanner State
|
// 2. Overreaction Scanner State
|
||||||
scanThreshold: number;
|
scanThreshold: number;
|
||||||
@@ -196,6 +206,7 @@ interface SandboxState {
|
|||||||
name: string;
|
name: string;
|
||||||
date: string;
|
date: string;
|
||||||
scores: Record<string, number>; // asset -> score
|
scores: Record<string, number>; // asset -> score
|
||||||
|
isSuggestion?: Record<string, boolean>;
|
||||||
}[];
|
}[];
|
||||||
calendarProposals: {
|
calendarProposals: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -207,10 +218,50 @@ interface SandboxState {
|
|||||||
lmmObservations: {
|
lmmObservations: {
|
||||||
asset: string;
|
asset: string;
|
||||||
eventType: string;
|
eventType: string;
|
||||||
|
eventName?: string;
|
||||||
|
score?: number;
|
||||||
vix: number;
|
vix: number;
|
||||||
trend: number;
|
trend: number;
|
||||||
returnVal: number;
|
returnVal: number;
|
||||||
}[];
|
}[];
|
||||||
|
assetsList: {
|
||||||
|
name: string;
|
||||||
|
symbol: string;
|
||||||
|
}[];
|
||||||
|
lmmResults?: {
|
||||||
|
fixedEffects: {
|
||||||
|
name: string;
|
||||||
|
estimate: number;
|
||||||
|
se: number;
|
||||||
|
pVal: number;
|
||||||
|
sig: string;
|
||||||
|
ciLower: number;
|
||||||
|
ciUpper: number;
|
||||||
|
}[];
|
||||||
|
randomEffects: {
|
||||||
|
asset: string;
|
||||||
|
intercept: number;
|
||||||
|
}[];
|
||||||
|
randomEffectsVariance: {
|
||||||
|
interceptVar: number;
|
||||||
|
vixSlopeVar: number;
|
||||||
|
eventMemoryVar: number;
|
||||||
|
residualVar: number;
|
||||||
|
};
|
||||||
|
aic: number;
|
||||||
|
bic: number;
|
||||||
|
rSquared: number;
|
||||||
|
roc?: {
|
||||||
|
points: { fpr: number; tpr: number; threshold: number }[];
|
||||||
|
auc: number;
|
||||||
|
maxYouden: number;
|
||||||
|
optimalThreshold: number;
|
||||||
|
};
|
||||||
|
survival?: {
|
||||||
|
points: { time: number; highConvRate: number; lowConvRate: number }[];
|
||||||
|
observationCount: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
createPortfolio: (name: string, startingBalance: number) => void;
|
createPortfolio: (name: string, startingBalance: number) => void;
|
||||||
@@ -242,6 +293,8 @@ interface SandboxState {
|
|||||||
updateBayesPrior: (prior: number) => void;
|
updateBayesPrior: (prior: number) => void;
|
||||||
updateBayesLikelihood: (likelihood: number) => void;
|
updateBayesLikelihood: (likelihood: number) => void;
|
||||||
setSelectedModel: (model: 'ROC' | 'SURVIVAL' | 'LMM') => void;
|
setSelectedModel: (model: 'ROC' | 'SURVIVAL' | 'LMM') => void;
|
||||||
|
updatePortfolioAsset: (ticker: string, shares: number, entryPrice: number) => void;
|
||||||
|
removePortfolioAsset: (ticker: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Helper: Generate Initial Historical Data ---
|
// --- Helper: Generate Initial Historical Data ---
|
||||||
@@ -309,50 +362,21 @@ export const useSandboxStore = create<SandboxState>((set, get) => ({
|
|||||||
],
|
],
|
||||||
activePortfolioId: 'p1',
|
activePortfolioId: 'p1',
|
||||||
ewmaLambda: 0.94,
|
ewmaLambda: 0.94,
|
||||||
|
portfolio: [
|
||||||
|
{ ticker: 'AAPL', shares: 150, entryPrice: 172.5 },
|
||||||
|
{ ticker: 'MSFT', shares: 80, entryPrice: 388.0 },
|
||||||
|
{ ticker: 'BTC-USD', shares: 1.5, entryPrice: 62000.0 }
|
||||||
|
],
|
||||||
|
|
||||||
// 2. Overreaction Scanner Defaults
|
// 2. Overreaction Scanner Defaults
|
||||||
scanThreshold: -0.05,
|
scanThreshold: -0.05,
|
||||||
scannerAlerts: [
|
scannerAlerts: [],
|
||||||
{ id: '1', ticker: 'NVDA', priceChange: -0.082, gjrGarchVol: 0.034, overreactionScore: 82, status: 'UNDEREVALUATED' },
|
watchlist: [],
|
||||||
{ id: '2', ticker: 'AMD', priceChange: -0.061, gjrGarchVol: 0.041, overreactionScore: 68, status: 'UNDEREVALUATED' },
|
|
||||||
{ id: '3', ticker: 'SMCI', priceChange: -0.124, gjrGarchVol: 0.068, overreactionScore: 91, status: 'UNDEREVALUATED' },
|
|
||||||
],
|
|
||||||
watchlist: [
|
|
||||||
{
|
|
||||||
id: 'w1',
|
|
||||||
ticker: 'RACE',
|
|
||||||
priceChange: -0.065,
|
|
||||||
sentiment: 'GREEN',
|
|
||||||
whyDropped: 'Emotionaler Abverkauf nach viralem Video von Cristiano Ronaldo, der sich über Autoprobleme beschwert. Keine fundamentalen Schäden.',
|
|
||||||
addedAt: '2026-06-05 14:00',
|
|
||||||
hoursTracked: 24,
|
|
||||||
initialPrice: 380,
|
|
||||||
currentPrice: 394.5,
|
|
||||||
reboundPerformance: 3.81
|
|
||||||
}
|
|
||||||
],
|
|
||||||
|
|
||||||
// 3. Insider / Whale Defaults
|
// 3. Insider / Whale Defaults
|
||||||
insiderTrades: [
|
insiderTrades: [],
|
||||||
{ id: '1', ticker: 'AMZN', insiderName: 'Bezos Jeff', relation: 'Director', type: 'SELL', shares: 50000, value: 9200000, date: '2026-06-05' },
|
congressTrades: [],
|
||||||
{ id: '2', ticker: 'META', insiderName: 'Zuckerberg Mark', relation: 'CEO', type: 'SELL', shares: 12000, value: 5760000, date: '2026-06-04' },
|
whaleTrades: [],
|
||||||
{ id: '3', ticker: 'PLTR', insiderName: 'Karp Alexander', relation: 'CEO', type: 'BUY', shares: 150000, value: 3300000, date: '2026-06-03' },
|
|
||||||
{ id: '4', ticker: 'PLTR', insiderName: 'Thiel Peter', relation: 'Director', type: 'BUY', shares: 100000, value: 2200000, date: '2026-06-02' },
|
|
||||||
{ id: '5', ticker: 'PLTR', insiderName: 'Cohen Stephen', relation: 'President', type: 'BUY', shares: 80000, value: 1760000, date: '2026-06-01' },
|
|
||||||
{ id: '6', ticker: 'RACE', insiderName: 'Vigna Benedetto', relation: 'CEO', type: 'BUY', shares: 8000, value: 3040000, date: '2026-06-04' },
|
|
||||||
{ id: '7', ticker: 'RACE', insiderName: 'Elkann John', relation: 'Director', type: 'BUY', shares: 12000, value: 4560000, date: '2026-06-03' },
|
|
||||||
{ id: '8', ticker: 'RACE', insiderName: 'Ferrari Piero', relation: 'Vice Chairman', type: 'BUY', shares: 10000, value: 3800000, date: '2026-06-02' }
|
|
||||||
],
|
|
||||||
congressTrades: [
|
|
||||||
{ id: 'c1', ticker: 'MSFT', representative: 'Nancy Pelosi', chamber: 'HOUSE', type: 'BUY', valueRange: '$1,000,001 - $5,000,000', transactionDate: '2026-04-20', filingDate: '2026-06-01', lagDays: 42 },
|
|
||||||
{ id: 'c2', ticker: 'NVDA', representative: 'Tommy Tuberville', chamber: 'SENATE', type: 'BUY', valueRange: '$100,001 - $250,000', transactionDate: '2026-04-25', filingDate: '2026-06-03', lagDays: 39 },
|
|
||||||
{ id: 'c3', ticker: 'AAPL', representative: 'Nancy Pelosi', chamber: 'HOUSE', type: 'SELL', valueRange: '$500,001 - $1,000,000', transactionDate: '2026-04-15', filingDate: '2026-05-28', lagDays: 43 }
|
|
||||||
],
|
|
||||||
whaleTrades: [
|
|
||||||
{ id: 'w1', ticker: 'AAPL', institution: 'Berkshire Hathaway', type: 'SELL', sharesTraded: 10000000, sharesHeld: 789000000, filingDate: '2026-05-15', estimatedValue: 1820000000 },
|
|
||||||
{ id: 'w2', ticker: 'PLTR', institution: 'Renaissance Technologies', type: 'BUY', sharesTraded: 5400000, sharesHeld: 12500000, filingDate: '2026-05-15', estimatedValue: 118800000 },
|
|
||||||
{ id: 'w3', ticker: 'NVDA', institution: 'BlackRock Inc.', type: 'BUY', sharesTraded: 15400000, sharesHeld: 182400000, filingDate: '2026-05-15', estimatedValue: 14553000000 }
|
|
||||||
],
|
|
||||||
insiderVolumes: {
|
insiderVolumes: {
|
||||||
'PLTR': [30000, 25000, 45000, 18000, 22000, 31000, 27000, 36000, 29000, 40000, 33000, 150000], // 12-month rolling (scaled down representation for monthly)
|
'PLTR': [30000, 25000, 45000, 18000, 22000, 31000, 27000, 36000, 29000, 40000, 33000, 150000], // 12-month rolling (scaled down representation for monthly)
|
||||||
'RACE': [8000, 6000, 7500, 9000, 5200, 7100, 6800, 9500, 8100, 10200, 9300, 30000],
|
'RACE': [8000, 6000, 7500, 9000, 5200, 7100, 6800, 9500, 8100, 10200, 9300, 30000],
|
||||||
@@ -375,19 +399,26 @@ export const useSandboxStore = create<SandboxState>((set, get) => ({
|
|||||||
{ id: 'ev2', name: 'US Wahlen (Präsidentschaft)', date: '2026-11-03', scores: { Apple: 2, NASDAQ: 1, Gold: 3, Bitcoin: 2 } },
|
{ id: 'ev2', name: 'US Wahlen (Präsidentschaft)', date: '2026-11-03', scores: { Apple: 2, NASDAQ: 1, Gold: 3, Bitcoin: 2 } },
|
||||||
{ id: 'ev3', name: 'SpaceX IPO (Gerüchte)', date: '2026-06-25', scores: { Apple: 0, NASDAQ: 2, Gold: -1, Bitcoin: 1 } },
|
{ id: 'ev3', name: 'SpaceX IPO (Gerüchte)', date: '2026-06-25', scores: { Apple: 0, NASDAQ: 2, Gold: -1, Bitcoin: 1 } },
|
||||||
],
|
],
|
||||||
|
assetsList: [
|
||||||
|
{ name: 'Apple', symbol: 'AAPL' },
|
||||||
|
{ name: 'NASDAQ', symbol: '^IXIC' },
|
||||||
|
{ name: 'Gold', symbol: 'GLD' },
|
||||||
|
{ name: 'Bitcoin', symbol: 'BTC-USD' }
|
||||||
|
],
|
||||||
calendarProposals: [
|
calendarProposals: [
|
||||||
{ id: 'cp1', name: 'CPI Inflationsdaten', date: '2026-06-12', archetype: 'Macro Announcement', defaultScores: { Apple: 1, NASDAQ: 2, Gold: -2, Bitcoin: 1 } },
|
{ id: 'cp1', name: 'CPI Inflationsdaten', date: '2026-06-12', archetype: 'Macro Announcement', defaultScores: { Apple: 1, NASDAQ: 2, Gold: -2, Bitcoin: 1 } },
|
||||||
{ id: 'cp2', name: 'US Non-Farm Payrolls', date: '2026-06-15', archetype: 'Employment Report', defaultScores: { Apple: 0, NASDAQ: 1, Gold: -1, Bitcoin: 0 } },
|
{ id: 'cp2', name: 'US Non-Farm Payrolls', date: '2026-06-15', archetype: 'Employment Report', defaultScores: { Apple: 0, NASDAQ: 1, Gold: -1, Bitcoin: 0 } },
|
||||||
{ id: 'cp3', name: 'EZB Pressekonferenz', date: '2026-06-18', archetype: 'Central Bank Policy', defaultScores: { Apple: -1, NASDAQ: -1, Gold: 2, Bitcoin: 1 } },
|
{ id: 'cp3', name: 'EZB Pressekonferenz', date: '2026-06-18', archetype: 'Central Bank Policy', defaultScores: { Apple: -1, NASDAQ: -1, Gold: 2, Bitcoin: 1 } },
|
||||||
],
|
],
|
||||||
lmmObservations: [
|
lmmObservations: [
|
||||||
{ asset: 'Apple', eventType: 'BULLISH', vix: 14.2, trend: 0.02, returnVal: 0.018 },
|
{ asset: 'Apple', eventType: 'BULLISH', eventName: 'Fed-Zinsentscheid (FOMC)', score: 1, vix: 14.2, trend: 0.02, returnVal: 0.018 },
|
||||||
{ asset: 'NASDAQ', eventType: 'BULLISH', vix: 15.5, trend: 0.015, returnVal: 0.022 },
|
{ asset: 'NASDAQ', eventType: 'BULLISH', eventName: 'Fed-Zinsentscheid (FOMC)', score: 2, vix: 15.5, trend: 0.015, returnVal: 0.022 },
|
||||||
{ asset: 'Gold', eventType: 'BEARISH', vix: 22.1, trend: -0.01, returnVal: -0.005 },
|
{ asset: 'Gold', eventType: 'BEARISH', eventName: 'Fed-Zinsentscheid (FOMC)', score: -1, vix: 22.1, trend: -0.01, returnVal: -0.005 },
|
||||||
{ asset: 'Bitcoin', eventType: 'BULLISH', vix: 18.4, trend: 0.03, returnVal: 0.035 },
|
{ asset: 'Bitcoin', eventType: 'BULLISH', eventName: 'Fed-Zinsentscheid (FOMC)', score: 2, vix: 18.4, trend: 0.03, returnVal: 0.035 },
|
||||||
{ asset: 'Apple', eventType: 'BEARISH', vix: 16.8, trend: -0.005, returnVal: -0.012 },
|
{ asset: 'Apple', eventType: 'BEARISH', eventName: 'US-Inflationsdaten (CPI)', score: -1, vix: 16.8, trend: -0.005, returnVal: -0.012 },
|
||||||
{ asset: 'NASDAQ', eventType: 'BEARISH', vix: 20.2, trend: -0.01, returnVal: -0.018 },
|
{ asset: 'NASDAQ', eventType: 'BEARISH', eventName: 'US-Inflationsdaten (CPI)', score: -2, vix: 20.2, trend: -0.01, returnVal: -0.018 },
|
||||||
],
|
],
|
||||||
|
lmmResults: undefined,
|
||||||
|
|
||||||
// --- Actions ---
|
// --- Actions ---
|
||||||
createPortfolio: (name, startingBalance) => set((state) => {
|
createPortfolio: (name, startingBalance) => set((state) => {
|
||||||
@@ -523,6 +554,25 @@ export const useSandboxStore = create<SandboxState>((set, get) => ({
|
|||||||
|
|
||||||
updateScannerAlerts: (scannerAlerts) => set({ scannerAlerts }),
|
updateScannerAlerts: (scannerAlerts) => set({ scannerAlerts }),
|
||||||
|
|
||||||
|
updatePortfolioAsset: (ticker, shares, entryPrice) => set((state) => {
|
||||||
|
const existingIndex = state.portfolio.findIndex(p => p.ticker === ticker);
|
||||||
|
let newPortfolio = [...state.portfolio];
|
||||||
|
if (existingIndex !== -1) {
|
||||||
|
if (shares <= 0) {
|
||||||
|
newPortfolio.splice(existingIndex, 1);
|
||||||
|
} else {
|
||||||
|
newPortfolio[existingIndex] = { ticker, shares, entryPrice };
|
||||||
|
}
|
||||||
|
} else if (shares > 0) {
|
||||||
|
newPortfolio.push({ ticker, shares, entryPrice });
|
||||||
|
}
|
||||||
|
return { portfolio: newPortfolio };
|
||||||
|
}),
|
||||||
|
|
||||||
|
removePortfolioAsset: (ticker) => set((state) => ({
|
||||||
|
portfolio: state.portfolio.filter(p => p.ticker !== ticker)
|
||||||
|
})),
|
||||||
|
|
||||||
addToWatchlist: (item) => set((state) => {
|
addToWatchlist: (item) => set((state) => {
|
||||||
const newItem: WatchlistItem = {
|
const newItem: WatchlistItem = {
|
||||||
...item,
|
...item,
|
||||||
|
|||||||
Reference in New Issue
Block a user