feat: swimlane screener suitability scoring global ticker search remove side panels
Build Institutional Trader / build-and-deploy (push) Successful in 2m7s
Details
Build Institutional Trader / build-and-deploy (push) Successful in 2m7s
Details
This commit is contained in:
parent
f6f35dba00
commit
911693da88
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 0–100. 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): 0–35 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: 0–25 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): 0–20 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: 0–20 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: 0–30 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): 0–25 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: 0–25 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: 0–20 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): 0–30 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: 0–25 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): 0–25 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): 0–20 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' };
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue