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>
338 lines
13 KiB
TypeScript
338 lines
13 KiB
TypeScript
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>
|
||
);
|
||
}
|