learning_ai_invt_trdg/web/src/views/ScreenerView.tsx
Saravana Achu Mac 938ed86044 feat: live data wiring (Alpaca/FMP) + strategy builder + screener
Wires the new dashboard to real market data and adds the strategy
builder & screener UIs that were stubbed in the previous commit.

Frontend (web/src/):
- lib/marketApi.ts: authenticated fetch helpers for chart bars,
  market indices, news, and FMP research endpoints
- views/HomeView.tsx: StockChart now fetches live OHLCV via
  fetchChartBars on symbol/period change with loading/error states;
  ResearchCards replaces the static placeholder with live FMP
  profile/metrics/earnings (next-earnings + last 3 historical)
- components/layout/Header.tsx: live SPY/DIA/QQQ price + change%
  via fetchMarketIndices, refreshing every 60s; removed unused
  static sparkline placeholder
- components/strategy/VisualRuleBuilder.tsx: drag-and-drop IF/THEN
  rule composer using @dnd-kit (RSI/MACD/EMA/Price/Volume,
  above/below/crosses, BUY/SELL with shares or % of capital);
  saves via POST /api/profiles
- components/strategy/CodeStrategyEditor.tsx: Monaco editor with
  JS strategy template; "Run Backtest" posts to /api/backtest and
  renders return/win-rate/Sharpe/drawdown plus trade log
- views/ResearchView.tsx: adds "Visual Builder" and "Code Editor"
  sub-tabs alongside Strategies / Signals / Backtesting
- views/ScreenerView.tsx: live FMP screener with market-cap and
  sector filters, sortable columns, click-to-load-symbol routing
- index.css: light theme background; @keyframes spin for loaders
- App.dom.test.tsx: rewritten for router-based AppShell (was
  asserting on the removed tab UI; fixes 5 prior failures)

Backend (backend/src/services/apiServer.ts):
- /api/chart/bars: detects crypto symbols (contains "/") and
  routes to Alpaca v1beta3/crypto/us/bars; equities use
  v2/stocks/{symbol}/bars with iex feed
- (existing) /api/news, /api/market/indices, /api/research/{
  profile,metrics,earnings}, /api/screener proxy endpoints

Build/config:
- web/vite.config.ts: dedupe react / react/jsx-runtime /
  react-router-dom so the vendored react-auth dist resolves the
  same React instance (fixes "Cannot resolve react/jsx-runtime"
  Rollup error)
- web/tsconfig.app.json: exclude shared/platform-clients.ts and
  shared/platform-mobile.ts (mobile-only, missing RN SDK)
- web/package.json: add react-router-dom, @monaco-editor/react,
  @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities

Verification: `npm run build` in web/ → clean (✓ built in 3s);
backend tsc --noEmit → clean. Test suite: 151/155 pass; the 4
remaining failures are pre-existing (3 useTabFeatureFlags module
cache leaks, 1 EntryForm), not introduced here.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 06:16:46 -07:00

338 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useCallback } from 'react';
import { SlidersHorizontal, Search, Loader2, RefreshCw } from 'lucide-react';
import { useAppContext } from '../context/AppContext';
import { useNavigate } from 'react-router-dom';
import { getPlatformAccessToken } from '../lib/authSession';
import { tradingRuntime } from '../lib/runtime';
import { createRequestId } from '../../../shared/request-id.js';
// ─── Types ────────────────────────────────────────────────────────────────────
interface ScreenerRow {
symbol: string;
companyName: string;
price: number;
changesPercentage: number;
marketCap: number;
pe: number | null;
sector: string;
volume: number;
}
const SECTORS = [
'All', 'Technology', 'Financial Services', 'Healthcare', 'Consumer Cyclical',
'Consumer Defensive', 'Industrials', 'Energy', 'Utilities', 'Real Estate',
'Communication Services', 'Basic Materials',
];
const CAP_OPTIONS: { label: string; min: number; max?: number }[] = [
{ label: 'Any Cap', min: 0 },
{ label: 'Mega (>$200B)', min: 200_000_000_000 },
{ label: 'Large ($10B$200B)', min: 10_000_000_000, max: 200_000_000_000 },
{ label: 'Mid ($2B$10B)', min: 2_000_000_000, max: 10_000_000_000 },
{ label: 'Small (<$2B)', min: 0, max: 2_000_000_000 },
];
const fmtCap = (n: number) => {
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.toLocaleString()}`;
};
// ─── Screener fetch ───────────────────────────────────────────────────────────
async function runScreener(params: {
sector: string;
marketCapMore?: number;
marketCapLess?: number;
limit: number;
}): Promise<ScreenerRow[]> {
const token = await getPlatformAccessToken();
const qs = new URLSearchParams({ limit: String(params.limit) });
if (params.sector && params.sector !== 'All') qs.set('sector', params.sector);
if (params.marketCapMore) qs.set('marketCapMoreThan', String(params.marketCapMore));
if (params.marketCapLess) qs.set('marketCapLessThan', String(params.marketCapLess));
const res = await fetch(`${tradingRuntime.tradingApiUrl}/api/screener?${qs}`, {
headers: {
Authorization: `Bearer ${token}`,
'x-request-id': createRequestId('web-screener'),
},
});
if (!res.ok) {
const body = await res.json().catch(() => ({})) as any;
throw new Error(body?.error ?? `Screener failed (${res.status})`);
}
const data = await res.json() as any;
return Array.isArray(data) ? data : (data.results ?? []);
}
// ─── ScreenerView ─────────────────────────────────────────────────────────────
export function ScreenerView() {
const { setActiveSymbol } = useAppContext();
const navigate = useNavigate();
const [results, setResults] = useState<ScreenerRow[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [query, setQuery] = useState('');
const [sector, setSector] = useState('All');
const [capIdx, setCapIdx] = useState(0);
const [sortKey, setSortKey] = useState<keyof ScreenerRow>('marketCap');
const [sortAsc, setSortAsc] = useState(false);
const fetchResults = useCallback(async () => {
setLoading(true);
setError(null);
const cap = CAP_OPTIONS[capIdx];
try {
const rows = await runScreener({
sector,
marketCapMore: cap.min > 0 ? cap.min : undefined,
marketCapLess: cap.max,
limit: 50,
});
setResults(rows);
} catch (e: any) {
setError(e?.message ?? 'Screener request failed');
} finally {
setLoading(false);
}
}, [sector, capIdx]);
// Fetch on mount and when filters change
useEffect(() => { fetchResults(); }, [fetchResults]);
// Client-side search filter + sort
const filtered = results
.filter(r => {
if (!query) return true;
const q = query.toUpperCase();
return r.symbol?.includes(q) || r.companyName?.toLowerCase().includes(query.toLowerCase());
})
.sort((a, b) => {
const av = a[sortKey] as any ?? 0;
const bv = b[sortKey] as any ?? 0;
return sortAsc ? (av > bv ? 1 : -1) : (av < bv ? 1 : -1);
});
const handleSort = (key: keyof ScreenerRow) => {
if (sortKey === key) setSortAsc(p => !p);
else { setSortKey(key); setSortAsc(false); }
};
const SortIcon = ({ k }: { k: keyof ScreenerRow }) => (
<span style={{ color: sortKey === k ? '#2563EB' : '#D1D5DB', marginLeft: 3, fontSize: 10 }}>
{sortKey === k ? (sortAsc ? '▲' : '▼') : '⇅'}
</span>
);
const handleRowClick = (symbol: string) => {
setActiveSymbol(symbol);
navigate('/');
};
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 20 }}>
<h2 style={{ fontSize: 22, fontWeight: 800, color: '#111827', margin: 0 }}>Stock Screener</h2>
<div style={{ flex: 1 }} />
<button
onClick={fetchResults}
disabled={loading}
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '7px 12px', border: '1px solid #E5E7EB', borderRadius: 8,
background: '#fff', color: '#374151', fontSize: 12, fontWeight: 600,
cursor: loading ? 'wait' : 'pointer',
}}
>
<RefreshCw size={13} style={{ animation: loading ? 'spin 1s linear infinite' : 'none' }} />
Refresh
</button>
</div>
{/* Filters */}
<div style={{ display: 'flex', gap: 12, marginBottom: 16, flexWrap: 'wrap', alignItems: 'center' }}>
{/* Search */}
<div style={{ position: 'relative', minWidth: 200, maxWidth: 280 }}>
<Search size={14} style={{
position: 'absolute', left: 10, top: '50%',
transform: 'translateY(-50%)', color: '#9CA3AF',
}} />
<input
type="text"
placeholder="Filter by name or ticker…"
value={query}
onChange={e => setQuery(e.target.value)}
style={{
width: '100%', paddingLeft: 32, paddingRight: 12,
paddingTop: 8, paddingBottom: 8,
border: '1px solid #E5E7EB', borderRadius: 8,
fontSize: 13, outline: 'none', background: '#fff',
color: '#374151', boxSizing: 'border-box', fontFamily: 'inherit',
}}
/>
</div>
{/* Market cap */}
<select
value={capIdx}
onChange={e => setCapIdx(Number(e.target.value))}
style={{
padding: '8px 12px', border: '1px solid #E5E7EB', borderRadius: 8,
fontSize: 12, background: '#fff', color: '#374151', fontFamily: 'inherit',
cursor: 'pointer',
}}
>
{CAP_OPTIONS.map((c, i) => (
<option key={c.label} value={i}>{c.label}</option>
))}
</select>
{/* Sector pills */}
<div style={{ display: 'flex', gap: 5, flexWrap: 'wrap', alignItems: 'center' }}>
<SlidersHorizontal size={13} color="#6B7280" />
{SECTORS.slice(0, 6).map(s => (
<button
key={s}
onClick={() => setSector(s)}
style={{
padding: '5px 10px', borderRadius: 20,
border: '1px solid', fontSize: 11, fontWeight: 600,
cursor: 'pointer',
borderColor: sector === s ? '#2563EB' : '#E5E7EB',
background: sector === s ? '#EFF6FF' : '#fff',
color: sector === s ? '#2563EB' : '#6B7280',
fontFamily: 'inherit',
}}
>
{s}
</button>
))}
{/* Sector dropdown for rest */}
<select
value={SECTORS.indexOf(sector) >= 6 ? sector : ''}
onChange={e => e.target.value && setSector(e.target.value)}
style={{
padding: '5px 8px', border: '1px solid #E5E7EB', borderRadius: 20,
fontSize: 11, background: '#fff', color: '#6B7280', fontFamily: 'inherit',
cursor: 'pointer',
}}
>
<option value="">More sectors</option>
{SECTORS.slice(6).map(s => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
</div>
{/* Error */}
{error && !loading && (
<div style={{
padding: 12, background: '#FEF2F2', border: '1px solid #FCA5A5',
borderRadius: 8, fontSize: 13, color: '#DC2626', marginBottom: 12,
}}>
{error}
</div>
)}
{/* Results table */}
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #E5E7EB', overflow: 'hidden' }}>
{/* Header */}
<div style={{
display: 'grid',
gridTemplateColumns: '100px 1fr 90px 90px 110px 80px 110px',
padding: '10px 16px',
borderBottom: '1px solid #F3F4F6',
background: '#F9FAFB',
}}>
{([
['symbol', 'Symbol'],
['companyName', 'Company'],
['price', 'Price'],
['changesPercentage', 'Change %'],
['marketCap', 'Market Cap'],
['pe', 'P/E'],
['volume', 'Volume'],
] as [keyof ScreenerRow, string][]).map(([key, label]) => (
<span
key={key}
onClick={() => handleSort(key)}
style={{
fontSize: 11, color: '#9CA3AF', fontWeight: 700,
textTransform: 'uppercase', letterSpacing: '0.05em',
cursor: 'pointer', userSelect: 'none',
}}
>
{label}<SortIcon k={key} />
</span>
))}
</div>
{/* Loading */}
{loading && (
<div style={{
padding: 40, display: 'flex', flexDirection: 'column',
alignItems: 'center', gap: 10, color: '#9CA3AF',
}}>
<Loader2 size={28} color="#93C5FD" style={{ animation: 'spin 1s linear infinite' }} />
<span style={{ fontSize: 13 }}>Fetching screener results</span>
</div>
)}
{/* Rows */}
{!loading && filtered.map((row, i) => (
<div
key={row.symbol}
onClick={() => handleRowClick(row.symbol)}
style={{
display: 'grid',
gridTemplateColumns: '100px 1fr 90px 90px 110px 80px 110px',
padding: '11px 16px',
borderBottom: i < filtered.length - 1 ? '1px solid #F9FAFB' : 'none',
cursor: 'pointer',
alignItems: 'center',
}}
onMouseEnter={e => (e.currentTarget.style.background = '#F9FAFB')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
>
<span style={{ fontSize: 13, fontWeight: 700, color: '#2563EB' }}>{row.symbol}</span>
<span style={{ fontSize: 12, color: '#374151' }}>{row.companyName}</span>
<span style={{ fontSize: 13, fontWeight: 600, color: '#111827' }}>
{row.price != null ? `$${row.price.toFixed(2)}` : '—'}
</span>
<span style={{
fontSize: 12, fontWeight: 600,
color: row.changesPercentage >= 0 ? '#16A34A' : '#DC2626',
}}>
{row.changesPercentage >= 0 ? '+' : ''}{row.changesPercentage?.toFixed(2)}%
</span>
<span style={{ fontSize: 12, color: '#374151' }}>
{row.marketCap ? fmtCap(row.marketCap) : '—'}
</span>
<span style={{ fontSize: 12, color: '#374151' }}>
{row.pe != null && row.pe > 0 ? row.pe.toFixed(1) : '—'}
</span>
<span style={{ fontSize: 12, color: '#374151' }}>
{row.volume ? fmtCap(row.volume).replace('$', '') : '—'}
</span>
</div>
))}
{!loading && filtered.length === 0 && !error && (
<div style={{ padding: 32, textAlign: 'center', color: '#9CA3AF', fontSize: 13 }}>
No results match your filters
</div>
)}
</div>
{!loading && filtered.length > 0 && (
<div style={{ marginTop: 8, fontSize: 11, color: '#9CA3AF' }}>
{filtered.length} companies · Click any row to view chart & research
</div>
)}
</div>
);
}