360 lines
11 KiB
Python
360 lines
11 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
Terminal Dashboard - Persistent unified panel using Rich Live
|
|
"""
|
|
# pylint: disable=R0915,R0912
|
|
import logging
|
|
import threading
|
|
import time
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from rich.console import Console
|
|
from rich.live import Live
|
|
from rich.panel import Panel
|
|
from rich.table import Table
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class TerminalDashboard:
|
|
"""Unified persistent terminal dashboard"""
|
|
|
|
def __init__(self, console: Console = None):
|
|
self.console = console or Console()
|
|
self.live: Optional[Live] = None
|
|
|
|
# Config state
|
|
self.mode = "live"
|
|
self.config_name = ""
|
|
self.host = "0.0.0.0"
|
|
self.port = 8765
|
|
self.poll_interval = 10
|
|
self.trigger_time = "now"
|
|
self.mock = False
|
|
self.enable_memory = False
|
|
self.local_time = ""
|
|
self.nyse_time = ""
|
|
self.start_date = ""
|
|
self.end_date = ""
|
|
self.tickers: List[str] = []
|
|
self.initial_cash = 100000.0
|
|
self.data_sources: Dict[str, Any] = {}
|
|
|
|
# Trading state
|
|
self.current_date = "-"
|
|
self.status = "Initializing"
|
|
self.total_value = 0.0
|
|
self.cash = 0.0
|
|
self.pnl_pct = 0.0
|
|
self.holdings: List[Dict] = []
|
|
self.trades: List[Dict] = []
|
|
self.days_completed = 0
|
|
self.days_total = 0
|
|
|
|
# Progress message (last line)
|
|
self.progress = ""
|
|
self._dots_index = 0
|
|
self._animator_running = False
|
|
self._animator_thread: Optional[threading.Thread] = None
|
|
|
|
def set_config(
|
|
self,
|
|
mode: str,
|
|
config_name: str,
|
|
host: str,
|
|
port: int,
|
|
poll_interval: int,
|
|
trigger_time: str = "now",
|
|
mock: bool = False,
|
|
enable_memory: bool = False,
|
|
local_time: str = "",
|
|
nyse_time: str = "",
|
|
start_date: str = "",
|
|
end_date: str = "",
|
|
tickers: List[str] = None,
|
|
initial_cash: float = 100000.0,
|
|
data_sources: Dict[str, Any] = None,
|
|
):
|
|
"""Set configuration state"""
|
|
self.mode = mode
|
|
self.config_name = config_name
|
|
self.host = host
|
|
self.port = port
|
|
self.poll_interval = poll_interval
|
|
self.trigger_time = trigger_time
|
|
self.mock = mock
|
|
self.enable_memory = enable_memory
|
|
self.local_time = local_time
|
|
self.nyse_time = nyse_time
|
|
self.start_date = start_date
|
|
self.end_date = end_date
|
|
self.tickers = tickers or []
|
|
self.initial_cash = initial_cash
|
|
self.data_sources = data_sources or {}
|
|
self.total_value = initial_cash
|
|
self.cash = initial_cash
|
|
|
|
def _build_panel(self) -> Panel:
|
|
"""Build the unified dashboard panel"""
|
|
# Main grid
|
|
main_table = Table.grid(padding=(0, 2))
|
|
main_table.add_column(width=28)
|
|
main_table.add_column(width=22)
|
|
main_table.add_column(width=22)
|
|
|
|
# Left: Config + Status
|
|
left = Table.grid(padding=(0, 0))
|
|
left.add_column()
|
|
|
|
# Mode line
|
|
if self.mode == "backtest":
|
|
mode_str = "[cyan]Backtest[/cyan]"
|
|
elif self.mock:
|
|
mode_str = "[yellow]MOCK[/yellow]"
|
|
else:
|
|
mode_str = "[green]LIVE[/green]"
|
|
|
|
left.add_row(f"[bold]Mode:[/bold] {mode_str}")
|
|
left.add_row(f"[dim]Config:[/dim] {self.config_name}")
|
|
left.add_row(f"[dim]Server:[/dim] {self.host}:{self.port}")
|
|
preferred_sources = self.data_sources.get("preferred", [])
|
|
if preferred_sources:
|
|
left.add_row(
|
|
f"[dim]Data:[/dim] {' -> '.join(preferred_sources)}",
|
|
)
|
|
|
|
if self.mode == "live" and self.nyse_time:
|
|
left.add_row(f"[dim]NYSE:[/dim] {self.nyse_time[:19]}")
|
|
trigger_display = (
|
|
"[green]NOW[/green]"
|
|
if self.trigger_time == "now"
|
|
else self.trigger_time
|
|
)
|
|
left.add_row(f"[dim]Trigger:[/dim] {trigger_display}")
|
|
|
|
# Status
|
|
left.add_row("")
|
|
status_style = "green" if self.status == "Running" else "yellow"
|
|
left.add_row(
|
|
"[bold]Status:[/bold] "
|
|
f"[{status_style}]{self.status}[/{status_style}]",
|
|
)
|
|
if self.mode == "backtest":
|
|
left.add_row(
|
|
f"[dim]Backtesting Period:[/dim] {self.days_total} days\n"
|
|
f" {self.start_date} -> {self.end_date}",
|
|
)
|
|
left.add_row(f"[dim]Current Date:[/dim] {self.current_date}")
|
|
|
|
# Middle: Portfolio
|
|
mid = Table.grid(padding=(0, 0))
|
|
mid.add_column()
|
|
|
|
pnl_style = "green" if self.pnl_pct >= 0 else "red"
|
|
mid.add_row("[bold]Portfolio[/bold]")
|
|
mid.add_row(f"NAV: [bold]${self.total_value:,.0f}[/bold]")
|
|
mid.add_row(f"Cash: ${self.cash:,.0f}")
|
|
mid.add_row(f"P&L: [{pnl_style}]{self.pnl_pct:+.2f}%[/{pnl_style}]")
|
|
|
|
# Positions
|
|
mid.add_row("")
|
|
mid.add_row("[bold]Positions[/bold]")
|
|
stock_holdings = [
|
|
h for h in self.holdings if h.get("ticker") != "CASH"
|
|
]
|
|
if stock_holdings:
|
|
for h in stock_holdings[:7]:
|
|
qty = h.get("quantity", 0)
|
|
ticker = h.get("ticker", "")[:5]
|
|
val = h.get("marketValue", 0)
|
|
qty_str = f"{qty:+d}" if qty != 0 else "0"
|
|
mid.add_row(
|
|
f"[cyan]{ticker:<5}[/cyan] {qty_str:>5} ${val:>7,.0f}",
|
|
)
|
|
if len(stock_holdings) > 7:
|
|
mid.add_row(f"[dim]+{len(stock_holdings) - 7} more[/dim]")
|
|
else:
|
|
mid.add_row("[dim]No positions[/dim]")
|
|
|
|
# Right: Recent Trades
|
|
right = Table.grid(padding=(0, 0))
|
|
right.add_column()
|
|
|
|
right.add_row("[bold]Recent Trades[/bold]")
|
|
if self.trades:
|
|
for t in self.trades[:10]:
|
|
side = t.get("side", "")
|
|
ticker = t.get("ticker", "")[:5]
|
|
qty = t.get("qty", 0)
|
|
if side == "LONG":
|
|
side_str = "[green]L[/green]"
|
|
elif side == "SHORT":
|
|
side_str = "[red]S[/red]"
|
|
else:
|
|
side_str = "[dim]H[/dim]"
|
|
right.add_row(f"{side_str} [cyan]{ticker:<5}[/cyan] {qty:>4}")
|
|
if len(self.trades) > 10:
|
|
right.add_row(f"[dim]+{len(self.trades) - 10} more[/dim]")
|
|
else:
|
|
right.add_row("[dim]No trades[/dim]")
|
|
|
|
main_table.add_row(left, mid, right)
|
|
|
|
# Outer table to add progress line at bottom
|
|
outer = Table.grid(padding=(0, 0))
|
|
outer.add_column()
|
|
outer.add_row(main_table)
|
|
|
|
# Progress line (last row) with animated dots
|
|
if self.progress:
|
|
DOTS_FRAMES = [" ", ". ", ".. ", "..."]
|
|
dots = DOTS_FRAMES[self._dots_index % len(DOTS_FRAMES)]
|
|
outer.add_row("")
|
|
outer.add_row(f"[dim]> {self.progress}{dots}[/dim]")
|
|
|
|
# Build panel
|
|
title = "[bold cyan]EvoTraders[/bold cyan]"
|
|
if self.mode == "backtest":
|
|
title += " [dim]Backtest[/dim]"
|
|
elif self.mock:
|
|
title += " [dim]Mock[/dim]"
|
|
else:
|
|
title += " [dim]Live[/dim]"
|
|
|
|
return Panel(
|
|
outer,
|
|
title=title,
|
|
border_style="cyan",
|
|
padding=(0, 1),
|
|
)
|
|
|
|
def _run_animator(self):
|
|
"""Background thread to animate the dots"""
|
|
while self._animator_running:
|
|
time.sleep(0.3)
|
|
if self.progress and self.live:
|
|
self._dots_index += 1
|
|
self.live.update(self._build_panel())
|
|
|
|
def start(self):
|
|
"""Start the live dashboard display"""
|
|
self.live = Live(
|
|
self._build_panel(),
|
|
console=self.console,
|
|
refresh_per_second=4,
|
|
vertical_overflow="visible",
|
|
)
|
|
self.live.start()
|
|
|
|
# Start animator thread
|
|
self._animator_running = True
|
|
self._animator_thread = threading.Thread(
|
|
target=self._run_animator,
|
|
daemon=True,
|
|
)
|
|
self._animator_thread.start()
|
|
|
|
def stop(self):
|
|
"""Stop the live dashboard"""
|
|
self._animator_running = False
|
|
if self._animator_thread:
|
|
self._animator_thread.join(timeout=0.5)
|
|
self._animator_thread = None
|
|
if self.live:
|
|
self.live.stop()
|
|
self.live = None
|
|
|
|
def update(
|
|
self,
|
|
date: str = None,
|
|
status: str = None,
|
|
portfolio: Dict[str, Any] = None,
|
|
holdings: List[Dict] = None,
|
|
trades: List[Dict] = None,
|
|
days_completed: int = None,
|
|
days_total: int = None,
|
|
data_sources: Dict[str, Any] = None,
|
|
):
|
|
"""Update dashboard state and refresh display"""
|
|
if date:
|
|
self.current_date = date
|
|
if status:
|
|
self.status = status
|
|
if days_completed is not None:
|
|
self.days_completed = days_completed
|
|
if days_total is not None:
|
|
self.days_total = days_total
|
|
|
|
if portfolio:
|
|
self.total_value = portfolio.get(
|
|
"totalAssetValue",
|
|
0,
|
|
) or portfolio.get(
|
|
"total_value",
|
|
self.initial_cash,
|
|
)
|
|
self.cash = portfolio.get("cashPosition", 0) or portfolio.get(
|
|
"cash",
|
|
self.initial_cash,
|
|
)
|
|
if self.total_value > 0 and self.initial_cash > 0:
|
|
self.pnl_pct = (
|
|
(self.total_value - self.initial_cash) / self.initial_cash
|
|
) * 100
|
|
|
|
if holdings is not None:
|
|
self.holdings = holdings
|
|
if trades is not None:
|
|
self.trades = trades
|
|
if data_sources is not None:
|
|
self.data_sources = data_sources
|
|
|
|
if self.live:
|
|
self.live.update(self._build_panel())
|
|
|
|
def log(self, msg: str, also_log: bool = True):
|
|
"""
|
|
Update progress message and refresh panel
|
|
|
|
Args:
|
|
msg: Progress message to display
|
|
also_log: Whether to also write to logger (default True)
|
|
"""
|
|
self.progress = msg
|
|
if also_log:
|
|
logger.info(msg)
|
|
if self.live:
|
|
self.live.update(self._build_panel())
|
|
|
|
def print_final_summary(self):
|
|
"""Print final summary when dashboard stops"""
|
|
pnl_style = "green" if self.pnl_pct >= 0 else "red"
|
|
|
|
if self.mode == "backtest":
|
|
msg = (
|
|
f"[bold]Backtest Complete[/bold] | "
|
|
f"Days: {self.days_completed} | "
|
|
f"NAV: ${self.total_value:,.0f} | "
|
|
f"Return: [{pnl_style}]{self.pnl_pct:+.2f}%[/{pnl_style}]"
|
|
)
|
|
else:
|
|
msg = (
|
|
f"[bold]Session End[/bold] | "
|
|
f"NAV: ${self.total_value:,.0f} | "
|
|
f"P&L: [{pnl_style}]{self.pnl_pct:+.2f}%[/{pnl_style}]"
|
|
)
|
|
|
|
self.console.print(Panel(msg, border_style="green"))
|
|
|
|
|
|
# Global instance
|
|
_dashboard: Optional[TerminalDashboard] = None
|
|
|
|
|
|
def get_dashboard() -> TerminalDashboard:
|
|
"""Get or create global dashboard instance"""
|
|
global _dashboard
|
|
if _dashboard is None:
|
|
_dashboard = TerminalDashboard()
|
|
return _dashboard
|