From f62c3b15eeb9eecc8ad37861338feb20d91bb5da Mon Sep 17 00:00:00 2001 From: Saravana Achu Mac Date: Sun, 3 May 2026 23:50:01 -0700 Subject: [PATCH] feat: full web dashboard redesign + 6 new API proxy endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the 12-tab dashboard with a 3-column layout matching the investing app mockup (sidebar nav, main chart area, right panel). Web changes: - New context/AppContext.tsx — shared botState/auth across all views - New layout: Sidebar, Header (with market index sparklines), RightPanel - New views: Home, Portfolio, Research, Markets, Screener, Watchlist, Alerts, Settings - AppShell wires React Router routes to all views - App.tsx refactored to use AppContext.Provider + BrowserRouter Backend changes: - 6 new proxy endpoints: /api/news, /api/market/indices, /api/research/profile, /api/research/metrics, /api/research/earnings, /api/screener - config/index.ts: FMP_API_KEY env var added Co-Authored-By: Claude Sonnet 4.6 --- backend/src/config/index.ts | 4 + backend/src/services/apiServer.ts | 128 +++++++ web/package.json | 5 + web/src/App.tsx | 448 ++++++----------------- web/src/components/layout/AppShell.tsx | 71 ++++ web/src/components/layout/Header.tsx | 164 +++++++++ web/src/components/layout/RightPanel.tsx | 219 +++++++++++ web/src/components/layout/Sidebar.tsx | 141 +++++++ web/src/context/AppContext.tsx | 20 + web/src/views/AlertsView.tsx | 12 + web/src/views/HomeView.tsx | 383 +++++++++++++++++++ web/src/views/MarketsView.tsx | 26 ++ web/src/views/PortfolioView.tsx | 48 +++ web/src/views/ResearchView.tsx | 70 ++++ web/src/views/ScreenerView.tsx | 134 +++++++ web/src/views/SettingsView.tsx | 53 +++ web/src/views/WatchlistView.tsx | 12 + 17 files changed, 1593 insertions(+), 345 deletions(-) create mode 100644 web/src/components/layout/AppShell.tsx create mode 100644 web/src/components/layout/Header.tsx create mode 100644 web/src/components/layout/RightPanel.tsx create mode 100644 web/src/components/layout/Sidebar.tsx create mode 100644 web/src/context/AppContext.tsx create mode 100644 web/src/views/AlertsView.tsx create mode 100644 web/src/views/HomeView.tsx create mode 100644 web/src/views/MarketsView.tsx create mode 100644 web/src/views/PortfolioView.tsx create mode 100644 web/src/views/ResearchView.tsx create mode 100644 web/src/views/ScreenerView.tsx create mode 100644 web/src/views/SettingsView.tsx create mode 100644 web/src/views/WatchlistView.tsx diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index ed2ab22..7e64d49 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -59,6 +59,10 @@ export const config = { .map(s => s.trim()) .filter(Boolean), + // Research data — Financial Modeling Prep (free tier: 250 req/day) + // Register free at https://financialmodelingprep.com/developer/docs + FMP_API_KEY: process.env.FMP_API_KEY || 'demo', + // Supabase SUPABASE_URL: process.env.SUPABASE_URL || '', SUPABASE_KEY: process.env.SUPABASE_KEY || process.env.SUPABASE_ANON_KEY || '', diff --git a/backend/src/services/apiServer.ts b/backend/src/services/apiServer.ts index ab9794f..1f792c9 100644 --- a/backend/src/services/apiServer.ts +++ b/backend/src/services/apiServer.ts @@ -2617,6 +2617,134 @@ RULES: res.status(500).json({ error: `Chat failed: ${error.message}` }); } }); + + // ══════════════════════════════════════════════════════════════════════ + // MARKET DATA PROXY ENDPOINTS (Phase 3-6 of web dashboard redesign) + // ══════════════════════════════════════════════════════════════════════ + + // ── News: proxy to Alpaca /v1beta1/news ─────────────────────────────── + this.app.get('/api/news', this.requireAuth, async (req, res) => { + try { + const symbols = String(req.query.symbols || '').trim().toUpperCase(); + const limit = Math.max(1, Math.min(50, Number(req.query.limit) || 10)); + const alpacaKey = config.ALPACA_API_KEY; + const alpacaSecret = config.ALPACA_API_SECRET; + if (!alpacaKey || !alpacaSecret) { + return res.status(503).json({ error: 'Alpaca credentials not configured' }); + } + const qs = new URLSearchParams({ + ...(symbols ? { symbols } : {}), + limit: String(limit), + sort: 'desc', + }); + const url = `https://data.alpaca.markets/v1beta1/news?${qs.toString()}`; + const r = await fetch(url, { + headers: { + 'APCA-API-KEY-ID': alpacaKey, + 'APCA-API-SECRET-KEY': alpacaSecret, + }, + }); + if (!r.ok) return res.status(r.status).json({ error: 'Alpaca news fetch failed' }); + const data = await r.json() as any; + res.json({ news: data.news ?? data }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // ── Market indices: SPY / DIA / QQQ snapshots from Alpaca ───────────── + this.app.get('/api/market/indices', this.requireAuth, async (req, res) => { + try { + const alpacaKey = config.ALPACA_API_KEY; + const alpacaSecret = config.ALPACA_API_SECRET; + if (!alpacaKey || !alpacaSecret) { + return res.status(503).json({ error: 'Alpaca credentials not configured' }); + } + const url = 'https://data.alpaca.markets/v2/stocks/snapshots?symbols=SPY,DIA,QQQ&feed=iex'; + const r = await fetch(url, { + headers: { + 'APCA-API-KEY-ID': alpacaKey, + 'APCA-API-SECRET-KEY': alpacaSecret, + }, + }); + if (!r.ok) return res.status(r.status).json({ error: 'Alpaca snapshots fetch failed' }); + const data = await r.json() as any; + res.json(data); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // ── Research: company profile from FMP ─────────────────────────────── + this.app.get('/api/research/profile', this.requireAuth, async (req, res) => { + try { + const symbol = String(req.query.symbol || '').trim().toUpperCase(); + if (!symbol) return res.status(400).json({ error: 'symbol required' }); + const apiKey = process.env.FMP_API_KEY || 'demo'; + const url = `https://financialmodelingprep.com/api/v3/profile/${symbol}?apikey=${apiKey}`; + const r = await fetch(url); + if (!r.ok) return res.status(r.status).json({ error: 'FMP profile fetch failed' }); + const data = await r.json() as any; + res.json(Array.isArray(data) ? data[0] ?? {} : data); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // ── Research: key metrics (P/E, ROE, etc.) from FMP ────────────────── + this.app.get('/api/research/metrics', this.requireAuth, async (req, res) => { + try { + const symbol = String(req.query.symbol || '').trim().toUpperCase(); + if (!symbol) return res.status(400).json({ error: 'symbol required' }); + const apiKey = process.env.FMP_API_KEY || 'demo'; + const url = `https://financialmodelingprep.com/api/v3/key-metrics/${symbol}?limit=4&apikey=${apiKey}`; + const r = await fetch(url); + if (!r.ok) return res.status(r.status).json({ error: 'FMP metrics fetch failed' }); + const data = await r.json() as any; + res.json(Array.isArray(data) ? data[0] ?? {} : data); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // ── Research: earnings calendar from FMP ────────────────────────────── + this.app.get('/api/research/earnings', this.requireAuth, async (req, res) => { + try { + const symbol = String(req.query.symbol || '').trim().toUpperCase(); + if (!symbol) return res.status(400).json({ error: 'symbol required' }); + const apiKey = process.env.FMP_API_KEY || 'demo'; + const url = `https://financialmodelingprep.com/api/v3/historical/earning_calendar/${symbol}?limit=8&apikey=${apiKey}`; + const r = await fetch(url); + if (!r.ok) return res.status(r.status).json({ error: 'FMP earnings fetch failed' }); + const data = await r.json() as any; + res.json({ earnings: Array.isArray(data) ? data : [] }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + + // ── Screener: stock screener from FMP ──────────────────────────────── + this.app.get('/api/screener', this.requireAuth, async (req, res) => { + try { + const apiKey = process.env.FMP_API_KEY || 'demo'; + const qs = new URLSearchParams(); + if (req.query.sector) qs.set('sector', String(req.query.sector)); + if (req.query.marketCapMoreThan) qs.set('marketCapMoreThan', String(req.query.marketCapMoreThan)); + if (req.query.marketCapLessThan) qs.set('marketCapLessThan', String(req.query.marketCapLessThan)); + if (req.query.betaMoreThan) qs.set('betaMoreThan', String(req.query.betaMoreThan)); + if (req.query.betaLessThan) qs.set('betaLessThan', String(req.query.betaLessThan)); + qs.set('limit', String(Math.min(100, Number(req.query.limit) || 50))); + qs.set('apikey', apiKey); + qs.set('isEtf', 'false'); + const url = `https://financialmodelingprep.com/api/v3/stock-screener?${qs.toString()}`; + const r = await fetch(url); + if (!r.ok) return res.status(r.status).json({ error: 'FMP screener fetch failed' }); + const data = await r.json() as any; + res.json({ results: Array.isArray(data) ? data : [] }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); } private setupSocketHandlers() { diff --git a/web/package.json b/web/package.json index f88d382..3512216 100644 --- a/web/package.json +++ b/web/package.json @@ -21,9 +21,14 @@ "@bytelyst/kill-switch-client": "file:../vendor/bytelyst/kill-switch-client", "@bytelyst/react-auth": "file:../vendor/bytelyst/react-auth", "@bytelyst/telemetry-client": "file:../vendor/bytelyst/telemetry-client", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@monaco-editor/react": "^4.7.0", "lucide-react": "^0.562.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-router-dom": "^7.14.2", "recharts": "^3.6.0", "socket.io-client": "^4.8.3" }, diff --git a/web/src/App.tsx b/web/src/App.tsx index 748c5a1..83b3210 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,43 +1,30 @@ import { useState, useEffect, useCallback } from 'react'; +import { BrowserRouter } from 'react-router-dom'; import { useWebSocket } from './hooks/useWebSocket'; -import { LivePulseTicker } from './components/LivePulseTicker'; -import { OverviewTab } from './tabs/OverviewTab'; -import { SignalsTab } from './tabs/SignalsTab'; -import { PositionsTab } from './tabs/PositionsTab'; -import { HistoryTab } from './tabs/HistoryTab'; -import { SettingsTab } from './tabs/SettingsTab'; -import { EntriesTab } from './tabs/EntriesTab'; -import { AdminTab } from './tabs/AdminTab'; -import { TradeProfileManager } from './components/TradeProfileManager'; -import { StrategyWizard } from './components/StrategyWizard'; -import { MyStrategiesTab } from './tabs/MyStrategiesTab'; -import { MarketplaceTab } from './tabs/MarketplaceTab'; -import { MembershipTab } from './tabs/MembershipTab'; -import { BacktestTab } from './tabs/BacktestTab'; -import { ChatControl } from './components/ChatControl'; -import type { StrategyPreset } from './lib/PresetRegistry'; -import { RISK_STYLE_TEMPLATES } from './lib/RiskStyleTemplates'; -import './App.css'; import { useAuth } from './components/AuthContext'; import { Login } from './components/Login'; import { ResetPassword } from './components/ResetPassword'; +import { ChatControl } from './components/ChatControl'; +import { AppContext } from './context/AppContext'; +import { AppShell } from './components/layout/AppShell'; import { useBacktestFeatureGate } from './backtest/useBacktestFeatureGate'; import { useTabFeatureFlags } from './hooks/useTabFeatureFlags'; import { tradingRuntime, tradingTelemetry } from './lib/runtime'; import { createTradeProfile, fetchTradeProfiles, updateTradeProfile } from './lib/profileApi'; +// ─── Helpers (preserved from original App.tsx) ─────────────────────────────── + export const resolveProfileNameForAction = ( action: string, requestedName: string | undefined, chatProfiles: Array<{ name?: string }>, - suffixProvider: () => string = () => new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }).replace(/:/g, '') + suffixProvider: () => string = () => + new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }).replace(/:/g, '') ) => { let profileName = requestedName || 'AI Profile'; if (action === 'create_profile') { const existing = chatProfiles.find(p => p.name === profileName); - if (existing) { - profileName = `${profileName} (${suffixProvider()})`; - } + if (existing) profileName = `${profileName} (${suffixProvider()})`; } return profileName; }; @@ -56,168 +43,79 @@ export const buildChatApplyPayload = ( strategy_config: profileData.strategy_config, }); +// ─── App ───────────────────────────────────────────────────────────────────── + function App() { const { user, profile, loading, signOut } = useAuth(); const { socket, botState, connected } = useWebSocket(tradingRuntime.tradingApiUrl); - const [activeTab, setActiveTab] = useState('overview'); - const [wizardSeed, setWizardSeed] = useState(null); - const [chatProfiles, setChatProfiles] = useState([]); + const [activeSymbol, setActiveSymbol] = useState(''); + const [chatProfiles, setChatProfiles] = useState([]); const [previewAsCustomer, setPreviewAsCustomer] = useState(false); - const { enabled: backtestEnabledForView, loading: backtestGateLoading } = useBacktestFeatureGate({ previewAsCustomer }); - const { flags: tabFlags } = useTabFeatureFlags(); - const systemHealthData = botState.health || {}; - const hasCapitalViolation = ((systemHealthData as any)?.capitalInvariantViolations || 0) > 0; - const anyLoopUnhealthy = (systemHealthData as any)?.tradingLoopHealthy === false || (systemHealthData as any)?.reconciliationLoopHealthy === false; - const systemHealthState = hasCapitalViolation ? 'Unhealthy' : anyLoopUnhealthy ? 'Degraded' : 'Healthy'; - const systemHealthColor = hasCapitalViolation ? '#ff6657' : anyLoopUnhealthy ? '#facc15' : '#34c759'; - const systemHealthTooltip = hasCapitalViolation - ? 'Capital invariant violation detected—resolve ledger divergence before trading resumes.' - : anyLoopUnhealthy - ? 'Degraded indicates a trading or reconciliation loop is lagging or experiencing lock contention.' - : 'All monitored loops are healthy.'; - const recentCriticalEvents = (botState.operationalEvents || []).filter(e => + const { enabled: backtestEnabledForView, loading: backtestGateLoading } = + useBacktestFeatureGate({ previewAsCustomer }); + const { flags: tabFlags } = useTabFeatureFlags(); + + // Feature gates + const isAdminAccount = profile?.role === 'admin'; + const isAdmin = isAdminAccount && !previewAsCustomer; + const showBacktestTab = isAdmin || (!backtestGateLoading && backtestEnabledForView); + const showMarketplaceTab = isAdmin || tabFlags.marketplace; + + // Critical system events (for the alert banner) + const recentCriticalEvents = (botState.operationalEvents ?? []).filter(e => (e.severity === 'ERROR' || e.severity === 'WARN') && - (Date.now() - e.timestamp < 600000) + Date.now() - e.timestamp < 600_000 ); const hasCriticalEvents = recentCriticalEvents.length > 0; + // Chat profile management const fetchChatProfiles = useCallback(async () => { const data = await fetchTradeProfiles(); - setChatProfiles(data || []); + setChatProfiles(data ?? []); }, []); useEffect(() => { if (user) { fetchChatProfiles(); - const interval = setInterval(fetchChatProfiles, 30000); - return () => clearInterval(interval); + const id = setInterval(fetchChatProfiles, 30_000); + return () => clearInterval(id); } }, [user, fetchChatProfiles]); - const handleChatApply = async (action: string, profileData: any): Promise<{ success: boolean; error?: string }> => { - const profileName = resolveProfileNameForAction(action, profileData.name, chatProfiles); - - // IMPORTANT: Use the authenticated user's ID (auth.uid()) for RLS compliance + const handleChatApply = async ( + action: string, + profileData: any + ): Promise<{ success: boolean; error?: string }> => { const currentUserId = user?.id; - if (!currentUserId) { - console.error('[ChatApply] No authenticated user found'); - return { success: false, error: 'Not authenticated' }; - } + if (!currentUserId) return { success: false, error: 'Not authenticated' }; + const profileName = resolveProfileNameForAction(action, profileData.name, chatProfiles); const payload = buildChatApplyPayload(profileData, currentUserId, profileName); - console.log('[ChatApply] Action:', action, 'user_id:', currentUserId, 'Payload:', payload); - if (action === 'create_profile') { try { await createTradeProfile(payload); - } catch (error: any) { - console.error('[ChatApply] Insert error:', error); - return { success: false, error: error.message }; + fetchChatProfiles(); + window.dispatchEvent(new Event('profiles-updated')); + return { success: true }; + } catch (err: any) { + return { success: false, error: err.message }; } - console.log('[ChatApply] Profile created successfully:', profileName); - fetchChatProfiles(); - window.dispatchEvent(new Event('profiles-updated')); - return { success: true }; - } else if (action === 'update_profile' && profileData.id) { + } + if (action === 'update_profile' && profileData.id) { try { await updateTradeProfile(profileData.id, payload); - } catch (error: any) { - console.error('[ChatApply] Update error:', error); - return { success: false, error: error.message }; + fetchChatProfiles(); + window.dispatchEvent(new Event('profiles-updated')); + return { success: true }; + } catch (err: any) { + return { success: false, error: err.message }; } - console.log('[ChatApply] Profile updated successfully:', profileName); - fetchChatProfiles(); - window.dispatchEvent(new Event('profiles-updated')); - return { success: true }; } return { success: false, error: 'Unknown action' }; }; - const handleClonePreset = (preset: StrategyPreset) => { - const style = RISK_STYLE_TEMPLATES.find(t => t.id === preset.riskStyleId); - const dummyProfile = { - name: `${preset.name} (Clone)`, - symbols: preset.recommendedAssets.join(','), - allocated_capital: 1000, - is_active: false, - strategy_config: { - execution: { minRulePassRatio: style?.minRulePassRatio || 1.0 }, - riskLimits: { dailyProfitTargetUsd: 100 }, - rules: [ - { ruleId: 'RiskManagementRule', enabled: true, ruleType: 'mandatory' }, - { ruleId: 'SessionRule', enabled: true, ruleType: 'mandatory', params: { sessions: 'LDN,NY' } }, - ...(style?.mandatoryRules || []).map(r => ({ ruleId: r, enabled: true, ruleType: 'mandatory' })), - ...(style?.votingRules || []).map(r => ({ ruleId: r, enabled: true, ruleType: 'voting' })) - ] - } - }; - setWizardSeed(dummyProfile); - setActiveTab('wizard'); - }; - - // Handle Password Reset Route - if (window.location.pathname === '/reset-callback') { - return ; - } - - if (loading) { - return
Loading...
; - } - - if (!user) { - return ; - } - - const isAdminAccount = profile?.role === 'admin'; - const isAdmin = isAdminAccount && !previewAsCustomer; - const showBacktestTab = isAdmin || (!backtestGateLoading && backtestEnabledForView); - const showMarketplaceTab = isAdmin || tabFlags.marketplace; - const showMembershipTab = isAdmin || tabFlags.membership; - - const renderTab = () => { - switch (activeTab) { - case 'overview': return ; - case 'signals': - if (!isAdmin) return ; - return ; - case 'entries': - if (!isAdmin) return ; - return ; - case 'positions': return ; - case 'history': return ; - case 'strategy_clusters': - return ; - case 'profiles': - return ; - case 'marketplace': - if (!showMarketplaceTab) return ; - return ; - case 'membership': - if (!showMembershipTab) return ; - return ; - case 'wizard': - return { - setWizardSeed(null); - setActiveTab('profiles'); - }} - />; - case 'backtest': - if (!showBacktestTab) return ; - return ; - case 'settings': return ; - case 'admin': - if (!isAdmin) return ; - return ; - default: return ; - } - }; - const handleSignOut = async () => { tradingTelemetry.client.trackEvent('info', 'auth', 'trading_web_sign_out', { userId: user?.id ?? 'anonymous', @@ -227,212 +125,72 @@ function App() { await signOut(); }; + // ── Auth gates ────────────────────────────────────────────────────────────── + if (window.location.pathname === '/reset-callback') return ; + + if (loading) { + return ( +
+ Loading… +
+ ); + } + + if (!user) return ; + + // ── Render ────────────────────────────────────────────────────────────────── return ( -
- {hasCriticalEvents && ( -
+ + {/* Critical system alert banner */} + {hasCriticalEvents && ( +
setActiveTab('admin')} - > - ⚠️ - SYSTEM ALERT: {recentCriticalEvents.length} CRITICAL ISSUES DETECTED. CLICK TO REVIEW IN ADMIN PANEL. - ⚠️ -
- )} -
-
-

Trading Bot Dashboard v2.3

-
-
- {connected ? 'Connected' : 'Reconnecting...'} -
-
- - {botState.settings.isAlgoEnabled ? '🟢' : '⏸️'} - -
- - {botState.settings.isAlgoEnabled ? 'Bot Active' : 'Bot Monitoring'} - - - Mode: {botState.settings.executionMode} - -
-
- {/* Trading Control Status Badge */} - {botState.health?.tradingControl && ( -
- - {botState.health.tradingControl.mode === 'PAUSED' ? '⏸️' : '▶️'} - -
- - {botState.health.tradingControl.mode === 'PAUSED' ? 'Trading Paused' : 'Trading Active'} - - - {botState.health.tradingControl.mode === 'PAUSED' ? 'No new entries' : 'Entries allowed'} - -
-
- )} - {/* Preview as Customer Toggle — Admin Only */} - {isAdminAccount && ( - - )} -
- -
- System Health - {systemHealthState} -
-
-
- 👤 - {user?.email} - -
+ gap: 10, + }}> + ⚠️ + + SYSTEM ALERT: {recentCriticalEvents.length} CRITICAL ISSUES DETECTED — GO TO SETTINGS › ADMIN PANEL + + ⚠️
+ )} + +
+
- -
- -
- -
- {renderTab()} -
-
- - {/* Global AI Strategy Assistant - floating robot icon on all pages */} - -
+ {/* Floating AI strategy assistant */} + + + ); } diff --git a/web/src/components/layout/AppShell.tsx b/web/src/components/layout/AppShell.tsx new file mode 100644 index 0000000..b5450f0 --- /dev/null +++ b/web/src/components/layout/AppShell.tsx @@ -0,0 +1,71 @@ +import { Routes, Route } from 'react-router-dom'; +import { Sidebar } from './Sidebar'; +import { Header } from './Header'; +import { RightPanel } from './RightPanel'; +import { HomeView } from '../../views/HomeView'; +import { PortfolioView } from '../../views/PortfolioView'; +import { ResearchView } from '../../views/ResearchView'; +import { MarketsView } from '../../views/MarketsView'; +import { ScreenerView } from '../../views/ScreenerView'; +import { WatchlistView } from '../../views/WatchlistView'; +import { AlertsView } from '../../views/AlertsView'; +import { SettingsView } from '../../views/SettingsView'; + +export function AppShell() { + return ( +
+ {/* Fixed left sidebar */} + + + {/* Main area (right of sidebar) */} +
+ {/* Sticky header */} +
+ + {/* Content row: main + right panel */} +
+ {/* Scrollable main content */} +
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {/* Fallback */} + } /> + +
+ + {/* Fixed right panel */} + +
+
+
+ ); +} diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx new file mode 100644 index 0000000..61446e3 --- /dev/null +++ b/web/src/components/layout/Header.tsx @@ -0,0 +1,164 @@ +import { useState, useRef } from 'react'; +import { Search } from 'lucide-react'; +import { useAppContext } from '../../context/AppContext'; +import { useNavigate } from 'react-router-dom'; + +// Minimal SVG sparkline +function Sparkline({ values, color }: { values: number[]; color: string }) { + if (values.length < 2) return null; + const min = Math.min(...values); + const max = Math.max(...values); + const range = max - min || 1; + const W = 64, H = 28; + const pts = values + .map((v, i) => { + const x = (i / (values.length - 1)) * W; + const y = H - ((v - min) / range) * H; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }) + .join(' '); + return ( + + + + ); +} + +// Static placeholder indices — Phase 3 will replace with live Alpaca data +const PLACEHOLDER_INDICES = [ + { + label: 'S&P 500', + change: '+0.38%', + positive: true, + spark: [395, 396, 394, 397, 399, 398, 401, 403, 402, 404], + }, + { + label: 'Dow Jones', + change: '-0.11%', + positive: false, + spark: [338, 339, 337, 336, 337, 336, 335, 336, 335, 338], + }, + { + label: 'Nasdaq', + change: '+0.65%', + positive: true, + spark: [174, 175, 176, 175, 177, 178, 179, 178, 180, 181], + }, +]; + +export function Header() { + const { activeSymbol, setActiveSymbol, connected } = useAppContext(); + const [query, setQuery] = useState(''); + const navigate = useNavigate(); + const inputRef = useRef(null); + + const handleSearch = (raw: string) => { + const symbol = raw.trim().toUpperCase(); + if (!symbol) return; + setActiveSymbol(symbol); + setQuery(''); + navigate('/'); + inputRef.current?.blur(); + }; + + return ( +
+ {/* Search bar */} +
+ + setQuery(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') handleSearch(query); + }} + placeholder="Ask about a company..." + style={{ + width: '100%', + paddingLeft: 34, + paddingRight: 12, + paddingTop: 8, + paddingBottom: 8, + border: '1px solid #E5E7EB', + borderRadius: 8, + fontSize: 13, + outline: 'none', + color: '#374151', + background: '#F9FAFB', + boxSizing: 'border-box', + fontFamily: 'inherit', + }} + /> +
+ + {/* Spacer */} +
+ + {/* Market indices */} +
+ {PLACEHOLDER_INDICES.map(idx => ( +
+
+
+ {idx.label} +
+
+ {idx.change} +
+
+ +
+ ))} +
+ + {/* Live indicator */} +
+ + + {connected ? 'Live' : 'Reconnecting'} + +
+
+ ); +} diff --git a/web/src/components/layout/RightPanel.tsx b/web/src/components/layout/RightPanel.tsx new file mode 100644 index 0000000..785ecbd --- /dev/null +++ b/web/src/components/layout/RightPanel.tsx @@ -0,0 +1,219 @@ +import { useState, useEffect, useRef } from 'react'; +import { ArrowRight } from 'lucide-react'; +import { useAppContext } from '../../context/AppContext'; + +// ─── Portfolio Summary ──────────────────────────────────────────────────────── + +function PortfolioSummary() { + const { botState } = useAppContext(); + const positions = botState.positions ?? []; + const account = botState.accountSnapshot; + + const totalValue = account + ? account.cash + positions.reduce((s, p) => s + (p.marketValue ?? 0), 0) + : positions.reduce((s, p) => s + (p.marketValue ?? 0), 0); + + const totalPnl = positions.reduce((s, p) => s + (p.unrealizedPnl ?? 0), 0); + const pnlPos = totalPnl >= 0; + + const fmt$ = (n: number) => + new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n); + + return ( +
+ {/* Header */} +
+ Portfolio + + View All + +
+ + {/* Total value */} +
+
+ {fmt$(totalValue)} +
+
+ {pnlPos ? '+' : ''}{fmt$(totalPnl)} unrealized +
+
+ + {/* Column headers */} +
+ {['Symbol','Price','Change','Value'].map(h => ( + + {h} + + ))} +
+ + {/* Rows */} + {positions.length === 0 ? ( +
+ No open positions +
+ ) : ( +
+ {positions.slice(0, 6).map(pos => { + const pct = pos.unrealizedPnlPercent ?? 0; + const pos_ = pct >= 0; + return ( +
+ {pos.symbol} + {pos.currentPrice?.toFixed(2) ?? '—'} + + {pos_ ? '+' : ''}{pct.toFixed(2)}% + + {fmt$(pos.marketValue ?? 0)} +
+ ); + })} +
+ )} +
+ ); +} + +// ─── News Feed ──────────────────────────────────────────────────────────────── + +interface NewsArticle { + id?: string; + url: string; + headline: string; + source: string; + created_at: string; + images?: Array<{ url: string; size?: string }>; +} + +function timeAgo(dateStr: string): string { + const diff = Date.now() - new Date(dateStr).getTime(); + const m = Math.floor(diff / 60_000); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + return `${Math.floor(h / 24)}d ago`; +} + +function NewsCard({ article }: { article: NewsArticle }) { + const img = article.images?.[0]?.url; + return ( + (e.currentTarget.style.background = '#F9FAFB')} + onMouseLeave={e => (e.currentTarget.style.background = 'transparent')} + > + {img && ( + { (e.target as HTMLImageElement).style.display = 'none'; }} + /> + )} +
+
+ {article.headline} +
+
+ {article.source} · {timeAgo(article.created_at)} +
+
+
+ ); +} + +function NewsFeed() { + const { activeSymbol } = useAppContext(); + const [news, setNews] = useState([]); + const [loading, setLoading] = useState(false); + const apiBase = (import.meta.env.VITE_TRADING_API_URL as string) || 'http://localhost:4018'; + + useEffect(() => { + if (!activeSymbol) { setNews([]); return; } + let cancelled = false; + setLoading(true); + fetch(`${apiBase}/api/news?symbols=${activeSymbol}&limit=8`) + .then(r => r.ok ? r.json() : Promise.reject()) + .then(d => { if (!cancelled) { setNews(d.news ?? d ?? []); setLoading(false); } }) + .catch(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; + }, [activeSymbol, apiBase]); + + return ( +
+
+ Latest News + + View All + +
+ + {loading && ( +
Loading news…
+ )} + + {!loading && news.length === 0 && ( +
+ {activeSymbol ? `No news found for ${activeSymbol}` : 'Search a ticker to see news'} +
+ )} + + {news.map((a, i) => )} +
+ ); +} + +// ─── Right Panel container ──────────────────────────────────────────────────── + +export function RightPanel() { + return ( + + ); +} diff --git a/web/src/components/layout/Sidebar.tsx b/web/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..a5d89db --- /dev/null +++ b/web/src/components/layout/Sidebar.tsx @@ -0,0 +1,141 @@ +import { NavLink, useNavigate } from 'react-router-dom'; +import { + Home, Briefcase, FlaskConical, TrendingUp, + SlidersHorizontal, Star, Bell, Settings, +} from 'lucide-react'; +import { useAppContext } from '../../context/AppContext'; + +const NAV = [ + { to: '/', icon: Home, label: 'Home', end: true }, + { to: '/portfolio', icon: Briefcase, label: 'Portfolio', end: false }, + { to: '/research', icon: FlaskConical, label: 'Research', end: false }, + { to: '/markets', icon: TrendingUp, label: 'Markets', end: false }, + { to: '/screener', icon: SlidersHorizontal, label: 'Screener', end: false }, + { to: '/watchlist', icon: Star, label: 'Watchlist', end: false }, + { to: '/alerts', icon: Bell, label: 'Alerts', end: false }, + { to: '/settings', icon: Settings, label: 'Settings', end: false }, +]; + +export function Sidebar() { + const { user, handleSignOut } = useAppContext(); + const initials = user?.email + ? user.email.slice(0, 2).toUpperCase() + : 'US'; + + return ( + + ); +} diff --git a/web/src/context/AppContext.tsx b/web/src/context/AppContext.tsx new file mode 100644 index 0000000..734443a --- /dev/null +++ b/web/src/context/AppContext.tsx @@ -0,0 +1,20 @@ +import { createContext, useContext } from 'react'; +import type { BotState } from '../hooks/useWebSocket'; +import type { Socket } from 'socket.io-client'; + +export interface AppContextValue { + botState: BotState; + socket: Socket | null; + connected: boolean; + activeSymbol: string; + setActiveSymbol: (symbol: string) => void; + isAdmin: boolean; + user: any; + profile: any; + showBacktestTab: boolean; + showMarketplaceTab: boolean; + handleSignOut: () => void; +} + +export const AppContext = createContext({} as AppContextValue); +export const useAppContext = () => useContext(AppContext); diff --git a/web/src/views/AlertsView.tsx b/web/src/views/AlertsView.tsx new file mode 100644 index 0000000..3921f46 --- /dev/null +++ b/web/src/views/AlertsView.tsx @@ -0,0 +1,12 @@ +import { useAppContext } from '../context/AppContext'; +import { AlertFeed } from '../components/AlertFeed'; + +export function AlertsView() { + const { botState } = useAppContext(); + return ( +
+

Alerts

+ +
+ ); +} diff --git a/web/src/views/HomeView.tsx b/web/src/views/HomeView.tsx new file mode 100644 index 0000000..a12e639 --- /dev/null +++ b/web/src/views/HomeView.tsx @@ -0,0 +1,383 @@ +import { useState, useEffect, useRef } from 'react'; +import { Star, Bell, BarChart2 } from 'lucide-react'; +import { + AreaChart, Area, XAxis, YAxis, Tooltip, + ResponsiveContainer, CartesianGrid, +} from 'recharts'; +import { useAppContext } from '../context/AppContext'; + +// ─── Time period config ─────────────────────────────────────────────────────── +const PERIODS = ['1D', '5D', '1M', '3M', '6M', 'YTD', '1Y', '5Y', 'MAX'] as const; +type Period = typeof PERIODS[number]; + +// ─── Helpers ────────────────────────────────────────────────────────────────── +const fmt$ = (n: number) => + new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n); + +function formatPriceLabel(ts: number, period: Period) { + const d = new Date(ts); + if (period === '1D') return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + if (period === '5D') return d.toLocaleDateString([], { weekday: 'short', hour: '2-digit', minute: '2-digit' }); + return d.toLocaleDateString([], { month: 'short', day: 'numeric' }); +} + +// ─── Ticker header ──────────────────────────────────────────────────────────── +function TickerHeader({ symbol }: { symbol: string }) { + const { botState } = useAppContext(); + const data = botState.symbols?.[symbol]; + const price = data?.price ?? 0; + const change = data?.changeToday ?? 0; + const changePct = price > 0 ? (change / (price - change)) * 100 : 0; + const positive = change >= 0; + + return ( +
+
+

+ {symbol} +

+ + {/* Company name placeholder — Phase 4 will fill from FMP */} + {symbol} + + +
+ {/* Saved badge */} + + +
+
+ +
+ + {price > 0 ? price.toFixed(2) : '—'} + + {price > 0 && ( + + {positive ? '+' : ''}{change.toFixed(2)} ({positive ? '+' : ''}{changePct.toFixed(2)}%) + + )} +
+ +
+ {new Date().toLocaleString('en-US', { + month: 'short', day: 'numeric', year: 'numeric', + hour: '2-digit', minute: '2-digit', + })} ET · NASDAQ +
+
+ ); +} + +// ─── Stock chart ────────────────────────────────────────────────────────────── +function StockChart({ symbol }: { symbol: string }) { + const { botState } = useAppContext(); + const [period, setPeriod] = useState('1Y'); + + // Use botState price history if available, else empty + const raw = botState.symbols?.[symbol]?.priceHistory ?? []; + const chartData = raw.map(p => ({ + ts: p.timestamp, + price: p.price, + label: formatPriceLabel(p.timestamp, period), + })); + + const firstPrice = chartData[0]?.price ?? 0; + const lastPrice = chartData[chartData.length - 1]?.price ?? 0; + const positive = lastPrice >= firstPrice; + const lineColor = positive ? '#2563EB' : '#DC2626'; + const fillColor = positive ? '#EFF6FF' : '#FEF2F2'; + + const minY = chartData.length ? Math.min(...chartData.map(d => d.price)) : 0; + const maxY = chartData.length ? Math.max(...chartData.map(d => d.price)) : 100; + const pad = (maxY - minY) * 0.1 || 10; + + return ( +
+ {/* Period selector + chart type */} +
+
+ {PERIODS.map(p => ( + + ))} +
+
+ Line Chart +
+
+ + {/* Chart */} + {chartData.length < 2 ? ( +
+ + Price chart will appear once {symbol} is tracked by the bot + Add {symbol} to your strategy symbols to start collecting data +
+ ) : ( + + + + + + + + + + + `$${v.toFixed(0)}`} + /> + [`$${val.toFixed(2)}`, 'Price']} + labelStyle={{ color: '#6B7280', fontSize: 11 }} + /> + + + + )} +
+ ); +} + +// ─── Quick stats cards ──────────────────────────────────────────────────────── +function QuickStats({ symbol }: { symbol: string }) { + const { botState } = useAppContext(); + const d = botState.symbols?.[symbol]; + + const stats = [ + { label: 'RSI (1H)', value: d?.indicators?.rsi_1h?.toFixed(1) ?? '—' }, + { label: 'EMA 50', value: d?.indicators?.ema50_4h?.toFixed(2) ?? '—' }, + { label: 'EMA 200', value: d?.indicators?.ema200_4h?.toFixed(2) ?? '—' }, + { label: 'Signal', value: d?.signal ?? '—' }, + ]; + + return ( +
+ {stats.map(s => ( +
+
{s.label}
+
{s.value}
+
+ ))} +
+ ); +} + +// ─── Placeholder research / financials cards ────────────────────────────────── +function ResearchPlaceholder({ symbol }: { symbol: string }) { + return ( +
+ {/* Company Research */} +
+
+ 📋 Company Research +
+ {['Business Overview','Industry Analysis','Competitive Position','Management Quality'].map((item, i) => ( +
+ {i < 2 ? '✅' : '⬜'} + {item} +
+ ))} + +
+ + {/* Financials — Phase 4 fills with real FMP data */} +
+
+ 📊 Financials +
+ {[ + ['Market Cap', '—'], + ['Revenue (TTM)', '—'], + ['Net Income (TTM)','—'], + ['P/E Ratio (TTM)', '—'], + ['ROE (TTM)', '—'], + ].map(([label, val]) => ( +
+ {label} + {val} +
+ ))} + +
+ + {/* Events */} +
+
+ 📅 Events +
+ {[ + ['Earnings Report Q2', '—'], + ['Shareholder Meeting', '—'], + ['Ex-Dividend Date', '—'], + ['Product Event', '—'], + ].map(([label, val]) => ( +
+ {label} + {val} +
+ ))} + +
+
+ ); +} + +// ─── Empty state ────────────────────────────────────────────────────────────── +function EmptyState() { + return ( +
+
📈
+
+ Search a company to get started +
+
+ Type a ticker symbol or company name in the search bar above to view charts, financials, and news. +
+
+ {['AAPL','MSFT','GOOGL','AMZN','NVDA'].map(t => ( + + {t} + + ))} +
+
+ ); +} + +// ─── HomeView ───────────────────────────────────────────────────────────────── +export function HomeView() { + const { activeSymbol, setActiveSymbol } = useAppContext(); + + // Allow clicking the example tickers in empty state + const handleTickerClick = (t: string) => setActiveSymbol(t); + + if (!activeSymbol) return ; + + return ( +
+ + + + +
+ ); +} diff --git a/web/src/views/MarketsView.tsx b/web/src/views/MarketsView.tsx new file mode 100644 index 0000000..8342f88 --- /dev/null +++ b/web/src/views/MarketsView.tsx @@ -0,0 +1,26 @@ +import { useAppContext } from '../context/AppContext'; +import { MarketplaceTab } from '../tabs/MarketplaceTab'; +import { TopVolatile, AISetups } from '../components/MarketOpportunities'; +import { RISK_STYLE_TEMPLATES } from '../lib/RiskStyleTemplates'; +import type { StrategyPreset } from '../lib/PresetRegistry'; +import { useState } from 'react'; + +export function MarketsView() { + const { botState, showMarketplaceTab } = useAppContext(); + const [clonedPreset, setClonedPreset] = useState(null); + + const handleClone = (preset: StrategyPreset) => setClonedPreset(preset); + + return ( +
+

Markets

+
+ + +
+ {showMarketplaceTab && ( + + )} +
+ ); +} diff --git a/web/src/views/PortfolioView.tsx b/web/src/views/PortfolioView.tsx new file mode 100644 index 0000000..a630de6 --- /dev/null +++ b/web/src/views/PortfolioView.tsx @@ -0,0 +1,48 @@ +import { useState } from 'react'; +import { useAppContext } from '../context/AppContext'; +import { PositionsTab } from '../tabs/PositionsTab'; +import { HistoryTab } from '../tabs/HistoryTab'; + +const TABS = ['Positions & Orders', 'Trade History'] as const; +type Tab = typeof TABS[number]; + +export function PortfolioView() { + const { botState } = useAppContext(); + const [tab, setTab] = useState('Positions & Orders'); + + return ( +
+

Portfolio

+ + {/* Sub-tabs */} +
+ {TABS.map(t => ( + + ))} +
+ + {tab === 'Positions & Orders' && } + {tab === 'Trade History' && } +
+ ); +} diff --git a/web/src/views/ResearchView.tsx b/web/src/views/ResearchView.tsx new file mode 100644 index 0000000..854592c --- /dev/null +++ b/web/src/views/ResearchView.tsx @@ -0,0 +1,70 @@ +import { useState } from 'react'; +import { useAppContext } from '../context/AppContext'; +import { SignalsTab } from '../tabs/SignalsTab'; +import { BacktestTab } from '../tabs/BacktestTab'; +import { MyStrategiesTab } from '../tabs/MyStrategiesTab'; +import { StrategyWizard } from '../components/StrategyWizard'; + +type ResearchTab = 'Signals' | 'Strategies' | 'Backtesting'; + +export function ResearchView() { + const { botState, connected, showBacktestTab, isAdmin } = useAppContext(); + const [tab, setTab] = useState('Strategies'); + const [wizardSeed, setWizardSeed] = useState(null); + + const tabs: ResearchTab[] = [ + 'Strategies', + ...(isAdmin ? ['Signals' as ResearchTab] : []), + ...(showBacktestTab ? ['Backtesting' as ResearchTab] : []), + ]; + + return ( +
+

Research

+ +
+ {tabs.map(t => ( + + ))} +
+ + {tab === 'Signals' && } + {tab === 'Strategies' && !wizardSeed && ( + + )} + {tab === 'Strategies' && wizardSeed && ( + setWizardSeed(null)} + /> + )} + {tab === 'Backtesting' && } +
+ ); +} diff --git a/web/src/views/ScreenerView.tsx b/web/src/views/ScreenerView.tsx new file mode 100644 index 0000000..638e5dc --- /dev/null +++ b/web/src/views/ScreenerView.tsx @@ -0,0 +1,134 @@ +import { useState } from 'react'; +import { SlidersHorizontal, Search } from 'lucide-react'; +import { useAppContext } from '../context/AppContext'; + +// Phase 6 will wire this to /api/screener (FMP) +const PLACEHOLDER_RESULTS = [ + { symbol: 'AAPL', name: 'Apple Inc.', price: 201.36, change: 1.02, marketCap: '3.0T', pe: 32.1, sector: 'Technology' }, + { symbol: 'MSFT', name: 'Microsoft Corp.', price: 426.52, change: 0.89, marketCap: '3.2T', pe: 36.4, sector: 'Technology' }, + { symbol: 'GOOGL', name: 'Alphabet Inc.', price: 172.49, change: 1.36, marketCap: '2.1T', pe: 20.3, sector: 'Technology' }, + { symbol: 'AMZN', name: 'Amazon.com Inc.', price: 186.10, change: -0.23, marketCap: '2.0T', pe: 41.8, sector: 'Consumer' }, + { symbol: 'NVDA', name: 'NVIDIA Corp.', price: 134.81, change: 2.31, marketCap: '3.3T', pe: 48.2, sector: 'Technology' }, + { symbol: 'META', name: 'Meta Platforms Inc.', price: 572.40, change: -0.55, marketCap: '1.4T', pe: 26.7, sector: 'Technology' }, + { symbol: 'TSLA', name: 'Tesla Inc.', price: 284.65, change: 3.12, marketCap: '908B', pe: 88.3, sector: 'Automotive' }, + { symbol: 'JPM', name: 'JPMorgan Chase & Co.', price: 249.90, change: 0.34, marketCap: '710B', pe: 13.2, sector: 'Financials' }, +]; + +const SECTORS = ['All', 'Technology', 'Financials', 'Consumer', 'Healthcare', 'Automotive', 'Energy']; + +export function ScreenerView() { + const { setActiveSymbol } = useAppContext(); + const [sector, setSector] = useState('All'); + const [query, setQuery] = useState(''); + + const filtered = PLACEHOLDER_RESULTS.filter(r => { + const matchSector = sector === 'All' || r.sector === sector; + const matchQuery = !query + || r.symbol.includes(query.toUpperCase()) + || r.name.toLowerCase().includes(query.toLowerCase()); + return matchSector && matchQuery; + }); + + return ( +
+

