UNPKG

vibe-stocks

Version:

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

226 lines (190 loc) 7.03 kB
const axios = require('axios'); const chalk = require('chalk'); const ora = require('ora'); const readline = require('readline'); const config = require('./config'); // Popular stocks to track const TOP_STOCKS = [ 'AAPL', 'MSFT', 'GOOGL', 'AMZN', 'NVDA', 'META', 'TSLA', 'BRK.B', 'JPM', 'JNJ', 'V', 'PG', 'UNH', 'MA', 'HD', 'DIS', 'ADBE', 'CRM', 'NFLX', 'PYPL', 'WMT', 'BAC', 'PFE', 'KO', 'CSCO', 'PEP', 'TMO', 'ABT', 'NKE', 'MCD', 'COST', 'VZ', 'INTC', 'AMD', 'BA', 'GS', 'UBER', 'SQ', 'SHOP', 'XOM', 'CVX', 'SPY', 'QQQ', 'DIA', 'IWM', 'COIN', 'ROKU' ]; class MarketViewer { constructor(options) { this.options = options; this.quotes = {}; this.spinner = ora('Loading market data...').start(); this.currentView = 'overview'; } async run() { try { // Setup keyboard input if (process.stdin.isTTY) { readline.emitKeypressEvents(process.stdin); process.stdin.setRawMode(true); this.setupKeyboardHandler(); } // Load initial data await this.loadMarketData(); this.spinner.stop(); // Start display this.display(); // Refresh interval setInterval(() => this.loadMarketData(), 30000); setInterval(() => this.display(), 2000); // Handle shutdown process.on('SIGINT', () => { console.log(chalk.yellow('\n\nExiting...')); process.exit(0); }); } catch (error) { this.spinner.fail('Failed to load market data'); console.error(chalk.red('Error:'), error.message); process.exit(1); } } async loadMarketData() { const limit = parseInt(this.options.top) || 50; const symbols = TOP_STOCKS.slice(0, limit); const batchSize = 10; for (let i = 0; i < symbols.length; i += batchSize) { const batch = symbols.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 < symbols.length) { await new Promise(resolve => setTimeout(resolve, 100)); } } } setupKeyboardHandler() { process.stdin.on('keypress', (str, key) => { if (key.ctrl && key.name === 'c') { process.exit(0); } switch(str) { case '1': this.currentView = 'overview'; this.display(); break; case '2': this.currentView = 'gainers'; this.display(); break; case '3': this.currentView = 'losers'; this.display(); break; } }); } display() { console.clear(); console.log(chalk.cyan.bold('Vibe Stocks - Market Overview')); console.log(chalk.gray(`Last Update: ${new Date().toLocaleTimeString()}\n`)); const quotes = Object.values(this.quotes).filter(q => q && q.price > 0); switch(this.currentView) { case 'gainers': this.displayGainers(quotes); break; case 'losers': this.displayLosers(quotes); break; default: this.displayOverview(quotes); } console.log(chalk.gray('\nPress 1: Overview | 2: Gainers | 3: Losers | Ctrl+C: Exit')); } displayOverview(quotes) { 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.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, 20).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(quotes) { 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(quotes) { 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 || ''}` ); }); } 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(); } } module.exports = { run: (options) => { const viewer = new MarketViewer(options); viewer.run(); } };