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 (
);
}
// ─── 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 (
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 */}
{item.symbol}
{item.capTier && (
{item.capTier}
)}
{item.name}
{/* Price + change */}
{fmtPrice(item.price)}
{change && (
{change.label}
)}
{/* Signals / reasons */}
{s.reasons.length > 0 && (
{s.reasons.map((r, i) => (
•
{r}
))}
)}
{/* Footer: sector + grade badge */}
{item.sector}
{s.grade.label}
);
}
// ─── Swimlane column ──────────────────────────────────────────────────────────
function Swimlane({ title, icon, description, items = [], lane, onSelect, loading, accentClass }) {
return (
{/* Lane header */}
{icon}
{title}
{!loading && items.length > 0 && (
{items.length} stocks
)}
{description}
{/* Cards */}
{loading ? (
Array.from({ length: 6 }).map((_, i) => (
))
) : items.length === 0 ? (
No data available
) : (
items.map(item => (
))
)}
);
}
// ─── 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 (
🔍
{ 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 && (
{ setQuery(''); onClear(); }}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-white text-lg leading-none"
>×
)}
{loading ? (
⟳
) : (
Fetch Details
)}
{error &&
{error} }
);
}
// ─── 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 (
{/* Leaderboard strip */}
{leaderboard && (
)}
{/* Screener header */}
📊 Trading Screener
Stocks ranked by trading suitability • {lastUpdated ? `Updated ${lastUpdated.toLocaleTimeString()}` : 'Loading...'}
{loading && Refreshing... }
{/* Cap tier filter pills */}
{CAP_FILTERS.map(f => (
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}
))}
{/* Search */}
onSelectSymbol?.(sym)}
onClear={() => {}}
/>
{ 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"
>
↻
{/* ─── 3 Swimlanes ─────────────────────────────────────────────────── */}
{/* Footer */}
Scores: Daytrade (vol+beta+RSI+MACD) · Short Term (MACD+RSI zone+SMA50+upside) · Long Term (SMA200+earnings+analyst consensus)
Data: Yahoo Finance · Refreshes every 90s
);
}
// ─── Leaderboard helper ───────────────────────────────────────────────────────
function LeaderCard({ title, items = [], type, onSelect }) {
return (
{title}
{items.slice(0, 5).map(item => (
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"
>
{item.symbol}
{fmtPrice(item.price)}
{type === 'volume' ? (
{item.volume >= 1e9 ? `${(item.volume / 1e9).toFixed(1)}B` :
item.volume >= 1e6 ? `${(item.volume / 1e6).toFixed(1)}M` : item.volume}
) : (
= 0 ? 'text-emerald-400' : 'text-red-400'
}`}>
{item.change_pct != null ? `${item.change_pct >= 0 ? '+' : ''}${item.change_pct.toFixed(2)}%` : '—'}
)}
))}
{items.length === 0 &&
Loading...
}
);
}