Screener

+ + {/* Filters */} +
+ {/* Search */} +
+ + 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', + }} + /> +
+ + {/* Sector pills */} +
+ + {SECTORS.map(s => ( + + ))} +
+
+ + {/* Results table */} +
+ {/* Header */} +
+ {['Symbol','Name','Price','Change','Mkt Cap','P/E'].map(h => ( + + {h} + + ))} +
+ + {filtered.map((row, i) => ( +
setActiveSymbol(row.symbol)} + style={{ + display: 'grid', + gridTemplateColumns: '1.5fr 2.5fr 1fr 1fr 1fr 1fr', + padding: '12px 16px', + borderBottom: i < filtered.length - 1 ? '1px solid #F9FAFB' : 'none', + cursor: 'pointer', + transition: 'background 0.1s', + alignItems: 'center', + }} + onMouseEnter={e => (e.currentTarget.style.background = '#F9FAFB')} + onMouseLeave={e => (e.currentTarget.style.background = 'transparent')} + > + {row.symbol} + {row.name} + ${row.price.toFixed(2)} + = 0 ? '#16A34A' : '#DC2626' }}> + {row.change >= 0 ? '+' : ''}{row.change.toFixed(2)}% + + {row.marketCap} + {row.pe} +
+ ))} + + {filtered.length === 0 && ( +
+ No results match your filters +
+ )} +
+ +
+ ⚡ Phase 6 will connect this to live FMP screener data with full filtering +
+
+ ); +} diff --git a/web/src/views/SettingsView.tsx b/web/src/views/SettingsView.tsx new file mode 100644 index 0000000..9bf1022 --- /dev/null +++ b/web/src/views/SettingsView.tsx @@ -0,0 +1,53 @@ +import { useState } from 'react'; +import { useAppContext } from '../context/AppContext'; +import { SettingsTab } from '../tabs/SettingsTab'; +import { AdminTab } from '../tabs/AdminTab'; +import { ConfigTab } from '../tabs/ConfigTab'; + +type SettingsSection = 'Account' | 'Bot Config' | 'Admin Panel'; + +export function SettingsView() { + const { botState, isAdmin, socket } = useAppContext(); + const sections: SettingsSection[] = [ + 'Account', + 'Bot Config', + ...(isAdmin ? ['Admin Panel' as SettingsSection] : []), + ]; + const [section, setSection] = useState('Account'); + + return ( +
+

Settings

+ +
+ {sections.map(s => ( + + ))} +
+ + {section === 'Account' && } + {section === 'Bot Config' && } + {section === 'Admin Panel' && isAdmin && } +
+ ); +} diff --git a/web/src/views/WatchlistView.tsx b/web/src/views/WatchlistView.tsx new file mode 100644 index 0000000..6882702 --- /dev/null +++ b/web/src/views/WatchlistView.tsx @@ -0,0 +1,12 @@ +import { useAppContext } from '../context/AppContext'; +import { EntriesTab } from '../tabs/EntriesTab'; + +export function WatchlistView() { + const { botState } = useAppContext(); + return ( +
+

Watchlist

+ +
+ ); +}