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 { fetchBasicQuote, fetchQuoteSummary, fetchTechnicals, fetchFullAnalysis } from '../services/fundamentalsService.js';
|
||||||
import { fetchInstitutionalHolders, fetchInsiderTransactions } from '../services/institutionalHoldersService.js';
|
import { fetchInstitutionalHolders, fetchInsiderTransactions } from '../services/institutionalHoldersService.js';
|
||||||
import { fetchNewsForSymbol, fetchMarketNews } from '../services/newsService.js';
|
import { fetchNewsForSymbol, fetchMarketNews } from '../services/newsService.js';
|
||||||
|
import { scoreSuitability } from '../services/suitabilityService.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const universeCache = new NodeCache({ stdTTL: 60 });
|
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;
|
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 OptionsFlowPanel from '@/components/dashboard/OptionsFlowPanel';
|
||||||
import ScannerPanel from '@/components/dashboard/ScannerPanel';
|
import ScannerPanel from '@/components/dashboard/ScannerPanel';
|
||||||
import BacktestPanel from '@/components/dashboard/BacktestPanel';
|
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 PerformanceTrackingPanel from '@/components/dashboard/PerformanceTrackingPanel';
|
||||||
import ReversalAlerts from '@/components/alerts/ReversalAlerts';
|
import ReversalAlerts from '@/components/alerts/ReversalAlerts';
|
||||||
import ConvergenceAlerts from '@/components/alerts/ConvergenceAlerts';
|
import ConvergenceAlerts from '@/components/alerts/ConvergenceAlerts';
|
||||||
import MarketScreenerPanel from '@/components/dashboard/MarketScreenerPanel';
|
import MarketScreenerPanel from '@/components/dashboard/MarketScreenerPanel';
|
||||||
import StockDetailPanel from '@/components/dashboard/StockDetailPanel';
|
import StockDetailPanel from '@/components/dashboard/StockDetailPanel';
|
||||||
import NewsFeedPanel from '@/components/dashboard/NewsFeedPanel';
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [selectedSymbol, setSelectedSymbol] = useState(null);
|
const [selectedSymbol, setSelectedSymbol] = useState(null);
|
||||||
|
|
@ -19,82 +16,69 @@ export default function App() {
|
||||||
<div className="min-h-screen bg-slate-950 text-slate-100">
|
<div className="min-h-screen bg-slate-950 text-slate-100">
|
||||||
<ConvergenceAlerts />
|
<ConvergenceAlerts />
|
||||||
<ReversalAlerts />
|
<ReversalAlerts />
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="border-b border-slate-800 bg-slate-900/50 backdrop-blur">
|
<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-4">
|
<div className="container mx-auto px-4 py-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent">
|
<h1 className="text-2xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent">
|
||||||
🚀 Institutional Flow Platform
|
🚀 Institutional Flow Platform
|
||||||
</h1>
|
</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
|
Real-time options flow · Tape analysis · Pro-grade signals
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-xs text-slate-400">Market Status</div>
|
<div className="text-xs text-slate-400">Market Status</div>
|
||||||
<div className="text-sm font-semibold text-green-400">
|
<div className="text-sm font-semibold text-green-400">RTH • LIVE</div>
|
||||||
RTH • LIVE
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content — full width */}
|
||||||
<div className="container mx-auto px-4 py-6">
|
<div className="container mx-auto px-4 py-6">
|
||||||
<div className="grid grid-cols-12 gap-6">
|
<Tabs defaultValue="market" className="w-full">
|
||||||
{/* Left: Main Panels */}
|
<TabsList className="bg-slate-900 border border-slate-800">
|
||||||
<div className="col-span-9">
|
<TabsTrigger value="market" className="data-[state=active]:bg-slate-800">
|
||||||
<Tabs defaultValue="market" className="w-full">
|
📊 Market Analysis
|
||||||
<TabsList className="bg-slate-900 border border-slate-800">
|
</TabsTrigger>
|
||||||
<TabsTrigger value="market" className="data-[state=active]:bg-slate-800">
|
<TabsTrigger value="flow" className="data-[state=active]:bg-slate-800">
|
||||||
📊 Market Analysis
|
🎯 Options Flow
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="flow" className="data-[state=active]:bg-slate-800">
|
<TabsTrigger value="scanner" className="data-[state=active]:bg-slate-800">
|
||||||
🎯 Options Flow
|
🔍 Multi-Signal Scanner
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="scanner" className="data-[state=active]:bg-slate-800">
|
<TabsTrigger value="backtest" className="data-[state=active]:bg-slate-800">
|
||||||
🔍 Multi-Signal Scanner
|
📊 Backtests
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="backtest" className="data-[state=active]:bg-slate-800">
|
</TabsList>
|
||||||
📊 Backtests
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="market" className="mt-6 space-y-6">
|
<TabsContent value="market" className="mt-6 space-y-6">
|
||||||
<MarketScreenerPanel onSelectSymbol={setSelectedSymbol} />
|
<MarketScreenerPanel onSelectSymbol={setSelectedSymbol} />
|
||||||
{selectedSymbol && (
|
{selectedSymbol && (
|
||||||
<StockDetailPanel
|
<StockDetailPanel
|
||||||
symbol={selectedSymbol}
|
symbol={selectedSymbol}
|
||||||
onClose={() => setSelectedSymbol(null)}
|
onClose={() => setSelectedSymbol(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="flow" className="mt-6">
|
<TabsContent value="flow" className="mt-6">
|
||||||
<OptionsFlowPanel />
|
<OptionsFlowPanel />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="scanner" className="mt-6">
|
<TabsContent value="scanner" className="mt-6">
|
||||||
<ScannerPanel />
|
<ScannerPanel />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="backtest" className="mt-6">
|
<TabsContent value="backtest" className="mt-6">
|
||||||
<BacktestPanel />
|
<BacktestPanel />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right: News, Watchlist & Alerts Feed */}
|
|
||||||
<div className="col-span-3 space-y-6">
|
|
||||||
<NewsFeedPanel />
|
|
||||||
<Watchlist />
|
|
||||||
<AlertsFeed />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom: Performance Tracking */}
|
{/* Bottom: Performance Tracking */}
|
||||||
<div className="mt-6">
|
<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 API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3010';
|
||||||
|
|
||||||
const CAP_TABS = [
|
// ─── Formatters ───────────────────────────────────────────────────────────────
|
||||||
{ id: 'all', label: '🌐 All', color: 'text-slate-300' },
|
function fmtPrice(n) {
|
||||||
{ id: 'large', label: '🏛️ Large Cap', color: 'text-blue-400' },
|
if (n == null) return '—';
|
||||||
{ id: 'mid', label: '🏢 Mid Cap', color: 'text-purple-400' },
|
return `$${n.toFixed(2)}`;
|
||||||
{ id: 'small', label: '🔬 Small Cap', color: 'text-orange-400' },
|
}
|
||||||
{ id: 'etf', label: '📦 ETFs', color: 'text-green-400' },
|
function fmtChange(n) {
|
||||||
];
|
if (n == null) return null;
|
||||||
|
return { value: n, label: `${n >= 0 ? '+' : ''}${n.toFixed(2)}%`, positive: n >= 0 };
|
||||||
|
}
|
||||||
function fmtMktCap(n) {
|
function fmtMktCap(n) {
|
||||||
if (n == null) return '—';
|
if (n == null) return '—';
|
||||||
if (n >= 1e12) return `$${(n / 1e12).toFixed(2)}T`;
|
if (n >= 1e12) return `$${(n / 1e12).toFixed(1)}T`;
|
||||||
if (n >= 1e9) return `$${(n / 1e9).toFixed(2)}B`;
|
if (n >= 1e9) return `$${(n / 1e9).toFixed(1)}B`;
|
||||||
if (n >= 1e6) return `$${(n / 1e6).toFixed(2)}M`;
|
if (n >= 1e6) return `$${(n / 1e6).toFixed(1)}M`;
|
||||||
return `$${n.toFixed(0)}`;
|
return `$${n.toFixed(0)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtVolume(n) {
|
// ─── Score ring component ─────────────────────────────────────────────────────
|
||||||
if (n == null) return '—';
|
function ScoreRing({ score, color }) {
|
||||||
if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`;
|
const r = 20;
|
||||||
if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
|
const circ = 2 * Math.PI * r;
|
||||||
if (n >= 1e3) return `${(n / 1e3).toFixed(0)}K`;
|
const fill = (score / 100) * circ;
|
||||||
return `${n}`;
|
const colors = {
|
||||||
}
|
emerald: '#10b981',
|
||||||
|
blue: '#3b82f6',
|
||||||
function ChangeBadge({ value }) {
|
yellow: '#eab308',
|
||||||
if (value == null) return <span className="text-slate-500">—</span>;
|
orange: '#f97316',
|
||||||
const positive = value >= 0;
|
red: '#ef4444',
|
||||||
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',
|
|
||||||
};
|
};
|
||||||
|
const stroke = colors[color] || colors.blue;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={`text-xs px-2 py-0.5 rounded border font-medium ${
|
<div className="relative w-14 h-14 flex-shrink-0">
|
||||||
styles[tier] || styles.UNKNOWN
|
<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" />
|
||||||
{tier}
|
<circle
|
||||||
</span>
|
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 }) {
|
export default function MarketScreenerPanel({ onSelectSymbol }) {
|
||||||
const [activeTab, setActiveTab] = useState('all');
|
const [screenerData, setScreenerData] = useState(null);
|
||||||
const [sortBy, setSortBy] = useState('market_cap');
|
const [leaderboard, setLeaderboard] = useState(null);
|
||||||
const [sortDir, setSortDir] = useState('desc');
|
const [loading, setLoading] = useState(true);
|
||||||
const [search, setSearch] = useState('');
|
const [lastUpdated, setLastUpdated] = useState(null);
|
||||||
const [data, setData] = useState([]);
|
const [capFilter, setCapFilter] = useState('all');
|
||||||
const [leaderboard, setLeaderboard] = useState(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
const [lastUpdated, setLastUpdated] = useState(null);
|
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const cap = activeTab === 'all' ? '' : activeTab;
|
const [screenerRes, lbRes] = await Promise.allSettled([
|
||||||
const url = `${API_BASE}/api/market/universe${cap ? `?cap=${cap}` : ''}`;
|
fetch(`${API_BASE}/api/market/screener`).then(r => r.json()),
|
||||||
const [universeRes, lbRes] = await Promise.allSettled([
|
|
||||||
fetch(url).then(r => r.json()),
|
|
||||||
fetch(`${API_BASE}/api/market/leaderboard`).then(r => r.json()),
|
fetch(`${API_BASE}/api/market/leaderboard`).then(r => r.json()),
|
||||||
]);
|
]);
|
||||||
|
if (screenerRes.status === 'fulfilled' && screenerRes.value.success) {
|
||||||
if (universeRes.status === 'fulfilled' && universeRes.value.success) {
|
setScreenerData(screenerRes.value.data);
|
||||||
setData(universeRes.value.data || []);
|
|
||||||
setLastUpdated(new Date());
|
setLastUpdated(new Date());
|
||||||
}
|
}
|
||||||
if (lbRes.status === 'fulfilled' && lbRes.value.success) {
|
if (lbRes.status === 'fulfilled' && lbRes.value.success) {
|
||||||
setLeaderboard(lbRes.value.data);
|
setLeaderboard(lbRes.value.data);
|
||||||
}
|
}
|
||||||
setError(null);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
console.error('[MarketScreenerPanel]', err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [activeTab]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
fetchData();
|
fetchData();
|
||||||
const interval = setInterval(fetchData, 60000);
|
const interval = setInterval(fetchData, 90000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
const processed = [...data]
|
// Filter lanes by cap tier
|
||||||
.filter(r => {
|
const filterByCap = (arr) => {
|
||||||
if (!search) return true;
|
if (!arr || capFilter === 'all') return arr || [];
|
||||||
const q = search.toUpperCase();
|
return arr.filter(s => {
|
||||||
return r.symbol.includes(q) || r.name.toUpperCase().includes(q) || r.sector?.toUpperCase().includes(q);
|
if (capFilter === 'etf') return s.isETF;
|
||||||
})
|
return s.capTier === capFilter.toUpperCase() && !s.isETF;
|
||||||
.sort((a, b) => {
|
|
||||||
const av = a[sortBy] ?? -Infinity;
|
|
||||||
const bv = b[sortBy] ?? -Infinity;
|
|
||||||
return sortDir === 'desc' ? bv - av : av - bv;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleSort = (col) => {
|
|
||||||
if (sortBy === col) setSortDir(d => d === 'desc' ? 'asc' : 'desc');
|
|
||||||
else { setSortBy(col); setSortDir('desc'); }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const SortIcon = ({ col }) => {
|
const CAP_FILTERS = [
|
||||||
if (sortBy !== col) return <span className="text-slate-600 ml-1">⇅</span>;
|
{ id: 'all', label: 'All' },
|
||||||
return <span className="text-blue-400 ml-1">{sortDir === 'desc' ? '↓' : '↑'}</span>;
|
{ id: 'large', label: '🏛 Large' },
|
||||||
};
|
{ id: 'mid', label: '🏢 Mid' },
|
||||||
|
{ id: 'small', label: '🔬 Small' },
|
||||||
|
{ id: 'etf', label: '📦 ETF' },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-5">
|
||||||
{/* Leaderboard strip */}
|
{/* Leaderboard strip */}
|
||||||
{leaderboard && (
|
{leaderboard && (
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<LeaderCard title="🚀 Top Gainers" items={leaderboard.top_gainers} type="gain" />
|
<LeaderCard title="🚀 Top Gainers" items={leaderboard.top_gainers} type="gain" onSelect={onSelectSymbol} />
|
||||||
<LeaderCard title="📉 Top Losers" items={leaderboard.top_losers} type="loss" />
|
<LeaderCard title="📉 Top Losers" items={leaderboard.top_losers} type="loss" onSelect={onSelectSymbol} />
|
||||||
<LeaderCard title="🔥 Most Active" items={leaderboard.most_active} type="volume" />
|
<LeaderCard title="🔥 Most Active" items={leaderboard.most_active} type="volume" onSelect={onSelectSymbol} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main screener card */}
|
{/* Screener header */}
|
||||||
<div className="bg-slate-900 border border-slate-700/50 rounded-xl overflow-hidden">
|
<div className="bg-slate-900 border border-slate-700/50 rounded-2xl overflow-hidden">
|
||||||
{/* Header */}
|
<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 justify-between px-4 py-3 border-b border-slate-700/50">
|
<div className="flex items-center gap-4 flex-wrap">
|
||||||
<div className="flex items-center gap-3">
|
<div>
|
||||||
<h2 className="text-white font-bold text-lg">📊 Market Screener</h2>
|
<h2 className="text-white font-bold text-lg">📊 Trading Screener</h2>
|
||||||
{lastUpdated && (
|
<p className="text-slate-500 text-xs mt-0.5">
|
||||||
<span className="text-xs text-slate-500">
|
Stocks ranked by trading suitability • {lastUpdated ? `Updated ${lastUpdated.toLocaleTimeString()}` : 'Loading...'}
|
||||||
Updated {lastUpdated.toLocaleTimeString()}
|
{loading && <span className="ml-2 text-blue-400 animate-pulse">Refreshing...</span>}
|
||||||
</span>
|
</p>
|
||||||
)}
|
</div>
|
||||||
{loading && <span className="text-xs text-blue-400 animate-pulse">Refreshing...</span>}
|
|
||||||
|
{/* 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>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
{/* Search */}
|
||||||
type="text"
|
<div className="flex items-center gap-3">
|
||||||
placeholder="Search symbol or name..."
|
<SearchBar
|
||||||
value={search}
|
onResult={(sym) => onSelectSymbol?.(sym)}
|
||||||
onChange={e => setSearch(e.target.value)}
|
onClear={() => {}}
|
||||||
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"
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={fetchData}
|
onClick={() => { setLoading(true); 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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cap tier tabs */}
|
{/* ─── 3 Swimlanes ─────────────────────────────────────────────────── */}
|
||||||
<div className="flex border-b border-slate-700/50 bg-slate-950/30">
|
<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">
|
||||||
{CAP_TABS.map(tab => (
|
<Swimlane
|
||||||
<button
|
title="Daytrade"
|
||||||
key={tab.id}
|
icon="⚡"
|
||||||
onClick={() => setActiveTab(tab.id)}
|
lane="daytrade"
|
||||||
className={`px-4 py-2.5 text-sm font-medium transition-all ${
|
accentClass="border-orange-500/25"
|
||||||
activeTab === tab.id
|
description="High relative volume · Elevated beta · Momentum RSI"
|
||||||
? `border-b-2 border-blue-500 ${tab.color} bg-blue-500/5`
|
items={filterByCap(screenerData?.daytrade)}
|
||||||
: 'text-slate-500 hover:text-slate-300'
|
loading={loading}
|
||||||
}`}
|
onSelect={onSelectSymbol}
|
||||||
>
|
/>
|
||||||
{tab.label}
|
<Swimlane
|
||||||
</button>
|
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>
|
</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 */}
|
{/* Footer */}
|
||||||
<div className="px-4 py-2 border-t border-slate-700/50 bg-slate-950/30 flex justify-between items-center">
|
<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">{processed.length} of {data.length} symbols</span>
|
<span className="text-xs text-slate-600">
|
||||||
<span className="text-xs text-slate-600">Powered by Yahoo Finance • Free data • 60s refresh</span>
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LeaderCard({ title, items = [], type }) {
|
// ─── Leaderboard helper ───────────────────────────────────────────────────────
|
||||||
|
function LeaderCard({ title, items = [], type, onSelect }) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-slate-900 border border-slate-700/50 rounded-xl p-3">
|
<div className="bg-slate-900 border border-slate-700/40 rounded-xl p-4">
|
||||||
<div className="text-xs font-semibold text-slate-400 mb-2">{title}</div>
|
<div className="text-xs font-bold text-slate-400 mb-3 uppercase tracking-wider">{title}</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
{items.slice(0, 5).map(item => (
|
{items.slice(0, 5).map(item => (
|
||||||
<div key={item.symbol} className="flex items-center justify-between">
|
<div
|
||||||
<span className="text-white font-medium text-sm">{item.symbol}</span>
|
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' ? (
|
{type === 'volume' ? (
|
||||||
<span className="text-slate-400 text-xs tabular-nums">
|
<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>
|
</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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue