Polymarket_Copy_Bot/src/tui-dashboard.ts
Alexis Bruneteau de52b1a65d Enhance TUI dashboard: add transaction IDs, fix window summary, and handle multiple trades per second
- Add transaction hash to trade display (first 10 chars + ...)
- Improve window summary rendering with better error handling and fallbacks
- Implement trade queue to handle multiple trades arriving per second
- Process queue on each render tick to display all trades
- Add processTradeQueue() method to move queued trades to recent trades
- Improve window summary display formatting with arrows and colors
2025-12-07 13:18:42 +01:00

425 lines
13 KiB
TypeScript

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();