/** * Visualization Module * * Terminal-based charts for equity curves, signals, and metrics * Uses ASCII art for compatibility across all terminals */ // Chart Configuration const chartConfig = { width: 80, height: 20, padding: { left: 10, right: 2, top: 1, bottom: 3 }, colors: { positive: '\x1b[32m', negative: '\x1b[31m', neutral: '\x1b[33m', reset: '\x1b[0m', dim: '\x1b[2m', bold: '\x1b[1m' } }; /** * ASCII Line Chart */ class LineChart { constructor(config = {}) { this.width = config.width || chartConfig.width; this.height = config.height || chartConfig.height; this.padding = { ...chartConfig.padding, ...config.padding }; } render(data, options = {}) { const { title = 'Chart', showGrid = true, colored = true } = options; if (!data || data.length === 0) return 'No data to display'; const plotWidth = this.width - this.padding.left - this.padding.right; const plotHeight = this.height - this.padding.top - this.padding.bottom; // Calculate min/max const values = data.map(d => typeof d === 'number' ? d : d.value); const min = Math.min(...values); const max = Math.max(...values); const range = max - min || 1; // Create canvas const canvas = []; for (let y = 0; y < this.height; y++) { canvas.push(new Array(this.width).fill(' ')); } // Draw title const titleStr = ` ${title} `; const titleStart = Math.floor((this.width - titleStr.length) / 2); for (let i = 0; i < titleStr.length; i++) { canvas[0][titleStart + i] = titleStr[i]; } // Draw Y-axis labels for (let y = 0; y < plotHeight; y++) { const value = max - (y / (plotHeight - 1)) * range; const label = this.formatNumber(value).padStart(this.padding.left - 1); for (let i = 0; i < label.length; i++) { canvas[this.padding.top + y][i] = label[i]; } canvas[this.padding.top + y][this.padding.left - 1] = '│'; } // Draw X-axis for (let x = this.padding.left; x < this.width - this.padding.right; x++) { canvas[this.height - this.padding.bottom][x] = '─'; } canvas[this.height - this.padding.bottom][this.padding.left - 1] = '└'; // Plot data points const step = Math.max(1, Math.floor(data.length / plotWidth)); let prevY = null; for (let i = 0; i < plotWidth && i * step < data.length; i++) { const idx = Math.min(i * step, data.length - 1); const value = values[idx]; const normalizedY = (max - value) / range; const y = Math.floor(normalizedY * (plotHeight - 1)); const x = this.padding.left + i; // Draw point const chartY = this.padding.top + y; if (chartY >= 0 && chartY < this.height) { if (prevY !== null && Math.abs(y - prevY) > 1) { // Draw connecting lines for gaps const startY = Math.min(y, prevY); const endY = Math.max(y, prevY); for (let cy = startY; cy <= endY; cy++) { const connectY = this.padding.top + cy; if (connectY >= 0 && connectY < this.height) { canvas[connectY][x - 1] = '│'; } } } canvas[chartY][x] = '●'; } prevY = y; } // Add grid if enabled if (showGrid) { for (let y = this.padding.top; y < this.height - this.padding.bottom; y += 4) { for (let x = this.padding.left; x < this.width - this.padding.right; x += 10) { if (canvas[y][x] === ' ') { canvas[y][x] = '·'; } } } } // Convert to string with colors let result = ''; const c = colored ? chartConfig.colors : { positive: '', negative: '', neutral: '', reset: '', dim: '', bold: '' }; for (let y = 0; y < this.height; y++) { let line = ''; for (let x = 0; x < this.width; x++) { const char = canvas[y][x]; if (char === '●') { const dataIdx = Math.floor((x - this.padding.left) * step); const value = values[Math.min(dataIdx, values.length - 1)]; const prevValue = dataIdx > 0 ? values[dataIdx - 1] : value; const color = value >= prevValue ? c.positive : c.negative; line += color + char + c.reset; } else if (char === '·') { line += c.dim + char + c.reset; } else { line += char; } } result += line + '\n'; } return result; } formatNumber(n) { if (Math.abs(n) >= 1000000) return (n / 1000000).toFixed(1) + 'M'; if (Math.abs(n) >= 1000) return (n / 1000).toFixed(1) + 'K'; return n.toFixed(Math.abs(n) < 10 ? 2 : 0); } } /** * Bar Chart (for returns, volume, etc.) */ class BarChart { constructor(config = {}) { this.width = config.width || 60; this.height = config.height || 15; this.barWidth = config.barWidth || 1; } render(data, options = {}) { const { title = 'Bar Chart', labels = [], colored = true } = options; if (!data || data.length === 0) return 'No data to display'; const values = data.map(d => typeof d === 'number' ? d : d.value); const maxVal = Math.max(...values.map(Math.abs)); const hasNegative = values.some(v => v < 0); const c = colored ? chartConfig.colors : { positive: '', negative: '', neutral: '', reset: '' }; let result = ''; // Title result += `\n ${title}\n`; result += ' ' + '─'.repeat(this.width) + '\n'; if (hasNegative) { // Diverging bar chart const midLine = Math.floor(this.height / 2); for (let y = 0; y < this.height; y++) { let line = ' '; const threshold = maxVal * (1 - (y / this.height) * 2); for (let i = 0; i < Math.min(values.length, this.width); i++) { const v = values[i]; const normalizedV = v / maxVal; if (y < midLine && v > 0 && normalizedV >= (midLine - y) / midLine) { line += c.positive + '█' + c.reset; } else if (y > midLine && v < 0 && Math.abs(normalizedV) >= (y - midLine) / midLine) { line += c.negative + '█' + c.reset; } else if (y === midLine) { line += '─'; } else { line += ' '; } } result += line + '\n'; } } else { // Standard bar chart for (let y = 0; y < this.height; y++) { let line = ' '; const threshold = maxVal * (1 - y / this.height); for (let i = 0; i < Math.min(values.length, this.width); i++) { const v = values[i]; if (v >= threshold) { line += c.positive + '█' + c.reset; } else { line += ' '; } } result += line + '\n'; } } // X-axis labels if (labels.length > 0) { result += ' ' + labels.slice(0, this.width).map(l => l[0] || ' ').join('') + '\n'; } return result; } } /** * Sparkline (inline mini chart) */ class Sparkline { static render(data, options = {}) { const { width = 20, colored = true } = options; const chars = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; if (!data || data.length === 0) return ''; const values = data.slice(-width); const min = Math.min(...values); const max = Math.max(...values); const range = max - min || 1; const c = colored ? chartConfig.colors : { positive: '', negative: '', reset: '' }; let result = ''; let prev = values[0]; for (const v of values) { const normalized = (v - min) / range; const idx = Math.min(Math.floor(normalized * chars.length), chars.length - 1); const color = v >= prev ? c.positive : c.negative; result += color + chars[idx] + c.reset; prev = v; } return result; } } /** * Table Renderer */ class Table { static render(data, options = {}) { const { headers = [], title = '' } = options; if (!data || data.length === 0) return 'No data'; // Calculate column widths const allRows = headers.length > 0 ? [headers, ...data] : data; const numCols = Math.max(...allRows.map(r => r.length)); const colWidths = new Array(numCols).fill(0); for (const row of allRows) { for (let i = 0; i < row.length; i++) { colWidths[i] = Math.max(colWidths[i], String(row[i]).length); } } const totalWidth = colWidths.reduce((a, b) => a + b, 0) + (numCols * 3) + 1; let result = ''; // Title if (title) { result += '\n ' + title + '\n'; } // Top border result += ' ┌' + colWidths.map(w => '─'.repeat(w + 2)).join('┬') + '┐\n'; // Headers if (headers.length > 0) { result += ' │'; for (let i = 0; i < numCols; i++) { const cell = String(headers[i] || '').padEnd(colWidths[i]); result += ` ${cell} │`; } result += '\n'; result += ' ├' + colWidths.map(w => '─'.repeat(w + 2)).join('┼') + '┤\n'; } // Data rows for (const row of data) { result += ' │'; for (let i = 0; i < numCols; i++) { const cell = String(row[i] || '').padEnd(colWidths[i]); result += ` ${cell} │`; } result += '\n'; } // Bottom border result += ' └' + colWidths.map(w => '─'.repeat(w + 2)).join('┴') + '┘\n'; return result; } } /** * Dashboard - Combines multiple visualizations */ class Dashboard { constructor(title = 'Trading Dashboard') { this.title = title; this.panels = []; } addPanel(content, options = {}) { this.panels.push({ content, ...options }); return this; } addEquityCurve(data, title = 'Equity Curve') { const chart = new LineChart({ width: 70, height: 12 }); return this.addPanel(chart.render(data, { title })); } addReturnsBar(returns, title = 'Daily Returns') { const chart = new BarChart({ width: 50, height: 8 }); return this.addPanel(chart.render(returns.slice(-50), { title })); } addMetricsTable(metrics) { const data = [ ['Total Return', `${(metrics.totalReturn * 100).toFixed(2)}%`], ['Sharpe Ratio', metrics.sharpeRatio.toFixed(2)], ['Max Drawdown', `${(metrics.maxDrawdown * 100).toFixed(2)}%`], ['Win Rate', `${(metrics.winRate * 100).toFixed(1)}%`], ['Profit Factor', metrics.profitFactor.toFixed(2)] ]; return this.addPanel(Table.render(data, { headers: ['Metric', 'Value'], title: 'Performance' })); } addSignals(signals) { const c = chartConfig.colors; let content = '\n SIGNALS\n ' + '─'.repeat(40) + '\n'; for (const [symbol, signal] of Object.entries(signals)) { const color = signal.direction === 'long' ? c.positive : signal.direction === 'short' ? c.negative : c.neutral; const arrow = signal.direction === 'long' ? '▲' : signal.direction === 'short' ? '▼' : '●'; content += ` ${color}${arrow}${c.reset} ${symbol.padEnd(6)} ${signal.direction.toUpperCase().padEnd(6)} `; content += `${(signal.strength * 100).toFixed(0)}% confidence\n`; } return this.addPanel(content); } render() { const c = chartConfig.colors; let result = '\n'; result += c.bold + '═'.repeat(80) + c.reset + '\n'; result += c.bold + ' '.repeat((80 - this.title.length) / 2) + this.title + c.reset + '\n'; result += c.bold + '═'.repeat(80) + c.reset + '\n'; for (const panel of this.panels) { result += panel.content; result += '\n'; } result += c.dim + '─'.repeat(80) + c.reset + '\n'; result += c.dim + `Generated at ${new Date().toLocaleString()}` + c.reset + '\n'; return result; } } /** * Quick visualization helpers */ const viz = { // Quick equity curve equity: (data, title = 'Equity Curve') => { const chart = new LineChart(); return chart.render(data, { title }); }, // Quick returns bar returns: (data, title = 'Returns') => { const chart = new BarChart(); return chart.render(data, { title }); }, // Inline sparkline spark: (data) => Sparkline.render(data), // Quick table table: (data, headers) => Table.render(data, { headers }), // Progress bar progress: (current, total, width = 30) => { const pct = current / total; const filled = Math.floor(pct * width); const empty = width - filled; return `[${'█'.repeat(filled)}${'░'.repeat(empty)}] ${(pct * 100).toFixed(1)}%`; }, // Status indicator status: (value, thresholds = { good: 0, warn: -0.05, bad: -0.1 }) => { const c = chartConfig.colors; if (value >= thresholds.good) return c.positive + '●' + c.reset; if (value >= thresholds.warn) return c.neutral + '●' + c.reset; return c.negative + '●' + c.reset; } }; export { LineChart, BarChart, Sparkline, Table, Dashboard, viz, chartConfig }; // Demo if run directly const isMainModule = import.meta.url === `file://${process.argv[1]}`; if (isMainModule) { console.log('═'.repeat(70)); console.log('VISUALIZATION MODULE DEMO'); console.log('═'.repeat(70)); // Generate sample data const equityData = [100000]; for (let i = 0; i < 100; i++) { equityData.push(equityData[i] * (1 + (Math.random() - 0.48) * 0.02)); } const returns = []; for (let i = 1; i < equityData.length; i++) { returns.push((equityData[i] - equityData[i-1]) / equityData[i-1]); } // Line chart const lineChart = new LineChart(); console.log(lineChart.render(equityData, { title: 'Portfolio Equity' })); // Sparkline console.log('Sparkline: ' + Sparkline.render(equityData.slice(-30))); console.log(); // Table console.log(Table.render([ ['AAPL', '+2.5%', '150.25', 'BUY'], ['MSFT', '-1.2%', '378.50', 'HOLD'], ['GOOGL', '+0.8%', '141.75', 'BUY'] ], { headers: ['Symbol', 'Change', 'Price', 'Signal'], title: 'Portfolio' })); // Dashboard const dashboard = new Dashboard('Trading Dashboard'); dashboard.addEquityCurve(equityData.slice(-50)); dashboard.addSignals({ AAPL: { direction: 'long', strength: 0.75 }, TSLA: { direction: 'short', strength: 0.60 }, MSFT: { direction: 'neutral', strength: 0.40 } }); console.log(dashboard.render()); }