vibe-stocks
Version:
Terminal-based stock market viewer with real-time data streaming
226 lines (190 loc) • 7.03 kB
JavaScript
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();
}
};