- 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
425 lines
13 KiB
TypeScript
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();
|