343 lines
15 KiB
JavaScript
343 lines
15 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>
|
||
);
|
||
}
|
||
|
||
// ─── 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>
|
||
);
|
||
}
|