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

343 lines
15 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>
);
}
// ─── Factor Profile Component ───────────────────────────────────────────────────
function pctBar(pct) {
if (pct == null) return <span className="text-slate-600">No data</span>;
const filled = Math.round(pct / 10);
const empty = 10 - filled;
return (
<span className="font-mono tracking-tighter">
<span className="text-blue-500">{'█'.repeat(filled)}</span>
<span className="text-slate-700">{'░'.repeat(empty)}</span>
</span>
);
}
function FactorProfileCard({ profile, onSelect }) {
return (
<div
onClick={() => onSelect?.(profile.ticker)}
className="group bg-slate-900 border border-slate-700/50 rounded-xl p-4 cursor-pointer
hover:bg-slate-800 hover:border-blue-500/50 transition-all duration-150
hover:shadow-lg active:scale-[0.99] relative"
title={profile._disclaimer}
>
<div className="flex items-center justify-between mb-3 border-b border-slate-800 pb-2">
<span className="font-bold text-white text-lg group-hover:text-blue-400 transition-colors">
{profile.ticker}
</span>
<span className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold bg-slate-800 px-2 py-1 rounded">
Factor Profile (vs. Universe)
</span>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-slate-400 w-20">Value</span>
{pctBar(profile.value)}
<span className="text-white w-16 text-right tabular-nums">{profile.value != null ? `${profile.value}th pct` : 'N/A'}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400 w-20">Quality</span>
{pctBar(profile.quality)}
<span className="text-white w-16 text-right tabular-nums">{profile.quality != null ? `${profile.quality}th pct` : 'N/A'}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400 w-20">Low-Vol</span>
{pctBar(profile.lowVol)}
<span className="text-white w-16 text-right tabular-nums">{profile.lowVol != null ? `${profile.lowVol}th pct` : 'N/A'}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400 w-20">Momentum</span>
{pctBar(profile.momentum)}
<span className="text-white w-16 text-right tabular-nums">{profile.momentum != null ? `${profile.momentum}th pct` : 'N/A'}</span>
</div>
</div>
{/* Disclaimer tooltip hint */}
<div className="absolute top-2 right-2 flex items-center justify-center w-5 h-5 rounded-full bg-slate-800 text-slate-400 text-[10px] opacity-0 group-hover:opacity-100 transition-opacity" title={profile._disclaimer}>
i
</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 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]);
// Sort profiles by composite percentile desc
const profiles = screenerData?.profiles || [];
const sortedProfiles = [...profiles].sort((a, b) => (b.composite || 0) - (a.composite || 0));
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>
)}
{/* Factor Profiles 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 flex items-center gap-2">
📊 Fundamental Factor Lens
<span className="text-[10px] uppercase tracking-wider font-bold bg-slate-800 text-blue-400 px-1.5 py-0.5 rounded border border-blue-500/30">
Descriptive Only
</span>
</h2>
<p className="text-slate-500 text-xs mt-0.5">
Where stocks rank vs. peers today {lastUpdated ? `Updated ${lastUpdated.toLocaleTimeString()}` : 'Loading...'}
{loading && <span className="ml-2 text-blue-400 animate-pulse">Refreshing...</span>}
</p>
</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 profiles"
>
</button>
</div>
</div>
{/* ─── Factor Grid ─────────────────────────────────────────────────── */}
<div className="p-5 bg-slate-950/50">
<div className="mb-4 text-sm text-slate-400 max-w-3xl">
<p><strong>Descriptive only.</strong> Shows where each stock ranks against its peers across four foundational factors based on current data. We have not validated that these ranks predict forward returns. We will only upgrade these to "predictive" signals after they pass rigorous out-of-sample holdout validation.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{loading ? (
Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="animate-pulse bg-slate-800/60 rounded-xl h-[160px]" />
))
) : sortedProfiles.length === 0 ? (
<div className="col-span-full text-center text-slate-600 py-12 text-sm">No data available</div>
) : (
sortedProfiles.map(p => (
<FactorProfileCard key={p.ticker} profile={p} onSelect={onSelectSymbol} />
))
)}
</div>
</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">
Sorted by Composite score · Unvalidated descriptive lens
</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>
);
}