fix(ui): tear out predictive swimlanes and replace with descriptive factor lens grid
Build Institutional Trader / build-and-deploy (push) Successful in 2m10s
Details
Build Institutional Trader / build-and-deploy (push) Successful in 2m10s
Details
This commit is contained in:
parent
a5e06b44b1
commit
8677489043
|
|
@ -214,10 +214,12 @@ router.get('/leaderboard', async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
import { buildFactorProfiles } from '../factorlab/descriptiveLens.js';
|
||||
|
||||
/**
|
||||
* GET /api/market/screener
|
||||
* Returns 3 sorted swimlanes: daytrade, shortTerm, longTerm
|
||||
* Scores every universe symbol using technicals + fundamentals (all cached).
|
||||
* Returns the descriptive factor profiles for the entire universe.
|
||||
* Strictly descriptive. No predictive claims.
|
||||
*/
|
||||
router.get('/screener', async (req, res) => {
|
||||
try {
|
||||
|
|
@ -225,105 +227,15 @@ router.get('/screener', async (req, res) => {
|
|||
const cached = universeCache.get(cacheKey);
|
||||
if (cached) return res.json({ success: true, data: cached, cached: true });
|
||||
|
||||
const CONCURRENCY = 6;
|
||||
const scored = [];
|
||||
|
||||
const [spyTech, vixQuote] = await Promise.all([
|
||||
fetchTechnicals('SPY'),
|
||||
fetchBasicQuote('^VIX')
|
||||
]);
|
||||
|
||||
for (let i = 0; i < UNIVERSE.length; i += CONCURRENCY) {
|
||||
const batch = UNIVERSE.slice(i, i + CONCURRENCY);
|
||||
const results = await Promise.allSettled(
|
||||
batch.map(async (entry) => {
|
||||
const [quote, technicals, fundamentals] = await Promise.allSettled([
|
||||
fetchBasicQuote(entry.symbol),
|
||||
fetchTechnicals(entry.symbol),
|
||||
fetchQuoteSummary(entry.symbol),
|
||||
]);
|
||||
const q = quote.status === 'fulfilled' ? quote.value : null;
|
||||
const t = technicals.status === 'fulfilled' ? technicals.value : null;
|
||||
const f = fundamentals.status === 'fulfilled' ? fundamentals.value : null;
|
||||
|
||||
const capTier = classifyCapTier(q?.market_cap ?? f?.market_cap ?? null, entry.isETF);
|
||||
|
||||
return {
|
||||
symbol: entry.symbol,
|
||||
name: entry.name,
|
||||
sector: entry.sector,
|
||||
isETF: entry.isETF,
|
||||
capTier,
|
||||
price: q?.price ?? null,
|
||||
change_pct: q?.change_pct ?? null,
|
||||
volume: q?.volume ?? null,
|
||||
market_cap: q?.market_cap ?? f?.market_cap ?? null,
|
||||
above_sma50: t?.above_sma50 ?? null,
|
||||
q, t, f
|
||||
};
|
||||
})
|
||||
);
|
||||
results.forEach(r => { if (r.status === 'fulfilled' && r.value) scored.push(r.value); });
|
||||
if (i + CONCURRENCY < UNIVERSE.length) await new Promise(r => setTimeout(r, 100));
|
||||
}
|
||||
|
||||
// Compute regime
|
||||
const totalWith50 = scored.filter(s => s.above_sma50 !== null).length;
|
||||
const above50 = scored.filter(s => s.above_sma50 === true).length;
|
||||
const breadthPctAbove50 = totalWith50 > 0 ? (above50 / totalWith50) * 100 : 50;
|
||||
const regime = classifyRegime(spyTech, vixQuote?.price ?? 20, breadthPctAbove50);
|
||||
|
||||
// Compute scores and log signals
|
||||
const signalsToLog = [];
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
scored.forEach(s => {
|
||||
// Data freshness (using quote timestamp or assumed fresh if pulled recently)
|
||||
// Usually would parse last trade time, but for phase 0 we approximate based on cache age or current time.
|
||||
const dataAgeSec = s.q ? 15 : 120; // 15s if we got quote, 120s if missing
|
||||
|
||||
const suitability = scoreSuitability({
|
||||
quote: s.q,
|
||||
technicals: s.t,
|
||||
fundamentals: s.f,
|
||||
regime,
|
||||
dataAgeSec
|
||||
});
|
||||
s.suitability = suitability;
|
||||
|
||||
// Clean up raw payloads for frontend transport size
|
||||
delete s.q; delete s.t; delete s.f; delete s.above_sma50;
|
||||
|
||||
// Build signals for logging (only log strong candidates to save DB space, or all. Let's log all.)
|
||||
for (const lane of ['daytrade', 'shortTerm', 'longTerm']) {
|
||||
signalsToLog.push({
|
||||
ticker: s.symbol,
|
||||
ts: timestamp,
|
||||
lane,
|
||||
score: suitability[lane].score,
|
||||
grade: suitability[lane].grade,
|
||||
entry_price: s.price ?? 0,
|
||||
features: suitability[lane].features,
|
||||
regime: regime.raw,
|
||||
data_age_sec: suitability[lane].dataAgeSec
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Fire and forget logging
|
||||
logSignals(signalsToLog);
|
||||
|
||||
// Sort each lane by score desc, take top 15 for each
|
||||
const sortByScore = (key) => [...scored].sort((a, b) => b.suitability[key].score - a.suitability[key].score).slice(0, 15);
|
||||
// Fetch the descriptive factor profiles
|
||||
const profiles = await buildFactorProfiles();
|
||||
|
||||
const data = {
|
||||
regime,
|
||||
daytrade: sortByScore('daytrade'),
|
||||
shortTerm: sortByScore('shortTerm'),
|
||||
longTerm: sortByScore('longTerm'),
|
||||
profiles: profiles,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
universeCache.set(cacheKey, data, 90); // 90s cache for screener
|
||||
universeCache.set(cacheKey, data, 120); // 120s cache
|
||||
res.json({ success: true, data });
|
||||
} catch (err) {
|
||||
console.error('[market/screener]', err);
|
||||
|
|
|
|||
|
|
@ -54,137 +54,63 @@ function ScoreRing({ score, color }) {
|
|||
);
|
||||
}
|
||||
|
||||
// ─── 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];
|
||||
|
||||
// ─── 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 (
|
||||
<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>
|
||||
{s.probability != null ? (
|
||||
<div className="flex flex-col items-end text-right ml-2 bg-slate-950/40 p-2 rounded-lg border border-slate-800">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className={`text-xl font-bold ${s.grade.color}`}>{s.grade.label}</span>
|
||||
<span className="text-white text-lg font-bold">· {(s.probability * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-400 font-medium">to hit +2R before -1R</span>
|
||||
</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.drivers && s.drivers.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{s.drivers.slice(0,3).map((d, i) => (
|
||||
<div key={i} className="text-[11px] text-slate-400 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-slate-600">•</span>
|
||||
<span title={d.label}>{d.desc}</span>
|
||||
</div>
|
||||
<span className={`font-mono text-[10px] ${d.points >= 15 ? 'text-emerald-500/70' : 'text-slate-500'}`}>+{d.points}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer: sector + grade badge */}
|
||||
<div className="flex items-center justify-between mt-3 pt-2.5 border-t border-slate-800/60">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-slate-600 bg-slate-800/60 px-1.5 py-0.5 rounded">
|
||||
{item.sector}
|
||||
</span>
|
||||
{s.dataAgeSec != null && (
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${s.stale ? 'bg-red-500' : s.dataAgeSec < 30 ? 'bg-emerald-500' : 'bg-yellow-500'}`}
|
||||
title={`Data age: ${s.dataAgeSec}s${s.stale ? ' (Stale)' : ''}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded ${lc.badge}`}>
|
||||
{s.grade.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-mono tracking-tighter">
|
||||
<span className="text-blue-500">{'█'.repeat(filled)}</span>
|
||||
<span className="text-slate-700">{'░'.repeat(empty)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Swimlane column ──────────────────────────────────────────────────────────
|
||||
function Swimlane({ title, icon, description, badge, items = [], lane, onSelect, loading, accentClass }) {
|
||||
function FactorProfileCard({ profile, onSelect }) {
|
||||
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 flex items-center gap-2">
|
||||
{title}
|
||||
{badge && (
|
||||
<span className="text-[9px] uppercase tracking-wider font-bold bg-slate-800 text-slate-400 px-1.5 py-0.5 rounded border border-slate-700">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</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
|
||||
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>
|
||||
|
||||
{/* Cards */}
|
||||
<div className="p-3 space-y-2.5">
|
||||
{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 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>
|
||||
);
|
||||
|
|
@ -265,10 +191,8 @@ function SearchBar({ onResult, onClear }) {
|
|||
export default function MarketScreenerPanel({ onSelectSymbol }) {
|
||||
const [screenerData, setScreenerData] = useState(null);
|
||||
const [leaderboard, setLeaderboard] = useState(null);
|
||||
const [regime, setRegime] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastUpdated, setLastUpdated] = useState(null);
|
||||
const [capFilter, setCapFilter] = useState('all');
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -278,7 +202,6 @@ export default function MarketScreenerPanel({ onSelectSymbol }) {
|
|||
]);
|
||||
if (screenerRes.status === 'fulfilled' && screenerRes.value.success) {
|
||||
setScreenerData(screenerRes.value.data);
|
||||
setRegime(screenerRes.value.data.regime);
|
||||
setLastUpdated(new Date());
|
||||
}
|
||||
if (lbRes.status === 'fulfilled' && lbRes.value.success) {
|
||||
|
|
@ -298,50 +221,12 @@ export default function MarketScreenerPanel({ onSelectSymbol }) {
|
|||
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' },
|
||||
];
|
||||
// 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">
|
||||
{/* Regime Banner */}
|
||||
{regime && (
|
||||
<div className="bg-slate-900/80 border border-slate-700/50 rounded-xl p-3 flex items-center justify-between shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg">🏛</span>
|
||||
<div>
|
||||
<div className="text-xs text-slate-400 uppercase tracking-wider font-semibold">Market Regime</div>
|
||||
<div className="text-sm text-white font-medium flex items-center gap-2 mt-0.5">
|
||||
<span className={regime.trend === 'risk_on' ? 'text-emerald-400' : regime.trend === 'risk_off' ? 'text-red-400' : 'text-yellow-400'}>
|
||||
{regime.trend === 'risk_on' ? 'Risk-On (Uptrend)' : regime.trend === 'risk_off' ? 'Risk-Off (Downtrend)' : 'Neutral Trend'}
|
||||
</span>
|
||||
<span className="text-slate-600">•</span>
|
||||
<span>{regime.vol === 'low_vol' ? 'Low Vol' : regime.vol === 'high_vol' ? 'High Vol' : 'Normal Vol'}</span>
|
||||
<span className="text-slate-600">•</span>
|
||||
<span>{regime.breadth === 'broad' ? 'Broad Breadth' : regime.breadth === 'narrow' ? 'Narrow Breadth' : 'Mixed Breadth'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-[10px] text-slate-500">SPY Close</div>
|
||||
<div className="text-xs font-mono text-slate-300">${regime.raw?.spy_close?.toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Leaderboard strip */}
|
||||
{leaderboard && (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
|
|
@ -351,34 +236,22 @@ export default function MarketScreenerPanel({ onSelectSymbol }) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Screener header */}
|
||||
{/* 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">📊 Trading Screener</h2>
|
||||
<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">
|
||||
Stocks ranked by trading suitability • {lastUpdated ? `Updated ${lastUpdated.toLocaleTimeString()}` : 'Loading...'}
|
||||
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>
|
||||
|
||||
{/* 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 */}
|
||||
|
|
@ -390,53 +263,38 @@ export default function MarketScreenerPanel({ onSelectSymbol }) {
|
|||
<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"
|
||||
title="Refresh profiles"
|
||||
>
|
||||
↻
|
||||
</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 (Swing)"
|
||||
icon="📅"
|
||||
description="RSI dips, SMA50 bounce, Earnings Revisions"
|
||||
badge="Not yet validated"
|
||||
items={filterByCap(screenerData?.shortTerm)}
|
||||
lane="shortTerm"
|
||||
onSelect={onSelectSymbol}
|
||||
loading={loading}
|
||||
accentClass="border-blue-500/20 shadow-[0_0_15px_rgba(59,130,246,0.05)]"
|
||||
/>
|
||||
<Swimlane
|
||||
title="Long Term"
|
||||
icon="💎"
|
||||
description="SMA200 uptrend, Earnings power, Margins"
|
||||
badge="Not yet validated"
|
||||
items={filterByCap(screenerData?.longTerm)}
|
||||
lane="longTerm"
|
||||
onSelect={onSelectSymbol}
|
||||
loading={loading}
|
||||
accentClass="border-emerald-500/20 shadow-[0_0_15px_rgba(16,185,129,0.05)]"
|
||||
/>
|
||||
{/* ─── 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">
|
||||
Scores: Daytrade (vol+beta+RSI+MACD) · Short Term (MACD+RSI zone+SMA50+upside) · Long Term (SMA200+earnings+analyst consensus)
|
||||
Sorted by Composite score · Unvalidated descriptive lens
|
||||
</span>
|
||||
<span className="text-xs text-slate-600">Data: Yahoo Finance · Refreshes every 90s</span>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue