You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

577 lines
21 KiB
Python

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