feat: swimlane screener suitability scoring global ticker search remove side panels
Build Institutional Trader / build-and-deploy (push) Successful in 2m7s Details

This commit is contained in:
Deep Koluguri 2026-06-24 18:53:11 -04:00
parent f6f35dba00
commit 911693da88
4 changed files with 679 additions and 280 deletions

View File

@ -10,6 +10,7 @@ import { UNIVERSE, getSymbolMeta, classifyCapTier } from '../services/stockUnive
import { fetchBasicQuote, fetchQuoteSummary, fetchTechnicals, fetchFullAnalysis } from '../services/fundamentalsService.js';
import { fetchInstitutionalHolders, fetchInsiderTransactions } from '../services/institutionalHoldersService.js';
import { fetchNewsForSymbol, fetchMarketNews } from '../services/newsService.js';
import { scoreSuitability } from '../services/suitabilityService.js';
const router = express.Router();
const universeCache = new NodeCache({ stdTTL: 60 });
@ -212,4 +213,108 @@ router.get('/leaderboard', async (req, res) => {
}
});
/**
* GET /api/market/screener
* Returns 3 sorted swimlanes: daytrade, shortTerm, longTerm
* Scores every universe symbol using technicals + fundamentals (all cached).
*/
router.get('/screener', async (req, res) => {
try {
const cacheKey = 'screener';
const cached = universeCache.get(cacheKey);
if (cached) return res.json({ success: true, data: cached, cached: true });
const CONCURRENCY = 6;
const scored = [];
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 suitability = scoreSuitability({ quote: q, technicals: t, fundamentals: f });
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,
beta: f?.beta ?? null,
rsi_14: t?.rsi_14 ?? null,
above_sma200: t?.above_sma200 ?? null,
recommendation: f?.recommendation ?? null,
suitability,
};
})
);
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));
}
// 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);
const data = {
daytrade: sortByScore('daytrade'),
shortTerm: sortByScore('shortTerm'),
longTerm: sortByScore('longTerm'),
updatedAt: new Date().toISOString(),
};
universeCache.set(cacheKey, data, 90); // 90s cache for screener
res.json({ success: true, data });
} catch (err) {
console.error('[market/screener]', err);
res.status(500).json({ success: false, error: err.message });
}
});
/**
* GET /api/market/search/:symbol
* Search ANY ticker (not just universe). Returns full analysis + suitability.
*/
router.get('/search/:symbol', async (req, res) => {
try {
const symbol = req.params.symbol.toUpperCase().trim();
const meta = getSymbolMeta(symbol); // may be null for out-of-universe tickers
const analysis = await fetchFullAnalysis(symbol);
if (!analysis.quote) {
return res.status(404).json({ success: false, error: `No data found for symbol ${symbol}` });
}
const suitability = scoreSuitability({
quote: analysis.quote,
technicals: analysis.technicals,
fundamentals: analysis.fundamentals,
});
const capTier = classifyCapTier(
analysis.quote?.market_cap ?? analysis.fundamentals?.market_cap ?? null,
meta?.isETF ?? false
);
res.json({
success: true,
data: { ...analysis, meta, capTier, suitability },
});
} catch (err) {
console.error(`[market/search/${req.params.symbol}]`, err);
res.status(500).json({ success: false, error: err.message });
}
});
export default router;

View File

@ -0,0 +1,183 @@
/**
* Suitability Service
* Scores stocks across three trading horizons using available market data.
*
* Daytrade: Volatility × Relative Volume × Momentum
* Short Term: Technical setup (MACD, RSI zone, SMA proximity)
* Long Term: Analyst consensus × Earnings trend × SMA200 uptrend
*
* All scores are 0100. Returns top candidates per lane.
*/
/**
* Score a single stock entry (quote + technicals + optional fundamentals)
* Returns { daytrade, shortTerm, longTerm, reasons }
*/
export function scoreSuitability({ quote, technicals, fundamentals }) {
const q = quote || {};
const t = technicals || {};
const f = fundamentals || {};
// ─── Daytrade Score ───────────────────────────────────────────────────────
// Needs: liquidity spike, beta, momentum in a tradeable RSI zone, MACD
let dt = 0;
// Relative volume (current vs 10-day avg): 035 pts
const relVol = q.volume && f.avg_volume_10d
? q.volume / f.avg_volume_10d
: q.volume && f.avg_volume_3m
? q.volume / f.avg_volume_3m
: null;
const dtReasons = [];
if (relVol != null) {
if (relVol >= 3.0) { dt += 35; dtReasons.push(`🔥 ${relVol.toFixed(1)}x rel. volume`); }
else if (relVol >= 2.0) { dt += 28; dtReasons.push(`${relVol.toFixed(1)}x rel. volume`); }
else if (relVol >= 1.5) { dt += 20; dtReasons.push(`📈 ${relVol.toFixed(1)}x rel. volume`); }
else if (relVol >= 1.0) { dt += 10; dtReasons.push(`Avg volume`); }
else { dt += 3; dtReasons.push(`Low volume`); }
}
// Beta: 025 pts
const beta = f.beta ?? null;
if (beta != null) {
if (beta >= 2.0) { dt += 25; dtReasons.push(`Beta ${beta.toFixed(1)} (high vol)`); }
else if (beta >= 1.5) { dt += 20; dtReasons.push(`Beta ${beta.toFixed(1)}`); }
else if (beta >= 1.2) { dt += 13; }
else if (beta >= 0.8) { dt += 5; }
else { dt += 0; dtReasons.push(`Low beta`); }
}
// RSI zone (tradeable, not exhausted): 020 pts
const rsi = t.rsi_14 ?? null;
if (rsi != null) {
if (rsi >= 40 && rsi <= 65) { dt += 20; dtReasons.push(`RSI ${rsi.toFixed(0)} (momentum zone)`); }
else if (rsi >= 30 && rsi < 40) { dt += 14; dtReasons.push(`RSI ${rsi.toFixed(0)} (bouncing)`); }
else if (rsi > 65 && rsi <= 75) { dt += 10; dtReasons.push(`RSI ${rsi.toFixed(0)} (extended but running)`); }
else if (rsi > 75) { dt += 2; dtReasons.push(`RSI ${rsi.toFixed(0)} (overbought)`); }
else { dt += 2; dtReasons.push(`RSI ${rsi.toFixed(0)} (oversold)`); }
}
// MACD histogram positive: 020 pts
const macdH = t.macd_histogram ?? null;
if (macdH != null) {
if (macdH > 0) { dt += 20; dtReasons.push(`MACD bullish`); }
else if (macdH > -0.1) { dt += 10; }
else { dt += 0; dtReasons.push(`MACD bearish`); }
}
// ─── Short Term (Swing) Score ─────────────────────────────────────────────
// Needs: MACD setup, RSI not extended, near SMA50, analyst upside
let st = 0;
const stReasons = [];
// MACD histogram: 030 pts
if (macdH != null) {
if (macdH > 0.5) { st += 30; stReasons.push(`Strong MACD bullish`); }
else if (macdH > 0) { st += 22; stReasons.push(`MACD bullish`); }
else if (macdH > -0.2) { st += 10; stReasons.push(`MACD near crossover`); }
else { st += 0; }
}
// RSI sweet spot (not extended, not exhausted): 025 pts
if (rsi != null) {
if (rsi >= 40 && rsi <= 60) { st += 25; stReasons.push(`RSI ${rsi.toFixed(0)} (ideal swing zone)`); }
else if (rsi >= 30 && rsi < 40) { st += 20; stReasons.push(`RSI ${rsi.toFixed(0)} (reset - buy dip)`); }
else if (rsi > 60 && rsi <= 70) { st += 15; stReasons.push(`RSI ${rsi.toFixed(0)} (momentum run)`); }
else if (rsi <= 30) { st += 10; stReasons.push(`RSI oversold`); }
else { st += 2; }
}
// % from SMA50: -10% to +10% = best setup for swing: 025 pts
const pctSma50 = t.pct_from_sma50 ?? null;
if (pctSma50 != null) {
const abs = Math.abs(pctSma50);
if (abs <= 3) { st += 25; stReasons.push(`Near SMA50 (${pctSma50.toFixed(1)}%) - coiled`); }
else if (abs <= 7) { st += 18; stReasons.push(`${pctSma50 > 0 ? 'Above' : 'Below'} SMA50 by ${abs.toFixed(1)}%`); }
else if (abs <= 15) { st += 8; }
else { st += 0; stReasons.push(`Extended from SMA50`); }
}
// Analyst upside: 020 pts
const price = q.price ?? null;
const targetMean = f.target_mean ?? null;
if (price && targetMean) {
const upside = ((targetMean - price) / price) * 100;
if (upside >= 20) { st += 20; stReasons.push(`${upside.toFixed(0)}% analyst upside`); }
else if (upside >= 10) { st += 14; stReasons.push(`${upside.toFixed(0)}% analyst upside`); }
else if (upside >= 5) { st += 8; }
else if (upside > 0) { st += 3; }
else { st += 0; stReasons.push(`Analyst consensus cautious`); }
}
// ─── Long Term Score ──────────────────────────────────────────────────────
// Needs: above SMA200 uptrend, earnings growth, analyst conviction, margins
let lt = 0;
const ltReasons = [];
// Above SMA200 (price in long-term uptrend): 030 pts
const above200 = t.above_sma200 ?? null;
const pctSma200 = t.pct_from_sma200 ?? null;
if (above200 != null) {
if (above200 && pctSma200 != null && pctSma200 > 10) { lt += 30; ltReasons.push(`${pctSma200.toFixed(0)}% above SMA200`); }
else if (above200) { lt += 22; ltReasons.push(`Above SMA200`); }
else if (pctSma200 != null && pctSma200 > -5) { lt += 10; ltReasons.push(`Near SMA200 support`); }
else { lt += 0; ltReasons.push(`Below SMA200`); }
}
// Earnings growth: 025 pts
const eg = f.earnings_growth ?? null;
if (eg != null) {
if (eg >= 0.30) { lt += 25; ltReasons.push(`${(eg * 100).toFixed(0)}% earnings growth`); }
else if (eg >= 0.15) { lt += 18; ltReasons.push(`${(eg * 100).toFixed(0)}% earnings growth`); }
else if (eg >= 0.05) { lt += 11; }
else if (eg >= 0) { lt += 5; }
else { lt += 0; ltReasons.push(`Negative earnings growth`); }
}
// Analyst conviction (strong buy/buy count as % of total ratings): 025 pts
const totalRec = (f.rec_strong_buy || 0) + (f.rec_buy || 0) + (f.rec_hold || 0) + (f.rec_sell || 0) + (f.rec_strong_sell || 0);
const bullishRec = (f.rec_strong_buy || 0) + (f.rec_buy || 0);
if (totalRec > 0) {
const bullPct = bullishRec / totalRec;
if (bullPct >= 0.75) { lt += 25; ltReasons.push(`${(bullPct * 100).toFixed(0)}% analysts bullish (${totalRec} coverage)`); }
else if (bullPct >= 0.55) { lt += 16; ltReasons.push(`${(bullPct * 100).toFixed(0)}% analysts bullish`); }
else if (bullPct >= 0.40) { lt += 8; }
else { lt += 0; ltReasons.push(`Bearish analyst consensus`); }
}
// Profit margins (durable business): 020 pts
const pm = f.profit_margins ?? null;
if (pm != null) {
if (pm >= 0.25) { lt += 20; ltReasons.push(`${(pm * 100).toFixed(0)}% profit margin`); }
else if (pm >= 0.15) { lt += 14; ltReasons.push(`${(pm * 100).toFixed(0)}% profit margin`); }
else if (pm >= 0.05) { lt += 7; }
else if (pm >= 0) { lt += 2; }
else { lt += 0; ltReasons.push(`Unprofitable`); }
}
return {
daytrade: {
score: Math.min(100, Math.round(dt)),
reasons: dtReasons.slice(0, 3),
grade: gradeLabel(dt),
},
shortTerm: {
score: Math.min(100, Math.round(st)),
reasons: stReasons.slice(0, 3),
grade: gradeLabel(st),
},
longTerm: {
score: Math.min(100, Math.round(lt)),
reasons: ltReasons.slice(0, 3),
grade: gradeLabel(lt),
},
};
}
function gradeLabel(score) {
if (score >= 80) return { label: 'Strong', color: 'emerald' };
if (score >= 60) return { label: 'Good', color: 'blue' };
if (score >= 40) return { label: 'Moderate', color: 'yellow' };
if (score >= 20) return { label: 'Weak', color: 'orange' };
return { label: 'Poor', color: 'red' };
}

View File

@ -3,14 +3,11 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import OptionsFlowPanel from '@/components/dashboard/OptionsFlowPanel';
import ScannerPanel from '@/components/dashboard/ScannerPanel';
import BacktestPanel from '@/components/dashboard/BacktestPanel';
import AlertsFeed from '@/components/dashboard/AlertsFeed';
import Watchlist from '@/components/dashboard/Watchlist';
import PerformanceTrackingPanel from '@/components/dashboard/PerformanceTrackingPanel';
import ReversalAlerts from '@/components/alerts/ReversalAlerts';
import ConvergenceAlerts from '@/components/alerts/ConvergenceAlerts';
import MarketScreenerPanel from '@/components/dashboard/MarketScreenerPanel';
import StockDetailPanel from '@/components/dashboard/StockDetailPanel';
import NewsFeedPanel from '@/components/dashboard/NewsFeedPanel';
export default function App() {
const [selectedSymbol, setSelectedSymbol] = useState(null);
@ -19,82 +16,69 @@ export default function App() {
<div className="min-h-screen bg-slate-950 text-slate-100">
<ConvergenceAlerts />
<ReversalAlerts />
{/* Header */}
<header className="border-b border-slate-800 bg-slate-900/50 backdrop-blur">
<div className="container mx-auto px-4 py-4">
<header className="border-b border-slate-800 bg-slate-900/50 backdrop-blur sticky top-0 z-40">
<div className="container mx-auto px-4 py-3">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent">
🚀 Institutional Flow Platform
</h1>
<p className="text-sm text-slate-400 mt-1">
<p className="text-xs text-slate-400 mt-0.5">
Real-time options flow · Tape analysis · Pro-grade signals
</p>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<div className="text-xs text-slate-400">Market Status</div>
<div className="text-sm font-semibold text-green-400">
RTH LIVE
</div>
<div className="text-sm font-semibold text-green-400">RTH LIVE</div>
</div>
</div>
</div>
</div>
</header>
{/* Main Content */}
{/* Main Content — full width */}
<div className="container mx-auto px-4 py-6">
<div className="grid grid-cols-12 gap-6">
{/* Left: Main Panels */}
<div className="col-span-9">
<Tabs defaultValue="market" className="w-full">
<TabsList className="bg-slate-900 border border-slate-800">
<TabsTrigger value="market" className="data-[state=active]:bg-slate-800">
📊 Market Analysis
</TabsTrigger>
<TabsTrigger value="flow" className="data-[state=active]:bg-slate-800">
🎯 Options Flow
</TabsTrigger>
<TabsTrigger value="scanner" className="data-[state=active]:bg-slate-800">
🔍 Multi-Signal Scanner
</TabsTrigger>
<TabsTrigger value="backtest" className="data-[state=active]:bg-slate-800">
📊 Backtests
</TabsTrigger>
</TabsList>
<Tabs defaultValue="market" className="w-full">
<TabsList className="bg-slate-900 border border-slate-800">
<TabsTrigger value="market" className="data-[state=active]:bg-slate-800">
📊 Market Analysis
</TabsTrigger>
<TabsTrigger value="flow" className="data-[state=active]:bg-slate-800">
🎯 Options Flow
</TabsTrigger>
<TabsTrigger value="scanner" className="data-[state=active]:bg-slate-800">
🔍 Multi-Signal Scanner
</TabsTrigger>
<TabsTrigger value="backtest" className="data-[state=active]:bg-slate-800">
📊 Backtests
</TabsTrigger>
</TabsList>
<TabsContent value="market" className="mt-6 space-y-6">
<MarketScreenerPanel onSelectSymbol={setSelectedSymbol} />
{selectedSymbol && (
<StockDetailPanel
symbol={selectedSymbol}
onClose={() => setSelectedSymbol(null)}
/>
)}
</TabsContent>
<TabsContent value="market" className="mt-6 space-y-6">
<MarketScreenerPanel onSelectSymbol={setSelectedSymbol} />
{selectedSymbol && (
<StockDetailPanel
symbol={selectedSymbol}
onClose={() => setSelectedSymbol(null)}
/>
)}
</TabsContent>
<TabsContent value="flow" className="mt-6">
<OptionsFlowPanel />
</TabsContent>
<TabsContent value="flow" className="mt-6">
<OptionsFlowPanel />
</TabsContent>
<TabsContent value="scanner" className="mt-6">
<ScannerPanel />
</TabsContent>
<TabsContent value="scanner" className="mt-6">
<ScannerPanel />
</TabsContent>
<TabsContent value="backtest" className="mt-6">
<BacktestPanel />
</TabsContent>
</Tabs>
</div>
{/* Right: News, Watchlist & Alerts Feed */}
<div className="col-span-3 space-y-6">
<NewsFeedPanel />
<Watchlist />
<AlertsFeed />
</div>
</div>
<TabsContent value="backtest" className="mt-6">
<BacktestPanel />
</TabsContent>
</Tabs>
{/* Bottom: Performance Tracking */}
<div className="mt-6">

View File

@ -1,299 +1,426 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3010';
const CAP_TABS = [
{ id: 'all', label: '🌐 All', color: 'text-slate-300' },
{ id: 'large', label: '🏛️ Large Cap', color: 'text-blue-400' },
{ id: 'mid', label: '🏢 Mid Cap', color: 'text-purple-400' },
{ id: 'small', label: '🔬 Small Cap', color: 'text-orange-400' },
{ id: 'etf', label: '📦 ETFs', color: 'text-green-400' },
];
// 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(2)}T`;
if (n >= 1e9) return `$${(n / 1e9).toFixed(2)}B`;
if (n >= 1e6) return `$${(n / 1e6).toFixed(2)}M`;
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)}`;
}
function fmtVolume(n) {
if (n == null) return '—';
if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`;
if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
if (n >= 1e3) return `${(n / 1e3).toFixed(0)}K`;
return `${n}`;
}
function ChangeBadge({ value }) {
if (value == null) return <span className="text-slate-500"></span>;
const positive = value >= 0;
return (
<span className={`font-semibold tabular-nums ${
positive ? 'text-emerald-400' : 'text-red-400'
}`}>
{positive ? '+' : ''}{value.toFixed(2)}%
</span>
);
}
function CapBadge({ tier }) {
const styles = {
LARGE: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
MID: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
SMALL: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
ETF: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
UNKNOWN: 'bg-slate-700 text-slate-400 border-slate-600',
// 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 (
<span className={`text-xs px-2 py-0.5 rounded border font-medium ${
styles[tier] || styles.UNKNOWN
}`}>
{tier}
</span>
<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>
);
}
// 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];
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>
<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.reasons.length > 0 && (
<div className="space-y-1">
{s.reasons.map((r, i) => (
<div key={i} className="text-[11px] text-slate-400 flex items-center gap-1.5">
<span className="text-slate-600"></span>
{r}
</div>
))}
</div>
)}
{/* Footer: sector + grade badge */}
<div className="flex items-center justify-between mt-3 pt-2.5 border-t border-slate-800/60">
<span className="text-[10px] text-slate-600 bg-slate-800/60 px-1.5 py-0.5 rounded">
{item.sector}
</span>
<span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded ${lc.badge}`}>
{s.grade.label}
</span>
</div>
</div>
);
}
// Swimlane column
function Swimlane({ title, icon, description, items = [], lane, onSelect, loading, accentClass }) {
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">{title}</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>
{/* Cards */}
<div className="flex-1 overflow-y-auto p-3 space-y-2.5 max-h-[calc(100vh-320px)]">
{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>
</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 [activeTab, setActiveTab] = useState('all');
const [sortBy, setSortBy] = useState('market_cap');
const [sortDir, setSortDir] = useState('desc');
const [search, setSearch] = useState('');
const [data, setData] = useState([]);
const [leaderboard, setLeaderboard] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [lastUpdated, setLastUpdated] = useState(null);
const [screenerData, setScreenerData] = useState(null);
const [leaderboard, setLeaderboard] = useState(null);
const [loading, setLoading] = useState(true);
const [lastUpdated, setLastUpdated] = useState(null);
const [capFilter, setCapFilter] = useState('all');
const fetchData = useCallback(async () => {
try {
const cap = activeTab === 'all' ? '' : activeTab;
const url = `${API_BASE}/api/market/universe${cap ? `?cap=${cap}` : ''}`;
const [universeRes, lbRes] = await Promise.allSettled([
fetch(url).then(r => r.json()),
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 (universeRes.status === 'fulfilled' && universeRes.value.success) {
setData(universeRes.value.data || []);
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);
}
setError(null);
} catch (err) {
setError(err.message);
console.error('[MarketScreenerPanel]', err);
} finally {
setLoading(false);
}
}, [activeTab]);
}, []);
useEffect(() => {
setLoading(true);
fetchData();
const interval = setInterval(fetchData, 60000);
const interval = setInterval(fetchData, 90000);
return () => clearInterval(interval);
}, [fetchData]);
const processed = [...data]
.filter(r => {
if (!search) return true;
const q = search.toUpperCase();
return r.symbol.includes(q) || r.name.toUpperCase().includes(q) || r.sector?.toUpperCase().includes(q);
})
.sort((a, b) => {
const av = a[sortBy] ?? -Infinity;
const bv = b[sortBy] ?? -Infinity;
return sortDir === 'desc' ? bv - av : av - bv;
// 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 toggleSort = (col) => {
if (sortBy === col) setSortDir(d => d === 'desc' ? 'asc' : 'desc');
else { setSortBy(col); setSortDir('desc'); }
};
const SortIcon = ({ col }) => {
if (sortBy !== col) return <span className="text-slate-600 ml-1"></span>;
return <span className="text-blue-400 ml-1">{sortDir === 'desc' ? '↓' : '↑'}</span>;
};
const CAP_FILTERS = [
{ id: 'all', label: 'All' },
{ id: 'large', label: '🏛 Large' },
{ id: 'mid', label: '🏢 Mid' },
{ id: 'small', label: '🔬 Small' },
{ id: 'etf', label: '📦 ETF' },
];
return (
<div className="space-y-4">
<div className="space-y-5">
{/* Leaderboard strip */}
{leaderboard && (
<div className="grid grid-cols-3 gap-3">
<LeaderCard title="🚀 Top Gainers" items={leaderboard.top_gainers} type="gain" />
<LeaderCard title="📉 Top Losers" items={leaderboard.top_losers} type="loss" />
<LeaderCard title="🔥 Most Active" items={leaderboard.most_active} type="volume" />
<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>
)}
{/* Main screener card */}
<div className="bg-slate-900 border border-slate-700/50 rounded-xl overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-700/50">
<div className="flex items-center gap-3">
<h2 className="text-white font-bold text-lg">📊 Market Screener</h2>
{lastUpdated && (
<span className="text-xs text-slate-500">
Updated {lastUpdated.toLocaleTimeString()}
</span>
)}
{loading && <span className="text-xs text-blue-400 animate-pulse">Refreshing...</span>}
{/* Screener 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>
<p className="text-slate-500 text-xs mt-0.5">
Stocks ranked by trading suitability {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>
<div className="flex items-center gap-2">
<input
type="text"
placeholder="Search symbol or name..."
value={search}
onChange={e => setSearch(e.target.value)}
className="bg-slate-800 border border-slate-600 rounded-lg px-3 py-1.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 w-52"
{/* Search */}
<div className="flex items-center gap-3">
<SearchBar
onResult={(sym) => onSelectSymbol?.(sym)}
onClear={() => {}}
/>
<button
onClick={fetchData}
className="bg-slate-800 hover:bg-slate-700 border border-slate-600 text-slate-300 px-3 py-1.5 rounded-lg text-sm transition-colors"
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"
>
</button>
</div>
</div>
{/* Cap tier tabs */}
<div className="flex border-b border-slate-700/50 bg-slate-950/30">
{CAP_TABS.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2.5 text-sm font-medium transition-all ${
activeTab === tab.id
? `border-b-2 border-blue-500 ${tab.color} bg-blue-500/5`
: 'text-slate-500 hover:text-slate-300'
}`}
>
{tab.label}
</button>
))}
{/* ─── 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"
icon="🔄"
lane="shortTerm"
accentClass="border-blue-500/25"
description="MACD setup · Near SMA50 · Analyst upside catalyst"
items={filterByCap(screenerData?.shortTerm)}
loading={loading}
onSelect={onSelectSymbol}
/>
<Swimlane
title="Long Term"
icon="🌱"
lane="longTerm"
accentClass="border-emerald-500/25"
description="Above SMA200 · Earnings growth · Analyst conviction"
items={filterByCap(screenerData?.longTerm)}
loading={loading}
onSelect={onSelectSymbol}
/>
</div>
{/* Table */}
{error ? (
<div className="p-8 text-center text-red-400">
<p> {error}</p>
<button onClick={fetchData} className="mt-2 text-blue-400 underline text-sm">Retry</button>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-slate-500 text-xs uppercase tracking-wide bg-slate-950/50">
<th className="text-left px-4 py-3 font-medium">Symbol</th>
<th className="text-left px-4 py-3 font-medium">Name</th>
<th className="text-left px-4 py-3 font-medium">Sector</th>
<th
className="text-right px-4 py-3 font-medium cursor-pointer hover:text-white transition-colors"
onClick={() => toggleSort('price')}
>Price <SortIcon col="price" /></th>
<th
className="text-right px-4 py-3 font-medium cursor-pointer hover:text-white transition-colors"
onClick={() => toggleSort('change_pct')}
>Change <SortIcon col="change_pct" /></th>
<th
className="text-right px-4 py-3 font-medium cursor-pointer hover:text-white transition-colors"
onClick={() => toggleSort('market_cap')}
>Mkt Cap <SortIcon col="market_cap" /></th>
<th
className="text-right px-4 py-3 font-medium cursor-pointer hover:text-white transition-colors"
onClick={() => toggleSort('volume')}
>Volume <SortIcon col="volume" /></th>
<th className="text-center px-4 py-3 font-medium">Cap Tier</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{loading && data.length === 0 ? (
Array.from({ length: 10 }).map((_, i) => (
<tr key={i} className="animate-pulse">
{Array.from({ length: 8 }).map((_, j) => (
<td key={j} className="px-4 py-3">
<div className="h-4 bg-slate-800 rounded w-full" />
</td>
))}
</tr>
))
) : processed.length === 0 ? (
<tr><td colSpan={8} className="px-4 py-12 text-center text-slate-500">No results found</td></tr>
) : (
processed.map(row => (
<tr
key={row.symbol}
onClick={() => onSelectSymbol?.(row.symbol)}
className="hover:bg-slate-800/40 cursor-pointer transition-colors group"
>
<td className="px-4 py-3">
<span className="font-bold text-white group-hover:text-blue-400 transition-colors">
{row.symbol}
</span>
</td>
<td className="px-4 py-3 text-slate-400 max-w-[180px] truncate">{row.name}</td>
<td className="px-4 py-3">
<span className="text-xs text-slate-500 bg-slate-800 px-2 py-0.5 rounded">
{row.sector}
</span>
</td>
<td className="px-4 py-3 text-right tabular-nums">
<span className="text-white font-medium">
{row.price != null ? `$${row.price.toFixed(2)}` : '—'}
</span>
</td>
<td className="px-4 py-3 text-right">
<ChangeBadge value={row.change_pct} />
</td>
<td className="px-4 py-3 text-right text-slate-300 tabular-nums">
{fmtMktCap(row.market_cap)}
</td>
<td className="px-4 py-3 text-right text-slate-400 tabular-nums">
{fmtVolume(row.volume)}
</td>
<td className="px-4 py-3 text-center">
<CapBadge tier={row.capTier} />
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
{/* Footer */}
<div className="px-4 py-2 border-t border-slate-700/50 bg-slate-950/30 flex justify-between items-center">
<span className="text-xs text-slate-600">{processed.length} of {data.length} symbols</span>
<span className="text-xs text-slate-600">Powered by Yahoo Finance Free data 60s refresh</span>
<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)
</span>
<span className="text-xs text-slate-600">Data: Yahoo Finance · Refreshes every 90s</span>
</div>
</div>
</div>
);
}
function LeaderCard({ title, items = [], type }) {
// Leaderboard helper
function LeaderCard({ title, items = [], type, onSelect }) {
return (
<div className="bg-slate-900 border border-slate-700/50 rounded-xl p-3">
<div className="text-xs font-semibold text-slate-400 mb-2">{title}</div>
<div className="space-y-1.5">
<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} className="flex items-center justify-between">
<span className="text-white font-medium text-sm">{item.symbol}</span>
<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 >= 1e6 ? `${(item.volume / 1e6).toFixed(1)}M` : item.volume}
{item.volume >= 1e9 ? `${(item.volume / 1e9).toFixed(1)}B` :
item.volume >= 1e6 ? `${(item.volume / 1e6).toFixed(1)}M` : item.volume}
</span>
) : (
<ChangeBadge value={item.change_pct} />
<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">Loading...</div>}
{items.length === 0 && <div className="text-slate-600 text-xs py-2">Loading...</div>}
</div>
</div>
);