fix(ui): tear out predictive swimlanes and replace with descriptive factor lens grid
Build Institutional Trader / build-and-deploy (push) Successful in 2m10s Details

This commit is contained in:
Deep Koluguri 2026-06-25 19:28:12 -04:00
parent a5e06b44b1
commit 8677489043
2 changed files with 91 additions and 321 deletions

View File

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

View File

@ -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>