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

540 statements  

« prev     ^ index     » next       coverage.py v7.10.3, created at 2025-10-13 20:16 +0000

1"""Command-line interface for cc-liquid.""" 

2 

3import os 

4import yaml 

5import time 

6import traceback 

7from datetime import datetime, timezone 

8import subprocess 

9import shutil 

10import shlex 

11 

12import click 

13from rich.console import Console 

14from rich.progress import ( 

15 Progress, 

16 BarColumn, 

17 TextColumn, 

18 TimeRemainingColumn, 

19 TimeElapsedColumn, 

20 MofNCompleteColumn, 

21) 

22from typing import Any 

23 

24from .cli_callbacks import RichCLICallbacks 

25from .cli_display import ( 

26 create_dashboard_layout, 

27 create_config_panel, 

28 create_header_panel, 

29 create_setup_welcome_panel, 

30 create_setup_summary_panel, 

31 display_portfolio, 

32 display_file_summary, 

33 display_backtest_summary, 

34 show_pre_alpha_warning, 

35 display_optimization_results, 

36 display_optimization_contours, 

37) 

38from .backtester import BacktestOptimizer, BacktestConfig, Backtester 

39from .config import config 

40from .config import apply_cli_overrides 

41from .data_loader import DataLoader 

42from .trader import CCLiquid 

43from .completion import detect_shell_from_env, install_completion 

44 

45 

46TMUX_SESSION_NAME = "cc-liquid" 

47TMUX_WINDOW_NAME = "cc-liquid" 

48 

49 

50@click.group() 

51def cli(): 

52 """cc-liquid - A metamodel-based rebalancer for Hyperliquid.""" 

53 # Suppress the pre-alpha banner during Click's completion mode to avoid 

54 # corrupting the generated completion script output. 

55 in_completion_mode = any(k.endswith("_COMPLETE") for k in os.environ) 

56 if not in_completion_mode: 

57 show_pre_alpha_warning() 

58 

59 

60@cli.command(name="init") 

61@click.option( 

62 "--non-interactive", is_flag=True, help="Skip interactive setup, use defaults" 

63) 

64def init_cmd(non_interactive: bool): 

65 """Interactive setup wizard for first-time users. 

66 

67 Guides you through creating config files with validation and helpful defaults. 

68 """ 

69 console = Console() 

70 from rich.prompt import Prompt, Confirm 

71 

72 # Check existing files 

73 cfg_path = "cc-liquid-config.yaml" 

74 env_path = ".env" 

75 

76 if os.path.exists(cfg_path) or os.path.exists(env_path): 

77 existing_files = [] 

78 if os.path.exists(cfg_path): 

79 existing_files.append(cfg_path) 

80 if os.path.exists(env_path): 

81 existing_files.append(env_path) 

82 

83 console.print( 

84 f"\n[yellow]⚠️ Found existing files: {', '.join(existing_files)}[/yellow]" 

85 ) 

86 if not non_interactive: 

87 if not Confirm.ask("Overwrite existing files?", default=False): 

88 console.print("[red]Setup cancelled.[/red]") 

89 return 

90 

91 # Gather all configuration based on mode 

92 if non_interactive: 

93 # All defaults in one place for non-interactive mode 

94 is_testnet = True 

95 data_source = "crowdcent" 

96 crowdcent_key = "" 

97 hyper_key_placeholder = "0x..." 

98 owner_address = None 

99 vault_address = None 

100 num_long = 10 

101 num_short = 10 

102 leverage = 1.0 

103 else: 

104 # Interactive flow 

105 console.print(create_setup_welcome_panel()) 

106 

107 # Step 1: Environment 

108 console.print("\n[bold]Step 1: Choose Environment[/bold]") 

109 console.print("[dim]Testnet is recommended for first-time users[/dim]") 

110 is_testnet = Confirm.ask("Use testnet?", default=True) 

111 

112 # Step 2: Data source 

113 console.print("\n[bold]Step 2: Data Source[/bold]") 

114 console.print("Available sources:") 

115 console.print( 

116 " • [cyan]crowdcent[/cyan] - CrowdCent metamodel (requires API key)" 

117 ) 

118 console.print(" • [cyan]numerai[/cyan] - Numerai crypto signals (free)") 

119 console.print(" • [cyan]local[/cyan] - Your own prediction file") 

120 

121 data_source = Prompt.ask( 

122 "Choose data source", 

123 choices=["crowdcent", "numerai", "local"], 

124 default="crowdcent", 

125 ) 

126 

127 # Step 3: API keys 

128 console.print("\n[bold]Step 3: API Keys[/bold]") 

129 

130 crowdcent_key = "" 

131 if data_source == "crowdcent": 

132 console.print("\n[cyan]CrowdCent API Key[/cyan]") 

133 console.print("[dim]Get from: https://crowdcent.com/profile[/dim]") 

134 crowdcent_key = Prompt.ask( 

135 "Enter CrowdCent API key (or press Enter to add later)", 

136 default="", 

137 show_default=False, 

138 ) 

139 

140 console.print("\n[cyan]Hyperliquid Private Key[/cyan]") 

141 console.print("[dim]Get from: https://app.hyperliquid.xyz/API[/dim]") 

142 console.print( 

143 "[yellow]⚠️ Use an agent wallet key, not your main wallet![/yellow]" 

144 ) 

145 hyper_key_input = Prompt.ask( 

146 "Enter Hyperliquid private key (or press Enter to add later)", 

147 default="", 

148 show_default=False, 

149 password=True, # Hide input for security 

150 ) 

151 hyper_key_placeholder = hyper_key_input if hyper_key_input else "0x..." 

152 

153 # Step 4: Addresses 

154 console.print("\n[bold]Step 4: Addresses[/bold]") 

155 console.print("[dim]Leave blank to fill in later[/dim]") 

156 

157 owner_address = Prompt.ask( 

158 "Owner address (your main wallet, NOT the agent wallet)", 

159 default="", 

160 show_default=False, 

161 ) 

162 owner_address = owner_address if owner_address else None 

163 

164 vault_address = Prompt.ask( 

165 "Vault address (optional, for managed vaults)", 

166 default="", 

167 show_default=False, 

168 ) 

169 vault_address = vault_address if vault_address else None 

170 

171 # Step 5: Portfolio settings 

172 console.print("\n[bold]Step 5: Portfolio Settings[/bold]") 

173 

174 num_long = int(Prompt.ask("Number of long positions", default="10")) 

175 num_short = int(Prompt.ask("Number of short positions", default="10")) 

176 

177 console.print("\n[yellow]⚠️ Leverage Warning:[/yellow]") 

178 console.print("[dim]1.0 = no leverage (safest)[/dim]") 

179 console.print("[dim]2.0 = 2x leverage (moderate risk)[/dim]") 

180 console.print("[dim]3.0+ = high risk of liquidation[/dim]") 

181 leverage = float(Prompt.ask("Target leverage", default="1.0")) 

182 

183 # Compose configurations 

184 yaml_cfg: dict[str, Any] = { 

185 "active_profile": "default", 

186 "profiles": { 

187 "default": { 

188 "owner": owner_address, 

189 "vault": vault_address, 

190 "signer_env": "HYPERLIQUID_PRIVATE_KEY", 

191 } 

192 }, 

193 "is_testnet": is_testnet, 

194 "data": { 

195 "source": data_source, 

196 "path": "predictions.parquet", 

197 **( 

198 { 

199 "date_column": "date", 

200 "asset_id_column": "symbol", 

201 "prediction_column": "meta_model", 

202 } 

203 if data_source == "numerai" 

204 else { 

205 "date_column": "release_date", 

206 "asset_id_column": "id", 

207 "prediction_column": "pred_10d", 

208 } 

209 ), 

210 }, 

211 "portfolio": { 

212 "num_long": num_long, 

213 "num_short": num_short, 

214 "target_leverage": leverage, 

215 "rebalancing": {"every_n_days": 10, "at_time": "18:15"}, 

216 }, 

217 "execution": {"slippage_tolerance": 0.005, "min_trade_value": 10.0}, 

218 } 

219 

220 env_lines = [ 

221 "# Secrets only - NEVER commit this file to git!", 

222 "# Add to .gitignore immediately", 

223 "", 

224 "# CrowdCent API (https://crowdcent.com/profile)", 

225 f"CROWDCENT_API_KEY={crowdcent_key}", 

226 "", 

227 "# Hyperliquid Agent Wallet Private Key (https://app.hyperliquid.xyz/API)", 

228 "# ⚠️ Use an agent wallet, NOT your main wallet!", 

229 f"HYPERLIQUID_PRIVATE_KEY={hyper_key_placeholder}", 

230 ] 

231 

232 # Write files 

233 try: 

234 with open(cfg_path, "w") as f: 

235 yaml.safe_dump(yaml_cfg, f, sort_keys=False) 

236 console.print(f"\n[green]✓[/green] Created [cyan]{cfg_path}[/cyan]") 

237 except Exception as e: 

238 console.print(f"[red]✗ Failed to write {cfg_path}:[/red] {e}") 

239 raise SystemExit(1) 

240 

241 try: 

242 with open(env_path, "w") as f: 

243 f.write("\n".join(env_lines) + "\n") 

244 console.print(f"[green]✓[/green] Created [cyan]{env_path}[/cyan]") 

245 except Exception as e: 

246 console.print(f"[red]✗ Failed to write {env_path}:[/red] {e}") 

247 raise SystemExit(1) 

248 

249 # Add to .gitignore if it exists 

250 if os.path.exists(".gitignore"): 

251 with open(".gitignore", "r") as f: 

252 gitignore_content = f.read() 

253 if ".env" not in gitignore_content: 

254 with open(".gitignore", "a") as f: 

255 f.write("\n# cc-liquid secrets\n.env\n") 

256 console.print("[green]✓[/green] Added .env to .gitignore") 

257 

258 # Summary and next steps 

259 summary = create_setup_summary_panel( 

260 is_testnet=is_testnet, 

261 data_source=data_source, 

262 num_long=num_long, 

263 num_short=num_short, 

264 leverage=leverage, 

265 ) 

266 console.print("\n") 

267 console.print(summary) 

268 

269 

270@cli.command(name="config") 

271def show_config(): 

272 """Show the current configuration.""" 

273 console = Console() 

274 config_dict = config.to_dict() 

275 panel = create_config_panel(config_dict) 

276 console.print(panel) 

277 

278 

279@cli.group() 

280def completion(): 

281 """Shell completion utilities.""" 

282 

283 

284@completion.command(name="install") 

285@click.option( 

286 "--shell", 

287 "shell_opt", 

288 type=click.Choice(["bash", "zsh", "fish"], case_sensitive=False), 

289 default=None, 

290 help="Target shell. Defaults to auto-detect from $SHELL.", 

291) 

292@click.option( 

293 "--prog-name", 

294 default="cc-liquid", 

295 show_default=True, 

296 help="Program name to install completion for (as installed on PATH).", 

297) 

298def completion_install(shell_opt: str | None, prog_name: str): 

299 """Install shell completion for the current user. 

300 

301 Writes the generated completion script to a standard location and, for 

302 Bash/Zsh, appends a source line to the user's rc file idempotently. 

303 """ 

304 console = Console() 

305 shell = shell_opt or detect_shell_from_env() 

306 if shell is None: 

307 console.print( 

308 "[red]Could not detect shell from $SHELL. Specify with[/red] " 

309 "[bold]--shell {bash|zsh|fish}[/bold]." 

310 ) 

311 raise SystemExit(2) 

312 

313 result = install_completion(prog_name, shell) 

314 

315 console.print( 

316 f"[green]✓[/green] Installed completion for [bold]{shell}[/bold] at " 

317 f"[cyan]{result.script_path}[/cyan]" 

318 + (" (updated)" if result.script_written else " (no changes)") 

319 ) 

320 

321 if result.rc_path is not None: 

322 console.print( 

323 f"[blue]•[/blue] Ensured rc entry in [cyan]{result.rc_path}[/cyan] " 

324 + ("(added)" if result.rc_line_added else "(already present)") 

325 ) 

326 

327 console.print( 

328 "[dim]Restart your shell or 'source' your rc file to activate completion.[/dim]" 

329 ) 

330 

331 

332@cli.group() 

333def profile(): 

334 """Manage configuration profiles (owner/vault/signer).""" 

335 

336 

337@profile.command(name="list") 

338def profile_list(): 

339 """List available profiles from YAML and highlight the active one.""" 

340 console = Console() 

341 profiles = config.profiles or {} 

342 if not profiles: 

343 console.print("[yellow]No profiles found in cc-liquid-config.yaml[/yellow]") 

344 return 

345 from rich.table import Table 

346 

347 table = Table(title="Profiles", show_lines=False, header_style="bold cyan") 

348 table.add_column("NAME", style="cyan") 

349 table.add_column("OWNER") 

350 table.add_column("VAULT") 

351 table.add_column("SIGNER ENV") 

352 for name, prof in profiles.items(): 

353 owner = (prof or {}).get("owner") or "-" 

354 vault = (prof or {}).get("vault") or "-" 

355 signer_env = (prof or {}).get("signer_env", "HYPERLIQUID_PRIVATE_KEY") 

356 label = f"[bold]{name}[/bold]" + ( 

357 " [green](active)[/green]" if name == config.active_profile else "" 

358 ) 

359 table.add_row(label, owner, vault, signer_env) 

360 console.print(table) 

361 

362 

363@profile.command(name="show") 

364@click.argument("name", required=False) 

365def profile_show(name: str | None): 

366 """Show details for a profile (defaults to active).""" 

367 console = Console() 

368 target = name or config.active_profile 

369 if not target: 

370 console.print("[red]No active profile set and no name provided[/red]") 

371 raise SystemExit(2) 

372 prof = (config.profiles or {}).get(target) 

373 if prof is None: 

374 console.print(f"[red]Profile '{target}' not found[/red]") 

375 raise SystemExit(2) 

376 data = { 

377 "name": target, 

378 "owner": prof.get("owner"), 

379 "vault": prof.get("vault"), 

380 "signer_env": prof.get("signer_env", "HYPERLIQUID_PRIVATE_KEY"), 

381 "is_active": target == config.active_profile, 

382 } 

383 panel = create_config_panel( 

384 { 

385 "is_testnet": config.is_testnet, 

386 "profile": { 

387 "active": data["name"] if data["is_active"] else config.active_profile, 

388 "owner": data["owner"], 

389 "vault": data["vault"], 

390 "signer_env": data["signer_env"], 

391 }, 

392 "data": config.data.__dict__, 

393 "portfolio": config.portfolio.__dict__ 

394 | {"rebalancing": config.portfolio.rebalancing.__dict__}, 

395 "execution": config.execution.__dict__, 

396 } 

397 ) 

398 console.print(panel) 

399 

400 

401@profile.command(name="use") 

402@click.argument("name", required=True) 

403def profile_use(name: str): 

404 """Set active profile and persist to YAML.""" 

405 console = Console() 

406 profiles = config.profiles or {} 

407 if name not in profiles: 

408 console.print(f"[red]Profile '{name}' not found in cc-liquid-config.yaml[/red]") 

409 raise SystemExit(2) 

410 

411 # Update file 

412 cfg_path = "cc-liquid-config.yaml" 

413 try: 

414 y: dict = {} 

415 if os.path.exists(cfg_path): 

416 with open(cfg_path) as f: 

417 y = yaml.safe_load(f) or {} 

418 y["active_profile"] = name 

419 with open(cfg_path, "w") as f: 

420 yaml.safe_dump(y, f, sort_keys=False) 

421 except Exception as e: 

422 console.print(f"[red]Failed to update {cfg_path}: {e}[/red]") 

423 raise SystemExit(1) 

424 

425 # Update runtime 

426 config.active_profile = name 

427 try: 

428 config.refresh_runtime() 

429 except Exception as e: 

430 console.print(f"[red]Failed to activate profile: {e}[/red]") 

431 raise SystemExit(1) 

432 console.print(f"[green]✓[/green] Active profile set to [bold]{name}[/bold]") 

433 

434 

435@cli.group() 

436def orders(): 

437 """Manage and monitor orders.""" 

438 pass 

439 

440 

441@orders.command(name="list") 

442@click.option("--history", is_flag=True, help="Show completed orders instead of open") 

443@click.option("--days", default=30, help="History lookback period in days") 

444@click.option( 

445 "--set", 

446 "set_overrides", 

447 multiple=True, 

448 help="Override config values", 

449) 

450def orders_list(history, days, set_overrides): 

451 """List open or historical orders.""" 

452 console = Console() 

453 

454 # Apply overrides 

455 overrides_applied = apply_cli_overrides(config, set_overrides) 

456 if overrides_applied: 

457 console.print(f"[dim]Applied overrides: {', '.join(overrides_applied)}[/dim]\n") 

458 

459 # Create trader 

460 callbacks = RichCLICallbacks() 

461 trader = CCLiquid(config, callbacks=callbacks) 

462 

463 if history: 

464 # Show historical orders 

465 order_history = trader.get_order_history(days=days) 

466 callbacks.show_order_history(order_history, days) 

467 else: 

468 # Show open orders 

469 open_orders = trader.get_open_orders() 

470 

471 if not open_orders: 

472 console.print("[yellow]No open orders[/yellow]") 

473 return 

474 

475 callbacks.show_open_orders(open_orders) 

476 

477 

478@orders.command(name="cancel") 

479@click.option("--order-id", help="Specific order ID to cancel") 

480@click.option("--all", "cancel_all", is_flag=True, help="Cancel all open orders") 

481@click.option( 

482 "--set", 

483 "set_overrides", 

484 multiple=True, 

485 help="Override config values", 

486) 

487def orders_cancel(order_id, cancel_all, set_overrides): 

488 """Cancel open orders.""" 

489 console = Console() 

490 

491 if not order_id and not cancel_all: 

492 console.print("[red]Error: Specify --order-id or --all[/red]") 

493 raise SystemExit(1) 

494 

495 overrides_applied = apply_cli_overrides(config, set_overrides) 

496 callbacks = RichCLICallbacks() 

497 trader = CCLiquid(config, callbacks=callbacks) 

498 

499 if cancel_all: 

500 open_orders = trader.get_open_orders() 

501 if not open_orders: 

502 console.print("[yellow]No open orders to cancel[/yellow]") 

503 return 

504 

505 console.print(f"Cancelling {len(open_orders)} orders...") 

506 

507 success_count = 0 

508 for order in open_orders: 

509 if trader.cancel_order(order.order_id): 

510 console.print(f"[green]✓[/green] Cancelled {order.coin} ({order.order_id[:12]}...)") 

511 success_count += 1 

512 else: 

513 console.print(f"[red]✗[/red] Failed to cancel {order.coin} ({order.order_id[:12]}...)") 

514 

515 console.print(f"\n[bold]Cancelled {success_count}/{len(open_orders)} orders[/bold]") 

516 else: 

517 if trader.cancel_order(order_id): 

518 console.print(f"[green]✓[/green] Cancelled order {order_id}") 

519 else: 

520 console.print(f"[red]✗[/red] Failed to cancel order {order_id}") 

521 

522 

523@orders.command(name="export") 

524@click.option("--output", "-o", required=True, help="Output CSV file path") 

525@click.option("--days", default=90, help="Export orders from last N days") 

526@click.option( 

527 "--set", 

528 "set_overrides", 

529 multiple=True, 

530 help="Override config values", 

531) 

532def orders_export(output, days, set_overrides): 

533 """Export order history to CSV.""" 

534 console = Console() 

535 

536 overrides_applied = apply_cli_overrides(config, set_overrides) 

537 trader = CCLiquid(config) 

538 

539 history = trader.get_order_history(days=days) 

540 

541 if not history.orders: 

542 console.print(f"[yellow]No orders found in the last {days} days[/yellow]") 

543 return 

544 

545 # Convert to CSV 

546 import csv 

547 with open(output, 'w', newline='') as f: 

548 writer = csv.writer(f) 

549 writer.writerow([ 

550 "order_id", "timestamp", "coin", "side", "strategy", 

551 "size", "filled_size", "avg_fill_price", "slippage_bps", 

552 "total_fees", "status", "twap_duration_minutes" 

553 ]) 

554 

555 for order in history.orders: 

556 writer.writerow([ 

557 order.order_id, 

558 order.timestamp.isoformat(), 

559 order.coin, 

560 order.side, 

561 order.strategy, 

562 order.size, 

563 order.filled_size, 

564 order.avg_fill_price, 

565 order.slippage_bps, 

566 order.total_fees, 

567 order.status, 

568 order.twap_duration_minutes 

569 ]) 

570 

571 console.print(f"[green]✓[/green] Exported {len(history.orders)} orders to {output}") 

572 

573 

574@cli.command() 

575def account(): 

576 """Show comprehensive account and positions summary.""" 

577 console = Console() 

578 trader = CCLiquid(config, callbacks=RichCLICallbacks()) 

579 

580 # Get structured portfolio info 

581 portfolio = trader.get_portfolio_info() 

582 

583 # Display using reusable display function with config 

584 display_portfolio(portfolio, console, config.to_dict()) 

585 

586 

587@cli.command() 

588@click.option( 

589 "--output", 

590 "-o", 

591 default=None, 

592 help="Output file path (defaults to path in config).", 

593) 

594def download_crowdcent(output): 

595 """Download the CrowdCent meta model.""" 

596 console = Console() 

597 if output is None: 

598 output = config.data.path 

599 try: 

600 predictions = DataLoader.from_crowdcent_api( 

601 api_key=config.CROWDCENT_API_KEY, download_path=output 

602 ) 

603 display_file_summary(console, predictions, output, "CrowdCent meta model") 

604 except Exception as e: 

605 console.print(f"[red]✗[/red] Failed to download CrowdCent meta model: {e}") 

606 raise 

607 

608 

609@cli.command() 

610@click.option( 

611 "--output", 

612 "-o", 

613 default=None, 

614 help="Output file path (defaults to path in config).", 

615) 

616def download_numerai(output): 

617 """Download the Numerai meta model.""" 

618 console = Console() 

619 if output is None: 

620 output = config.data.path 

621 try: 

622 predictions = DataLoader.from_numerai_api(download_path=output) 

623 display_file_summary(console, predictions, output, "Numerai meta model") 

624 except Exception as e: 

625 console.print(f"[red]✗[/red] Failed to download Numerai meta model: {e}") 

626 raise 

627 

628 

629@cli.command() 

630@click.option( 

631 "--skip-confirm", 

632 is_flag=True, 

633 help="Skip confirmation prompt for closing positions.", 

634) 

635@click.option( 

636 "--set", 

637 "set_overrides", 

638 multiple=True, 

639 help="Override config values (e.g., --set is_testnet=true)", 

640) 

641@click.option( 

642 "--force", 

643 is_flag=True, 

644 help="Force close positions below min notional by composing a two-step workaround.", 

645) 

646def close_all(skip_confirm, set_overrides, force): 

647 """Close all positions and return to cash.""" 

648 console = Console() 

649 

650 # Apply CLI overrides to config 

651 overrides_applied = apply_cli_overrides(config, set_overrides) 

652 

653 # Create callbacks and trader 

654 callbacks = RichCLICallbacks() 

655 trader = CCLiquid(config, callbacks=callbacks) 

656 

657 # Show applied overrides through callbacks 

658 callbacks.on_config_override(overrides_applied) 

659 

660 try: 

661 # Preview plan first (no execution) 

662 plan = trader.plan_close_all_positions(force=force) 

663 

664 # Render plan via callbacks 

665 all_trades = plan["trades"] + plan["skipped_trades"] 

666 callbacks.show_trade_plan( 

667 plan["target_positions"], 

668 all_trades, 

669 plan["account_value"], 

670 plan["leverage"], 

671 ) 

672 

673 # Confirm/auto-confirm 

674 if skip_confirm or callbacks.ask_confirmation("Close all positions?"): 

675 # Execute 

676 result = trader.execute_plan(plan) 

677 callbacks.show_execution_summary( 

678 result["successful_trades"], 

679 result["all_trades"], 

680 plan["target_positions"], 

681 plan["account_value"], 

682 ) 

683 else: 

684 callbacks.info("Cancelled by user") 

685 except Exception as e: 

686 console.print(f"[red]✗ Error closing positions:[/red] {e}") 

687 traceback.print_exc() 

688 

689 

690@cli.command() 

691@click.option( 

692 "--skip-confirm", 

693 is_flag=True, 

694 help="Skip confirmation prompt for executing trades.", 

695) 

696@click.option( 

697 "--set", 

698 "set_overrides", 

699 multiple=True, 

700 help="Override config values (e.g., --set data.source=numerai --set portfolio.num_long=10)", 

701) 

702def rebalance(skip_confirm, set_overrides): 

703 """Execute rebalancing based on the configured data source.""" 

704 

705 # Apply CLI overrides to config 

706 overrides_applied = apply_cli_overrides(config, set_overrides) 

707 

708 # Create callbacks and trader 

709 callbacks = RichCLICallbacks() 

710 trader = CCLiquid(config, callbacks=callbacks) 

711 

712 # Show applied overrides through callbacks 

713 callbacks.on_config_override(overrides_applied) 

714 

715 # Preview plan first (no execution) 

716 plan = trader.plan_rebalance() 

717 

718 # Render plan via callbacks 

719 all_trades = plan["trades"] + plan["skipped_trades"] 

720 callbacks.show_trade_plan( 

721 plan["target_positions"], all_trades, plan["account_value"], plan["leverage"] 

722 ) 

723 

724 # Confirm/auto-confirm 

725 if skip_confirm or callbacks.ask_confirmation("Execute these trades?"): 

726 result = trader.execute_plan(plan) 

727 callbacks.show_execution_summary( 

728 result["successful_trades"], 

729 result["all_trades"], 

730 plan["target_positions"], 

731 plan["account_value"], 

732 ) 

733 else: 

734 callbacks.info("Trading cancelled by user") 

735 

736 

737@cli.command() 

738@click.option( 

739 "--prices", 

740 default="raw_data.parquet", 

741 help="Path to price data (parquet file with date, id, close columns)", 

742) 

743@click.option( 

744 "--start-date", 

745 type=click.DateTime(), 

746 help="Start date for backtest (YYYY-MM-DD)", 

747) 

748@click.option( 

749 "--end-date", 

750 type=click.DateTime(), 

751 help="End date for backtest (YYYY-MM-DD)", 

752) 

753@click.option( 

754 "--set", 

755 "set_overrides", 

756 multiple=True, 

757 help="Override config values (e.g., --set portfolio.num_long=15 --set data.source=numerai)", 

758) 

759@click.option( 

760 "--fee-bps", 

761 type=float, 

762 default=4.0, 

763 help="Trading fee in basis points", 

764) 

765@click.option( 

766 "--slippage-bps", 

767 type=float, 

768 default=50.0, 

769 help="Slippage cost in basis points", 

770) 

771@click.option( 

772 "--prediction-lag", 

773 type=int, 

774 default=1, 

775 help="Days between prediction date and trading date (default: 1, use higher values to avoid look-ahead bias)", 

776) 

777@click.option( 

778 "--save-daily", 

779 help="Save daily results to CSV file", 

780) 

781@click.option( 

782 "--show-positions", 

783 is_flag=True, 

784 help="Show detailed position analysis table", 

785) 

786@click.option( 

787 "--verbose", 

788 is_flag=True, 

789 help="Show detailed progress", 

790) 

791def analyze( 

792 prices, 

793 start_date, 

794 end_date, 

795 set_overrides, 

796 fee_bps, 

797 slippage_bps, 

798 prediction_lag, 

799 save_daily, 

800 show_positions, 

801 verbose, 

802): 

803 """Run backtest analysis on historical data. 

804 

805 ⚠️ IMPORTANT DISCLAIMER: 

806 Past performance does not guarantee future results. Backtesting results are 

807 hypothetical and have inherent limitations. Actual trading results may differ 

808 significantly. Always consider market conditions, liquidity, and execution costs 

809 that may not be fully captured in simulations. 

810 """ 

811 from .backtester import Backtester, BacktestConfig 

812 from .config import config 

813 

814 console = Console() 

815 

816 # Apply CLI overrides to config (includes smart defaults for data.source changes) 

817 overrides_applied = apply_cli_overrides(config, set_overrides) 

818 

819 # Show applied overrides through console 

820 if overrides_applied: 

821 console.print("[cyan]Configuration overrides applied:[/cyan]") 

822 for override in overrides_applied: 

823 console.print(f"{override}") 

824 console.print() 

825 

826 # Now use the config value (which may have been overridden) 

827 predictions = config.data.path 

828 

829 # Create backtest config using the updated config values 

830 bt_config = BacktestConfig( 

831 prices_path=prices, 

832 predictions_path=predictions, 

833 # Use config columns for predictions 

834 pred_date_column=config.data.date_column, 

835 pred_id_column=config.data.asset_id_column, 

836 pred_value_column=config.data.prediction_column, 

837 data_provider=config.data.source, 

838 start_date=start_date, 

839 end_date=end_date, 

840 num_long=config.portfolio.num_long, 

841 num_short=config.portfolio.num_short, 

842 target_leverage=config.portfolio.target_leverage, 

843 weighting_scheme=config.portfolio.weighting_scheme, 

844 rank_power=config.portfolio.rank_power, 

845 rebalance_every_n_days=config.portfolio.rebalancing.every_n_days, 

846 prediction_lag_days=prediction_lag, 

847 fee_bps=fee_bps, 

848 slippage_bps=slippage_bps, 

849 verbose=verbose, 

850 ) 

851 

852 try: 

853 # Run backtest with spinner 

854 from rich.spinner import Spinner 

855 from rich.live import Live 

856 

857 with Live( 

858 Spinner("dots", text="Running backtest..."), console=console, transient=True 

859 ): 

860 backtester = Backtester(bt_config) 

861 result = backtester.run() 

862 

863 display_backtest_summary( 

864 console, result, bt_config, show_positions=show_positions 

865 ) 

866 

867 # Save daily results if requested 

868 if save_daily: 

869 result.daily.write_csv(save_daily) 

870 console.print(f"\n[green]✓[/green] Saved daily results to {save_daily}") 

871 

872 except Exception as e: 

873 from rich.markup import escape 

874 

875 console.print(f"[red]✗ Backtest failed: {escape(str(e))}[/red]") 

876 if verbose: 

877 import traceback 

878 

879 traceback.print_exc() 

880 

881 

882@cli.command() 

883@click.option( 

884 "--prices", 

885 default="raw_data.parquet", 

886 help="Path to price data", 

887) 

888@click.option( 

889 "--start-date", 

890 type=click.DateTime(), 

891 help="Start date for backtest (YYYY-MM-DD)", 

892) 

893@click.option( 

894 "--end-date", 

895 type=click.DateTime(), 

896 help="End date for backtest (YYYY-MM-DD)", 

897) 

898@click.option( 

899 "--set", 

900 "set_overrides", 

901 multiple=True, 

902 help="Override config values (e.g., --set data.source=numerai)", 

903) 

904@click.option( 

905 "--num-longs", 

906 default="10,20,30,40,50", 

907 help="Comma-separated list of long positions to test", 

908) 

909@click.option( 

910 "--num-shorts", 

911 default="10,20,30,40,50", 

912 help="Comma-separated list of short positions to test", 

913) 

914@click.option( 

915 "--leverages", 

916 default="1.0,2.0,3.0,4.0,5.0", 

917 help="Comma-separated list of leverage values to test", 

918) 

919@click.option( 

920 "--rebalance-days", 

921 default="8,10,12", 

922 help="Comma-separated list of rebalance frequencies to test", 

923) 

924@click.option( 

925 "--rank-powers", 

926 default="0.0,0.5,1.0,1.5,2.0", 

927 help="Comma-separated list of rank power values to test (0=equal weight)", 

928) 

929@click.option( 

930 "--metric", 

931 type=click.Choice(["sharpe", "cagr", "calmar"]), 

932 default="sharpe", 

933 help="Optimization metric", 

934) 

935@click.option( 

936 "--max-drawdown", 

937 type=float, 

938 help="Maximum drawdown constraint (e.g., 0.2 for 20%)", 

939) 

940@click.option( 

941 "--fee-bps", 

942 type=float, 

943 default=4.0, 

944 help="Trading fee in basis points", 

945) 

946@click.option( 

947 "--slippage-bps", 

948 type=float, 

949 default=50.0, 

950 help="Slippage cost in basis points", 

951) 

952@click.option( 

953 "--prediction-lag", 

954 type=int, 

955 default=1, 

956 help="Days between prediction date and trading date (default: 1, use higher values to avoid look-ahead bias)", 

957) 

958@click.option( 

959 "--top-n", 

960 type=int, 

961 default=20, 

962 help="Show top N results", 

963) 

964@click.option( 

965 "--apply-best", 

966 is_flag=True, 

967 help="Run full analysis with best parameters", 

968) 

969@click.option( 

970 "--save-results", 

971 help="Save optimization results to CSV", 

972) 

973@click.option( 

974 "--plot", 

975 is_flag=True, 

976 help="Show contour plots of results", 

977) 

978@click.option( 

979 "--max-workers", 

980 type=int, 

981 help="Number of parallel workers (default: auto)", 

982) 

983@click.option( 

984 "--clear-cache", 

985 is_flag=True, 

986 help="Clear cached optimization results", 

987) 

988@click.option( 

989 "--verbose", 

990 is_flag=True, 

991 help="Show detailed progress", 

992) 

993def optimize( 

994 prices, 

995 start_date, 

996 end_date, 

997 set_overrides, 

998 num_longs, 

999 num_shorts, 

1000 leverages, 

1001 rebalance_days, 

1002 rank_powers, 

1003 metric, 

1004 max_drawdown, 

1005 fee_bps, 

1006 slippage_bps, 

1007 prediction_lag, 

1008 top_n, 

1009 apply_best, 

1010 save_results, 

1011 plot, 

1012 max_workers, 

1013 clear_cache, 

1014 verbose, 

1015): 

1016 """Optimize backtest parameters using parallel grid search. 

1017 

1018 ⚠️ IMPORTANT DISCLAIMER: 

1019 Optimization results are based on historical data and are subject to overfitting. 

1020 Parameters that performed well in the past may not perform well in the future. 

1021 Always use out-of-sample testing and forward walk analysis. Consider that 

1022 optimized parameters may be curve-fit to historical noise rather than true patterns. 

1023 """ 

1024 console = Console() 

1025 

1026 # Apply CLI overrides to config (includes smart defaults for data.source changes) 

1027 overrides_applied = apply_cli_overrides(config, set_overrides) 

1028 

1029 # Show applied overrides through console 

1030 if overrides_applied: 

1031 console.print("[cyan]Configuration overrides applied:[/cyan]") 

1032 for override in overrides_applied: 

1033 console.print(f"{override}") 

1034 console.print() 

1035 

1036 # Parse parameter lists 

1037 num_longs_list = [int(x.strip()) for x in num_longs.split(",")] 

1038 num_shorts_list = [int(x.strip()) for x in num_shorts.split(",")] 

1039 leverages_list = [float(x.strip()) for x in leverages.split(",")] 

1040 rebalance_days_list = [int(x.strip()) for x in rebalance_days.split(",")] 

1041 rank_powers_list = [float(x.strip()) for x in rank_powers.split(",")] 

1042 

1043 # Now use the config value (which may have been overridden) 

1044 predictions = config.data.path 

1045 

1046 # Create base config with all parameters 

1047 base_config = BacktestConfig( 

1048 prices_path=prices, 

1049 predictions_path=predictions, 

1050 pred_date_column=config.data.date_column, 

1051 pred_id_column=config.data.asset_id_column, 

1052 pred_value_column=config.data.prediction_column, 

1053 data_provider=config.data.source, 

1054 start_date=start_date, 

1055 end_date=end_date, 

1056 weighting_scheme=config.portfolio.weighting_scheme, 

1057 rank_power=config.portfolio.rank_power, 

1058 prediction_lag_days=prediction_lag, 

1059 fee_bps=fee_bps, 

1060 slippage_bps=slippage_bps, 

1061 verbose=verbose, 

1062 ) 

1063 

1064 try: 

1065 # Calculate total combinations 

1066 total_combos = ( 

1067 len(num_longs_list) 

1068 * len(num_shorts_list) 

1069 * len(leverages_list) 

1070 * len(rebalance_days_list) 

1071 ) 

1072 

1073 # Create optimizer 

1074 optimizer = BacktestOptimizer(base_config) 

1075 

1076 # Clear cache if requested 

1077 if clear_cache: 

1078 optimizer.clear_cache() 

1079 console.print("[yellow]Cache cleared[/yellow]\n") 

1080 

1081 # Show optimization header 

1082 header = create_header_panel(f"OPTIMIZATION :: {total_combos} COMBINATIONS") 

1083 console.print(header) 

1084 console.print(f"\nOptimizing for: [bold yellow]{metric.upper()}[/bold yellow]") 

1085 if max_drawdown: 

1086 console.print( 

1087 f"Max drawdown constraint: [yellow]{max_drawdown:.1%}[/yellow]" 

1088 ) 

1089 console.print( 

1090 f"Parameters: L={num_longs_list} S={num_shorts_list} Lev={leverages_list} Days={rebalance_days_list} Power={rank_powers_list}" 

1091 ) 

1092 

1093 if max_workers: 

1094 console.print(f"Parallel workers: [cyan]{max_workers}[/cyan]") 

1095 else: 

1096 import multiprocessing as mp 

1097 

1098 auto_workers = min(mp.cpu_count(), 24) 

1099 console.print(f"Parallel workers: [cyan]{auto_workers}[/cyan] (auto)") 

1100 console.print() 

1101 

1102 # Run optimization with Rich Progress 

1103 with Progress( 

1104 TextColumn("[progress.description]{task.description}"), 

1105 BarColumn(), 

1106 MofNCompleteColumn(), 

1107 TextColumn("•"), 

1108 TimeElapsedColumn(), 

1109 TextColumn("•"), 

1110 TimeRemainingColumn(), 

1111 console=console, 

1112 expand=False, 

1113 ) as progress: 

1114 # Run parallel optimization 

1115 results_df = optimizer.grid_search_parallel( 

1116 num_longs=num_longs_list, 

1117 num_shorts=num_shorts_list, 

1118 leverages=leverages_list, 

1119 rebalance_days=rebalance_days_list, 

1120 rank_powers=rank_powers_list, 

1121 metric=metric, 

1122 max_drawdown_limit=max_drawdown, 

1123 max_workers=max_workers, 

1124 progress_callback=progress, 

1125 ) 

1126 

1127 if len(results_df) == 0: 

1128 console.print("[red]No valid results found[/red]") 

1129 return 

1130 

1131 # Display results 

1132 console.print() # Space after progress 

1133 display_optimization_results(console, results_df, metric, top_n, base_config) 

1134 

1135 # Show contour plots if requested 

1136 if plot: 

1137 display_optimization_contours(console, results_df, metric) 

1138 

1139 # Save results if requested 

1140 if save_results: 

1141 results_df.write_csv(save_results) 

1142 console.print(f"\n[green]✓[/green] Saved results to {save_results}") 

1143 

1144 # Apply best parameters if requested 

1145 if apply_best: 

1146 best_params = optimizer.get_best_params(results_df, metric) 

1147 if best_params: 

1148 console.print( 

1149 "\n[bold cyan]Running full analysis with best parameters...[/bold cyan]" 

1150 ) 

1151 console.print( 

1152 f"Best params: L={best_params['num_long']}, S={best_params['num_short']}, " 

1153 f"Lev={best_params['target_leverage']:.1f}x, Days={best_params['rebalance_every_n_days']}, " 

1154 f"Power={best_params['rank_power']}" 

1155 ) 

1156 

1157 # Create config with best params and all other settings 

1158 best_config = BacktestConfig( 

1159 prices_path=prices, 

1160 predictions_path=predictions, 

1161 pred_date_column=config.data.date_column, 

1162 pred_id_column=config.data.asset_id_column, 

1163 pred_value_column=config.data.prediction_column, 

1164 data_provider=config.data.source, 

1165 start_date=start_date, 

1166 end_date=end_date, 

1167 num_long=best_params["num_long"], 

1168 num_short=best_params["num_short"], 

1169 target_leverage=best_params["target_leverage"], 

1170 rebalance_every_n_days=best_params["rebalance_every_n_days"], 

1171 weighting_scheme="rank_power", # Always use rank_power (power=0 is equal weight) 

1172 rank_power=best_params["rank_power"], 

1173 prediction_lag_days=prediction_lag, 

1174 fee_bps=fee_bps, 

1175 slippage_bps=slippage_bps, 

1176 verbose=False, 

1177 ) 

1178 

1179 # Run backtest 

1180 backtester = Backtester(best_config) 

1181 result = backtester.run() 

1182 

1183 display_backtest_summary( 

1184 console, result, best_config, show_positions=False 

1185 ) 

1186 

1187 except Exception as e: 

1188 from rich.markup import escape 

1189 

1190 console.print(f"[red]✗ Optimization failed: {escape(str(e))}[/red]") 

1191 if verbose: 

1192 import traceback 

1193 

1194 traceback.print_exc() 

1195 

1196 

1197@cli.command() 

1198@click.option( 

1199 "--skip-confirm", 

1200 is_flag=True, 

1201 help="Skip confirmation prompt for executing trades.", 

1202) 

1203@click.option( 

1204 "--set", 

1205 "set_overrides", 

1206 multiple=True, 

1207 help="Override config values (e.g., --set is_testnet=true)", 

1208) 

1209@click.option( 

1210 "--refresh", 

1211 type=float, 

1212 default=1.0, 

1213 show_default=True, 

1214 help="Dashboard update cadence in seconds.", 

1215) 

1216@click.option( 

1217 "--tmux", 

1218 is_flag=True, 

1219 help="Run inside a fixed tmux session (attach if exists, else create and run).", 

1220) 

1221def run(skip_confirm, set_overrides, refresh, tmux): 

1222 """Start continuous monitoring and automatic rebalancing with live dashboard.""" 

1223 # Minimal tmux wrapper: attach-or-create a fixed session, with recursion guard 

1224 if tmux and os.environ.get("CCLIQUID_TMUX_CHILD") != "1": 

1225 if shutil.which("tmux") is None: 

1226 raise click.ClickException( 

1227 "tmux not found in PATH. Please install tmux to use --tmux." 

1228 ) 

1229 

1230 inside_tmux = bool(os.environ.get("TMUX")) 

1231 

1232 # Check if fixed session exists 

1233 session_exists = ( 

1234 subprocess.call( 

1235 ["tmux", "has-session", "-t", TMUX_SESSION_NAME], 

1236 stdout=subprocess.DEVNULL, 

1237 stderr=subprocess.DEVNULL, 

1238 ) 

1239 == 0 

1240 ) 

1241 

1242 if session_exists: 

1243 # Attach or switch to existing session 

1244 if inside_tmux: 

1245 subprocess.check_call( 

1246 ["tmux", "switch-client", "-t", TMUX_SESSION_NAME] 

1247 ) 

1248 return 

1249 else: 

1250 os.execvp("tmux", ["tmux", "attach", "-t", TMUX_SESSION_NAME]) 

1251 

1252 # Create the session and run inner command with guard set 

1253 inner_cmd = [ 

1254 "uv", 

1255 "run", 

1256 "-m", 

1257 "cc_liquid.cli", 

1258 "run", 

1259 ] 

1260 if skip_confirm: 

1261 inner_cmd.append("--skip-confirm") 

1262 for override in set_overrides: 

1263 inner_cmd.extend(["--set", override]) 

1264 inner_cmd.extend(["--refresh", str(refresh)]) 

1265 

1266 # Build a single shell-quoted command string with guard env var 

1267 command_string = f"CCLIQUID_TMUX_CHILD=1 {shlex.join(inner_cmd)}" 

1268 

1269 subprocess.check_call( 

1270 [ 

1271 "tmux", 

1272 "new-session", 

1273 "-d", 

1274 "-s", 

1275 TMUX_SESSION_NAME, 

1276 "-n", 

1277 TMUX_WINDOW_NAME, 

1278 command_string, 

1279 ] 

1280 ) 

1281 

1282 if inside_tmux: 

1283 subprocess.check_call(["tmux", "switch-client", "-t", TMUX_SESSION_NAME]) 

1284 return 

1285 else: 

1286 os.execvp("tmux", ["tmux", "attach", "-t", TMUX_SESSION_NAME]) 

1287 

1288 # Normal, non-tmux path 

1289 overrides_applied = apply_cli_overrides(config, set_overrides) 

1290 run_live_cli(config, skip_confirm, overrides_applied, refresh) 

1291 

1292 

1293def run_live_cli( 

1294 config_obj, 

1295 skip_confirm: bool, 

1296 overrides_applied: list[str], 

1297 refresh_seconds: float = 1.0, 

1298): 

1299 """Run continuous monitoring with live dashboard. 

1300 

1301 Args: 

1302 config_obj: The configuration object 

1303 skip_confirm: Whether to skip confirmations during rebalancing 

1304 overrides_applied: List of CLI overrides applied (for display) 

1305 refresh_seconds: UI update cadence in seconds 

1306 """ 

1307 console = Console() 

1308 

1309 # Create trader with initial callbacks and load state 

1310 callbacks = RichCLICallbacks() 

1311 trader = CCLiquid(config_obj, callbacks=callbacks) 

1312 

1313 # Show applied overrides if any (route via callbacks) 

1314 callbacks.on_config_override(overrides_applied) 

1315 if overrides_applied: 

1316 time.sleep(2) # Brief pause to show overrides 

1317 

1318 last_rebalance_date = trader.load_state() 

1319 

1320 # converts seconds per refresh to Live's refresh-per-second value 

1321 live_rps = 1.0 / refresh_seconds if refresh_seconds > 0 else 1.0 

1322 from rich.spinner import Spinner 

1323 from rich.live import Live 

1324 

1325 spinner = Spinner("dots", text="Loading...") 

1326 with Live( 

1327 spinner, 

1328 console=console, 

1329 screen=True, # Use alternate screen 

1330 refresh_per_second=live_rps, 

1331 transient=False, 

1332 ) as live: 

1333 # quick loading screen 

1334 try: 

1335 while True: 

1336 # Get current portfolio state 

1337 portfolio = trader.get_portfolio_info() 

1338 

1339 # Get open orders and monitor stops 

1340 open_orders = trader.get_open_orders() 

1341 trader.monitor_stops() 

1342 

1343 # Calculate next rebalance time and determine if due 

1344 next_action_time = trader.compute_next_rebalance_time( 

1345 last_rebalance_date 

1346 ) 

1347 now = datetime.now(timezone.utc) 

1348 should_rebalance = now >= next_action_time 

1349 

1350 if should_rebalance: 

1351 # Stop the live display to run the standard rebalancing flow 

1352 live.stop() 

1353 

1354 try: 

1355 console.print( 

1356 "\n[bold yellow]-- Scheduled rebalance started --[/bold yellow]" 

1357 ) 

1358 # Preview plan 

1359 plan = trader.plan_rebalance() 

1360 all_trades = plan["trades"] + plan["skipped_trades"] 

1361 callbacks.show_trade_plan( 

1362 plan["target_positions"], 

1363 all_trades, 

1364 plan["account_value"], 

1365 plan["leverage"], 

1366 ) 

1367 

1368 proceed = skip_confirm or callbacks.ask_confirmation( 

1369 "Execute these trades?" 

1370 ) 

1371 if proceed: 

1372 result = trader.execute_plan(plan) 

1373 callbacks.show_execution_summary( 

1374 result["successful_trades"], 

1375 result["all_trades"], 

1376 plan["target_positions"], 

1377 plan["account_value"], 

1378 ) 

1379 else: 

1380 callbacks.info("Trading cancelled by user") 

1381 

1382 # Update state on successful completion 

1383 last_rebalance_date = datetime.now(timezone.utc) 

1384 trader.save_state(last_rebalance_date) 

1385 

1386 console.input( 

1387 "\n[bold green]✓ Rebalance cycle finished. Press [bold]Enter[/bold] to resume dashboard...[/bold green]" 

1388 ) 

1389 

1390 except Exception as e: 

1391 console.print( 

1392 f"\n[bold red]✗ Rebalancing failed:[/bold red] {e}" 

1393 ) 

1394 traceback.print_exc() 

1395 console.input( 

1396 "\n[yellow]Press [bold]Enter[/bold] to resume dashboard...[/yellow]" 

1397 ) 

1398 finally: 

1399 # Resume the live dashboard 

1400 live.start() 

1401 # Continue to the next loop iteration to immediately refresh the dashboard 

1402 continue 

1403 

1404 else: 

1405 # Normal monitoring dashboard 

1406 dashboard = create_dashboard_layout( 

1407 portfolio=portfolio, 

1408 open_orders=open_orders, 

1409 next_rebalance_time=next_action_time, 

1410 last_rebalance_time=last_rebalance_date, 

1411 is_rebalancing=False, 

1412 config_dict=config_obj.to_dict(), 

1413 refresh_seconds=refresh_seconds, 

1414 ) 

1415 live.update(dashboard) 

1416 

1417 # Sleep to control dashboard update cadence and API usage 

1418 time.sleep(refresh_seconds if refresh_seconds > 0 else 1) 

1419 

1420 except KeyboardInterrupt: 

1421 pass 

1422 except Exception as e: 

1423 console.print(f"[red]✗ Error:[/red] {e}") 

1424 traceback.print_exc() 

1425 

1426 

1427if __name__ == "__main__": 

1428 cli()