From 86774890439dd7bf175431bb79aa7a2c6a17f680 Mon Sep 17 00:00:00 2001 From: Deep Koluguri Date: Thu, 25 Jun 2026 19:28:12 -0400 Subject: [PATCH] fix(ui): tear out predictive swimlanes and replace with descriptive factor lens grid --- backend/src/routes/marketAnalysis.js | 106 +----- .../dashboard/MarketScreenerPanel.jsx | 306 +++++------------- 2 files changed, 91 insertions(+), 321 deletions(-) diff --git a/backend/src/routes/marketAnalysis.js b/backend/src/routes/marketAnalysis.js index 1d8bee1..a279c60 100644 --- a/backend/src/routes/marketAnalysis.js +++ b/backend/src/routes/marketAnalysis.js @@ -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); diff --git a/frontend/src/components/dashboard/MarketScreenerPanel.jsx b/frontend/src/components/dashboard/MarketScreenerPanel.jsx index ea2c9ab..f61beda 100644 --- a/frontend/src/components/dashboard/MarketScreenerPanel.jsx +++ b/frontend/src/components/dashboard/MarketScreenerPanel.jsx @@ -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 No data; + const filled = Math.round(pct / 10); + const empty = 10 - filled; 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} -
-
- {s.probability != null ? ( -
-
- {s.grade.label} - · {(s.probability * 100).toFixed(1)}% -
- to hit +2R before -1R -
- ) : ( - - )} -
- - {/* Price + change */} -
- {fmtPrice(item.price)} - {change && ( - - {change.label} - - )} -
- - {/* Signals / reasons */} - {s.drivers && s.drivers.length > 0 && ( -
- {s.drivers.slice(0,3).map((d, i) => ( -
-
- - {d.desc} -
- = 15 ? 'text-emerald-500/70' : 'text-slate-500'}`}>+{d.points} -
- ))} -
- )} - - {/* Footer: sector + grade badge */} -
-
- - {item.sector} - - {s.dataAgeSec != null && ( -
- )} -
- - {s.grade.label} - -
-
+ + {'█'.repeat(filled)} + {'░'.repeat(empty)} + ); } -// ─── Swimlane column ────────────────────────────────────────────────────────── -function Swimlane({ title, icon, description, badge, items = [], lane, onSelect, loading, accentClass }) { +function FactorProfileCard({ profile, onSelect }) { return ( -
- {/* Lane header */} -
-
- {icon} -

- {title} - {badge && ( - - {badge} - - )} -

- {!loading && items.length > 0 && ( - {items.length} stocks - )} -
-

{description}

+
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} + > +
+ + {profile.ticker} + + + Factor Profile (vs. Universe) +
- {/* Cards */} -
- {loading ? ( - Array.from({ length: 6 }).map((_, i) => ( -
- )) - ) : items.length === 0 ? ( -
No data available
- ) : ( - items.map(item => ( - - )) - )} +
+
+ Value + {pctBar(profile.value)} + {profile.value != null ? `${profile.value}th pct` : 'N/A'} +
+
+ Quality + {pctBar(profile.quality)} + {profile.quality != null ? `${profile.quality}th pct` : 'N/A'} +
+
+ Low-Vol + {pctBar(profile.lowVol)} + {profile.lowVol != null ? `${profile.lowVol}th pct` : 'N/A'} +
+
+ Momentum + {pctBar(profile.momentum)} + {profile.momentum != null ? `${profile.momentum}th pct` : 'N/A'} +
+
+ + {/* Disclaimer tooltip hint */} +
+ i
); @@ -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 (
- {/* Regime Banner */} - {regime && ( -
-
- 🏛 -
-
Market Regime
-
- - {regime.trend === 'risk_on' ? 'Risk-On (Uptrend)' : regime.trend === 'risk_off' ? 'Risk-Off (Downtrend)' : 'Neutral Trend'} - - - {regime.vol === 'low_vol' ? 'Low Vol' : regime.vol === 'high_vol' ? 'High Vol' : 'Normal Vol'} - - {regime.breadth === 'broad' ? 'Broad Breadth' : regime.breadth === 'narrow' ? 'Narrow Breadth' : 'Mixed Breadth'} -
-
-
-
-
SPY Close
-
${regime.raw?.spy_close?.toFixed(2)}
-
-
- )} - {/* Leaderboard strip */} {leaderboard && (
@@ -351,34 +236,22 @@ export default function MarketScreenerPanel({ onSelectSymbol }) {
)} - {/* Screener header */} + {/* Factor Profiles header */}
-

📊 Trading Screener

+

+ 📊 Fundamental Factor Lens + + Descriptive Only + +

- Stocks ranked by trading suitability • {lastUpdated ? `Updated ${lastUpdated.toLocaleTimeString()}` : 'Loading...'} + Where stocks rank vs. peers today • {lastUpdated ? `Updated ${lastUpdated.toLocaleTimeString()}` : 'Loading...'} {loading && Refreshing...}

- - {/* Cap tier filter pills */} -
- {CAP_FILTERS.map(f => ( - - ))} -
{/* Search */} @@ -390,53 +263,38 @@ export default function MarketScreenerPanel({ onSelectSymbol }) {
- {/* ─── 3 Swimlanes ─────────────────────────────────────────────────── */} -
- - - + {/* ─── Factor Grid ─────────────────────────────────────────────────── */} +
+
+

Descriptive only. 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.

+
+ +
+ {loading ? ( + Array.from({ length: 8 }).map((_, i) => ( +
+ )) + ) : sortedProfiles.length === 0 ? ( +
No data available
+ ) : ( + sortedProfiles.map(p => ( + + )) + )} +
{/* Footer */}
- 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 Data: Yahoo Finance · Refreshes every 90s