Coverage for src/cc_liquid/cli_display.py: 0%

1"""Display utilities for rendering structured data."""

2

3 from datetime import datetime, timezone

4 from typing import TYPE_CHECKING, Any

5

6 from rich import box

7 from rich.box import DOUBLE

8 from rich.columns import Columns

9 from rich.console import Console, Group

10 from rich.layout import Layout

11 from rich.panel import Panel

12 from rich.table import Table

13 from rich.text import Text

14 import re

15

16 if TYPE_CHECKING:

17 from .trader import AccountInfo, PortfolioInfo

18 from .order import Order, OrderHistory

19 from .stop_loss import StopLossOrder

20

21

22 def 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)

26

27

28 def 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")

38

39

40 def 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 )

65

66

67 def 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

72

73 # Clear and configure plotext

74 plt.clf()

75 plt.plotsize(width, height)

76

77 # Execute the plotting function

78 plot_func(plt)

79

80 # Get clean output

81 chart_str = plt.build()

82 clean_chart = strip_ansi_codes(chart_str)

83

84 return Panel(Text(clean_chart, style=style), title=title, box=box.HEAVY)

85

86

87 def 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}"

101

102

103 def 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}"

110

111

112 def 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)

115

116

117 def 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 )

125

126

127 def 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")

132

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 )

141

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 ]

150

151 for row in rows:

152 table.add_row(*row)

153

154 return table

155

156

157 def 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)

163

164 account_val = (

165 portfolio.account.account_value if portfolio.account.account_value > 0 else 1

166 )

167

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!

173

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 ]

198

199 for row in rows:

200 table.add_row(*row)

201

202 return table

203

204

205 def 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 )

218

219

220 def create_positions_panel(portfolio: "PortfolioInfo") -> Panel:

221 """Create a panel displaying all open positions with summary statistics.

222

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.

225

226 Args:

227 portfolio (PortfolioInfo): The portfolio containing positions and account info.

228

229 Returns:

230 Panel: A rich Panel containing the positions table and summary.

231 """

232 positions = portfolio.positions

233

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 )

240

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"

249

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 )

255

256 table = Table(

257 box=box.HEAVY_HEAD,

258 show_lines=False,

259 header_style="bold cyan on #001926",

260 expand=True,

261 )

262

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)

272

273 # Sort positions by absolute value

274 sorted_positions = sorted(positions, key=lambda p: abs(p.value), reverse=True)

275

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"

279

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}"

287

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 )

298

299 return Panel(table, title=title, box=box.HEAVY)

300

301

302 def 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)

307

308

309 def 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)

316

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)

325

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}"

332

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]"

343

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]"

354

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

360

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 )

369

370 return Panel(status_grid, box=box.HEAVY)

371

372

373 def 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.

384

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 )

392

393 layout = Layout()

394

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

403

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 )

413

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 )

418

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 )

431

432 layout["metrics"].update(create_metrics_panel(portfolio))

433 layout["positions"].update(create_positions_panel(portfolio))

434

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

440

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)

447

448 return layout

449

450

451 def 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")

456

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"

460

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 )

471

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)

479

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)

487

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"

494

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 ]

527

528 for row in rows:

529 table.add_row(*row)

530

531 return table

532

533

534 def 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()

542

543 # Use the new dashboard layout

544 layout = create_dashboard_layout(portfolio, config_dict)

545 console.print(layout)

546

547

548 def 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 )

555

556

557 def 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)}")

564

565

566 def show_pre_alpha_warning() -> None:

567 """Display pre-alpha warning to users."""

568 console = Console()

569 warning_copy = """

570 This 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.

575

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)

586

587

588 def 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)

603

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)

609

610 # Trades panel

611 trades_panel = create_trades_panel(trades)

612 console.print(trades_panel)

613

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 )

623

624

625 def 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")

636

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

640

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

645

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 )

660

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

664

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

676

677 return Panel(

678 Columns([left_table, right_table], expand=True),

679 title="[bold cyan]METRICS[/bold cyan]",

680 box=box.HEAVY,

681 )

682

683

684 def 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 )

693

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)]

697

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 )

708

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]"

713

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 )

721

722 table = Table(

723 box=box.HEAVY_HEAD,

724 show_lines=False,

725 header_style="bold cyan on #001926",

726 expand=True,

727 )

728

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)

739

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 )

744

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)

751

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

762

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}]"

770

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}]"

777

778 # Trade direction

779 trade_action = "[green]BUY[/green]" if is_buy else "[red]SELL[/red]"

780

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}]"

784

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 )

805

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 )

817

818 return Panel(table, title=title, box=box.HEAVY, expand=True)

819

820

821 def 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

830

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

837

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

846

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

851

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

865

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}%")

874

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

878

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 )

898

899 return Panel(

900 Columns([left_table, right_table], expand=True),

901 title="[bold cyan]METRICS[/bold cyan]",

902 box=box.HEAVY,

903 )

904

905

906 def 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 ]

915

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 )

923

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 )

929

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 )

936

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)

946

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"

954

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 )

965

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"

970

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 )

981

982 panel = Panel(table, title=title, box=box.HEAVY)

983 return panel

984

985

986 def 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.

994

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)

1006

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)

1012

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

1019

1020

1021 def display_backtest_summary(

1022 console: Console, result, config=None, show_positions=False

1023):

1024 """Display comprehensive backtest results in a cleaner sequential layout

1025

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

1032

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)

1045

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)

1057

1058 # 2-3. Metrics + Charts stacked on the left, Config as a persistent right sidebar

1059 metrics_panel = create_backtest_metrics_panel(result.stats)

1060

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)

1073

1074 top_row = Columns([equity_panel, drawdown_panel], expand=True)

1075 left_group = Group(metrics_panel, top_row, dist_panel)

1076

1077 summary_layout = Layout()

1078 summary_layout.split_row(

1079 Layout(name="main", ratio=3), Layout(name="config", ratio=1)

1080 )

1081

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 )

1092

1093 console.print(summary_layout)

1094

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)

1101

1102

1103 def 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")

1108

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

1119

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

1137

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

1143

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

1163

1164 return Panel(tree, title="[bold cyan]BACKTEST CONFIG[/bold cyan]", box=box.HEAVY)

1165

1166

1167 def 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")

1173

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

1177

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)

1182

1183 ret_color = "green" if total_return >= 0 else "red"

1184 cagr_color = "green" if cagr >= 0 else "red"

1185

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%}")

1191

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)

1198

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"

1202

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%}")

1208

1209 return Panel(

1210 Columns([left_table, right_table], expand=True),

1211 title="[bold cyan]PERFORMANCE METRICS[/bold cyan]",

1212 box=box.HEAVY,

1213 )

1214

1215

1216 def create_linechart_panel(daily_df, metric: str, color: str, y_label: str) -> Panel:

1217 """Create line chart panel using plotext."""

1218

1219 def plot_line(plt):

1220 plt.plot(daily_df[metric], marker="braille", color=color)

1221 plt.xlabel("Days")

1222 plt.ylabel(y_label)

1223

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 )

1231

1232

1233 def create_backtest_distributions(daily_df) -> Panel:

1234 """Create backtest distributions using standardized small-multiples like optimize."""

1235 series_map: dict[str, dict[str, Any]] = {}

1236

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}

1242

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}

1248

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 }

1259

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)

1270

1271

1272 def create_latest_positions_panel(positions_df) -> Panel:

1273 """Create panel showing latest position snapshot."""

1274 import polars as pl

1275

1276 if len(positions_df) == 0:

1277 return Panel("[dim]No position data[/dim]", box=box.HEAVY)

1278

1279 # Get last rebalance date

1280 latest_date = positions_df["date"].max()

1281 latest_positions = positions_df.filter(positions_df["date"] == latest_date)

1282

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)

1287

1288 table = Table(

1289 show_header=True,

1290 box=box.SIMPLE_HEAD,

1291 header_style="bold cyan on #001926",

1292 padding=(0, 1),

1293 )

1294

1295 table.add_column("COIN", style="cyan")

1296 table.add_column("WEIGHT", justify="right")

1297

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

1303

1304 for row in latest_positions.iter_rows(named=True):

1305 weight = row["weight"]

1306 weight_style = "green" if weight > 0 else "red"

1307

1308 table.add_row(

1309 row["id"],

1310 f"[{weight_style}]{abs(weight):.1%}[/{weight_style}]",

1311 )

1312

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 )

1318

1319 return Panel(table, title=title, box=box.HEAVY)

1320

1321

1322 def create_enhanced_positions_table(positions_df, daily_df) -> Panel:

1323 """Create enhanced positions table with detailed statistics."""

1324 import polars as pl

1325

1326 if len(positions_df) == 0:

1327 return Panel("[dim]No position data[/dim]", box=box.HEAVY)

1328

1329 # Get unique dates for counting

1330 unique_dates = positions_df["date"].unique().sort()

1331 total_periods = len(unique_dates)

1332

1333 # Get last rebalance date

1334 latest_date = positions_df["date"].max()

1335

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 )

1372

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 )

1382

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 )

1392

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 )

1400

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)

1410

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"]

1420

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"

1428

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}%)"

1432

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'}]"

1435

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'}]"

1441

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

1445

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 )

1456

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 )

1467

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

1472

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 = ""

1483

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 )

1489

1490 if consistency_note:

1491 title += f" [dim]│[/dim] {consistency_note}"

1492

1493 return Panel(table, title=title, box=box.HEAVY)

1494

1495

1496 def display_optimization_results(

1497 console: Console, results_df, metric: str, top_n: int = 20, config=None

1498):

1499 """Display optimization results."""

1500

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)

1513

1514 layout = Layout()

1515 layout.split_column(Layout(name="header", size=3), Layout(name="body"))

1516

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)

1528

1529 # Body: Main results + best params summary

1530 layout["body"].split_row(

1531 Layout(name="results", ratio=3), Layout(name="summary", ratio=1)

1532 )

1533

1534 # Results table

1535 results_panel = create_optimization_results_table(results_df, metric, top_n)

1536 layout["results"].update(results_panel)

1537

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

1546

1547 console.print(layout)

1548

1549 sm_panel = create_optimization_small_multiples_panel(results_df)

1550 if sm_panel is not None:

1551 console.print(sm_panel)

1552

1553

1554 def _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."""

1558

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

1565

1566 return create_plotext_panel(

1567 plot_hist, f"[bold]{title}[/bold]", width=width, height=height, style=color

1568 )

1569

1570

1571 def 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)

1588

1589

1590 def 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

1627

1628

1629 def 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 )

1637

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)

1665

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"

1670

1671 dd = row["max_dd"]

1672 dd_color = "green" if dd > -0.1 else "yellow" if dd > -0.2 else "red"

1673

1674 cagr = row["cagr"]

1675 cagr_color = "green" if cagr > 0.2 else "yellow" if cagr > 0 else "red"

1676

1677 # Special highlighting for top 3

1678 rank_style = "bold cyan" if i == 1 else "cyan" if i <= 3 else "dim"

1679

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 )

1693

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 )

1699

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 )

1705

1706 return Panel(table, title=title, box=box.HEAVY)

1707

1708

1709 def 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__()

1712

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

1717

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

1727

1728 # Performance metrics

1729 table.add_row("[bold]METRICS[/bold]", "")

1730 table.add_row("", "")

1731

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

1745

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

1750

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

1756

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 )

1770

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 )

1780

1781 # Prediction column

1782 table.add_row("PRED COL", str(config.pred_value_column)[:15])

1783

1784 return Panel(

1785 table, title=f"[bold cyan]BEST BY {metric.upper()}[/bold cyan]", box=box.HEAVY

1786 )

1787

1788

1789 def 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

1800

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 )

1807

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)

1815

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 )

1820

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 )

1830

1831 task = progress.add_task("Optimization", total=total, completed=current)

1832 progress.update(task, completed=current)

1833

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)

1840

1841 # Parameters display

1842 layout["params"].split_row(

1843 Layout(name="current", ratio=1), Layout(name="best", ratio=1)

1844 )

1845

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

1850

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', '-')}")

1861

1862 layout["current"].update(

1863 Panel(current_table, title="[bold cyan]TESTING[/bold cyan]", box=box.HEAVY)

1864 )

1865

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

1871

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

1883

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 )

1900

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 "--:--"

1904

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 )

1912

1913 layout["footer"].update(Panel(footer_text, box=box.HEAVY, style="dim"))

1914

1915 return layout

1916

1917

1918 def display_optimization_contours(console: Console, results_df, metric: str):

1919 """Display parameter heatmaps using ASCII visualization as small multiples."""

1920 import polars as pl

1921

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

1926

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)

1937

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)

1942

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

1953

1954 pivot = df.pivot(

1955 index="num_long", on="num_short", values=metric, aggregate_function="mean"

1956 )

1957

1958 longs = sorted(pivot["num_long"].to_list())

1959 shorts = sorted([c for c in pivot.columns if c != "num_long"])

1960

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 )

1968

1969 heatmap_panels.append(

1970 Panel(

1971 heatmap_str,

1972 title=subtitle,

1973 box=box.HEAVY,

1974 height=12,

1975 )

1976 )

1977

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

1988

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

1992

1993 from rich.table import Table as RichTable

1994

1995 container = RichTable.grid(expand=True)

1996 for row in rows:

1997 container.add_row(row)

1998

1999 console.print(container)

2000

2001

2002 def 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

2005

2006 # Ensure numeric sort order (5, 10, 15 ...)

2007 longs = sorted(longs)

2008 shorts = sorted(shorts)

2009

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)

2021

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

2027

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)

2058

2059

2060 def 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

2065

2066 table = Table(

2067 box=box.HEAVY_HEAD,

2068 show_lines=False,

2069 header_style="bold cyan on #001926",

2070 expand=True,

2071 )

2072

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)

2081

2082 now = datetime.now(timezone.utc)

2083

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

2087

2088 # Format filled size

2089 filled_str = f"{order.filled_size:.2f}/{order.size:.2f}"

2090

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]"

2097

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}]"

2109

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"

2118

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 )

2129

2130 title = f"[bold cyan]OPEN ORDERS[/bold cyan] [dim]│[/dim] {len(orders)} active"

2131 return Panel(table, title=title, box=box.HEAVY)

2132

2133

2134 def 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

2140

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

2145

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]", "")

2152

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

2159

2160 if history.avg_fill_time_minutes > 0:

2161 stats_table.add_row("AVG FILL TIME", f"{history.avg_fill_time_minutes:.0f}min")

2162

2163 # Recent orders table

2164 orders_table = Table(

2165 box=box.SIMPLE_HEAD,

2166 show_lines=False,

2167 header_style="bold cyan",

2168 )

2169

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)

2176

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"

2180

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 = "-"

2186

2187 status_color = "green" if order.status == "filled" else "red" if order.status == "failed" else "dim"

2188

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 )

2197

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)

2202

2203 return Panel(layout, title=f"[bold cyan]ORDER HISTORY :: {days} DAYS[/bold cyan]", box=box.HEAVY)