Spaces:
Running
Running
| import { useState } from 'react'; | |
| import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts'; | |
| import { portfolioAPI, riskAPI, holdingsAPI } from '../api/client'; | |
| const COLORS = ['#005241', '#0a8f5c', '#b8860b', '#1a5276', '#6b21a8', '#c23030', '#0d7377', '#7c3aed', '#2563eb', '#059669']; | |
| export default function PortfolioAnalysis() { | |
| const [tickers, setTickers] = useState('AAPL, MSFT, GOOGL, AMZN, NVDA, JPM, JNJ, V'); | |
| const [method, setMethod] = useState('mean_variance'); | |
| const [weights, setWeights] = useState<any>(null); | |
| const [riskData, setRiskData] = useState<any>(null); | |
| const [loading, setLoading] = useState(false); | |
| const [activeTab, setActiveTab] = useState('optimize'); | |
| const [corrData, setCorrData] = useState<any>(null); | |
| const [corrLoading, setCorrLoading] = useState(false); | |
| const [holdingsLoading, setHoldingsLoading] = useState(false); | |
| const optimize = async () => { | |
| setLoading(true); | |
| const tickerList = tickers.split(',').map(t => t.trim()).filter(Boolean); | |
| try { | |
| const [optRes, riskRes] = await Promise.all([ | |
| portfolioAPI.optimize({ tickers: tickerList, method }), | |
| riskAPI.analyze({ tickers: tickerList }), | |
| ]); | |
| setWeights(optRes.data); | |
| setRiskData(riskRes.data); | |
| } catch (e) { console.error(e); } finally { setLoading(false); } | |
| }; | |
| const useMyHoldings = async () => { | |
| setHoldingsLoading(true); | |
| try { | |
| const res = await holdingsAPI.summary(); | |
| const holdings = res.data.holdings || []; | |
| if (holdings.length === 0) { | |
| alert('No holdings found. Add positions in the Holdings section first.'); | |
| return; | |
| } | |
| const holdingTickers = holdings.map((h: any) => h.ticker).join(', '); | |
| setTickers(holdingTickers); | |
| } catch (e) { | |
| console.error(e); | |
| alert('Failed to fetch holdings. Please try again.'); | |
| } finally { | |
| setHoldingsLoading(false); | |
| } | |
| }; | |
| const pieData = weights?.weights ? Object.entries(weights.weights).filter(([,v]: any) => v > 0.001).map(([ticker, weight]: any) => ({ | |
| name: ticker, value: parseFloat((weight * 100).toFixed(1)), | |
| })) : []; | |
| return ( | |
| <div className="page animate-fade-in"> | |
| <div className="page-header"> | |
| <h1>Portfolio <span className="text-gradient">Analysis</span></h1> | |
| <p>Portfolio optimization and risk decomposition</p> | |
| </div> | |
| {/* Input */} | |
| <div className="card" style={{marginBottom:'1.5rem'}}> | |
| <div style={{display:'flex',gap:'0.75rem',alignItems:'flex-end',flexWrap:'wrap'}}> | |
| <div style={{flex:1,minWidth:300}}> | |
| <div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:'0.375rem'}}> | |
| <label style={{fontSize:'0.75rem',fontWeight:600,color:'var(--text-muted)',textTransform:'uppercase',letterSpacing:'0.05em'}}>Portfolio Assets</label> | |
| <button | |
| onClick={useMyHoldings} | |
| disabled={holdingsLoading} | |
| style={{ | |
| background:'none',border:'1px solid var(--border-color)',borderRadius:'var(--radius-sm)', | |
| padding:'0.2rem 0.6rem',fontSize:'0.7rem',fontWeight:600,color:'var(--accent)', | |
| cursor:'pointer',display:'flex',alignItems:'center',gap:'0.3rem', | |
| transition:'all 150ms ease',fontFamily:'var(--font-sans)', | |
| }} | |
| onMouseEnter={e => { e.currentTarget.style.background = 'var(--accent-lighter)'; e.currentTarget.style.borderColor = 'var(--accent)'; }} | |
| onMouseLeave={e => { e.currentTarget.style.background = 'none'; e.currentTarget.style.borderColor = 'var(--border-color)'; }} | |
| > | |
| {holdingsLoading ? <div className="spinner" style={{width:10,height:10,borderWidth:1.5}} /> : ( | |
| <svg width="12" height="12" viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M4 4a2 2 0 00-2 2v4a2 2 0 002 2V6h10a2 2 0 00-2-2H4zm2 6a2 2 0 012-2h8a2 2 0 012 2v4a2 2 0 01-2 2H8a2 2 0 01-2-2v-4zm6 4a2 2 0 100-4 2 2 0 000 4z" clipRule="evenodd"/></svg> | |
| )} | |
| Use My Holdings | |
| </button> | |
| </div> | |
| <input className="input" value={tickers} onChange={e => setTickers(e.target.value)} /> | |
| </div> | |
| <div> | |
| <label style={{fontSize:'0.75rem',fontWeight:600,color:'var(--text-muted)',textTransform:'uppercase',letterSpacing:'0.05em',display:'block',marginBottom:'0.375rem'}}>Method</label> | |
| <select className="input" value={method} onChange={e => setMethod(e.target.value)} style={{width:180}}> | |
| <option value="mean_variance">Mean-Variance (Max Sharpe)</option> | |
| <option value="min_variance">Minimum Variance</option> | |
| <option value="risk_parity">Risk Parity</option> | |
| <option value="equal_weight">Equal Weight</option> | |
| </select> | |
| </div> | |
| <button className="btn btn-primary" onClick={optimize} disabled={loading}> | |
| {loading ? <div className="spinner" /> : 'Optimize'} | |
| </button> | |
| </div> | |
| </div> | |
| <div className="tabs"> | |
| <button className={`tab ${activeTab === 'optimize' ? 'active' : ''}`} onClick={() => setActiveTab('optimize')}>Allocation</button> | |
| <button className={`tab ${activeTab === 'risk' ? 'active' : ''}`} onClick={() => setActiveTab('risk')}>Risk Metrics</button> | |
| <button className={`tab ${activeTab === 'correlation' ? 'active' : ''}`} onClick={() => { setActiveTab('correlation'); if (!corrData && !corrLoading) { setCorrLoading(true); holdingsAPI.correlation().then(({data}) => setCorrData(data)).catch(console.error).finally(() => setCorrLoading(false)); } }}>Correlation</button> | |
| </div> | |
| {activeTab === 'optimize' && weights && ( | |
| <div className="grid-2"> | |
| {/* Portfolio Metrics */} | |
| <div className="card"> | |
| <div className="card-header"><h3>Optimized Portfolio — {method.replace('_',' ')}</h3></div> | |
| <div className="grid-3" style={{marginBottom:'1.5rem'}}> | |
| {[ | |
| { label: 'Exp. Return', value: weights.expected_return != null ? (weights.expected_return * 100).toFixed(2) + '%' : '—', cls: 'positive' }, | |
| { label: 'Exp. Volatility', value: weights.expected_volatility != null ? (weights.expected_volatility * 100).toFixed(2) + '%' : '—', cls: 'neutral' }, | |
| { label: 'Sharpe Ratio', value: weights.sharpe_ratio?.toFixed(2) || '—', cls: weights.sharpe_ratio > 0 ? 'positive' : 'negative' }, | |
| ].map((m, i) => ( | |
| <div key={i} className="metric"> | |
| <div className={`metric-value ${m.cls}`}>{m.value}</div> | |
| <div className="metric-label">{m.label}</div> | |
| </div> | |
| ))} | |
| </div> | |
| {/* Weights table */} | |
| <div className="table-container"> | |
| <table> | |
| <thead><tr><th>Asset</th><th>Weight</th><th>Allocation</th></tr></thead> | |
| <tbody> | |
| {pieData.map((item: any, i: number) => ( | |
| <tr key={i}> | |
| <td style={{fontWeight:600}}>{item.name}</td> | |
| <td>{item.value.toFixed(1)}%</td> | |
| <td> | |
| <div style={{background:'var(--bg-tertiary)',borderRadius:4,height:8,width:'100%',overflow:'hidden'}}> | |
| <div style={{height:'100%',width:`${item.value}%`,background:COLORS[i % COLORS.length],borderRadius:4,transition:'width 0.5s ease'}} /> | |
| </div> | |
| </td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| {/* Pie Chart */} | |
| <div className="card"> | |
| <div className="card-header"><h3>Allocation Chart</h3></div> | |
| <div style={{height:350}}> | |
| <ResponsiveContainer> | |
| <PieChart> | |
| <Pie data={pieData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={120} innerRadius={60} | |
| strokeWidth={2} stroke="#fff" label={({name, value}) => `${name}: ${value}%`}> | |
| {pieData.map((_: any, i: number) => <Cell key={i} fill={COLORS[i % COLORS.length]} />)} | |
| </Pie> | |
| <Tooltip contentStyle={{background:'var(--chart-tooltip-bg)',border:'1px solid var(--chart-tooltip-border)',borderRadius:8,fontSize:'0.8rem',boxShadow:'var(--shadow-md)'}} /> | |
| </PieChart> | |
| </ResponsiveContainer> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {activeTab === 'risk' && riskData && ( | |
| <div className="card"> | |
| <div className="card-header"><h3>Risk Metrics</h3></div> | |
| <div className="table-container"> | |
| <table> | |
| <thead><tr><th>Asset</th><th>Volatility</th><th>Sharpe</th><th>Max DD</th><th>VaR 95%</th><th>Beta</th></tr></thead> | |
| <tbody> | |
| {riskData.metrics?.map((m: any, i: number) => ( | |
| <tr key={i}> | |
| <td style={{fontWeight:600}}>{m.ticker || m.portfolio_name}</td> | |
| <td>{(m.volatility * 100).toFixed(2)}%</td> | |
| <td className={m.sharpe_ratio > 0 ? 'positive' : 'negative'}>{m.sharpe_ratio?.toFixed(2)}</td> | |
| <td className="negative">{(m.max_drawdown * 100).toFixed(2)}%</td> | |
| <td>{m.var_95 != null ? (m.var_95 * 100).toFixed(3) + '%' : '—'}</td> | |
| <td>{m.beta?.toFixed(2) || '—'}</td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| )} | |
| {/* Correlation Heatmap Tab */} | |
| {activeTab === 'correlation' && ( | |
| <div> | |
| {corrLoading ? ( | |
| <div className="loading-overlay" style={{ padding: '2rem' }}><div className="spinner" /><span>Computing correlations...</span></div> | |
| ) : !corrData?.matrix?.length ? ( | |
| <div className="empty-state card"> | |
| <h3>No correlation data</h3> | |
| <p>Add at least 2 holdings to your portfolio first, then click the Correlation tab to view the heatmap.</p> | |
| </div> | |
| ) : ( | |
| <div className="grid-2"> | |
| {/* Heatmap */} | |
| <div className="card"> | |
| <div className="card-header"><h3>Correlation Heatmap</h3></div> | |
| <div style={{ overflowX: 'auto' }}> | |
| <div style={{ | |
| display: 'grid', | |
| gridTemplateColumns: `60px repeat(${corrData.tickers.length}, 1fr)`, | |
| gap: '2px', | |
| minWidth: corrData.tickers.length * 60 + 60, | |
| }}> | |
| {/* Header row */} | |
| <div /> | |
| {corrData.tickers.map((t: string) => ( | |
| <div key={`h-${t}`} style={{ fontSize: '0.65rem', fontWeight: 700, textAlign: 'center', padding: '0.375rem 0.25rem', color: 'var(--text-secondary)' }}>{t}</div> | |
| ))} | |
| {/* Data rows */} | |
| {corrData.tickers.map((t1: string, i: number) => ( | |
| <> | |
| <div key={`l-${t1}`} style={{ fontSize: '0.65rem', fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'flex-end', paddingRight: '0.5rem', color: 'var(--text-secondary)' }}>{t1}</div> | |
| {corrData.matrix[i]?.map((val: number, j: number) => { | |
| const abs = Math.abs(val); | |
| const bg = i === j ? '#005241' | |
| : val > 0.6 ? `rgba(194, 48, 48, ${0.15 + abs * 0.6})` | |
| : val < -0.3 ? `rgba(10, 143, 92, ${0.15 + abs * 0.6})` | |
| : `rgba(184, 134, 11, ${0.05 + abs * 0.25})`; | |
| return ( | |
| <div key={`c-${i}-${j}`} style={{ | |
| background: bg, | |
| color: i === j ? 'white' : 'var(--text-primary)', | |
| display: 'flex', alignItems: 'center', justifyContent: 'center', | |
| fontSize: '0.7rem', fontWeight: 600, | |
| borderRadius: '4px', padding: '0.5rem 0.25rem', minHeight: 36, | |
| }}> | |
| {val.toFixed(2)} | |
| </div> | |
| ); | |
| })} | |
| </> | |
| ))} | |
| </div> | |
| </div> | |
| <div style={{ display: 'flex', gap: '1rem', marginTop: '1rem', fontSize: '0.7rem', color: 'var(--text-muted)' }}> | |
| <span>🟥 High positive</span> | |
| <span>🟨 Low/moderate</span> | |
| <span>🟩 Negative (diversifying)</span> | |
| <span>Data: {corrData.data_points} trading days</span> | |
| </div> | |
| </div> | |
| {/* Pairs & Risk Flags */} | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}> | |
| {/* Risk Flags */} | |
| {corrData.risk_flags?.length > 0 && ( | |
| <div className="card"> | |
| <div className="card-header"><h3>⚠️ Concentration Alerts</h3></div> | |
| {corrData.risk_flags.map((f: any, i: number) => ( | |
| <div key={i} style={{ padding: '0.625rem 0', borderBottom: '1px solid var(--border-subtle)', display: 'flex', gap: '0.75rem', alignItems: 'center' }}> | |
| <span className={`badge ${f.severity === 'high' ? 'badge-rose' : 'badge-amber'}`}>{f.severity}</span> | |
| <span style={{ fontSize: '0.8rem' }}>{f.message}</span> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| {/* Top Pairs */} | |
| <div className="card"> | |
| <div className="card-header"><h3>Correlation Pairs</h3></div> | |
| <div style={{ maxHeight: 400, overflowY: 'auto' }}> | |
| {corrData.pairs?.map((p: any, i: number) => ( | |
| <div key={i} style={{ padding: '0.5rem 0', borderBottom: '1px solid var(--border-subtle)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> | |
| <div> | |
| <span style={{ fontWeight: 600, fontSize: '0.82rem' }}>{p.ticker1} ↔ {p.ticker2}</span> | |
| <div style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>{p.strength} · {p.direction}</div> | |
| </div> | |
| <span style={{ | |
| fontWeight: 700, fontSize: '0.85rem', | |
| color: Math.abs(p.correlation) > 0.7 ? '#c23030' : Math.abs(p.correlation) > 0.4 ? '#b8860b' : '#0a8f5c', | |
| }}>{p.correlation > 0 ? '+' : ''}{p.correlation}</span> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |