import blessed from 'blessed'; import chalk from 'chalk'; import { storage } from './storage'; interface DashboardData { status: 'RUNNING' | 'STOPPED' | 'ERROR'; botAddress: string; copyAddress: string; balance: number; copiedBalance: number; tradesExecuted: number; tradesFailed: number; currentTrade?: { timestamp: string; type: 'BUY' | 'SELL'; outcome: string; market: string; shares: number; price: number; status: 'PENDING' | 'SUCCESS' | 'FAILED' | 'SIMULATED'; txHash?: string; }; recentTrades: Array<{ timestamp: string; type: 'BUY' | 'SELL'; outcome: string; shares: number; price: number; status: 'SUCCESS' | 'FAILED' | 'SIMULATED'; txHash?: string; }>; heartbeat: string; } export class TUIDashboard { private screen: blessed.Widgets.Screen; private headerBox: blessed.Widgets.BoxElement; private statsBox: blessed.Widgets.BoxElement; private currentTradeBox: blessed.Widgets.BoxElement; private windowSummaryBox: blessed.Widgets.BoxElement; private tradesHistoryBox: blessed.Widgets.BoxElement; private statusBox: blessed.Widgets.BoxElement; private maxHistory: number; private tradeQueue: Array<{ timestamp: string; type: 'BUY' | 'SELL'; outcome: string; market: string; shares: number; price: number; txHash?: string; }> = []; private data: DashboardData = { status: 'RUNNING', botAddress: '', copyAddress: '', balance: 0, copiedBalance: 0, tradesExecuted: 0, tradesFailed: 0, recentTrades: [], heartbeat: new Date().toLocaleTimeString(), }; constructor() { this.screen = blessed.screen({ mouse: true, title: '📈 Polymarket Trading Console', smartCSR: true, style: { focused: { border: { fg: 'cyan' }, }, }, }); // Clear any previous terminal output so the TUI has a clean canvas try { if (process.stdout && process.stdout.isTTY) { process.stdout.write('\x1b[2J\x1b[0f'); } } catch (e) { // ignore } // Create header this.headerBox = blessed.box({ parent: this.screen, top: 0, left: 0, right: 0, height: 3, tags: true, style: { bg: 'blue', fg: 'white', bold: true, }, padding: 1, }); // Create stats panel this.statsBox = blessed.box({ parent: this.screen, top: 3, left: 0, width: '50%', height: 10, tags: true, border: 'line', style: { border: { fg: 'cyan' }, fg: 'white', }, padding: 1, label: ' 💰 Account Stats ', }); // Create current trade panel this.currentTradeBox = blessed.box({ parent: this.screen, top: 3, right: 0, width: '50%', height: 10, tags: true, border: 'line', style: { border: { fg: 'green' }, fg: 'white', }, padding: 1, label: ' 🔄 Current Trade ', }); // Create 15-minute window summary panel (left column, under stats) this.windowSummaryBox = blessed.box({ parent: this.screen, top: 13, left: 0, width: '50%', height: 6, tags: true, border: 'line', style: { border: { fg: 'magenta' }, fg: 'white', }, padding: 1, label: ' 🕒 15m Window Summary ', }); // Create trades history panel (moved down to make space for window summary) this.tradesHistoryBox = blessed.box({ parent: this.screen, top: 19, left: 0, right: 0, bottom: 3, tags: true, border: 'line', style: { border: { fg: 'yellow' }, fg: 'white', }, padding: 1, label: ' 📜 Recent Trades ', scrollable: true, keys: true, mouse: true, alwaysScroll: true, }); // Create status/heartbeat panel this.statusBox = blessed.box({ parent: this.screen, bottom: 0, left: 0, right: 0, height: 3, tags: true, border: 'line', style: { border: { fg: 'magenta' }, fg: 'white', }, padding: 1, }); // Handle keyboard shortcuts this.screen.key(['escape', 'q', 'C-c'], () => { return process.exit(0); }); this.render(); // Determine sensible history size based on terminal rows so panel can fill try { const rows = (this.screen as any).rows || 24; // Keep at least 50 entries, but prefer screen-sized history (approximately rows * 2) this.maxHistory = Math.max(50, Math.floor(rows * 1.5)); } catch (e) { this.maxHistory = 100; } } updateStatus(status: 'RUNNING' | 'STOPPED' | 'ERROR') { this.data.status = status; } updateAddresses(botAddress: string, copyAddress: string) { this.data.botAddress = botAddress; this.data.copyAddress = copyAddress; } updateBalance(balance: number, copiedBalance: number, ratio: number) { this.data.balance = balance; this.data.copiedBalance = copiedBalance; } addTrade(trade: { timestamp: string; type: 'BUY' | 'SELL'; outcome: string; market: string; shares: number; price: number; txHash?: string; }) { // Add to queue to handle multiple trades per second this.tradeQueue.push(trade); // Always update currentTrade for display in the current trade panel this.data.currentTrade = { ...trade, status: 'PENDING', }; } processTradeQueue() { // Move all queued trades to recent trades history while (this.tradeQueue.length > 0) { const trade = this.tradeQueue.shift(); if (trade) { this.data.recentTrades.unshift({ timestamp: trade.timestamp, type: trade.type, outcome: trade.outcome, shares: trade.shares, price: trade.price, status: 'SIMULATED', txHash: trade.txHash, }); } } // Keep last N trades (screen-aware) so the history panel can fill the page if (this.data.recentTrades.length > (this.maxHistory || 100)) { this.data.recentTrades.splice(this.maxHistory || 100); } } updateTradeStatus(status: 'SUCCESS' | 'FAILED') { if (this.data.currentTrade) { // Allow SIMULATED flows by accepting a string and mapping accordingly if (status === 'SUCCESS') { this.data.currentTrade.status = 'SUCCESS'; this.data.tradesExecuted++; } else if (status === 'FAILED') { this.data.currentTrade.status = 'FAILED'; this.data.tradesFailed++; } else { // For any other status (e.g., SIMULATED), store as-is on the trade but don't increment counters // Cast to allow assignment when invoked with extended statuses (this.data.currentTrade as any).status = status as any; } // Move to history (allow SIMULATED to be recorded) this.data.recentTrades.unshift({ timestamp: this.data.currentTrade.timestamp, type: this.data.currentTrade.type, outcome: this.data.currentTrade.outcome, shares: this.data.currentTrade.shares, price: this.data.currentTrade.price, // If status is not SUCCESS/FAILED, keep it as-is (e.g., SIMULATED) status: (this.data.currentTrade as any).status as 'SUCCESS' | 'FAILED' | 'SIMULATED', txHash: this.data.currentTrade.txHash, }); // Keep last N trades (screen-aware) so the history panel can fill the page if (this.data.recentTrades.length > (this.maxHistory || 100)) { this.data.recentTrades.pop(); } this.data.currentTrade = undefined; } } updateHeartbeat() { this.data.heartbeat = new Date().toLocaleTimeString(); } render() { // Header this.headerBox.setContent( `{center}{bold}{cyan-fg}🤖 POLYMARKET COPY TRADING CONSOLE{/}{/}{/center}` ); // Stats panel const yourBalanceStr = this.data.balance.toFixed(2).padStart(12); const copyBalanceStr = this.data.copiedBalance.toFixed(2).padStart(12); const ratio = this.data.copiedBalance > 0 ? (this.data.balance / this.data.copiedBalance).toFixed(4) : '0.0000'; const statsContent = [ `{cyan-fg}Bot Address:{/} ${this.data.botAddress}`, `{cyan-fg}Copy Address:{/} ${this.data.copyAddress}`, '', `{bold}{green-fg}💰 Your Balance:{/}{/} ${yourBalanceStr} USDC`, `{bold}{yellow-fg}📈 Copy Balance:{/}{/} ${copyBalanceStr} USDC`, `{bold}{cyan-fg}📊 Ratio:{/}{/} ${ratio}`, `{dim}Executed: ${this.data.tradesExecuted} | Failed: ${this.data.tradesFailed}{/}`, ].join('\n'); this.statsBox.setContent(statsContent); // Current trade panel if (this.data.currentTrade) { const trade = this.data.currentTrade; const statusColor = trade.status === 'PENDING' ? 'yellow-fg' : trade.status === 'SUCCESS' ? 'green-fg' : trade.status === 'SIMULATED' ? 'cyan-fg' : 'red-fg'; const statusSymbol = trade.status === 'PENDING' ? '⏳' : trade.status === 'SUCCESS' ? '✓' : trade.status === 'SIMULATED' ? '~' : '✗'; const currentTradeContent = [ `${statusSymbol} {${statusColor}}${trade.status}{/} {white-fg}${trade.timestamp}{/}`, '', `{cyan-fg}${trade.type}{/} {bold}${trade.shares}{/} ${trade.outcome}`, `{white-fg}Price: ${trade.price.toFixed(4)} | Market: ${trade.market}{/}`, ].join('\n'); this.currentTradeBox.setContent(currentTradeContent); } else { this.currentTradeBox.setContent('{grey-fg}Waiting for next trade...{/}'); } // Recent trades history // 15-minute window summaries (current + previous 1 window) - fits in 6-line box try { const ts = Math.floor(Date.now() / 1000); const currentWindow = Math.floor(ts / 900) * 900; const lines: string[] = []; for (let i = 0; i < 2; i++) { const ws = currentWindow - i * 900; const summary = storage.getWindowSummary(ws); // Check both capitalized and original case for outcomes const upData = summary['Up'] || summary['up'] || summary['UP']; const downData = summary['Down'] || summary['down'] || summary['DOWN']; const up = upData ? `{bold}${upData.count.toFixed(2)}{/} @ ${upData.avgPrice?.toFixed(4)}` : '{dim}0{/}'; const down = downData ? `{bold}${downData.count.toFixed(2)}{/} @ ${downData.avgPrice?.toFixed(4)}` : '{dim}0{/}'; const label = new Date(ws * 1000).toLocaleTimeString(); lines.push(`{cyan-fg}{bold}Window:{/}{/} ${label}`); lines.push(`{green-fg}↑ Up:{/} ${up} {red-fg}↓ Down:{/} ${down}`); if (i < 1) lines.push(''); } if (lines.length === 0) { this.windowSummaryBox.setContent('{grey-fg}Waiting for trades...{/}'); } else { this.windowSummaryBox.setContent(lines.join('\n')); } } catch (e) { this.windowSummaryBox.setContent(`{grey-fg}Error loading window data{/}`); } if (this.data.recentTrades.length === 0) { this.tradesHistoryBox.setContent('{grey-fg}No trades yet{/}'); } else { const tradesContent = this.data.recentTrades .map((trade) => { const statusSymbol = trade.status === 'SUCCESS' ? '✓' : trade.status === 'SIMULATED' ? '~' : '✗'; const statusColor = trade.status === 'SUCCESS' ? 'green-fg' : trade.status === 'SIMULATED' ? 'cyan-fg' : 'red-fg'; const typeSymbol = trade.type === 'BUY' ? '▲' : '▼'; const typeColor = trade.type === 'BUY' ? 'green-fg' : 'red-fg'; const txId = trade.txHash ? trade.txHash.substring(0, 10) + '...' : 'N/A'; return ( `${statusSymbol} {${statusColor}}${trade.status}{/} | ` + `{${typeColor}}${typeSymbol} ${trade.type}{/} {bold}${trade.shares.toFixed(2)}{/} ${trade.outcome} @ ${trade.price.toFixed(4)} | ` + `{grey-fg}${trade.timestamp} | Tx: ${txId}{/}` ); }) .join('\n'); this.tradesHistoryBox.setContent(tradesContent); this.tradesHistoryBox.setScrollPerc(0); } // Status bar const statusIndicator = this.data.status === 'RUNNING' ? '{green-fg}●{/}' : this.data.status === 'STOPPED' ? '{yellow-fg}●{/}' : '{red-fg}●{/}'; this.statusBox.setContent( `${statusIndicator} ${this.data.status} | {cyan-fg}Heartbeat:{/} {white-fg}${this.data.heartbeat}{/} | {grey-fg}Press Q to exit{/}` ); this.screen.render(); } tick() { this.updateHeartbeat(); this.processTradeQueue(); this.render(); } startAutoRender(interval: number = 1000) { setInterval(() => this.tick(), interval); } } export const dashboard = new TUIDashboard();