vibe-stocks
Version:
Terminal-based stock market viewer with real-time data streaming
164 lines (139 loc) • 4.69 kB
JavaScript
const WebSocket = require('ws');
const axios = require('axios');
const chalk = require('chalk');
const ora = require('ora');
const config = require('./config');
class StreamViewer {
constructor(symbols, options) {
this.symbols = symbols.length > 0 ? symbols : config.defaults.symbols;
this.options = options;
this.quotes = {};
this.ws = null;
this.spinner = ora('Connecting to market data...').start();
}
async run() {
try {
// Fetch initial quotes
await this.fetchInitialQuotes();
this.spinner.stop();
// Connect WebSocket
this.connectWebSocket();
// Start display loop
this.startDisplay();
// Handle graceful shutdown
process.on('SIGINT', () => {
console.log(chalk.yellow('\n\nShutting down...'));
if (this.ws) this.ws.close();
process.exit(0);
});
} catch (error) {
this.spinner.fail('Failed to connect to market data');
console.error(chalk.red('Error:'), error.message);
process.exit(1);
}
}
async fetchInitialQuotes() {
const promises = this.symbols.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;
}
});
}
connectWebSocket() {
this.ws = new WebSocket(config.MCP_WS_URL);
this.ws.on('open', () => {
this.symbols.forEach(symbol => {
this.ws.send(JSON.stringify({
type: 'subscribe',
symbol: symbol
}));
});
});
this.ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
if (message.type === 'quote' && message.data) {
this.quotes[message.data.symbol] = message.data;
}
} catch (error) {
// Ignore parse errors
}
});
this.ws.on('error', (error) => {
console.error(chalk.red('WebSocket error:'), error.message);
});
this.ws.on('close', () => {
setTimeout(() => this.connectWebSocket(), 5000);
});
}
startDisplay() {
const displayLoop = () => {
console.clear();
this.displayHeader();
this.displayQuotes();
this.displayFooter();
};
displayLoop();
setInterval(displayLoop, parseInt(this.options.interval) || config.defaults.updateInterval);
}
displayHeader() {
console.log(chalk.cyan.bold('Vibe Stocks - Real-Time Market Data'));
console.log(chalk.gray(`Last Update: ${new Date().toLocaleTimeString()}\n`));
}
displayQuotes() {
if (this.options.compact) {
this.displayCompact();
} else {
this.displayDetailed();
}
}
displayCompact() {
Object.values(this.quotes).forEach(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(
`${chalk.bold(quote.symbol.padEnd(6))} ${this.formatCurrency(quote.price).padStart(10)} ${changeColor(changeStr.padStart(8))}`
);
});
}
displayDetailed() {
Object.values(this.quotes).forEach(quote => {
const change = quote.changesPercentage || 0;
const changeColor = change > 0 ? chalk.green : change < 0 ? chalk.red : chalk.gray;
console.log(chalk.bold.white(`${quote.symbol} - ${quote.name || 'N/A'}`));
console.log(`Price: ${chalk.bold(this.formatCurrency(quote.price))} ${changeColor(`${change >= 0 ? '+' : ''}${change.toFixed(2)}%`)}`);
console.log(`Volume: ${this.formatNumber(quote.volume)} | Market Cap: ${this.formatNumber(quote.marketCap)}`);
console.log(`Day Range: ${this.formatCurrency(quote.dayLow)} - ${this.formatCurrency(quote.dayHigh)}`);
console.log('');
});
}
displayFooter() {
console.log(chalk.gray('\nPress Ctrl+C to exit'));
}
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);
}
formatNumber(num) {
if (num === null || num === undefined) return 'N/A';
return new Intl.NumberFormat('en-US').format(num);
}
}
module.exports = {
run: (symbols, options) => {
const viewer = new StreamViewer(symbols, options);
viewer.run();
}
};