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
« prev ^ index » next coverage.py v7.10.3, created at 2025-10-13 20:16 +0000
1"""Command-line interface for cc-liquid."""
3import os
4import yaml
5import time
6import traceback
7from datetime import datetime, timezone
8import subprocess
9import shutil
10import shlex
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
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
46TMUX_SESSION_NAME = "cc-liquid"
47TMUX_WINDOW_NAME = "cc-liquid"
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()
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.
67 Guides you through creating config files with validation and helpful defaults.
68 """
69 console = Console()
70 from rich.prompt import Prompt, Confirm
72 # Check existing files
73 cfg_path = "cc-liquid-config.yaml"
74 env_path = ".env"
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)
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
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())
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)
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")
121 data_source = Prompt.ask(
122 "Choose data source",
123 choices=["crowdcent", "numerai", "local"],
124 default="crowdcent",
125 )
127 # Step 3: API keys
128 console.print("\n[bold]Step 3: API Keys[/bold]")
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 )
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..."
153 # Step 4: Addresses
154 console.print("\n[bold]Step 4: Addresses[/bold]")
155 console.print("[dim]Leave blank to fill in later[/dim]")
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
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
171 # Step 5: Portfolio settings
172 console.print("\n[bold]Step 5: Portfolio Settings[/bold]")
174 num_long = int(Prompt.ask("Number of long positions", default="10"))
175 num_short = int(Prompt.ask("Number of short positions", default="10"))
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"))
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 }
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 ]
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)
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)
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")
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)
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)
279@cli.group()
280def completion():
281 """Shell completion utilities."""
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.
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)
313 result = install_completion(prog_name, shell)
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 )
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 )
327 console.print(
328 "[dim]Restart your shell or 'source' your rc file to activate completion.[/dim]"
329 )
332@cli.group()
333def profile():
334 """Manage configuration profiles (owner/vault/signer)."""
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
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)
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)
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)
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)
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]")
435@cli.group()
436def orders():
437 """Manage and monitor orders."""
438 pass
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()
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")
459 # Create trader
460 callbacks = RichCLICallbacks()
461 trader = CCLiquid(config, callbacks=callbacks)
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()
471 if not open_orders:
472 console.print("[yellow]No open orders[/yellow]")
473 return
475 callbacks.show_open_orders(open_orders)
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()
491 if not order_id and not cancel_all:
492 console.print("[red]Error: Specify --order-id or --all[/red]")
493 raise SystemExit(1)
495 overrides_applied = apply_cli_overrides(config, set_overrides)
496 callbacks = RichCLICallbacks()
497 trader = CCLiquid(config, callbacks=callbacks)
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
505 console.print(f"Cancelling {len(open_orders)} orders...")
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]}...)")
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}")
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()
536 overrides_applied = apply_cli_overrides(config, set_overrides)
537 trader = CCLiquid(config)
539 history = trader.get_order_history(days=days)
541 if not history.orders:
542 console.print(f"[yellow]No orders found in the last {days} days[/yellow]")
543 return
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 ])
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 ])
571 console.print(f"[green]✓[/green] Exported {len(history.orders)} orders to {output}")
574@cli.command()
575def account():
576 """Show comprehensive account and positions summary."""
577 console = Console()
578 trader = CCLiquid(config, callbacks=RichCLICallbacks())
580 # Get structured portfolio info
581 portfolio = trader.get_portfolio_info()
583 # Display using reusable display function with config
584 display_portfolio(portfolio, console, config.to_dict())
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
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
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()
650 # Apply CLI overrides to config
651 overrides_applied = apply_cli_overrides(config, set_overrides)
653 # Create callbacks and trader
654 callbacks = RichCLICallbacks()
655 trader = CCLiquid(config, callbacks=callbacks)
657 # Show applied overrides through callbacks
658 callbacks.on_config_override(overrides_applied)
660 try:
661 # Preview plan first (no execution)
662 plan = trader.plan_close_all_positions(force=force)
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 )
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()
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."""
705 # Apply CLI overrides to config
706 overrides_applied = apply_cli_overrides(config, set_overrides)
708 # Create callbacks and trader
709 callbacks = RichCLICallbacks()
710 trader = CCLiquid(config, callbacks=callbacks)
712 # Show applied overrides through callbacks
713 callbacks.on_config_override(overrides_applied)
715 # Preview plan first (no execution)
716 plan = trader.plan_rebalance()
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 )
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")
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.
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
814 console = Console()
816 # Apply CLI overrides to config (includes smart defaults for data.source changes)
817 overrides_applied = apply_cli_overrides(config, set_overrides)
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()
826 # Now use the config value (which may have been overridden)
827 predictions = config.data.path
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 )
852 try:
853 # Run backtest with spinner
854 from rich.spinner import Spinner
855 from rich.live import Live
857 with Live(
858 Spinner("dots", text="Running backtest..."), console=console, transient=True
859 ):
860 backtester = Backtester(bt_config)
861 result = backtester.run()
863 display_backtest_summary(
864 console, result, bt_config, show_positions=show_positions
865 )
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}")
872 except Exception as e:
873 from rich.markup import escape
875 console.print(f"[red]✗ Backtest failed: {escape(str(e))}[/red]")
876 if verbose:
877 import traceback
879 traceback.print_exc()
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.
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()
1026 # Apply CLI overrides to config (includes smart defaults for data.source changes)
1027 overrides_applied = apply_cli_overrides(config, set_overrides)
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()
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(",")]
1043 # Now use the config value (which may have been overridden)
1044 predictions = config.data.path
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 )
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 )
1073 # Create optimizer
1074 optimizer = BacktestOptimizer(base_config)
1076 # Clear cache if requested
1077 if clear_cache:
1078 optimizer.clear_cache()
1079 console.print("[yellow]Cache cleared[/yellow]\n")
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 )
1093 if max_workers:
1094 console.print(f"Parallel workers: [cyan]{max_workers}[/cyan]")
1095 else:
1096 import multiprocessing as mp
1098 auto_workers = min(mp.cpu_count(), 24)
1099 console.print(f"Parallel workers: [cyan]{auto_workers}[/cyan] (auto)")
1100 console.print()
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 )
1127 if len(results_df) == 0:
1128 console.print("[red]No valid results found[/red]")
1129 return
1131 # Display results
1132 console.print() # Space after progress
1133 display_optimization_results(console, results_df, metric, top_n, base_config)
1135 # Show contour plots if requested
1136 if plot:
1137 display_optimization_contours(console, results_df, metric)
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}")
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 )
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 )
1179 # Run backtest
1180 backtester = Backtester(best_config)
1181 result = backtester.run()
1183 display_backtest_summary(
1184 console, result, best_config, show_positions=False
1185 )
1187 except Exception as e:
1188 from rich.markup import escape
1190 console.print(f"[red]✗ Optimization failed: {escape(str(e))}[/red]")
1191 if verbose:
1192 import traceback
1194 traceback.print_exc()
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 )
1230 inside_tmux = bool(os.environ.get("TMUX"))
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 )
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])
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)])
1266 # Build a single shell-quoted command string with guard env var
1267 command_string = f"CCLIQUID_TMUX_CHILD=1 {shlex.join(inner_cmd)}"
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 )
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])
1288 # Normal, non-tmux path
1289 overrides_applied = apply_cli_overrides(config, set_overrides)
1290 run_live_cli(config, skip_confirm, overrides_applied, refresh)
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.
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()
1309 # Create trader with initial callbacks and load state
1310 callbacks = RichCLICallbacks()
1311 trader = CCLiquid(config_obj, callbacks=callbacks)
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
1318 last_rebalance_date = trader.load_state()
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
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()
1339 # Get open orders and monitor stops
1340 open_orders = trader.get_open_orders()
1341 trader.monitor_stops()
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
1350 if should_rebalance:
1351 # Stop the live display to run the standard rebalancing flow
1352 live.stop()
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 )
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")
1382 # Update state on successful completion
1383 last_rebalance_date = datetime.now(timezone.utc)
1384 trader.save_state(last_rebalance_date)
1386 console.input(
1387 "\n[bold green]✓ Rebalance cycle finished. Press [bold]Enter[/bold] to resume dashboard...[/bold green]"
1388 )
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
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)
1417 # Sleep to control dashboard update cadence and API usage
1418 time.sleep(refresh_seconds if refresh_seconds > 0 else 1)
1420 except KeyboardInterrupt:
1421 pass
1422 except Exception as e:
1423 console.print(f"[red]✗ Error:[/red] {e}")
1424 traceback.print_exc()
1427if __name__ == "__main__":
1428 cli()