UNPKG

tenzetta

Version:

Terminal-based stock market viewer with real-time data streaming

1,134 lines (958 loc) 41.9 kB
const axios = require('axios'); const chalk = require('chalk'); const ora = require('ora'); const readline = require('readline'); const asciichart = require('asciichart'); const config = require('./config'); // S&P 500 Stock List (Full List) const SP500_STOCKS = [ // Top 100 by market cap 'AAPL', 'MSFT', 'GOOGL', 'GOOG', 'AMZN', 'NVDA', 'META', 'TSLA', 'AVGO', 'WMT', 'LLY', 'XOM', 'UNH', 'V', 'JPM', 'PG', 'MA', 'JNJ', 'HD', 'CVX', 'MRK', 'ABBV', 'COST', 'ADBE', 'BAC', 'CRM', 'KO', 'PEP', 'TMO', 'NFLX', 'AMD', 'LIN', 'CSCO', 'ABT', 'DIS', 'WFC', 'ACN', 'TXN', 'VZ', 'PMT', 'SPGI', 'QCOM', 'GE', 'AMAT', 'CAT', 'BKNG', 'INTU', 'IBM', 'GS', 'RTX', // Next 100 (51-150) 'AXP', 'NOW', 'ISRG', 'RMD', 'SYK', 'PGR', 'TJX', 'BSX', 'MMC', 'LRCX', 'BLK', 'MU', 'ADI', 'SCHW', 'AMT', 'ADP', 'MDLZ', 'GILD', 'C', 'KLAC', 'VRTX', 'FI', 'NKE', 'REGN', 'PANW', 'AON', 'CMG', 'MCD', 'ANET', 'EOG', 'ITW', 'PLD', 'ATVI', 'APD', 'CL', 'MSI', 'KMB', 'GD', 'SO', 'FCX', 'ICE', 'CME', 'PNC', 'SNPS', 'HUM', 'MCK', 'DUK', 'COP', 'USB', 'TGT', // Next 100 (151-250) 'CCI', 'PSA', 'EMR', 'CDNS', 'NSC', 'TFC', 'AON', 'SHW', 'MPC', 'COF', 'GM', 'F', 'ORLY', 'JCI', 'WELL', 'ECL', 'CARR', 'PYPL', 'O', 'MCO', 'BDX', 'MMM', 'TRV', 'AJG', 'NOC', 'PCAR', 'AIG', 'ALL', 'CMI', 'D', 'WM', 'CTVA', 'EXC', 'PSX', 'SLB', 'VLO', 'MCHP', 'ADM', 'OXY', 'SPG', 'TEL', 'DOW', 'FDX', 'IQV', 'RSG', 'PAYX', 'A', 'ROST', 'MSCI', 'OTIS', // Next 100 (251-350) 'FAST', 'VRSK', 'EW', 'CTAS', 'KR', 'IDXX', 'BK', 'CPRT', 'EA', 'VICI', 'KMI', 'PPG', 'ODFL', 'YUM', 'MLM', 'AZO', 'DDOG', 'KEYS', 'ROK', 'CMI', 'CTSH', 'EXR', 'AVB', 'IFF', 'ANSS', 'RJF', 'STT', 'WBA', 'EBAY', 'HPQ', 'GLW', 'EQR', 'FITB', 'FRC', 'MTD', 'SBAC', 'AWK', 'CBRE', 'LH', 'WDC', 'GPC', 'ED', 'HBAN', 'PPL', 'NTRS', 'CDW', 'ETR', 'MAR', 'TROW', 'KEY', // Remaining (351-500) 'STZ', 'WMB', 'CMS', 'FE', 'AEE', 'CNP', 'NI', 'LNT', 'EVRG', 'PEG', 'SO', 'NEE', 'AEP', 'XEL', 'WEC', 'ES', 'PNW', 'SRE', 'PCG', 'EIX', 'AWK', 'ATO', 'CMS', 'DTE', 'ETR', 'EVRG', 'FE', 'LNT', 'NI', 'NRG', 'OGE', 'PCG', 'PEG', 'PNW', 'PPL', 'SRE', 'UGI', 'WEC', 'XEL', 'AES', 'CNP', 'D', 'DUK', 'ED', 'EIX', 'ES', 'ETR', 'EVRG', 'EXC', 'FE', 'NEE', 'NI', 'NRG', 'PCG', 'PEG', 'PNW', 'PPL', 'SO', 'SRE', 'WEC', // ETFs for market context 'SPY', 'QQQ', 'IWM', 'DIA', 'VTI', 'VOO', 'IVV', 'ARKK', 'TLT', 'GLD' ]; // Keep a smaller focused list for quick loading const TOP_PERFORMERS = [ 'AAPL', 'MSFT', 'GOOGL', 'AMZN', 'NVDA', 'META', 'TSLA', 'AVGO', 'ORCL', 'WMT', 'LLY', 'XOM', 'UNH', 'V', 'JPM', 'PG', 'MA', 'JNJ', 'HD', 'CVX', 'MRK', 'ABBV', 'COST', 'ADBE', 'BAC', 'CRM', 'KO', 'PEP', 'TMO', 'NFLX' ]; // Market Indices const MARKET_INDICES = [ { symbol: 'SPY', name: 'S&P 500', icon: '📊' }, { symbol: 'QQQ', name: 'Nasdaq 100', icon: '💻' }, { symbol: 'DIA', name: 'Dow Jones', icon: '🏛️' }, { symbol: 'IWM', name: 'Russell 2000', icon: '📈' }, { symbol: 'VIX', name: 'Volatility Index', icon: '⚡' }, { symbol: 'VTI', name: 'Total Market', icon: '🌐' } ]; // S&P 500 Sector ETFs const SECTOR_ETFS = { 'XLK': { name: 'Technology', icon: '💻' }, 'XLF': { name: 'Financials', icon: '🏦' }, 'XLV': { name: 'Health Care', icon: '🏥' }, 'XLE': { name: 'Energy', icon: '⛽' }, 'XLI': { name: 'Industrials', icon: '🏭' }, 'XLC': { name: 'Communication Services', icon: '📡' }, 'XLY': { name: 'Consumer Discretionary', icon: '🛍️' }, 'XLP': { name: 'Consumer Staples', icon: '🛒' }, 'XLRE': { name: 'Real Estate', icon: '🏢' }, 'XLU': { name: 'Utilities', icon: '💡' }, 'XLB': { name: 'Materials', icon: '🔧' }, 'SPY': { name: 'S&P 500 Index', icon: '📊' } }; class TenzettaUnified { constructor(options = {}) { this.options = options; this.currentView = options.startView || 'indices-sectors'; this.currentSymbol = options.startSymbol || 'AAPL'; this.quotes = {}; this.symbolData = {}; this.isLoading = false; this.spinner = null; this.isPrompting = false; // Prevent multiple prompts this.searchActive = false; // Track search mode } async run() { try { console.clear(); this.showLogo(); // Setup keyboard input if (process.stdin.isTTY) { readline.emitKeypressEvents(process.stdin); process.stdin.setRawMode(true); this.setupKeyboardHandler(); } // Load initial market data await this.loadMarketData(); // If starting with a quote view, load that symbol data if (this.currentView.startsWith('quote-')) { await this.loadSymbolData(this.currentSymbol); } // Start display this.display(); // Auto refresh market data every 30 seconds this.marketDataInterval = setInterval(() => this.loadMarketData(), 30000); this.displayInterval = setInterval(() => this.display(), 2000); // Handle shutdown signals - force exit immediately process.on('SIGINT', () => { console.log(chalk.yellow('\n\nForce exit (SIGINT)...')); process.exit(0); }); process.on('SIGTERM', () => { console.log(chalk.yellow('\n\nForce exit (SIGTERM)...')); process.exit(0); }); // Handle uncaught exceptions process.on('uncaughtException', (error) => { console.error(chalk.red('Uncaught Exception:'), error.message); process.exit(1); }); } catch (error) { console.error(chalk.red('Error:'), error.message); this.cleanup(); } } showLogo() { const logo = chalk.cyan.bold(`╔╦╗╔═╗╔╗╔╔═╗╔═╗╔╦╗╔╦╗╔═╗ ║ ║╣ ║║║╔═╝║╣ ║ ║ ╠═╣ ╩ ╚═╝╝╚╝╚═╝╚═╝ ╩ ╩ ╩ ╩`) + chalk.gray(' 📈 Terminal Stock Data'); console.log(logo); console.log(chalk.gray('─'.repeat(50))); console.log(chalk.gray('Loading market data...')); } async loadMarketData() { if (this.isLoading) return; this.isLoading = true; // Include market indices, sector ETFs, and S&P 500 stocks const indexSymbols = MARKET_INDICES.map(idx => idx.symbol); const sectorSymbols = Object.keys(SECTOR_ETFS); const allSymbols = [...new Set([...indexSymbols, ...sectorSymbols, ...SP500_STOCKS])]; const batchSize = 10; for (let i = 0; i < allSymbols.length; i += batchSize) { const batch = allSymbols.slice(i, i + batchSize); const promises = batch.map(symbol => axios.get(`${config.MCP_SERVER_URL}${config.endpoints.quote(symbol)}`) .then(res => ({ symbol, data: res.data })) .catch(() => ({ symbol, data: null })) ); const results = await Promise.all(promises); results.forEach(({ symbol, data }) => { if (data) this.quotes[symbol] = data; }); if (i + batchSize < allSymbols.length) { await new Promise(resolve => setTimeout(resolve, 100)); } } this.isLoading = false; } async loadSymbolData(symbol) { if (this.symbolData[symbol]) return this.symbolData[symbol]; this.symbolData[symbol] = { symbol, loading: true }; try { // Load quote data const quoteResponse = await axios.get(`${config.MCP_SERVER_URL}${config.endpoints.quote(symbol)}`); this.symbolData[symbol].quote = quoteResponse.data; // Load financial data in parallel const financialPromises = [ axios.get(`${config.MCP_SERVER_URL}${config.endpoints.keyMetrics(symbol)}`) .then(res => this.symbolData[symbol].keyMetrics = res.data) .catch(() => this.symbolData[symbol].keyMetrics = null), axios.get(`${config.MCP_SERVER_URL}${config.endpoints.financialRatios(symbol)}`) .then(res => this.symbolData[symbol].ratios = res.data) .catch(() => this.symbolData[symbol].ratios = null), axios.get(`${config.MCP_SERVER_URL}${config.endpoints.incomeStatement(symbol)}`) .then(res => this.symbolData[symbol].income = res.data) .catch(() => this.symbolData[symbol].income = null) ]; await Promise.all(financialPromises); } catch (error) { console.error(chalk.red(`Error loading ${symbol}:`), error.message); this.symbolData[symbol].quote = null; } this.symbolData[symbol].loading = false; return this.symbolData[symbol]; } setupKeyboardHandler() { process.stdin.on('keypress', (str, key) => { // Handle Ctrl+C exit - check multiple ways if ((key && key.ctrl && key.name === 'c') || (str && str.charCodeAt && str.charCodeAt(0) === 3) || (key && key.sequence === '\u0003')) { // Unicode Ctrl+C console.log(chalk.yellow('\nExiting...')); process.exit(0); } // Handle ESC key as alternative exit if (key && key.name === 'escape') { console.log(chalk.yellow('\nExiting...')); process.exit(0); } // Handle 'q' or 'Q' for quit if (str === 'q' || str === 'Q') { console.log(chalk.yellow('\nExiting...')); process.exit(0); } // Main navigation switch(str) { case '1': this.currentView = 'indices-sectors'; this.display(); break; case '2': this.currentView = 'gainers'; this.display(); break; case '3': this.currentView = 'losers'; this.display(); break; case '4': this.currentView = 'sp500-extreme'; this.display(); break; case '5': this.currentView = 'quote-overview'; this.loadAndDisplayQuote(this.currentSymbol); break; case '6': this.currentView = 'quote-chart'; this.loadAndDisplayQuote(this.currentSymbol); break; case '7': this.currentView = 'quote-financials'; this.loadAndDisplayQuote(this.currentSymbol); break; case '8': this.currentView = 'sectors'; this.display(); break; case '9': this.currentView = 'market-overview'; this.display(); break; case 's': case 'S': this.simpleSymbolSearch(); break; } }); } simpleSymbolSearch() { // Pause all background activity during search this.pauseBackgroundActivity(); console.clear(); console.log(chalk.cyan.bold('🔍 SEARCH STOCK SYMBOL')); console.log(chalk.gray('Type a symbol and press Enter (e.g. TSLA, GOOGL, NVDA)')); console.log(chalk.gray('Press ESC to cancel\n')); process.stdout.write(chalk.cyan('Symbol: ')); let inputBuffer = ''; this.searchActive = true; // Use instance variable to track state // Create a temporary keypress handler for search input const searchHandler = (str, key) => { if (!this.searchActive) return; // Ignore if search is no longer active // Handle ESC to cancel if (key && key.name === 'escape') { console.log(chalk.yellow('\nSearch cancelled')); this.searchActive = false; this.restoreAndShow(); return; } // Handle Ctrl+C - be very specific about detection if (key && key.ctrl && key.name === 'c') { console.log(chalk.yellow('\nExiting...')); process.exit(0); } // Handle Enter if (key && key.name === 'return') { this.searchActive = false; const symbol = inputBuffer.trim().toUpperCase(); if (symbol && symbol.length > 0) { console.log(chalk.green(`\n✓ Loading ${symbol}...`)); this.currentSymbol = symbol; this.currentView = 'quote-overview'; this.restoreAndShow(); } else { console.log(chalk.yellow('\nNo symbol entered')); this.restoreAndShow(); } return; } // Handle backspace if (key && key.name === 'backspace') { if (inputBuffer.length > 0) { inputBuffer = inputBuffer.slice(0, -1); process.stdout.write('\b \b'); // Clear the character } return; } // Handle regular characters (letters, numbers, dots) - be very specific if (str && typeof str === 'string' && str.length === 1 && /[A-Za-z0-9.]/.test(str)) { inputBuffer += str.toUpperCase(); process.stdout.write(str.toUpperCase()); } }; // Remove current handlers and add search handler process.stdin.removeAllListeners('keypress'); process.stdin.on('keypress', searchHandler); } pauseBackgroundActivity() { // Temporarily clear intervals to prevent interference if (this.displayInterval) { clearInterval(this.displayInterval); this.displayInterval = null; } } resumeBackgroundActivity() { // Restart display interval if not already running if (!this.displayInterval) { this.displayInterval = setInterval(() => { if (!this.searchActive) { // Only display if not searching this.display(); } }, 2000); } } restoreAndShow() { // Mark search as inactive this.searchActive = false; // Resume background activity this.resumeBackgroundActivity(); // Small delay to ensure previous handlers are cleared setTimeout(() => { // Remove any lingering search handlers process.stdin.removeAllListeners('keypress'); // Restore keyboard mode if (process.stdin.isTTY) { process.stdin.setRawMode(true); readline.emitKeypressEvents(process.stdin); this.setupKeyboardHandler(); } // Load and display if (this.currentView.startsWith('quote-')) { this.loadAndDisplayQuote(this.currentSymbol); } else { this.display(); } }, 100); } async loadAndDisplayQuote(symbol) { console.clear(); console.log(chalk.cyan('Loading...')); await this.loadSymbolData(symbol); this.display(); } display() { console.clear(); // Header console.log(chalk.cyan.bold('TENZETTA - Unified Terminal')); console.log(chalk.gray(`Last Update: ${new Date().toLocaleTimeString()}`)); console.log(chalk.gray('─'.repeat(60))); // Content based on current view switch(this.currentView) { case 'gainers': this.displayGainers(); break; case 'losers': this.displayLosers(); break; case 'sp500-extreme': this.displaySP500Extreme(); break; case 'quote-overview': this.displayQuoteOverview(); break; case 'quote-chart': this.displayQuoteChart(); break; case 'quote-financials': this.displayQuoteFinancials(); break; case 'sectors': this.displaySectors(); break; case 'indices-sectors': this.displayIndicesAndSectors(); break; default: this.displayMarketOverview(); } // Navigation help this.displayNavigation(); } displayMarketOverview() { const quotes = Object.values(this.quotes).filter(q => q && q.price > 0); const sorted = quotes.sort((a, b) => (b.changesPercentage || 0) - (a.changesPercentage || 0)); const gainers = quotes.filter(q => q.changesPercentage > 0).length; const losers = quotes.filter(q => q.changesPercentage < 0).length; console.log(chalk.yellow.bold('📊 Market Overview\n')); console.log(`${chalk.green(`↑ ${gainers} Gainers`)} | ${chalk.red(`↓ ${losers} Losers`)}\n`); // Top movers const extremeMovers = sorted.filter(q => Math.abs(q.changesPercentage) >= 3); if (extremeMovers.length > 0) { console.log(chalk.yellow.bold('🔥 Extreme Movers (>3%)')); extremeMovers.slice(0, 5).forEach(q => { const changeStr = this.formatPercent(q.changesPercentage); console.log(` ${q.symbol}: ${this.formatCurrency(q.price)} ${changeStr}`); }); console.log(''); } // Regular display console.log(chalk.bold('Symbol Name Price Change Volume')); console.log('─'.repeat(65)); sorted.slice(0, 15).forEach(quote => { const name = (quote.name || '').substring(0, 20).padEnd(20); const changeStr = this.formatPercent(quote.changesPercentage); const volStr = this.formatVolume(quote.volume); console.log( `${quote.symbol.padEnd(8)} ${name} ${this.formatCurrency(quote.price).padStart(10)} ${changeStr.padStart(9)} ${volStr.padStart(8)}` ); }); } displayGainers() { const quotes = Object.values(this.quotes).filter(q => q && q.price > 0); const gainers = quotes .filter(q => q.changesPercentage > 0) .sort((a, b) => b.changesPercentage - a.changesPercentage); console.log(chalk.green.bold('📈 Top Gainers\n')); gainers.slice(0, 20).forEach((quote, i) => { const changeStr = this.formatPercent(quote.changesPercentage); console.log( `${(i + 1).toString().padStart(2)}. ${quote.symbol.padEnd(6)} ${this.formatCurrency(quote.price).padStart(10)} ${changeStr.padStart(9)} ${quote.name || ''}` ); }); } displayLosers() { const quotes = Object.values(this.quotes).filter(q => q && q.price > 0); const losers = quotes .filter(q => q.changesPercentage < 0) .sort((a, b) => a.changesPercentage - b.changesPercentage); console.log(chalk.red.bold('📉 Top Losers\n')); losers.slice(0, 20).forEach((quote, i) => { const changeStr = this.formatPercent(quote.changesPercentage); console.log( `${(i + 1).toString().padStart(2)}. ${quote.symbol.padEnd(6)} ${this.formatCurrency(quote.price).padStart(10)} ${changeStr.padStart(9)} ${quote.name || ''}` ); }); } displaySP500Extreme() { const quotes = Object.values(this.quotes).filter(q => q && q.price > 0); const sp500Symbols = new Set(SP500_STOCKS); const extremeMovers = quotes .filter(q => sp500Symbols.has(q.symbol) && Math.abs(q.changesPercentage || 0) >= 2) .sort((a, b) => Math.abs(b.changesPercentage) - Math.abs(a.changesPercentage)); console.log(chalk.yellow.bold('🔥 S&P 500 Extreme Movers (>2%)\n')); if (extremeMovers.length === 0) { console.log(chalk.gray('No extreme movers found (>2% change)')); return; } console.log(chalk.bold('Rank Symbol Price Change Name')); console.log('─'.repeat(60)); extremeMovers.slice(0, 25).forEach((quote, i) => { const changeStr = this.formatPercent(quote.changesPercentage); const rank = (i + 1).toString().padStart(2); const name = (quote.name || '').substring(0, 25); const isExtreme = Math.abs(quote.changesPercentage) >= 5; const displayRank = isExtreme ? chalk.bgYellow.black(rank) : rank; console.log( `${displayRank} ${quote.symbol.padEnd(6)} ${this.formatCurrency(quote.price).padStart(10)} ${changeStr.padStart(9)} ${name}` ); }); console.log(chalk.gray(`\nTracking ${SP500_STOCKS.length} S&P 500 stocks • ${extremeMovers.length} extreme movers found`)); } displayQuoteOverview() { const data = this.symbolData[this.currentSymbol]; if (!data || data.loading) { console.log(chalk.yellow('Loading quote data...')); return; } const quote = data.quote; if (!quote) { console.log(chalk.red('No quote data available')); return; } console.log(chalk.yellow.bold(`📋 ${quote.symbol} - Overview`)); console.log(chalk.cyan.bold(`${quote.name || 'N/A'}\n`)); const change = quote.changesPercentage || 0; const changeColor = change > 0 ? chalk.green : change < 0 ? chalk.red : chalk.gray; const changeStr = `${change >= 0 ? '+' : ''}${change.toFixed(2)}%`; console.log(`Price: ${chalk.bold.white(this.formatCurrency(quote.price))} ${changeColor(changeStr)}\n`); console.log(`Volume: ${this.formatNumber(quote.volume)}`); console.log(`Market Cap: ${this.formatNumber(quote.marketCap)}`); console.log(`Avg Volume: ${this.formatNumber(quote.avgVolume)}`); console.log(''); console.log(`Day Range: ${this.formatCurrency(quote.dayLow)} - ${this.formatCurrency(quote.dayHigh)}`); console.log(`52W Range: ${this.formatCurrency(quote.yearLow)} - ${this.formatCurrency(quote.yearHigh)}`); console.log(''); console.log(`P/E Ratio: ${quote.pe?.toFixed(2) || 'N/A'}`); console.log(`EPS: ${this.formatCurrency(quote.eps)}`); console.log(`Beta: ${quote.beta?.toFixed(2) || 'N/A'}`); console.log(`Dividend Yield: ${quote.dividendYield ? (quote.dividendYield * 100).toFixed(2) + '%' : 'N/A'}`); } displayQuoteChart() { const data = this.symbolData[this.currentSymbol]; if (!data || data.loading) { console.log(chalk.yellow('Loading chart data...')); return; } console.log(chalk.yellow.bold(`📈 ${this.currentSymbol} - Price Chart\n`)); // Try to load chart data if not already loaded if (!data.chart) { this.loadChartData(this.currentSymbol); console.log(chalk.gray('Initializing chart data...')); return; } const quote = data.quote; if (quote) { const change = quote.changesPercentage || 0; const changeColor = change > 0 ? chalk.green : change < 0 ? chalk.red : chalk.gray; const changeStr = `${change >= 0 ? '+' : ''}${change.toFixed(2)}%`; console.log(`Current: ${chalk.bold.white(this.formatCurrency(quote.price))} ${changeColor(changeStr)}`); console.log(`Company: ${quote.name || 'N/A'}\n`); } if (data.chart && data.chart.length > 0) { // Create price data for chart - take most recent 25 days and reverse for chronological order const prices = data.chart.slice(0, 25).reverse().map(item => { const price = item.close || item.price || item.adjClose || 0; return parseFloat(price); }).filter(p => p > 0); if (prices.length >= 3) { const chartOptions = { height: 10, format: (x) => this.formatCurrency(x) }; const chart = asciichart.plot(prices, chartOptions); console.log(chalk.cyan(chart)); console.log(''); const minPrice = Math.min(...prices); const maxPrice = Math.max(...prices); const startPrice = prices[0]; const endPrice = prices[prices.length - 1]; const chartChange = ((endPrice - startPrice) / startPrice * 100); console.log(`Chart Range: ${this.formatCurrency(minPrice)} - ${this.formatCurrency(maxPrice)}`); console.log(`Period Change: ${chartChange >= 0 ? '+' : ''}${chartChange.toFixed(2)}%`); console.log(chalk.gray(`Data Points: ${prices.length} | Type: ${data.chart.length > 50 ? 'Intraday' : 'Historical'}`)); } else { console.log(chalk.red('Insufficient price data for chart')); console.log(chalk.gray(`Only ${prices.length} valid data points available`)); } } else { console.log(chalk.yellow('Loading chart data...')); // Trigger data loading if not already started if (!this.symbolData[this.currentSymbol].chartLoading) { this.symbolData[this.currentSymbol].chartLoading = true; this.loadChartData(this.currentSymbol); } } } async loadChartData(symbol) { if (!this.symbolData[symbol]) { this.symbolData[symbol] = { symbol, loading: false }; } try { // Try historical data first console.log(chalk.dim(`Loading chart data for ${symbol}...`)); let response = await axios.get(`${config.MCP_SERVER_URL}${config.endpoints.historical(symbol)}`); let chartData = null; if (response.data && response.data.historical) { chartData = response.data.historical; } else if (Array.isArray(response.data)) { chartData = response.data; } // If no historical data, try intraday if (!chartData || chartData.length === 0) { console.log(chalk.dim('No historical data, trying intraday...')); try { response = await axios.get(`${config.MCP_SERVER_URL}${config.endpoints.intraday(symbol, '5min')}`); if (response.data && Array.isArray(response.data)) { chartData = response.data; } else if (response.data && response.data.data) { chartData = response.data.data; } } catch (intradayError) { console.log(chalk.dim('Intraday data also failed, generating sample chart...')); } } // Generate sample data if no real data available if (!chartData || chartData.length === 0) { const quote = this.symbolData[symbol].quote; if (quote && quote.price) { chartData = this.generateSampleChart(quote.price, quote.changesPercentage || 0); } } this.symbolData[symbol].chart = chartData || []; } catch (error) { console.error(chalk.red('Chart data error:'), error.message); // Generate sample chart as fallback const quote = this.symbolData[symbol].quote; if (quote && quote.price) { this.symbolData[symbol].chart = this.generateSampleChart(quote.price, quote.changesPercentage || 0); } else { this.symbolData[symbol].chart = []; } } // Refresh display this.display(); } generateSampleChart(currentPrice, changePercent) { // Generate a realistic-looking price chart based on current price const dataPoints = 30; const chart = []; let price = currentPrice * (1 - changePercent / 100); // Start price for (let i = 0; i < dataPoints; i++) { // Add some realistic price movement const randomChange = (Math.random() - 0.5) * 0.02; // ±1% random walk const trendFactor = (changePercent / 100) * (i / dataPoints); // Apply overall trend price = price * (1 + randomChange + trendFactor / dataPoints); chart.push({ date: new Date(Date.now() - (dataPoints - i) * 5 * 60 * 1000).toISOString(), close: price, price: price }); } return chart; } displayQuoteFinancials() { const data = this.symbolData[this.currentSymbol]; if (!data || data.loading) { console.log(chalk.yellow('Loading financial data...')); return; } console.log(chalk.yellow.bold(`📊 ${this.currentSymbol} - Key Financials & Ratios`)); const quote = data.quote; if (quote) { console.log(chalk.cyan.bold(`${quote.name || 'N/A'}\n`)); } // Key Metrics TTM const keyMetrics = data.keyMetrics; if (keyMetrics && Array.isArray(keyMetrics) && keyMetrics.length > 0) { const metrics = keyMetrics[0]; // Most recent TTM data console.log(chalk.green.bold('📈 Key Metrics (TTM)')); console.log('─'.repeat(50)); if (metrics.revenuePerShareTTM) { console.log(`Revenue per Share: ${this.formatCurrency(metrics.revenuePerShareTTM)}`); } if (metrics.netIncomePerShareTTM) { console.log(`Net Income per Share: ${this.formatCurrency(metrics.netIncomePerShareTTM)}`); } if (metrics.operatingCashFlowPerShareTTM) { console.log(`Operating Cash Flow per Share: ${this.formatCurrency(metrics.operatingCashFlowPerShareTTM)}`); } if (metrics.freeCashFlowPerShareTTM) { console.log(`Free Cash Flow per Share: ${this.formatCurrency(metrics.freeCashFlowPerShareTTM)}`); } if (metrics.cashPerShareTTM) { console.log(`Cash per Share: ${this.formatCurrency(metrics.cashPerShareTTM)}`); } if (metrics.bookValuePerShareTTM) { console.log(`Book Value per Share: ${this.formatCurrency(metrics.bookValuePerShareTTM)}`); } if (metrics.tangibleBookValuePerShareTTM) { console.log(`Tangible Book Value per Share: ${this.formatCurrency(metrics.tangibleBookValuePerShareTTM)}`); } if (metrics.shareholdersEquityPerShareTTM) { console.log(`Shareholders Equity per Share: ${this.formatCurrency(metrics.shareholdersEquityPerShareTTM)}`); } console.log(''); } // Financial Ratios TTM const ratios = data.ratios; if (ratios && Array.isArray(ratios) && ratios.length > 0) { const ratio = ratios[0]; // Most recent TTM ratios console.log(chalk.blue.bold('📉 Financial Ratios (TTM)')); console.log('─'.repeat(50)); // Profitability Ratios if (ratio.returnOnAssetsTTM !== undefined) { console.log(`Return on Assets: ${(ratio.returnOnAssetsTTM * 100).toFixed(2)}%`); } if (ratio.returnOnEquityTTM !== undefined) { console.log(`Return on Equity: ${(ratio.returnOnEquityTTM * 100).toFixed(2)}%`); } if (ratio.returnOnCapitalEmployedTTM !== undefined) { console.log(`Return on Capital Employed: ${(ratio.returnOnCapitalEmployedTTM * 100).toFixed(2)}%`); } // Margin Ratios if (ratio.netProfitMarginTTM !== undefined) { console.log(`Net Profit Margin: ${(ratio.netProfitMarginTTM * 100).toFixed(2)}%`); } if (ratio.operatingProfitMarginTTM !== undefined) { console.log(`Operating Profit Margin: ${(ratio.operatingProfitMarginTTM * 100).toFixed(2)}%`); } if (ratio.grossProfitMarginTTM !== undefined) { console.log(`Gross Profit Margin: ${(ratio.grossProfitMarginTTM * 100).toFixed(2)}%`); } // Liquidity Ratios if (ratio.currentRatioTTM !== undefined) { console.log(`Current Ratio: ${ratio.currentRatioTTM.toFixed(2)}`); } if (ratio.quickRatioTTM !== undefined) { console.log(`Quick Ratio: ${ratio.quickRatioTTM.toFixed(2)}`); } // Efficiency Ratios if (ratio.assetTurnoverTTM !== undefined) { console.log(`Asset Turnover: ${ratio.assetTurnoverTTM.toFixed(2)}x`); } if (ratio.inventoryTurnoverTTM !== undefined) { console.log(`Inventory Turnover: ${ratio.inventoryTurnoverTTM.toFixed(2)}x`); } console.log(''); } // Income Statement Highlights const income = data.income; if (income && Array.isArray(income) && income.length > 0) { const latest = income[0]; // Most recent year console.log(chalk.magenta.bold('💰 Income Statement Highlights')); console.log('─'.repeat(50)); if (latest.revenue) { console.log(`Revenue: ${this.formatLargeNumber(latest.revenue)}`); } if (latest.grossProfit) { console.log(`Gross Profit: ${this.formatLargeNumber(latest.grossProfit)}`); } if (latest.operatingIncome) { console.log(`Operating Income: ${this.formatLargeNumber(latest.operatingIncome)}`); } if (latest.netIncome) { console.log(`Net Income: ${this.formatLargeNumber(latest.netIncome)}`); } if (latest.eps) { console.log(`EPS: ${this.formatCurrency(latest.eps)}`); } if (latest.date) { console.log(chalk.gray(`Period: ${latest.date}`)); } } // Show message if no data available if (!keyMetrics && !ratios && !income) { console.log(chalk.gray('Financial data not available for this symbol')); } } displaySectors() { console.log(chalk.yellow.bold('🏛️ S&P 500 Sectors Performance\n')); // Get sector data const sectorData = []; for (const [symbol, info] of Object.entries(SECTOR_ETFS)) { const quote = this.quotes[symbol]; if (quote && quote.price > 0) { sectorData.push({ symbol, name: info.name, icon: info.icon, price: quote.price, change: quote.change || 0, changePercent: quote.changesPercentage || 0, volume: quote.volume || 0, dayHigh: quote.dayHigh || 0, dayLow: quote.dayLow || 0, yearHigh: quote.yearHigh || 0, yearLow: quote.yearLow || 0 }); } } // Sort by performance const sorted = sectorData.sort((a, b) => b.changePercent - a.changePercent); // Market breadth summary const gainers = sorted.filter(s => s.changePercent > 0).length; const losers = sorted.filter(s => s.changePercent < 0).length; const unchanged = sorted.filter(s => s.changePercent === 0).length; console.log(chalk.bold('Sector Breadth: ') + chalk.green(`↑ ${gainers} Up`) + ' | ' + chalk.red(`↓ ${losers} Down`) + ' | ' + chalk.gray(`→ ${unchanged} Flat\n`)); // Display headers console.log(chalk.bold('Rank Sector Symbol Price Change Day Range 52W Range')); console.log('─'.repeat(100)); // Display each sector sorted.forEach((sector, index) => { const rank = (index + 1).toString().padStart(2); const sectorName = `${sector.icon} ${sector.name}`.padEnd(30); const priceStr = this.formatCurrency(sector.price).padStart(10); const changeStr = this.formatPercent(sector.changePercent).padStart(9); // Day range const dayRange = `${this.formatCurrency(sector.dayLow)}-${this.formatCurrency(sector.dayHigh)}`.padEnd(16); // 52 week range percentage from high const yearRangePercent = sector.yearHigh > 0 ? ((sector.yearHigh - sector.price) / sector.yearHigh * 100).toFixed(1) : 'N/A'; const yearRange = yearRangePercent === 'N/A' ? 'N/A' : `-${yearRangePercent}% from high`; // Highlight top/bottom performers let displayRank = rank; if (index === 0 && sector.changePercent > 0) { displayRank = chalk.bgGreen.black(rank); // Best performer } else if (index === sorted.length - 1 && sector.changePercent < 0) { displayRank = chalk.bgRed.black(rank); // Worst performer } console.log( `${displayRank} ${sectorName} ${sector.symbol.padEnd(6)} ${priceStr} ${changeStr} ${dayRange} ${yearRange}` ); }); // Market analysis console.log('\n' + chalk.bold('📊 Sector Rotation Analysis')); console.log('─'.repeat(50)); // Find strongest and weakest if (sorted.length > 0) { const strongest = sorted[0]; const weakest = sorted[sorted.length - 1]; console.log(chalk.green(`Strongest: ${strongest.icon} ${strongest.name} (${strongest.symbol}) ${strongest.changePercent >= 0 ? '+' : ''}${strongest.changePercent.toFixed(2)}%`)); console.log(chalk.red(`Weakest: ${weakest.icon} ${weakest.name} (${weakest.symbol}) ${weakest.changePercent >= 0 ? '+' : ''}${weakest.changePercent.toFixed(2)}%`)); // Calculate spread const spread = Math.abs(strongest.changePercent - weakest.changePercent); console.log(chalk.yellow(`Spread: ${spread.toFixed(2)}% (${spread > 3 ? 'High dispersion' : spread > 1.5 ? 'Moderate dispersion' : 'Low dispersion'})`)); // Risk on/off analysis const techChange = sectorData.find(s => s.symbol === 'XLK')?.changePercent || 0; const utilChange = sectorData.find(s => s.symbol === 'XLU')?.changePercent || 0; const stapleChange = sectorData.find(s => s.symbol === 'XLP')?.changePercent || 0; if (techChange > utilChange && techChange > stapleChange) { console.log(chalk.cyan('Market Mode: Risk-On (Growth outperforming Defensives)')); } else if (utilChange > techChange || stapleChange > techChange) { console.log(chalk.magenta('Market Mode: Risk-Off (Defensives outperforming Growth)')); } else { console.log(chalk.gray('Market Mode: Mixed signals')); } } } displayIndicesAndSectors() { console.log(chalk.cyan.bold('📊 Market Indices & Sectors\n')); // Display Market Indices console.log(chalk.yellow.bold('Major Market Indices')); console.log('─'.repeat(50)); MARKET_INDICES.forEach(index => { const quote = this.quotes[index.symbol]; if (quote) { const change = quote.changesPercentage || 0; const changeStr = this.formatPercent(change); const price = this.formatCurrency(quote.price); console.log( `${index.icon} ${index.symbol.padEnd(6)} ${index.name.padEnd(20)} ${price.padStart(10)} ${changeStr.padStart(9)}` ); } }); // VIX analysis const vixQuote = this.quotes['VIX']; if (vixQuote) { console.log('\n' + chalk.gray('─'.repeat(50))); const vixLevel = vixQuote.price; let vixStatus = ''; if (vixLevel < 15) vixStatus = chalk.green('Low volatility - Market calm'); else if (vixLevel < 20) vixStatus = chalk.yellow('Normal volatility'); else if (vixLevel < 30) vixStatus = chalk.red('Elevated volatility - Market stress'); else vixStatus = chalk.red.bold('High volatility - Extreme fear'); console.log(`VIX Analysis: ${vixStatus}`); } // Display Sectors console.log(chalk.yellow.bold('\nS&P 500 Sector Performance')); console.log('─'.repeat(50)); const sectorData = []; Object.entries(SECTOR_ETFS).forEach(([symbol, info]) => { if (symbol !== 'SPY') { // Skip SPY as it's in indices const quote = this.quotes[symbol]; if (quote) { sectorData.push({ symbol, name: info.name, icon: info.icon, changePercent: quote.changesPercentage || 0, price: quote.price }); } } }); // Sort sectors by performance sectorData.sort((a, b) => b.changePercent - a.changePercent); sectorData.forEach(sector => { const changeStr = this.formatPercent(sector.changePercent); const price = this.formatCurrency(sector.price); console.log( `${sector.icon} ${sector.symbol.padEnd(6)} ${sector.name.padEnd(25)} ${price.padStart(10)} ${changeStr.padStart(9)}` ); }); // Market breadth analysis if (sectorData.length > 0) { const gainingSectors = sectorData.filter(s => s.changePercent > 0).length; const losingSectors = sectorData.filter(s => s.changePercent < 0).length; console.log('\n' + chalk.gray('─'.repeat(50))); console.log(chalk.white('Market Breadth: ') + chalk.green(`${gainingSectors} sectors up`) + ' | ' + chalk.red(`${losingSectors} sectors down`)); // Sector rotation insights const strongest = sectorData[0]; const weakest = sectorData[sectorData.length - 1]; console.log(chalk.green(`Strongest: ${strongest.icon} ${strongest.name} (${strongest.changePercent >= 0 ? '+' : ''}${strongest.changePercent.toFixed(2)}%)`)); console.log(chalk.red(`Weakest: ${weakest.icon} ${weakest.name} (${weakest.changePercent >= 0 ? '+' : ''}${weakest.changePercent.toFixed(2)}%)`)); } } displayNavigation() { console.log(chalk.gray('\n' + '─'.repeat(70))); console.log(chalk.gray('MARKET: 1=Indices 2=Gainers 3=Losers 4=S&P500 8=Sectors 9=Overview')); console.log(chalk.gray('QUOTES: 5=Overview 6=Chart 7=Financials | S=Search')); console.log(chalk.gray('EXIT: Q=Quit | ESC=Exit | Ctrl+C=Force Exit')); console.log(chalk.gray(`Current Symbol: ${chalk.cyan(this.currentSymbol)} | Current View: ${chalk.yellow(this.currentView)}`)); } cleanup() { try { // Clear intervals if they exist if (this.marketDataInterval) clearInterval(this.marketDataInterval); if (this.displayInterval) clearInterval(this.displayInterval); // Restore normal input mode if (process.stdin.isTTY) { process.stdin.setRawMode(false); } // Force exit immediately process.exit(0); } catch (error) { // Ultra-aggressive exit if cleanup fails process.exit(1); } } formatCurrency(num) { if (num === null || num === undefined) return 'N/A'; return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(num); } formatPercent(num) { if (num === null || num === undefined) return 'N/A'; const sign = num >= 0 ? '+' : ''; const color = num > 0 ? chalk.green : num < 0 ? chalk.red : chalk.gray; const formatted = `${sign}${num.toFixed(2)}%`; if (Math.abs(num) >= 3) { return chalk.bold(color(formatted)); } return color(formatted); } formatVolume(num) { if (num === null || num === undefined) return 'N/A'; if (num >= 1e9) return (num / 1e9).toFixed(1) + 'B'; if (num >= 1e6) return (num / 1e6).toFixed(1) + 'M'; if (num >= 1e3) return (num / 1e3).toFixed(1) + 'K'; return num.toString(); } formatNumber(num) { if (num === null || num === undefined) return 'N/A'; return new Intl.NumberFormat('en-US').format(num); } formatLargeNumber(num) { if (num === null || num === undefined) return 'N/A'; if (Math.abs(num) >= 1e12) return `$${(num / 1e12).toFixed(2)}T`; if (Math.abs(num) >= 1e9) return `$${(num / 1e9).toFixed(2)}B`; if (Math.abs(num) >= 1e6) return `$${(num / 1e6).toFixed(2)}M`; if (Math.abs(num) >= 1e3) return `$${(num / 1e3).toFixed(2)}K`; return this.formatCurrency(num); } } module.exports = { run: (options) => { const app = new TenzettaUnified(options); app.run(); } };