diff --git a/packages/dashboard/src/App.tsx b/packages/dashboard/src/App.tsx index 45abfa9..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} /> @@ -176,6 +181,7 @@ export function App() { 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' && ( )} diff --git a/packages/dashboard/src/components/ReportsPanel.tsx b/packages/dashboard/src/components/ReportsPanel.tsx index 8601884..e0a7cd4 100644 --- a/packages/dashboard/src/components/ReportsPanel.tsx +++ b/packages/dashboard/src/components/ReportsPanel.tsx @@ -1,9 +1,10 @@ -import { useState, useEffect } from 'react' -import type { AnalysisReport, BudgetStatus } from '@tastytrade-monitor/shared' +import { useState, useEffect, useRef } from 'react' +import type { AnalysisReport, BudgetStatus, AgentStatus } from '@tastytrade-monitor/shared' interface Props { reports: AnalysisReport[] budgetStatus: BudgetStatus | null + agentStatus: AgentStatus | null onRequestReports: (date?: string) => 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 && (
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 + )} +
+
+ )} + +
+ + +
+
+
+ )}
) } 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/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..e12d988 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' @@ -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 ?? ''), @@ -453,6 +454,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 +799,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/monitor/src/db.ts b/packages/monitor/src/db.ts index 32cc8a6..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 @@ -302,6 +310,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' @@ -311,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, @@ -318,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 { @@ -336,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/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 be47043..b2dcc80 100644 --- a/packages/monitor/src/watchlistService.ts +++ b/packages/monitor/src/watchlistService.ts @@ -5,13 +5,16 @@ import { log } from './logger.js' import { getUserWatchlists, getOrCreateDefaultWatchlist, + getOrCreateWatchlist, getWatchlistItems, replaceWatchlistItems, deleteWatchlistItem, + deleteWatchlistByName, + renameWatchlist as dbRenameWatchlist, 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) @@ -29,13 +32,13 @@ 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, items.map((item, idx) => ({ ticker: item.ticker.toUpperCase(), + description: item.description, layer: item.layer, strategies: JSON.stringify(item.strategies), thesis: item.thesis, @@ -46,11 +49,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] @@ -138,19 +158,24 @@ 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, + description: entry.description, + 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 { @@ -158,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, 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() }),