import type { DatabaseSync } from 'node:sqlite'; import { randomUUID } from 'node:crypto'; import { resolveBusinessContext } from '../analysis/tickerContext.ts'; export interface WatchlistEntry { symbol: string; added_at: string; notes?: string | null; } export interface WatchlistMeta { id: string; name: string; symbol_count: number; sort_order: number; created_at: string; kind?: string; class_key?: string | null; class_label?: string | null; } interface WatchlistRow { id: string; owner_id: string; name: string; symbols: string; created_at: string; sort_order: number; kind: string; class_key: string | null; class_label: string | null; } // Mapping from ETF symbol → market-outlook display label (kept in sync with marketRotationRs.ts). const ETF_TO_LABEL: Record = { XLK: 'Technology', XLF: 'Financials', XLE: 'Energy', XLI: 'Industrials', XLV: 'Healthcare', XLY: 'Consumer Discretionary', XLP: 'Consumer Staples', XLU: 'Utilities', XLRE: 'Real Estate', XLC: 'Communication Services', XLB: 'Materials', SMH: 'Semiconductors', XBI: 'Biotech', IWM: 'Style: Small', EFA: 'Region: Intl', }; const ALLOWED_SECTOR_ETFS = new Set(Object.keys(ETF_TO_LABEL).filter((k) => k.startsWith('XL'))); const ALLOWED_THEME_ETFS = new Set(['SMH', 'XBI']); const ALLOWED_STYLE_ETFS = new Set(['IWM']); const ALLOWED_REGION_ETFS = new Set(['EFA']); const US_EXCHANGES = new Set(['NASDAQ', 'NYSE', 'NYSEAMERICAN', 'NYSEARCA', 'BATS', 'NYSEMKT', 'OTC', 'PNK']); function stmts(db: DatabaseSync) { return { upsert: db.prepare( `INSERT INTO watchlists (id, owner_id, name, symbols, created_at, sort_order, kind, class_key, class_label) VALUES (?, ?, ?, ?, ?, COALESCE(?, 0), ?, ?, ?) ON CONFLICT(owner_id, kind, name) DO UPDATE SET symbols = excluded.symbols, sort_order = excluded.sort_order, class_key = excluded.class_key, class_label = excluded.class_label`, ), selectByOwnerKindName: db.prepare( `SELECT id, owner_id, name, symbols, created_at, sort_order, kind, class_key, class_label FROM watchlists WHERE owner_id = ? AND kind = ? AND name = ?`, ), selectByOwner: db.prepare( `SELECT id, owner_id, name, symbols, created_at, sort_order, kind, class_key, class_label FROM watchlists WHERE owner_id = ? ORDER BY kind DESC, sort_order ASC, created_at ASC`, ), selectByOwnerKind: db.prepare( `SELECT id, owner_id, name, symbols, created_at, sort_order, kind, class_key, class_label FROM watchlists WHERE owner_id = ? AND kind = ? ORDER BY sort_order ASC, created_at ASC`, ), deleteByOwnerKindName: db.prepare( `DELETE FROM watchlists WHERE owner_id = ? AND kind = ? AND name = ?`, ), updateSymbols: db.prepare( `UPDATE watchlists SET symbols = ? WHERE id = ? AND owner_id = ?`, ), selectById: db.prepare( `SELECT id, owner_id, name, symbols, created_at, sort_order, kind, class_key, class_label FROM watchlists WHERE id = ? AND owner_id = ?`, ), deleteById: db.prepare( `DELETE FROM watchlists WHERE id = ? AND owner_id = ?`, ), updateSortOrder: db.prepare( `UPDATE watchlists SET sort_order = ? WHERE id = ? AND owner_id = ?`, ), selectAllByOwner: db.prepare( `SELECT id, owner_id, name, symbols, created_at, sort_order, kind, class_key, class_label FROM watchlists WHERE owner_id = ? ORDER BY kind DESC, sort_order ASC, created_at ASC`, ), deleteSystemByClassKey: db.prepare( `DELETE FROM watchlists WHERE owner_id = ? AND kind != 'user' AND class_key = ?`, ), upsertSystem: db.prepare( `INSERT INTO watchlists (id, owner_id, name, symbols, created_at, sort_order, kind, class_key, class_label) VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?) ON CONFLICT(owner_id, kind, name) DO UPDATE SET symbols = excluded.symbols, class_key = excluded.class_key, class_label = excluded.class_label`, ), }; } export function addSymbol( db: DatabaseSync, userId: string, symbol: string, notes?: string, watchlistName: string = 'default', ): boolean { const s = stmts(db); const upper = symbol.toUpperCase(); const existing = readWatchlistRaw(db, userId, watchlistName, 'user'); if (existing) { const alreadyExists = existing.symbols.some((sym) => { if (typeof sym === 'string') return sym === upper; return sym.symbol === upper; }); if (alreadyExists) return false; if (notes) { existing.symbols.push({ symbol: upper, notes }); } else { existing.symbols.push(upper); } const now = new Date().toISOString(); s.upsert.run(existing.id, userId, watchlistName, JSON.stringify(existing.symbols), now, 0, 'user', null, null); return true; } const serialized = notes ? [{ symbol: upper, notes }] : [upper]; const id = generateId(); const now = new Date().toISOString(); s.upsert.run(id, userId, watchlistName, JSON.stringify(serialized), now, 0, 'user', null, null); return true; } export function removeSymbol( db: DatabaseSync, userId: string, symbol: string, watchlistName: string = 'default', ): boolean { const s = stmts(db); const upper = symbol.toUpperCase(); const existing = readWatchlistRaw(db, userId, watchlistName, 'user'); if (!existing) return false; const before = existing.symbols.length; const remaining = existing.symbols.filter((sym) => { const symStr = typeof sym === 'string' ? sym : sym.symbol; return symStr !== upper; }); if (remaining.length === before) { return false; } if (remaining.length === 0) { if (watchlistName === 'default') { s.updateSymbols.run('[]', existing.id, userId); return true; } s.deleteByOwnerKindName.run(userId, 'user', watchlistName); return true; } s.updateSymbols.run(JSON.stringify(remaining), existing.id, userId); return true; } export function moveSymbol( db: DatabaseSync, userId: string, symbol: string, fromWatchlist: string, toWatchlist: string, ): boolean { if (fromWatchlist === toWatchlist) return false; const sym = symbol.toUpperCase(); const src = readWatchlistRaw(db, userId, fromWatchlist, 'user'); if (!src) return false; const dst = readWatchlistRaw(db, userId, toWatchlist, 'user'); if (!dst) return false; const srcRemaining = src.symbols.filter((s) => { const sStr = typeof s === 'string' ? s : s.symbol; return sStr !== sym; }); const alreadyInTarget = dst.symbols.some((s) => { const sStr = typeof s === 'string' ? s : s.symbol; return sStr === sym; }); const now = new Date().toISOString(); if (srcRemaining.length === 0 && fromWatchlist !== 'default') { stmts(db).deleteByOwnerKindName.run(userId, 'user', fromWatchlist); } else { stmts(db).updateSymbols.run(JSON.stringify(srcRemaining), src.id, userId); } if (alreadyInTarget) return true; const mergedSymbols = [...dst.symbols, sym]; stmts(db).updateSymbols.run(JSON.stringify(mergedSymbols), dst.id, userId); materializeClassificationWatchlists(db, userId); return true; } export function listSymbols( db: DatabaseSync, userId: string, ): WatchlistEntry[] { const s = stmts(db); const rows = s.selectByOwnerKind.all(userId, 'user') as unknown as WatchlistRow[]; const entries: WatchlistEntry[] = []; for (const row of rows) { const parsed = safeParseSymbols(row.symbols); for (const item of parsed) { if (typeof item === 'string') { entries.push({ symbol: item, added_at: '' }); } else if (typeof item === 'object' && item !== null) { const obj = item as { symbol?: string; notes?: string }; entries.push({ symbol: (obj.symbol ?? '').toUpperCase(), notes: obj.notes ?? null, added_at: '', }); } } } return entries; } export function listWatchlists(db: DatabaseSync, userId: string): WatchlistMeta[] { materializeClassificationWatchlists(db, userId); const s = stmts(db); const rows = s.selectAllByOwner.all(userId) as unknown as WatchlistRow[]; return rows.map((row) => { const parsed = safeParseSymbols(row.symbols); return { id: row.id, name: row.name, symbol_count: parsed.length, sort_order: row.sort_order, created_at: row.created_at, kind: row.kind, class_key: row.class_key, class_label: row.class_label, }; }); } export function createWatchlist( db: DatabaseSync, userId: string, name: string, symbols?: string[], ): WatchlistMeta { const s = stmts(db); const id = generateId(); const now = new Date().toISOString(); const serialized = JSON.stringify(symbols ?? []); s.upsert.run(id, userId, name, serialized, now, 0, 'user', null, null); return { id, name, symbol_count: (symbols ?? []).length, sort_order: 0, created_at: now, kind: 'user' }; } export function deleteWatchlist(db: DatabaseSync, userId: string, name: string): boolean { if (name === 'default') return false; const s = stmts(db); const existing = readWatchlistRaw(db, userId, name, 'user'); if (!existing) return false; s.deleteByOwnerKindName.run(userId, 'user', name); return true; } export function renameWatchlist( db: DatabaseSync, userId: string, oldName: string, newName: string, ): boolean { if (oldName === 'default') return false; const s = stmts(db); const existing = readWatchlistRaw(db, userId, oldName, 'user'); if (!existing) return false; const conflict = readWatchlistRaw(db, userId, newName, 'user'); if (conflict) return false; const now = new Date().toISOString(); s.upsert.run(existing.id, userId, newName, JSON.stringify(existing.symbols), now, existing.sort_order, 'user', null, null); s.deleteByOwnerKindName.run(userId, 'user', oldName); return true; } export function reorderWatchlists( db: DatabaseSync, userId: string, orders: { id: string; sort_order: number }[], ): void { const s = stmts(db); for (const { id, sort_order } of orders) { s.updateSortOrder.run(sort_order, id, userId); } } export function getSymbolsInWatchlist( db: DatabaseSync, userId: string, watchlistName: string = 'default', ): string[] { const existing = readWatchlistRaw(db, userId, watchlistName, 'user'); if (!existing) return []; return existing.symbols.map((sym) => { if (typeof sym === 'string') return sym; return sym.symbol; }); } function safeParseSymbols(value: string | null | undefined): (string | Record)[] { if (!value) return []; try { const parsed = JSON.parse(value); if (Array.isArray(parsed)) { return parsed; } } catch { /* ignore parse errors */ } return []; } export function listSymbolsByWatchlist( db: DatabaseSync, userId: string, watchlistName: string, ): WatchlistEntry[] { // Try user lists first, then fall back to system lists (for viewing sector lists). let raw = readWatchlistRaw(db, userId, watchlistName, 'user'); if (!raw) { const systemRow = db .prepare(`SELECT id, symbols, kind, class_key, class_label FROM watchlists WHERE owner_id = ? AND kind != 'user' AND name = ?`) .get(userId, watchlistName) as { id: string; symbols: string; kind: string; class_key: string | null; class_label: string | null } | undefined; if (!systemRow) return []; const parsed = safeParseSymbols(systemRow.symbols); return parsed.map((item) => { if (typeof item === 'string') return { symbol: item, added_at: '' }; return { symbol: (item.symbol ?? '').toUpperCase(), notes: (item as Record).notes as string | null ?? null, added_at: '' }; }); } const entries: WatchlistEntry[] = []; for (const item of raw.symbols) { if (typeof item === 'string') { entries.push({ symbol: item, added_at: '' }); } else if (typeof item === 'object' && item !== null) { const obj = item as { symbol?: string; notes?: string }; entries.push({ symbol: (obj.symbol ?? '').toUpperCase(), notes: obj.notes ?? null, added_at: '', }); } } return entries; } function readWatchlistRaw( db: DatabaseSync, userId: string, name: string, kind?: string, ): { id: string; symbols: Array; sort_order: number } | null { const s = stmts(db); const rows = kind ? s.selectByOwnerKindName.all(userId, kind, name) as unknown as WatchlistRow[] : s.selectByOwner.all(userId).filter((r) => r.name === name) as unknown as WatchlistRow[]; if (rows.length === 0) return null; const row = rows[0]; const rawSymbols = safeParseSymbols(String(row.symbols)); return { id: row.id, symbols: rawSymbols as Array, sort_order: row.sort_order }; } function generateId(): string { return randomUUID(); } /** Materialize system watchlists (sector, thematic, style, region) from user's symbol metadata. Idempotent; auto-prunes empty lists. */ export function materializeClassificationWatchlists(db: DatabaseSync, userId: string): void { const s = stmts(db); // 1. Gather all unique symbols across user's lists (kind='user'). const userRows = s.selectByOwnerKind.all(userId, 'user') as unknown as WatchlistRow[]; const symbolSet = new Set(); for (const row of userRows) { for (const item of safeParseSymbols(row.symbols)) { const sym = typeof item === 'string' ? item.toUpperCase() : (item.symbol ?? '').toUpperCase(); if (sym) symbolSet.add(sym); } } if (symbolSet.size === 0) { // Prune all system lists for this user. db.prepare(`DELETE FROM watchlists WHERE owner_id = ? AND kind != 'user'`).run(userId); return; } // 2. Fetch symbol metadata from symbols table (market_cap/description not in schema). const symbolMeta = new Map(); for (const sym of symbolSet) { const row = db.prepare('SELECT sector, industry, exchange FROM symbols WHERE symbol = ?').get(sym) as | { sector: string | null; industry: string | null; exchange: string | null } | undefined; if (row) symbolMeta.set(sym, row); } // 3. Resolve classifications per symbol. const groups = new Map>(); // class_key -> symbol set for (const sym of symbolSet) { const meta = symbolMeta.get(sym); if (!meta) continue; const ctx = resolveBusinessContext({ symbol: sym, sector: meta.sector ?? null, industry: meta.industry ?? null, description: null, }); // Sector if (ctx.sectorEtf && ALLOWED_SECTOR_ETFS.has(ctx.sectorEtf)) { groups.set(ctx.sectorEtf, (groups.get(ctx.sectorEtf) ?? new Set()).add(sym)); } // Thematic (SMH=Semiconductors, XBI=Biotech) — only if industry actually matches the theme. if (ctx.themeEtf && ALLOWED_THEME_ETFS.has(ctx.themeEtf)) { const industry = (meta.industry ?? '').toLowerCase(); if ((ctx.themeEtf === 'SMH' && /semiconductor|chip|gpu|foundry|fabless|wafer|memory|processors?/.test(industry)) || (ctx.themeEtf === 'XBI' && /biotech|biotechnology|genomic|pharmaceut|drug|therapeutic/.test(industry))) { groups.set(ctx.themeEtf, (groups.get(ctx.themeEtf) ?? new Set()).add(sym)); } } // Style: market_cap column does not exist in schema — skip Small Cap for now. // Region: non-US exchange -> Intl (EFA) if (meta.exchange && !US_EXCHANGES.has(meta.exchange.toUpperCase())) { groups.set('EFA', (groups.get('EFA') ?? new Set()).add(sym)); } } // 4. Upsert system lists; delete orphaned ones. const presentKeys = new Set(); for (const [classKey, syms] of groups) { presentKeys.add(classKey); const label = ETF_TO_LABEL[classKey] ?? classKey; const kind = classKey === 'SMH' || classKey === 'XBI' ? 'thematic' : classKey === 'IWM' ? 'style' : classKey === 'EFA' ? 'region' : 'sector'; const name = label; const symbolsArr = Array.from(syms).map((s) => s); const serialized = JSON.stringify(symbolsArr); const now = new Date().toISOString(); const id = generateId(); s.upsertSystem.run(id, userId, name, serialized, now, kind, classKey, label); } // 5. Prune system lists for class keys no longer present. for (const classKey of ALLOWED_SECTOR_ETFS) { if (!presentKeys.has(classKey)) s.deleteSystemByClassKey.run(userId, classKey); } for (const classKey of ALLOWED_THEME_ETFS) { if (!presentKeys.has(classKey)) s.deleteSystemByClassKey.run(userId, classKey); } for (const classKey of ALLOWED_STYLE_ETFS) { if (!presentKeys.has(classKey)) s.deleteSystemByClassKey.run(userId, classKey); } for (const classKey of ALLOWED_REGION_ETFS) { if (!presentKeys.has(classKey)) s.deleteSystemByClassKey.run(userId, classKey); } }