Coverage for src/cc_liquid/cli_display.py: 0%
890 statements
« prev ^ index » next coverage.py v7.10.3, created at 2025-10-13 20:16 +0000
« prev ^ index » next coverage.py v7.10.3, created at 2025-10-13 20:16 +0000
1"""Display utilities for rendering structured data."""
3from datetime import datetime, timezone
4from typing import TYPE_CHECKING, Any
6from rich import box
7from rich.box import DOUBLE
8from rich.columns import Columns
9from rich.console import Console, Group
10from rich.layout import Layout
11from rich.panel import Panel
12from rich.table import Table
13from rich.text import Text
14import re
16if TYPE_CHECKING:
17 from .trader import AccountInfo, PortfolioInfo
18 from .order import Order, OrderHistory
19 from .stop_loss import StopLossOrder
22def strip_ansi_codes(text: str) -> str:
23 """Strip ANSI escape codes from text to prevent Rich layout issues."""
24 ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
25 return ansi_escape.sub("", text)
28def create_setup_welcome_panel() -> Panel:
29 """Build the setup wizard welcome panel used by init command."""
30 welcome_text = Text.from_markup(
31 "[bold cyan]Welcome to cc-liquid setup![/bold cyan]\n\n"
32 "This wizard will help you create:\n"
33 "• [cyan].env[/cyan] - for your private keys (never commit!)\n"
34 "• [cyan]cc-liquid-config.yaml[/cyan] - for your trading configuration\n\n"
35 "[dim]Press Ctrl+C anytime to cancel[/dim]"
36 )
37 return Panel(welcome_text, title="Setup Wizard", border_style="cyan")
40def create_setup_summary_panel(
41 is_testnet: bool,
42 data_source: str,
43 num_long: int,
44 num_short: int,
45 leverage: float,
46) -> Panel:
47 """Build the final setup summary panel used by init command."""
48 summary_text = Text.from_markup(
49 f"[bold green]✅ Setup Complete![/bold green]\n\n"
50 f"Environment: [cyan]{'TESTNET' if is_testnet else 'MAINNET'}[/cyan]\n"
51 f"Data source: [cyan]{data_source}[/cyan]\n"
52 f"Portfolio: [green]{num_long}L[/green] / [red]{num_short}S[/red] @ [yellow]{leverage}x[/yellow]\n\n"
53 "[bold]Next steps:[/bold]\n"
54 "1. Fill in any missing values in [cyan].env[/cyan]\n"
55 "2. Test connection: [cyan]cc-liquid account[/cyan]\n"
56 "3. View config: [cyan]cc-liquid config[/cyan]\n"
57 "4. First rebalance: [cyan]cc-liquid rebalance[/cyan]\n\n"
58 "[dim]Optional: Install tab completion with 'cc-liquid completion install'[/dim]"
59 )
60 return Panel(
61 summary_text,
62 title="🎉 Ready to Trade",
63 border_style="green",
64 )
67def create_plotext_panel(
68 plot_func, title: str, width: int = 50, height: int = 10, style: str = "cyan"
69) -> Panel:
70 """Create a Rich panel with plotext content, handling ANSI codes properly."""
71 import plotext as plt
73 # Clear and configure plotext
74 plt.clf()
75 plt.plotsize(width, height)
77 # Execute the plotting function
78 plot_func(plt)
80 # Get clean output
81 chart_str = plt.build()
82 clean_chart = strip_ansi_codes(chart_str)
84 return Panel(Text(clean_chart, style=style), title=title, box=box.HEAVY)
87def create_data_bar(
88 value: float,
89 max_value: float,
90 width: int = 20,
91 filled_char: str = "█",
92 empty_char: str = "░",
93) -> str:
94 """Create a visual data bar for representing proportions."""
95 if max_value == 0:
96 filled_width = 0
97 else:
98 filled_width = int((value / max_value) * width)
99 empty_width = width - filled_width
100 return f"{filled_char * filled_width}{empty_char * empty_width}"
103def format_currency(value: float, compact: bool = False) -> str:
104 """Format currency values with appropriate styling."""
105 if compact and abs(value) >= 1000:
106 if abs(value) >= 1_000_000:
107 return f"${value / 1_000_000:.1f}M"
108 return f"${value / 1_000:.1f}K"
109 return f"${value:,.2f}"
112def create_metric_row(label: str, value: str, style: str = "") -> tuple:
113 """Create a metric row tuple for tables."""
114 return (f"[cyan]{label}[/cyan]", f"[{style}]{value}[/{style}]" if style else value)
117def create_header_panel(base_title: str, is_rebalancing: bool = False) -> Panel:
118 """Create a header panel for the dashboard view, optionally showing rebalancing status."""
119 header_text = base_title
120 if is_rebalancing:
121 header_text += " :: [yellow blink]REBALANCING[/yellow blink]"
122 return Panel(
123 Text(header_text, style="bold cyan", justify="center"), box=DOUBLE, style="cyan"
124 )
127def create_account_metrics_table(account: "AccountInfo") -> Table:
128 """Create a compact account metrics table for the metrics Panel of the dashboard view."""
129 table = Table(show_header=False, box=None, padding=(0, 1))
130 table.add_column("", width=10)
131 table.add_column("", justify="right")
133 # Leverage color based on risk
134 lev_color = (
135 "green"
136 if account.current_leverage <= 2
137 else "yellow"
138 if account.current_leverage <= 3
139 else "red"
140 )
142 rows = [
143 create_metric_row(
144 "VALUE", format_currency(account.account_value), "bold green"
145 ),
146 create_metric_row("MARGIN", format_currency(account.margin_used)),
147 create_metric_row("FREE", format_currency(account.free_collateral)),
148 create_metric_row("LEVERAGE", f"{account.current_leverage:.2f}x", lev_color),
149 ]
151 for row in rows:
152 table.add_row(*row)
154 return table
157def create_account_exposure_table(portfolio: "PortfolioInfo") -> Table:
158 """Create a compact exposure analysis table for the metrics Panel of the dashboard view."""
159 table = Table(show_header=False, box=None, padding=(0, 1))
160 table.add_column("", width=8)
161 table.add_column("", justify="right", width=10)
162 table.add_column("", width=18)
164 account_val = (
165 portfolio.account.account_value if portfolio.account.account_value > 0 else 1
166 )
168 # Calculate percentages
169 long_pct = portfolio.total_long_value / account_val * 100
170 short_pct = portfolio.total_short_value / account_val * 100
171 net_pct = portfolio.net_exposure / account_val * 100
172 gross_pct = long_pct + short_pct # Just sum the percentages!
174 # Build rows with visual bars for long/short
175 net_color = "green" if portfolio.net_exposure >= 0 else "red"
176 rows = [
177 (
178 "LONG",
179 f"[green]{format_currency(portfolio.total_long_value, compact=True)}[/green]",
180 f"[green]{create_data_bar(long_pct, 300, 12, '▓')} {long_pct:.0f}%[/green]",
181 ),
182 (
183 "SHORT",
184 f"[red]{format_currency(portfolio.total_short_value, compact=True)}[/red]",
185 f"[red]{create_data_bar(short_pct, 300, 12, '▓')} {short_pct:.0f}%[/red]",
186 ),
187 (
188 "NET",
189 f"[{net_color}]{format_currency(portfolio.net_exposure, compact=True)}[/{net_color}]",
190 f"[dim]{net_pct:+.0f}%[/dim]",
191 ),
192 (
193 "GROSS",
194 format_currency(portfolio.total_exposure, compact=True),
195 f"[dim]{gross_pct:.0f}%[/dim]",
196 ),
197 ]
199 for row in rows:
200 table.add_row(*row)
202 return table
205def create_metrics_panel(portfolio: "PortfolioInfo") -> Panel:
206 """Create portfolio metrics panel (account + exposure) for the dashboard view."""
207 return Panel(
208 Columns(
209 [
210 create_account_metrics_table(portfolio.account),
211 create_account_exposure_table(portfolio),
212 ],
213 expand=True,
214 ),
215 title="[bold cyan]METRICS[/bold cyan]",
216 box=box.HEAVY,
217 )
220def create_positions_panel(portfolio: "PortfolioInfo") -> Panel:
221 """Create a panel displaying all open positions with summary statistics.
223 The panel includes a table of positions (sorted by value), and a title summarizing
224 the number of longs/shorts and total unrealized PnL.
226 Args:
227 portfolio (PortfolioInfo): The portfolio containing positions and account info.
229 Returns:
230 Panel: A rich Panel containing the positions table and summary.
231 """
232 positions = portfolio.positions
234 if not portfolio.positions:
235 return Panel(
236 "[yellow]No open positions[/yellow]",
237 box=box.HEAVY,
238 title="[bold cyan]POSITIONS[/bold cyan]",
239 )
241 account_val = (
242 portfolio.account.account_value if portfolio.account.account_value > 0 else 1
243 )
244 long_count = sum(1 for p in positions if p.side == "LONG")
245 short_count = sum(1 for p in positions if p.side == "SHORT")
246 total_pnl = sum(p.unrealized_pnl for p in positions)
247 pnl_pct = total_pnl / account_val * 100
248 pnl_color = "green" if total_pnl >= 0 else "red"
250 title = (
251 f"[bold cyan]POSITIONS[/bold cyan] [dim]│[/dim] "
252 f"[green]{long_count}L[/green] [red]{short_count}S[/red] [dim]│[/dim] "
253 f"UNREALIZED [{pnl_color}]${total_pnl:+,.2f} ({pnl_pct:+.1f}%)[/{pnl_color}]"
254 )
256 table = Table(
257 box=box.HEAVY_HEAD,
258 show_lines=False,
259 header_style="bold cyan on #001926",
260 expand=True,
261 )
263 # Define columns
264 table.add_column("COIN", style="cyan", width=8)
265 table.add_column("SIDE", justify="center", width=8)
266 table.add_column("SIZE", justify="right", width=8)
267 table.add_column("ENTRY", justify="right", width=10)
268 table.add_column("MARK", justify="right", width=10)
269 table.add_column("VALUE", justify="right", width=12)
270 table.add_column("PNL", justify="right", width=10)
271 table.add_column("PERF", justify="center", width=8)
273 # Sort positions by absolute value
274 sorted_positions = sorted(positions, key=lambda p: abs(p.value), reverse=True)
276 for pos in sorted_positions:
277 side_style = "green" if pos.side == "LONG" else "red"
278 pnl_color = "green" if pos.unrealized_pnl >= 0 else "red"
280 # Format size based on magnitude
281 if abs(pos.size) >= 1000:
282 size_str = f"{pos.size:,.0f}"
283 elif abs(pos.size) >= 1:
284 size_str = f"{pos.size:.2f}"
285 else:
286 size_str = f"{pos.size:.4f}"
288 table.add_row(
289 f"[bold]{pos.coin}[/bold]",
290 f"[{side_style}]{pos.side[:1]}[/{side_style}]",
291 size_str,
292 format_currency(pos.entry_price, compact=True),
293 format_currency(pos.mark_price, compact=True),
294 format_currency(abs(pos.value), compact=True),
295 f"[{pnl_color}]${pos.unrealized_pnl:+,.2f}[/{pnl_color}]",
296 f"[{pnl_color}]{pos.return_pct:+.1f}% [/{pnl_color}]",
297 )
299 return Panel(table, title=title, box=box.HEAVY)
302def create_sidebar_panel(config_dict: dict | None, empty_label: str) -> Panel:
303 """Create sidebar panel containing config or a standardized empty state."""
304 if config_dict:
305 return create_config_panel(config_dict)
306 return Panel(empty_label, box=box.HEAVY)
309def create_footer_panel(
310 next_rebalance_time: datetime | None,
311 last_rebalance_time: datetime | None,
312 refresh_seconds: float | None,
313) -> Panel:
314 """Create monitoring footer with countdown and status details."""
315 now = datetime.now(timezone.utc)
317 # Next rebalance countdown
318 countdown_str = ""
319 if next_rebalance_time:
320 time_until = next_rebalance_time - now
321 if time_until.total_seconds() > 0:
322 total_seconds = int(time_until.total_seconds())
323 hours, remainder = divmod(total_seconds, 3600)
324 minutes, seconds = divmod(remainder, 60)
326 if hours > 24:
327 days = hours // 24
328 hours = hours % 24
329 countdown = f"{days}d {hours:02d}:{minutes:02d}:{seconds:02d}"
330 else:
331 countdown = f"{hours:02d}:{minutes:02d}:{seconds:02d}"
333 if total_seconds < 60:
334 countdown_str = f"[bold yellow blink]{countdown}[/bold yellow blink]"
335 elif total_seconds < 3600:
336 countdown_str = f"[yellow]{countdown}[/yellow]"
337 else:
338 countdown_str = f"[green]{countdown}[/green]"
339 else:
340 countdown_str = "[bold red blink]REBALANCING[/bold red blink]"
341 else:
342 countdown_str = "[dim]Calculating...[/dim]"
344 # Last rebalance string
345 last_rebalance_str = "[dim]Never[/dim]"
346 if last_rebalance_time:
347 time_since = now - last_rebalance_time
348 hours_ago = time_since.total_seconds() / 3600
349 if hours_ago < 24:
350 last_rebalance_str = f"[dim]{hours_ago:.1f}h ago[/dim]"
351 else:
352 days_ago = hours_ago / 24
353 last_rebalance_str = f"[dim]{days_ago:.1f}d ago[/dim]"
355 status_grid = Table.grid(expand=True)
356 status_grid.add_column(justify="left")
357 status_grid.add_column(justify="center")
358 status_grid.add_column(justify="center")
359 status_grid.add_column(justify="right")
361 status_grid.add_row(
362 f"[bold cyan]Next rebalance: {countdown_str}[/bold cyan]",
363 f"[dim]Last: {last_rebalance_str}[/dim]",
364 f"[dim]Monitor refresh: {refresh_seconds:.1f}s[/dim]"
365 if refresh_seconds is not None
366 else "",
367 "[red]Press Ctrl+C to exit[/red]",
368 )
370 return Panel(status_grid, box=box.HEAVY)
373def create_dashboard_layout(
374 portfolio: "PortfolioInfo",
375 config_dict: dict | None = None,
376 open_orders: list["Order"] | None = None,
377 *,
378 next_rebalance_time: datetime | None = None,
379 last_rebalance_time: datetime | None = None,
380 is_rebalancing: bool = False,
381 refresh_seconds: float | None = None,
382) -> Layout:
383 """Unified portfolio dashboard builder.
385 - Builds the common header, body (metrics, positions, sidebar)
386 - Optionally adds a monitoring footer when scheduling data is provided
387 """
388 has_footer = any(
389 value is not None
390 for value in (next_rebalance_time, last_rebalance_time, refresh_seconds)
391 )
393 layout = Layout()
395 if has_footer:
396 layout.split_column(
397 Layout(name="header", size=3),
398 Layout(name="body"),
399 Layout(name="footer", size=3),
400 )
401 else:
402 layout.split_column(Layout(name="header", size=3), Layout(name="body"))
404 # Header
405 header_title = (
406 "CC-LIQUID MONITOR :: METAMODEL REBALANCER"
407 if has_footer
408 else "CC-LIQUID :: METAMODEL REBALANCER"
409 )
410 layout["header"].update(
411 create_header_panel(header_title, is_rebalancing if has_footer else False)
412 )
414 # Body: split into main area and sidebar
415 layout["body"].split_row(
416 Layout(name="main", ratio=2), Layout(name="sidebar", ratio=1)
417 )
419 # Main area: metrics + positions (+ orders if present)
420 if open_orders and len(open_orders) > 0:
421 layout["main"].split_column(
422 Layout(name="metrics", size=8),
423 Layout(name="orders", size=10),
424 Layout(name="positions")
425 )
426 layout["orders"].update(create_open_orders_panel(open_orders))
427 else:
428 layout["main"].split_column(
429 Layout(name="metrics", size=8), Layout(name="positions")
430 )
432 layout["metrics"].update(create_metrics_panel(portfolio))
433 layout["positions"].update(create_positions_panel(portfolio))
435 # Sidebar
436 empty_sidebar_text = (
437 "[dim]No config loaded[/dim]" if has_footer else "[dim]No config[/dim]"
438 )
439 layout["sidebar"].update(create_sidebar_panel(config_dict, empty_sidebar_text))
441 # Footer (optional)
442 if has_footer:
443 footer = create_footer_panel(
444 next_rebalance_time, last_rebalance_time, refresh_seconds
445 )
446 layout["footer"].update(footer)
448 return layout
451def create_config_tree_table(config_dict: dict) -> Table:
452 """Create config display as a tree structure (reusable)."""
453 table = Table(show_header=False, box=None, padding=(0, 0))
454 table.add_column("Setting", style="cyan", width=20, no_wrap=True)
455 table.add_column("Value", style="white")
457 # Environment section
458 network = "TESTNET" if config_dict.get("is_testnet", False) else "MAINNET"
459 network_color = "yellow" if config_dict.get("is_testnet", False) else "green"
461 # Data source section
462 data_config = config_dict.get("data", {})
463 source = data_config.get("source", "crowdcent")
464 source_color = (
465 "green"
466 if source == "crowdcent"
467 else "yellow"
468 if source == "numerai"
469 else "white"
470 )
472 # Portfolio section
473 portfolio_config = config_dict.get("portfolio", {})
474 leverage = portfolio_config.get("target_leverage", 1.0)
475 leverage_color = "green" if leverage <= 2 else "yellow" if leverage <= 3 else "red"
476 rebalancing = portfolio_config.get("rebalancing", {})
477 weighting_scheme = portfolio_config.get("weighting_scheme", "equal")
478 rank_power = portfolio_config.get("rank_power", 1.5)
480 # Execution section
481 execution_config = config_dict.get("execution", {})
482 slippage_pct = execution_config.get("slippage_tolerance", 0.005) * 100
483 slippage_color = (
484 "green" if slippage_pct <= 0.5 else "yellow" if slippage_pct <= 1.0 else "red"
485 )
486 min_trade_value = execution_config.get("min_trade_value", 10.0)
488 # Profile section (owner/vault and signer env name)
489 profile_cfg = config_dict.get("profile", {})
490 owner = profile_cfg.get("owner") or "[dim]-[/dim]"
491 vault = profile_cfg.get("vault") or "[dim]-[/dim]"
492 active_profile = profile_cfg.get("active") or "[dim]-[/dim]"
493 signer_env = profile_cfg.get("signer_env") or "HYPERLIQUID_PRIVATE_KEY"
495 # Build all rows
496 rows = [
497 ("[bold]ENVIRONMENT[/bold]", ""),
498 ("├─ Network", f"[{network_color}]{network}[/{network_color}]"),
499 ("├─ Active Profile", f"[white]{active_profile}[/white]"),
500 ("├─ Owner", f"[white]{owner}[/white]"),
501 ("├─ Vault", f"[white]{vault}[/white]"),
502 ("└─ Signer Env", f"[white]{signer_env}[/white]"),
503 ("", ""),
504 ("[bold]DATA SOURCE[/bold]", ""),
505 ("├─ Provider", f"[{source_color}]{source}[/{source_color}]"),
506 ("├─ Path", data_config.get("path", "predictions.parquet")),
507 ("└─ Prediction", data_config.get("prediction_column", "pred_10d")),
508 ("", ""),
509 ("[bold]PORTFOLIO[/bold]", ""),
510 ("├─ Long Positions", f"[green]{portfolio_config.get('num_long', 10)}[/green]"),
511 ("├─ Short Positions", f"[red]{portfolio_config.get('num_short', 10)}[/red]"),
512 ("├─ Target Leverage", f"[{leverage_color}]{leverage:.1f}x[/{leverage_color}]"),
513 (
514 "├─ Weighting",
515 f"{weighting_scheme} ({rank_power})"
516 if weighting_scheme == "rank_power"
517 else "",
518 ),
519 ("└─ Rebalancing", ""),
520 (" ├─ Frequency", f"Every {rebalancing.get('every_n_days', 10)} days"),
521 (" └─ Time (UTC)", rebalancing.get("at_time", "18:15")),
522 ("", ""),
523 ("[bold]EXECUTION[/bold]", ""),
524 ("├─ Slippage", f"[{slippage_color}]{slippage_pct:.1f}%[/{slippage_color}]"),
525 ("└─ Min Trade Value", format_currency(min_trade_value, compact=False)),
526 ]
528 for row in rows:
529 table.add_row(*row)
531 return table
534def display_portfolio(
535 portfolio: "PortfolioInfo",
536 console: Console | None = None,
537 config_dict: dict | None = None,
538) -> None:
539 """Display portfolio information in a compact dashboard."""
540 if console is None:
541 console = Console()
543 # Use the new dashboard layout
544 layout = create_dashboard_layout(portfolio, config_dict)
545 console.print(layout)
548def create_config_panel(config_dict: dict) -> Panel:
549 """Create display Panel for the config view. Can be used standalone or as part of the dashboard view."""
550 return Panel(
551 create_config_tree_table(config_dict),
552 title="[bold cyan]CONFIG[/bold cyan]",
553 box=box.HEAVY,
554 )
557def display_file_summary(
558 console: Console, predictions, output_path: str, model_name: str
559) -> None:
560 """Display a summary of downloaded predictions file."""
561 console.print(f"[green]✓[/green] Downloaded {model_name} to {output_path}")
562 console.print(f"[cyan]Shape:[/cyan] {predictions.shape}")
563 console.print(f"[cyan]Columns:[/cyan] {list(predictions.columns)}")
566def show_pre_alpha_warning() -> None:
567 """Display pre-alpha warning to users."""
568 console = Console()
569 warning_copy = """
570This is pre-alpha software provided as a reference implementation only.
571• Using this software may result in COMPLETE LOSS of funds.
572• CrowdCent makes NO WARRANTIES and assumes NO LIABILITY for any losses.
573• Users must comply with all Hyperliquid and CrowdCent terms of service.
574• We do NOT endorse any vaults or strategies using this tool.
576[bold yellow]By continuing, you acknowledge that you understand and accept ALL risks.[/bold yellow]
577 """
578 warning_text = Text.from_markup(warning_copy, justify="left")
579 panel = Panel(
580 warning_text,
581 title="[bold cyan]CC-LIQUID ::[/bold cyan] [bold red] PRE-ALPHA SOFTWARE - USE AT YOUR OWN RISK [/bold red]",
582 border_style="red",
583 box=box.HEAVY,
584 )
585 console.print(panel)
588def show_rebalancing_plan(
589 console: Console,
590 target_positions: dict,
591 trades: list,
592 account_value: float,
593 leverage: float,
594) -> None:
595 """Create a comprehensive rebalancing dashboard layout."""
596 # Header
597 header = Panel(
598 Text("REBALANCING PLAN", style="bold cyan", justify="center"),
599 box=DOUBLE,
600 style="cyan",
601 )
602 console.print(header)
604 # Metrics row: account + rebalancing summary
605 metrics_content = create_rebalancing_metrics_panel(
606 account_value, leverage, trades, target_positions
607 )
608 console.print(metrics_content)
610 # Trades panel
611 trades_panel = create_trades_panel(trades)
612 console.print(trades_panel)
614 # Check if we have skipped trades
615 skipped_count = sum(1 for t in trades if t.get("skipped", False))
616 if skipped_count > 0:
617 console.print(
618 f"\n[bold yellow]⚠️ WARNING: {skipped_count} trade(s) marked as SKIPPED[/bold yellow]\n"
619 f"[yellow]These positions cannot be resized due to minimum trade size constraints.[/yellow]\n"
620 f"[yellow]They will remain at their current sizes, causing portfolio imbalance.[/yellow]\n"
621 f"[dim]Consider: increasing account value, using higher leverage, or reducing position count.[/dim]"
622 )
625def create_rebalancing_metrics_panel(
626 account_value: float, leverage: float, trades: list, target_positions: dict
627) -> Panel:
628 """Create rebalancing metrics panel."""
629 # Position counts using the type field
630 executable_trades = [t for t in trades if not t.get("skipped", False)]
631 opens = sum(1 for t in executable_trades if t.get("type") == "open")
632 closes = sum(1 for t in executable_trades if t.get("type") == "close")
633 flips = sum(1 for t in executable_trades if t.get("type") == "flip")
634 reduces = sum(1 for t in executable_trades if t.get("type") == "reduce")
635 increases = sum(1 for t in executable_trades if t.get("type") == "increase")
637 # Target portfolio metrics
638 total_long_value = sum(v for v in target_positions.values() if v > 0)
639 total_short_value = abs(sum(v for v in target_positions.values() if v < 0))
641 # Create two columns
642 left_table = Table(show_header=False, box=None, padding=(0, 1))
643 left_table.add_column("", width=12)
644 left_table.add_column("", justify="right")
646 left_table.add_row("ACCOUNT", format_currency(account_value, compact=False))
647 left_table.add_row(
648 "LEVERAGE",
649 f"[{'green' if leverage <= 2 else 'yellow' if leverage <= 3 else 'red'}]{leverage:.1f}x[/{'green' if leverage <= 2 else 'yellow' if leverage <= 3 else 'red'}]",
650 )
651 left_table.add_row("MAX EXPOSURE", format_currency(account_value * leverage))
652 left_table.add_row("", "") # spacer
653 left_table.add_row(
654 "TARGET LONG",
655 f"[green]{format_currency(total_long_value, compact=True)}[/green]",
656 )
657 left_table.add_row(
658 "TARGET SHORT", f"[red]{format_currency(total_short_value, compact=True)}[/red]"
659 )
661 right_table = Table(show_header=False, box=None, padding=(0, 1))
662 right_table.add_column("", width=12)
663 right_table.add_column("", justify="right")
665 right_table.add_row("TRADES", f"[bold]{len(executable_trades)}[/bold]")
666 if opens > 0:
667 right_table.add_row("OPEN", f"[green]{opens}[/green]")
668 if closes > 0:
669 right_table.add_row("CLOSE", f"[red]{closes}[/red]")
670 if flips > 0:
671 right_table.add_row("FLIP", f"[yellow]{flips}[/yellow]")
672 if reduces > 0:
673 right_table.add_row("REDUCE", f"[blue]{reduces}[/blue]")
674 if increases > 0:
675 right_table.add_row("ADD", f"[cyan]{increases}[/cyan]")
677 return Panel(
678 Columns([left_table, right_table], expand=True),
679 title="[bold cyan]METRICS[/bold cyan]",
680 box=box.HEAVY,
681 )
684def create_trades_panel(trades: list) -> Panel:
685 """Create a panel for the trades table matching the positions table style."""
686 # Handle no trades
687 if not trades:
688 return Panel(
689 "[yellow]No trades required - portfolio is already balanced[/yellow]",
690 box=box.HEAVY,
691 title="[bold cyan]TRADES[/bold cyan]",
692 )
694 # Separate executable and skipped trades
695 executable_trades = [t for t in trades if not t.get("skipped", False)]
696 skipped_trades = [t for t in trades if t.get("skipped", False)]
698 # Calculate summary for title (only executable trades)
699 total_volume = sum(abs(t.get("delta_value", 0)) for t in executable_trades)
700 buy_count = sum(1 for t in executable_trades if t.get("is_buy"))
701 sell_count = len(executable_trades) - buy_count
702 buy_volume = sum(
703 abs(t.get("delta_value", 0)) for t in executable_trades if t.get("is_buy")
704 )
705 sell_volume = sum(
706 abs(t.get("delta_value", 0)) for t in executable_trades if not t.get("is_buy")
707 )
709 # Add skipped count to title if any
710 skipped_info = ""
711 if skipped_trades:
712 skipped_info = f" [dim]│[/dim] [yellow]{len(skipped_trades)} SKIPPED[/yellow]"
714 title = (
715 f"[bold cyan]TRADES[/bold cyan] [dim]│[/dim] "
716 f"[green]{buy_count} BUY (${buy_volume:,.2f})[/green] "
717 f"[red]{sell_count} SELL (${sell_volume:,.2f})[/red] [dim]│[/dim] "
718 f"VOLUME [bold]${total_volume:,.2f}[/bold]"
719 f"{skipped_info}"
720 )
722 table = Table(
723 box=box.HEAVY_HEAD,
724 show_lines=False,
725 header_style="bold cyan on #001926",
726 expand=True,
727 )
729 # Define columns
730 table.add_column("COIN", style="cyan", width=8)
731 table.add_column("ACTION", justify="center", width=7)
732 table.add_column("CURRENT", justify="right", width=10)
733 table.add_column("→", justify="center", width=1, style="dim")
734 table.add_column("TARGET", justify="right", width=10)
735 table.add_column("DELTA", justify="right", width=10)
736 table.add_column("TRADE", justify="center", width=6)
737 table.add_column("SIZE", justify="right", width=10)
738 table.add_column("PRICE", justify="right", width=10)
740 # Sort trades by absolute delta value, with executable trades first
741 sorted_trades = sorted(
742 trades, key=lambda t: (t.get("skipped", False), -abs(t.get("delta_value", 0)))
743 )
745 for trade in sorted_trades:
746 coin = trade["coin"]
747 is_buy = trade.get("is_buy", False) # Skipped trades may not have this
748 current_value = trade.get("current_value", 0)
749 target_value = trade.get("target_value", 0)
750 delta_value = trade.get("delta_value", 0)
752 # Use the type field from trade calculation
753 trade_type = trade.get("type", "increase") # fallback for old data
754 action_styles = {
755 "open": "[green]OPEN[/green]",
756 "close": "[red]CLOSE[/red]",
757 "flip": "[yellow]FLIP[/yellow]",
758 "reduce": "[blue]REDUCE[/blue]",
759 "increase": "[cyan]ADD[/cyan]",
760 }
761 action = action_styles.get(trade_type, "[dim]ADJUST[/dim]")
763 # Format current and target with side indicators
764 if current_value == 0:
765 current_str = "[dim]-[/dim]"
766 else:
767 side = "L" if current_value > 0 else "S"
768 side_color = "green" if current_value > 0 else "red"
769 current_str = f"{format_currency(abs(current_value), compact=True)} [{side_color}]{side}[/{side_color}]"
771 if target_value == 0:
772 target_str = "[dim]-[/dim]"
773 else:
774 side = "L" if target_value > 0 else "S"
775 side_color = "green" if target_value > 0 else "red"
776 target_str = f"{format_currency(abs(target_value), compact=True)} [{side_color}]{side}[/{side_color}]"
778 # Trade direction
779 trade_action = "[green]BUY[/green]" if is_buy else "[red]SELL[/red]"
781 # Delta with color
782 delta_color = "green" if delta_value > 0 else "red"
783 delta_str = f"[{delta_color}]{delta_value:+,.0f}[/{delta_color}]"
785 # Style differently if trade is skipped
786 if trade.get("skipped", False):
787 # Show the skip reason in the trade column
788 trade_action = "[yellow]SKIP[/yellow]"
789 # Dim the entire row for skipped trades
790 coin_str = f"[dim]{coin}[/dim]"
791 action = f"[dim]{action}[/dim]"
792 current_str = f"[dim]{current_str}[/dim]"
793 target_str = f"[dim]{target_str}[/dim]"
794 delta_str = f"[dim yellow]{delta_value:+,.0f}[/dim yellow]"
795 size_str = "[dim]-[/dim]" # No size since it won't execute
796 price_str = "[dim]-[/dim]" # No price since it won't execute
797 else:
798 coin_str = f"[bold]{coin}[/bold]"
799 size_str = f"{trade.get('sz', 0):.4f}" if "sz" in trade else "[dim]-[/dim]"
800 price_str = (
801 format_currency(trade["price"], compact=True)
802 if "price" in trade
803 else "[dim]-[/dim]"
804 )
806 table.add_row(
807 coin_str,
808 action,
809 current_str,
810 "→",
811 target_str,
812 delta_str,
813 trade_action,
814 size_str,
815 price_str,
816 )
818 return Panel(table, title=title, box=box.HEAVY, expand=True)
821def create_execution_metrics_panel(
822 successful_trades: list[dict],
823 all_trades: list[dict],
824 target_positions: dict,
825 account_value: float,
826) -> Panel:
827 """Create execution summary metrics panel."""
828 total_success = len(successful_trades)
829 total_failed = len(all_trades) - total_success
831 # Calculate portfolio metrics
832 total_long_value = sum(v for v in target_positions.values() if v > 0)
833 total_short_value = abs(sum(v for v in target_positions.values() if v < 0))
834 total_exposure = total_long_value + total_short_value
835 net_exposure = total_long_value - total_short_value
836 leverage = total_exposure / account_value if account_value > 0 else 0
838 # Calculate slippage stats from successful trades
839 if successful_trades:
840 slippages = [t.get("slippage_pct", 0) for t in successful_trades]
841 avg_slippage = sum(slippages) / len(slippages)
842 max_slippage = max(slippages)
843 min_slippage = min(slippages)
844 else:
845 avg_slippage = max_slippage = min_slippage = 0
847 # Create two columns
848 left_table = Table(show_header=False, box=None, padding=(0, 1))
849 left_table.add_column("", width=15)
850 left_table.add_column("", justify="right")
852 # Execution results
853 left_table.add_row(
854 "EXECUTED", f"[green]{total_success}[/green]" if total_success > 0 else "0"
855 )
856 if total_failed > 0:
857 left_table.add_row("FAILED", f"[red]{total_failed}[/red]")
858 left_table.add_row(
859 "SUCCESS RATE",
860 f"[bold]{total_success / len(all_trades) * 100:.1f}%[/bold]"
861 if all_trades
862 else "N/A",
863 )
864 left_table.add_row("", "") # spacer
866 # Slippage stats
867 if successful_trades:
868 left_table.add_row(
869 "AVG SLIPPAGE",
870 f"[{'green' if avg_slippage <= 0 else 'red'}]{avg_slippage:+.3f}%[/{'green' if avg_slippage <= 0 else 'red'}]",
871 )
872 left_table.add_row("MAX SLIPPAGE", f"{max_slippage:+.3f}%")
873 left_table.add_row("MIN SLIPPAGE", f"{min_slippage:+.3f}%")
875 right_table = Table(show_header=False, box=None, padding=(0, 1))
876 right_table.add_column("", width=15)
877 right_table.add_column("", justify="right")
879 # Portfolio metrics
880 right_table.add_row("TOTAL EXPOSURE", format_currency(total_exposure))
881 right_table.add_row("NET EXPOSURE", format_currency(net_exposure))
882 right_table.add_row(
883 "LEVERAGE",
884 f"[{'green' if leverage <= 2 else 'yellow' if leverage <= 3 else 'red'}]{leverage:.2f}x[/{'green' if leverage <= 2 else 'yellow' if leverage <= 3 else 'red'}]",
885 )
886 right_table.add_row("", "") # spacer
887 right_table.add_row(
888 "LONG VALUE",
889 f"[green]{format_currency(total_long_value, compact=True)}[/green]",
890 )
891 right_table.add_row(
892 "SHORT VALUE", f"[red]{format_currency(total_short_value, compact=True)}[/red]"
893 )
894 right_table.add_row(
895 "NET % OF NAV",
896 f"{net_exposure / account_value * 100:+.1f}%" if account_value > 0 else "N/A",
897 )
899 return Panel(
900 Columns([left_table, right_table], expand=True),
901 title="[bold cyan]METRICS[/bold cyan]",
902 box=box.HEAVY,
903 )
906def create_execution_details_panel(
907 successful_trades: list[dict], all_trades: list[dict]
908) -> Panel:
909 """Create execution details table."""
910 # Create a set of successful trade identifiers (coin + is_buy combination)
911 successful_ids = {(t["coin"], t["is_buy"]) for t in successful_trades}
912 failed_trades = [
913 t for t in all_trades if (t["coin"], t["is_buy"]) not in successful_ids
914 ]
916 # Calculate volumes for successful trades
917 success_volume = sum(
918 float(t.get("fill_data", {}).get("totalSz", 0))
919 * float(t.get("fill_data", {}).get("avgPx", 0))
920 for t in successful_trades
921 if "fill_data" in t
922 )
924 title = (
925 f"[bold cyan]EXECUTION DETAILS[/bold cyan] [dim]│[/dim] "
926 f"[green]{len(successful_trades)} SUCCESS (${success_volume:,.2f})[/green] "
927 f"[red]{len(failed_trades)} FAILED[/red]"
928 )
930 table = Table(
931 box=box.HEAVY_HEAD,
932 show_lines=False,
933 header_style="bold cyan on #001926",
934 title_style="bold cyan",
935 )
937 # Define columns
938 table.add_column("COIN", style="cyan", width=8)
939 table.add_column("SIDE", justify="center", width=6)
940 table.add_column("SIZE", justify="right", width=10)
941 table.add_column("EXPECTED", justify="right", width=10)
942 table.add_column("FILLED", justify="right", width=10)
943 table.add_column("SLIPPAGE", justify="right", width=10)
944 table.add_column("VALUE", justify="right", width=12)
945 table.add_column("STATUS", justify="center", width=8)
947 # Add successful trades first
948 for trade in successful_trades:
949 if "fill_data" in trade:
950 fill = trade["fill_data"]
951 side = "BUY" if trade["is_buy"] else "SELL"
952 side_style = "green" if side == "BUY" else "red"
953 slippage_style = "green" if trade.get("slippage_pct", 0) <= 0 else "red"
955 table.add_row(
956 f"[bold]{trade['coin']}[/bold]",
957 f"[{side_style}]{side}[/{side_style}]",
958 f"{float(fill['totalSz']):.4f}",
959 format_currency(trade["price"], compact=True),
960 format_currency(float(fill["avgPx"]), compact=True),
961 f"[{slippage_style}]{trade.get('slippage_pct', 0):+.3f}%[/{slippage_style}]",
962 format_currency(float(fill["totalSz"]) * float(fill["avgPx"])),
963 "[green]✓[/green]",
964 )
966 # Add failed trades
967 for trade in failed_trades:
968 side = "BUY" if trade["is_buy"] else "SELL"
969 side_style = "green" if side == "BUY" else "red"
971 table.add_row(
972 f"[bold]{trade['coin']}[/bold]",
973 f"[{side_style}]{side}[/{side_style}]",
974 f"{trade['sz']:.4f}",
975 format_currency(trade["price"], compact=True),
976 "[red]-[/red]",
977 "[red]-[/red]",
978 "[red]-[/red]",
979 "[red]✗[/red]",
980 )
982 panel = Panel(table, title=title, box=box.HEAVY)
983 return panel
986def display_execution_summary(
987 console: Console,
988 successful_trades: list[dict],
989 all_trades: list[dict],
990 target_positions: dict,
991 account_value: float,
992) -> None:
993 """Display execution summary after trades complete.
995 Prints panels sequentially (header → summary metrics → details),
996 matching the style of the trade plan output.
997 """
998 # Header
999 header = Panel(
1000 Text("EXECUTION SUMMARY", style="bold cyan", justify="center"),
1001 box=DOUBLE,
1002 style="cyan",
1003 )
1004 console.print("\n")
1005 console.print(header)
1007 # Summary metrics
1008 summary_panel = create_execution_metrics_panel(
1009 successful_trades, all_trades, target_positions, account_value
1010 )
1011 console.print(summary_panel)
1013 # Details (only when there were any trades or failures)
1014 if successful_trades or (len(all_trades) > len(successful_trades)):
1015 details_panel = create_execution_details_panel(successful_trades, all_trades)
1016 console.print(details_panel)
1017 else:
1018 console.print(Panel("[dim]No trades executed[/dim]", box=box.HEAVY))
1021def display_backtest_summary(
1022 console: Console, result, config=None, show_positions=False
1023):
1024 """Display comprehensive backtest results in a cleaner sequential layout
1026 Args:
1027 console: Rich console for output
1028 result: BacktestResult with daily data and stats
1029 config: Optional BacktestConfig to display
1030 show_positions: Whether to show the detailed position analysis table
1031 """
1033 # 0. Disclaimer warning
1034 disclaimer = Panel(
1035 Text.from_markup(
1036 "[yellow]\n Past performance does not guarantee future results. "
1037 "These results are hypothetical and subject to limitations.[/yellow]\n",
1038 justify="center",
1039 ),
1040 title="[bold yellow] BACKTEST DISCLAIMER [/bold yellow]",
1041 box=box.HEAVY,
1042 border_style="yellow",
1043 )
1044 console.print(disclaimer)
1046 # 1. Header
1047 header = Panel(
1048 Text(
1049 "BACKTEST RESULTS :: PERFORMANCE ANALYSIS",
1050 style="bold cyan",
1051 justify="center",
1052 ),
1053 box=DOUBLE,
1054 style="cyan",
1055 )
1056 console.print(header)
1058 # 2-3. Metrics + Charts stacked on the left, Config as a persistent right sidebar
1059 metrics_panel = create_backtest_metrics_panel(result.stats)
1061 if len(result.daily) > 0:
1062 equity_panel = create_linechart_panel(
1063 result.daily, "equity", "cyan", "Equity ($)"
1064 )
1065 drawdown_panel = create_linechart_panel(
1066 result.daily, "drawdown", "red", "Drawdown (%)"
1067 )
1068 dist_panel = create_backtest_distributions(result.daily)
1069 else:
1070 equity_panel = Panel("[dim]No data[/dim]", box=box.HEAVY)
1071 drawdown_panel = Panel("[dim]No data[/dim]", box=box.HEAVY)
1072 dist_panel = Panel("[dim]No distribution data[/dim]", box=box.HEAVY)
1074 top_row = Columns([equity_panel, drawdown_panel], expand=True)
1075 left_group = Group(metrics_panel, top_row, dist_panel)
1077 summary_layout = Layout()
1078 summary_layout.split_row(
1079 Layout(name="main", ratio=3), Layout(name="config", ratio=1)
1080 )
1082 summary_layout["main"].update(left_group)
1083 summary_layout["config"].update(
1084 create_backtest_config_panel(config)
1085 if config
1086 else Panel(
1087 "[dim]No configuration data[/dim]",
1088 box=box.HEAVY,
1089 title="[bold cyan]BACKTEST CONFIG[/bold cyan]",
1090 )
1091 )
1093 console.print(summary_layout)
1095 # 4. Positions table (full width) - only if flag is set
1096 if show_positions and len(result.rebalance_positions) > 0:
1097 positions_table = create_enhanced_positions_table(
1098 result.rebalance_positions, result.daily
1099 )
1100 console.print(positions_table)
1103def create_backtest_config_panel(config) -> Panel:
1104 """Create configuration panel for backtest sidebar using tree-like layout."""
1105 tree = Table(show_header=False, box=None, padding=(0, 0))
1106 tree.add_column("Setting", style="cyan", width=18, no_wrap=True)
1107 tree.add_column("Value", style="white")
1109 # Environment/date range
1110 if config.start_date or config.end_date:
1111 start_str = (
1112 config.start_date.strftime("%Y-%m-%d") if config.start_date else "start"
1113 )
1114 end_str = config.end_date.strftime("%Y-%m-%d") if config.end_date else "end"
1115 tree.add_row("[bold]RANGE[/bold]", "")
1116 tree.add_row("├─ From", start_str)
1117 tree.add_row("└─ To", end_str)
1118 tree.add_row("", "")
1120 # Portfolio
1121 leverage_color = (
1122 "green"
1123 if config.target_leverage <= 2
1124 else "yellow"
1125 if config.target_leverage <= 3
1126 else "red"
1127 )
1128 tree.add_row("[bold]PORTFOLIO[/bold]", "")
1129 tree.add_row("├─ Long", f"[green]{config.num_long}[/green]")
1130 tree.add_row("├─ Short", f"[red]{config.num_short}[/red]")
1131 tree.add_row(
1132 "├─ Leverage",
1133 f"[{leverage_color}]{config.target_leverage:.1f}x[/{leverage_color}]",
1134 )
1135 tree.add_row("└─ Rebalance", f"{config.rebalance_every_n_days}d")
1136 tree.add_row("", "")
1138 # Costs
1139 tree.add_row("[bold]COSTS[/bold]", "")
1140 tree.add_row("├─ Fee", f"{config.fee_bps:.1f}bps")
1141 tree.add_row("└─ Slippage", f"{config.slippage_bps:.1f}bps")
1142 tree.add_row("", "")
1144 # Data
1145 source_name = (
1146 config.predictions_path.split("/")[-1]
1147 if "/" in config.predictions_path
1148 else config.predictions_path
1149 )
1150 provider = getattr(config, "data_provider", None)
1151 provider_color = (
1152 "green"
1153 if provider == "crowdcent"
1154 else "yellow"
1155 if provider == "numerai"
1156 else "white"
1157 )
1158 tree.add_row("[bold]DATA[/bold]", "")
1159 if provider:
1160 tree.add_row("├─ Provider", f"[{provider_color}]{provider}[/{provider_color}]")
1161 tree.add_row("├─ Source", source_name)
1162 tree.add_row("└─ Pred Col", str(config.pred_value_column))
1164 return Panel(tree, title="[bold cyan]BACKTEST CONFIG[/bold cyan]", box=box.HEAVY)
1167def create_backtest_metrics_panel(stats: dict) -> Panel:
1168 """Create metrics panel for backtest results."""
1169 # Create two-column layout for metrics
1170 left_table = Table(show_header=False, box=None, padding=(0, 1))
1171 left_table.add_column("", width=15)
1172 left_table.add_column("", justify="right")
1174 right_table = Table(show_header=False, box=None, padding=(0, 1))
1175 right_table.add_column("", width=15)
1176 right_table.add_column("", justify="right")
1178 # Left column: Returns and basic metrics
1179 total_return = stats.get("total_return", 0)
1180 cagr = stats.get("cagr", 0)
1181 final_equity = stats.get("final_equity", 0)
1183 ret_color = "green" if total_return >= 0 else "red"
1184 cagr_color = "green" if cagr >= 0 else "red"
1186 left_table.add_row("DAYS", f"{stats.get('days', 0):,}")
1187 left_table.add_row("FINAL EQUITY", format_currency(final_equity, compact=True))
1188 left_table.add_row("TOTAL RETURN", f"[{ret_color}]{total_return:.1%}[/{ret_color}]")
1189 left_table.add_row("CAGR", f"[{cagr_color}]{cagr:.1%}[/{cagr_color}]")
1190 left_table.add_row("WIN RATE", f"{stats.get('win_rate', 0):.1%}")
1192 # Right column: Risk metrics
1193 sharpe = stats.get("sharpe_ratio", 0)
1194 sortino = stats.get("sortino_ratio", 0)
1195 calmar = stats.get("calmar_ratio", 0)
1196 max_dd = stats.get("max_drawdown", 0)
1197 vol = stats.get("annual_volatility", 0)
1199 # Color code risk metrics
1200 sharpe_color = "green" if sharpe > 1 else "yellow" if sharpe > 0.5 else "red"
1201 dd_color = "green" if max_dd > -0.1 else "yellow" if max_dd > -0.2 else "red"
1203 right_table.add_row("SHARPE", f"[{sharpe_color}]{sharpe:.2f}[/{sharpe_color}]")
1204 right_table.add_row("SORTINO", f"{sortino:.2f}")
1205 right_table.add_row("CALMAR", f"{calmar:.2f}")
1206 right_table.add_row("MAX DRAWDOWN", f"[{dd_color}]{max_dd:.1%}[/{dd_color}]")
1207 right_table.add_row("VOLATILITY", f"{vol:.1%}")
1209 return Panel(
1210 Columns([left_table, right_table], expand=True),
1211 title="[bold cyan]PERFORMANCE METRICS[/bold cyan]",
1212 box=box.HEAVY,
1213 )
1216def create_linechart_panel(daily_df, metric: str, color: str, y_label: str) -> Panel:
1217 """Create line chart panel using plotext."""
1219 def plot_line(plt):
1220 plt.plot(daily_df[metric], marker="braille", color=color)
1221 plt.xlabel("Days")
1222 plt.ylabel(y_label)
1224 return create_plotext_panel(
1225 plot_line,
1226 f"[bold cyan]{metric.upper()}[/bold cyan]",
1227 style=color,
1228 height=9,
1229 width=40,
1230 )
1233def create_backtest_distributions(daily_df) -> Panel:
1234 """Create backtest distributions using standardized small-multiples like optimize."""
1235 series_map: dict[str, dict[str, Any]] = {}
1237 # Returns distribution
1238 if "returns" in daily_df.columns:
1239 returns = daily_df["returns"].to_list()
1240 if returns:
1241 series_map["RETURNS"] = {"values": returns, "color": "green", "bins": 15}
1243 # Drawdown distribution
1244 if "drawdown" in daily_df.columns:
1245 drawdowns = daily_df["drawdown"].to_list()
1246 if drawdowns:
1247 series_map["DRAWDOWN"] = {"values": drawdowns, "color": "red", "bins": 15}
1249 # Turnover distribution (non-zero only)
1250 if "turnover" in daily_df.columns:
1251 turnovers = daily_df["turnover"].to_list()
1252 turnovers_nz = [t for t in turnovers if t and t != 0]
1253 if turnovers_nz:
1254 series_map["TURNOVER"] = {
1255 "values": turnovers_nz,
1256 "color": "yellow",
1257 "bins": 15,
1258 }
1260 panel = (
1261 create_small_multiples_panel(
1262 series_map, "[bold cyan]DAILY DISTRIBUTIONS[/bold cyan]"
1263 )
1264 if series_map
1265 else None
1266 )
1267 if panel is not None:
1268 return panel
1269 return Panel("[dim]No distribution data available[/dim]", box=box.HEAVY)
1272def create_latest_positions_panel(positions_df) -> Panel:
1273 """Create panel showing latest position snapshot."""
1274 import polars as pl
1276 if len(positions_df) == 0:
1277 return Panel("[dim]No position data[/dim]", box=box.HEAVY)
1279 # Get last rebalance date
1280 latest_date = positions_df["date"].max()
1281 latest_positions = positions_df.filter(positions_df["date"] == latest_date)
1283 # Sort by absolute weight
1284 latest_positions = latest_positions.with_columns(
1285 pl.col("weight").abs().alias("abs_weight")
1286 ).sort("abs_weight", descending=True)
1288 table = Table(
1289 show_header=True,
1290 box=box.SIMPLE_HEAD,
1291 header_style="bold cyan on #001926",
1292 padding=(0, 1),
1293 )
1295 table.add_column("COIN", style="cyan")
1296 table.add_column("WEIGHT", justify="right")
1298 # Count positions
1299 long_count = sum(
1300 1 for row in latest_positions.iter_rows(named=True) if row["weight"] > 0
1301 )
1302 short_count = len(latest_positions) - long_count
1304 for row in latest_positions.iter_rows(named=True):
1305 weight = row["weight"]
1306 weight_style = "green" if weight > 0 else "red"
1308 table.add_row(
1309 row["id"],
1310 f"[{weight_style}]{abs(weight):.1%}[/{weight_style}]",
1311 )
1313 title = (
1314 f"[bold cyan]FINAL POSITIONS[/bold cyan] [dim]│[/dim] "
1315 f"[green]{long_count}L[/green] [red]{short_count}S[/red] [dim]│[/dim] "
1316 f"{latest_date.strftime('%Y-%m-%d')}"
1317 )
1319 return Panel(table, title=title, box=box.HEAVY)
1322def create_enhanced_positions_table(positions_df, daily_df) -> Panel:
1323 """Create enhanced positions table with detailed statistics."""
1324 import polars as pl
1326 if len(positions_df) == 0:
1327 return Panel("[dim]No position data[/dim]", box=box.HEAVY)
1329 # Get unique dates for counting
1330 unique_dates = positions_df["date"].unique().sort()
1331 total_periods = len(unique_dates)
1333 # Get last rebalance date
1334 latest_date = positions_df["date"].max()
1336 # Calculate statistics for each asset
1337 position_stats = (
1338 positions_df.group_by("id")
1339 .agg(
1340 [
1341 # Count how many times this asset was held
1342 pl.col("weight")
1343 .filter(pl.col("weight") != 0)
1344 .count()
1345 .alias("times_held"),
1346 # Average weight when held (non-zero)
1347 pl.col("weight")
1348 .filter(pl.col("weight") != 0)
1349 .mean()
1350 .alias("avg_weight"),
1351 # Count longs vs shorts
1352 pl.col("weight")
1353 .filter(pl.col("weight") > 0)
1354 .count()
1355 .alias("times_long"),
1356 pl.col("weight")
1357 .filter(pl.col("weight") < 0)
1358 .count()
1359 .alias("times_short"),
1360 # First and last appearance
1361 pl.col("date").min().alias("first_date"),
1362 pl.col("date").max().alias("last_date"),
1363 # Current weight (from latest date)
1364 pl.col("weight")
1365 .filter(pl.col("date") == latest_date)
1366 .first()
1367 .alias("current_weight"),
1368 ]
1369 )
1370 .filter(pl.col("times_held") > 0) # Only include assets that were held
1371 )
1373 # Add a column for predominant side
1374 position_stats = position_stats.with_columns(
1375 pl.when(pl.col("times_long") > pl.col("times_short"))
1376 .then(pl.lit("LONG"))
1377 .when(pl.col("times_short") > pl.col("times_long"))
1378 .then(pl.lit("SHORT"))
1379 .otherwise(pl.lit("MIXED"))
1380 .alias("predominant_side")
1381 )
1383 # Sort by times held (frequency) and then by absolute average weight
1384 position_stats = position_stats.sort(
1385 [
1386 pl.col("times_held").is_not_null(), # Non-null first
1387 pl.col("times_held"),
1388 pl.col("avg_weight").abs(),
1389 ],
1390 descending=[True, True, True],
1391 )
1393 # Create the table
1394 table = Table(
1395 box=box.HEAVY_HEAD,
1396 show_lines=False,
1397 header_style="bold cyan on #001926",
1398 expand=True,
1399 )
1401 # Define columns
1402 table.add_column("COIN", style="cyan", width=8)
1403 table.add_column("SIDE", justify="center", width=8)
1404 table.add_column("FREQUENCY", justify="right", width=10)
1405 table.add_column("AVG WEIGHT", justify="right", width=10)
1406 table.add_column("CURRENT", justify="right", width=10)
1407 table.add_column("FIRST HELD", justify="center", width=12)
1408 table.add_column("LAST HELD", justify="center", width=12)
1409 table.add_column("CONSISTENCY", justify="center", width=12)
1411 # Add rows
1412 for row in position_stats.iter_rows(named=True):
1413 coin = row["id"]
1414 side = row["predominant_side"]
1415 times_held = row["times_held"] or 0
1416 avg_weight = row["avg_weight"] or 0
1417 current_weight = row["current_weight"] or 0
1418 first_date = row["first_date"]
1419 last_date = row["last_date"]
1421 # Determine side color
1422 if side == "LONG":
1423 side_style = "green"
1424 elif side == "SHORT":
1425 side_style = "red"
1426 else:
1427 side_style = "yellow"
1429 # Format frequency as fraction and percentage
1430 freq_pct = (times_held / total_periods * 100) if total_periods > 0 else 0
1431 freq_str = f"{times_held}/{total_periods} ({freq_pct:.0f}%)"
1433 # Format average weight
1434 avg_weight_str = f"[{'green' if avg_weight > 0 else 'red'}]{abs(avg_weight):.1%}[/{'green' if avg_weight > 0 else 'red'}]"
1436 # Format current weight
1437 if current_weight == 0:
1438 current_str = "[dim]-[/dim]"
1439 else:
1440 current_str = f"[{'green' if current_weight > 0 else 'red'}]{abs(current_weight):.1%}[/{'green' if current_weight > 0 else 'red'}]"
1442 # Format dates
1443 first_str = first_date.strftime("%Y-%m-%d") if first_date else "-"
1444 last_str = last_date.strftime("%Y-%m-%d") if last_date else "-"
1446 # Consistency indicator (visual bar)
1447 consistency_bar = create_data_bar(
1448 freq_pct, 100, width=8, filled_char="▓", empty_char="░"
1449 )
1450 consistency_color = (
1451 "green" if freq_pct >= 75 else "yellow" if freq_pct >= 50 else "dim"
1452 )
1453 consistency_str = (
1454 f"[{consistency_color}]{consistency_bar}[/{consistency_color}]"
1455 )
1457 table.add_row(
1458 f"[bold]{coin}[/bold]",
1459 f"[{side_style}]{side}[/{side_style}]",
1460 freq_str,
1461 avg_weight_str,
1462 current_str,
1463 first_str,
1464 last_str,
1465 consistency_str,
1466 )
1468 # Count current positions
1469 current_positions = position_stats.filter(pl.col("current_weight") != 0)
1470 current_longs = len(current_positions.filter(pl.col("current_weight") > 0))
1471 current_shorts = len(current_positions.filter(pl.col("current_weight") < 0))
1473 # Calculate some summary stats
1474 total_unique_assets = len(position_stats)
1475 most_consistent = position_stats.head(1)
1476 if len(most_consistent) > 0:
1477 top_asset = most_consistent["id"][0]
1478 top_freq = most_consistent["times_held"][0]
1479 top_pct = (top_freq / total_periods * 100) if total_periods > 0 else 0
1480 consistency_note = f"Most consistent: {top_asset} ({top_pct:.0f}%)"
1481 else:
1482 consistency_note = ""
1484 title = (
1485 f"[bold cyan]POSITION ANALYSIS[/bold cyan] [dim]│[/dim] "
1486 f"{total_unique_assets} unique assets over {total_periods} periods [dim]│[/dim] "
1487 f"Current: [green]{current_longs}L[/green] [red]{current_shorts}S[/red]"
1488 )
1490 if consistency_note:
1491 title += f" [dim]│[/dim] {consistency_note}"
1493 return Panel(table, title=title, box=box.HEAVY)
1496def display_optimization_results(
1497 console: Console, results_df, metric: str, top_n: int = 20, config=None
1498):
1499 """Display optimization results."""
1501 # Disclaimer warning first
1502 disclaimer = Panel(
1503 Text.from_markup(
1504 "[yellow]\n Optimized parameters are based on historical data and may be overfit. "
1505 "Past optimal parameters may not remain optimal in future market conditions.[/yellow]\n",
1506 justify="center",
1507 ),
1508 title="[bold yellow] OPTIMIZATION DISCLAIMER [/bold yellow]",
1509 box=box.HEAVY,
1510 border_style="yellow",
1511 )
1512 console.print(disclaimer)
1514 layout = Layout()
1515 layout.split_column(Layout(name="header", size=3), Layout(name="body"))
1517 # Header
1518 header = Panel(
1519 Text(
1520 f"OPTIMIZATION RESULTS :: {metric.upper()}",
1521 style="bold cyan",
1522 justify="center",
1523 ),
1524 box=DOUBLE,
1525 style="cyan",
1526 )
1527 layout["header"].update(header)
1529 # Body: Main results + best params summary
1530 layout["body"].split_row(
1531 Layout(name="results", ratio=3), Layout(name="summary", ratio=1)
1532 )
1534 # Results table
1535 results_panel = create_optimization_results_table(results_df, metric, top_n)
1536 layout["results"].update(results_panel)
1538 # Summary panel with best parameters
1539 if len(results_df) > 0:
1540 summary_panel = create_optimization_summary_panel(
1541 results_df.head(1), metric, config
1542 )
1543 layout["summary"].update(summary_panel)
1544 else:
1545 layout["summary"].update(Panel("[dim]No results[/dim]", box=box.HEAVY))
1547 console.print(layout)
1549 sm_panel = create_optimization_small_multiples_panel(results_df)
1550 if sm_panel is not None:
1551 console.print(sm_panel)
1554def _create_hist_panel(
1555 values, title: str, color: str, bins: int = 15, width: int = 24, height: int = 6
1556) -> Panel:
1557 """Create a compact histogram panel for small multiples."""
1559 def plot_hist(plt):
1560 if not values:
1561 return
1562 plt.hist(values, bins=bins, color=color)
1563 plt.xlabel("")
1564 plt.ylabel("")
1566 return create_plotext_panel(
1567 plot_hist, f"[bold]{title}[/bold]", width=width, height=height, style=color
1568 )
1571def create_small_multiples_panel(
1572 series_map: dict[str, dict[str, Any]], title: str
1573) -> Panel | None:
1574 """Create a small-multiples panel from a mapping of label -> values."""
1575 panels = []
1576 # Fixed sizes to encourage consistent visual scale
1577 for label, conf in series_map.items():
1578 values = conf.get("values", [])
1579 if values is None:
1580 values = []
1581 color = conf.get("color", "cyan")
1582 bins = conf.get("bins", 15)
1583 p = _create_hist_panel(values, label, color=color, bins=bins)
1584 panels.append(p)
1585 if not panels:
1586 return None
1587 return Panel(Columns(panels, expand=True), title=title, box=box.HEAVY)
1590def create_optimization_small_multiples_panel(results_df) -> Panel | None:
1591 """Build small-multiples histograms for optimization metrics: Sharpe, CAGR, Calmar, Max DD, Volatility."""
1592 try:
1593 cols = set(results_df.columns)
1594 series_map = {}
1595 if "sharpe" in cols:
1596 series_map["SHARPE"] = {
1597 "values": results_df["sharpe"].to_list(),
1598 "color": "cyan",
1599 }
1600 if "cagr" in cols:
1601 series_map["CAGR"] = {
1602 "values": results_df["cagr"].to_list(),
1603 "color": "green",
1604 }
1605 if "calmar" in cols:
1606 series_map["CALMAR"] = {
1607 "values": results_df["calmar"].to_list(),
1608 "color": "blue",
1609 }
1610 if "max_dd" in cols:
1611 series_map["MAX DD"] = {
1612 "values": results_df["max_dd"].to_list(),
1613 "color": "red",
1614 }
1615 if "volatility" in cols:
1616 series_map["VOL"] = {
1617 "values": results_df["volatility"].to_list(),
1618 "color": "yellow",
1619 }
1620 if not series_map:
1621 return None
1622 return create_small_multiples_panel(
1623 series_map, "[bold cyan]METRIC DISTRIBUTIONS[/bold cyan]"
1624 )
1625 except Exception:
1626 return None
1629def create_optimization_results_table(results_df, metric: str, top_n: int) -> Panel:
1630 """Create optimization results table panel."""
1631 table = Table(
1632 box=box.HEAVY_HEAD,
1633 show_lines=False,
1634 header_style="bold cyan on #001926",
1635 expand=True,
1636 )
1638 # Add columns with consistent styling
1639 table.add_column("RANK", style="dim", width=4, justify="right")
1640 table.add_column("LONG", style="green", justify="right", width=6)
1641 table.add_column("SHORT", style="red", justify="right", width=6)
1642 table.add_column("LEV", style="yellow", justify="right", width=6)
1643 table.add_column("DAYS", style="cyan", justify="right", width=6)
1644 table.add_column("POWER", style="magenta", justify="right", width=6)
1645 table.add_column(
1646 "SHARPE",
1647 justify="right",
1648 width=8,
1649 style="bold white" if metric == "sharpe" else "dim",
1650 )
1651 table.add_column(
1652 "CAGR",
1653 justify="right",
1654 width=8,
1655 style="bold white" if metric == "cagr" else "dim",
1656 )
1657 table.add_column(
1658 "CALMAR",
1659 justify="right",
1660 width=8,
1661 style="bold white" if metric == "calmar" else "dim",
1662 )
1663 table.add_column("MAX DD", justify="right", width=8)
1664 table.add_column("EQUITY", justify="right", width=10)
1666 for i, row in enumerate(results_df.head(top_n).iter_rows(named=True), 1):
1667 # Color code metrics based on performance
1668 sharpe = row["sharpe"]
1669 sharpe_color = "green" if sharpe > 1 else "yellow" if sharpe > 0.5 else "red"
1671 dd = row["max_dd"]
1672 dd_color = "green" if dd > -0.1 else "yellow" if dd > -0.2 else "red"
1674 cagr = row["cagr"]
1675 cagr_color = "green" if cagr > 0.2 else "yellow" if cagr > 0 else "red"
1677 # Special highlighting for top 3
1678 rank_style = "bold cyan" if i == 1 else "cyan" if i <= 3 else "dim"
1680 table.add_row(
1681 f"[{rank_style}]{i}[/{rank_style}]",
1682 str(row["num_long"]),
1683 str(row["num_short"]),
1684 f"{row['leverage']:.1f}x",
1685 str(row["rebalance_days"]),
1686 f"{row['rank_power']:.1f}",
1687 f"[{sharpe_color}]{sharpe:.2f}[/{sharpe_color}]",
1688 f"[{cagr_color}]{cagr:.1%}[/{cagr_color}]",
1689 f"{row['calmar']:.2f}",
1690 f"[{dd_color}]{dd:.1%}[/{dd_color}]",
1691 format_currency(row["final_equity"], compact=True),
1692 )
1694 # Calculate title with statistics
1695 total_tested = len(results_df)
1696 positive_sharpe = sum(
1697 1 for row in results_df.iter_rows(named=True) if row["sharpe"] > 0
1698 )
1700 title = (
1701 f"[bold cyan]TOP {top_n} COMBINATIONS[/bold cyan] [dim]│[/dim] "
1702 f"Tested {total_tested} [dim]│[/dim] "
1703 f"Positive Sharpe {positive_sharpe}/{total_tested} ({positive_sharpe / total_tested * 100:.1f}%)"
1704 )
1706 return Panel(table, title=title, box=box.HEAVY)
1709def create_optimization_summary_panel(best_row_df, metric: str, config=None) -> Panel:
1710 """Create summary panel for best parameters."""
1711 best = best_row_df.iter_rows(named=True).__next__()
1713 # Create metrics table
1714 table = Table(show_header=False, box=None, padding=(0, 1))
1715 table.add_column("", width=12)
1716 table.add_column("", justify="right")
1718 # Best configuration
1719 table.add_row("[bold]BEST CONFIG[/bold]", "")
1720 table.add_row("", "")
1721 table.add_row("LONG", f"[green]{best['num_long']}[/green]")
1722 table.add_row("SHORT", f"[red]{best['num_short']}[/red]")
1723 table.add_row("LEVERAGE", f"[yellow]{best['leverage']:.1f}x[/yellow]")
1724 table.add_row("REBALANCE", f"{best['rebalance_days']} days")
1725 table.add_row("RANK POWER", f"[magenta]{best['rank_power']:.1f}[/magenta]")
1726 table.add_row("", "")
1728 # Performance metrics
1729 table.add_row("[bold]METRICS[/bold]", "")
1730 table.add_row("", "")
1732 # Highlight the optimization metric
1733 if metric == "sharpe":
1734 table.add_row("SHARPE", f"[bold cyan]{best['sharpe']:.2f}[/bold cyan]")
1735 table.add_row("CAGR", f"{best['cagr']:.1%}")
1736 table.add_row("CALMAR", f"{best['calmar']:.2f}")
1737 elif metric == "cagr":
1738 table.add_row("SHARPE", f"{best['sharpe']:.2f}")
1739 table.add_row("CAGR", f"[bold cyan]{best['cagr']:.1%}[/bold cyan]")
1740 table.add_row("CALMAR", f"{best['calmar']:.2f}")
1741 else: # calmar
1742 table.add_row("SHARPE", f"{best['sharpe']:.2f}")
1743 table.add_row("CAGR", f"{best['cagr']:.1%}")
1744 table.add_row("CALMAR", f"[bold cyan]{best['calmar']:.2f}[/bold cyan]")
1746 table.add_row("MAX DD", f"{best['max_dd']:.1%}")
1747 table.add_row("VOLATILITY", f"{best['volatility']:.1%}")
1748 table.add_row("", "")
1749 table.add_row("FINAL EQUITY", format_currency(best["final_equity"]))
1751 # Add data source information if config is provided
1752 if config:
1753 table.add_row("", "")
1754 table.add_row("[bold]DATA[/bold]", "")
1755 table.add_row("", "")
1757 # Provider
1758 provider = getattr(config, "data_provider", None)
1759 if provider:
1760 provider_color = (
1761 "green"
1762 if provider == "crowdcent"
1763 else "yellow"
1764 if provider == "numerai"
1765 else "white"
1766 )
1767 table.add_row(
1768 "PROVIDER", f"[{provider_color}]{provider}[/{provider_color}]"
1769 )
1771 # Source file
1772 source_name = (
1773 config.predictions_path.split("/")[-1]
1774 if "/" in config.predictions_path
1775 else config.predictions_path
1776 )
1777 table.add_row(
1778 "SOURCE", source_name[:12] + "..." if len(source_name) > 15 else source_name
1779 )
1781 # Prediction column
1782 table.add_row("PRED COL", str(config.pred_value_column)[:15])
1784 return Panel(
1785 table, title=f"[bold cyan]BEST BY {metric.upper()}[/bold cyan]", box=box.HEAVY
1786 )
1789def create_optimization_progress_display(
1790 current: int,
1791 total: int,
1792 current_params: dict,
1793 best_so_far: dict | None = None,
1794 elapsed_time: float = 0,
1795) -> Layout:
1796 """Create live progress display for optimization."""
1797 from rich.layout import Layout
1798 from rich.progress import Progress, BarColumn, TextColumn, TimeRemainingColumn
1799 from rich.align import Align
1801 layout = Layout()
1802 layout.split_column(
1803 Layout(name="header", size=3),
1804 Layout(name="body", size=12),
1805 Layout(name="footer", size=3),
1806 )
1808 # Header
1809 header = Panel(
1810 Text("OPTIMIZATION IN PROGRESS", style="bold cyan blink", justify="center"),
1811 box=DOUBLE,
1812 style="cyan",
1813 )
1814 layout["header"].update(header)
1816 # Body: Progress bar and current/best params
1817 layout["body"].split_column(
1818 Layout(name="progress", size=5), Layout(name="params", size=7)
1819 )
1821 # Create progress bar
1822 progress = Progress(
1823 TextColumn("[bold cyan]Testing:[/bold cyan]"),
1824 BarColumn(bar_width=40, style="cyan", complete_style="green"),
1825 TextColumn("{task.completed}/{task.total}"),
1826 TextColumn("[cyan]{task.percentage:>3.0f}%[/cyan]"),
1827 TimeRemainingColumn(),
1828 expand=False,
1829 )
1831 task = progress.add_task("Optimization", total=total, completed=current)
1832 progress.update(task, completed=current)
1834 progress_panel = Panel(
1835 Align.center(progress, vertical="middle"),
1836 box=box.HEAVY,
1837 title="[bold cyan]PROGRESS[/bold cyan]",
1838 )
1839 layout["progress"].update(progress_panel)
1841 # Parameters display
1842 layout["params"].split_row(
1843 Layout(name="current", ratio=1), Layout(name="best", ratio=1)
1844 )
1846 # Current parameters
1847 current_table = Table(show_header=False, box=None, padding=(0, 1))
1848 current_table.add_column("", width=12)
1849 current_table.add_column("", justify="right")
1851 current_table.add_row("COMBINATION", f"{current}/{total}")
1852 current_table.add_row("", "")
1853 current_table.add_row(
1854 "LONG", f"[green]{current_params.get('num_long', '-')}[/green]"
1855 )
1856 current_table.add_row("SHORT", f"[red]{current_params.get('num_short', '-')}[/red]")
1857 current_table.add_row(
1858 "LEVERAGE", f"[yellow]{current_params.get('leverage', 0):.1f}x[/yellow]"
1859 )
1860 current_table.add_row("DAYS", f"{current_params.get('rebalance_days', '-')}")
1862 layout["current"].update(
1863 Panel(current_table, title="[bold cyan]TESTING[/bold cyan]", box=box.HEAVY)
1864 )
1866 # Best so far
1867 if best_so_far:
1868 best_table = Table(show_header=False, box=None, padding=(0, 1))
1869 best_table.add_column("", width=12)
1870 best_table.add_column("", justify="right")
1872 best_table.add_row(
1873 "SHARPE", f"[green]{best_so_far.get('sharpe', 0):.2f}[/green]"
1874 )
1875 best_table.add_row("CAGR", f"{best_so_far.get('cagr', 0):.1%}")
1876 best_table.add_row("", "")
1877 best_table.add_row("CONFIG", "")
1878 best_table.add_row(
1879 "L/S",
1880 f"{best_so_far.get('num_long', '-')}/{best_so_far.get('num_short', '-')}",
1881 )
1882 best_table.add_row("LEV", f"{best_so_far.get('leverage', 0):.1f}x")
1884 layout["best"].update(
1885 Panel(
1886 best_table,
1887 title="[bold green]BEST SO FAR[/bold green]",
1888 box=box.HEAVY,
1889 border_style="green",
1890 )
1891 )
1892 else:
1893 layout["best"].update(
1894 Panel(
1895 "[dim]No results yet[/dim]",
1896 title="[bold]BEST SO FAR[/bold]",
1897 box=box.HEAVY,
1898 )
1899 )
1901 # Footer
1902 eta = (elapsed_time / current * (total - current)) if current > 0 else 0
1903 eta_str = f"{int(eta // 60)}:{int(eta % 60):02d}" if eta > 0 else "--:--"
1905 footer_text = (
1906 f"[dim]Elapsed: {int(elapsed_time // 60)}:{int(elapsed_time % 60):02d} │ "
1907 f"ETA: {eta_str} │ "
1908 f"Rate: {current / elapsed_time:.1f}/s[/dim]"
1909 if elapsed_time > 0
1910 else "[dim]Starting...[/dim]"
1911 )
1913 layout["footer"].update(Panel(footer_text, box=box.HEAVY, style="dim"))
1915 return layout
1918def display_optimization_contours(console: Console, results_df, metric: str):
1919 """Display parameter heatmaps using ASCII visualization as small multiples."""
1920 import polars as pl
1922 leverages = results_df["leverage"].unique().sort().to_list()
1923 if not leverages:
1924 console.print(Panel("[dim]No data to visualize[/dim]", box=box.HEAVY))
1925 return
1927 header = Panel(
1928 Text(
1929 f"PARAMETER HEATMAPS :: {metric.upper()}",
1930 style="bold cyan",
1931 justify="center",
1932 ),
1933 box=DOUBLE,
1934 style="cyan",
1935 )
1936 console.print(header)
1938 # Collect heatmap panels for a grid layout (up to 2x3)
1939 heatmap_panels = []
1940 for leverage in leverages:
1941 df = results_df.filter(pl.col("leverage") == leverage)
1943 if len(df) < 4:
1944 heatmap_panels.append(
1945 Panel(
1946 "[dim]Insufficient data[/dim]",
1947 title=f"[bold]LEV {leverage:.1f}x[/bold]",
1948 box=box.HEAVY,
1949 height=12,
1950 )
1951 )
1952 continue
1954 pivot = df.pivot(
1955 index="num_long", on="num_short", values=metric, aggregate_function="mean"
1956 )
1958 longs = sorted(pivot["num_long"].to_list())
1959 shorts = sorted([c for c in pivot.columns if c != "num_long"])
1961 heatmap_str = create_ascii_heatmap(pivot, longs, shorts, metric)
1962 best = df[metric].max()
1963 worst = df[metric].min()
1964 subtitle = (
1965 f"[bold cyan]LEV {leverage:.1f}x[/bold cyan] [dim]│[/dim] "
1966 f"Best {best:.3f} [dim]│[/dim] Worst {worst:.3f}"
1967 )
1969 heatmap_panels.append(
1970 Panel(
1971 heatmap_str,
1972 title=subtitle,
1973 box=box.HEAVY,
1974 height=12,
1975 )
1976 )
1978 # Arrange panels into small multiples grid and print once
1979 if heatmap_panels:
1980 num_panels = len(heatmap_panels)
1981 # Choose columns per row for pleasant layout
1982 if num_panels == 1:
1983 cols_per_row = 1
1984 elif num_panels in (2, 4):
1985 cols_per_row = 2
1986 else:
1987 cols_per_row = 3
1989 rows = []
1990 for i in range(0, num_panels, cols_per_row):
1991 rows.append(Columns(heatmap_panels[i : i + cols_per_row], expand=True))
1993 from rich.table import Table as RichTable
1995 container = RichTable.grid(expand=True)
1996 for row in rows:
1997 container.add_row(row)
1999 console.print(container)
2002def create_ascii_heatmap(pivot_df, longs: list, shorts: list, metric: str) -> str:
2003 """Create an ASCII heatmap representation with sorted axes, legend-aware colors, and best-cell emphasis."""
2004 import polars as pl
2006 # Ensure numeric sort order (5, 10, 15 ...)
2007 longs = sorted(longs)
2008 shorts = sorted(shorts)
2010 # Find best cell for emphasis
2011 best_val = None
2012 best_coord = None
2013 for long_val in longs:
2014 row = pivot_df.filter(pl.col("num_long") == long_val)
2015 for short_val in shorts:
2016 if len(row) > 0 and str(short_val) in row.columns:
2017 val = row[str(short_val)][0]
2018 if val is not None and (best_val is None or val > best_val):
2019 best_val = val
2020 best_coord = (long_val, short_val)
2022 # Build the heatmap as a string
2023 lines = []
2024 header = " L\\S │ " + " ".join(f"{s:>4}" for s in shorts)
2025 lines.append(header)
2026 lines.append("─" * len(header))
2028 for long_val in longs:
2029 row_data = pivot_df.filter(pl.col("num_long") == long_val)
2030 row_str = f" {long_val:>6} │ "
2031 if len(row_data) > 0:
2032 for short_val in shorts:
2033 if str(short_val) in row_data.columns:
2034 val = row_data[str(short_val)][0]
2035 if val is not None:
2036 # Color buckets for sharpe-like metrics
2037 if metric == "sharpe":
2038 if val > 1.5:
2039 txt = f"[green]{val:>4.1f}[/green]"
2040 elif val > 0:
2041 txt = f"[yellow]{val:>4.1f}[/yellow]"
2042 else:
2043 txt = f"[red]{val:>4.1f}[/red]"
2044 else:
2045 txt = f"{val:>4.1f}"
2046 # Emphasize best cell
2047 if best_coord == (long_val, short_val):
2048 txt = f"[bold]{txt}[/bold]"
2049 row_str += txt + " "
2050 else:
2051 row_str += " - "
2052 else:
2053 row_str += " - "
2054 else:
2055 row_str += " - " * len(shorts)
2056 lines.append(row_str)
2057 return "\n".join(lines)
2060def create_open_orders_panel(orders: list["Order"]) -> Panel:
2061 """Create panel showing active orders."""
2062 from rich.table import Table
2063 from rich.panel import Panel
2064 from rich import box
2066 table = Table(
2067 box=box.HEAVY_HEAD,
2068 show_lines=False,
2069 header_style="bold cyan on #001926",
2070 expand=True,
2071 )
2073 table.add_column("COIN", style="cyan", width=8)
2074 table.add_column("SIDE", justify="center", width=6)
2075 table.add_column("STRATEGY", justify="center", width=12)
2076 table.add_column("PROGRESS", justify="right", width=10)
2077 table.add_column("FILLED", justify="right", width=12)
2078 table.add_column("SLIPPAGE", justify="right", width=10)
2079 table.add_column("STATUS", justify="center", width=12)
2080 table.add_column("AGE", justify="right", width=8)
2082 now = datetime.now(timezone.utc)
2084 for order in sorted(orders, key=lambda o: o.timestamp, reverse=True):
2085 # Calculate progress
2086 progress = (order.filled_size / order.size * 100) if order.size > 0 else 0
2088 # Format filled size
2089 filled_str = f"{order.filled_size:.2f}/{order.size:.2f}"
2091 # Slippage color
2092 if order.slippage_bps is not None:
2093 slippage_color = "green" if order.slippage_bps <= 5 else "yellow" if order.slippage_bps <= 15 else "red"
2094 slippage_str = f"[{slippage_color}]{order.slippage_bps:+.1f}bps[/{slippage_color}]"
2095 else:
2096 slippage_str = "[dim]-[/dim]"
2098 # Status color
2099 status_colors = {
2100 "pending": "yellow",
2101 "open": "cyan",
2102 "partially_filled": "blue",
2103 "filled": "green",
2104 "cancelled": "dim",
2105 "failed": "red"
2106 }
2107 status_color = status_colors.get(order.status, "white")
2108 status_str = f"[{status_color}]{order.status.upper().replace('_', ' ')}[/{status_color}]"
2110 # Age
2111 age = now - order.timestamp
2112 if age.total_seconds() < 3600:
2113 age_str = f"{int(age.total_seconds() / 60)}m"
2114 elif age.total_seconds() < 86400:
2115 age_str = f"{age.total_seconds() / 3600:.1f}h"
2116 else:
2117 age_str = f"{age.days}d"
2119 table.add_row(
2120 f"[bold]{order.coin}[/bold]",
2121 f"[{'green' if order.side == 'BUY' else 'red'}]{order.side}[/{'green' if order.side == 'BUY' else 'red'}]",
2122 order.strategy.replace("_", " ").upper(),
2123 f"{progress:.0f}%",
2124 filled_str,
2125 slippage_str,
2126 status_str,
2127 age_str
2128 )
2130 title = f"[bold cyan]OPEN ORDERS[/bold cyan] [dim]│[/dim] {len(orders)} active"
2131 return Panel(table, title=title, box=box.HEAVY)
2134def create_order_history_panel(history: "OrderHistory", days: int) -> Panel:
2135 """Create panel showing order history and stats."""
2136 from rich.table import Table
2137 from rich.panel import Panel
2138 from rich.columns import Columns
2139 from rich import box
2141 # Stats table
2142 stats_table = Table(show_header=False, box=None, padding=(0, 1))
2143 stats_table.add_column("", width=18)
2144 stats_table.add_column("", justify="right")
2146 stats_table.add_row("[bold]SUMMARY[/bold]", "")
2147 stats_table.add_row("PERIOD", f"{days} days")
2148 stats_table.add_row("TOTAL ORDERS", str(len(history.orders)))
2149 stats_table.add_row("FILL RATE", f"{history.fill_rate:.1%}")
2150 stats_table.add_row("", "")
2151 stats_table.add_row("[bold]PERFORMANCE[/bold]", "")
2153 slippage_color = "green" if history.total_slippage_bps <= 10 else "yellow" if history.total_slippage_bps <= 25 else "red"
2154 stats_table.add_row(
2155 "AVG SLIPPAGE",
2156 f"[{slippage_color}]{history.total_slippage_bps:.1f}bps[/{slippage_color}]"
2157 )
2158 stats_table.add_row("TOTAL FEES", format_currency(history.total_fees))
2160 if history.avg_fill_time_minutes > 0:
2161 stats_table.add_row("AVG FILL TIME", f"{history.avg_fill_time_minutes:.0f}min")
2163 # Recent orders table
2164 orders_table = Table(
2165 box=box.SIMPLE_HEAD,
2166 show_lines=False,
2167 header_style="bold cyan",
2168 )
2170 orders_table.add_column("DATE", width=12)
2171 orders_table.add_column("COIN", width=6)
2172 orders_table.add_column("SIDE", width=4)
2173 orders_table.add_column("SIZE", justify="right", width=10)
2174 orders_table.add_column("SLIP", justify="right", width=8)
2175 orders_table.add_column("STATUS", width=10)
2177 for order in history.orders[:20]: # Show last 20
2178 date_str = order.timestamp.strftime("%Y-%m-%d")
2179 side_color = "green" if order.side == "BUY" else "red"
2181 if order.slippage_bps is not None:
2182 slippage_color = "green" if abs(order.slippage_bps) <= 10 else "yellow"
2183 slip_str = f"[{slippage_color}]{order.slippage_bps:+.1f}[/{slippage_color}]"
2184 else:
2185 slip_str = "-"
2187 status_color = "green" if order.status == "filled" else "red" if order.status == "failed" else "dim"
2189 orders_table.add_row(
2190 date_str,
2191 order.coin,
2192 f"[{side_color}]{order.side[0]}[/{side_color}]",
2193 f"{order.filled_size:.2f}",
2194 slip_str,
2195 f"[{status_color}]{order.status[:8]}[/{status_color}]"
2196 )
2198 layout = Columns([
2199 Panel(stats_table, title="[bold cyan]STATS[/bold cyan]", box=box.HEAVY),
2200 Panel(orders_table, title="[bold cyan]RECENT ORDERS[/bold cyan]", box=box.HEAVY)
2201 ], expand=True)
2203 return Panel(layout, title=f"[bold cyan]ORDER HISTORY :: {days} DAYS[/bold cyan]", box=box.HEAVY)