From 61c9d5ecb83870144906bae080567f3a79fe949b Mon Sep 17 00:00:00 2001 From: Berti Date: Sun, 29 Mar 2026 14:47:29 -0500 Subject: [PATCH 1/7] fix(dashboard): replace fake timer with real agent progress in ReportsPanel The "Run Now" button used a blind 10s setTimeout with no server feedback. Now uses agentStatus from WebSocket to show real-time processing state, current ticker, model info, and queue depth. Auto-clears when report arrives. Made-with: Cursor --- packages/dashboard/src/App.tsx | 1 + .../dashboard/src/components/ReportsPanel.tsx | 46 ++++++++++++++++--- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/packages/dashboard/src/App.tsx b/packages/dashboard/src/App.tsx index 45abfa9..55b9e3a 100644 --- a/packages/dashboard/src/App.tsx +++ b/packages/dashboard/src/App.tsx @@ -176,6 +176,7 @@ export function App() { void onRunNow: () => void onRequestBudget: () => void @@ -18,20 +19,30 @@ const RUN_TYPE_LABELS: Record = { manual: { label: 'Manual', color: 'text-gray-400 bg-gray-800' }, } -export function ReportsPanel({ reports, budgetStatus, onRequestReports, onRunNow, onRequestBudget }: Props) { +export function ReportsPanel({ reports, budgetStatus, agentStatus, onRequestReports, onRunNow, onRequestBudget }: Props) { const [selectedDate, setSelectedDate] = useState(() => new Date().toISOString().slice(0, 10)) const [expandedId, setExpandedId] = useState(null) - const [running, setRunning] = useState(false) + const [runRequested, setRunRequested] = useState(false) + const prevReportCount = useRef(reports.length) + + const isProcessing = agentStatus?.state === 'processing' + const running = runRequested || isProcessing useEffect(() => { onRequestReports(selectedDate) onRequestBudget() }, [selectedDate, onRequestReports, onRequestBudget]) + useEffect(() => { + if (reports.length > prevReportCount.current && runRequested) { + setRunRequested(false) + } + prevReportCount.current = reports.length + }, [reports.length, runRequested]) + const handleRunNow = () => { - setRunning(true) + setRunRequested(true) onRunNow() - setTimeout(() => setRunning(false), 10000) } return ( @@ -60,6 +71,29 @@ export function ReportsPanel({ reports, budgetStatus, onRequestReports, onRunNow + {/* Agent progress banner */} + {running && ( +
+
+
+ + {isProcessing && agentStatus?.currentTicker + ? `Analyzing ${agentStatus.currentTicker}...` + : 'Agent processing...'} + + {agentStatus && ( +
+ Model: {agentStatus.model} + {(agentStatus.queueDepth ?? 0) > 0 && Queue: {agentStatus.queueDepth}} +
+ )} +
+
+
+
+
+ )} + {/* Budget bar */} {budgetStatus && (
From aa9685367ff8027bd7f9f8d0a955167281b3230b Mon Sep 17 00:00:00 2001 From: Berti Date: Sun, 29 Mar 2026 14:48:10 -0500 Subject: [PATCH 2/7] fix(dashboard): gate test alert buttons behind sandbox environment Test alert firing in AgentSettings is now only visible in sandbox mode, matching the existing gating in AgentExportPanel. Button label clarifies these are synthetic alerts to prevent confusion in production. Made-with: Cursor --- packages/dashboard/src/App.tsx | 1 + packages/dashboard/src/components/AgentSettings.tsx | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/dashboard/src/App.tsx b/packages/dashboard/src/App.tsx index 55b9e3a..af395a5 100644 --- a/packages/dashboard/src/App.tsx +++ b/packages/dashboard/src/App.tsx @@ -189,6 +189,7 @@ export function App() { onSave={saveAgentConfig} onRequest={requestAgentConfig} onTestAlert={sendTestAlert} + env={env} scheduleConfig={scheduleConfig} budgetStatus={budgetStatus} onSaveSchedule={saveScheduleConfig} diff --git a/packages/dashboard/src/components/AgentSettings.tsx b/packages/dashboard/src/components/AgentSettings.tsx index 9c32420..33517c1 100644 --- a/packages/dashboard/src/components/AgentSettings.tsx +++ b/packages/dashboard/src/components/AgentSettings.tsx @@ -6,6 +6,7 @@ interface AgentSettingsProps { onSave: (config: { provider: string; apiKey?: string; model?: string; maxBudgetUsd?: number; externalUrl?: string }) => void onRequest: () => void onTestAlert: (ticker: string) => void + env: 'sandbox' | 'production' scheduleConfig: ScheduleConfigResponse | null budgetStatus: BudgetStatus | null onSaveSchedule: (cfg: Partial) => void @@ -19,7 +20,7 @@ const MODELS = [ 'claude-haiku-3-20250422', ] -export function AgentSettings({ config, onSave, onRequest, onTestAlert, scheduleConfig, budgetStatus, onSaveSchedule, onRequestSchedule, onRequestBudget }: AgentSettingsProps) { +export function AgentSettings({ config, onSave, onRequest, onTestAlert, env, scheduleConfig, budgetStatus, onSaveSchedule, onRequestSchedule, onRequestBudget }: AgentSettingsProps) { const [provider, setProvider] = useState(config?.provider ?? 'none') const [apiKey, setApiKey] = useState('') const [model, setModel] = useState(config?.model ?? 'claude-sonnet-4-20250514') @@ -214,7 +215,7 @@ export function AgentSettings({ config, onSave, onRequest, onTestAlert, schedule {saved ? 'Saved!' : 'Save Configuration'} - {provider !== 'none' && ( + {provider !== 'none' && env === 'sandbox' && ( )}
From bdb955ef73a094743f1f188c1acb9bcfe3263350 Mon Sep 17 00:00:00 2001 From: Berti Date: Sun, 29 Mar 2026 14:48:50 -0500 Subject: [PATCH 3/7] feat(monitor): support multiple named watchlists in backend - Add getOrCreateWatchlist(userId, name) to db.ts for named list upsert - Add deleteWatchlistByName and renameWatchlist to db.ts - Fix saveWatchlist to resolve by name instead of always targeting Default - Add createWatchlist, deleteWatchlist, renameWatchlist service exports - syncFromTastytrade now correctly creates separate named lists Made-with: Cursor --- packages/monitor/src/db.ts | 21 ++++++++++++++++++++ packages/monitor/src/watchlistService.ts | 25 +++++++++++++++++++++--- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/packages/monitor/src/db.ts b/packages/monitor/src/db.ts index 32cc8a6..3cba345 100644 --- a/packages/monitor/src/db.ts +++ b/packages/monitor/src/db.ts @@ -302,6 +302,27 @@ export function getOrCreateDefaultWatchlist(userId: number): WatchlistRow { ).get(userId) as WatchlistRow } +export function getOrCreateWatchlist(userId: number, name: string): WatchlistRow { + const existing = getDb().prepare( + 'SELECT * FROM watchlists WHERE user_id = ? AND name = ?' + ).get(userId, name) as WatchlistRow | undefined + if (existing) return existing + + return getDb().prepare( + 'INSERT INTO watchlists (user_id, name) VALUES (?, ?) RETURNING *' + ).get(userId, name) as WatchlistRow +} + +export function deleteWatchlistByName(userId: number, name: string): void { + getDb().prepare('DELETE FROM watchlists WHERE user_id = ? AND name = ?').run(userId, name) +} + +export function renameWatchlist(userId: number, oldName: string, newName: string): void { + getDb().prepare( + 'UPDATE watchlists SET name = ? WHERE user_id = ? AND name = ?' + ).run(newName, userId, oldName) +} + export function getWatchlistItems(watchlistId: number): WatchlistItemRow[] { return getDb().prepare( 'SELECT * FROM watchlist_items WHERE watchlist_id = ? ORDER BY sort_order, ticker' diff --git a/packages/monitor/src/watchlistService.ts b/packages/monitor/src/watchlistService.ts index be47043..87c9566 100644 --- a/packages/monitor/src/watchlistService.ts +++ b/packages/monitor/src/watchlistService.ts @@ -5,9 +5,12 @@ import { log } from './logger.js' import { getUserWatchlists, getOrCreateDefaultWatchlist, + getOrCreateWatchlist, getWatchlistItems, replaceWatchlistItems, deleteWatchlistItem, + deleteWatchlistByName, + renameWatchlist as dbRenameWatchlist, getAllUserTickers, type WatchlistItemRow, } from './db.js' @@ -29,8 +32,7 @@ export function getUserWatchlistsWithItems(userId: number): Watchlist[] { } export function saveWatchlist(userId: number, name: string, items: WatchlistItem[]): Watchlist { - const wl = getOrCreateDefaultWatchlist(userId) - const dbName = name || wl.name + const wl = getOrCreateWatchlist(userId, name || 'Default') replaceWatchlistItems( wl.id, @@ -46,11 +48,28 @@ export function saveWatchlist(userId: number, name: string, items: WatchlistItem return { id: wl.id, - name: dbName, + name: wl.name, items: getWatchlistItems(wl.id).map(rowToItem), } } +export function createWatchlist(userId: number, name: string): Watchlist { + const wl = getOrCreateWatchlist(userId, name) + return { + id: wl.id, + name: wl.name, + items: getWatchlistItems(wl.id).map(rowToItem), + } +} + +export function deleteWatchlist(userId: number, name: string): void { + deleteWatchlistByName(userId, name) +} + +export function renameWatchlist(userId: number, oldName: string, newName: string): void { + dbRenameWatchlist(userId, oldName, newName) +} + export function removeWatchlistItem(userId: number, watchlistName: string, ticker: string): void { const lists = getUserWatchlists(userId) const wl = lists.find(l => l.name === watchlistName) ?? lists[0] From eec9e89494be389923852f88365ed03e0b9e4afa Mon Sep 17 00:00:00 2001 From: Berti Date: Sun, 29 Mar 2026 14:50:04 -0500 Subject: [PATCH 4/7] feat: add watchlist CRUD messages and description field to schema - Add create_watchlist, delete_watchlist, rename_watchlist WS message types - Add description field to WatchlistItemSchema for company names - Add broadcaster handlers for new watchlist operations - Add client-side callbacks in useMonitorSocket hook Made-with: Cursor --- .../dashboard/src/hooks/useMonitorSocket.ts | 25 ++++++++- packages/monitor/src/broadcaster.ts | 54 ++++++++++++++++++- packages/shared/src/alert.schema.ts | 13 +++++ 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/packages/dashboard/src/hooks/useMonitorSocket.ts b/packages/dashboard/src/hooks/useMonitorSocket.ts index 6c08484..43c8d98 100644 --- a/packages/dashboard/src/hooks/useMonitorSocket.ts +++ b/packages/dashboard/src/hooks/useMonitorSocket.ts @@ -44,6 +44,9 @@ export interface MonitorState { requestWatchlist: () => void saveWatchlist: (name: string, items: WatchlistItem[]) => void deleteWatchlistItem: (watchlistName: string, ticker: string) => void + createWatchlist: (name: string) => void + deleteWatchlist: (name: string) => void + renameWatchlist: (oldName: string, newName: string) => void syncTastytradeWatchlists: () => void searchSymbols: (query: string) => void sendChatMessage: (message: string) => void @@ -193,6 +196,24 @@ export function useMonitorSocket(): MonitorState { } }, []) + const createWatchlistFn = useCallback((name: string) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: 'create_watchlist', data: { name } })) + } + }, []) + + const deleteWatchlistFn = useCallback((name: string) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: 'delete_watchlist', data: { name } })) + } + }, []) + + const renameWatchlistFn = useCallback((oldName: string, newName: string) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: 'rename_watchlist', data: { oldName, newName } })) + } + }, []) + const syncTastytradeWatchlists = useCallback(() => { if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ type: 'sync_tastytrade_watchlists' })) @@ -420,7 +441,9 @@ export function useMonitorSocket(): MonitorState { watchlists, chatMessages, scheduleConfig, budgetStatus, reports, searchResults, requestChain, sendRaw, login: loginFn, register: registerFn, logout, saveAgentConfig, requestAgentConfig, sendTestAlert, - requestWatchlist, saveWatchlist, deleteWatchlistItem, syncTastytradeWatchlists, + requestWatchlist, saveWatchlist, deleteWatchlistItem, + createWatchlist: createWatchlistFn, deleteWatchlist: deleteWatchlistFn, renameWatchlist: renameWatchlistFn, + syncTastytradeWatchlists, searchSymbols: searchSymbolsFn, sendChatMessage, clearChat, saveScheduleConfig, requestScheduleConfig, requestBudgetStatus, requestReports, runAnalysisNow, diff --git a/packages/monitor/src/broadcaster.ts b/packages/monitor/src/broadcaster.ts index 3b58e33..4f020a8 100644 --- a/packages/monitor/src/broadcaster.ts +++ b/packages/monitor/src/broadcaster.ts @@ -20,7 +20,7 @@ import { } from './db.js' import type { JwtPayload } from './auth.js' import { handleOAuthRoute, getEnabledOAuthProviders } from './oauth.js' -import { getUserWatchlistsWithItems, saveWatchlist, removeWatchlistItem, syncFromTastytrade, searchSymbols } from './watchlistService.js' +import { getUserWatchlistsWithItems, saveWatchlist, removeWatchlistItem, syncFromTastytrade, searchSymbols, createWatchlist, deleteWatchlist, renameWatchlist } from './watchlistService.js' import { getBudgetStatus } from './budgetTracker.js' import { handleChatMessage, clearChatHistory } from './chatHandler.js' import { runScheduledAnalysis } from './scheduledAnalysis.js' @@ -453,6 +453,37 @@ function handleDeleteWatchlistItem(ws: WebSocket, data: Record) handleRequestWatchlist(ws) } +function handleCreateWatchlist(ws: WebSocket, data: Record): void { + const auth = clientAuth.get(ws) + if (!auth) return + const name = String(data.name ?? '') + if (!name) return + createWatchlist(auth.userId, name) + handleRequestWatchlist(ws) + log.info(`Watchlist "${name}" created for ${auth.email}`) +} + +function handleDeleteWatchlist(ws: WebSocket, data: Record): void { + const auth = clientAuth.get(ws) + if (!auth) return + const name = String(data.name ?? '') + if (!name) return + deleteWatchlist(auth.userId, name) + handleRequestWatchlist(ws) + log.info(`Watchlist "${name}" deleted for ${auth.email}`) +} + +function handleRenameWatchlist(ws: WebSocket, data: Record): void { + const auth = clientAuth.get(ws) + if (!auth) return + const oldName = String(data.oldName ?? '') + const newName = String(data.newName ?? '') + if (!oldName || !newName) return + renameWatchlist(auth.userId, oldName, newName) + handleRequestWatchlist(ws) + log.info(`Watchlist renamed "${oldName}" → "${newName}" for ${auth.email}`) +} + async function handleSyncTastytradeWatchlists(ws: WebSocket): Promise { const auth = clientAuth.get(ws) if (!auth) return @@ -767,6 +798,27 @@ const authenticatedWsRoutes: Record = { } }, + create_watchlist(ws, msg) { + const data = msg.data + if (data && typeof data === 'object' && !Array.isArray(data)) { + handleCreateWatchlist(ws, data as Record) + } + }, + + delete_watchlist(ws, msg) { + const data = msg.data + if (data && typeof data === 'object' && !Array.isArray(data)) { + handleDeleteWatchlist(ws, data as Record) + } + }, + + rename_watchlist(ws, msg) { + const data = msg.data + if (data && typeof data === 'object' && !Array.isArray(data)) { + handleRenameWatchlist(ws, data as Record) + } + }, + sync_tastytrade_watchlists(ws) { void handleSyncTastytradeWatchlists(ws) }, diff --git a/packages/shared/src/alert.schema.ts b/packages/shared/src/alert.schema.ts index b23332f..910dcd2 100644 --- a/packages/shared/src/alert.schema.ts +++ b/packages/shared/src/alert.schema.ts @@ -201,6 +201,7 @@ export type WsServerAgentConfig = z.infer export const WatchlistItemSchema = z.object({ ticker: z.string(), + description: z.string().optional(), layer: z.string().nullable(), strategies: z.array(z.string()), thesis: z.string(), @@ -452,6 +453,18 @@ export const WsClientMessageSchema = z.discriminatedUnion('type', [ type: z.literal('delete_watchlist_item'), data: z.object({ watchlistName: z.string(), ticker: z.string() }), }), + z.object({ + type: z.literal('create_watchlist'), + data: z.object({ name: z.string() }), + }), + z.object({ + type: z.literal('delete_watchlist'), + data: z.object({ name: z.string() }), + }), + z.object({ + type: z.literal('rename_watchlist'), + data: z.object({ oldName: z.string(), newName: z.string() }), + }), z.object({ type: z.literal('chat_send'), data: z.object({ message: z.string() }), From 39f964f53f9a9c35a65903095e97a559e67cf137 Mon Sep 17 00:00:00 2001 From: Berti Date: Sun, 29 Mar 2026 14:51:18 -0500 Subject: [PATCH 5/7] feat(monitor): restructure seed data into multiple sector watchlists Replaces single flat WATCHLIST with SEED_WATCHLISTS grouped by sector: AI Supply Chain, Defense & Aerospace, Energy, AI Semiconductors, Biotech, Macro Hedges, and Crypto. Each sector has its own layer templates. Adds company descriptions to all seed entries. Seed function now creates separate named watchlists per sector. Made-with: Cursor --- packages/monitor/src/watchlist.config.ts | 222 ++++++++++++++++------- packages/monitor/src/watchlistService.ts | 32 ++-- 2 files changed, 172 insertions(+), 82 deletions(-) diff --git a/packages/monitor/src/watchlist.config.ts b/packages/monitor/src/watchlist.config.ts index 51ac66d..4f0b293 100644 --- a/packages/monitor/src/watchlist.config.ts +++ b/packages/monitor/src/watchlist.config.ts @@ -1,83 +1,169 @@ export interface WatchlistEntry { ticker: string + description?: string layer: string | null strategies: string[] thesis: string instrumentType: 'equity' | 'crypto' } -export const WATCHLIST: WatchlistEntry[] = [ - // === Strategy 1: AI Hidden Supply Chain (7 layers) === - - // Layer 1 — Chip Packaging & Inspection - { ticker: 'AMKR', layer: 'Layer 1 — Chip Packaging', strategies: ['supply_chain'], thesis: '2.5D advanced packaging, TSMC alternative, Arizona + Vietnam expansion', instrumentType: 'equity' }, - { ticker: 'CAMT', layer: 'Layer 1 — Chip Inspection', strategies: ['supply_chain'], thesis: 'HBM wafer inspection, HBM4 transition catalyst', instrumentType: 'equity' }, - { ticker: 'ACMR', layer: 'Layer 1 — Chip Cleaning', strategies: ['supply_chain'], thesis: 'Wafer cleaning equipment for AI fabs', instrumentType: 'equity' }, - - // Layer 2 — Optical Interconnects - { ticker: 'FN', layer: 'Layer 2 — Optical Interconnects', strategies: ['supply_chain'], thesis: 'Only manufacturer for 1.6T transceivers at scale', instrumentType: 'equity' }, - { ticker: 'CIEN', layer: 'Layer 2 — Optical Networking', strategies: ['supply_chain'], thesis: 'Optical networking for inter-data center connectivity', instrumentType: 'equity' }, - { ticker: 'LITE', layer: 'Layer 2 — Optical Components', strategies: ['supply_chain'], thesis: 'Optical switches and laser components for AI servers', instrumentType: 'equity' }, - - // Layer 3 — Signal Integrity & AI Connectivity - { ticker: 'ALAB', layer: 'Layer 3 — Signal Connectivity', strategies: ['supply_chain'], thesis: 'PCIe retimers, Scorpio AI fabric switches, NVLink Fusion', instrumentType: 'equity' }, - { ticker: 'CRDO', layer: 'Layer 3 — Signal Integrity', strategies: ['supply_chain'], thesis: 'Competing retimer chips, high volatility leverage', instrumentType: 'equity' }, - { ticker: 'MRVL', layer: 'Layer 3 — Custom ASICs', strategies: ['supply_chain'], thesis: 'AWS/Microsoft custom AI chips, 3nm volume ramp', instrumentType: 'equity' }, - - // Layer 4 — Rack Deployment - { ticker: 'CLS', layer: 'Layer 4 — Rack Integration', strategies: ['supply_chain'], thesis: 'AI rack integration, Broadcom manufacturing partner', instrumentType: 'equity' }, - { ticker: 'EME', layer: 'Layer 4 — DC Construction', strategies: ['supply_chain'], thesis: 'Physical data center construction and electrical infrastructure', instrumentType: 'equity' }, - { ticker: 'CSCO', layer: 'Layer 4 — Networking', strategies: ['supply_chain'], thesis: 'Ultra-ethernet consortium, data center switching', instrumentType: 'equity' }, - - // Layer 5 — Thermal & Power Management - { ticker: 'VRT', layer: 'Layer 5 — Thermal & Power', strategies: ['supply_chain'], thesis: 'Liquid cooling, UPS — every DC must retrofit', instrumentType: 'equity' }, - { ticker: 'MOHN', layer: 'Layer 5 — Thermal Systems', strategies: ['supply_chain'], thesis: 'Thermal management systems for high-density racks', instrumentType: 'equity' }, - { ticker: 'NVT', layer: 'Layer 5 — Electrical Enclosures', strategies: ['supply_chain'], thesis: 'Electrical enclosures and thermal management', instrumentType: 'equity' }, - - // Layer 6 — Raw Materials - { ticker: 'FCX', layer: 'Layer 6 — Copper', strategies: ['supply_chain'], thesis: 'Largest public copper producer, inelastic AI demand', instrumentType: 'equity' }, - { ticker: 'COPX', layer: 'Layer 6 — Copper Miners ETF', strategies: ['supply_chain'], thesis: 'Diversified copper miner exposure', instrumentType: 'equity' }, - { ticker: 'MP', layer: 'Layer 6 — Rare Earth', strategies: ['supply_chain'], thesis: 'Rare earth materials, magnets for AI hardware', instrumentType: 'equity' }, - - // Layer 7 — Nuclear & Uranium - { ticker: 'CEG', layer: 'Layer 7 — Nuclear Power', strategies: ['supply_chain'], thesis: 'Largest U.S. nuclear operator, hyperscaler PPAs', instrumentType: 'equity' }, - { ticker: 'CCJ', layer: 'Layer 7 — Uranium', strategies: ['supply_chain'], thesis: 'Largest listed uranium producer, 55% earnings growth 2026', instrumentType: 'equity' }, - { ticker: 'UEC', layer: 'Layer 7 — Uranium Mining', strategies: ['supply_chain'], thesis: 'U.S.-based, leveraged to spot uranium price', instrumentType: 'equity' }, - { ticker: 'TLN', layer: 'Layer 7 — Nuclear PPAs', strategies: ['supply_chain'], thesis: 'Nuclear PPA deals with AI hyperscalers', instrumentType: 'equity' }, - - // === Strategy 2: Midterm Macro Options (5 sectors) === - - // Energy - { ticker: 'XLE', layer: 'Macro — Energy', strategies: ['midterm_macro'], thesis: 'Energy sector ETF, oil geopolitics exposure', instrumentType: 'equity' }, - { ticker: 'XOM', layer: 'Macro — Energy', strategies: ['midterm_macro'], thesis: 'Oil major, Strait of Hormuz beneficiary', instrumentType: 'equity' }, - { ticker: 'CVX', layer: 'Macro — Energy', strategies: ['midterm_macro'], thesis: 'Integrated oil major, energy infrastructure', instrumentType: 'equity' }, - - // Defense & Aerospace - { ticker: 'RTX', layer: 'Macro — Defense', strategies: ['midterm_macro'], thesis: 'Patriot $50B contract, Strait of Hormuz play', instrumentType: 'equity' }, - { ticker: 'LMT', layer: 'Macro — Defense', strategies: ['midterm_macro'], thesis: 'F-35 program, European rearmament beneficiary', instrumentType: 'equity' }, - { ticker: 'NOC', layer: 'Macro — Defense', strategies: ['midterm_macro'], thesis: 'B-21 ramp, Golden Dome awards', instrumentType: 'equity' }, - { ticker: 'GD', layer: 'Macro — Defense', strategies: ['midterm_macro'], thesis: 'Gulfstream + defense platforms', instrumentType: 'equity' }, - - // AI & Semiconductors (overlap with supply chain) - { ticker: 'NVDA', layer: 'Macro — AI Semis', strategies: ['midterm_macro', 'supply_chain'], thesis: 'Core AI GPU — visibility on capex cycle', instrumentType: 'equity' }, - { ticker: 'AVGO', layer: 'Macro — AI Semis', strategies: ['midterm_macro', 'supply_chain'], thesis: 'Custom AI chips, 106% AI revenue YoY', instrumentType: 'equity' }, - { ticker: 'AMD', layer: 'Macro — AI Semis', strategies: ['midterm_macro'], thesis: 'MI300X ramp, data center GPU share gains', instrumentType: 'equity' }, - { ticker: 'MU', layer: 'Macro — AI Semis', strategies: ['midterm_macro'], thesis: 'HBM3E production ramp, memory pricing', instrumentType: 'equity' }, - { ticker: 'SMCI', layer: 'Macro — AI Semis', strategies: ['midterm_macro'], thesis: 'AI server integration, liquid cooling', instrumentType: 'equity' }, - - // Biotech & Healthcare - { ticker: 'VRTX', layer: 'Macro — Biotech', strategies: ['midterm_macro'], thesis: 'FDA catalysts, gene therapy pipeline', instrumentType: 'equity' }, +export interface SectorWatchlist { + name: string + entries: WatchlistEntry[] + layers: string[] +} - // Macro Bear / Hedges - { ticker: 'QQQ', layer: 'Macro — Hedges', strategies: ['midterm_macro'], thesis: 'Tech-heavy hedge, portfolio protection', instrumentType: 'equity' }, - { ticker: 'SPY', layer: 'Macro — Hedges', strategies: ['midterm_macro'], thesis: 'Broad market hedge', instrumentType: 'equity' }, - { ticker: 'XRT', layer: 'Macro — Hedges', strategies: ['midterm_macro'], thesis: 'Consumer retail weakness indicator', instrumentType: 'equity' }, +export const SECTOR_TEMPLATES: Record = { + 'AI Supply Chain': [ + 'Layer 1 — Chip Packaging', + 'Layer 1 — Chip Inspection', + 'Layer 1 — Chip Cleaning', + 'Layer 2 — Optical Interconnects', + 'Layer 2 — Optical Networking', + 'Layer 2 — Optical Components', + 'Layer 3 — Signal Connectivity', + 'Layer 3 — Signal Integrity', + 'Layer 3 — Custom ASICs', + 'Layer 4 — Rack Integration', + 'Layer 4 — DC Construction', + 'Layer 4 — Networking', + 'Layer 5 — Thermal & Power', + 'Layer 5 — Thermal Systems', + 'Layer 5 — Electrical Enclosures', + 'Layer 6 — Copper', + 'Layer 6 — Copper Miners ETF', + 'Layer 6 — Rare Earth', + 'Layer 7 — Nuclear Power', + 'Layer 7 — Uranium', + 'Layer 7 — Uranium Mining', + 'Layer 7 — Nuclear PPAs', + ], + 'Defense & Aerospace': [ + 'Prime Contractors', + 'Missile Systems', + 'Space & Satellite', + 'Cybersecurity', + ], + 'Energy': [ + 'Oil Majors', + 'Renewables', + 'Nuclear', + 'Utilities', + 'Infrastructure', + ], + 'AI Semiconductors': [ + 'GPU / Accelerator', + 'Custom ASIC', + 'Memory / HBM', + 'Server Integration', + ], + 'Biotech': [ + 'Gene Therapy', + 'Oncology', + 'Rare Disease', + 'Medical Devices', + ], + 'Macro Hedges': [ + 'Index ETFs', + 'Sector ETFs', + 'Volatility', + ], + 'Crypto': [ + 'Layer 1', + 'DeFi', + 'Infrastructure', + ], + 'Custom': [], +} - // === Strategy 3: Crypto (24/7 alert testing) === - { ticker: 'BTC/USD', layer: 'Crypto', strategies: ['crypto'], thesis: '24/7 Bitcoin, high volatility for alert pipeline testing', instrumentType: 'crypto' }, - { ticker: 'ETH/USD', layer: 'Crypto', strategies: ['crypto'], thesis: '24/7 Ethereum, DeFi/L2 activity proxy', instrumentType: 'crypto' }, +export const SEED_WATCHLISTS: SectorWatchlist[] = [ + { + name: 'AI Supply Chain', + layers: SECTOR_TEMPLATES['AI Supply Chain'], + entries: [ + { ticker: 'AMKR', description: 'Amkor Technology', layer: 'Layer 1 — Chip Packaging', strategies: ['supply_chain'], thesis: '2.5D advanced packaging, TSMC alternative, Arizona + Vietnam expansion', instrumentType: 'equity' }, + { ticker: 'CAMT', description: 'Camtek Ltd', layer: 'Layer 1 — Chip Inspection', strategies: ['supply_chain'], thesis: 'HBM wafer inspection, HBM4 transition catalyst', instrumentType: 'equity' }, + { ticker: 'ACMR', description: 'ACM Research', layer: 'Layer 1 — Chip Cleaning', strategies: ['supply_chain'], thesis: 'Wafer cleaning equipment for AI fabs', instrumentType: 'equity' }, + { ticker: 'FN', description: 'Fabrinet', layer: 'Layer 2 — Optical Interconnects', strategies: ['supply_chain'], thesis: 'Only manufacturer for 1.6T transceivers at scale', instrumentType: 'equity' }, + { ticker: 'CIEN', description: 'Ciena Corp', layer: 'Layer 2 — Optical Networking', strategies: ['supply_chain'], thesis: 'Optical networking for inter-data center connectivity', instrumentType: 'equity' }, + { ticker: 'LITE', description: 'Lumentum Holdings', layer: 'Layer 2 — Optical Components', strategies: ['supply_chain'], thesis: 'Optical switches and laser components for AI servers', instrumentType: 'equity' }, + { ticker: 'ALAB', description: 'Astera Labs', layer: 'Layer 3 — Signal Connectivity', strategies: ['supply_chain'], thesis: 'PCIe retimers, Scorpio AI fabric switches, NVLink Fusion', instrumentType: 'equity' }, + { ticker: 'CRDO', description: 'Credo Technology', layer: 'Layer 3 — Signal Integrity', strategies: ['supply_chain'], thesis: 'Competing retimer chips, high volatility leverage', instrumentType: 'equity' }, + { ticker: 'MRVL', description: 'Marvell Technology', layer: 'Layer 3 — Custom ASICs', strategies: ['supply_chain'], thesis: 'AWS/Microsoft custom AI chips, 3nm volume ramp', instrumentType: 'equity' }, + { ticker: 'CLS', description: 'Celestica Inc', layer: 'Layer 4 — Rack Integration', strategies: ['supply_chain'], thesis: 'AI rack integration, Broadcom manufacturing partner', instrumentType: 'equity' }, + { ticker: 'EME', description: 'EMCOR Group', layer: 'Layer 4 — DC Construction', strategies: ['supply_chain'], thesis: 'Physical data center construction and electrical infrastructure', instrumentType: 'equity' }, + { ticker: 'CSCO', description: 'Cisco Systems', layer: 'Layer 4 — Networking', strategies: ['supply_chain'], thesis: 'Ultra-ethernet consortium, data center switching', instrumentType: 'equity' }, + { ticker: 'VRT', description: 'Vertiv Holdings', layer: 'Layer 5 — Thermal & Power', strategies: ['supply_chain'], thesis: 'Liquid cooling, UPS — every DC must retrofit', instrumentType: 'equity' }, + { ticker: 'MOHN', description: 'Modine Manufacturing', layer: 'Layer 5 — Thermal Systems', strategies: ['supply_chain'], thesis: 'Thermal management systems for high-density racks', instrumentType: 'equity' }, + { ticker: 'NVT', description: 'nVent Electric', layer: 'Layer 5 — Electrical Enclosures', strategies: ['supply_chain'], thesis: 'Electrical enclosures and thermal management', instrumentType: 'equity' }, + { ticker: 'FCX', description: 'Freeport-McMoRan', layer: 'Layer 6 — Copper', strategies: ['supply_chain'], thesis: 'Largest public copper producer, inelastic AI demand', instrumentType: 'equity' }, + { ticker: 'COPX', description: 'Global X Copper Miners ETF', layer: 'Layer 6 — Copper Miners ETF', strategies: ['supply_chain'], thesis: 'Diversified copper miner exposure', instrumentType: 'equity' }, + { ticker: 'MP', description: 'MP Materials', layer: 'Layer 6 — Rare Earth', strategies: ['supply_chain'], thesis: 'Rare earth materials, magnets for AI hardware', instrumentType: 'equity' }, + { ticker: 'CEG', description: 'Constellation Energy', layer: 'Layer 7 — Nuclear Power', strategies: ['supply_chain'], thesis: 'Largest U.S. nuclear operator, hyperscaler PPAs', instrumentType: 'equity' }, + { ticker: 'CCJ', description: 'Cameco Corp', layer: 'Layer 7 — Uranium', strategies: ['supply_chain'], thesis: 'Largest listed uranium producer, 55% earnings growth 2026', instrumentType: 'equity' }, + { ticker: 'UEC', description: 'Uranium Energy Corp', layer: 'Layer 7 — Uranium Mining', strategies: ['supply_chain'], thesis: 'U.S.-based, leveraged to spot uranium price', instrumentType: 'equity' }, + { ticker: 'TLN', description: 'Talen Energy', layer: 'Layer 7 — Nuclear PPAs', strategies: ['supply_chain'], thesis: 'Nuclear PPA deals with AI hyperscalers', instrumentType: 'equity' }, + ], + }, + { + name: 'Defense & Aerospace', + layers: SECTOR_TEMPLATES['Defense & Aerospace'], + entries: [ + { ticker: 'RTX', description: 'RTX Corp', layer: 'Prime Contractors', strategies: ['midterm_macro'], thesis: 'Patriot $50B contract, Strait of Hormuz play', instrumentType: 'equity' }, + { ticker: 'LMT', description: 'Lockheed Martin', layer: 'Prime Contractors', strategies: ['midterm_macro'], thesis: 'F-35 program, European rearmament beneficiary', instrumentType: 'equity' }, + { ticker: 'NOC', description: 'Northrop Grumman', layer: 'Prime Contractors', strategies: ['midterm_macro'], thesis: 'B-21 ramp, Golden Dome awards', instrumentType: 'equity' }, + { ticker: 'GD', description: 'General Dynamics', layer: 'Prime Contractors', strategies: ['midterm_macro'], thesis: 'Gulfstream + defense platforms', instrumentType: 'equity' }, + ], + }, + { + name: 'Energy', + layers: SECTOR_TEMPLATES['Energy'], + entries: [ + { ticker: 'XLE', description: 'Energy Select Sector SPDR', layer: 'Oil Majors', strategies: ['midterm_macro'], thesis: 'Energy sector ETF, oil geopolitics exposure', instrumentType: 'equity' }, + { ticker: 'XOM', description: 'Exxon Mobil', layer: 'Oil Majors', strategies: ['midterm_macro'], thesis: 'Oil major, Strait of Hormuz beneficiary', instrumentType: 'equity' }, + { ticker: 'CVX', description: 'Chevron Corp', layer: 'Oil Majors', strategies: ['midterm_macro'], thesis: 'Integrated oil major, energy infrastructure', instrumentType: 'equity' }, + ], + }, + { + name: 'AI Semiconductors', + layers: SECTOR_TEMPLATES['AI Semiconductors'], + entries: [ + { ticker: 'NVDA', description: 'NVIDIA Corp', layer: 'GPU / Accelerator', strategies: ['midterm_macro', 'supply_chain'], thesis: 'Core AI GPU — visibility on capex cycle', instrumentType: 'equity' }, + { ticker: 'AVGO', description: 'Broadcom Inc', layer: 'Custom ASIC', strategies: ['midterm_macro', 'supply_chain'], thesis: 'Custom AI chips, 106% AI revenue YoY', instrumentType: 'equity' }, + { ticker: 'AMD', description: 'Advanced Micro Devices', layer: 'GPU / Accelerator', strategies: ['midterm_macro'], thesis: 'MI300X ramp, data center GPU share gains', instrumentType: 'equity' }, + { ticker: 'MU', description: 'Micron Technology', layer: 'Memory / HBM', strategies: ['midterm_macro'], thesis: 'HBM3E production ramp, memory pricing', instrumentType: 'equity' }, + { ticker: 'SMCI', description: 'Super Micro Computer', layer: 'Server Integration', strategies: ['midterm_macro'], thesis: 'AI server integration, liquid cooling', instrumentType: 'equity' }, + ], + }, + { + name: 'Biotech', + layers: SECTOR_TEMPLATES['Biotech'], + entries: [ + { ticker: 'VRTX', description: 'Vertex Pharmaceuticals', layer: 'Gene Therapy', strategies: ['midterm_macro'], thesis: 'FDA catalysts, gene therapy pipeline', instrumentType: 'equity' }, + ], + }, + { + name: 'Macro Hedges', + layers: SECTOR_TEMPLATES['Macro Hedges'], + entries: [ + { ticker: 'QQQ', description: 'Invesco QQQ Trust', layer: 'Index ETFs', strategies: ['midterm_macro'], thesis: 'Tech-heavy hedge, portfolio protection', instrumentType: 'equity' }, + { ticker: 'SPY', description: 'SPDR S&P 500 ETF', layer: 'Index ETFs', strategies: ['midterm_macro'], thesis: 'Broad market hedge', instrumentType: 'equity' }, + { ticker: 'XRT', description: 'SPDR S&P Retail ETF', layer: 'Sector ETFs', strategies: ['midterm_macro'], thesis: 'Consumer retail weakness indicator', instrumentType: 'equity' }, + ], + }, + { + name: 'Crypto', + layers: SECTOR_TEMPLATES['Crypto'], + entries: [ + { ticker: 'BTC/USD', description: 'Bitcoin', layer: 'Layer 1', strategies: ['crypto'], thesis: '24/7 Bitcoin, high volatility for alert pipeline testing', instrumentType: 'crypto' }, + { ticker: 'ETH/USD', description: 'Ethereum', layer: 'Layer 1', strategies: ['crypto'], thesis: '24/7 Ethereum, DeFi/L2 activity proxy', instrumentType: 'crypto' }, + ], + }, ] +/** Flat list for backward compat with streamer subscription */ +export const WATCHLIST: WatchlistEntry[] = SEED_WATCHLISTS.flatMap(s => s.entries) + export function getUniqueSymbols(): string[] { return [...new Set(WATCHLIST.map(w => w.ticker))] } diff --git a/packages/monitor/src/watchlistService.ts b/packages/monitor/src/watchlistService.ts index 87c9566..fdb8b9b 100644 --- a/packages/monitor/src/watchlistService.ts +++ b/packages/monitor/src/watchlistService.ts @@ -14,7 +14,7 @@ import { getAllUserTickers, type WatchlistItemRow, } from './db.js' -import { WATCHLIST } from './watchlist.config.js' +import { WATCHLIST, SEED_WATCHLISTS } from './watchlist.config.js' export function getUserWatchlistsWithItems(userId: number): Watchlist[] { const lists = getUserWatchlists(userId) @@ -157,19 +157,23 @@ export async function searchSymbols(query: string): Promise<{ ticker: string; de } function seedDefaultWatchlist(userId: number): void { - const wl = getOrCreateDefaultWatchlist(userId) - replaceWatchlistItems( - wl.id, - WATCHLIST.map((entry, idx) => ({ - ticker: entry.ticker, - layer: entry.layer, - strategies: JSON.stringify(entry.strategies), - thesis: entry.thesis, - instrumentType: entry.instrumentType, - sortOrder: idx, - })), - ) - log.info(`Seeded default watchlist for user ${userId} with ${WATCHLIST.length} items`) + let totalItems = 0 + for (const sector of SEED_WATCHLISTS) { + const wl = getOrCreateWatchlist(userId, sector.name) + replaceWatchlistItems( + wl.id, + sector.entries.map((entry, idx) => ({ + ticker: entry.ticker, + layer: entry.layer, + strategies: JSON.stringify(entry.strategies), + thesis: entry.thesis, + instrumentType: entry.instrumentType, + sortOrder: idx, + })), + ) + totalItems += sector.entries.length + } + log.info(`Seeded ${SEED_WATCHLISTS.length} sector watchlists for user ${userId} with ${totalItems} total items`) } function rowToItem(row: WatchlistItemRow): WatchlistItem { From 46fc8fcaca3f590ea470fcd8425d0587aba798ed Mon Sep 17 00:00:00 2001 From: Berti Date: Sun, 29 Mar 2026 14:53:11 -0500 Subject: [PATCH 6/7] feat(dashboard): multi-watchlist builder with sector templates and smart search Complete rewrite of WatchlistBuilder with: - Left sidebar showing all watchlists with create/rename/delete actions - Sector template picker when creating new watchlists (AI Supply Chain, Defense, Energy, Biotech, Crypto, etc.) with pre-populated layers - Company name column in the watchlist table - Debounced ticker search (300ms) with loading spinner - Keyboard navigation (arrow keys + Enter) in search dropdown - Dynamic layer dropdowns based on active watchlist's sector template Made-with: Cursor --- packages/dashboard/src/App.tsx | 7 +- .../src/components/WatchlistBuilder.tsx | 645 +++++++++++++----- 2 files changed, 462 insertions(+), 190 deletions(-) diff --git a/packages/dashboard/src/App.tsx b/packages/dashboard/src/App.tsx index af395a5..c1e59ee 100644 --- a/packages/dashboard/src/App.tsx +++ b/packages/dashboard/src/App.tsx @@ -53,7 +53,9 @@ export function App() { paperAccount, paperPositions, paperOrders, requestChain, sendRaw, login, register, logout, saveAgentConfig, requestAgentConfig, sendTestAlert, - requestWatchlist, saveWatchlist, deleteWatchlistItem, syncTastytradeWatchlists, + requestWatchlist, saveWatchlist, deleteWatchlistItem, + createWatchlist, deleteWatchlist, renameWatchlist, + syncTastytradeWatchlists, searchSymbols, sendChatMessage, clearChat, saveScheduleConfig, requestScheduleConfig, requestBudgetStatus, requestReports, runAnalysisNow, @@ -153,6 +155,9 @@ export function App() { onRequestWatchlist={requestWatchlist} onSave={saveWatchlist} onDelete={deleteWatchlistItem} + onCreate={createWatchlist} + onDeleteWatchlist={deleteWatchlist} + onRenameWatchlist={renameWatchlist} onSync={syncTastytradeWatchlists} onSearch={searchSymbols} /> diff --git a/packages/dashboard/src/components/WatchlistBuilder.tsx b/packages/dashboard/src/components/WatchlistBuilder.tsx index ade5583..a2d243a 100644 --- a/packages/dashboard/src/components/WatchlistBuilder.tsx +++ b/packages/dashboard/src/components/WatchlistBuilder.tsx @@ -1,57 +1,88 @@ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useRef, useCallback } from 'react' import type { Watchlist, WatchlistItem } from '@tastytrade-monitor/shared' +const SECTOR_TEMPLATES: Record = { + 'AI Supply Chain': [ + 'Layer 1 — Chip Packaging', 'Layer 1 — Chip Inspection', 'Layer 1 — Chip Cleaning', + 'Layer 2 — Optical Interconnects', 'Layer 2 — Optical Networking', 'Layer 2 — Optical Components', + 'Layer 3 — Signal Connectivity', 'Layer 3 — Signal Integrity', 'Layer 3 — Custom ASICs', + 'Layer 4 — Rack Integration', 'Layer 4 — DC Construction', 'Layer 4 — Networking', + 'Layer 5 — Thermal & Power', 'Layer 5 — Thermal Systems', 'Layer 5 — Electrical Enclosures', + 'Layer 6 — Copper', 'Layer 6 — Copper Miners ETF', 'Layer 6 — Rare Earth', + 'Layer 7 — Nuclear Power', 'Layer 7 — Uranium', 'Layer 7 — Uranium Mining', 'Layer 7 — Nuclear PPAs', + ], + 'Defense & Aerospace': ['Prime Contractors', 'Missile Systems', 'Space & Satellite', 'Cybersecurity'], + 'Energy': ['Oil Majors', 'Renewables', 'Nuclear', 'Utilities', 'Infrastructure'], + 'AI Semiconductors': ['GPU / Accelerator', 'Custom ASIC', 'Memory / HBM', 'Server Integration'], + 'Biotech': ['Gene Therapy', 'Oncology', 'Rare Disease', 'Medical Devices'], + 'Macro Hedges': ['Index ETFs', 'Sector ETFs', 'Volatility'], + 'Crypto': ['Layer 1', 'DeFi', 'Infrastructure'], + 'Custom': [], +} + +const STRATEGIES = ['supply_chain', 'midterm_macro', 'crypto'] + interface Props { watchlists: Watchlist[] searchResults: { ticker: string; description: string; instrumentType: string }[] onRequestWatchlist: () => void onSave: (name: string, items: WatchlistItem[]) => void onDelete: (watchlistName: string, ticker: string) => void + onCreate: (name: string) => void + onDeleteWatchlist: (name: string) => void + onRenameWatchlist: (oldName: string, newName: string) => void onSync: () => void onSearch: (query: string) => void } -const LAYERS = [ - 'Layer 1 — Chip Packaging', - 'Layer 2 — Optical Interconnects', - 'Layer 3 — Signal Connectivity', - 'Layer 4 — Rack Integration', - 'Layer 5 — Thermal & Power', - 'Layer 6 — Raw Materials', - 'Layer 7 — Nuclear Power', - 'Macro — Energy', - 'Macro — Defense', - 'Macro — AI Semis', - 'Macro — Biotech', - 'Macro — Hedges', - 'Crypto', -] - -const STRATEGIES = ['supply_chain', 'midterm_macro', 'crypto'] - -export function WatchlistBuilder({ watchlists, searchResults, onRequestWatchlist, onSave, onDelete, onSync, onSearch }: Props) { +export function WatchlistBuilder({ + watchlists, searchResults, onRequestWatchlist, onSave, onDelete, + onCreate, onDeleteWatchlist, onRenameWatchlist, onSync, onSearch, +}: Props) { + const [activeListName, setActiveListName] = useState(null) const [editingItem, setEditingItem] = useState(null) const [addTicker, setAddTicker] = useState('') const [showSearch, setShowSearch] = useState(false) + const [searching, setSearching] = useState(false) const [syncing, setSyncing] = useState(false) const [dirty, setDirty] = useState(false) const [localItems, setLocalItems] = useState([]) + const [showCreateModal, setShowCreateModal] = useState(false) + const [newListName, setNewListName] = useState('') + const [newListSector, setNewListSector] = useState('Custom') + const [renamingList, setRenamingList] = useState(null) + const [renameValue, setRenameValue] = useState('') + const [highlightIdx, setHighlightIdx] = useState(-1) const searchRef = useRef(null) + const debounceRef = useRef>(undefined) + const dropdownRef = useRef(null) useEffect(() => { onRequestWatchlist() }, [onRequestWatchlist]) - const wl = watchlists[0] useEffect(() => { - if (wl) { - setLocalItems(wl.items) + if (watchlists.length > 0 && !activeListName) { + setActiveListName(watchlists[0].name) + } + }, [watchlists, activeListName]) + + const activeWl = watchlists.find(w => w.name === activeListName) ?? watchlists[0] + + useEffect(() => { + if (activeWl) { + setLocalItems(activeWl.items) setDirty(false) } - }, [wl]) + }, [activeWl?.name, activeWl?.items.length]) + + const activeLayers = activeListName + ? SECTOR_TEMPLATES[activeListName] ?? SECTOR_TEMPLATES['Custom'] + : [] - const handleAddTicker = (ticker: string, instrumentType = 'equity') => { + const handleAddTicker = (ticker: string, instrumentType = 'equity', description = '') => { if (!ticker || localItems.some(i => i.ticker === ticker.toUpperCase())) return const newItem: WatchlistItem = { ticker: ticker.toUpperCase(), + description: description || undefined, layer: null, strategies: [], thesis: '', @@ -62,6 +93,7 @@ export function WatchlistBuilder({ watchlists, searchResults, onRequestWatchlist setDirty(true) setAddTicker('') setShowSearch(false) + setHighlightIdx(-1) } const handleUpdateItem = (ticker: string, updates: Partial) => { @@ -74,12 +106,14 @@ export function WatchlistBuilder({ watchlists, searchResults, onRequestWatchlist const handleRemove = (ticker: string) => { setLocalItems(prev => prev.filter(i => i.ticker !== ticker)) setDirty(true) - if (wl) onDelete(wl.name, ticker) + if (activeWl) onDelete(activeWl.name, ticker) } const handleSave = () => { - onSave(wl?.name ?? 'Default', localItems) - setDirty(false) + if (activeWl) { + onSave(activeWl.name, localItems) + setDirty(false) + } } const handleSync = () => { @@ -88,190 +122,423 @@ export function WatchlistBuilder({ watchlists, searchResults, onRequestWatchlist setTimeout(() => setSyncing(false), 3000) } - const handleSearchInput = (q: string) => { - setAddTicker(q) - if (q.length >= 1) { + const debouncedSearch = useCallback((q: string) => { + clearTimeout(debounceRef.current) + if (q.length < 1) { + setShowSearch(false) + setSearching(false) + return + } + setSearching(true) + debounceRef.current = setTimeout(() => { onSearch(q) setShowSearch(true) - } else { + }, 300) + }, [onSearch]) + + useEffect(() => { + if (searchResults.length > 0) setSearching(false) + }, [searchResults]) + + const handleSearchInput = (q: string) => { + setAddTicker(q) + setHighlightIdx(-1) + debouncedSearch(q) + } + + const handleSearchKeyDown = (e: React.KeyboardEvent) => { + if (!showSearch || searchResults.length === 0) { + if (e.key === 'Enter' && addTicker) handleAddTicker(addTicker) + return + } + if (e.key === 'ArrowDown') { + e.preventDefault() + setHighlightIdx(prev => Math.min(prev + 1, searchResults.length - 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setHighlightIdx(prev => Math.max(prev - 1, 0)) + } else if (e.key === 'Enter') { + e.preventDefault() + if (highlightIdx >= 0 && highlightIdx < searchResults.length) { + const r = searchResults[highlightIdx] + handleAddTicker(r.ticker, r.instrumentType, r.description) + } else if (addTicker) { + handleAddTicker(addTicker) + } + } else if (e.key === 'Escape') { setShowSearch(false) + setHighlightIdx(-1) } } + const handleCreateList = () => { + if (!newListName.trim()) return + onCreate(newListName.trim()) + setActiveListName(newListName.trim()) + setShowCreateModal(false) + setNewListName('') + setNewListSector('Custom') + } + + const handleDeleteList = (name: string) => { + if (!confirm(`Delete watchlist "${name}" and all its items?`)) return + onDeleteWatchlist(name) + if (activeListName === name) { + setActiveListName(watchlists.find(w => w.name !== name)?.name ?? null) + } + } + + const handleRenameSubmit = (oldName: string) => { + if (renameValue.trim() && renameValue.trim() !== oldName) { + onRenameWatchlist(oldName, renameValue.trim()) + if (activeListName === oldName) setActiveListName(renameValue.trim()) + } + setRenamingList(null) + setRenameValue('') + } + return ( -
-
-
-

- Watchlist Builder -

-

- {localItems.length} symbols — manage your trading watchlist -

-
-
+
+ {/* Sidebar: watchlist selector */} +
+
+

Watchlists

- {dirty && ( - - )}
-
- {/* Add ticker input */} -
-
- handleSearchInput(e.target.value)} - onKeyDown={e => { - if (e.key === 'Enter' && addTicker) handleAddTicker(addTicker) - }} - placeholder="Add symbol (e.g. AAPL, TSLA)..." - className="flex-1 bg-[#0a0a0a] border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 font-mono focus:border-amber-500 focus:outline-none placeholder:text-gray-700" - /> - -
- {showSearch && searchResults.length > 0 && ( -
- {searchResults.map(r => ( - - ))} +
+
{wl.name}
+
{wl.items.length} symbols
+
+
+ + +
+
+ )} +
+ ))} + + {watchlists.length === 0 && ( +
+ No watchlists yet
)} + +
- {/* Table */} -
- - - - - - - - - - - - - {localItems.map((item) => ( - - - - - - - - - ))} - -
SymbolLayerStrategiesThesisType
{item.ticker} - {editingItem === item.ticker ? ( - - ) : ( - setEditingItem(item.ticker)} - > - {item.layer ?? '-'} - + {/* Main: watchlist editor */} +
+ {activeWl ? ( + <> +
+
+

+ {activeWl.name} +

+

+ {localItems.length} symbols + {SECTOR_TEMPLATES[activeWl.name] && ( + — {Object.keys(SECTOR_TEMPLATES).find(k => k === activeWl.name) ? 'sector template' : 'custom'} )} -

-
- {STRATEGIES.map(s => ( - - ))} -
-
- {editingItem === item.ticker ? ( - handleUpdateItem(item.ticker, { thesis: e.target.value })} - onBlur={() => setEditingItem(null)} - className="bg-[#0a0a0a] border border-gray-700 rounded px-1 py-0.5 text-[11px] text-gray-300 w-full" - autoFocus - /> - ) : ( - setEditingItem(item.ticker)} - title={item.thesis} - > - {item.thesis || 'click to add thesis...'} - +

+ + {dirty && ( + + )} + + + {/* Add ticker input with debounced search */} +
+
+
+ handleSearchInput(e.target.value)} + onKeyDown={handleSearchKeyDown} + onBlur={() => setTimeout(() => setShowSearch(false), 200)} + onFocus={() => { if (addTicker.length >= 1 && searchResults.length > 0) setShowSearch(true) }} + placeholder="Search by ticker or company name..." + className="w-full bg-[#0a0a0a] border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 font-mono focus:border-amber-500 focus:outline-none placeholder:text-gray-700" + /> + {searching && ( +
+
+
)} -
- - {item.instrumentType} - - - -
- {localItems.length === 0 && ( -
- No symbols in watchlist. Add a ticker above or sync from tastytrade. +
+ +
+ {showSearch && searchResults.length > 0 && ( +
+ {searchResults.map((r, idx) => ( + + ))} +
+ )} +
+ + {/* Table */} +
+ + + + + + + + + + + + + + {localItems.map((item) => ( + + + + + + + + + + ))} + +
SymbolCompanyLayerStrategiesThesisType
{item.ticker} + {item.description || } + + {editingItem === item.ticker ? ( + + ) : ( + setEditingItem(item.ticker)} + > + {item.layer ?? '-'} + + )} + +
+ {STRATEGIES.map(s => ( + + ))} +
+
+ {editingItem === item.ticker ? ( + handleUpdateItem(item.ticker, { thesis: e.target.value })} + onBlur={() => setEditingItem(null)} + className="bg-[#0a0a0a] border border-gray-700 rounded px-1 py-0.5 text-[11px] text-gray-300 w-full" + autoFocus + /> + ) : ( + setEditingItem(item.ticker)} + title={item.thesis} + > + {item.thesis || 'click to add thesis...'} + + )} + + + {item.instrumentType} + + + +
+ {localItems.length === 0 && ( +
+ No symbols in this watchlist. Search for a ticker above or sync from tastytrade. +
+ )} +
+ + ) : ( +
+ Select a watchlist or create a new one
)}
+ + {/* Create watchlist modal */} + {showCreateModal && ( +
+
+

New Watchlist

+ +
+ + +
+ +
+ + setNewListName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleCreateList() }} + placeholder="e.g. Quantum Computing" + className="w-full bg-[#0a0a0a] border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 font-mono focus:border-amber-500 focus:outline-none placeholder:text-gray-700" + /> +
+ + {newListSector !== 'Custom' && SECTOR_TEMPLATES[newListSector]?.length > 0 && ( +
+ Layers included: +
+ {SECTOR_TEMPLATES[newListSector].slice(0, 8).map(l => ( + {l} + ))} + {SECTOR_TEMPLATES[newListSector].length > 8 && ( + +{SECTOR_TEMPLATES[newListSector].length - 8} more + )} +
+
+ )} + +
+ + +
+
+
+ )}
) } From 1830a23adf024b9a66d71d5687bea1f0c3651bac Mon Sep 17 00:00:00 2001 From: Berti Date: Sun, 29 Mar 2026 14:55:18 -0500 Subject: [PATCH 7/7] feat: add company name (description) to watchlist items and market data table - Add description column to watchlist_items DB schema with migration - Pass description through save/load/seed pipeline - Show company name column in WatchlistTable (Market Data view) - Filter by company name in market data search - Description persisted from ticker search results when adding symbols Made-with: Cursor --- .../src/components/WatchlistTable.tsx | 5 +++- packages/monitor/src/broadcaster.ts | 1 + packages/monitor/src/db.ts | 24 +++++++++++++------ packages/monitor/src/watchlistService.ts | 3 +++ 4 files changed, 25 insertions(+), 8 deletions(-) diff --git a/packages/dashboard/src/components/WatchlistTable.tsx b/packages/dashboard/src/components/WatchlistTable.tsx index 1ee4481..48c393e 100644 --- a/packages/dashboard/src/components/WatchlistTable.tsx +++ b/packages/dashboard/src/components/WatchlistTable.tsx @@ -28,7 +28,8 @@ export function WatchlistTable({ snapshots, alerts }: Props) { const filtered = snapshots.filter(s => !filter || s.ticker.toLowerCase().includes(filter.toLowerCase()) || - (s.layer?.toLowerCase().includes(filter.toLowerCase()) ?? false) + (s.layer?.toLowerCase().includes(filter.toLowerCase()) ?? false) || + (s.description?.toLowerCase().includes(filter.toLowerCase()) ?? false) ) const sorted = [...filtered].sort((a, b) => { @@ -77,6 +78,7 @@ export function WatchlistTable({ snapshots, alerts }: Props) { + Name Bid @@ -114,6 +116,7 @@ export function WatchlistTable({ snapshots, alerts }: Props) { )} + {s.description ?? ''} {s.layer ?? '-'} {fmtPrice(s.price)} {fmtPrice(s.bid)} diff --git a/packages/monitor/src/broadcaster.ts b/packages/monitor/src/broadcaster.ts index 4f020a8..e12d988 100644 --- a/packages/monitor/src/broadcaster.ts +++ b/packages/monitor/src/broadcaster.ts @@ -433,6 +433,7 @@ function handleSaveWatchlist(ws: WebSocket, data: Record): void const items = (data.items ?? []) as Array> saveWatchlist(auth.userId, name, items.map((item, idx) => ({ ticker: String(item.ticker ?? ''), + description: item.description != null ? String(item.description) : undefined, layer: item.layer != null ? String(item.layer) : null, strategies: Array.isArray(item.strategies) ? item.strategies.map(String) : [], thesis: String(item.thesis ?? ''), diff --git a/packages/monitor/src/db.ts b/packages/monitor/src/db.ts index 3cba345..4c37a5e 100644 --- a/packages/monitor/src/db.ts +++ b/packages/monitor/src/db.ts @@ -78,6 +78,7 @@ function migrateV2Tables(db: Database.Database): void { id INTEGER PRIMARY KEY AUTOINCREMENT, watchlist_id INTEGER NOT NULL REFERENCES watchlists(id) ON DELETE CASCADE, ticker TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', layer TEXT, strategies TEXT NOT NULL DEFAULT '[]', thesis TEXT NOT NULL DEFAULT '', @@ -161,6 +162,12 @@ function migrateV2Tables(db: Database.Database): void { CREATE INDEX IF NOT EXISTS idx_analyses_log_user ON analyses_log(user_id, created_at); `) + + const wiCols = db.pragma('table_info(watchlist_items)') as { name: string }[] + if (!wiCols.some(c => c.name === 'description')) { + db.exec("ALTER TABLE watchlist_items ADD COLUMN description TEXT NOT NULL DEFAULT ''") + log.info('Migrated watchlist_items: added description column') + } } // ─── Interfaces ────────────────────────────────────────────────── @@ -280,6 +287,7 @@ export interface WatchlistItemRow { id: number watchlist_id: number ticker: string + description: string layer: string | null strategies: string thesis: string @@ -332,6 +340,7 @@ export function getWatchlistItems(watchlistId: number): WatchlistItemRow[] { export function upsertWatchlistItem( watchlistId: number, ticker: string, + description: string, layer: string | null, strategies: string, thesis: string, @@ -339,16 +348,17 @@ export function upsertWatchlistItem( sortOrder: number, ): WatchlistItemRow { return getDb().prepare(` - INSERT INTO watchlist_items (watchlist_id, ticker, layer, strategies, thesis, instrument_type, sort_order) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO watchlist_items (watchlist_id, ticker, description, layer, strategies, thesis, instrument_type, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(watchlist_id, ticker) DO UPDATE SET + description = excluded.description, layer = excluded.layer, strategies = excluded.strategies, thesis = excluded.thesis, instrument_type = excluded.instrument_type, sort_order = excluded.sort_order RETURNING * - `).get(watchlistId, ticker, layer, strategies, thesis, instrumentType, sortOrder) as WatchlistItemRow + `).get(watchlistId, ticker, description, layer, strategies, thesis, instrumentType, sortOrder) as WatchlistItemRow } export function deleteWatchlistItem(watchlistId: number, ticker: string): void { @@ -357,16 +367,16 @@ export function deleteWatchlistItem(watchlistId: number, ticker: string): void { export function replaceWatchlistItems( watchlistId: number, - items: { ticker: string; layer: string | null; strategies: string; thesis: string; instrumentType: string; sortOrder: number }[], + items: { ticker: string; description?: string; layer: string | null; strategies: string; thesis: string; instrumentType: string; sortOrder: number }[], ): void { const tx = getDb().transaction(() => { getDb().prepare('DELETE FROM watchlist_items WHERE watchlist_id = ?').run(watchlistId) const insert = getDb().prepare(` - INSERT INTO watchlist_items (watchlist_id, ticker, layer, strategies, thesis, instrument_type, sort_order) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO watchlist_items (watchlist_id, ticker, description, layer, strategies, thesis, instrument_type, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) `) for (const item of items) { - insert.run(watchlistId, item.ticker, item.layer, item.strategies, item.thesis, item.instrumentType, item.sortOrder) + insert.run(watchlistId, item.ticker, item.description ?? '', item.layer, item.strategies, item.thesis, item.instrumentType, item.sortOrder) } }) tx() diff --git a/packages/monitor/src/watchlistService.ts b/packages/monitor/src/watchlistService.ts index fdb8b9b..b2dcc80 100644 --- a/packages/monitor/src/watchlistService.ts +++ b/packages/monitor/src/watchlistService.ts @@ -38,6 +38,7 @@ export function saveWatchlist(userId: number, name: string, items: WatchlistItem wl.id, items.map((item, idx) => ({ ticker: item.ticker.toUpperCase(), + description: item.description, layer: item.layer, strategies: JSON.stringify(item.strategies), thesis: item.thesis, @@ -164,6 +165,7 @@ function seedDefaultWatchlist(userId: number): void { wl.id, sector.entries.map((entry, idx) => ({ ticker: entry.ticker, + description: entry.description, layer: entry.layer, strategies: JSON.stringify(entry.strategies), thesis: entry.thesis, @@ -181,6 +183,7 @@ function rowToItem(row: WatchlistItemRow): WatchlistItem { try { strategies = JSON.parse(row.strategies) } catch { /* empty */ } return { ticker: row.ticker, + description: row.description || undefined, layer: row.layer, strategies, thesis: row.thesis,