tenzetta
Version:
Terminal-based stock market viewer with real-time data streaming
1,134 lines (958 loc) • 41.9 kB
JavaScript
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();
}
};