1362 lines
68 KiB
TypeScript
1362 lines
68 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useMemo } from 'react';
|
|
import { useSandboxStore, PortfolioHolding, Transaction } from '@/lib/store';
|
|
import { calculateEWMA, calculateKellyFraction, calculateAssetCovariance } from '@/lib/math/statistics';
|
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, ReferenceLine, AreaChart, Area } from 'recharts';
|
|
import 'katex/dist/katex.min.css';
|
|
import { BlockMath, InlineMath } from 'react-katex';
|
|
import PortfolioMathModal from './PortfolioMathModal';
|
|
import {
|
|
TrendingUp, Wallet, ArrowDownRight, ArrowUpRight, Percent, Plus, FolderSync,
|
|
HelpCircle, Settings, Calendar, DollarSign, Tag, Check, AlertCircle, ChevronDown, ChevronUp, Sparkles,
|
|
BookOpen, Trash2
|
|
} from 'lucide-react';
|
|
|
|
export default function SandboxDemo() {
|
|
const {
|
|
portfolios,
|
|
activePortfolioId,
|
|
ewmaLambda,
|
|
createPortfolio,
|
|
setActivePortfolio,
|
|
executeTransaction,
|
|
setEwmaLambda,
|
|
scannerAlerts,
|
|
posteriorProbability,
|
|
portfolio,
|
|
watchlist,
|
|
updatePortfolioAsset,
|
|
removePortfolioAsset
|
|
} = useSandboxStore();
|
|
|
|
// Selected portfolio
|
|
const activePortfolio = useMemo(() => {
|
|
return portfolios.find(p => p.id === activePortfolioId) || portfolios[0];
|
|
}, [portfolios, activePortfolioId]);
|
|
|
|
const [mounted, setMounted] = useState(false);
|
|
React.useEffect(() => {
|
|
setMounted(true);
|
|
}, []);
|
|
|
|
// UI state
|
|
const [showNewPortfolioModal, setShowNewPortfolioModal] = useState(false);
|
|
const [newPortfolioName, setNewPortfolioName] = useState('');
|
|
const [newStartingBalance, setNewStartingBalance] = useState(50000);
|
|
|
|
const [tradeSymbol, setTradeSymbol] = useState('AAPL');
|
|
const [tradeWknOrIsin, setTradeWknOrIsin] = useState('865985');
|
|
const [tradeShares, setTradeShares] = useState(10);
|
|
const [tradePrice, setTradePrice] = useState(182);
|
|
const [tradeType, setTradeType] = useState<'BUY' | 'SELL'>('BUY');
|
|
const [simulateFees, setSimulateFees] = useState(true);
|
|
const [isBackfill, setIsBackfill] = useState(false);
|
|
const [backfillDate, setBackfillDate] = useState('2026-05-20');
|
|
const [hypothesisTag, setHypothesisTag] = useState('Fokus auf KI-Infrastruktur');
|
|
const [orderError, setOrderError] = useState<string | null>(null);
|
|
const [orderSuccess, setOrderSuccess] = useState(false);
|
|
|
|
const [showMathAccordion, setShowMathAccordion] = useState(false);
|
|
const [isMathModalOpen, setIsMathModalOpen] = useState(false);
|
|
const [showMsciBenchmark, setShowMsciBenchmark] = useState(true);
|
|
|
|
// Kelly Position Sizing states
|
|
const [kellySource, setKellySource] = useState<'scanner' | 'crypto' | 'econometric' | 'custom'>('custom');
|
|
const [customProb, setCustomProb] = useState<number>(0.60);
|
|
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
|
|
const netWorth = useMemo(() => {
|
|
const assetsVal = activePortfolio.holdings.reduce((sum, h) => sum + h.shares * h.currentPrice, 0);
|
|
return Math.round((activePortfolio.cash + assetsVal) * 100) / 100;
|
|
}, [activePortfolio]);
|
|
|
|
// Dynamic winning probability (p) based on selected source
|
|
const kellyProbability = useMemo(() => {
|
|
if (kellySource === 'scanner') {
|
|
const alert = scannerAlerts.find(a => a.ticker.toUpperCase() === tradeSymbol.toUpperCase());
|
|
return alert ? alert.overreactionScore / 100 : 0.52;
|
|
}
|
|
if (kellySource === 'crypto') {
|
|
return posteriorProbability; // e.g. 0.72
|
|
}
|
|
if (kellySource === 'econometric') {
|
|
return 0.65; // ROC target probability
|
|
}
|
|
return customProb;
|
|
}, [kellySource, customProb, tradeSymbol, scannerAlerts, posteriorProbability]);
|
|
|
|
// Check potential cluster risk for the input symbol
|
|
const potentialClusterRisk = useMemo(() => {
|
|
if (!tradeSymbol) return false;
|
|
const holdingsWithWeights = activePortfolio.holdings.map(h => ({
|
|
symbol: h.symbol,
|
|
weight: (h.shares * h.currentPrice) / (netWorth || 1.0)
|
|
}));
|
|
const covResult = calculateAssetCovariance(holdingsWithWeights, tradeSymbol);
|
|
return covResult.clusterRisk;
|
|
}, [activePortfolio.holdings, tradeSymbol, netWorth]);
|
|
|
|
// Compute Kelly fraction and recommended cash amount
|
|
const kellyFraction = useMemo(() => {
|
|
const rawKelly = calculateKellyFraction(kellyProbability, oddsRatio);
|
|
// Cap at Half-Kelly already done in calculateKellyFraction, but we can scale by 50% if there is cluster risk
|
|
return potentialClusterRisk ? rawKelly * 0.5 : rawKelly;
|
|
}, [kellyProbability, oddsRatio, potentialClusterRisk]);
|
|
|
|
const recommendedKellyCash = useMemo(() => {
|
|
return activePortfolio.cash * kellyFraction;
|
|
}, [activePortfolio.cash, kellyFraction]);
|
|
|
|
// Compute returns based on active portfolio's historical value series
|
|
const portfolioReturns = useMemo(() => {
|
|
const vals = activePortfolio.historicalValues;
|
|
if (vals.length < 2) return [];
|
|
const r: number[] = [];
|
|
for (let i = 1; i < vals.length; i++) {
|
|
r.push((vals[i].value - vals[i - 1].value) / vals[i - 1].value);
|
|
}
|
|
return r;
|
|
}, [activePortfolio.historicalValues]);
|
|
|
|
// Calculate EWMA Volatility live
|
|
const ewmaResult = useMemo(() => {
|
|
return calculateEWMA(portfolioReturns, ewmaLambda);
|
|
}, [portfolioReturns, ewmaLambda]);
|
|
|
|
// Combine data for charting
|
|
const chartData = useMemo(() => {
|
|
const vals = activePortfolio.historicalValues;
|
|
if (vals.length === 0) return [];
|
|
|
|
// Normalize MSCI World index from the same starting value of the portfolio
|
|
const baseValue = vals[0].value;
|
|
let msciVal = baseValue;
|
|
|
|
return vals.map((hv, idx) => {
|
|
// Deterministic pseudo-random walk for MSCI World
|
|
if (idx > 0) {
|
|
const rand = Math.sin(idx * 57.8) * 0.45 + 0.05; // range: -0.4% to +0.5% return
|
|
msciVal = msciVal * (1 + rand * 0.015);
|
|
}
|
|
|
|
const vol = ewmaResult.series[idx - 1] || 0;
|
|
|
|
return {
|
|
date: hv.date,
|
|
Portfolio: hv.value,
|
|
'MSCI World (Benchmark)': Math.round(msciVal),
|
|
'EWMA Vol (%)': parseFloat(vol.toFixed(2)),
|
|
};
|
|
});
|
|
}, [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
|
|
const totalGainLoss = netWorth - activePortfolio.startingBalance;
|
|
const totalGainLossPct = (totalGainLoss / activePortfolio.startingBalance) * 100;
|
|
const isPositiveOverall = totalGainLoss >= 0;
|
|
|
|
const handleCreatePortfolio = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!newPortfolioName.trim()) return;
|
|
createPortfolio(newPortfolioName, newStartingBalance);
|
|
setNewPortfolioName('');
|
|
setShowNewPortfolioModal(false);
|
|
};
|
|
|
|
const handleTransactionSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setOrderError(null);
|
|
setOrderSuccess(false);
|
|
|
|
if (tradeShares <= 0 || tradePrice <= 0) {
|
|
setOrderError('Bitte geben Sie eine gültige Stückzahl und einen Kurs an.');
|
|
return;
|
|
}
|
|
|
|
const ok = executeTransaction(
|
|
activePortfolio.id,
|
|
tradeSymbol,
|
|
tradeWknOrIsin,
|
|
tradeType,
|
|
tradeShares,
|
|
tradePrice,
|
|
simulateFees,
|
|
isBackfill,
|
|
backfillDate,
|
|
hypothesisTag
|
|
);
|
|
|
|
if (ok) {
|
|
setOrderSuccess(true);
|
|
setTimeout(() => setOrderSuccess(false), 3000);
|
|
} else {
|
|
setOrderError(
|
|
tradeType === 'BUY'
|
|
? 'Unzureichendes Barguthaben (inklusive allfälliger Transaktionsgebühren).'
|
|
: 'Unzureichende Anteile im Depot für den Verkauf.'
|
|
);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
|
|
{activePortfolio.riskProfile?.status === 'RED' && (
|
|
<div className="bg-rose-950/40 border border-rose-800/80 text-rose-400 text-xs rounded-xl p-4 flex items-center gap-3 shadow-[0_0_15px_rgba(244,63,94,0.15)] animate-pulse">
|
|
<AlertCircle className="w-5 h-5 text-rose-400 shrink-0" />
|
|
<div className="flex-1">
|
|
<span className="font-bold">Kritische Klumpenrisiken (Kovarianz RED):</span> {activePortfolio.riskProfile.message}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* SECTION 1: Portfolio Selector & Stats Bar */}
|
|
<div className="bg-slate-900/60 backdrop-blur-md border border-slate-800 rounded-2xl p-6 text-slate-100 shadow-xl relative overflow-hidden">
|
|
<div className="absolute top-0 right-0 w-32 h-32 bg-emerald-500/10 rounded-full blur-3xl -z-10" />
|
|
|
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-6">
|
|
<div className="space-y-1">
|
|
<span className="text-emerald-400 text-xs font-semibold uppercase tracking-wider">Strategic Sandbox</span>
|
|
<div className="flex items-center gap-3">
|
|
<FolderSync className="text-emerald-400 w-6 h-6" />
|
|
<select
|
|
value={activePortfolioId}
|
|
onChange={(e) => setActivePortfolio(e.target.value)}
|
|
className="bg-slate-950 border border-slate-800 rounded-xl px-4 py-2 text-slate-100 font-sans font-semibold text-lg focus:outline-none focus:border-emerald-500 cursor-pointer"
|
|
>
|
|
{portfolios.map((p) => (
|
|
<option key={p.id} value={p.id}>
|
|
{p.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
onClick={() => setShowNewPortfolioModal(true)}
|
|
className="p-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-emerald-400 hover:text-emerald-300 transition-colors border border-slate-700"
|
|
title="Neues Sandbox-Portfolio erstellen"
|
|
>
|
|
<Plus className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<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 */}
|
|
<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="font-mono text-xl font-bold text-slate-100 mt-1">
|
|
${netWorth.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Performance 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="text-[10px] text-slate-400 uppercase font-semibold">GuV (Gesamt)</div>
|
|
<div className={`font-mono text-xl font-bold mt-1 flex items-center gap-1 ${isPositiveOverall ? 'text-emerald-400' : 'text-rose-400'}`}>
|
|
{isPositiveOverall ? <ArrowUpRight className="w-5 h-5" /> : <ArrowDownRight className="w-5 h-5" />}
|
|
<span>{isPositiveOverall ? '+' : ''}{totalGainLossPct.toFixed(2)}%</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Live EWMA Vol 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] relative group">
|
|
<div className="text-[10px] text-slate-400 uppercase font-semibold flex items-center gap-1">
|
|
<span>EWMA Volatilität</span>
|
|
<span className="cursor-help flex items-center" title="Annualisierte Schwankungsbreite basierend auf historischen Renditen.">
|
|
<HelpCircle className="w-3.5 h-3.5 text-slate-500 group-hover:text-emerald-400 transition-colors" />
|
|
</span>
|
|
</div>
|
|
<div className="font-mono text-xl font-bold text-teal-400 mt-1">
|
|
{ewmaResult.latest.toFixed(2)}%
|
|
</div>
|
|
</div>
|
|
|
|
{/* Covariance Risk Traffic Light Card */}
|
|
<div className="flex-1 md:flex-initial bg-slate-950/80 border border-slate-800 rounded-xl p-3 px-5 min-w-[150px] relative group">
|
|
<div className="text-[10px] text-slate-400 uppercase font-semibold flex items-center gap-1">
|
|
<span>Kovarianz-Ampel</span>
|
|
<span className="cursor-help flex items-center" title="Systemische Portfolio-Klumpenrisiken basierend auf historischen Asset-Kovarianzen.">
|
|
<HelpCircle className="w-3.5 h-3.5 text-slate-500 group-hover:text-rose-400 transition-colors" />
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 mt-1.5">
|
|
<span className={`w-3.5 h-3.5 rounded-full ${
|
|
activePortfolio.riskProfile?.status === 'RED' ? 'bg-rose-500 animate-pulse shadow-[0_0_8px_#f43f5e]' :
|
|
activePortfolio.riskProfile?.status === 'YELLOW' ? 'bg-amber-500 shadow-[0_0_8px_#f59e0b]' :
|
|
'bg-emerald-500 shadow-[0_0_8px_#10b981]'
|
|
}`} />
|
|
<span className={`font-mono text-sm font-bold ${
|
|
activePortfolio.riskProfile?.status === 'RED' ? 'text-rose-400' :
|
|
activePortfolio.riskProfile?.status === 'YELLOW' ? 'text-amber-400' :
|
|
'text-emerald-400'
|
|
}`}>
|
|
{activePortfolio.riskProfile?.status || 'GREEN'} RISK
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Modal for creating a new Portfolio */}
|
|
{showNewPortfolioModal && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
|
<div className="bg-slate-900 border border-slate-800 rounded-2xl p-6 max-w-sm w-full space-y-4 shadow-2xl">
|
|
<h3 className="text-lg font-bold text-white">Neues Sandbox-Portfolio</h3>
|
|
<form onSubmit={handleCreatePortfolio} className="space-y-4">
|
|
<div>
|
|
<label className="text-xs text-slate-400 block mb-1">Portfolio Name</label>
|
|
<input
|
|
type="text"
|
|
required
|
|
placeholder="z.B. Biotech Risk High Yield"
|
|
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-slate-100 focus:outline-none focus:border-emerald-500"
|
|
value={newPortfolioName}
|
|
onChange={(e) => setNewPortfolioName(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-slate-400 block mb-1">Startkapital ($)</label>
|
|
<input
|
|
type="number"
|
|
required
|
|
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-slate-100 focus:outline-none focus:border-emerald-500 font-mono"
|
|
value={newStartingBalance}
|
|
onChange={(e) => setNewStartingBalance(Number(e.target.value))}
|
|
/>
|
|
</div>
|
|
<div className="flex gap-3 pt-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowNewPortfolioModal(false)}
|
|
className="flex-1 bg-slate-850 hover:bg-slate-800 text-slate-300 font-semibold py-2 rounded-lg transition-colors border border-slate-700"
|
|
>
|
|
Abbrechen
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
className="flex-1 bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-slate-950 font-bold py-2 rounded-lg transition-all shadow-lg shadow-emerald-500/20"
|
|
>
|
|
Erstellen
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Accordion / Math Button */}
|
|
<div className="border-t border-slate-850 pt-4 mt-4">
|
|
<button
|
|
onClick={() => setShowMathAccordion(!showMathAccordion)}
|
|
className="flex items-center gap-1.5 text-xs text-slate-400 hover:text-emerald-400 transition-colors focus:outline-none"
|
|
>
|
|
<span>{showMathAccordion ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}</span>
|
|
<span className="font-semibold uppercase tracking-wider">Mathematische Spezifikation & EWMA-Volatilitätsmodell</span>
|
|
</button>
|
|
|
|
{showMathAccordion && (
|
|
<div className="mt-4 p-4 rounded-xl border border-slate-850 bg-slate-950/40 text-xs text-slate-300 space-y-4">
|
|
<div>
|
|
<h4 className="font-semibold text-emerald-400 mb-1.5">1. EWMA Volatilitätsmodell</h4>
|
|
<p className="mb-2">
|
|
Die Volatilität wird mittels des <strong>Exponentially Weighted Moving Average (EWMA)</strong>-Modells ermittelt. Jüngere Renditen erhalten hierbei ein höheres Gewicht als weiter in der Vergangenheit liegende Renditen, gesteuert durch den Zerfallsparameter <InlineMath math="\lambda" /> (Lambda).
|
|
</p>
|
|
<div className="py-2 overflow-x-auto">
|
|
<BlockMath math="\sigma_t^2 = \lambda \sigma_{t-1}^2 + (1 - \lambda) r_{t-1}^2" />
|
|
</div>
|
|
<p className="mb-2">
|
|
Die tägliche Volatilität <InlineMath math="\sigma_t" /> wird auf ein ganzes Jahr hochgerechnet (Annualisierung) unter der Annahme von 252 Handelstagen:
|
|
</p>
|
|
<div className="py-2 overflow-x-auto">
|
|
<BlockMath math="\sigma_{\text{ann}} = \sqrt{\sigma_t^2 \times 252}" />
|
|
</div>
|
|
<p className="text-slate-400">
|
|
RiskMetrics empfiehlt für tägliche Finanzdaten einen Lambda-Wert von <InlineMath math="\lambda = 0.94" />.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="border-t border-slate-800 pt-3">
|
|
<h4 className="font-semibold text-emerald-400 mb-1.5">2. Kelly-Kriterium zur Positionsgrößenbestimmung</h4>
|
|
<p className="mb-2">
|
|
Die Kelly-Formel bestimmt den optimalen Anteil des Kapitals (<InlineMath math="f^*" />), der in ein Geschäft investiert werden soll, um das exponentielle Wachstum des Kapitals zu maximieren:
|
|
</p>
|
|
<div className="py-2 overflow-x-auto">
|
|
<BlockMath math="f^* = \frac{p \cdot b - q}{b} = \frac{p \cdot b - (1 - p)}{b}" />
|
|
</div>
|
|
<p className="mb-2">
|
|
Um Risiken durch ungenaue Schätzungen zu verringern, wenden wir das konservative <strong>Half-Kelly</strong>-Sizing an und begrenzen das Ergebnis auf <InlineMath math="0.5 \times f^*" /> (zusätzlich begrenzt auf <InlineMath math="\ge 0" />).
|
|
</p>
|
|
</div>
|
|
|
|
<div className="border-t border-slate-800 pt-3">
|
|
<h4 className="font-semibold text-rose-400 mb-1.5">3. Covariance & Cluster Risk (Kovarianz-Ampel)</h4>
|
|
<p className="mb-2">
|
|
Die Kovarianz zwischen Assets wird durch Multiplikation ihrer paarweisen Korrelation mit ihren jeweiligen Standardabweichungen (Volatilitäten) bestimmt:
|
|
</p>
|
|
<div className="py-2 overflow-x-auto">
|
|
<BlockMath math="\text{Cov}(A, B) = \text{Corr}(A, B) \times \sigma_A \times \sigma_B" />
|
|
</div>
|
|
<p className="text-slate-400">
|
|
Ein <strong>Klumpenrisiko (Risk RED)</strong> wird ausgelöst, wenn ein Asset eine Korrelation <InlineMath math="\text{Corr}(A, B) > 0.70" /> zu bestehenden Positionen aufweist und diese Positionen jeweils mehr als 15% des Portfolios ausmachen.
|
|
</p>
|
|
</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 */}
|
|
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
|
|
|
|
{/* Left 2 Columns: Analytics Performance Plot */}
|
|
<div className="xl:col-span-2 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 justify-between items-center border-b border-slate-800 pb-3">
|
|
<h3 className="text-lg font-bold text-white flex items-center gap-2">
|
|
<TrendingUp className="text-emerald-400 w-5 h-5" /> Portfolio Wertentwicklung & Benchmark
|
|
</h3>
|
|
<div className="flex items-center gap-3">
|
|
<label className="flex items-center gap-2 text-xs text-slate-400 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={showMsciBenchmark}
|
|
onChange={(e) => setShowMsciBenchmark(e.target.checked)}
|
|
className="rounded border-slate-800 text-emerald-500 focus:ring-0 accent-emerald-500 w-4 h-4"
|
|
/>
|
|
<span>MSCI World (Benchmark) anzeigen</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="h-80 w-full">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<LineChart data={chartData}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
|
<XAxis dataKey="date" stroke="#64748b" fontSize={10} />
|
|
<YAxis stroke="#64748b" fontSize={10} domain={['auto', 'auto']} tickFormatter={(v) => `$${v.toLocaleString()}`} />
|
|
<Tooltip contentStyle={{ backgroundColor: '#0f172a', borderColor: '#334155', borderRadius: '8px' }} />
|
|
<Legend verticalAlign="top" height={36} />
|
|
<Line type="monotone" dataKey="Portfolio" name={activePortfolio.name} stroke="#10b981" strokeWidth={3} dot={false} activeDot={{ r: 6 }} />
|
|
{showMsciBenchmark && (
|
|
<Line type="monotone" dataKey="MSCI World (Benchmark)" name="MSCI World Index (Normiert)" stroke="#3b82f6" strokeWidth={2} strokeDasharray="4 4" dot={false} />
|
|
)}
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
|
|
{/* EWMA parameter tuner slider */}
|
|
<div className="p-4 rounded-xl border border-slate-850 bg-slate-950/20 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
|
<div className="space-y-1">
|
|
<h4 className="text-sm font-semibold text-slate-200">Parameteranpassung EWMA Lambda (λ)</h4>
|
|
<p className="text-xs text-slate-400">Zerfallsfaktor steuert die Schock-Sensitivität des Volatilitätsmodells.</p>
|
|
</div>
|
|
<div className="flex items-center gap-4 w-full sm:w-auto">
|
|
<input
|
|
type="range"
|
|
min="0.80"
|
|
max="0.99"
|
|
step="0.01"
|
|
value={ewmaLambda}
|
|
onChange={(e) => setEwmaLambda(parseFloat(e.target.value))}
|
|
className="w-40 accent-emerald-400 cursor-pointer"
|
|
/>
|
|
<span className="font-mono text-emerald-400 text-sm font-bold bg-emerald-500/10 px-2 py-0.5 rounded border border-emerald-500/20">λ = {ewmaLambda.toFixed(2)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right 1 Column: Advanced Order Mask */}
|
|
<div className="bg-slate-900/60 backdrop-blur-md border border-slate-800 rounded-2xl p-6 text-slate-100 shadow-xl relative overflow-hidden flex flex-col justify-between">
|
|
<div className="space-y-6">
|
|
<div className="border-b border-slate-800 pb-3">
|
|
<h3 className="text-lg font-bold text-white flex items-center gap-2">
|
|
<Settings className="text-emerald-400 w-5 h-5" /> Order-Maske (Simuliert)
|
|
</h3>
|
|
</div>
|
|
|
|
<form onSubmit={handleTransactionSubmit} className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="text-xs text-slate-400 block mb-1">Ticker / Symbol</label>
|
|
<input
|
|
type="text"
|
|
required
|
|
placeholder="AAPL"
|
|
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-2 text-slate-100 font-mono focus:outline-none focus:border-emerald-500"
|
|
value={tradeSymbol}
|
|
onChange={(e) => setTradeSymbol(e.target.value.toUpperCase())}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-slate-400 block mb-1">WKN / ISIN</label>
|
|
<input
|
|
type="text"
|
|
placeholder="865985"
|
|
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-2 text-slate-100 font-mono focus:outline-none focus:border-emerald-500"
|
|
value={tradeWknOrIsin}
|
|
onChange={(e) => setTradeWknOrIsin(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-slate-400 block mb-1">Stücke</label>
|
|
<input
|
|
type="number"
|
|
required
|
|
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-2 text-slate-100 font-mono focus:outline-none focus:border-emerald-500"
|
|
value={tradeShares}
|
|
onChange={(e) => setTradeShares(Number(e.target.value))}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-slate-400 block mb-1">Kurs ($)</label>
|
|
<input
|
|
type="number"
|
|
required
|
|
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-2 text-slate-100 font-mono focus:outline-none focus:border-emerald-500"
|
|
value={tradePrice}
|
|
onChange={(e) => setTradePrice(Number(e.target.value))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Order direction buttons */}
|
|
<div className="flex gap-2 p-1 rounded-xl bg-slate-950 border border-slate-800">
|
|
<button
|
|
type="button"
|
|
onClick={() => setTradeType('BUY')}
|
|
className={`flex-1 py-1.5 text-xs font-bold rounded-lg transition-all ${tradeType === 'BUY' ? 'bg-emerald-500 text-slate-950 shadow-md shadow-emerald-500/10' : 'text-slate-400 hover:text-slate-200'}`}
|
|
>
|
|
Kauf (Long)
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setTradeType('SELL')}
|
|
className={`flex-1 py-1.5 text-xs font-bold rounded-lg transition-all ${tradeType === 'SELL' ? 'bg-rose-500 text-white shadow-md shadow-rose-500/10' : 'text-slate-400 hover:text-slate-200'}`}
|
|
>
|
|
Verkauf (Short)
|
|
</button>
|
|
</div>
|
|
|
|
{/* Kelly Sizing Risk Recommendation Widget */}
|
|
{tradeType === 'BUY' && (
|
|
<div className="bg-slate-950/60 border border-slate-850 rounded-xl p-3 text-[11px] space-y-2 relative overflow-hidden">
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-[10px] text-slate-400 uppercase font-semibold flex items-center gap-1">
|
|
<Sparkles className="w-3 h-3 text-emerald-400" /> Risk-Engine Sizing
|
|
</span>
|
|
<span className="font-bold text-emerald-400 text-[10px] uppercase tracking-wider">Kelly recommendation</span>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<label className="text-[9px] text-slate-500 block mb-0.5 font-semibold uppercase">Prob Source (p)</label>
|
|
<select
|
|
value={kellySource}
|
|
onChange={(e) => setKellySource(e.target.value as any)}
|
|
className="w-full bg-slate-900 border border-slate-800 rounded px-1.5 py-1 text-[10px] text-slate-200 focus:outline-none"
|
|
>
|
|
<option value="custom">Manueller Regler</option>
|
|
<option value="scanner">El. 2 Scanner-Score</option>
|
|
<option value="crypto">El. 4 Bayes Posterior</option>
|
|
<option value="econometric">El. 5 ROC Breakout</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-[9px] text-slate-500 block mb-0.5 font-semibold uppercase">Odds-Verhältnis (b)</label>
|
|
<input
|
|
type="number"
|
|
step="0.1"
|
|
min="0.1"
|
|
value={oddsRatio}
|
|
onChange={(e) => setOddsRatio(Number(e.target.value))}
|
|
className="w-full bg-slate-900 border border-slate-800 rounded px-1.5 py-1 text-[10px] text-slate-200 font-mono focus:outline-none"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{kellySource === 'custom' && (
|
|
<div className="space-y-1">
|
|
<div className="flex justify-between text-[9px] text-slate-500">
|
|
<span>Erfolgswahrscheinlichkeit:</span>
|
|
<span className="font-mono text-emerald-400 font-bold">{(kellyProbability * 100).toFixed(0)}%</span>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min="0.1"
|
|
max="0.99"
|
|
step="0.01"
|
|
value={customProb}
|
|
onChange={(e) => setCustomProb(Number(e.target.value))}
|
|
className="w-full accent-emerald-500 h-1 bg-slate-900 rounded"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{kellySource !== 'custom' && (
|
|
<div className="text-[9px] text-slate-400 flex justify-between bg-slate-900/40 p-1 px-1.5 rounded border border-slate-900">
|
|
<span>System-Wahrscheinlichkeit:</span>
|
|
<span className="font-mono text-emerald-400 font-bold">{(kellyProbability * 100).toFixed(1)}%</span>
|
|
</div>
|
|
)}
|
|
|
|
{potentialClusterRisk && (
|
|
<div className="p-2 bg-rose-500/10 text-rose-400 border border-rose-500/20 text-[9px] rounded flex items-start gap-1">
|
|
<AlertCircle className="w-3.5 h-3.5 shrink-0 text-rose-400" />
|
|
<div>
|
|
<strong>Klumpenrisiko!</strong> Korrelation > 0.70 zu bestehenden Positionen. Kelly-Empfehlung wurde um 50% halbiert.
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="bg-slate-900/80 p-2 rounded-lg border border-slate-850 flex justify-between items-center text-xs">
|
|
<div>
|
|
<span className="block text-[9px] text-slate-500 uppercase font-semibold">Kelly-Anteil:</span>
|
|
<span className="font-mono font-bold text-slate-200">{(kellyFraction * 100).toFixed(1)}% des Cashs</span>
|
|
</div>
|
|
<div className="text-right">
|
|
<span className="block text-[9px] text-slate-500 uppercase font-semibold">Kaufvolumen:</span>
|
|
<span className="font-mono font-bold text-emerald-400">${Math.round(recommendedKellyCash).toLocaleString()}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Hypothesis input */}
|
|
<div>
|
|
<label className="text-xs text-slate-400 block mb-1 flex items-center gap-1">
|
|
<Tag className="w-3 h-3 text-emerald-400" />
|
|
<span>Hypothese / What-if Notiz</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
placeholder="z.B. Ferrari E-Auto Skepsis"
|
|
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-2 text-slate-100 text-xs focus:outline-none focus:border-emerald-500"
|
|
value={hypothesisTag}
|
|
onChange={(e) => setHypothesisTag(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Fees Toggle */}
|
|
<div className="flex items-center justify-between border-t border-slate-850 pt-3 text-xs">
|
|
<span className="text-slate-400 flex items-center gap-1">
|
|
<DollarSign className="w-3.5 h-3.5 text-slate-500" /> Ordergebühren simulieren
|
|
</span>
|
|
<input
|
|
type="checkbox"
|
|
checked={simulateFees}
|
|
onChange={(e) => setSimulateFees(e.target.checked)}
|
|
className="rounded border-slate-800 text-emerald-500 focus:ring-0 accent-emerald-500 w-4 h-4 cursor-pointer"
|
|
/>
|
|
</div>
|
|
|
|
{/* Backfill Date Picker Toggle */}
|
|
<div className="space-y-2 border-t border-slate-850 pt-3 text-xs">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-slate-400 flex items-center gap-1">
|
|
<Calendar className="w-3.5 h-3.5 text-slate-500" /> Historischer Backfill
|
|
</span>
|
|
<input
|
|
type="checkbox"
|
|
checked={isBackfill}
|
|
onChange={(e) => setIsBackfill(e.target.checked)}
|
|
className="rounded border-slate-800 text-emerald-500 focus:ring-0 accent-emerald-500 w-4 h-4 cursor-pointer"
|
|
/>
|
|
</div>
|
|
|
|
{isBackfill && (
|
|
<input
|
|
type="date"
|
|
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-2 text-slate-100 text-xs focus:outline-none focus:border-emerald-500 font-mono"
|
|
value={backfillDate}
|
|
onChange={(e) => setBackfillDate(e.target.value)}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{orderError && (
|
|
<div className="p-3 rounded-lg bg-rose-500/10 text-rose-400 border border-rose-500/20 text-xs flex items-center gap-2">
|
|
<AlertCircle className="w-4 h-4 shrink-0" />
|
|
<span>{orderError}</span>
|
|
</div>
|
|
)}
|
|
|
|
{orderSuccess && (
|
|
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 text-xs flex items-center gap-2">
|
|
<Check className="w-4 h-4 shrink-0" />
|
|
<span>Transaktion erfolgreich gebucht!</span>
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
type="submit"
|
|
className={`w-full font-bold py-2.5 px-4 rounded-lg transition-all active:scale-[0.98] mt-2 shadow-lg ${tradeType === 'BUY' ? 'bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-slate-950 shadow-emerald-500/10' : 'bg-gradient-to-r from-rose-500 to-pink-500 hover:from-rose-600 hover:to-pink-600 text-white shadow-rose-500/10'}`}
|
|
>
|
|
Order an den Markt senden
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
{/* SECTION 3: Holdings Table & Transactions Log */}
|
|
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
|
|
|
|
{/* Left 2 Columns: Holdings List */}
|
|
<div className="xl:col-span-2 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">
|
|
<TrendingUp className="text-emerald-400 w-5 h-5" /> Depotbestände ({activePortfolio.holdings.length})
|
|
</h3>
|
|
<div className="text-xs text-slate-400 flex items-center gap-4">
|
|
<span>Barguthaben: <span className="font-mono text-emerald-400 font-bold">${activePortfolio.cash.toLocaleString()}</span></span>
|
|
</div>
|
|
</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">
|
|
<thead>
|
|
<tr className="border-b border-slate-800 text-slate-400 font-semibold bg-slate-900/40">
|
|
<th className="p-3">Asset</th>
|
|
<th className="p-3">Stücke</th>
|
|
<th className="p-3">Einstand</th>
|
|
<th className="p-3">Kurs</th>
|
|
<th className="p-3">Hypothese</th>
|
|
<th className="p-3 text-right">GuV</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{activePortfolio.holdings.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={6} className="p-8 text-center text-slate-500">
|
|
Keine Bestände in diesem Sandbox-Portfolio. Nutzen Sie die Order-Maske, um Werte hinzuzufügen.
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
activePortfolio.holdings.map((hold) => {
|
|
const profitLoss = (hold.currentPrice - hold.avgPrice) * hold.shares;
|
|
const isPositive = profitLoss >= 0;
|
|
return (
|
|
<tr key={hold.symbol} className="border-b border-slate-850/50 hover:bg-slate-850/20 transition-colors">
|
|
<td className="p-3">
|
|
<div className="font-bold text-teal-400 font-mono">{hold.symbol}</div>
|
|
{hold.wknOrIsin && <div className="text-[10px] text-slate-500 font-mono">WKN: {hold.wknOrIsin}</div>}
|
|
</td>
|
|
<td className="p-3 font-mono font-medium">{hold.shares}</td>
|
|
<td className="p-3 font-mono text-slate-300">${hold.avgPrice.toFixed(2)}</td>
|
|
<td className="p-3 font-mono text-slate-300">${hold.currentPrice.toFixed(2)}</td>
|
|
<td className="p-3 text-slate-400 max-w-[200px] truncate text-xs" title={hold.hypothesisTag}>
|
|
{hold.hypothesisTag || '-'}
|
|
</td>
|
|
<td className={`p-3 font-mono text-right flex items-center justify-end gap-1 ${isPositive ? 'text-emerald-400' : 'text-rose-400'}`}>
|
|
{isPositive ? <ArrowUpRight className="w-4 h-4" /> : <ArrowDownRight className="w-4 h-4" />}
|
|
${Math.abs(profitLoss).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right 1 Column: Transactions History */}
|
|
<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="border-b border-slate-800 pb-3">
|
|
<h3 className="text-lg font-bold text-white flex items-center gap-2">
|
|
<Calendar className="text-emerald-400 w-5 h-5" /> Letzte Orderbuch-Einträge
|
|
</h3>
|
|
</div>
|
|
|
|
<div className="max-h-60 overflow-y-auto space-y-2 pr-1">
|
|
{activePortfolio.transactions.length === 0 ? (
|
|
<p className="text-xs text-slate-500 text-center py-8">Bislang keine Transaktionen in diesem Portfolio.</p>
|
|
) : (
|
|
activePortfolio.transactions.map((tx) => {
|
|
const isBuy = tx.type === 'BUY';
|
|
return (
|
|
<div key={tx.id} className="p-3 bg-slate-950/40 border border-slate-850 rounded-lg space-y-1.5">
|
|
<div className="flex justify-between items-start">
|
|
<div className="flex items-center gap-2">
|
|
<span className={`px-1.5 py-0.5 rounded text-[9px] font-bold ${isBuy ? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20' : 'bg-rose-500/10 text-rose-400 border border-rose-500/20'}`}>
|
|
{isBuy ? 'KAUF' : 'VERKAUF'}
|
|
</span>
|
|
<span className="font-mono font-bold text-slate-200">{tx.symbol}</span>
|
|
</div>
|
|
<span className="text-[10px] text-slate-500 font-mono">{tx.timestamp}</span>
|
|
</div>
|
|
|
|
<div className="flex justify-between text-xs font-mono text-slate-400">
|
|
<span>{tx.shares} Stk @ ${tx.price.toFixed(2)}</span>
|
|
<span className="text-[10px] text-slate-500">Gebühr: ${tx.feeApplied.toFixed(2)}</span>
|
|
</div>
|
|
|
|
{tx.hypothesisTag && (
|
|
<div className="text-[10px] text-slate-500 flex items-center gap-1 border-t border-slate-900 pt-1">
|
|
<Tag className="w-2.5 h-2.5 text-teal-500" />
|
|
<span className="italic truncate max-w-[220px]">{tx.hypothesisTag}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<PortfolioMathModal isOpen={isMathModalOpen} onClose={() => setIsMathModalOpen(false)} />
|
|
</div>
|
|
);
|
|
}
|