"""Rich console output for paper trading status, positions, trades, and equity.""" from __future__ import annotations import datetime as dt from typing import Any from rich import box from rich.console import Console from rich.panel import Panel from rich.table import Table from rich.text import Text from apps.paper_trader.alpaca_broker import AlpacaBroker, Position from apps.paper_trader.state import SessionRow, StateManager, StrategyStateRow _console = Console(width=140) def _pnl_color(val: float) -> str: return "green" if val >= 0 else "red" def _fmt_pct(val: float) -> str: sign = "+" if val >= 0 else "" return f"{sign}{val:.2f}%" def _fmt_pnl(val: float) -> str: sign = "+" if val >= 0 else "" return f"{sign}${val:,.2f}" # ------------------------------------------------------------------ # # Session status # ------------------------------------------------------------------ # def print_status( session: SessionRow, broker: AlpacaBroker, state: StateManager, ) -> None: account = broker.get_account() session_st = state.get_session_state(session.session_id) snapshots = state.list_snapshots(session.session_id) initial_equity = session.initial_equity # Use latest snapshot equity if available, otherwise fall back to initial if snapshots: current_equity = snapshots[-1]["equity"] else: current_equity = initial_equity total_pnl = current_equity - initial_equity total_pnl_pct = total_pnl / initial_equity * 100 if initial_equity else 0.0 peak_equity = state.get_peak_equity(session.session_id, initial_equity) drawdown_pct = max(0.0, (peak_equity - current_equity) / peak_equity * 100) if peak_equity > 0 else 0.0 positions = broker.list_positions() kill_status = "[bold red]ON[/]" if session_st.kill_switch_triggered else "[green]OFF[/]" cooldown_str = f"{session_st.cooldown_remaining}d" if session_st.cooldown_remaining > 0 else "none" pnl_style = _pnl_color(total_pnl) _console.print() _console.print(Panel( f"[bold cyan]Session:[/] {session.session_name} " f"[dim]|[/] [bold]Config:[/] {session.config_path} " f"[dim]|[/] [bold]Status:[/] {session.status}", border_style="cyan", padding=(0, 1), )) tbl = Table(box=box.SIMPLE, show_header=False, padding=(0, 2)) tbl.add_column("Key", style="bold yellow", no_wrap=True) tbl.add_column("Value", no_wrap=True) tbl.add_row("Equity", f"[bold]${current_equity:,.2f}[/] [{pnl_style}]{_fmt_pnl(total_pnl)} ({_fmt_pct(total_pnl_pct)})[/]") tbl.add_row("Cash", f"${account.cash:,.2f}") tbl.add_row("Market Value", f"${account.long_market_value:,.2f}") tbl.add_row("Open Positions", str(len(positions))) tbl.add_row("Drawdown", f"[{'red' if drawdown_pct > 5 else 'green'}]{drawdown_pct:.2f}%[/]") tbl.add_row("Consecutive Losses", str(session_st.consecutive_losses)) tbl.add_row("Cooldown", cooldown_str) tbl.add_row("Kill Switch", kill_status) tbl.add_row("Total Trades", str(len(state.list_trades(session.session_id)))) if snapshots: tbl.add_row("Last Processed", snapshots[-1]["date"]) _console.print(tbl) _console.print() # ------------------------------------------------------------------ # # Positions # ------------------------------------------------------------------ # def print_positions( session: SessionRow, broker: AlpacaBroker, state: StateManager, ) -> None: positions = broker.list_positions() strategy_states = { ss.symbol: ss for ss in state.get_open_strategy_states(session.session_id) } if not positions: _console.print("[dim]No open positions.[/]") return tbl = Table( box=box.SIMPLE_HEAD, show_header=True, header_style="bold yellow", padding=(0, 1), title=f"[bold cyan]Open Positions[/] — {session.session_name}", title_justify="left", ) tbl.add_column("Symbol", style="bold", no_wrap=True) tbl.add_column("Qty", justify="right") tbl.add_column("Entry", justify="right") tbl.add_column("Current", justify="right") tbl.add_column("P&L $", justify="right") tbl.add_column("P&L %", justify="right") tbl.add_column("Days", justify="right") tbl.add_column("Stop", justify="right") tbl.add_column("Target", justify="right") for pos in sorted(positions, key=lambda p: p.symbol): ss = strategy_states.get(pos.symbol) stop_str = f"${ss.current_stop:.2f}" if ss else "-" target_str = f"${ss.target_price:.2f}" if ss else "-" days_str = str(ss.days_held) if ss else "-" pnl = pos.unrealized_pl pnl_pct = pnl / (pos.avg_entry_price * pos.qty) * 100 if pos.avg_entry_price and pos.qty else 0.0 pnl_color = _pnl_color(pnl) tbl.add_row( pos.symbol, str(pos.qty), f"${pos.avg_entry_price:.2f}", f"${pos.current_price:.2f}", f"[{pnl_color}]{_fmt_pnl(pnl)}[/]", f"[{pnl_color}]{_fmt_pct(pnl_pct)}[/]", days_str, stop_str, target_str, ) _console.print() _console.print(tbl) _console.print() # ------------------------------------------------------------------ # # Trades # ------------------------------------------------------------------ # def print_trades( session: SessionRow, state: StateManager, last: int | None = None, ) -> None: trades = state.list_trades(session.session_id, limit=last) if not trades: _console.print("[dim]No trades recorded yet.[/]") return tbl = Table( box=box.SIMPLE_HEAD, show_header=True, header_style="bold yellow", padding=(0, 1), title=f"[bold cyan]Trades[/] — {session.session_name}", title_justify="left", ) tbl.add_column("Symbol", style="bold", no_wrap=True) tbl.add_column("Entry Date", no_wrap=True) tbl.add_column("Exit Date", no_wrap=True) tbl.add_column("Entry $", justify="right") tbl.add_column("Exit $", justify="right") tbl.add_column("Shares", justify="right") tbl.add_column("Net P&L", justify="right") tbl.add_column("R", justify="right") tbl.add_column("Days", justify="right") tbl.add_column("Reason", style="dim") for t in trades: pnl = t.get("net_pnl") or 0.0 r = t.get("r_multiple") or 0.0 pnl_color = _pnl_color(pnl) r_color = _pnl_color(r) tbl.add_row( str(t.get("symbol", "")), str(t.get("entry_date", "-")), str(t.get("exit_date", "-")), f"${t.get('entry_price') or 0:.2f}", f"${t.get('exit_price') or 0:.2f}", str(t.get("shares", "-")), f"[{pnl_color}]{_fmt_pnl(pnl)}[/]", f"[{r_color}]{r:+.2f}R[/]", str(t.get("holding_days", "-")), str(t.get("exit_reason", "-")), ) _console.print() _console.print(tbl) _console.print() # ------------------------------------------------------------------ # # Equity curve (ASCII sparkline) # ------------------------------------------------------------------ # def print_equity( session: SessionRow, state: StateManager, ) -> None: snapshots = state.list_snapshots(session.session_id) if not snapshots: _console.print("[dim]No equity history yet.[/]") return tbl = Table( box=box.SIMPLE_HEAD, show_header=True, header_style="bold yellow", padding=(0, 1), title=f"[bold cyan]Equity Curve[/] — {session.session_name}", title_justify="left", ) tbl.add_column("Date", no_wrap=True) tbl.add_column("Equity", justify="right") tbl.add_column("Daily P&L", justify="right") tbl.add_column("Total P&L", justify="right") tbl.add_column("Drawdown", justify="right") tbl.add_column("Positions", justify="right") for snap in snapshots: equity = snap.get("equity", 0.0) daily_pnl = snap.get("daily_pnl") or 0.0 total_pnl = snap.get("total_pnl") or 0.0 dd = snap.get("drawdown_pct") or 0.0 n_pos = snap.get("open_position_count") daily_color = _pnl_color(daily_pnl) total_color = _pnl_color(total_pnl) dd_color = "red" if dd > 5 else "green" tbl.add_row( str(snap.get("date", "-")), f"${equity:,.2f}", f"[{daily_color}]{_fmt_pnl(daily_pnl)}[/]", f"[{total_color}]{_fmt_pnl(total_pnl)}[/]", f"[{dd_color}]{dd:.2f}%[/]", str(n_pos) if n_pos is not None else "-", ) _console.print() _console.print(tbl) _console.print() # ------------------------------------------------------------------ # # Sessions list # ------------------------------------------------------------------ # def print_sessions(sessions: list[SessionRow]) -> None: if not sessions: _console.print("[dim]No sessions found.[/]") return tbl = Table( box=box.SIMPLE_HEAD, show_header=True, header_style="bold yellow", padding=(0, 1), title="[bold cyan]Paper Trading Sessions[/]", title_justify="left", ) tbl.add_column("ID", style="dim", no_wrap=True) tbl.add_column("Name", style="bold", no_wrap=True) tbl.add_column("Config", no_wrap=True) tbl.add_column("Capital", justify="right") tbl.add_column("Status", no_wrap=True) tbl.add_column("Created", no_wrap=True) for s in sessions: status_color = "green" if s.status == "active" else "dim" tbl.add_row( s.session_id, s.session_name, s.config_path, f"${s.initial_equity:,.0f}", f"[{status_color}]{s.status}[/]", s.created_at[:10], ) _console.print() _console.print(tbl) _console.print() # ------------------------------------------------------------------ # # Run summary # ------------------------------------------------------------------ # def _print_overlay_detail(r: dict) -> None: """Print overlay regime allocation + per-book summary.""" name = r["session_name"] # Regime day counts regime_counts = r.get("regime_day_counts", {}) if regime_counts: regime_tbl = Table( box=box.SIMPLE_HEAD, show_header=True, header_style="bold yellow", padding=(0, 1), title=f"[bold magenta]Regime Days — {name}[/]", title_justify="left", ) regime_tbl.add_column("Regime", style="bold") regime_tbl.add_column("Days", justify="right") regime_tbl.add_column("Allocation", no_wrap=True) allocations = r.get("allocations", {}) for regime, count in sorted(regime_counts.items()): alloc = allocations.get(regime, {}) alloc_str = " ".join(f"{k}={v:.0%}" for k, v in alloc.items()) regime_tbl.add_row(regime, str(count), alloc_str) _console.print(regime_tbl) # Per-book summary book_results = r.get("book_results", []) if book_results: book_tbl = Table( box=box.SIMPLE_HEAD, show_header=True, header_style="bold yellow", padding=(0, 1), title=f"[bold magenta]Books — {name}[/]", title_justify="left", ) book_tbl.add_column("Book", style="bold") book_tbl.add_column("Return", justify="right") book_tbl.add_column("MaxDD", justify="right") book_tbl.add_column("Trades", justify="right") book_tbl.add_column("WinRate", justify="right") book_tbl.add_column("Sharpe", justify="right") for br in book_results: bs = br["result"]["summary"] ret_color = "green" if bs["return_pct"] >= 0 else "red" book_tbl.add_row( br["label"], f"[{ret_color}]{bs['return_pct']:+.2f}%[/{ret_color}]", f"[red]-{bs['max_dd_pct']:.2f}%[/]", str(bs["trade_count"]), f"{bs['win_rate']:.0f}%", f"{bs['sharpe']:+.2f}", ) _console.print(book_tbl) def print_backtest_results(results: list[dict], output_dir: str | None = None, show_trades: bool = True) -> None: """Print equity curve comparison, summary table, and per-strategy trade logs.""" import csv import os if not results: _console.print("[dim]No backtest results.[/]") return # ── Summary table ────────────────────────────────────────────────────── sum_tbl = Table( box=box.SIMPLE_HEAD, show_header=True, header_style="bold yellow", padding=(0, 1), title="[bold cyan]Summary[/]", title_justify="left", ) sum_tbl.add_column("Strategy", style="bold", no_wrap=True) sum_tbl.add_column("Return", justify="right") sum_tbl.add_column("MaxDD", justify="right") sum_tbl.add_column("Trades", justify="right") sum_tbl.add_column("WinRate", justify="right") sum_tbl.add_column("Sharpe", justify="right") for r in results: s = r["summary"] ret_color = "green" if s["return_pct"] >= 0 else "red" name = r["session_name"] if r.get("is_overlay"): name = f"{name} [overlay]" sum_tbl.add_row( name, f"[{ret_color}]{s['return_pct']:+.2f}%[/{ret_color}]", f"[red]-{s['max_dd_pct']:.2f}%[/]", str(s["trade_count"]), f"{s['win_rate']:.0f}%" if s.get("win_rate") else "-", f"{s['sharpe']:+.2f}", ) _console.print(sum_tbl) # ── Overlay detail sections ─────────────────────────────────────────── for r in results: if not r.get("is_overlay"): continue _print_overlay_detail(r) # ── Per-strategy trade logs ──────────────────────────────────────────── if not show_trades: if output_dir: import csv as _csv os.makedirs(output_dir, exist_ok=True) for r in results: name = r["session_name"] eq_path = os.path.join(output_dir, f"{name}_equity.csv") with open(eq_path, "w", newline="") as f: w = _csv.DictWriter(f, fieldnames=["date", "equity"]) w.writeheader() for row in r["equity_curve"]: w.writerow({"date": row["date"].isoformat(), "equity": row["equity"]}) trades_path = os.path.join(output_dir, f"{name}_trades.csv") if r.get("trades"): with open(trades_path, "w", newline="") as f: fieldnames = ["symbol", "event_type", "score", "entry_date", "exit_date", "shares", "entry_price", "exit_price", "pnl", "reason"] w = _csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore") w.writeheader() w.writerows(r["trades"]) _console.print(f"\n[dim]Results saved to {output_dir}[/]") return for r in results: trades = r.get("trades", []) if not trades: _console.print(f" [dim]{r['session_name']}: no completed trades[/]") continue tbl = Table( box=box.SIMPLE_HEAD, show_header=True, header_style="bold yellow", padding=(0, 1), title=f"[bold cyan]Trades — {r['session_name']}[/]", title_justify="left", ) tbl.add_column("Symbol", style="bold", no_wrap=True) tbl.add_column("EventType", no_wrap=True) tbl.add_column("Score", justify="right") tbl.add_column("Entry", no_wrap=True) tbl.add_column("Exit", no_wrap=True) tbl.add_column("Days", justify="right") tbl.add_column("Shares", justify="right") tbl.add_column("EntryPx", justify="right") tbl.add_column("ExitPx", justify="right") tbl.add_column("P&L", justify="right") tbl.add_column("Reason", style="dim") for t in sorted(trades, key=lambda x: x.get("entry_date", "")): pnl = t.get("pnl", 0.0) color = _pnl_color(pnl) entry_d = t.get("entry_date", "") exit_d = t.get("exit_date", "") try: import datetime as _dt holding_days = (_dt.date.fromisoformat(str(exit_d)) - _dt.date.fromisoformat(str(entry_d))).days if entry_d and exit_d else "-" except Exception: holding_days = "-" score = t.get("score") score_str = f"{score:.2f}" if score is not None else "-" entry_px = t.get("entry_price") exit_px = t.get("exit_price") entry_px_str = f"${entry_px:.2f}" if entry_px is not None else "-" exit_px_str = f"${exit_px:.2f}" if exit_px is not None else "-" tbl.add_row( t.get("symbol", ""), t.get("event_type", "-"), score_str, str(entry_d) if entry_d else "-", str(exit_d) if exit_d else "-", str(holding_days), str(t.get("shares", "-")), entry_px_str, exit_px_str, f"[{color}]{_fmt_pnl(pnl)}[/{color}]", t.get("reason", "-"), ) _console.print(tbl) # ── Optional CSV save ────────────────────────────────────────────────── if output_dir: os.makedirs(output_dir, exist_ok=True) for r in results: name = r["session_name"] eq_path = os.path.join(output_dir, f"{name}_equity.csv") with open(eq_path, "w", newline="") as f: w = csv.DictWriter(f, fieldnames=["date", "equity"]) w.writeheader() for row in r["equity_curve"]: w.writerow({"date": row["date"].isoformat(), "equity": row["equity"]}) trades_path = os.path.join(output_dir, f"{name}_trades.csv") if r.get("trades"): with open(trades_path, "w", newline="") as f: fieldnames = ["symbol", "event_type", "score", "entry_date", "exit_date", "shares", "entry_price", "exit_price", "pnl", "reason"] w = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore") w.writeheader() w.writerows(r["trades"]) _console.print(f"\n[dim]Results saved to {output_dir}[/]") def print_run_summary(summary: dict) -> None: date = summary.get("date", "-") status = summary.get("status", "-") if status in ("already_processed", "non_trading_day"): _console.print(f"[dim]{date}: {status}[/]") return _console.print(f"\nProcessing [bold]{date}[/]...") exits = summary.get("exits", []) entries = summary.get("entries", []) rejected = summary.get("rejected", []) candidates_detected = summary.get("candidates_detected", None) account = summary.get("account", {}) # Exits if exits: _console.print(" [bold]EXITS:[/]") for e in exits: pnl = e.get("pnl", 0.0) r = e.get("r_multiple", 0.0) color = _pnl_color(pnl) _console.print( f" [{color}]{e['symbol']}[/] — {e['reason']} " f"P&L: [{color}]{_fmt_pnl(pnl)}[/] R: [{color}]{r:+.2f}R[/]" ) else: _console.print(" [dim]EXITS: none[/]") # Candidates detected if candidates_detected is not None: if candidates_detected == 0: _console.print(" [dim]CANDIDATES: 0 events in DB for this date[/]") else: _console.print(f" [dim]CANDIDATES: {candidates_detected} events detected from DB[/]") # Entries if entries: _console.print(" [bold]ENTRIES:[/]") for e in entries: _console.print( f" [green]{e['symbol']}[/] ({e['event_type']}, score={e['score']:.2f}) " f"-> BUY {e['shares']} shares stop=${e['stop']:.2f} target=${e['target']:.2f}" ) else: _console.print(" [dim]ENTRIES: none[/]") # Rejected (only if non-zero) if rejected: _console.print(f" [dim]REJECTED: {len(rejected)} candidates[/]") # Account summary if account: pnl = account.get("total_pnl", 0.0) color = _pnl_color(pnl) _console.print( f"\n Equity: [bold]${account.get('equity', 0):,.2f}[/] " f"Total P&L: [{color}]{_fmt_pnl(pnl)}[/] " f"Drawdown: {account.get('drawdown_pct', 0):.2f}%" ) _console.print()