institutional-trader/frontend/src/components/dashboard/MarketScreenerPanel.jsx

428 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}