quanthedge / frontend /src /pages /FactorAnalysis.tsx
jashdoshi77's picture
added dark mode
e85ce30
import { useState } from 'react';
import { Tooltip, ResponsiveContainer, RadarChart, Radar, PolarGrid, PolarAngleAxis, PolarRadiusAxis, BarChart, Bar, XAxis, YAxis, CartesianGrid, Cell } from 'recharts';
import { quantAPI, mlAPI } from '../api/client';
import { TrendingUp, TrendingDown, Activity, Target, Brain, Eye, BarChart3, Clock, ArrowUpRight, ArrowDownRight } from 'lucide-react';
import TickerSearch from '../components/TickerSearch';
export default function FactorAnalysis() {
const [tickers, setTickers] = useState('AAPL, MSFT, GOOGL, AMZN, NVDA');
const factors = ['momentum', 'value', 'size', 'quality', 'volatility'];
const [results, setResults] = useState<any>(null);
const [signals, setSignals] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [activeTab, setActiveTab] = useState('factors');
// ML state
const [mlTicker, setMlTicker] = useState('AAPL');
const [prediction, setPrediction] = useState<any>(null);
const [regime, setRegime] = useState<any>(null);
const [mlLoading, setMlLoading] = useState(false);
const analyze = async () => {
setLoading(true);
const tickerList = tickers.split(',').map(t => t.trim()).filter(Boolean);
try {
const [factorRes, signalRes] = await Promise.all([
quantAPI.analyzeFactors({ tickers: tickerList, factors }),
quantAPI.generateSignals({ tickers: tickerList }),
]);
setResults(factorRes.data);
setSignals(signalRes.data);
} catch (e) { console.error(e); } finally { setLoading(false); }
};
const runML = async () => {
if (!mlTicker.trim()) return;
setMlLoading(true);
setPrediction(null);
setRegime(null);
try {
// Sequential calls to avoid overwhelming Yahoo Finance with parallel requests
const regimeRes = await mlAPI.regime(mlTicker.trim().toUpperCase());
setRegime(regimeRes.data);
const predRes = await mlAPI.predict(mlTicker.trim().toUpperCase());
setPrediction(predRes.data);
} catch (e: any) {
console.error(e);
alert(e?.response?.data?.detail || 'ML prediction failed');
} finally { setMlLoading(false); }
};
const getRadarData = () => {
if (!results?.exposures) return [];
const factorNames = [...new Set(results.exposures.map((e: any) => e.factor_name))];
return factorNames.map(f => {
const entry: any = { factor: f };
results.exposures.filter((e: any) => e.factor_name === f).forEach((e: any) => {
entry[e.ticker] = (e.percentile_rank * 100).toFixed(0);
});
return entry;
});
};
const regimeColor = (r: string) =>
r === 'bull' ? '#0a8f5c' : r === 'bear' ? '#c23030' : '#b8860b';
const RegimeIcon = ({ regime: r, size = 14 }: { regime: string; size?: number }) => {
if (r === 'bull') return <TrendingUp size={size} color="#0a8f5c" />;
if (r === 'bear') return <TrendingDown size={size} color="#c23030" />;
return <Activity size={size} color="#b8860b" />;
};
return (
<div className="page animate-fade-in">
<div className="page-header">
<h1 style={{fontSize:'1.75rem',fontFamily:'var(--font-serif)'}}>Factor Analysis</h1>
<p style={{color:'var(--text-muted)',fontSize:'0.85rem'}}>Multi-factor model exposures, quantitative signals, and ML-powered predictions</p>
</div>
{/* Input */}
<div className="card" style={{marginBottom:'1.5rem'}}>
<div className="factor-input-bar" style={{display:'flex',gap:'0.75rem',alignItems:'flex-end',flexWrap:'wrap'}}>
<div style={{flex:1,minWidth:300}}>
<label style={{fontSize:'0.75rem',fontWeight:600,color:'var(--text-muted)',textTransform:'uppercase',letterSpacing:'0.05em',display:'block',marginBottom:'0.375rem'}}>Ticker Universe (comma-separated)</label>
<input className="input" value={tickers} onChange={e => setTickers(e.target.value)} placeholder="AAPL, MSFT, GOOGL..." />
</div>
<button className="btn btn-primary" onClick={analyze} disabled={loading}>
{loading ? <div className="spinner" /> : 'Analyze Factors'}
</button>
</div>
</div>
{/* Tabs */}
<div className="tabs">
<button className={`tab ${activeTab === 'factors' ? 'active' : ''}`} onClick={() => setActiveTab('factors')}>
<span style={{display:'flex',alignItems:'center',gap:'0.375rem'}}><BarChart3 size={13}/> Factor Exposures</span>
</button>
<button className={`tab ${activeTab === 'signals' ? 'active' : ''}`} onClick={() => setActiveTab('signals')}>
<span style={{display:'flex',alignItems:'center',gap:'0.375rem'}}><Activity size={13}/> Signals</span>
</button>
<button className={`tab ${activeTab === 'ml' ? 'active' : ''}`} onClick={() => setActiveTab('ml')}>
<span style={{display:'flex',alignItems:'center',gap:'0.375rem'}}><Brain size={13}/> ML Insights</span>
</button>
</div>
{/* Factor Results */}
{activeTab === 'factors' && results && (
<div className="grid-2">
<div className="card">
<div className="card-header"><h3>Factor Exposure Heatmap</h3></div>
<div className="table-container">
<table>
<thead>
<tr>
<th>Ticker</th>
{factors.map(f => <th key={f}>{f.charAt(0).toUpperCase() + f.slice(1)}</th>)}
</tr>
</thead>
<tbody>
{results.tickers?.map((ticker: string) => (
<tr key={ticker}>
<td style={{fontWeight:600}}>{ticker}</td>
{factors.map(f => {
const exp = results.exposures?.find((e: any) => e.ticker === ticker && e.factor_name === f);
const val = exp?.z_score || 0;
const bg = val > 1 ? 'rgba(16,185,129,0.15)' : val < -1 ? 'rgba(244,63,94,0.15)' : 'transparent';
return <td key={f} style={{background:bg,color:val > 0 ? 'var(--accent-emerald)' : val < 0 ? 'var(--accent-rose)' : 'var(--text-secondary)'}}>{val.toFixed(2)}</td>;
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="card">
<div className="card-header"><h3>Factor Radar</h3></div>
<div style={{height:300}}>
<ResponsiveContainer>
<RadarChart data={getRadarData()}>
<PolarGrid stroke="var(--chart-grid)" />
<PolarAngleAxis dataKey="factor" tick={{fontSize:11,fill:'var(--chart-axis)'}} />
<PolarRadiusAxis tick={{fontSize:9,fill:'var(--chart-axis)'}} />
{results.tickers?.slice(0, 4).map((t: string, i: number) => (
<Radar key={t} name={t} dataKey={t} stroke={['#6366f1','#10b981','#f59e0b','#f43f5e'][i]} fill={['#6366f1','#10b981','#f59e0b','#f43f5e'][i]} fillOpacity={0.1} strokeWidth={2} />
))}
<Tooltip contentStyle={{background:'var(--chart-tooltip-bg)',border:'1px solid var(--chart-tooltip-border)',borderRadius:8,fontSize:'0.8rem',boxShadow:'var(--shadow-md)'}} />
</RadarChart>
</ResponsiveContainer>
</div>
</div>
</div>
)}
{/* Signals */}
{activeTab === 'signals' && signals && (
<div className="card">
<div className="card-header"><h3>Generated Signals ({signals.total_signals || 0})</h3></div>
<div className="table-container">
<table>
<thead><tr><th>Ticker</th><th>Signal</th><th>Type</th><th>Direction</th><th>Strength</th><th>Value</th></tr></thead>
<tbody>
{signals.results?.flatMap((r: any) => r.signals?.map((s: any, i: number) => (
<tr key={`${r.ticker}-${i}`}>
<td style={{fontWeight:600}}>{s.ticker}</td>
<td>{s.name}</td>
<td><span className="badge badge-primary">{s.signal_type}</span></td>
<td><span className={`badge ${s.direction === 'long' ? 'badge-emerald' : s.direction === 'short' ? 'badge-rose' : 'badge-amber'}`}>{s.direction?.toUpperCase()}</span></td>
<td>{(s.strength * 100).toFixed(0)}%</td>
<td>{s.value?.toFixed(4)}</td>
</tr>
)) || [])}
</tbody>
</table>
</div>
</div>
)}
{/* ML Insights */}
{activeTab === 'ml' && (
<div>
{/* ML Search */}
<div className="card" style={{marginBottom:'1.5rem'}}>
<div style={{display:'flex',gap:'0.75rem',alignItems:'flex-end',flexWrap:'wrap'}}>
<div style={{flex:1,minWidth:200}}>
<label style={{fontSize:'0.75rem',fontWeight:600,color:'var(--text-muted)',textTransform:'uppercase',letterSpacing:'0.05em',display:'block',marginBottom:'0.375rem'}}>Ticker for ML Analysis</label>
<TickerSearch value={mlTicker} onChange={v => setMlTicker(v)} onSelect={t => setMlTicker(t.symbol)} placeholder="Search any ticker..." />
</div>
<button className="btn btn-primary" onClick={runML} disabled={mlLoading}>
{mlLoading ? <div className="spinner" /> : <><Brain size={14}/> Run ML Models</>}
</button>
</div>
<p style={{fontSize:'0.75rem',color:'var(--text-muted)',marginTop:'0.5rem'}}>
Models train on 2 years of live data with 30+ technical features. Auto-retrains every 6 hours.
</p>
</div>
{mlLoading && (
<div className="card" style={{textAlign:'center',padding:'3rem'}}>
<div className="spinner" style={{margin:'0 auto 1rem'}} />
<p style={{color:'var(--text-muted)'}}>Training models on live data... This may take a few seconds.</p>
</div>
)}
{prediction && regime && (
<div className="grid-2">
{/* XGBoost Prediction */}
<div className="card">
<div className="card-header">
<h3 style={{display:'flex',alignItems:'center',gap:'0.5rem'}}><Target size={16} color="var(--accent)"/> XGBoost Return Prediction</h3>
<span className="badge badge-primary">{prediction.horizon_days}-day forecast</span>
</div>
{/* Direction badge */}
<div style={{display:'flex',alignItems:'center',gap:'1rem',margin:'1.5rem 0',padding:'1.25rem',borderRadius:12,background: prediction.prediction === 'up' ? 'rgba(34,197,94,0.08)' : 'rgba(239,68,68,0.08)',border: `1px solid ${prediction.prediction === 'up' ? 'rgba(34,197,94,0.2)' : 'rgba(239,68,68,0.2)'}`}}>
<div style={{width:44,height:44,borderRadius:'var(--radius-md)',background: prediction.prediction === 'up' ? 'rgba(10,143,92,0.1)' : 'rgba(194,48,48,0.1)',display:'flex',alignItems:'center',justifyContent:'center'}}>{prediction.prediction === 'up' ? <ArrowUpRight size={24} color="#0a8f5c"/> : <ArrowDownRight size={24} color="#c23030"/>}</div>
<div>
<div style={{fontSize:'1.5rem',fontWeight:700,color: prediction.prediction === 'up' ? '#0a8f5c' : '#c23030'}}>
{prediction.prediction.toUpperCase()}
</div>
<div style={{fontSize:'0.85rem',color:'var(--text-muted)'}}>
Probability: <strong>{(prediction.probability * 100).toFixed(1)}%</strong>
</div>
</div>
<div style={{marginLeft:'auto',textAlign:'right'}}>
<div style={{fontSize:'0.7rem',textTransform:'uppercase',color:'var(--text-muted)',letterSpacing:'0.05em'}}>Expected Return</div>
<div style={{fontSize:'1.25rem',fontWeight:700,fontFamily:'var(--font-mono)',color: prediction.expected_return_pct > 0 ? '#0a8f5c' : '#c23030'}}>
{prediction.expected_return_pct > 0 ? '+' : ''}{prediction.expected_return_pct}%
</div>
</div>
</div>
{/* Confidence + Price */}
<div style={{display:'flex',gap:'1rem',marginBottom:'1.25rem',flexWrap:'wrap'}}>
<div className="metric">
<div className="metric-label">Confidence</div>
<div className="metric-value" style={{color: prediction.confidence_level === 'high' ? '#0a8f5c' : prediction.confidence_level === 'medium' ? '#b8860b' : '#c23030'}}>
{prediction.confidence_level?.toUpperCase()}
</div>
</div>
<div className="metric">
<div className="metric-label">Current Price</div>
<div className="metric-value">${prediction.current_price}</div>
</div>
<div className="metric">
<div className="metric-label">Model Accuracy</div>
<div className="metric-value">{(prediction.model_metrics?.accuracy * 100).toFixed(1)}%</div>
</div>
<div className="metric">
<div className="metric-label">F1 Score</div>
<div className="metric-value">{(prediction.model_metrics?.f1 * 100).toFixed(1)}%</div>
</div>
</div>
{/* Feature Importance */}
<h4 style={{fontSize:'0.85rem',fontWeight:600,marginBottom:'0.75rem',color:'var(--text-muted)'}}>Top Feature Importance</h4>
<div style={{height:220}}>
<ResponsiveContainer>
<BarChart data={prediction.top_features} layout="vertical" margin={{left:100,right:20,top:5,bottom:5}}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--chart-grid)" />
<XAxis type="number" tick={{fontSize:10,fill:'var(--chart-axis)'}} />
<YAxis type="category" dataKey="name" tick={{fontSize:10,fill:'var(--chart-axis)'}} width={95} />
<Tooltip contentStyle={{background:'var(--chart-tooltip-bg)',border:'1px solid var(--chart-tooltip-border)',borderRadius:8,fontSize:'0.8rem',boxShadow:'var(--shadow-md)'}} />
<Bar dataKey="importance" radius={[0,4,4,0]}>
{prediction.top_features?.map((_: any, i: number) => (
<Cell key={i} fill={['#6366f1','#818cf8','#a5b4fc','#c7d2fe','#e0e7ff','#eef2ff','#f0f0ff','#f5f5ff','#fafafe','#fdfdfd'][i] || '#c7d2fe'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
{prediction.from_cache && (
<p style={{fontSize:'0.7rem',color:'var(--text-muted)',marginTop:'0.5rem',display:'flex',alignItems:'center',gap:'0.25rem'}}><Clock size={11}/> Used cached model (retrains every 6h)</p>
)}
</div>
{/* HMM Regime */}
<div className="card">
<div className="card-header">
<h3 style={{display:'flex',alignItems:'center',gap:'0.5rem'}}><Eye size={16} color="var(--accent)"/> Market Regime Detection</h3>
<span className="badge" style={{background:regimeColor(regime.current_regime)+'20',color:regimeColor(regime.current_regime),fontWeight:600,display:'inline-flex',alignItems:'center',gap:'0.25rem'}}>
<RegimeIcon regime={regime.current_regime} size={12}/> {regime.current_regime?.replace('_',' ').toUpperCase()}
</span>
</div>
{/* Current regime banner */}
<div style={{padding:'1.25rem',borderRadius:12,background:regimeColor(regime.current_regime)+'10',border:`1px solid ${regimeColor(regime.current_regime)}30`,margin:'1rem 0'}}>
<div style={{display:'flex',alignItems:'center',gap:'0.75rem'}}>
<div style={{width:40,height:40,borderRadius:'var(--radius-md)',background:regimeColor(regime.current_regime)+'15',display:'flex',alignItems:'center',justifyContent:'center'}}><RegimeIcon regime={regime.current_regime} size={22}/></div>
<div>
<div style={{fontWeight:700,fontSize:'1.1rem',color:regimeColor(regime.current_regime)}}>
{regime.current_regime?.replace('_',' ').replace(/^\w/, (c: string) => c.toUpperCase())} Regime
</div>
<div style={{fontSize:'0.8rem',color:'var(--text-muted)'}}>
{regime.regime_info?.description}
</div>
</div>
</div>
</div>
{/* Regime probabilities */}
<h4 style={{fontSize:'0.85rem',fontWeight:600,marginBottom:'0.75rem',color:'var(--text-muted)'}}>Regime Probabilities</h4>
<div style={{display:'flex',flexDirection:'column',gap:'0.5rem',marginBottom:'1.25rem'}}>
{regime.regime_probabilities && Object.entries(regime.regime_probabilities).map(([label, prob]: [string, any]) => (
<div key={label} style={{display:'flex',alignItems:'center',gap:'0.75rem'}}>
<span style={{width:120,fontSize:'0.8rem',fontWeight:500,display:'flex',alignItems:'center',gap:'0.35rem'}}><RegimeIcon regime={label} size={12}/> {label.replace('_',' ')}</span>
<div style={{flex:1,height:20,borderRadius:10,background:'var(--chart-bar-bg)',overflow:'hidden'}}>
<div style={{width:`${prob*100}%`,height:'100%',borderRadius:10,background:regimeColor(label),transition:'width 0.5s ease'}} />
</div>
<span style={{fontSize:'0.8rem',fontWeight:600,width:45,textAlign:'right'}}>{(prob*100).toFixed(1)}%</span>
</div>
))}
</div>
{/* Regime stats */}
{regime.regime_stats && (
<>
<h4 style={{fontSize:'0.85rem',fontWeight:600,marginBottom:'0.75rem',color:'var(--text-muted)'}}>Regime Statistics</h4>
<div className="table-container" style={{marginBottom:'1.25rem'}}>
<table>
<thead><tr><th>Regime</th><th>Avg Daily Return</th><th>Daily Volatility</th><th>Days</th><th>% of Time</th></tr></thead>
<tbody>
{Object.entries(regime.regime_stats).map(([label, s]: [string, any]) => (
<tr key={label}>
<td style={{fontWeight:600,fontSize:'0.8rem'}}><span style={{display:'inline-flex',alignItems:'center',gap:'0.3rem'}}><RegimeIcon regime={label} size={12}/> {label.replace('_',' ')}</span></td>
<td style={{color: s.mean_daily_return_pct > 0 ? '#0a8f5c' : '#c23030',fontFamily:'var(--font-mono)',fontSize:'0.8rem'}}>{s.mean_daily_return_pct > 0 ? '+' : ''}{s.mean_daily_return_pct}%</td>
<td>{s.daily_volatility_pct}%</td>
<td>{s.days_in_regime}</td>
<td>{s.pct_of_time}%</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
{/* Regime History Timeline */}
<h4 style={{fontSize:'0.85rem',fontWeight:600,marginBottom:'0.75rem',color:'var(--text-muted)'}}>Regime History (Last 60 Days)</h4>
<div style={{display:'flex',gap:1,height:24,borderRadius:6,overflow:'hidden',marginBottom:'1.25rem'}}>
{regime.regime_history?.map((d: any, i: number) => (
<div key={i} style={{flex:1,background:regimeColor(d.regime),opacity:0.8,cursor:'pointer'}} title={`${d.date}: ${d.regime}`} />
))}
</div>
<div style={{display:'flex',gap:'1rem',fontSize:'0.7rem',color:'var(--text-muted)'}}>
<span style={{display:'inline-flex',alignItems:'center',gap:'0.2rem'}}><span style={{width:8,height:8,borderRadius:'50%',background:'#0a8f5c',display:'inline-block'}}/>Bull</span>
<span style={{display:'inline-flex',alignItems:'center',gap:'0.2rem'}}><span style={{width:8,height:8,borderRadius:'50%',background:'#c23030',display:'inline-block'}}/>Bear</span>
<span style={{display:'inline-flex',alignItems:'center',gap:'0.2rem'}}><span style={{width:8,height:8,borderRadius:'50%',background:'#b8860b',display:'inline-block'}}/>High Vol</span>
<span style={{marginLeft:'auto'}}>{regime.regime_history?.[0]?.date} → {regime.regime_history?.[regime.regime_history.length-1]?.date}</span>
</div>
{/* Transition Matrix */}
{regime.transition_matrix && (
<>
<h4 style={{fontSize:'0.85rem',fontWeight:600,margin:'1.25rem 0 0.75rem',color:'var(--text-muted)'}}>Transition Matrix</h4>
<div className="table-container">
<table>
<thead><tr><th style={{fontSize:'0.7rem'}}>From / To</th><th style={{fontSize:'0.7rem'}}>Bull</th><th style={{fontSize:'0.7rem'}}>Bear</th><th style={{fontSize:'0.7rem'}}>High Vol</th></tr></thead>
<tbody>
{['bull','bear','high_volatility'].map(from => (
<tr key={from}>
<td style={{fontWeight:600,fontSize:'0.8rem'}}><span style={{display:'inline-flex',alignItems:'center',gap:'0.3rem'}}><RegimeIcon regime={from} size={12}/> {from.replace('_',' ')}</span></td>
{['bull','bear','high_volatility'].map(to => {
const p = regime.transition_matrix[from]?.[to] || 0;
return (
<td key={to} style={{
background: from === to ? `${regimeColor(to)}15` : p > 0.3 ? 'rgba(99,102,241,0.08)' : 'transparent',
fontWeight: from === to ? 700 : 400,
}}>
{(p*100).toFixed(1)}%
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
<p style={{fontSize:'0.7rem',color:'var(--text-muted)',marginTop:'0.5rem'}}>
Diagonal = probability of staying in same regime. Off-diagonal = probability of switching.
</p>
</>
)}
</div>
</div>
)}
{!prediction && !mlLoading && (
<div className="empty-state card"><h3>Enter a ticker to run ML analysis</h3><p>XGBoost predicts 5-day return direction. HMM detects bull/bear/high-volatility regimes. Models auto-retrain on live data every 6 hours.</p></div>
)}
</div>
)}
{activeTab !== 'ml' && !results && !loading && (
<div className="empty-state card"><h3>Enter tickers and run analysis</h3><p>The factor model will compute momentum, value, size, quality, and volatility exposures.</p></div>
)}
</div>
);
}