tenzetta
Version:
Terminal-based stock market viewer with real-time data streaming
224 lines (184 loc) • 6.6 kB
JavaScript
const axios = require('axios');
const chalk = require('chalk');
const ora = require('ora');
const readline = require('readline');
const config = require('./config');
class QuoteViewer {
constructor(symbol) {
this.symbol = symbol.toUpperCase();
this.currentTab = 'overview';
this.data = {};
this.spinner = ora(`Loading data for ${this.symbol}...`).start();
}
async run() {
try {
// Setup keyboard input
if (process.stdin.isTTY) {
readline.emitKeypressEvents(process.stdin);
process.stdin.setRawMode(true);
this.setupKeyboardHandler();
}
// Load all data
await this.loadAllData();
this.spinner.stop();
// Start display
this.display();
// Handle shutdown
process.on('SIGINT', () => {
console.log(chalk.yellow('\n\nExiting...'));
if (process.stdin.isTTY) {
process.stdin.setRawMode(false);
}
process.exit(0);
});
} catch (error) {
this.spinner.fail(`Failed to load data for ${this.symbol}`);
console.error(chalk.red('Error:'), error.message);
process.exit(1);
}
}
async loadAllData() {
const promises = [
axios.get(`${config.MCP_SERVER_URL}${config.endpoints.quote(this.symbol)}`)
.then(res => this.data.quote = res.data)
.catch(() => this.data.quote = null),
axios.get(`${config.MCP_SERVER_URL}${config.endpoints.profile(this.symbol)}`)
.then(res => this.data.profile = res.data)
.catch(() => this.data.profile = null),
axios.get(`${config.MCP_SERVER_URL}${config.endpoints.news(this.symbol)}`)
.then(res => this.data.news = res.data)
.catch(() => this.data.news = null)
];
await Promise.all(promises);
}
setupKeyboardHandler() {
process.stdin.on('keypress', (str, key) => {
if (key.ctrl && key.name === 'c') {
if (process.stdin.isTTY) {
process.stdin.setRawMode(false);
}
process.exit(0);
}
switch(str) {
case '1':
this.currentTab = 'overview';
this.display();
break;
case '2':
this.currentTab = 'profile';
this.display();
break;
case '3':
this.currentTab = 'news';
this.display();
break;
}
});
}
display() {
console.clear();
const quote = this.data.quote;
if (!quote) {
console.log(chalk.red('No quote data available'));
return;
}
// Header
console.log(chalk.cyan.bold(`${quote.symbol} - ${quote.name || 'N/A'}`));
console.log(chalk.gray('─'.repeat(60)));
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(formatCurrency(quote.price))} ${changeColor(changeStr)}`);
console.log('');
// Tab content
switch(this.currentTab) {
case 'profile':
this.displayProfile();
break;
case 'news':
this.displayNews();
break;
default:
this.displayOverview();
}
console.log(chalk.gray('\nPress 1: Overview | 2: Profile | 3: News | Ctrl+C: Exit'));
}
displayOverview() {
const quote = this.data.quote;
console.log(chalk.yellow.bold('Overview\n'));
// Price metrics
console.log(`Volume: ${formatNumber(quote.volume)}`);
console.log(`Market Cap: ${formatNumber(quote.marketCap)}`);
console.log(`Avg Volume: ${formatNumber(quote.avgVolume)}`);
console.log('');
// Ranges
console.log(`Day Range: ${formatCurrency(quote.dayLow)} - ${formatCurrency(quote.dayHigh)}`);
console.log(`52W Range: ${formatCurrency(quote.yearLow)} - ${formatCurrency(quote.yearHigh)}`);
console.log('');
// Valuation
console.log(`P/E Ratio: ${quote.pe?.toFixed(2) || 'N/A'}`);
console.log(`EPS: ${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'}`);
}
displayProfile() {
const profile = this.data.profile;
console.log(chalk.yellow.bold('Company Profile\n'));
if (!profile || profile.length === 0) {
console.log(chalk.gray('No profile data available'));
return;
}
const company = Array.isArray(profile) ? profile[0] : profile;
console.log(`Industry: ${company.industry || 'N/A'}`);
console.log(`Sector: ${company.sector || 'N/A'}`);
console.log(`Country: ${company.country || 'N/A'}`);
console.log(`Exchange: ${company.exchangeShortName || 'N/A'}`);
console.log('');
console.log(`Employees: ${formatNumber(company.fullTimeEmployees)}`);
console.log(`Founded: ${company.ipoDate || 'N/A'}`);
console.log('');
if (company.description) {
console.log('Description:');
const desc = company.description.substring(0, 300);
console.log(chalk.gray(desc + (company.description.length > 300 ? '...' : '')));
}
if (company.website) {
console.log(`\nWebsite: ${chalk.blue.underline(company.website)}`);
}
}
displayNews() {
const news = this.data.news;
console.log(chalk.yellow.bold('Recent News\n'));
if (!news || !Array.isArray(news) || news.length === 0) {
console.log(chalk.gray('No news available'));
return;
}
news.slice(0, 8).forEach((article, i) => {
const date = new Date(article.publishedDate).toLocaleDateString();
console.log(`${(i + 1).toString().padStart(2)}. ${chalk.bold(article.title)}`);
console.log(` ${chalk.gray(date)} • ${chalk.blue(article.site || 'Unknown')}`);
if (article.text) {
const summary = article.text.substring(0, 120);
console.log(` ${chalk.dim(summary + (article.text.length > 120 ? '...' : ''))}`);
}
console.log('');
});
}
}
module.exports = {
run: async (symbol) => {
const viewer = new QuoteViewer(symbol);
viewer.run();
}
};
function formatCurrency(num) {
if (num === null || num === undefined) return 'N/A';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(num);
}
function formatNumber(num) {
if (num === null || num === undefined) return 'N/A';
return new Intl.NumberFormat('en-US').format(num);
}