428 lines
17 KiB
JavaScript
428 lines
17 KiB
JavaScript
import { useState, useEffect, useCallback, useRef } from 'react';
|
||
|
||
const API_BASE = import.meta.env.VITE_API_URL || '';
|
||
|
||
// ─── Formatters ───────────────────────────────────────────────────────────────
|
||
function fmtPrice(n) {
|
||
if (n == null) return '—';
|
||
return `$${n.toFixed(2)}`;
|
||
}
|
||
function fmtChange(n) {
|
||
if (n == null) return null;
|
||
return { value: n, label: `${n >= 0 ? '+' : ''}${n.toFixed(2)}%`, positive: n >= 0 };
|
||
}
|
||
function fmtMktCap(n) {
|
||
if (n == null) return '—';
|
||
if (n >= 1e12) return `$${(n / 1e12).toFixed(1)}T`;
|
||
if (n >= 1e9) return `$${(n / 1e9).toFixed(1)}B`;
|
||
if (n >= 1e6) return `$${(n / 1e6).toFixed(1)}M`;
|
||
return `$${n.toFixed(0)}`;
|
||
}
|
||
|
||
// ─── Score ring component ─────────────────────────────────────────────────────
|
||
function ScoreRing({ score, color }) {
|
||
const r = 20;
|
||
const circ = 2 * Math.PI * r;
|
||
const fill = (score / 100) * circ;
|
||
const colors = {
|
||
emerald: '#10b981',
|
||
blue: '#3b82f6',
|
||
yellow: '#eab308',
|
||
orange: '#f97316',
|
||
red: '#ef4444',
|
||
};
|
||
const stroke = colors[color] || colors.blue;
|
||
|
||
return (
|
||
<div className="relative w-14 h-14 flex-shrink-0">
|
||
<svg viewBox="0 0 50 50" className="w-full h-full -rotate-90">
|
||
<circle cx="25" cy="25" r={r} fill="none" stroke="#1e293b" strokeWidth="5" />
|
||
<circle
|
||
cx="25" cy="25" r={r}
|
||
fill="none"
|
||
stroke={stroke}
|
||
strokeWidth="5"
|
||
strokeDasharray={`${fill} ${circ}`}
|
||
strokeLinecap="round"
|
||
style={{ transition: 'stroke-dasharray 0.6s ease' }}
|
||
/>
|
||
</svg>
|
||
<div className="absolute inset-0 flex items-center justify-center">
|
||
<span className="text-xs font-bold text-white tabular-nums">{score}</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Swimlane card ────────────────────────────────────────────────────────────
|
||
function StockCard({ item, lane, onSelect }) {
|
||
const s = item.suitability?.[lane] || { score: 0, reasons: [], grade: { label: 'N/A', color: 'red' } };
|
||
const change = fmtChange(item.change_pct);
|
||
|
||
const laneColors = {
|
||
daytrade: { accent: 'border-orange-500/30', badge: 'bg-orange-500/10 text-orange-400' },
|
||
shortTerm: { accent: 'border-blue-500/30', badge: 'bg-blue-500/10 text-blue-400' },
|
||
longTerm: { accent: 'border-emerald-500/30',badge: 'bg-emerald-500/10 text-emerald-400' },
|
||
};
|
||
const lc = laneColors[lane];
|
||
|
||
return (
|
||
<div
|
||
onClick={() => onSelect?.(item.symbol)}
|
||
className={`group bg-slate-900 border ${lc.accent} rounded-xl p-3.5 cursor-pointer
|
||
hover:bg-slate-800/80 hover:border-opacity-70 transition-all duration-150
|
||
hover:shadow-lg hover:shadow-black/20 active:scale-[0.99]`}
|
||
>
|
||
{/* Top row: symbol + score */}
|
||
<div className="flex items-start justify-between gap-2 mb-3">
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-bold text-white text-base group-hover:text-blue-400 transition-colors">
|
||
{item.symbol}
|
||
</span>
|
||
{item.capTier && (
|
||
<span className={`text-[10px] px-1.5 py-0.5 rounded font-semibold ${lc.badge}`}>
|
||
{item.capTier}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="text-slate-500 text-xs truncate mt-0.5" title={item.name}>
|
||
{item.name}
|
||
</div>
|
||
</div>
|
||
<ScoreRing score={s.score} color={s.grade.color} />
|
||
</div>
|
||
|
||
{/* Price + change */}
|
||
<div className="flex items-center justify-between mb-3">
|
||
<span className="text-white font-semibold tabular-nums">{fmtPrice(item.price)}</span>
|
||
{change && (
|
||
<span className={`text-sm font-semibold tabular-nums ${change.positive ? 'text-emerald-400' : 'text-red-400'}`}>
|
||
{change.label}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Signals / reasons */}
|
||
{s.reasons.length > 0 && (
|
||
<div className="space-y-1">
|
||
{s.reasons.map((r, i) => (
|
||
<div key={i} className="text-[11px] text-slate-400 flex items-center gap-1.5">
|
||
<span className="text-slate-600">•</span>
|
||
{r}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Footer: sector + grade badge */}
|
||
<div className="flex items-center justify-between mt-3 pt-2.5 border-t border-slate-800/60">
|
||
<span className="text-[10px] text-slate-600 bg-slate-800/60 px-1.5 py-0.5 rounded">
|
||
{item.sector}
|
||
</span>
|
||
<span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded ${lc.badge}`}>
|
||
{s.grade.label}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Swimlane column ──────────────────────────────────────────────────────────
|
||
function Swimlane({ title, icon, description, items = [], lane, onSelect, loading, accentClass }) {
|
||
return (
|
||
<div className={`flex flex-col bg-slate-950/40 border ${accentClass} rounded-2xl overflow-hidden`}>
|
||
{/* Lane header */}
|
||
<div className={`px-4 py-3.5 border-b ${accentClass} bg-gradient-to-r from-slate-900 to-slate-950/50`}>
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<span className="text-xl">{icon}</span>
|
||
<h3 className="font-bold text-white text-base">{title}</h3>
|
||
{!loading && items.length > 0 && (
|
||
<span className="ml-auto text-xs text-slate-500">{items.length} stocks</span>
|
||
)}
|
||
</div>
|
||
<p className="text-xs text-slate-500">{description}</p>
|
||
</div>
|
||
|
||
{/* Cards */}
|
||
<div className="flex-1 overflow-y-auto p-3 space-y-2.5 max-h-[calc(100vh-320px)]">
|
||
{loading ? (
|
||
Array.from({ length: 6 }).map((_, i) => (
|
||
<div key={i} className="animate-pulse bg-slate-800/60 rounded-xl h-[130px]" />
|
||
))
|
||
) : items.length === 0 ? (
|
||
<div className="text-center text-slate-600 py-12 text-sm">No data available</div>
|
||
) : (
|
||
items.map(item => (
|
||
<StockCard key={item.symbol} item={item} lane={lane} onSelect={onSelect} />
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Search bar ───────────────────────────────────────────────────────────────
|
||
function SearchBar({ onResult, onClear }) {
|
||
const [query, setQuery] = useState('');
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState(null);
|
||
const inputRef = useRef(null);
|
||
|
||
const handleSearch = async () => {
|
||
const sym = query.trim().toUpperCase();
|
||
if (!sym) return;
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/market/search/${sym}`);
|
||
const data = await res.json();
|
||
if (data.success) {
|
||
onResult(sym, data.data);
|
||
} else {
|
||
setError(data.error || 'Symbol not found');
|
||
}
|
||
} catch (e) {
|
||
setError('Network error. Check backend.');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleKeyDown = (e) => {
|
||
if (e.key === 'Enter') handleSearch();
|
||
if (e.key === 'Escape') { setQuery(''); onClear(); }
|
||
};
|
||
|
||
return (
|
||
<div className="flex items-center gap-2">
|
||
<div className="relative">
|
||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 text-sm">🔍</span>
|
||
<input
|
||
ref={inputRef}
|
||
type="text"
|
||
placeholder="Search any ticker (e.g. TSLA, GME)..."
|
||
value={query}
|
||
onChange={e => { setQuery(e.target.value.toUpperCase()); setError(null); }}
|
||
onKeyDown={handleKeyDown}
|
||
className="bg-slate-800 border border-slate-700 rounded-xl pl-9 pr-4 py-2 text-sm text-white placeholder-slate-500
|
||
focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 w-72 transition-all"
|
||
/>
|
||
{query && (
|
||
<button
|
||
onClick={() => { setQuery(''); onClear(); }}
|
||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-white text-lg leading-none"
|
||
>×</button>
|
||
)}
|
||
</div>
|
||
<button
|
||
id="search-fetch-btn"
|
||
onClick={handleSearch}
|
||
disabled={!query.trim() || loading}
|
||
className="bg-blue-600 hover:bg-blue-500 disabled:bg-slate-700 disabled:text-slate-500
|
||
text-white font-semibold px-4 py-2 rounded-xl text-sm transition-all flex items-center gap-2"
|
||
>
|
||
{loading ? (
|
||
<span className="animate-spin text-base">⟳</span>
|
||
) : (
|
||
<span>Fetch Details</span>
|
||
)}
|
||
</button>
|
||
{error && <span className="text-red-400 text-xs">{error}</span>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||
export default function MarketScreenerPanel({ onSelectSymbol }) {
|
||
const [screenerData, setScreenerData] = useState(null);
|
||
const [leaderboard, setLeaderboard] = useState(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [lastUpdated, setLastUpdated] = useState(null);
|
||
const [capFilter, setCapFilter] = useState('all');
|
||
|
||
const fetchData = useCallback(async () => {
|
||
try {
|
||
const [screenerRes, lbRes] = await Promise.allSettled([
|
||
fetch(`${API_BASE}/api/market/screener`).then(r => r.json()),
|
||
fetch(`${API_BASE}/api/market/leaderboard`).then(r => r.json()),
|
||
]);
|
||
if (screenerRes.status === 'fulfilled' && screenerRes.value.success) {
|
||
setScreenerData(screenerRes.value.data);
|
||
setLastUpdated(new Date());
|
||
}
|
||
if (lbRes.status === 'fulfilled' && lbRes.value.success) {
|
||
setLeaderboard(lbRes.value.data);
|
||
}
|
||
} catch (err) {
|
||
console.error('[MarketScreenerPanel]', err);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
setLoading(true);
|
||
fetchData();
|
||
const interval = setInterval(fetchData, 90000);
|
||
return () => clearInterval(interval);
|
||
}, [fetchData]);
|
||
|
||
// Filter lanes by cap tier
|
||
const filterByCap = (arr) => {
|
||
if (!arr || capFilter === 'all') return arr || [];
|
||
return arr.filter(s => {
|
||
if (capFilter === 'etf') return s.isETF;
|
||
return s.capTier === capFilter.toUpperCase() && !s.isETF;
|
||
});
|
||
};
|
||
|
||
const CAP_FILTERS = [
|
||
{ id: 'all', label: 'All' },
|
||
{ id: 'large', label: '🏛 Large' },
|
||
{ id: 'mid', label: '🏢 Mid' },
|
||
{ id: 'small', label: '🔬 Small' },
|
||
{ id: 'etf', label: '📦 ETF' },
|
||
];
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
{/* Leaderboard strip */}
|
||
{leaderboard && (
|
||
<div className="grid grid-cols-3 gap-4">
|
||
<LeaderCard title="🚀 Top Gainers" items={leaderboard.top_gainers} type="gain" onSelect={onSelectSymbol} />
|
||
<LeaderCard title="📉 Top Losers" items={leaderboard.top_losers} type="loss" onSelect={onSelectSymbol} />
|
||
<LeaderCard title="🔥 Most Active" items={leaderboard.most_active} type="volume" onSelect={onSelectSymbol} />
|
||
</div>
|
||
)}
|
||
|
||
{/* Screener header */}
|
||
<div className="bg-slate-900 border border-slate-700/50 rounded-2xl overflow-hidden">
|
||
<div className="flex flex-wrap items-center justify-between gap-3 px-5 py-4 border-b border-slate-700/40">
|
||
<div className="flex items-center gap-4 flex-wrap">
|
||
<div>
|
||
<h2 className="text-white font-bold text-lg">📊 Trading Screener</h2>
|
||
<p className="text-slate-500 text-xs mt-0.5">
|
||
Stocks ranked by trading suitability • {lastUpdated ? `Updated ${lastUpdated.toLocaleTimeString()}` : 'Loading...'}
|
||
{loading && <span className="ml-2 text-blue-400 animate-pulse">Refreshing...</span>}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Cap tier filter pills */}
|
||
<div className="flex items-center gap-1.5">
|
||
{CAP_FILTERS.map(f => (
|
||
<button
|
||
key={f.id}
|
||
onClick={() => setCapFilter(f.id)}
|
||
className={`px-3 py-1 rounded-full text-xs font-medium transition-all ${
|
||
capFilter === f.id
|
||
? 'bg-blue-600 text-white'
|
||
: 'bg-slate-800 text-slate-400 hover:text-white hover:bg-slate-700'
|
||
}`}
|
||
>
|
||
{f.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Search */}
|
||
<div className="flex items-center gap-3">
|
||
<SearchBar
|
||
onResult={(sym) => onSelectSymbol?.(sym)}
|
||
onClear={() => {}}
|
||
/>
|
||
<button
|
||
onClick={() => { setLoading(true); fetchData(); }}
|
||
className="bg-slate-800 hover:bg-slate-700 border border-slate-600 text-slate-300 px-3 py-2 rounded-xl text-sm transition-colors"
|
||
title="Refresh screener"
|
||
>
|
||
↻
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ─── 3 Swimlanes ─────────────────────────────────────────────────── */}
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-0 divide-y md:divide-y-0 md:divide-x divide-slate-800/60 p-4 gap-4">
|
||
<Swimlane
|
||
title="Daytrade"
|
||
icon="⚡"
|
||
lane="daytrade"
|
||
accentClass="border-orange-500/25"
|
||
description="High relative volume · Elevated beta · Momentum RSI"
|
||
items={filterByCap(screenerData?.daytrade)}
|
||
loading={loading}
|
||
onSelect={onSelectSymbol}
|
||
/>
|
||
<Swimlane
|
||
title="Short Term"
|
||
icon="🔄"
|
||
lane="shortTerm"
|
||
accentClass="border-blue-500/25"
|
||
description="MACD setup · Near SMA50 · Analyst upside catalyst"
|
||
items={filterByCap(screenerData?.shortTerm)}
|
||
loading={loading}
|
||
onSelect={onSelectSymbol}
|
||
/>
|
||
<Swimlane
|
||
title="Long Term"
|
||
icon="🌱"
|
||
lane="longTerm"
|
||
accentClass="border-emerald-500/25"
|
||
description="Above SMA200 · Earnings growth · Analyst conviction"
|
||
items={filterByCap(screenerData?.longTerm)}
|
||
loading={loading}
|
||
onSelect={onSelectSymbol}
|
||
/>
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="px-5 py-2.5 border-t border-slate-800/50 flex justify-between items-center">
|
||
<span className="text-xs text-slate-600">
|
||
Scores: Daytrade (vol+beta+RSI+MACD) · Short Term (MACD+RSI zone+SMA50+upside) · Long Term (SMA200+earnings+analyst consensus)
|
||
</span>
|
||
<span className="text-xs text-slate-600">Data: Yahoo Finance · Refreshes every 90s</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Leaderboard helper ───────────────────────────────────────────────────────
|
||
function LeaderCard({ title, items = [], type, onSelect }) {
|
||
return (
|
||
<div className="bg-slate-900 border border-slate-700/40 rounded-xl p-4">
|
||
<div className="text-xs font-bold text-slate-400 mb-3 uppercase tracking-wider">{title}</div>
|
||
<div className="space-y-2">
|
||
{items.slice(0, 5).map(item => (
|
||
<div
|
||
key={item.symbol}
|
||
onClick={() => onSelect?.(item.symbol)}
|
||
className="flex items-center justify-between cursor-pointer hover:bg-slate-800/50 rounded-lg px-2 py-1 transition-colors group"
|
||
>
|
||
<div>
|
||
<span className="text-white font-semibold text-sm group-hover:text-blue-400 transition-colors">
|
||
{item.symbol}
|
||
</span>
|
||
<span className="text-slate-600 text-xs ml-1.5">{fmtPrice(item.price)}</span>
|
||
</div>
|
||
{type === 'volume' ? (
|
||
<span className="text-slate-400 text-xs tabular-nums">
|
||
{item.volume >= 1e9 ? `${(item.volume / 1e9).toFixed(1)}B` :
|
||
item.volume >= 1e6 ? `${(item.volume / 1e6).toFixed(1)}M` : item.volume}
|
||
</span>
|
||
) : (
|
||
<span className={`text-sm font-semibold tabular-nums ${
|
||
(item.change_pct ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400'
|
||
}`}>
|
||
{item.change_pct != null ? `${item.change_pct >= 0 ? '+' : ''}${item.change_pct.toFixed(2)}%` : '—'}
|
||
</span>
|
||
)}
|
||
</div>
|
||
))}
|
||
{items.length === 0 && <div className="text-slate-600 text-xs py-2">Loading...</div>}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|