Spaces:
Running
Running
| const API_BASE = '/api'; | |
| let allTickers = []; | |
| let currentTicker = ''; | |
| let chartInstance = null; | |
| let highlightedIndex = -1; | |
| let dropdownItems = []; | |
| // Cache TTL configuration (in minutes) | |
| const CACHE_TTL = { | |
| STATIC: null, // Cache until page refresh | |
| DAILY: 1440, // 24 hours (for EOD data) | |
| MODERATE: 30, // 30 minutes | |
| SHORT: 15 // 15 minutes | |
| }; | |
| // Cache manager | |
| const cache = { | |
| data: {}, | |
| set(key, value, ttlMinutes = null) { | |
| this.data[key] = { | |
| value: value, | |
| timestamp: Date.now(), | |
| ttl: ttlMinutes ? ttlMinutes * 60 * 1000 : null | |
| }; | |
| }, | |
| get(key) { | |
| const item = this.data[key]; | |
| if (!item) return null; | |
| // Check if expired | |
| if (item.ttl && (Date.now() - item.timestamp > item.ttl)) { | |
| delete this.data[key]; | |
| return null; | |
| } | |
| return item.value; | |
| }, | |
| has(key) { | |
| return this.get(key) !== null; | |
| }, | |
| clear() { | |
| this.data = {}; | |
| }, | |
| getStats() { | |
| return { | |
| entries: Object.keys(this.data).length, | |
| keys: Object.keys(this.data) | |
| }; | |
| } | |
| }; | |
| // Expose cache to global scope for debugging | |
| window.stockCache = cache; | |
| // Recent/Popular Ticker Functions | |
| function getRecentTickers() { | |
| try { | |
| const recent = localStorage.getItem('recentTickers'); | |
| if (recent) { | |
| return JSON.parse(recent); | |
| } | |
| } catch (error) { | |
| console.error('Error reading recent tickers:', error); | |
| } | |
| return []; | |
| } | |
| function saveRecentTicker(ticker, title) { | |
| try { | |
| let recent = getRecentTickers(); | |
| // Remove if already exists | |
| recent = recent.filter(item => item.ticker !== ticker); | |
| // Add to front | |
| recent.unshift({ ticker, title }); | |
| // Keep only 5 most recent | |
| recent = recent.slice(0, 5); | |
| localStorage.setItem('recentTickers', JSON.stringify(recent)); | |
| } catch (error) { | |
| console.error('Error saving recent ticker:', error); | |
| } | |
| } | |
| function getPopularTickers() { | |
| const popularSymbols = ['AAPL', 'MSFT', 'GOOGL', 'AMZN', 'TSLA', 'META', 'NVDA', 'AMD']; | |
| return allTickers.filter(ticker => popularSymbols.includes(ticker.ticker)); | |
| } | |
| // Dropdown control functions | |
| function showDropdown() { | |
| document.getElementById('dropdownList').classList.remove('hidden'); | |
| } | |
| function hideDropdown() { | |
| document.getElementById('dropdownList').classList.add('hidden'); | |
| highlightedIndex = -1; | |
| } | |
| function populateDropdown(tickers, searchTerm = '') { | |
| const dropdown = document.getElementById('dropdownList'); | |
| dropdown.innerHTML = ''; | |
| dropdownItems = []; | |
| if (searchTerm === '') { | |
| // Show recent and popular tickers | |
| const recent = getRecentTickers(); | |
| const popular = getPopularTickers(); | |
| if (recent.length > 0) { | |
| const recentHeader = document.createElement('div'); | |
| recentHeader.className = 'dropdown-section-header'; | |
| recentHeader.textContent = 'Recent'; | |
| dropdown.appendChild(recentHeader); | |
| recent.forEach(item => { | |
| const div = createDropdownItem(item.ticker, item.title); | |
| dropdown.appendChild(div); | |
| dropdownItems.push({ element: div, ticker: item.ticker, title: item.title }); | |
| }); | |
| } | |
| if (popular.length > 0) { | |
| const popularHeader = document.createElement('div'); | |
| popularHeader.className = 'dropdown-section-header'; | |
| popularHeader.textContent = 'Popular'; | |
| dropdown.appendChild(popularHeader); | |
| popular.forEach(item => { | |
| const div = createDropdownItem(item.ticker, item.title); | |
| dropdown.appendChild(div); | |
| dropdownItems.push({ element: div, ticker: item.ticker, title: item.title }); | |
| }); | |
| } | |
| } else { | |
| // Show filtered results | |
| const limited = tickers.slice(0, 50); | |
| if (limited.length === 0) { | |
| const noResults = document.createElement('div'); | |
| noResults.className = 'dropdown-no-results'; | |
| noResults.textContent = 'No results found'; | |
| dropdown.appendChild(noResults); | |
| } else { | |
| limited.forEach(item => { | |
| const div = createDropdownItem(item.ticker, item.title); | |
| dropdown.appendChild(div); | |
| dropdownItems.push({ element: div, ticker: item.ticker, title: item.title }); | |
| }); | |
| } | |
| } | |
| } | |
| function createDropdownItem(ticker, title) { | |
| const div = document.createElement('div'); | |
| div.className = 'dropdown-item'; | |
| div.textContent = `${ticker} - ${title}`; | |
| div.dataset.ticker = ticker; | |
| div.dataset.title = title; | |
| // Use mousedown instead of click to fire before blur | |
| div.addEventListener('mousedown', (e) => { | |
| e.preventDefault(); | |
| selectTicker(ticker, title); | |
| }); | |
| return div; | |
| } | |
| function selectTicker(ticker, title) { | |
| const input = document.getElementById('tickerSearch'); | |
| input.value = `${ticker} - ${title}`; | |
| currentTicker = ticker; | |
| hideDropdown(); | |
| saveRecentTicker(ticker, title); | |
| loadStockData(ticker); | |
| updateChatContext(); | |
| } | |
| function highlightItem(index) { | |
| // Remove all highlights | |
| dropdownItems.forEach(item => item.element.classList.remove('highlighted')); | |
| if (index >= 0 && index < dropdownItems.length) { | |
| dropdownItems[index].element.classList.add('highlighted'); | |
| dropdownItems[index].element.scrollIntoView({ block: 'nearest' }); | |
| } | |
| } | |
| // Load tickers on page load | |
| document.addEventListener('DOMContentLoaded', async () => { | |
| initTheme(); | |
| await loadTickers(); | |
| await loadMarketStatus(); | |
| setupEventListeners(); | |
| }); | |
| // Theme toggle functionality | |
| function initTheme() { | |
| const savedTheme = localStorage.getItem('theme'); | |
| const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; | |
| if (savedTheme) { | |
| document.documentElement.setAttribute('data-theme', savedTheme); | |
| } else if (prefersDark) { | |
| document.documentElement.setAttribute('data-theme', 'dark'); | |
| } | |
| const themeToggle = document.getElementById('themeToggle'); | |
| if (themeToggle) { | |
| themeToggle.addEventListener('click', toggleTheme); | |
| } | |
| } | |
| function toggleTheme() { | |
| const currentTheme = document.documentElement.getAttribute('data-theme'); | |
| const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; | |
| document.documentElement.setAttribute('data-theme', newTheme); | |
| localStorage.setItem('theme', newTheme); | |
| // Redraw chart with new theme colors | |
| if (chartState.data) { | |
| drawChart(1); | |
| } | |
| } | |
| async function loadTickers() { | |
| try { | |
| const response = await fetch('/company_tickers.json'); | |
| const data = await response.json(); | |
| allTickers = Object.values(data).map(item => ({ | |
| ticker: item.ticker, | |
| title: item.title, | |
| cik: item.cik_str | |
| })); | |
| } catch (error) { | |
| console.error('Error loading tickers:', error); | |
| } | |
| } | |
| function setupEventListeners() { | |
| const tickerSearch = document.getElementById('tickerSearch'); | |
| const dropdownContainer = document.querySelector('.dropdown-container'); | |
| // Input focus - show dropdown with recent/popular or current results | |
| tickerSearch.addEventListener('focus', (e) => { | |
| const searchTerm = e.target.value.trim(); | |
| if (searchTerm === '') { | |
| populateDropdown([], ''); | |
| } else { | |
| // Select all text for easy replacement | |
| e.target.select(); | |
| const filtered = allTickers.filter(item => | |
| item.ticker.toLowerCase().includes(searchTerm.toLowerCase()) || | |
| item.title.toLowerCase().includes(searchTerm.toLowerCase()) | |
| ); | |
| populateDropdown(filtered, searchTerm); | |
| } | |
| showDropdown(); | |
| }); | |
| // Input blur - hide dropdown with delay | |
| tickerSearch.addEventListener('blur', () => { | |
| setTimeout(() => { | |
| hideDropdown(); | |
| }, 200); | |
| }); | |
| // Input keydown - handle keyboard navigation | |
| tickerSearch.addEventListener('keydown', (e) => { | |
| const dropdown = document.getElementById('dropdownList'); | |
| const isOpen = !dropdown.classList.contains('hidden'); | |
| if (!isOpen && e.key !== 'Escape') return; | |
| switch(e.key) { | |
| case 'Escape': | |
| if (e.target.value) { | |
| e.target.value = ''; | |
| populateDropdown([], ''); | |
| showDropdown(); | |
| } else { | |
| hideDropdown(); | |
| } | |
| break; | |
| case 'ArrowDown': | |
| e.preventDefault(); | |
| highlightedIndex++; | |
| if (highlightedIndex >= dropdownItems.length) { | |
| highlightedIndex = 0; | |
| } | |
| highlightItem(highlightedIndex); | |
| break; | |
| case 'ArrowUp': | |
| e.preventDefault(); | |
| highlightedIndex--; | |
| if (highlightedIndex < 0) { | |
| highlightedIndex = dropdownItems.length - 1; | |
| } | |
| highlightItem(highlightedIndex); | |
| break; | |
| case 'Enter': | |
| e.preventDefault(); | |
| if (highlightedIndex >= 0 && highlightedIndex < dropdownItems.length) { | |
| const item = dropdownItems[highlightedIndex]; | |
| selectTicker(item.ticker, item.title); | |
| } | |
| break; | |
| } | |
| }); | |
| // Input input - filter and show results | |
| tickerSearch.addEventListener('input', (e) => { | |
| const searchTerm = e.target.value.trim(); | |
| highlightedIndex = -1; | |
| if (searchTerm === '') { | |
| populateDropdown([], ''); | |
| } else { | |
| const filtered = allTickers.filter(item => | |
| item.ticker.toLowerCase().includes(searchTerm.toLowerCase()) || | |
| item.title.toLowerCase().includes(searchTerm.toLowerCase()) | |
| ); | |
| populateDropdown(filtered, searchTerm); | |
| } | |
| showDropdown(); | |
| }); | |
| // Click outside to close dropdown | |
| document.addEventListener('click', (e) => { | |
| if (!dropdownContainer.contains(e.target)) { | |
| hideDropdown(); | |
| } | |
| }); | |
| // Tab switching | |
| const tabButtons = document.querySelectorAll('.tab-button'); | |
| tabButtons.forEach(button => { | |
| button.addEventListener('click', () => { | |
| const tabName = button.dataset.tab; | |
| switchTab(tabName); | |
| }); | |
| }); | |
| // Chart range buttons | |
| const rangeButtons = document.querySelectorAll('.chart-range-btn'); | |
| rangeButtons.forEach(button => { | |
| button.addEventListener('click', () => { | |
| const range = button.dataset.range; | |
| loadChartData(currentTicker, range); | |
| rangeButtons.forEach(btn => btn.classList.remove('active')); | |
| button.classList.add('active'); | |
| }); | |
| }); | |
| // Chart view toggle buttons (line/candle) | |
| const viewButtons = document.querySelectorAll('.chart-view-btn'); | |
| viewButtons.forEach(button => { | |
| button.addEventListener('click', () => { | |
| viewButtons.forEach(btn => btn.classList.remove('active')); | |
| button.classList.add('active'); | |
| chartState.viewMode = button.dataset.view; | |
| if (chartState.data) { | |
| drawChart(1); | |
| } | |
| }); | |
| }); | |
| // Setup chat listeners | |
| setupChatListeners(); | |
| } | |
| function switchTab(tabName) { | |
| const tabButtons = document.querySelectorAll('.tab-button'); | |
| const tabPanes = document.querySelectorAll('.tab-pane'); | |
| tabButtons.forEach(btn => btn.classList.remove('active')); | |
| tabPanes.forEach(pane => pane.classList.remove('active')); | |
| document.querySelector(`[data-tab="${tabName}"]`).classList.add('active'); | |
| document.getElementById(tabName).classList.add('active'); | |
| if (tabName === 'overview' && currentTicker) { | |
| loadChartData(currentTicker, document.querySelector('.chart-range-btn.active').dataset.range || '1M'); | |
| } else if (tabName === 'financials' && currentTicker) { | |
| loadFinancials(currentTicker); | |
| } else if (tabName === 'news' && currentTicker) { | |
| loadNews(currentTicker); | |
| } else if (tabName === 'dividends' && currentTicker) { | |
| loadDividends(currentTicker); | |
| } else if (tabName === 'splits' && currentTicker) { | |
| loadSplits(currentTicker); | |
| } else if (tabName === 'sentiment' && currentTicker) { | |
| loadSentiment(currentTicker); | |
| } else if (tabName === 'forecast' && currentTicker) { | |
| loadForecast(currentTicker); | |
| } | |
| } | |
| function clearStockDisplay() { | |
| document.getElementById('stockTitle').textContent = ''; | |
| document.getElementById('stockPrice').textContent = ''; | |
| document.getElementById('stockPrice').className = 'stock-price'; | |
| document.getElementById('companyDesc').textContent = ''; | |
| document.getElementById('marketCap').textContent = '--'; | |
| document.getElementById('openPrice').textContent = '--'; | |
| document.getElementById('highPrice').textContent = '--'; | |
| document.getElementById('lowPrice').textContent = '--'; | |
| document.getElementById('volume').textContent = '--'; | |
| document.getElementById('peRatio').textContent = '--'; | |
| } | |
| async function loadStockData(ticker) { | |
| currentTicker = ticker; | |
| document.getElementById('stockData').classList.remove('hidden'); | |
| clearStockDisplay(); | |
| try { | |
| const activeTab = document.querySelector('.tab-button.active').dataset.tab; | |
| const loadPromises = [ | |
| loadTickerDetails(ticker), | |
| loadPreviousClose(ticker) | |
| ]; | |
| if (activeTab === 'overview') { | |
| loadPromises.push(loadChartData(ticker, '1M')); | |
| } | |
| await Promise.all(loadPromises); | |
| if (activeTab === 'financials') { | |
| await loadFinancials(ticker); | |
| } else if (activeTab === 'news') { | |
| await loadNews(ticker); | |
| } else if (activeTab === 'sentiment') { | |
| loadSentiment(ticker); | |
| } else if (activeTab === 'forecast') { | |
| loadForecast(ticker); | |
| } else if (activeTab === 'dividends') { | |
| loadDividends(ticker); | |
| } else if (activeTab === 'splits') { | |
| loadSplits(ticker); | |
| } | |
| // Preload news and trigger article scraping for RAG in background | |
| preloadNewsForRAG(ticker); | |
| } catch (error) { | |
| console.error('Error loading stock data:', error); | |
| alert('Error loading stock data. Please check your API key and try again.'); | |
| } | |
| } | |
| async function loadTickerDetails(ticker) { | |
| const cacheKey = `details_${ticker}`; | |
| // Check cache first | |
| if (cache.has(cacheKey)) { | |
| const data = cache.get(cacheKey); | |
| renderTickerDetails(data); | |
| return; | |
| } | |
| try { | |
| const response = await fetch(`${API_BASE}/ticker/${ticker}/details`); | |
| const data = await response.json(); | |
| if (data.results) { | |
| cache.set(cacheKey, data, CACHE_TTL.STATIC); | |
| renderTickerDetails(data); | |
| } | |
| } catch (error) { | |
| console.error('Error loading ticker details:', error); | |
| } | |
| } | |
| function renderTickerDetails(data) { | |
| if (data.results) { | |
| const results = data.results; | |
| document.getElementById('stockTitle').textContent = | |
| `${results.ticker} - ${results.name}`; | |
| document.getElementById('companyDesc').textContent = | |
| results.description || 'No description available'; | |
| document.getElementById('marketCap').textContent = | |
| results.market_cap ? formatLargeNumber(results.market_cap) : '--'; | |
| } | |
| } | |
| async function loadSnapshot(ticker) { | |
| try { | |
| const response = await fetch(`${API_BASE}/ticker/${ticker}/snapshot`); | |
| const data = await response.json(); | |
| if (data.ticker) { | |
| const ticker_data = data.ticker; | |
| const day = ticker_data.day || {}; | |
| document.getElementById('openPrice').textContent = | |
| day.o ? `$${day.o.toFixed(2)}` : '--'; | |
| document.getElementById('highPrice').textContent = | |
| day.h ? `$${day.h.toFixed(2)}` : '--'; | |
| document.getElementById('lowPrice').textContent = | |
| day.l ? `$${day.l.toFixed(2)}` : '--'; | |
| document.getElementById('volume').textContent = | |
| day.v ? formatLargeNumber(day.v) : '--'; | |
| } | |
| } catch (error) { | |
| console.error('Error loading snapshot:', error); | |
| } | |
| } | |
| async function loadPreviousClose(ticker) { | |
| const cacheKey = `prev_close_${ticker}`; | |
| // Check cache first | |
| if (cache.has(cacheKey)) { | |
| const data = cache.get(cacheKey); | |
| renderPreviousClose(data); | |
| return; | |
| } | |
| try { | |
| const response = await fetch(`${API_BASE}/ticker/${ticker}/previous-close`); | |
| const data = await response.json(); | |
| if (data.results && data.results.length > 0) { | |
| cache.set(cacheKey, data, CACHE_TTL.DAILY); | |
| renderPreviousClose(data); | |
| } | |
| } catch (error) { | |
| console.error('Error loading previous close:', error); | |
| } | |
| } | |
| function renderPreviousClose(data) { | |
| if (data.results && data.results.length > 0) { | |
| const result = data.results[0]; | |
| const priceElement = document.getElementById('stockPrice'); | |
| priceElement.textContent = `$${result.c.toFixed(2)}`; | |
| const change = result.c - result.o; | |
| const changePercent = ((change / result.o) * 100).toFixed(2); | |
| if (change >= 0) { | |
| priceElement.classList.add('positive'); | |
| priceElement.classList.remove('negative'); | |
| priceElement.innerHTML += ` <span style="font-size: 0.6em;">+${changePercent}%</span>`; | |
| } else { | |
| priceElement.classList.add('negative'); | |
| priceElement.classList.remove('positive'); | |
| priceElement.innerHTML += ` <span style="font-size: 0.6em;">${changePercent}%</span>`; | |
| } | |
| // Populate Overview metrics from previous close data | |
| document.getElementById('openPrice').textContent = `$${result.o.toFixed(2)}`; | |
| document.getElementById('highPrice').textContent = `$${result.h.toFixed(2)}`; | |
| document.getElementById('lowPrice').textContent = `$${result.l.toFixed(2)}`; | |
| document.getElementById('volume').textContent = formatLargeNumber(result.v); | |
| } | |
| } | |
| async function loadChartData(ticker, range) { | |
| const cacheKey = `chart_${ticker}_${range}`; | |
| const chartLoading = document.getElementById('chartLoading'); | |
| // Check cache first | |
| if (cache.has(cacheKey)) { | |
| const data = cache.get(cacheKey); | |
| if (data.results) { | |
| renderChart(data.results); | |
| } | |
| return; | |
| } | |
| chartLoading.classList.remove('hidden'); | |
| const { from, to } = getDateRange(range); | |
| try { | |
| const response = await fetch( | |
| `${API_BASE}/ticker/${ticker}/aggregates?from=${from}&to=${to}×pan=day` | |
| ); | |
| const data = await response.json(); | |
| if (data.results) { | |
| cache.set(cacheKey, data, CACHE_TTL.DAILY); | |
| renderChart(data.results); | |
| } | |
| } catch (error) { | |
| console.error('Error loading chart data:', error); | |
| } finally { | |
| chartLoading.classList.add('hidden'); | |
| } | |
| } | |
| function getDateRange(range) { | |
| const to = new Date(); | |
| const from = new Date(); | |
| switch(range) { | |
| case '1M': | |
| from.setMonth(from.getMonth() - 1); | |
| break; | |
| case '3M': | |
| from.setMonth(from.getMonth() - 3); | |
| break; | |
| case '6M': | |
| from.setMonth(from.getMonth() - 6); | |
| break; | |
| case '1Y': | |
| from.setFullYear(from.getFullYear() - 1); | |
| break; | |
| case '5Y': | |
| from.setFullYear(from.getFullYear() - 5); | |
| break; | |
| } | |
| return { | |
| from: from.toISOString().split('T')[0], | |
| to: to.toISOString().split('T')[0] | |
| }; | |
| } | |
| // Chart state for interactivity | |
| let chartState = { | |
| data: null, | |
| canvas: null, | |
| ctx: null, | |
| padding: { top: 20, right: 20, bottom: 40, left: 65 }, | |
| hoveredIndex: -1, | |
| animationProgress: 0, | |
| animationFrame: null, | |
| viewMode: 'line', // 'line' or 'candle' | |
| resizeObserver: null | |
| }; | |
| function getChartColors() { | |
| const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; | |
| return { | |
| line: isDark ? '#818cf8' : '#4f46e5', | |
| lineLight: isDark ? '#a5b4fc' : '#6366f1', | |
| gradientTop: isDark ? 'rgba(129, 140, 248, 0.3)' : 'rgba(79, 70, 229, 0.15)', | |
| gradientBottom: isDark ? 'rgba(129, 140, 248, 0)' : 'rgba(79, 70, 229, 0)', | |
| grid: isDark ? 'rgba(148, 163, 184, 0.1)' : 'rgba(148, 163, 184, 0.3)', | |
| text: isDark ? '#94a3b8' : '#64748b', | |
| textStrong: isDark ? '#cbd5e1' : '#475569', | |
| crosshair: isDark ? 'rgba(148, 163, 184, 0.5)' : 'rgba(100, 116, 139, 0.4)', | |
| tooltipBg: isDark ? '#1e293b' : '#ffffff', | |
| tooltipBorder: isDark ? '#334155' : '#e2e8f0', | |
| positive: '#10b981', | |
| negative: '#ef4444' | |
| }; | |
| } | |
| function renderChart(data) { | |
| const canvas = document.getElementById('priceChart'); | |
| const ctx = canvas.getContext('2d'); | |
| // High DPI support | |
| const dpr = window.devicePixelRatio || 1; | |
| const rect = canvas.getBoundingClientRect(); | |
| canvas.width = rect.width * dpr; | |
| canvas.height = rect.height * dpr; | |
| ctx.scale(dpr, dpr); | |
| chartState.data = data; | |
| chartState.canvas = canvas; | |
| chartState.ctx = ctx; | |
| chartState.hoveredIndex = -1; | |
| // Cancel any existing animation | |
| if (chartState.animationFrame) { | |
| cancelAnimationFrame(chartState.animationFrame); | |
| } | |
| // Animate the chart drawing | |
| chartState.animationProgress = 0; | |
| animateChart(); | |
| // Set up mouse events | |
| canvas.onmousemove = handleChartMouseMove; | |
| canvas.onmouseleave = handleChartMouseLeave; | |
| // Redraw on resize/zoom so canvas always fits its container | |
| if (!chartState.resizeObserver) { | |
| chartState.resizeObserver = new ResizeObserver(() => { | |
| if (!chartState.data || !chartState.canvas) return; | |
| const c = chartState.canvas; | |
| const r = c.getBoundingClientRect(); | |
| if (r.width === 0) return; | |
| const d = window.devicePixelRatio || 1; | |
| c.width = r.width * d; | |
| c.height = r.height * d; | |
| chartState.ctx = c.getContext('2d'); | |
| chartState.ctx.scale(d, d); | |
| drawChart(1); | |
| }); | |
| chartState.resizeObserver.observe(canvas.parentElement); | |
| } | |
| } | |
| function animateChart() { | |
| chartState.animationProgress += 0.04; | |
| if (chartState.animationProgress > 1) chartState.animationProgress = 1; | |
| drawChart(chartState.animationProgress); | |
| if (chartState.animationProgress < 1) { | |
| chartState.animationFrame = requestAnimationFrame(animateChart); | |
| } | |
| } | |
| function drawChart(progress = 1) { | |
| const { data, canvas, ctx, padding, viewMode } = chartState; | |
| if (!data || !ctx) return; | |
| const colors = getChartColors(); | |
| const dpr = window.devicePixelRatio || 1; | |
| const width = canvas.width / dpr; | |
| const height = canvas.height / dpr; | |
| const chartWidth = width - padding.left - padding.right; | |
| const chartHeight = height - padding.top - padding.bottom; | |
| // For candlestick, use high/low for price range | |
| let minPrice, maxPrice; | |
| if (viewMode === 'candle') { | |
| minPrice = Math.min(...data.map(d => d.l)); | |
| maxPrice = Math.max(...data.map(d => d.h)); | |
| } else { | |
| const prices = data.map(d => d.c); | |
| minPrice = Math.min(...prices); | |
| maxPrice = Math.max(...prices); | |
| } | |
| const pricePadding = (maxPrice - minPrice) * 0.05; | |
| const adjustedMin = minPrice - pricePadding; | |
| const adjustedMax = maxPrice + pricePadding; | |
| const priceRange = adjustedMax - adjustedMin; | |
| ctx.clearRect(0, 0, width, height); | |
| // Draw horizontal grid lines and Y-axis labels | |
| const numGridLines = 5; | |
| ctx.strokeStyle = colors.grid; | |
| ctx.lineWidth = 1; | |
| ctx.fillStyle = colors.text; | |
| ctx.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; | |
| ctx.textAlign = 'right'; | |
| ctx.textBaseline = 'middle'; | |
| for (let i = 0; i <= numGridLines; i++) { | |
| const y = padding.top + (i / numGridLines) * chartHeight; | |
| const price = adjustedMax - (i / numGridLines) * priceRange; | |
| ctx.beginPath(); | |
| ctx.setLineDash([4, 4]); | |
| ctx.moveTo(padding.left, y); | |
| ctx.lineTo(width - padding.right, y); | |
| ctx.stroke(); | |
| ctx.setLineDash([]); | |
| ctx.fillText(`$${price.toFixed(2)}`, padding.left - 8, y); | |
| } | |
| // Draw chart based on view mode | |
| if (viewMode === 'candle') { | |
| drawCandlesticks(data, adjustedMax, priceRange, chartWidth, chartHeight, height, padding, colors, progress); | |
| } else { | |
| drawLineChart(data, adjustedMax, priceRange, chartWidth, chartHeight, height, padding, colors, progress); | |
| } | |
| // Draw X-axis date labels | |
| drawXAxisLabels(data, chartWidth, height, padding, colors); | |
| // Draw crosshair and tooltip if hovering | |
| if (chartState.hoveredIndex >= 0 && chartState.hoveredIndex < data.length && progress === 1) { | |
| drawCrosshair(chartState.hoveredIndex, data, adjustedMin, adjustedMax, priceRange, chartWidth, chartHeight, width, height, padding, colors); | |
| } | |
| } | |
| function drawLineChart(data, adjustedMax, priceRange, chartWidth, chartHeight, height, padding, colors, progress) { | |
| const ctx = chartState.ctx; | |
| // Calculate points for animation | |
| const pointsToDraw = Math.floor(data.length * progress); | |
| const points = []; | |
| for (let i = 0; i < pointsToDraw; i++) { | |
| const x = padding.left + (i / (data.length - 1)) * chartWidth; | |
| const y = padding.top + ((adjustedMax - data[i].c) / priceRange) * chartHeight; | |
| points.push({ x, y, data: data[i] }); | |
| } | |
| if (points.length < 2) return; | |
| // Draw gradient fill | |
| const gradient = ctx.createLinearGradient(0, padding.top, 0, height - padding.bottom); | |
| gradient.addColorStop(0, colors.gradientTop); | |
| gradient.addColorStop(1, colors.gradientBottom); | |
| ctx.beginPath(); | |
| ctx.moveTo(points[0].x, height - padding.bottom); | |
| points.forEach(p => ctx.lineTo(p.x, p.y)); | |
| ctx.lineTo(points[points.length - 1].x, height - padding.bottom); | |
| ctx.closePath(); | |
| ctx.fillStyle = gradient; | |
| ctx.fill(); | |
| // Draw the line | |
| ctx.beginPath(); | |
| ctx.strokeStyle = colors.line; | |
| ctx.lineWidth = 2; | |
| ctx.lineJoin = 'round'; | |
| ctx.lineCap = 'round'; | |
| points.forEach((p, i) => { | |
| if (i === 0) { | |
| ctx.moveTo(p.x, p.y); | |
| } else { | |
| ctx.lineTo(p.x, p.y); | |
| } | |
| }); | |
| ctx.stroke(); | |
| } | |
| function drawCandlesticks(data, adjustedMax, priceRange, chartWidth, chartHeight, height, padding, colors, progress) { | |
| const ctx = chartState.ctx; | |
| const candleCount = data.length; | |
| const totalCandleSpace = chartWidth / candleCount; | |
| const candleWidth = Math.max(1, totalCandleSpace * 0.7); | |
| const candlesToDraw = Math.floor(candleCount * progress); | |
| for (let i = 0; i < candlesToDraw; i++) { | |
| const point = data[i]; | |
| const x = padding.left + (i + 0.5) * totalCandleSpace; | |
| const openY = padding.top + ((adjustedMax - point.o) / priceRange) * chartHeight; | |
| const closeY = padding.top + ((adjustedMax - point.c) / priceRange) * chartHeight; | |
| const highY = padding.top + ((adjustedMax - point.h) / priceRange) * chartHeight; | |
| const lowY = padding.top + ((adjustedMax - point.l) / priceRange) * chartHeight; | |
| const isUp = point.c >= point.o; | |
| const candleColor = isUp ? colors.positive : colors.negative; | |
| // Draw wick (high to low line) | |
| ctx.beginPath(); | |
| ctx.strokeStyle = candleColor; | |
| ctx.lineWidth = 1; | |
| ctx.moveTo(x, highY); | |
| ctx.lineTo(x, lowY); | |
| ctx.stroke(); | |
| // Draw body (open to close rectangle) | |
| const bodyTop = Math.min(openY, closeY); | |
| const bodyHeight = Math.max(1, Math.abs(closeY - openY)); | |
| ctx.fillStyle = candleColor; | |
| ctx.fillRect(x - candleWidth / 2, bodyTop, candleWidth, bodyHeight); | |
| } | |
| } | |
| function drawXAxisLabels(data, chartWidth, height, padding, colors) { | |
| const ctx = chartState.ctx; | |
| const labelCount = Math.min(6, data.length); | |
| const step = Math.floor(data.length / labelCount); | |
| ctx.fillStyle = colors.text; | |
| ctx.font = '10px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.textBaseline = 'top'; | |
| for (let i = 0; i < data.length; i += step) { | |
| const x = padding.left + (i / (data.length - 1)) * chartWidth; | |
| const date = new Date(data[i].t); | |
| const label = formatDateLabel(date, data.length); | |
| ctx.fillText(label, x, height - padding.bottom + 8); | |
| } | |
| // Always show last date | |
| const lastX = padding.left + chartWidth; | |
| const lastDate = new Date(data[data.length - 1].t); | |
| ctx.fillText(formatDateLabel(lastDate, data.length), lastX, height - padding.bottom + 8); | |
| } | |
| function formatDateLabel(date, dataLength) { | |
| const month = date.toLocaleDateString('en-US', { month: 'short' }); | |
| const day = date.getDate(); | |
| const year = date.getFullYear().toString().slice(-2); | |
| if (dataLength > 365) { | |
| return `${month} '${year}`; | |
| } | |
| return `${month} ${day}`; | |
| } | |
| function drawCrosshair(index, data, adjustedMin, adjustedMax, priceRange, chartWidth, chartHeight, width, height, padding, colors) { | |
| const ctx = chartState.ctx; | |
| const point = data[index]; | |
| const x = padding.left + (index / (data.length - 1)) * chartWidth; | |
| const y = padding.top + ((adjustedMax - point.c) / priceRange) * chartHeight; | |
| // Vertical line | |
| ctx.strokeStyle = colors.crosshair; | |
| ctx.lineWidth = 1; | |
| ctx.setLineDash([4, 4]); | |
| ctx.beginPath(); | |
| ctx.moveTo(x, padding.top); | |
| ctx.lineTo(x, height - padding.bottom); | |
| ctx.stroke(); | |
| // Horizontal line | |
| ctx.beginPath(); | |
| ctx.moveTo(padding.left, y); | |
| ctx.lineTo(width - padding.right, y); | |
| ctx.stroke(); | |
| ctx.setLineDash([]); | |
| // Point dot | |
| ctx.beginPath(); | |
| ctx.fillStyle = colors.line; | |
| ctx.arc(x, y, 5, 0, Math.PI * 2); | |
| ctx.fill(); | |
| ctx.strokeStyle = colors.tooltipBg; | |
| ctx.lineWidth = 2; | |
| ctx.stroke(); | |
| // Tooltip | |
| drawTooltip(x, y, point, data, index, width, height, padding, colors); | |
| } | |
| function drawTooltip(x, y, point, data, index, width, height, padding, colors) { | |
| const ctx = chartState.ctx; | |
| const isCandleMode = chartState.viewMode === 'candle'; | |
| const date = new Date(point.t); | |
| const dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); | |
| const change = index > 0 ? point.c - data[index - 1].c : 0; | |
| const changePercent = index > 0 ? (change / data[index - 1].c) * 100 : 0; | |
| const changeColor = change >= 0 ? colors.positive : colors.negative; | |
| const changeSign = change >= 0 ? '+' : ''; | |
| const tooltipWidth = isCandleMode ? 155 : 140; | |
| const tooltipHeight = isCandleMode ? 105 : 72; | |
| let tooltipX = x + 12; | |
| let tooltipY = y - tooltipHeight / 2; | |
| // Keep tooltip in bounds | |
| if (tooltipX + tooltipWidth > width - padding.right) { | |
| tooltipX = x - tooltipWidth - 12; | |
| } | |
| if (tooltipY < padding.top) { | |
| tooltipY = padding.top; | |
| } | |
| if (tooltipY + tooltipHeight > height - padding.bottom) { | |
| tooltipY = height - padding.bottom - tooltipHeight; | |
| } | |
| // Tooltip background | |
| ctx.fillStyle = colors.tooltipBg; | |
| ctx.strokeStyle = colors.tooltipBorder; | |
| ctx.lineWidth = 1; | |
| ctx.beginPath(); | |
| ctx.roundRect(tooltipX, tooltipY, tooltipWidth, tooltipHeight, 6); | |
| ctx.fill(); | |
| ctx.stroke(); | |
| // Tooltip content | |
| ctx.fillStyle = colors.text; | |
| ctx.font = '10px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; | |
| ctx.textAlign = 'left'; | |
| ctx.textBaseline = 'top'; | |
| ctx.fillText(dateStr, tooltipX + 10, tooltipY + 10); | |
| if (isCandleMode) { | |
| // OHLC display for candlestick mode | |
| ctx.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; | |
| const ohlcY = tooltipY + 28; | |
| const lineHeight = 16; | |
| ctx.fillStyle = colors.text; | |
| ctx.fillText('O:', tooltipX + 10, ohlcY); | |
| ctx.fillText('H:', tooltipX + 10, ohlcY + lineHeight); | |
| ctx.fillText('L:', tooltipX + 10, ohlcY + lineHeight * 2); | |
| ctx.fillText('C:', tooltipX + 10, ohlcY + lineHeight * 3); | |
| ctx.fillStyle = colors.textStrong; | |
| ctx.fillText(`$${point.o.toFixed(2)}`, tooltipX + 28, ohlcY); | |
| ctx.fillText(`$${point.h.toFixed(2)}`, tooltipX + 28, ohlcY + lineHeight); | |
| ctx.fillText(`$${point.l.toFixed(2)}`, tooltipX + 28, ohlcY + lineHeight * 2); | |
| ctx.fillStyle = changeColor; | |
| ctx.fillText(`$${point.c.toFixed(2)}`, tooltipX + 28, ohlcY + lineHeight * 3); | |
| // Change indicator on the right | |
| ctx.fillStyle = changeColor; | |
| ctx.textAlign = 'right'; | |
| ctx.fillText(`${changeSign}${changePercent.toFixed(2)}%`, tooltipX + tooltipWidth - 10, ohlcY + lineHeight * 3); | |
| } else { | |
| // Simple display for line chart | |
| ctx.fillStyle = colors.textStrong; | |
| ctx.font = 'bold 16px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; | |
| ctx.fillText(`$${point.c.toFixed(2)}`, tooltipX + 10, tooltipY + 26); | |
| ctx.fillStyle = changeColor; | |
| ctx.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; | |
| ctx.fillText(`${changeSign}${change.toFixed(2)} (${changeSign}${changePercent.toFixed(2)}%)`, tooltipX + 10, tooltipY + 50); | |
| } | |
| } | |
| function handleChartMouseMove(e) { | |
| const { data, canvas, padding } = chartState; | |
| if (!data) return; | |
| const rect = canvas.getBoundingClientRect(); | |
| const dpr = window.devicePixelRatio || 1; | |
| const width = canvas.width / dpr; | |
| const chartWidth = width - padding.left - padding.right; | |
| const mouseX = e.clientX - rect.left; | |
| const relativeX = mouseX - padding.left; | |
| const index = Math.round((relativeX / chartWidth) * (data.length - 1)); | |
| if (index >= 0 && index < data.length && index !== chartState.hoveredIndex) { | |
| chartState.hoveredIndex = index; | |
| drawChart(1); | |
| } | |
| } | |
| function handleChartMouseLeave() { | |
| chartState.hoveredIndex = -1; | |
| drawChart(1); | |
| } | |
| async function loadFinancials(ticker) { | |
| const cacheKey = `financials_${ticker}`; | |
| const container = document.getElementById('financialsData'); | |
| container.innerHTML = '<p>Loading financial data...</p>'; | |
| // Check cache first | |
| if (cache.has(cacheKey)) { | |
| const data = cache.get(cacheKey); | |
| renderFinancials(data, container); | |
| return; | |
| } | |
| try { | |
| const response = await fetch(`${API_BASE}/ticker/${ticker}/financials`); | |
| const data = await response.json(); | |
| // Store in cache | |
| cache.set(cacheKey, data, CACHE_TTL.MODERATE); | |
| renderFinancials(data, container); | |
| } catch (error) { | |
| console.error('Error loading financials:', error); | |
| container.innerHTML = '<p>Error loading financial data.</p>'; | |
| } | |
| } | |
| function renderFinancials(data, container) { | |
| if (data.results && data.results.length > 0) { | |
| let html = ''; | |
| data.results.forEach(period => { | |
| const financials = period.financials; | |
| const endDate = period.end_date ? new Date(period.end_date).toLocaleDateString() : ''; | |
| const dateDisplay = endDate ? ` (${endDate})` : ''; | |
| html += ` | |
| <div class="financial-period"> | |
| <h4>${period.fiscal_year} - ${period.fiscal_period}${dateDisplay}</h4> | |
| <div class="financial-grid"> | |
| `; | |
| if (financials.income_statement) { | |
| const income = financials.income_statement; | |
| if (income.revenues) { | |
| html += ` | |
| <div class="financial-item"> | |
| <span class="financial-item-label">Revenue</span> | |
| <span class="financial-item-value">${formatLargeNumber(income.revenues.value)}</span> | |
| </div> | |
| `; | |
| } | |
| if (income.net_income_loss) { | |
| html += ` | |
| <div class="financial-item"> | |
| <span class="financial-item-label">Net Income</span> | |
| <span class="financial-item-value">${formatLargeNumber(income.net_income_loss.value)}</span> | |
| </div> | |
| `; | |
| } | |
| if (income.gross_profit) { | |
| html += ` | |
| <div class="financial-item"> | |
| <span class="financial-item-label">Gross Profit</span> | |
| <span class="financial-item-value">${formatLargeNumber(income.gross_profit.value)}</span> | |
| </div> | |
| `; | |
| } | |
| } | |
| if (financials.balance_sheet) { | |
| const balance = financials.balance_sheet; | |
| if (balance.assets) { | |
| html += ` | |
| <div class="financial-item"> | |
| <span class="financial-item-label">Total Assets</span> | |
| <span class="financial-item-value">${formatLargeNumber(balance.assets.value)}</span> | |
| </div> | |
| `; | |
| } | |
| if (balance.liabilities) { | |
| html += ` | |
| <div class="financial-item"> | |
| <span class="financial-item-label">Total Liabilities</span> | |
| <span class="financial-item-value">${formatLargeNumber(balance.liabilities.value)}</span> | |
| </div> | |
| `; | |
| } | |
| } | |
| html += ` | |
| </div> | |
| </div> | |
| `; | |
| }); | |
| container.innerHTML = html; | |
| } else { | |
| container.innerHTML = '<p>No financial data available.</p>'; | |
| } | |
| } | |
| async function loadNews(ticker) { | |
| const cacheKey = `news_${ticker}`; | |
| const container = document.getElementById('newsContainer'); | |
| container.innerHTML = '<p>Loading news...</p>'; | |
| // Check cache first | |
| if (cache.has(cacheKey)) { | |
| const data = cache.get(cacheKey); | |
| renderNews(data, container); | |
| return; | |
| } | |
| try { | |
| const response = await fetch(`${API_BASE}/ticker/${ticker}/news?limit=10`); | |
| const data = await response.json(); | |
| // Store in cache | |
| cache.set(cacheKey, data, CACHE_TTL.SHORT); | |
| renderNews(data, container); | |
| // Trigger article scraping in background | |
| scrapeAndEmbedArticles(); | |
| } catch (error) { | |
| console.error('Error loading news:', error); | |
| container.innerHTML = '<p>Error loading news.</p>'; | |
| } | |
| } | |
| function renderNews(data, container) { | |
| if (data.results && data.results.length > 0) { | |
| let html = ''; | |
| data.results.forEach(article => { | |
| const date = new Date(article.published_utc).toLocaleDateString(); | |
| html += ` | |
| <div class="news-article"> | |
| <h4><a href="${article.article_url}" target="_blank">${article.title}</a></h4> | |
| <div class="news-meta"> | |
| ${article.publisher?.name || 'Unknown'} - ${date} | |
| </div> | |
| <div class="news-description"> | |
| ${article.description || ''} | |
| </div> | |
| </div> | |
| `; | |
| }); | |
| container.innerHTML = html; | |
| } else { | |
| container.innerHTML = '<p>No news available.</p>'; | |
| } | |
| } | |
| function formatLargeNumber(num) { | |
| if (num >= 1e12) return `$${(num / 1e12).toFixed(2)}T`; | |
| if (num >= 1e9) return `$${(num / 1e9).toFixed(2)}B`; | |
| if (num >= 1e6) return `$${(num / 1e6).toFixed(2)}M`; | |
| if (num >= 1e3) return `$${(num / 1e3).toFixed(2)}K`; | |
| return `$${num.toFixed(2)}`; | |
| } | |
| function showLoading(show) { | |
| const loading = document.getElementById('loading'); | |
| if (show) { | |
| loading.classList.remove('hidden'); | |
| } else { | |
| loading.classList.add('hidden'); | |
| } | |
| } | |
| async function loadMarketStatus() { | |
| const cacheKey = 'market_status'; | |
| // Check cache first | |
| if (cache.has(cacheKey)) { | |
| const data = cache.get(cacheKey); | |
| renderMarketStatus(data); | |
| return; | |
| } | |
| try { | |
| const response = await fetch('/api/market-status'); | |
| const data = await response.json(); | |
| // Store in cache | |
| cache.set(cacheKey, data, CACHE_TTL.DAILY); | |
| renderMarketStatus(data); | |
| } catch (error) { | |
| console.error('Error loading market status:', error); | |
| document.getElementById('marketStatusText').textContent = 'Unknown'; | |
| } | |
| } | |
| function renderMarketStatus(data) { | |
| const statusText = document.getElementById('marketStatusText'); | |
| if (data.market === 'open') { | |
| statusText.textContent = 'Open'; | |
| statusText.classList.add('status-open'); | |
| statusText.classList.remove('status-closed'); | |
| } else { | |
| statusText.textContent = 'Closed'; | |
| statusText.classList.add('status-closed'); | |
| statusText.classList.remove('status-open'); | |
| } | |
| } | |
| async function loadDividends(ticker) { | |
| const cacheKey = `dividends_${ticker}`; | |
| const container = document.getElementById('dividendsContainer'); | |
| container.innerHTML = '<p>Loading dividend data...</p>'; | |
| // Check cache first | |
| if (cache.has(cacheKey)) { | |
| const data = cache.get(cacheKey); | |
| renderDividends(data, container); | |
| return; | |
| } | |
| try { | |
| const response = await fetch(`${API_BASE}/ticker/${ticker}/dividends?limit=20`); | |
| const data = await response.json(); | |
| // Store in cache | |
| cache.set(cacheKey, data, CACHE_TTL.STATIC); | |
| renderDividends(data, container); | |
| } catch (error) { | |
| console.error('Error loading dividends:', error); | |
| container.innerHTML = '<p>Error loading dividend data.</p>'; | |
| } | |
| } | |
| function renderDividends(data, container) { | |
| if (data.results && data.results.length > 0) { | |
| let html = '<table class="data-table"><thead><tr>'; | |
| html += '<th>Ex-Dividend Date</th>'; | |
| html += '<th>Pay Date</th>'; | |
| html += '<th>Amount</th>'; | |
| html += '<th>Frequency</th>'; | |
| html += '</tr></thead><tbody>'; | |
| data.results.forEach(dividend => { | |
| const exDate = new Date(dividend.ex_dividend_date).toLocaleDateString(); | |
| const payDate = dividend.pay_date ? new Date(dividend.pay_date).toLocaleDateString() : 'N/A'; | |
| const frequency = getFrequencyText(dividend.frequency); | |
| html += '<tr>'; | |
| html += `<td>${exDate}</td>`; | |
| html += `<td>${payDate}</td>`; | |
| html += `<td>$${dividend.cash_amount.toFixed(4)}</td>`; | |
| html += `<td>${frequency}</td>`; | |
| html += '</tr>'; | |
| }); | |
| html += '</tbody></table>'; | |
| container.innerHTML = html; | |
| } else { | |
| container.innerHTML = '<p>No dividend data available for this stock.</p>'; | |
| } | |
| } | |
| async function loadSplits(ticker) { | |
| const cacheKey = `splits_${ticker}`; | |
| const container = document.getElementById('splitsContainer'); | |
| container.innerHTML = '<p>Loading stock split data...</p>'; | |
| // Check cache first | |
| if (cache.has(cacheKey)) { | |
| const data = cache.get(cacheKey); | |
| renderSplits(data, container); | |
| return; | |
| } | |
| try { | |
| const response = await fetch(`${API_BASE}/ticker/${ticker}/splits?limit=20`); | |
| const data = await response.json(); | |
| // Store in cache | |
| cache.set(cacheKey, data, CACHE_TTL.STATIC); | |
| renderSplits(data, container); | |
| } catch (error) { | |
| console.error('Error loading splits:', error); | |
| container.innerHTML = '<p>Error loading stock split data.</p>'; | |
| } | |
| } | |
| function renderSplits(data, container) { | |
| if (data.results && data.results.length > 0) { | |
| let html = '<table class="data-table"><thead><tr>'; | |
| html += '<th>Execution Date</th>'; | |
| html += '<th>Split Ratio</th>'; | |
| html += '<th>Type</th>'; | |
| html += '</tr></thead><tbody>'; | |
| data.results.forEach(split => { | |
| const execDate = new Date(split.execution_date).toLocaleDateString(); | |
| const ratio = `${split.split_to}:${split.split_from}`; | |
| const type = split.split_from > split.split_to ? 'Reverse Split' : 'Forward Split'; | |
| html += '<tr>'; | |
| html += `<td>${execDate}</td>`; | |
| html += `<td>${ratio}</td>`; | |
| html += `<td>${type}</td>`; | |
| html += '</tr>'; | |
| }); | |
| html += '</tbody></table>'; | |
| container.innerHTML = html; | |
| } else { | |
| container.innerHTML = '<p>No stock split data available for this stock.</p>'; | |
| } | |
| } | |
| function getFrequencyText(frequency) { | |
| const frequencies = { | |
| 0: 'One-time', | |
| 1: 'Annual', | |
| 2: 'Semi-Annual', | |
| 4: 'Quarterly', | |
| 12: 'Monthly', | |
| 24: 'Bi-Monthly', | |
| 52: 'Weekly' | |
| }; | |
| return frequencies[frequency] || 'Unknown'; | |
| } | |
| // ============================================ | |
| // CHAT FUNCTIONALITY | |
| // ============================================ | |
| // Chat state management | |
| let chatState = { | |
| conversationId: generateUUID(), | |
| messages: [], | |
| isOpen: true, // Default to open for persistent panel | |
| isLoading: false | |
| }; | |
| function generateUUID() { | |
| return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { | |
| const r = Math.random() * 16 | 0; | |
| const v = c === 'x' ? r : (r & 0x3 | 0x8); | |
| return v.toString(16); | |
| }); | |
| } | |
| function updateChatContext() { | |
| const contextEl = document.getElementById('chatCurrentTicker'); | |
| if (currentTicker) { | |
| const detailsCache = cache.get(`details_${currentTicker}`); | |
| const companyName = detailsCache?.results?.name || currentTicker; | |
| contextEl.textContent = `${currentTicker} - ${companyName}`; | |
| } else { | |
| contextEl.textContent = 'Select a stock to start'; | |
| } | |
| } | |
| // Tool display names for status indicators | |
| const TOOL_DISPLAY_NAMES = { | |
| 'get_stock_quote': 'Fetching stock price', | |
| 'get_company_info': 'Looking up company info', | |
| 'get_financials': 'Retrieving financial data', | |
| 'get_news': 'Searching news articles', | |
| 'search_knowledge_base': 'Searching knowledge base', | |
| 'analyze_sentiment': 'Analyzing social sentiment', | |
| 'get_price_forecast': 'Generating price forecast', | |
| 'get_dividends': 'Fetching dividend history', | |
| 'get_stock_splits': 'Checking split history', | |
| 'get_price_history': 'Loading price history' | |
| }; | |
| function parseSSEBuffer(buffer) { | |
| const parsed = []; | |
| const lines = buffer.split('\n'); | |
| let currentEvent = { type: null, data: null }; | |
| let remaining = ''; | |
| for (let i = 0; i < lines.length; i++) { | |
| const line = lines[i]; | |
| if (line.startsWith('event: ')) { | |
| currentEvent.type = line.substring(7).trim(); | |
| } else if (line.startsWith('data: ')) { | |
| currentEvent.data = line.substring(6); | |
| } else if (line === '' && currentEvent.type !== null) { | |
| let data = currentEvent.data; | |
| if (currentEvent.type !== 'text') { | |
| try { data = JSON.parse(data); } catch (e) {} | |
| } | |
| parsed.push({ type: currentEvent.type, data: data }); | |
| currentEvent = { type: null, data: null }; | |
| } | |
| } | |
| // Keep incomplete event in buffer | |
| if (currentEvent.type !== null || currentEvent.data !== null) { | |
| remaining = ''; | |
| if (currentEvent.type) remaining += `event: ${currentEvent.type}\n`; | |
| if (currentEvent.data !== null) remaining += `data: ${currentEvent.data}\n`; | |
| } | |
| return { parsed, remaining }; | |
| } | |
| function renderToolStatuses(elementId, statuses) { | |
| const el = document.getElementById(elementId); | |
| if (!el) return; | |
| let html = ''; | |
| for (const status of statuses) { | |
| const icon = status.status === 'complete' ? '✓' : | |
| status.status === 'error' ? '✗' : | |
| '<span class="tool-spinner"></span>'; | |
| const className = `tool-status-item ${status.status}`; | |
| html += `<div class="${className}">${icon} ${escapeHtml(status.displayName)}</div>`; | |
| } | |
| el.innerHTML = html; | |
| el.scrollIntoView({ behavior: 'smooth', block: 'end' }); | |
| } | |
| async function sendChatMessage() { | |
| const input = document.getElementById('chatInput'); | |
| const message = input.value.trim(); | |
| if (!message) return; | |
| if (!currentTicker) { | |
| addMessageToChat('error', 'Please select a stock first.'); | |
| return; | |
| } | |
| // Add user message to UI | |
| addMessageToChat('user', message); | |
| input.value = ''; | |
| // Disable input while processing | |
| input.disabled = true; | |
| document.getElementById('sendChatBtn').disabled = true; | |
| // Show loading indicator | |
| const loadingId = addMessageToChat('loading', ''); | |
| // Collect current stock context from cache (for agent's hybrid caching) | |
| const context = { | |
| overview: { | |
| details: cache.get(`details_${currentTicker}`), | |
| previousClose: cache.get(`prev_close_${currentTicker}`) | |
| }, | |
| financials: cache.get(`financials_${currentTicker}`), | |
| news: cache.get(`news_${currentTicker}`), | |
| dividends: cache.get(`dividends_${currentTicker}`), | |
| splits: cache.get(`splits_${currentTicker}`), | |
| sentiment: cache.get(`sentiment_${currentTicker}`) | |
| }; | |
| try { | |
| const response = await fetch(`${API_BASE}/chat/message`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| ticker: currentTicker, | |
| message: message, | |
| context: context, | |
| conversation_id: chatState.conversationId | |
| }) | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`HTTP error! status: ${response.status}`); | |
| } | |
| // Remove loading indicator | |
| removeMessage(loadingId); | |
| // Create tool status container and assistant message container | |
| const toolStatusId = addMessageToChat('tool-status', ''); | |
| let toolStatuses = []; | |
| const messageId = addMessageToChat('assistant', ''); | |
| let assistantMessage = ''; | |
| // Parse structured SSE events | |
| const reader = response.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let buffer = ''; | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| buffer += decoder.decode(value, { stream: true }); | |
| const result = parseSSEBuffer(buffer); | |
| buffer = result.remaining; | |
| for (const event of result.parsed) { | |
| if (event.type === 'tool_call') { | |
| const displayName = TOOL_DISPLAY_NAMES[event.data.tool] || event.data.tool; | |
| if (event.data.status === 'calling') { | |
| toolStatuses.push({ tool: event.data.tool, displayName, status: 'calling' }); | |
| } else { | |
| const existing = toolStatuses.find(t => t.tool === event.data.tool); | |
| if (existing) existing.status = event.data.status; | |
| } | |
| renderToolStatuses(toolStatusId, toolStatuses); | |
| } else if (event.type === 'text') { | |
| assistantMessage += event.data; | |
| updateMessage(messageId, assistantMessage); | |
| } else if (event.type === 'done') { | |
| // Fade out tool statuses | |
| const toolEl = document.getElementById(toolStatusId); | |
| if (toolEl && toolStatuses.length > 0) { | |
| toolEl.classList.add('tool-status-complete'); | |
| } | |
| } else if (event.type === 'error') { | |
| removeMessage(messageId); | |
| addMessageToChat('error', event.data.message || 'An error occurred'); | |
| } | |
| } | |
| } | |
| // Remove tool status container if no tools were called | |
| if (toolStatuses.length === 0) { | |
| removeMessage(toolStatusId); | |
| } | |
| // Save to chat state | |
| chatState.messages.push( | |
| { role: 'user', content: message }, | |
| { role: 'assistant', content: assistantMessage } | |
| ); | |
| } catch (error) { | |
| console.error('Chat error:', error); | |
| removeMessage(loadingId); | |
| addMessageToChat('error', 'Failed to get response. Please try again.'); | |
| } finally { | |
| input.disabled = false; | |
| document.getElementById('sendChatBtn').disabled = false; | |
| input.focus(); | |
| } | |
| } | |
| function addMessageToChat(type, content) { | |
| const container = document.getElementById('chatMessages'); | |
| const messageId = generateUUID(); | |
| const messageDiv = document.createElement('div'); | |
| messageDiv.id = messageId; | |
| messageDiv.className = `message ${type}`; | |
| if (type === 'loading') { | |
| messageDiv.innerHTML = '<div class="typing-indicator"><span></span><span></span><span></span></div>'; | |
| } else { | |
| messageDiv.textContent = content; | |
| } | |
| container.appendChild(messageDiv); | |
| messageDiv.scrollIntoView({ behavior: 'smooth', block: 'end' }); | |
| return messageId; | |
| } | |
| function updateMessage(messageId, content) { | |
| const messageDiv = document.getElementById(messageId); | |
| if (messageDiv) { | |
| messageDiv.textContent = content; | |
| messageDiv.scrollIntoView({ behavior: 'smooth', block: 'end' }); | |
| } | |
| } | |
| function removeMessage(messageId) { | |
| const messageDiv = document.getElementById(messageId); | |
| if (messageDiv) { | |
| messageDiv.remove(); | |
| } | |
| } | |
| // Preload news data and trigger RAG scraping when stock is selected | |
| async function preloadNewsForRAG(ticker) { | |
| const cacheKey = `news_${ticker}`; | |
| // Skip if already cached | |
| if (cache.has(cacheKey)) { | |
| // Trigger scraping with cached data | |
| scrapeAndEmbedArticles(); | |
| return; | |
| } | |
| try { | |
| const response = await fetch(`${API_BASE}/ticker/${ticker}/news?limit=10`); | |
| const data = await response.json(); | |
| // Store in cache | |
| cache.set(cacheKey, data, CACHE_TTL.SHORT); | |
| // Trigger article scraping in background | |
| scrapeAndEmbedArticles(); | |
| } catch (error) { | |
| console.error('Error preloading news for RAG:', error); | |
| } | |
| } | |
| // Background job to scrape and embed articles when News tab is loaded | |
| async function scrapeAndEmbedArticles() { | |
| if (!currentTicker) return; | |
| const newsCache = cache.get(`news_${currentTicker}`); | |
| if (!newsCache || !newsCache.results) return; | |
| try { | |
| // Call scrape endpoint in background (don't await) | |
| fetch(`${API_BASE}/chat/scrape-articles`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| ticker: currentTicker, | |
| articles: newsCache.results | |
| }) | |
| }).then(response => response.json()) | |
| .then(result => { | |
| console.log('Article scraping complete:', result); | |
| }) | |
| .catch(error => { | |
| console.error('Article scraping error:', error); | |
| }); | |
| } catch (error) { | |
| console.error('Failed to initiate article scraping:', error); | |
| } | |
| } | |
| // Setup chat event listeners | |
| function setupChatListeners() { | |
| // Mobile chat toggle button | |
| const mobileToggle = document.getElementById('mobileChatToggle'); | |
| if (mobileToggle) { | |
| mobileToggle.addEventListener('click', toggleMobileChat); | |
| } | |
| // Mobile chat close button | |
| const mobileClose = document.getElementById('closeMobileChat'); | |
| if (mobileClose) { | |
| mobileClose.addEventListener('click', toggleMobileChat); | |
| } | |
| // Send button and input | |
| document.getElementById('sendChatBtn').addEventListener('click', sendChatMessage); | |
| document.getElementById('chatInput').addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| sendChatMessage(); | |
| } | |
| }); | |
| // Setup sentiment listeners | |
| setupSentimentListeners(); | |
| // Setup forecast listeners | |
| setupForecastListeners(); | |
| } | |
| // Toggle chat for mobile (full screen overlay) | |
| function toggleMobileChat() { | |
| const chatPanel = document.getElementById('chatPanel'); | |
| const isOpen = chatPanel.classList.contains('open'); | |
| if (isOpen) { | |
| chatPanel.classList.remove('open'); | |
| } else { | |
| chatPanel.classList.add('open'); | |
| updateChatContext(); | |
| } | |
| } | |
| // ============================================ | |
| // SENTIMENT ANALYSIS FUNCTIONALITY | |
| // ============================================ | |
| let sentimentState = { | |
| currentFilter: 'all', | |
| posts: [], | |
| isLoading: false | |
| }; | |
| function setupSentimentListeners() { | |
| // Filter buttons | |
| const filterBtns = document.querySelectorAll('.posts-filter .filter-btn'); | |
| filterBtns.forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| filterBtns.forEach(b => b.classList.remove('active')); | |
| btn.classList.add('active'); | |
| sentimentState.currentFilter = btn.dataset.filter; | |
| renderSentimentPosts(sentimentState.posts); | |
| }); | |
| }); | |
| // Refresh button | |
| const refreshBtn = document.getElementById('sentimentRefreshBtn'); | |
| if (refreshBtn) { | |
| refreshBtn.addEventListener('click', () => { | |
| if (currentTicker && !sentimentState.isLoading) { | |
| loadSentiment(currentTicker, true); | |
| } | |
| }); | |
| } | |
| } | |
| async function loadSentiment(ticker, forceRefresh = false) { | |
| const cacheKey = `sentiment_${ticker}`; | |
| const container = document.getElementById('sentimentPostsContainer'); | |
| const refreshBtn = document.getElementById('sentimentRefreshBtn'); | |
| // Show loading state | |
| container.innerHTML = '<p class="loading-text">Analyzing social media sentiment...</p>'; | |
| sentimentState.isLoading = true; | |
| if (refreshBtn) { | |
| refreshBtn.disabled = true; | |
| refreshBtn.classList.add('loading'); | |
| } | |
| // Check cache first (skip if force refresh) | |
| if (!forceRefresh && cache.has(cacheKey)) { | |
| const data = cache.get(cacheKey); | |
| renderSentiment(data); | |
| sentimentState.isLoading = false; | |
| if (refreshBtn) { | |
| refreshBtn.disabled = false; | |
| refreshBtn.classList.remove('loading'); | |
| } | |
| return; | |
| } | |
| try { | |
| const response = await fetch(`${API_BASE}/sentiment/analyze`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ ticker: ticker, force_refresh: forceRefresh }) | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`HTTP error! status: ${response.status}`); | |
| } | |
| const data = await response.json(); | |
| // Cache with short TTL (15 minutes) | |
| cache.set(cacheKey, data, CACHE_TTL.SHORT); | |
| renderSentiment(data); | |
| } catch (error) { | |
| console.error('Error loading sentiment:', error); | |
| container.innerHTML = '<p class="error-text">Error loading sentiment data. Please try again.</p>'; | |
| resetSentimentUI(); | |
| } finally { | |
| sentimentState.isLoading = false; | |
| if (refreshBtn) { | |
| refreshBtn.disabled = false; | |
| refreshBtn.classList.remove('loading'); | |
| } | |
| } | |
| } | |
| function renderSentiment(data) { | |
| const aggregate = data.aggregate; | |
| // Update gauge | |
| updateSentimentGauge(aggregate.score); | |
| // Update label and confidence | |
| const labelEl = document.getElementById('sentimentLabel'); | |
| labelEl.textContent = aggregate.label.toUpperCase(); | |
| labelEl.className = 'sentiment-label ' + aggregate.label; | |
| // Update stats | |
| document.getElementById('sentimentPostCount').textContent = aggregate.post_count; | |
| document.getElementById('sentimentLastUpdated').textContent = new Date().toLocaleTimeString(); | |
| // Update source breakdown | |
| const sources = aggregate.sources || {}; | |
| updateSourceItem('stocktwitsSource', sources.stocktwits || 0, aggregate.post_count); | |
| updateSourceItem('redditSource', sources.reddit || 0, aggregate.post_count); | |
| updateSourceItem('twitterSource', sources.twitter || 0, aggregate.post_count); | |
| // Store posts and render | |
| sentimentState.posts = data.posts || []; | |
| renderSentimentPosts(sentimentState.posts); | |
| } | |
| function updateSentimentGauge(score) { | |
| // Score ranges from -1 (bearish) to +1 (bullish) | |
| // Map to rotation: -90deg (bearish) to +90deg (bullish) | |
| const rotation = score * 90; | |
| const needle = document.getElementById('gaugeNeedle'); | |
| if (needle) { | |
| needle.style.transform = `rotate(${rotation}deg)`; | |
| } | |
| // Update gauge fill color based on sentiment | |
| const fill = document.getElementById('gaugeFill'); | |
| if (fill) { | |
| if (score > 0.2) { | |
| fill.className = 'gauge-fill bullish'; | |
| } else if (score < -0.2) { | |
| fill.className = 'gauge-fill bearish'; | |
| } else { | |
| fill.className = 'gauge-fill neutral'; | |
| } | |
| } | |
| } | |
| function updateSourceItem(elementId, count, total) { | |
| const element = document.getElementById(elementId); | |
| if (!element) return; | |
| const countEl = element.querySelector('.source-count'); | |
| if (countEl) { | |
| countEl.textContent = count; | |
| } | |
| } | |
| function renderSentimentPosts(posts) { | |
| const container = document.getElementById('sentimentPostsContainer'); | |
| if (!posts || posts.length === 0) { | |
| container.innerHTML = '<p class="no-data-text">No sentiment data available for this stock.</p>'; | |
| return; | |
| } | |
| // Apply filter | |
| let filteredPosts = posts; | |
| if (sentimentState.currentFilter !== 'all') { | |
| filteredPosts = posts.filter(post => { | |
| const label = post.sentiment?.label || 'neutral'; | |
| return label === sentimentState.currentFilter; | |
| }); | |
| } | |
| if (filteredPosts.length === 0) { | |
| container.innerHTML = `<p class="no-data-text">No ${sentimentState.currentFilter} posts found.</p>`; | |
| return; | |
| } | |
| let html = ''; | |
| filteredPosts.forEach(post => { | |
| const sentimentLabel = post.sentiment?.label || 'neutral'; | |
| const sentimentScore = post.sentiment?.score || 0; | |
| const scorePercent = (sentimentScore * 100).toFixed(0); | |
| const timestamp = post.timestamp ? formatRelativeTime(post.timestamp) : ''; | |
| const platform = post.platform || 'unknown'; | |
| const engagement = post.engagement || {}; | |
| html += ` | |
| <div class="sentiment-post ${sentimentLabel}"> | |
| <div class="post-header"> | |
| <span class="post-platform">${getPlatformIcon(platform)} ${platform}</span> | |
| <span class="post-sentiment ${sentimentLabel}"> | |
| ${sentimentLabel} (${scorePercent}%) | |
| </span> | |
| </div> | |
| <p class="post-content">${escapeHtml(post.content || '')}</p> | |
| <div class="post-meta"> | |
| <span class="post-author">@${escapeHtml(post.author || 'unknown')}</span> | |
| <span class="post-time">${timestamp}</span> | |
| <span class="post-engagement"> | |
| ${engagement.likes || 0} likes ${engagement.comments ? `· ${engagement.comments} comments` : ''} | |
| </span> | |
| </div> | |
| ${post.url ? `<a href="${post.url}" target="_blank" class="post-link">View original</a>` : ''} | |
| </div> | |
| `; | |
| }); | |
| container.innerHTML = html; | |
| } | |
| function getPlatformIcon(platform) { | |
| const icons = { | |
| 'stocktwits': '📈', | |
| 'reddit': '👽', | |
| 'twitter': '🐦' | |
| }; | |
| return icons[platform] || '💬'; | |
| } | |
| function formatRelativeTime(timestamp) { | |
| try { | |
| const date = new Date(timestamp); | |
| const now = new Date(); | |
| const diffMs = now - date; | |
| const diffMins = Math.floor(diffMs / 60000); | |
| const diffHours = Math.floor(diffMs / 3600000); | |
| const diffDays = Math.floor(diffMs / 86400000); | |
| if (diffMins < 1) return 'just now'; | |
| if (diffMins < 60) return `${diffMins}m ago`; | |
| if (diffHours < 24) return `${diffHours}h ago`; | |
| if (diffDays < 7) return `${diffDays}d ago`; | |
| return date.toLocaleDateString(); | |
| } catch { | |
| return ''; | |
| } | |
| } | |
| function escapeHtml(text) { | |
| const div = document.createElement('div'); | |
| div.textContent = text; | |
| return div.innerHTML; | |
| } | |
| function resetSentimentUI() { | |
| document.getElementById('sentimentLabel').textContent = '--'; | |
| document.getElementById('sentimentLabel').className = 'sentiment-label'; | |
| document.getElementById('sentimentConfidence').textContent = '--'; | |
| document.getElementById('sentimentPostCount').textContent = '--'; | |
| document.getElementById('sentimentLastUpdated').textContent = '--'; | |
| updateSourceItem('stocktwitsSource', 0, 0); | |
| updateSourceItem('redditSource', 0, 0); | |
| updateSourceItem('twitterSource', 0, 0); | |
| const needle = document.getElementById('gaugeNeedle'); | |
| if (needle) { | |
| needle.style.transform = 'rotate(0deg)'; | |
| } | |
| } | |
| // ============================================ | |
| // FORECAST FUNCTIONALITY | |
| // ============================================ | |
| let forecastState = { | |
| data: null, | |
| isLoading: false, | |
| modelStatus: null | |
| }; | |
| let forecastChartState = { | |
| data: null, | |
| canvas: null, | |
| ctx: null, | |
| padding: { top: 20, right: 20, bottom: 40, left: 65 }, | |
| hoveredIndex: -1, | |
| totalPoints: 0, | |
| historicalLength: 0 | |
| }; | |
| function setupForecastListeners() { | |
| const refreshBtn = document.getElementById('forecastRefreshBtn'); | |
| if (refreshBtn) { | |
| refreshBtn.addEventListener('click', () => { | |
| if (currentTicker && !forecastState.isLoading) { | |
| loadForecast(currentTicker, true); | |
| } | |
| }); | |
| } | |
| } | |
| async function getHistoricalDataForForecast(ticker) { | |
| // Try to get 2-year data from cache first (used for training) | |
| const cacheKey2Y = `chart_${ticker}_2Y`; | |
| if (cache.has(cacheKey2Y)) { | |
| const data = cache.get(cacheKey2Y); | |
| if (data.results && data.results.length > 0) { | |
| return data.results; | |
| } | |
| } | |
| // Try 5-year cache as fallback (has more than enough data) | |
| const cacheKey5Y = `chart_${ticker}_5Y`; | |
| if (cache.has(cacheKey5Y)) { | |
| const data = cache.get(cacheKey5Y); | |
| if (data.results && data.results.length > 0) { | |
| return data.results; | |
| } | |
| } | |
| // Try 1-year cache (minimum for decent training) | |
| const cacheKey1Y = `chart_${ticker}_1Y`; | |
| if (cache.has(cacheKey1Y)) { | |
| const data = cache.get(cacheKey1Y); | |
| if (data.results && data.results.length > 0) { | |
| return data.results; | |
| } | |
| } | |
| // No cached data available, fetch 2 years of data | |
| const to = new Date(); | |
| const from = new Date(); | |
| from.setFullYear(from.getFullYear() - 2); | |
| const fromStr = from.toISOString().split('T')[0]; | |
| const toStr = to.toISOString().split('T')[0]; | |
| try { | |
| const response = await fetch( | |
| `${API_BASE}/ticker/${ticker}/aggregates?from=${fromStr}&to=${toStr}×pan=day` | |
| ); | |
| const data = await response.json(); | |
| // Cache it for future use | |
| if (data.results) { | |
| cache.set(cacheKey2Y, data, CACHE_TTL.DAILY); | |
| return data.results; | |
| } | |
| } catch (error) { | |
| console.error('Error fetching historical data for forecast:', error); | |
| } | |
| return null; | |
| } | |
| async function loadForecast(ticker, forceRefresh = false) { | |
| const cacheKey = `forecast_${ticker}`; | |
| const container = document.getElementById('forecastTableContainer'); | |
| const refreshBtn = document.getElementById('forecastRefreshBtn'); | |
| // Show loading state | |
| container.innerHTML = '<p class="loading-text">Loading forecast...</p>'; | |
| forecastState.isLoading = true; | |
| if (refreshBtn) { | |
| refreshBtn.disabled = true; | |
| refreshBtn.classList.add('loading'); | |
| } | |
| // Update status to show loading | |
| document.getElementById('forecastModelStatus').textContent = 'Loading...'; | |
| // Check cache first (skip if force refresh) | |
| if (!forceRefresh && cache.has(cacheKey)) { | |
| const data = cache.get(cacheKey); | |
| renderForecast(data); | |
| forecastState.isLoading = false; | |
| if (refreshBtn) { | |
| refreshBtn.disabled = false; | |
| refreshBtn.classList.remove('loading'); | |
| } | |
| return; | |
| } | |
| try { | |
| // Get historical data from cache or fetch it (reuse existing chart data) | |
| const historicalData = await getHistoricalDataForForecast(ticker); | |
| const response = await fetch(`${API_BASE}/forecast/predict/${ticker}`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| historical_data: historicalData, | |
| force_retrain: forceRefresh | |
| }) | |
| }); | |
| if (!response.ok) { | |
| const error = await response.json(); | |
| throw new Error(error.error || 'Forecast failed'); | |
| } | |
| const data = await response.json(); | |
| cache.set(cacheKey, data, CACHE_TTL.MODERATE); | |
| renderForecast(data); | |
| } catch (error) { | |
| console.error('Error loading forecast:', error); | |
| container.innerHTML = `<p class="error-text">${error.message || 'Error loading forecast'}</p>`; | |
| resetForecastUI(); | |
| } finally { | |
| forecastState.isLoading = false; | |
| if (refreshBtn) { | |
| refreshBtn.disabled = false; | |
| refreshBtn.classList.remove('loading'); | |
| } | |
| } | |
| } | |
| function renderForecast(data) { | |
| forecastState.data = data; | |
| // Clear loading text | |
| document.getElementById('forecastTableContainer').innerHTML = ''; | |
| // Update status | |
| if (data.model_info) { | |
| document.getElementById('forecastModelStatus').textContent = 'Trained'; | |
| const trainedAt = data.model_info.trained_at; | |
| if (trainedAt) { | |
| const date = new Date(trainedAt); | |
| document.getElementById('forecastLastUpdated').textContent = date.toLocaleDateString(); | |
| } | |
| } else { | |
| document.getElementById('forecastModelStatus').textContent = 'Not trained'; | |
| } | |
| // Draw forecast chart | |
| drawForecastChart(data); | |
| } | |
| function drawForecastChart(data, skipSetup = false) { | |
| const canvas = document.getElementById('forecastChart'); | |
| if (!canvas) return; | |
| const ctx = canvas.getContext('2d'); | |
| const colors = getChartColors(); | |
| // High DPI support | |
| const dpr = window.devicePixelRatio || 1; | |
| const rect = canvas.getBoundingClientRect(); | |
| if (!skipSetup) { | |
| canvas.width = rect.width * dpr; | |
| canvas.height = rect.height * dpr; | |
| ctx.scale(dpr, dpr); | |
| canvas.style.width = rect.width + 'px'; | |
| canvas.style.height = rect.height + 'px'; | |
| } | |
| const padding = { top: 20, right: 20, bottom: 40, left: 65 }; | |
| const width = rect.width; | |
| const height = rect.height; | |
| const chartWidth = width - padding.left - padding.right; | |
| const chartHeight = height - padding.top - padding.bottom; | |
| // Combine historical and forecast data | |
| const historical = data.historical || []; | |
| const forecast = data.forecast || []; | |
| const confidenceBounds = data.confidence_bounds || {}; | |
| if (historical.length === 0 && forecast.length === 0) { | |
| ctx.fillStyle = colors.text; | |
| ctx.font = '14px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('No data available', width / 2, height / 2); | |
| return; | |
| } | |
| // Store state for hover interactions | |
| forecastChartState.data = data; | |
| forecastChartState.canvas = canvas; | |
| forecastChartState.ctx = ctx; | |
| forecastChartState.totalPoints = historical.length + forecast.length; | |
| forecastChartState.historicalLength = historical.length; | |
| // Calculate price range | |
| const historicalPrices = historical.map(d => d.close); | |
| const forecastPrices = forecast.map(d => d.predicted_close); | |
| const upperBound = confidenceBounds.upper || []; | |
| const lowerBound = confidenceBounds.lower || []; | |
| const allPrices = [...historicalPrices, ...forecastPrices, ...upperBound, ...lowerBound]; | |
| const minPrice = Math.min(...allPrices); | |
| const maxPrice = Math.max(...allPrices); | |
| const pricePadding = (maxPrice - minPrice) * 0.1; | |
| const adjustedMin = minPrice - pricePadding; | |
| const adjustedMax = maxPrice + pricePadding; | |
| const priceRange = adjustedMax - adjustedMin; | |
| const totalPoints = historical.length + forecast.length; | |
| ctx.clearRect(0, 0, width, height); | |
| // Draw grid lines | |
| const numGridLines = 5; | |
| ctx.strokeStyle = colors.grid; | |
| ctx.lineWidth = 1; | |
| ctx.fillStyle = colors.text; | |
| ctx.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; | |
| ctx.textAlign = 'right'; | |
| ctx.textBaseline = 'middle'; | |
| for (let i = 0; i <= numGridLines; i++) { | |
| const y = padding.top + (i / numGridLines) * chartHeight; | |
| const price = adjustedMax - (i / numGridLines) * priceRange; | |
| ctx.beginPath(); | |
| ctx.setLineDash([4, 4]); | |
| ctx.moveTo(padding.left, y); | |
| ctx.lineTo(width - padding.right, y); | |
| ctx.stroke(); | |
| ctx.setLineDash([]); | |
| ctx.fillText(`$${price.toFixed(2)}`, padding.left - 8, y); | |
| } | |
| // Draw vertical divider line between historical and forecast | |
| if (historical.length > 0 && forecast.length > 0) { | |
| const dividerX = padding.left + (historical.length / totalPoints) * chartWidth; | |
| ctx.strokeStyle = colors.crosshair; | |
| ctx.lineWidth = 1; | |
| ctx.setLineDash([8, 4]); | |
| ctx.beginPath(); | |
| ctx.moveTo(dividerX, padding.top); | |
| ctx.lineTo(dividerX, height - padding.bottom); | |
| ctx.stroke(); | |
| ctx.setLineDash([]); | |
| } | |
| // Draw confidence band (shaded area) | |
| if (upperBound.length > 0 && lowerBound.length > 0) { | |
| ctx.beginPath(); | |
| ctx.fillStyle = 'rgba(16, 185, 129, 0.15)'; | |
| // Start from first forecast point | |
| const startIdx = historical.length; | |
| for (let i = 0; i < forecast.length; i++) { | |
| const x = padding.left + ((startIdx + i) / (totalPoints - 1)) * chartWidth; | |
| const y = padding.top + ((adjustedMax - upperBound[i]) / priceRange) * chartHeight; | |
| if (i === 0) ctx.moveTo(x, y); | |
| else ctx.lineTo(x, y); | |
| } | |
| // Go back along lower bound | |
| for (let i = forecast.length - 1; i >= 0; i--) { | |
| const x = padding.left + ((startIdx + i) / (totalPoints - 1)) * chartWidth; | |
| const y = padding.top + ((adjustedMax - lowerBound[i]) / priceRange) * chartHeight; | |
| ctx.lineTo(x, y); | |
| } | |
| ctx.closePath(); | |
| ctx.fill(); | |
| } | |
| // Draw historical line | |
| if (historical.length > 1) { | |
| ctx.beginPath(); | |
| ctx.strokeStyle = colors.line; | |
| ctx.lineWidth = 2; | |
| ctx.lineJoin = 'round'; | |
| ctx.lineCap = 'round'; | |
| historical.forEach((d, i) => { | |
| const x = padding.left + (i / (totalPoints - 1)) * chartWidth; | |
| const y = padding.top + ((adjustedMax - d.close) / priceRange) * chartHeight; | |
| if (i === 0) ctx.moveTo(x, y); | |
| else ctx.lineTo(x, y); | |
| }); | |
| ctx.stroke(); | |
| } | |
| // Draw forecast line (dashed) | |
| if (forecast.length > 0) { | |
| ctx.beginPath(); | |
| ctx.strokeStyle = colors.positive; | |
| ctx.lineWidth = 2; | |
| ctx.setLineDash([6, 4]); | |
| ctx.lineJoin = 'round'; | |
| ctx.lineCap = 'round'; | |
| // Connect from last historical point | |
| if (historical.length > 0) { | |
| const lastHistorical = historical[historical.length - 1]; | |
| const x = padding.left + ((historical.length - 1) / (totalPoints - 1)) * chartWidth; | |
| const y = padding.top + ((adjustedMax - lastHistorical.close) / priceRange) * chartHeight; | |
| ctx.moveTo(x, y); | |
| } | |
| forecast.forEach((d, i) => { | |
| const x = padding.left + ((historical.length + i) / (totalPoints - 1)) * chartWidth; | |
| const y = padding.top + ((adjustedMax - d.predicted_close) / priceRange) * chartHeight; | |
| if (historical.length === 0 && i === 0) ctx.moveTo(x, y); | |
| else ctx.lineTo(x, y); | |
| }); | |
| ctx.stroke(); | |
| ctx.setLineDash([]); | |
| } | |
| // Draw X-axis labels | |
| ctx.fillStyle = colors.text; | |
| ctx.font = '10px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.textBaseline = 'top'; | |
| // Show a few date labels | |
| const allDates = [...historical.map(d => d.date), ...forecast.map(d => d.date)]; | |
| const labelIndices = [0, Math.floor(historical.length / 2), historical.length - 1, historical.length + Math.floor(forecast.length / 2), totalPoints - 1]; | |
| labelIndices.forEach(idx => { | |
| if (idx >= 0 && idx < allDates.length) { | |
| const x = padding.left + (idx / (totalPoints - 1)) * chartWidth; | |
| const date = new Date(allDates[idx]); | |
| const label = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); | |
| ctx.fillText(label, x, height - padding.bottom + 8); | |
| } | |
| }); | |
| // Draw crosshair and tooltip if hovering | |
| if (forecastChartState.hoveredIndex >= 0 && forecastChartState.hoveredIndex < totalPoints) { | |
| drawForecastCrosshair(forecastChartState.hoveredIndex, data, adjustedMin, adjustedMax, priceRange, chartWidth, chartHeight, width, height, padding, colors); | |
| } | |
| // Set up mouse events (only on initial draw) | |
| if (!skipSetup) { | |
| canvas.onmousemove = handleForecastChartMouseMove; | |
| canvas.onmouseleave = handleForecastChartMouseLeave; | |
| } | |
| } | |
| function drawForecastCrosshair(index, data, adjustedMin, adjustedMax, priceRange, chartWidth, chartHeight, width, height, padding, colors) { | |
| const ctx = forecastChartState.ctx; | |
| const historical = data.historical || []; | |
| const forecast = data.forecast || []; | |
| const totalPoints = historical.length + forecast.length; | |
| // Determine if we're in historical or forecast region | |
| const isHistorical = index < historical.length; | |
| let point, price, date; | |
| if (isHistorical) { | |
| point = historical[index]; | |
| price = point.close; | |
| date = point.date; | |
| } else { | |
| const forecastIdx = index - historical.length; | |
| point = forecast[forecastIdx]; | |
| price = point.predicted_close; | |
| date = point.date; | |
| } | |
| const x = padding.left + (index / (totalPoints - 1)) * chartWidth; | |
| const y = padding.top + ((adjustedMax - price) / priceRange) * chartHeight; | |
| // Crosshair lines | |
| ctx.strokeStyle = colors.crosshair; | |
| ctx.lineWidth = 1; | |
| ctx.setLineDash([4, 4]); | |
| // Vertical line | |
| ctx.beginPath(); | |
| ctx.moveTo(x, padding.top); | |
| ctx.lineTo(x, height - padding.bottom); | |
| ctx.stroke(); | |
| // Horizontal line | |
| ctx.beginPath(); | |
| ctx.moveTo(padding.left, y); | |
| ctx.lineTo(width - padding.right, y); | |
| ctx.stroke(); | |
| ctx.setLineDash([]); | |
| // Point dot | |
| ctx.beginPath(); | |
| ctx.fillStyle = isHistorical ? colors.line : colors.positive; | |
| ctx.arc(x, y, 5, 0, Math.PI * 2); | |
| ctx.fill(); | |
| ctx.strokeStyle = colors.tooltipBg; | |
| ctx.lineWidth = 2; | |
| ctx.stroke(); | |
| // Tooltip | |
| drawForecastTooltip(x, y, point, isHistorical, width, height, padding, colors); | |
| } | |
| function drawForecastTooltip(x, y, point, isHistorical, width, height, padding, colors) { | |
| const ctx = forecastChartState.ctx; | |
| const date = new Date(point.date); | |
| const dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); | |
| const price = isHistorical ? point.close : point.predicted_close; | |
| const tooltipWidth = isHistorical ? 140 : 155; | |
| const tooltipHeight = isHistorical ? 52 : 72; | |
| let tooltipX = x + 12; | |
| let tooltipY = y - tooltipHeight / 2; | |
| // Keep tooltip in bounds | |
| if (tooltipX + tooltipWidth > width - padding.right) { | |
| tooltipX = x - tooltipWidth - 12; | |
| } | |
| if (tooltipY < padding.top) { | |
| tooltipY = padding.top; | |
| } | |
| if (tooltipY + tooltipHeight > height - padding.bottom) { | |
| tooltipY = height - padding.bottom - tooltipHeight; | |
| } | |
| // Tooltip background | |
| ctx.fillStyle = colors.tooltipBg; | |
| ctx.strokeStyle = colors.tooltipBorder; | |
| ctx.lineWidth = 1; | |
| ctx.beginPath(); | |
| ctx.roundRect(tooltipX, tooltipY, tooltipWidth, tooltipHeight, 6); | |
| ctx.fill(); | |
| ctx.stroke(); | |
| // Tooltip content | |
| ctx.fillStyle = colors.text; | |
| ctx.font = '10px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; | |
| ctx.textAlign = 'left'; | |
| ctx.textBaseline = 'top'; | |
| ctx.fillText(dateStr, tooltipX + 10, tooltipY + 8); | |
| ctx.fillStyle = colors.textStrong; | |
| ctx.font = 'bold 16px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; | |
| ctx.fillText(`$${price.toFixed(2)}`, tooltipX + 10, tooltipY + 24); | |
| if (!isHistorical) { | |
| // Show confidence range for predictions | |
| ctx.fillStyle = colors.text; | |
| ctx.font = '10px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; | |
| const rangeStr = `Range: $${point.lower_bound.toFixed(2)} - $${point.upper_bound.toFixed(2)}`; | |
| ctx.fillText(rangeStr, tooltipX + 10, tooltipY + 48); | |
| } | |
| } | |
| function handleForecastChartMouseMove(e) { | |
| const { data, canvas, padding, totalPoints } = forecastChartState; | |
| if (!data) return; | |
| const rect = canvas.getBoundingClientRect(); | |
| const dpr = window.devicePixelRatio || 1; | |
| const width = canvas.width / dpr; | |
| const chartWidth = width - padding.left - padding.right; | |
| const mouseX = e.clientX - rect.left; | |
| const relativeX = mouseX - padding.left; | |
| const index = Math.round((relativeX / chartWidth) * (totalPoints - 1)); | |
| if (index >= 0 && index < totalPoints && index !== forecastChartState.hoveredIndex) { | |
| forecastChartState.hoveredIndex = index; | |
| drawForecastChart(data, true); | |
| } | |
| } | |
| function handleForecastChartMouseLeave() { | |
| forecastChartState.hoveredIndex = -1; | |
| if (forecastChartState.data) { | |
| drawForecastChart(forecastChartState.data, true); | |
| } | |
| } | |
| function resetForecastUI() { | |
| document.getElementById('forecastModelStatus').textContent = 'Not trained'; | |
| document.getElementById('forecastLastUpdated').textContent = '--'; | |
| forecastState.data = null; | |
| const canvas = document.getElementById('forecastChart'); | |
| if (canvas) { | |
| const ctx = canvas.getContext('2d'); | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| } | |
| } | |