Spaces:
Running
Running
| import { useState, useEffect } from 'react'; | |
| import { sentimentAPI } from '../api/client'; | |
| import TickerSearch from '../components/TickerSearch'; | |
| import { | |
| BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, | |
| ResponsiveContainer, Cell, PieChart, Pie, RadialBarChart, RadialBar | |
| } from 'recharts'; | |
| import { | |
| TrendingUp, TrendingDown, Minus, Search, Activity, BarChart3, | |
| Newspaper, ArrowUpRight, ArrowDownRight, Eye | |
| } from 'lucide-react'; | |
| const MOOD_COLORS: Record<string, string> = { bullish: '#0a8f5c', bearish: '#c23030', neutral: '#b8860b' }; | |
| export default function Sentiment() { | |
| const [tab, setTab] = useState<'market' | 'search'>('market'); | |
| const [marketMood, setMarketMood] = useState<any>(null); | |
| const [loading, setLoading] = useState(true); | |
| const [searchTicker, setSearchTicker] = useState(''); | |
| const [tickerResult, setTickerResult] = useState<any>(null); | |
| const [searchLoading, setSearchLoading] = useState(false); | |
| useEffect(() => { loadMarketMood(); }, []); | |
| const loadMarketMood = async () => { | |
| setLoading(true); | |
| try { const res = await sentimentAPI.marketMood(); setMarketMood(res.data); } catch { /* empty */ } | |
| setLoading(false); | |
| }; | |
| const handleSearch = async (e: React.FormEvent) => { | |
| e.preventDefault(); | |
| if (!searchTicker.trim()) return; | |
| setSearchLoading(true); | |
| try { const res = await sentimentAPI.ticker(searchTicker.trim().toUpperCase()); setTickerResult(res.data); } catch { /* empty */ } | |
| setSearchLoading(false); | |
| }; | |
| if (loading) { | |
| return <div className="page animate-fade-in"><div className="loading-overlay"><div className="spinner" /><span>Analyzing market sentiment...</span></div></div>; | |
| } | |
| const moodColor = MOOD_COLORS[(marketMood?.mood as string) || 'neutral']; | |
| const MoodIcon = marketMood?.mood === 'bullish' ? TrendingUp : marketMood?.mood === 'bearish' ? TrendingDown : Minus; | |
| const score = marketMood?.score || 0; | |
| // Gauge data: normalize score from [-1,1] to [0,100] | |
| const gaugeValue = ((score + 1) / 2) * 100; | |
| const gaugeData = [{ name: 'Sentiment', value: gaugeValue, fill: moodColor }]; | |
| return ( | |
| <div className="page animate-fade-in"> | |
| <div className="page-header"> | |
| <h1 style={{fontSize:'1.75rem',fontFamily:'var(--font-serif)'}}>Sentiment Analysis</h1> | |
| <p style={{color:'var(--text-muted)',fontSize:'0.85rem'}}>Real-time market sentiment derived from financial news and headlines</p> | |
| </div> | |
| {/* Market Mood Header */} | |
| {marketMood && ( | |
| <div className="grid-2" style={{marginBottom:'1.5rem'}}> | |
| {/* Mood Summary */} | |
| <div className="card" style={{display:'flex',alignItems:'center',gap:'1.5rem',padding:'1.5rem'}}> | |
| <div style={{width:56,height:56,borderRadius:'50%',background:`${moodColor}10`,display:'flex',alignItems:'center',justifyContent:'center',flexShrink:0}}> | |
| <MoodIcon size={28} color={moodColor} /> | |
| </div> | |
| <div> | |
| <div style={{fontSize:'0.68rem',fontWeight:600,color:'var(--text-muted)',textTransform:'uppercase',letterSpacing:'0.05em',marginBottom:'0.2rem'}}>Market Sentiment</div> | |
| <h2 style={{color:moodColor,textTransform:'capitalize',fontSize:'1.5rem',fontFamily:'var(--font-serif)',marginBottom:'0.15rem'}}> | |
| {marketMood.mood} | |
| </h2> | |
| <div style={{fontSize:'0.8rem',color:'var(--text-muted)'}}> | |
| Score: <span style={{fontFamily:'var(--font-mono)',fontWeight:600}}>{score.toFixed(3)}</span> | {marketMood.tickers_analyzed} tickers analyzed | |
| </div> | |
| </div> | |
| </div> | |
| {/* Sentiment Gauge */} | |
| <div className="card" style={{padding:'1rem'}}> | |
| <div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:'0.5rem'}}> | |
| <span style={{fontSize:'0.68rem',fontWeight:600,color:'var(--text-muted)',textTransform:'uppercase',letterSpacing:'0.05em'}}>Sentiment Gauge</span> | |
| <div style={{display:'flex',gap:'1rem',fontSize:'0.7rem',color:'var(--text-muted)'}}> | |
| <span style={{display:'flex',alignItems:'center',gap:'0.25rem'}}><span style={{width:8,height:8,borderRadius:'50%',background:'#c23030',display:'inline-block'}}/>Bearish</span> | |
| <span style={{display:'flex',alignItems:'center',gap:'0.25rem'}}><span style={{width:8,height:8,borderRadius:'50%',background:'#b8860b',display:'inline-block'}}/>Neutral</span> | |
| <span style={{display:'flex',alignItems:'center',gap:'0.25rem'}}><span style={{width:8,height:8,borderRadius:'50%',background:'#0a8f5c',display:'inline-block'}}/>Bullish</span> | |
| </div> | |
| </div> | |
| <div style={{height:130,display:'flex',alignItems:'center',justifyContent:'center'}}> | |
| <ResponsiveContainer width="100%" height="100%"> | |
| <RadialBarChart cx="50%" cy="85%" innerRadius="65%" outerRadius="100%" startAngle={180} endAngle={0} data={gaugeData} barSize={12}> | |
| <RadialBar background={{ fill: 'var(--bg-tertiary)' }} dataKey="value" cornerRadius={6} /> | |
| </RadialBarChart> | |
| </ResponsiveContainer> | |
| </div> | |
| <div style={{textAlign:'center',marginTop:'-0.25rem'}}> | |
| <span style={{fontFamily:'var(--font-mono)',fontSize:'1.1rem',fontWeight:700,color:moodColor}}>{score.toFixed(3)}</span> | |
| <span style={{color:'var(--text-muted)',fontSize:'0.75rem',marginLeft:'0.375rem'}}>/ 1.000</span> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Breakdown Chart */} | |
| {marketMood?.breakdown?.length > 0 && ( | |
| <div className="card" style={{ marginBottom: '1.5rem' }}> | |
| <div className="card-header"> | |
| <h3 style={{display:'flex',alignItems:'center',gap:'0.5rem'}}><BarChart3 size={15}/> Ticker Sentiment Breakdown</h3> | |
| </div> | |
| <div style={{ width: '100%', height: 320 }}> | |
| <ResponsiveContainer> | |
| <BarChart data={marketMood.breakdown}> | |
| <CartesianGrid strokeDasharray="3 3" stroke="var(--chart-grid)" /> | |
| <XAxis dataKey="ticker" tick={{ fontSize: 11, fill: 'var(--chart-axis)', fontWeight: 500 }} /> | |
| <YAxis tick={{ fontSize: 10, fill: 'var(--chart-axis)' }} domain={[-1, 1]} /> | |
| <Tooltip | |
| formatter={(v: any) => (Number(v)).toFixed(3)} | |
| contentStyle={{ background:'var(--bg-card)', border:'1px solid var(--border-color)', borderRadius:8, fontSize:'0.8rem', boxShadow:'var(--shadow-md)' }} | |
| /> | |
| <Bar dataKey="score" radius={[4, 4, 0, 0]}> | |
| {marketMood.breakdown.map((d: any, i: number) => ( | |
| <Cell key={i} fill={d.score > 0.15 ? '#0a8f5c' : d.score < -0.15 ? '#c23030' : '#b8860b'} /> | |
| ))} | |
| </Bar> | |
| </BarChart> | |
| </ResponsiveContainer> | |
| </div> | |
| </div> | |
| )} | |
| {/* Tabs */} | |
| <div className="tabs"> | |
| <button className={`tab ${tab === 'market' ? 'active' : ''}`} onClick={() => setTab('market')}> | |
| <span style={{display:'flex',alignItems:'center',gap:'0.375rem'}}><Activity size={13}/> Market Overview</span> | |
| </button> | |
| <button className={`tab ${tab === 'search' ? 'active' : ''}`} onClick={() => setTab('search')}> | |
| <span style={{display:'flex',alignItems:'center',gap:'0.375rem'}}><Search size={13}/> Ticker Search</span> | |
| </button> | |
| </div> | |
| {/* Market Overview */} | |
| {tab === 'market' && marketMood?.breakdown && ( | |
| <div className="grid-3"> | |
| {marketMood.breakdown.map((t: any) => ( | |
| <div key={t.ticker} className="card" style={{borderLeft:`3px solid ${MOOD_COLORS[t.sentiment] || 'var(--border-color)'}`}}> | |
| <div className="flex-between" style={{ marginBottom: '0.5rem' }}> | |
| <h3 style={{ fontFamily:'var(--font-mono)', fontSize:'0.95rem' }}>{t.ticker}</h3> | |
| <span style={{ | |
| display:'inline-flex',alignItems:'center',gap:'0.25rem', | |
| padding:'0.15rem 0.5rem',borderRadius:'var(--radius-sm)',fontSize:'0.7rem',fontWeight:600, | |
| background: `${MOOD_COLORS[t.sentiment] || '#6b7280'}10`, | |
| color: MOOD_COLORS[t.sentiment] || 'var(--text-muted)',textTransform:'capitalize' | |
| }}> | |
| {t.sentiment === 'bullish' ? <ArrowUpRight size={11}/> : t.sentiment === 'bearish' ? <ArrowDownRight size={11}/> : <Minus size={11}/>} | |
| {t.sentiment} | |
| </span> | |
| </div> | |
| <div style={{display:'flex',alignItems:'baseline',gap:'0.5rem'}}> | |
| <span style={{fontSize:'1.25rem',fontWeight:700,fontFamily:'var(--font-mono)',color: MOOD_COLORS[t.sentiment] || 'var(--text-primary)'}}> | |
| {(t.score >= 0 ? '+' : '')}{t.score?.toFixed(3)} | |
| </span> | |
| <span style={{fontSize:'0.7rem',color:'var(--text-muted)'}}>sentiment score</span> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| {/* Ticker Search */} | |
| {tab === 'search' && ( | |
| <div> | |
| <form onSubmit={handleSearch} style={{ display: 'flex', gap: '0.75rem', marginBottom: '1.5rem' }}> | |
| <TickerSearch value={searchTicker} onChange={v => setSearchTicker(v)} onSelect={t => setSearchTicker(t.symbol)} | |
| placeholder="Search stocks, ETFs, futures, crypto..." style={{ flex: 1 }} /> | |
| <button className="btn btn-primary" type="submit" disabled={searchLoading} style={{display:'flex',alignItems:'center',gap:'0.375rem'}}> | |
| <Search size={14}/> | |
| {searchLoading ? 'Analyzing...' : 'Analyze'} | |
| </button> | |
| </form> | |
| {tickerResult && ( | |
| <> | |
| {/* Summary Cards */} | |
| <div className="grid-4" style={{ marginBottom: '1.5rem' }}> | |
| <div className="card" style={{padding:'1rem 1.25rem',borderLeft:`3px solid ${MOOD_COLORS[tickerResult.sentiment] || '#6b7280'}`}}> | |
| <div style={{display:'flex',alignItems:'center',gap:'0.5rem',marginBottom:'0.25rem'}}> | |
| <Eye size={14} color={MOOD_COLORS[tickerResult.sentiment] || 'var(--text-muted)'}/> | |
| <span style={{fontSize:'0.68rem',fontWeight:600,color:'var(--text-muted)',textTransform:'uppercase',letterSpacing:'0.05em'}}>Sentiment</span> | |
| </div> | |
| <div style={{fontSize:'1.25rem',fontWeight:700,textTransform:'capitalize',color: MOOD_COLORS[tickerResult.sentiment]}}>{tickerResult.sentiment}</div> | |
| </div> | |
| <div className="card" style={{padding:'1rem 1.25rem'}}> | |
| <div style={{display:'flex',alignItems:'center',gap:'0.5rem',marginBottom:'0.25rem'}}> | |
| <Activity size={14} color="var(--accent)"/> | |
| <span style={{fontSize:'0.68rem',fontWeight:600,color:'var(--text-muted)',textTransform:'uppercase',letterSpacing:'0.05em'}}>Score</span> | |
| </div> | |
| <div style={{fontSize:'1.25rem',fontWeight:700,fontFamily:'var(--font-mono)'}}>{tickerResult.avg_score?.toFixed(3)}</div> | |
| </div> | |
| <div className="card" style={{padding:'1rem 1.25rem'}}> | |
| <div style={{display:'flex',alignItems:'center',gap:'0.5rem',marginBottom:'0.25rem'}}> | |
| <Newspaper size={14} color="var(--blue-info)"/> | |
| <span style={{fontSize:'0.68rem',fontWeight:600,color:'var(--text-muted)',textTransform:'uppercase',letterSpacing:'0.05em'}}>Headlines</span> | |
| </div> | |
| <div style={{fontSize:'1.25rem',fontWeight:700,fontFamily:'var(--font-mono)'}}>{tickerResult.headline_count}</div> | |
| </div> | |
| <div className="card" style={{padding:'1rem 1.25rem'}}> | |
| <div style={{display:'flex',alignItems:'center',gap:'0.5rem',marginBottom:'0.25rem'}}> | |
| <BarChart3 size={14} color="var(--text-secondary)"/> | |
| <span style={{fontSize:'0.68rem',fontWeight:600,color:'var(--text-muted)',textTransform:'uppercase',letterSpacing:'0.05em'}}>Distribution</span> | |
| </div> | |
| <div style={{display:'flex',gap:'0.5rem',fontSize:'0.85rem',fontWeight:600,fontFamily:'var(--font-mono)'}}> | |
| <span style={{ color:'#0a8f5c' }}>{tickerResult.bullish}</span> | |
| <span style={{ color:'var(--text-muted)' }}>/</span> | |
| <span style={{ color:'#b8860b' }}>{tickerResult.neutral}</span> | |
| <span style={{ color:'var(--text-muted)' }}>/</span> | |
| <span style={{ color:'#c23030' }}>{tickerResult.bearish}</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="grid-2"> | |
| {/* Pie Chart */} | |
| <div className="card"> | |
| <div className="card-header"><h3 style={{display:'flex',alignItems:'center',gap:'0.5rem'}}><BarChart3 size={15}/> Sentiment Distribution</h3></div> | |
| <div style={{ width: '100%', height: 260 }}> | |
| <ResponsiveContainer> | |
| <PieChart> | |
| <Pie | |
| data={[ | |
| { name: 'Bullish', value: tickerResult.bullish, fill: '#0a8f5c' }, | |
| { name: 'Neutral', value: tickerResult.neutral, fill: '#b8860b' }, | |
| { name: 'Bearish', value: tickerResult.bearish, fill: '#c23030' }, | |
| ]} | |
| cx="50%" cy="50%" innerRadius={50} outerRadius={85} dataKey="value" | |
| label={({ name, percent }) => `${name} ${((percent ?? 0) * 100).toFixed(0)}%`} | |
| labelLine={{ stroke: 'var(--chart-axis)', strokeWidth: 1 }} | |
| /> | |
| <Tooltip contentStyle={{ background:'var(--bg-card)', border:'1px solid var(--border-color)', borderRadius:8, fontSize:'0.8rem' }} /> | |
| </PieChart> | |
| </ResponsiveContainer> | |
| </div> | |
| </div> | |
| {/* Headlines */} | |
| <div className="card"> | |
| <div className="card-header"><h3 style={{display:'flex',alignItems:'center',gap:'0.5rem'}}><Newspaper size={15}/> Recent Headlines</h3></div> | |
| <div style={{ maxHeight: 320, overflowY: 'auto' }}> | |
| {tickerResult.headlines?.map((h: any, i: number) => ( | |
| <div key={i} style={{ padding:'0.625rem 0', borderBottom:'1px solid var(--border-subtle)', display:'flex', gap:'0.75rem', alignItems:'flex-start' }}> | |
| <div style={{width:3,height:'100%',minHeight:36,borderRadius:2,background: MOOD_COLORS[h.sentiment] || '#6b7280',flexShrink:0,marginTop:'0.15rem'}} /> | |
| <div style={{ flex: 1 }}> | |
| {h.url ? ( | |
| <a href={h.url} target="_blank" rel="noopener noreferrer" style={{ fontSize:'0.8rem', color:'var(--text-primary)', textDecoration:'none', fontWeight:500, lineHeight:1.4 }}> | |
| {h.title} | |
| </a> | |
| ) : ( | |
| <span style={{ fontSize:'0.8rem', fontWeight:500 }}>{h.title}</span> | |
| )} | |
| <div style={{ fontSize:'0.68rem', color:'var(--text-muted)', marginTop:'0.2rem', display:'flex', gap:'0.5rem', alignItems:'center' }}> | |
| <span>{h.source}</span> | |
| <span style={{width:3,height:3,borderRadius:'50%',background:'var(--text-muted)',display:'inline-block'}}/> | |
| <span style={{fontFamily:'var(--font-mono)'}}>{h.score}</span> | |
| </div> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| </> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |