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

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

2

3 import os

4 import yaml

5 import time

6 import traceback

7 from datetime import datetime, timezone

8 import subprocess

9 import shutil

10 import shlex

11

12 import click

13 from rich.console import Console

14 from rich.progress import (

15 Progress,

16 BarColumn,

17 TextColumn,

18 TimeRemainingColumn,

19 TimeElapsedColumn,

20 MofNCompleteColumn,

21)

22 from typing import Any

23

24 from .cli_callbacks import RichCLICallbacks

25 from .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)

38 from .backtester import BacktestOptimizer, BacktestConfig, Backtester

39 from .config import config

40 from .config import apply_cli_overrides

41 from .data_loader import DataLoader

42 from .trader import CCLiquid

43 from .completion import detect_shell_from_env, install_completion

44

45

46 TMUX_SESSION_NAME = "cc-liquid"

47 TMUX_WINDOW_NAME = "cc-liquid"

48

49

50@click.group()

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

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

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

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

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

333 def profile():

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

335

336

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1427 if __name__ == "__main__":

1428 cli()