643 lines
30 KiB
JavaScript
643 lines
30 KiB
JavaScript
import { useState, useEffect } from 'react';
|
||
|
||
const API_BASE = import.meta.env.VITE_API_URL || '';
|
||
|
||
function fmt(n, decimals = 2, prefix = '') {
|
||
if (n == null || isNaN(n)) return '—';
|
||
return `${prefix}${n.toFixed(decimals)}`;
|
||
}
|
||
|
||
function fmtPct(n) {
|
||
if (n == null) return '—';
|
||
const val = (n * 100).toFixed(1);
|
||
return `${val}%`;
|
||
}
|
||
|
||
function fmtLarge(n) {
|
||
if (n == null) return '—';
|
||
if (Math.abs(n) >= 1e12) return `$${(n / 1e12).toFixed(2)}T`;
|
||
if (Math.abs(n) >= 1e9) return `$${(n / 1e9).toFixed(2)}B`;
|
||
if (Math.abs(n) >= 1e6) return `$${(n / 1e6).toFixed(2)}M`;
|
||
if (Math.abs(n) >= 1e3) return `$${(n / 1e3).toFixed(1)}K`;
|
||
return `$${n.toFixed(0)}`;
|
||
}
|
||
|
||
function MetricRow({ label, value, highlight }) {
|
||
return (
|
||
<tr className="border-b border-slate-800/50 hover:bg-slate-800/20 transition-colors">
|
||
<td className="py-2 px-3 text-slate-400 text-sm">{label}</td>
|
||
<td className={`py-2 px-3 text-sm font-medium text-right ${
|
||
highlight === 'positive' ? 'text-emerald-400' :
|
||
highlight === 'negative' ? 'text-red-400' :
|
||
'text-white'
|
||
}`}>{value}</td>
|
||
</tr>
|
||
);
|
||
}
|
||
|
||
function SentimentBadge({ sentiment }) {
|
||
const styles = {
|
||
BULLISH: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
|
||
BEARISH: 'bg-red-500/20 text-red-400 border-red-500/30',
|
||
NEUTRAL: 'bg-slate-700/50 text-slate-400 border-slate-600',
|
||
};
|
||
const icons = { BULLISH: '▲', BEARISH: '▼', NEUTRAL: '●' };
|
||
return (
|
||
<span className={`text-xs px-2 py-0.5 rounded border font-medium ${styles[sentiment] || styles.NEUTRAL}`}>
|
||
{icons[sentiment]} {sentiment}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function DirectionArrow({ dir }) {
|
||
if (dir === 'INCREASED') return <span className="text-emerald-400 font-bold">▲</span>;
|
||
if (dir === 'DECREASED') return <span className="text-red-400 font-bold">▼</span>;
|
||
return <span className="text-slate-500">—</span>;
|
||
}
|
||
|
||
export default function StockDetailPanel({ symbol, onClose }) {
|
||
const [activeTab, setActiveTab] = useState('overview');
|
||
const [stockData, setStockData] = useState(null);
|
||
const [news, setNews] = useState([]);
|
||
const [holders, setHolders] = useState(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [newsLoading, setNewsLoading] = useState(false);
|
||
const [holdersLoading, setHoldersLoading] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (!symbol) return;
|
||
setLoading(true);
|
||
setStockData(null);
|
||
setNews([]);
|
||
setHolders(null);
|
||
|
||
fetch(`${API_BASE}/api/market/stock/${symbol}`)
|
||
.then(r => r.json())
|
||
.then(d => { if (d.success) setStockData(d.data); })
|
||
.catch(console.error)
|
||
.finally(() => setLoading(false));
|
||
}, [symbol]);
|
||
|
||
useEffect(() => {
|
||
if (!symbol || activeTab !== 'news') return;
|
||
if (news.length > 0) return;
|
||
setNewsLoading(true);
|
||
fetch(`${API_BASE}/api/market/stock/${symbol}/news`)
|
||
.then(r => r.json())
|
||
.then(d => { if (d.success) setNews(d.data || []); })
|
||
.catch(console.error)
|
||
.finally(() => setNewsLoading(false));
|
||
}, [symbol, activeTab]);
|
||
|
||
useEffect(() => {
|
||
if (!symbol || activeTab !== 'holders') return;
|
||
if (holders) return;
|
||
setHoldersLoading(true);
|
||
fetch(`${API_BASE}/api/market/stock/${symbol}/holders`)
|
||
.then(r => r.json())
|
||
.then(d => { if (d.success) setHolders(d.data); })
|
||
.catch(console.error)
|
||
.finally(() => setHoldersLoading(false));
|
||
}, [symbol, activeTab]);
|
||
|
||
if (!symbol) return null;
|
||
|
||
const q = stockData?.quote;
|
||
const f = stockData?.fundamentals;
|
||
const t = stockData?.technicals;
|
||
const meta = stockData?.meta;
|
||
|
||
const TABS = [
|
||
{ id: 'overview', label: '📋 Overview' },
|
||
{ id: 'fundamentals', label: '📊 Fundamentals' },
|
||
{ id: 'technicals', label: '📈 Technicals' },
|
||
{ id: 'holders', label: '🏛️ Institutional' },
|
||
{ id: 'news', label: '📰 News' },
|
||
];
|
||
|
||
return (
|
||
<div className="bg-slate-900 border border-slate-700/50 rounded-xl overflow-hidden">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-700/50 bg-slate-950/40">
|
||
<div className="flex items-center gap-4">
|
||
<div>
|
||
<div className="flex items-center gap-2">
|
||
<h2 className="text-xl font-bold text-white">{symbol}</h2>
|
||
{stockData?.capTier && (
|
||
<span className={`text-xs px-2 py-0.5 rounded border font-medium ${
|
||
stockData.capTier === 'LARGE' ? 'bg-blue-500/20 text-blue-400 border-blue-500/30' :
|
||
stockData.capTier === 'MID' ? 'bg-purple-500/20 text-purple-400 border-purple-500/30' :
|
||
stockData.capTier === 'ETF' ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' :
|
||
'bg-orange-500/20 text-orange-400 border-orange-500/30'
|
||
}`}>{stockData.capTier} CAP</span>
|
||
)}
|
||
</div>
|
||
<div className="text-slate-400 text-sm">{meta?.name} · {meta?.sector}</div>
|
||
</div>
|
||
{q && (
|
||
<div className="ml-4">
|
||
<div className="text-2xl font-bold text-white tabular-nums">
|
||
${q.price?.toFixed(2) ?? '—'}
|
||
</div>
|
||
<div className={`text-sm font-medium ${
|
||
(q.change_pct ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400'
|
||
}`}>
|
||
{q.change != null ? (q.change >= 0 ? '+' : '') + q.change.toFixed(2) : '—'}
|
||
{' '}({q.change_pct != null ? (q.change_pct >= 0 ? '+' : '') + q.change_pct.toFixed(2) + '%' : '—'})
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<button
|
||
onClick={onClose}
|
||
className="text-slate-400 hover:text-white transition-colors text-2xl font-light w-8 h-8 flex items-center justify-center rounded-lg hover:bg-slate-800"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
{/* Tabs */}
|
||
<div className="flex border-b border-slate-700/50 bg-slate-950/20">
|
||
{TABS.map(tab => (
|
||
<button
|
||
key={tab.id}
|
||
onClick={() => setActiveTab(tab.id)}
|
||
className={`px-4 py-2.5 text-sm font-medium transition-all ${
|
||
activeTab === tab.id
|
||
? 'border-b-2 border-blue-500 text-blue-400 bg-blue-500/5'
|
||
: 'text-slate-500 hover:text-slate-300'
|
||
}`}
|
||
>
|
||
{tab.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className="p-5 overflow-y-auto max-h-[calc(100vh-300px)]">
|
||
{loading ? (
|
||
<div className="space-y-3 animate-pulse">
|
||
{Array.from({ length: 8 }).map((_, i) => (
|
||
<div key={i} className="h-10 bg-slate-800 rounded" />
|
||
))}
|
||
</div>
|
||
) : (
|
||
<>
|
||
{activeTab === 'overview' && <OverviewTab q={q} f={f} t={t} />}
|
||
{activeTab === 'fundamentals' && <FundamentalsTab f={f} />}
|
||
{activeTab === 'technicals' && <TechnicalsTab t={t} q={q} />}
|
||
{activeTab === 'holders' && <HoldersTab data={holders} loading={holdersLoading} />}
|
||
{activeTab === 'news' && <NewsTab items={news} loading={newsLoading} />}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function OverviewTab({ q, f, t }) {
|
||
return (
|
||
<div className="grid grid-cols-2 gap-6">
|
||
<div>
|
||
<div className="text-xs uppercase tracking-widest text-slate-500 mb-3 font-semibold">Price Snapshot</div>
|
||
<table className="w-full">
|
||
<tbody>
|
||
<MetricRow label="Open" value={q?.open != null ? `$${q.open.toFixed(2)}` : '—'} />
|
||
<MetricRow label="Day High" value={q?.high != null ? `$${q.high.toFixed(2)}` : '—'} />
|
||
<MetricRow label="Day Low" value={q?.low != null ? `$${q.low.toFixed(2)}` : '—'} />
|
||
<MetricRow label="Prev Close" value={q?.prev_close != null ? `$${q.prev_close.toFixed(2)}` : '—'} />
|
||
<MetricRow label="52W High" value={f?.week52_high != null ? `$${f.week52_high.toFixed(2)}` : '—'} />
|
||
<MetricRow label="52W Low" value={f?.week52_low != null ? `$${f.week52_low.toFixed(2)}` : '—'} />
|
||
<MetricRow label="Volume" value={q?.volume != null ? q.volume.toLocaleString() : '—'} />
|
||
<MetricRow label="Avg Vol (3M)" value={f?.avg_volume_3m != null ? f.avg_volume_3m.toLocaleString() : '—'} />
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div>
|
||
<div className="text-xs uppercase tracking-widest text-slate-500 mb-3 font-semibold">Key Metrics</div>
|
||
<table className="w-full">
|
||
<tbody>
|
||
<MetricRow label="P/E (TTM)" value={fmt(f?.pe_ttm)} />
|
||
<MetricRow label="P/E (Fwd)" value={fmt(f?.pe_forward)} />
|
||
<MetricRow label="EPS (TTM)" value={f?.eps_ttm != null ? `$${f.eps_ttm.toFixed(2)}` : '—'} />
|
||
<MetricRow label="Beta" value={fmt(f?.beta)} />
|
||
<MetricRow label="Short % Float" value={fmtPct(f?.short_pct_float)} highlight={f?.short_pct_float > 0.15 ? 'negative' : undefined} />
|
||
<MetricRow label="Inst. Ownership" value={fmtPct(f?.held_pct_institutions)} />
|
||
<MetricRow label="Analyst Target" value={f?.target_mean != null ? `$${f.target_mean.toFixed(2)}` : '—'} />
|
||
<MetricRow label="Recommendation" value={f?.recommendation?.toUpperCase() ?? '—'}
|
||
highlight={f?.recommendation === 'buy' || f?.recommendation === 'strongBuy' ? 'positive' :
|
||
f?.recommendation === 'sell' || f?.recommendation === 'strongSell' ? 'negative' : undefined}
|
||
/>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function FundamentalsTab({ f }) {
|
||
if (!f) return <div className="text-slate-500 text-center py-8">No fundamental data available</div>;
|
||
const sections = [
|
||
{
|
||
title: '💰 Valuation',
|
||
metrics: [
|
||
{ label: 'P/E Ratio (TTM)', value: fmt(f.pe_ttm, 1) },
|
||
{ label: 'P/E Ratio (Fwd)', value: fmt(f.pe_forward, 1) },
|
||
{ label: 'Price / Book', value: fmt(f.price_to_book, 2) },
|
||
{ label: 'Price / Sales', value: fmt(f.price_to_sales, 2) },
|
||
{ label: 'EV / EBITDA', value: fmt(f.ev_ebitda, 1) },
|
||
{ label: 'Market Cap', value: fmtLarge(f.market_cap) },
|
||
]
|
||
},
|
||
{
|
||
title: '📈 Growth',
|
||
metrics: [
|
||
{ label: 'EPS (TTM)', value: f.eps_ttm != null ? `$${f.eps_ttm.toFixed(2)}` : '—' },
|
||
{ label: 'EPS (Forward)', value: f.eps_forward != null ? `$${f.eps_forward.toFixed(2)}` : '—' },
|
||
{ label: 'Earnings Growth', value: fmtPct(f.earnings_growth), h: f.earnings_growth > 0 ? 'positive' : f.earnings_growth < 0 ? 'negative' : undefined },
|
||
{ label: 'Revenue Growth', value: fmtPct(f.revenue_growth), h: f.revenue_growth > 0 ? 'positive' : f.revenue_growth < 0 ? 'negative' : undefined },
|
||
{ label: 'Total Revenue', value: fmtLarge(f.total_revenue) },
|
||
{ label: 'Free Cash Flow', value: fmtLarge(f.free_cash_flow), h: f.free_cash_flow > 0 ? 'positive' : f.free_cash_flow < 0 ? 'negative' : undefined },
|
||
]
|
||
},
|
||
{
|
||
title: '📊 Margins',
|
||
metrics: [
|
||
{ label: 'Gross Margin', value: fmtPct(f.gross_margins) },
|
||
{ label: 'EBITDA Margin', value: fmtPct(f.ebitda_margins) },
|
||
{ label: 'Operating Margin', value: fmtPct(f.operating_margins) },
|
||
{ label: 'Profit Margin', value: fmtPct(f.profit_margins) },
|
||
{ label: 'Return on Equity', value: fmtPct(f.return_on_equity), h: f.return_on_equity > 0.15 ? 'positive' : f.return_on_equity < 0 ? 'negative' : undefined },
|
||
{ label: 'Return on Assets', value: fmtPct(f.return_on_assets) },
|
||
]
|
||
},
|
||
{
|
||
title: '🏦 Balance Sheet',
|
||
metrics: [
|
||
{ label: 'Debt / Equity', value: fmt(f.debt_to_equity, 2), h: f.debt_to_equity > 2 ? 'negative' : f.debt_to_equity < 0.5 ? 'positive' : undefined },
|
||
{ label: 'Current Ratio', value: fmt(f.current_ratio, 2), h: f.current_ratio > 2 ? 'positive' : f.current_ratio < 1 ? 'negative' : undefined },
|
||
{ label: 'Quick Ratio', value: fmt(f.quick_ratio, 2) },
|
||
{ label: 'Total Debt', value: fmtLarge(f.total_debt) },
|
||
{ label: 'Book Value / Share', value: f.book_value != null ? `$${f.book_value.toFixed(2)}` : '—' },
|
||
{ label: 'Float Shares', value: f.float_shares != null ? (f.float_shares / 1e6).toFixed(1) + 'M' : '—' },
|
||
]
|
||
},
|
||
{
|
||
title: '🎯 Analyst Consensus',
|
||
metrics: [
|
||
{ label: 'Recommendation', value: f.recommendation?.toUpperCase() ?? '—', h: f.recommendation?.includes('buy') ? 'positive' : f.recommendation?.includes('sell') ? 'negative' : undefined },
|
||
{ label: 'Target (Mean)', value: f.target_mean != null ? `$${f.target_mean.toFixed(2)}` : '—' },
|
||
{ label: 'Target (High)', value: f.target_high != null ? `$${f.target_high.toFixed(2)}` : '—' },
|
||
{ label: 'Target (Low)', value: f.target_low != null ? `$${f.target_low.toFixed(2)}` : '—' },
|
||
{ label: '# Analysts', value: f.analyst_count ?? '—' },
|
||
{ label: 'Strong Buy / Buy', value: `${f.rec_strong_buy ?? 0} / ${f.rec_buy ?? 0}`, h: 'positive' },
|
||
{ label: 'Hold', value: `${f.rec_hold ?? 0}` },
|
||
{ label: 'Sell / Strong Sell', value: `${f.rec_sell ?? 0} / ${f.rec_strong_sell ?? 0}`, h: (f.rec_sell + f.rec_strong_sell) > 0 ? 'negative' : undefined },
|
||
]
|
||
},
|
||
{
|
||
title: '📉 Short Interest',
|
||
metrics: [
|
||
{ label: 'Short % of Float', value: fmtPct(f.short_pct_float), h: f.short_pct_float > 0.20 ? 'negative' : f.short_pct_float < 0.05 ? 'positive' : undefined },
|
||
{ label: 'Shares Short', value: f.shares_short != null ? (f.shares_short / 1e6).toFixed(1) + 'M' : '—' },
|
||
{ label: 'Inst. Ownership', value: fmtPct(f.held_pct_institutions) },
|
||
{ label: 'Insider Ownership', value: fmtPct(f.held_pct_insiders) },
|
||
{ label: 'Dividend Yield', value: fmtPct(f.dividend_yield), h: f.dividend_yield > 0 ? 'positive' : undefined },
|
||
{ label: 'Payout Ratio', value: fmtPct(f.payout_ratio) },
|
||
]
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div className="grid grid-cols-2 gap-x-6 gap-y-6">
|
||
{sections.map(section => (
|
||
<div key={section.title}>
|
||
<div className="text-xs uppercase tracking-widest text-slate-500 mb-3 font-semibold">{section.title}</div>
|
||
<table className="w-full">
|
||
<tbody>
|
||
{section.metrics.map(m => (
|
||
<MetricRow key={m.label} label={m.label} value={m.value} highlight={m.h} />
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TechnicalsTab({ t, q }) {
|
||
if (!t) return <div className="text-slate-500 text-center py-8">Insufficient price history for technical calculations</div>;
|
||
|
||
function rsiColor(v) {
|
||
if (v == null) return 'text-white';
|
||
if (v >= 70) return 'text-red-400';
|
||
if (v <= 30) return 'text-emerald-400';
|
||
return 'text-white';
|
||
}
|
||
function rsiLabel(v) {
|
||
if (v == null) return '';
|
||
if (v >= 70) return ' (Overbought)';
|
||
if (v <= 30) return ' (Oversold)';
|
||
return ' (Neutral)';
|
||
}
|
||
|
||
return (
|
||
<div className="grid grid-cols-2 gap-6">
|
||
<div>
|
||
<div className="text-xs uppercase tracking-widest text-slate-500 mb-3 font-semibold">Momentum Indicators</div>
|
||
<table className="w-full">
|
||
<tbody>
|
||
<tr className="border-b border-slate-800/50">
|
||
<td className="py-2 px-3 text-slate-400 text-sm">RSI (14)</td>
|
||
<td className={`py-2 px-3 text-sm font-bold text-right ${rsiColor(t.rsi_14)}`}>
|
||
{t.rsi_14 != null ? `${t.rsi_14.toFixed(1)}${rsiLabel(t.rsi_14)}` : '—'}
|
||
</td>
|
||
</tr>
|
||
<tr className="border-b border-slate-800/50">
|
||
<td className="py-2 px-3 text-slate-400 text-sm">MACD Line</td>
|
||
<td className={`py-2 px-3 text-sm font-medium text-right ${t.macd_line >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
||
{t.macd_line != null ? t.macd_line.toFixed(4) : '—'}
|
||
</td>
|
||
</tr>
|
||
<tr className="border-b border-slate-800/50">
|
||
<td className="py-2 px-3 text-slate-400 text-sm">MACD Signal</td>
|
||
<td className="py-2 px-3 text-sm font-medium text-right text-slate-300">
|
||
{t.macd_signal != null ? t.macd_signal.toFixed(4) : '—'}
|
||
</td>
|
||
</tr>
|
||
<tr className="border-b border-slate-800/50">
|
||
<td className="py-2 px-3 text-slate-400 text-sm">MACD Histogram</td>
|
||
<td className={`py-2 px-3 text-sm font-medium text-right ${t.macd_histogram >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
||
{t.macd_histogram != null ? t.macd_histogram.toFixed(4) : '—'}
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div>
|
||
<div className="text-xs uppercase tracking-widest text-slate-500 mb-3 font-semibold">Moving Averages</div>
|
||
<table className="w-full">
|
||
<tbody>
|
||
<tr className="border-b border-slate-800/50">
|
||
<td className="py-2 px-3 text-slate-400 text-sm">SMA 50</td>
|
||
<td className="py-2 px-3 text-sm text-right">
|
||
<span className="text-white">{t.sma_50 != null ? `$${t.sma_50.toFixed(2)}` : '—'}</span>
|
||
{t.above_sma50 != null && (
|
||
<span className={`ml-2 text-xs ${t.above_sma50 ? 'text-emerald-400' : 'text-red-400'}`}>
|
||
{t.above_sma50 ? '▲ Above' : '▼ Below'}
|
||
</span>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
<tr className="border-b border-slate-800/50">
|
||
<td className="py-2 px-3 text-slate-400 text-sm">SMA 200</td>
|
||
<td className="py-2 px-3 text-sm text-right">
|
||
<span className="text-white">{t.sma_200 != null ? `$${t.sma_200.toFixed(2)}` : '—'}</span>
|
||
{t.above_sma200 != null && (
|
||
<span className={`ml-2 text-xs ${t.above_sma200 ? 'text-emerald-400' : 'text-red-400'}`}>
|
||
{t.above_sma200 ? '▲ Above' : '▼ Below'}
|
||
</span>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
<tr className="border-b border-slate-800/50">
|
||
<td className="py-2 px-3 text-slate-400 text-sm">% from SMA 50</td>
|
||
<td className={`py-2 px-3 text-sm font-medium text-right ${t.pct_from_sma50 >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
||
{t.pct_from_sma50 != null ? `${t.pct_from_sma50.toFixed(2)}%` : '—'}
|
||
</td>
|
||
</tr>
|
||
<tr className="border-b border-slate-800/50">
|
||
<td className="py-2 px-3 text-slate-400 text-sm">% from SMA 200</td>
|
||
<td className={`py-2 px-3 text-sm font-medium text-right ${t.pct_from_sma200 >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
||
{t.pct_from_sma200 != null ? `${t.pct_from_sma200.toFixed(2)}%` : '—'}
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<div className="mt-4 p-3 rounded-lg bg-slate-800/50 border border-slate-700/30">
|
||
<div className="text-xs text-slate-500 mb-1 font-semibold uppercase tracking-wide">Signal Summary</div>
|
||
<div className="flex flex-wrap gap-2 mt-1">
|
||
{t.rsi_14 != null && (
|
||
<span className={`text-xs px-2 py-1 rounded border ${
|
||
t.rsi_14 >= 70 ? 'bg-red-500/20 text-red-400 border-red-500/30' :
|
||
t.rsi_14 <= 30 ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' :
|
||
'bg-slate-700 text-slate-400 border-slate-600'
|
||
}`}>
|
||
RSI {t.rsi_14 >= 70 ? '🔴 OS' : t.rsi_14 <= 30 ? '🟢 OB' : '⚪ Neutral'}
|
||
</span>
|
||
)}
|
||
{t.macd_histogram != null && (
|
||
<span className={`text-xs px-2 py-1 rounded border ${
|
||
t.macd_histogram > 0 ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' :
|
||
'bg-red-500/20 text-red-400 border-red-500/30'
|
||
}`}>
|
||
MACD {t.macd_histogram > 0 ? '🟢 Bull' : '🔴 Bear'}
|
||
</span>
|
||
)}
|
||
{t.above_sma50 != null && (
|
||
<span className={`text-xs px-2 py-1 rounded border ${
|
||
t.above_sma50 ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' :
|
||
'bg-red-500/20 text-red-400 border-red-500/30'
|
||
}`}>
|
||
{t.above_sma50 ? '🟢 Above SMA50' : '🔴 Below SMA50'}
|
||
</span>
|
||
)}
|
||
{t.above_sma200 != null && (
|
||
<span className={`text-xs px-2 py-1 rounded border ${
|
||
t.above_sma200 ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' :
|
||
'bg-red-500/20 text-red-400 border-red-500/30'
|
||
}`}>
|
||
{t.above_sma200 ? '🟢 Above SMA200' : '🔴 Below SMA200'}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function HoldersTab({ data, loading }) {
|
||
if (loading) return <div className="space-y-3 animate-pulse">{Array.from({ length: 5 }).map((_, i) => <div key={i} className="h-10 bg-slate-800 rounded" />)}</div>;
|
||
if (!data) return <div className="text-slate-500 text-center py-8">Loading holder data...</div>;
|
||
|
||
const inst = data.institutional;
|
||
const ins = data.insiders;
|
||
|
||
function fmtLargeLocal(n) {
|
||
if (n == null) return '—';
|
||
if (Math.abs(n) >= 1e9) return `$${(n / 1e9).toFixed(2)}B`;
|
||
if (Math.abs(n) >= 1e6) return `$${(n / 1e6).toFixed(2)}M`;
|
||
if (Math.abs(n) >= 1e3) return `$${(n / 1e3).toFixed(1)}K`;
|
||
return `$${n.toFixed(0)}`;
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Institutional summary */}
|
||
{inst?.summary && (
|
||
<div className="grid grid-cols-4 gap-3">
|
||
{[
|
||
{ label: 'Inst. Ownership', value: inst.summary.pct_held_institutions != null ? `${(inst.summary.pct_held_institutions * 100).toFixed(1)}%` : '—' },
|
||
{ label: 'Float Inst. %', value: inst.summary.pct_float_institutions != null ? `${(inst.summary.pct_float_institutions * 100).toFixed(1)}%` : '—' },
|
||
{ label: 'Insider %', value: inst.summary.pct_held_insiders != null ? `${(inst.summary.pct_held_insiders * 100).toFixed(1)}%` : '—' },
|
||
{ label: 'Inst. Count', value: inst.summary.institution_count?.toLocaleString() ?? '—' },
|
||
].map(m => (
|
||
<div key={m.label} className="bg-slate-800/50 rounded-lg p-3 border border-slate-700/30">
|
||
<div className="text-xs text-slate-500">{m.label}</div>
|
||
<div className="text-lg font-bold text-white mt-1">{m.value}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Top institutional holders */}
|
||
{inst?.holders && inst.holders.length > 0 && (
|
||
<div>
|
||
<div className="text-xs uppercase tracking-widest text-slate-500 mb-3 font-semibold">🏛️ Top Institutional Holders</div>
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="text-slate-500 text-xs bg-slate-950/30">
|
||
<th className="text-left px-3 py-2">Institution</th>
|
||
<th className="text-right px-3 py-2">% Held</th>
|
||
<th className="text-right px-3 py-2">Shares</th>
|
||
<th className="text-right px-3 py-2">Value</th>
|
||
<th className="text-center px-3 py-2">QoQ Change</th>
|
||
<th className="text-right px-3 py-2">Reported</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-800/50">
|
||
{inst.holders.slice(0, 10).map((h, i) => (
|
||
<tr key={i} className="hover:bg-slate-800/20">
|
||
<td className="px-3 py-2.5 text-white font-medium">{h.name}</td>
|
||
<td className="px-3 py-2.5 text-right text-slate-300 tabular-nums">
|
||
{h.pct_held != null ? `${(h.pct_held * 100).toFixed(2)}%` : '—'}
|
||
</td>
|
||
<td className="px-3 py-2.5 text-right text-slate-400 tabular-nums">
|
||
{h.shares != null ? h.shares.toLocaleString() : '—'}
|
||
</td>
|
||
<td className="px-3 py-2.5 text-right text-slate-400 tabular-nums">
|
||
{h.value != null ? fmtLargeLocal(h.value) : '—'}
|
||
</td>
|
||
<td className="px-3 py-2.5 text-center">
|
||
<DirectionArrow dir={h.direction} />
|
||
{h.shares_change != null && (
|
||
<span className={`ml-1 text-xs ${h.shares_change > 0 ? 'text-emerald-400' : h.shares_change < 0 ? 'text-red-400' : 'text-slate-500'}`}>
|
||
{h.shares_change > 0 ? '+' : ''}{h.shares_change?.toLocaleString()}
|
||
</span>
|
||
)}
|
||
</td>
|
||
<td className="px-3 py-2.5 text-right text-slate-500 text-xs">{h.date_reported || '—'}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Insider activity */}
|
||
{ins && (
|
||
<div>
|
||
<div className="flex items-center gap-3 mb-3">
|
||
<div className="text-xs uppercase tracking-widest text-slate-500 font-semibold">👤 Insider Activity (90d)</div>
|
||
{ins.insider_summary && (
|
||
<span className={`text-xs px-2 py-0.5 rounded border font-medium ${
|
||
ins.insider_summary.net_sentiment === 'BULLISH' ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' :
|
||
ins.insider_summary.net_sentiment === 'BEARISH' ? 'bg-red-500/20 text-red-400 border-red-500/30' :
|
||
'bg-slate-700 text-slate-400 border-slate-600'
|
||
}`}>
|
||
{ins.insider_summary.net_sentiment} · {ins.insider_summary.buys_90d}B / {ins.insider_summary.sells_90d}S
|
||
</span>
|
||
)}
|
||
</div>
|
||
{ins.transactions && ins.transactions.length > 0 ? (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="text-slate-500 text-xs bg-slate-950/30">
|
||
<th className="text-left px-3 py-2">Insider</th>
|
||
<th className="text-left px-3 py-2">Title</th>
|
||
<th className="text-center px-3 py-2">Type</th>
|
||
<th className="text-right px-3 py-2">Shares</th>
|
||
<th className="text-right px-3 py-2">Value</th>
|
||
<th className="text-right px-3 py-2">Date</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-800/50">
|
||
{ins.transactions.slice(0, 15).map((tx, i) => (
|
||
<tr key={i} className="hover:bg-slate-800/20">
|
||
<td className="px-3 py-2 text-white font-medium">{tx.insider_name}</td>
|
||
<td className="px-3 py-2 text-slate-400 text-xs">{tx.title}</td>
|
||
<td className="px-3 py-2 text-center">
|
||
<span className={`text-xs px-2 py-0.5 rounded font-medium ${
|
||
tx.direction === 'BUY' ? 'bg-emerald-500/20 text-emerald-400' :
|
||
tx.direction === 'SELL' ? 'bg-red-500/20 text-red-400' :
|
||
'bg-slate-700 text-slate-400'
|
||
}`}>{tx.direction}</span>
|
||
</td>
|
||
<td className="px-3 py-2 text-right text-slate-300 tabular-nums">
|
||
{tx.shares != null ? tx.shares.toLocaleString() : '—'}
|
||
</td>
|
||
<td className="px-3 py-2 text-right text-slate-400 tabular-nums">
|
||
{tx.value != null ? fmtLargeLocal(tx.value) : '—'}
|
||
</td>
|
||
<td className="px-3 py-2 text-right text-slate-500 text-xs">{tx.start_date || '—'}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
) : (
|
||
<div className="text-slate-500 text-sm py-4 text-center">No recent insider transactions</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function NewsTab({ items, loading }) {
|
||
if (loading) return <div className="space-y-3 animate-pulse">{Array.from({ length: 6 }).map((_, i) => <div key={i} className="h-16 bg-slate-800 rounded" />)}</div>;
|
||
if (!items || items.length === 0) return <div className="text-slate-500 text-center py-8">No financial news found for this symbol</div>;
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
{items.map((item, i) => (
|
||
<a
|
||
key={i}
|
||
href={item.url}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="block p-3 rounded-lg bg-slate-800/40 border border-slate-700/30 hover:bg-slate-800/70 hover:border-slate-600/50 transition-all group"
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="flex-1 min-w-0">
|
||
<div className="text-white text-sm font-medium group-hover:text-blue-400 transition-colors line-clamp-2">
|
||
{item.title}
|
||
</div>
|
||
{item.snippet && (
|
||
<div className="text-slate-500 text-xs mt-1 line-clamp-1">{item.snippet}</div>
|
||
)}
|
||
<div className="flex items-center gap-2 mt-1.5">
|
||
<span className="text-xs text-slate-600">{item.source}</span>
|
||
{item.publishedAt && (
|
||
<>
|
||
<span className="text-xs text-slate-700">·</span>
|
||
<span className="text-xs text-slate-600">
|
||
{new Date(item.publishedAt).toLocaleDateString()}
|
||
</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<SentimentBadge sentiment={item.sentiment} />
|
||
</div>
|
||
</a>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|