#!/usr/bin/env python """ Predix CLI - Wrapper for rdagent with LLM model selection. Usage: predix quant # Local llama.cpp (default) predix quant --model local # Explicit local predix quant --model openrouter # OpenRouter cloud model predix quant -d # With web dashboard """ import os import sys from pathlib import Path from dotenv import load_dotenv load_dotenv(Path(__file__).parent / ".env") import typer from rich.console import Console app = typer.Typer(help="Predix - AI Quantitative Trading Agent") console = Console() @app.command() def quant( model: str = typer.Option( "local", "--model", "-m", help="LLM backend: 'local' (llama.cpp) or 'openrouter' (cloud)", ), dashboard: bool = typer.Option( False, "--dashboard/-d", help="Start web dashboard", ), cli_dashboard: bool = typer.Option( False, "--cli-dashboard/-c", help="Start CLI dashboard", ), log_file: str = typer.Option( None, # None means auto-detect based on run_id "--log-file", help="Log file path (default: auto-detected). Use 'none' to disable.", ), step_n: int = typer.Option(None, help="Number of steps to run"), loop_n: int = typer.Option(None, help="Number of loops to run"), run_id: int = typer.Option( 0, "--run-id", help="Parallel run ID (for isolated results). 0 = single run mode.", ), ): """ Start EURUSD quantitative trading loop. Examples: predix quant # Local llama.cpp predix quant -m openrouter # OpenRouter cloud model predix quant -d # With web dashboard predix quant -m openrouter -d # Both predix quant --run-id 1 # Parallel run #1 (isolated) """ import subprocess import threading import time import sys # ---- Parallel Run Isolation ---- # When run_id > 0, isolate all outputs (logs, results, workspace) if run_id > 0: os.environ["PARALLEL_RUN_ID"] = str(run_id) console.print(f"\n[bold yellow]πŸ”€ Parallel Run Mode:[/bold yellow] [cyan]ID={run_id}[/cyan]") # Auto-detect log file for parallel run if log_file is None: log_file = f"fin_quant_run{run_id}.log" # Isolate results directories results_base = Path(__file__).parent / "results" / "runs" / f"run{run_id}" results_base.mkdir(parents=True, exist_ok=True) # Isolate workspace directory workspace_dir = Path(__file__).parent / f"RD-Agent_workspace_run{run_id}" os.environ["RD_AGENT_WORKSPACE"] = str(workspace_dir) console.print(f" [dim]Log: {log_file}[/dim]") console.print(f" [dim]Results: results/runs/run{run_id}/[/dim]") console.print(f" [dim]Workspace: {workspace_dir.name}/[/dim]") else: # Single run mode: default log file if log_file is None: log_file = "fin_quant.log" # ---- Log File Setup ---- if log_file.lower() != "none": log_path = Path(__file__).parent / log_file log_path.parent.mkdir(parents=True, exist_ok=True) # Open log file for appending log_f = open(log_path, "a", encoding="utf-8") # Redirect stdout and stderr to both console and log file class TeeWriter: def __init__(self, *streams): self._streams = streams def write(self, data): for s in self._streams: try: s.write(data) s.flush() except: pass def flush(self): for s in self._streams: try: s.flush() except: pass sys.stdout = TeeWriter(sys.__stdout__, log_f) sys.stderr = TeeWriter(sys.__stderr__, log_f) console.print(f"\n[dim]πŸ“ Logging to: {log_path}[/dim]") else: console.print("\n[dim]⚠️ Logging disabled (console only)[/dim]") # ---- LLM Model Selection ---- if model == "openrouter": api_key = os.getenv("OPENROUTER_API_KEY", "") api_key_2 = os.getenv("OPENROUTER_API_KEY_2", "") if not api_key: console.print("\n[bold red]❌ OPENROUTER_API_KEY not set in .env[/bold red]") console.print("[yellow]Add your API key to .env:[/yellow]") console.print(' OPENROUTER_API_KEY=sk-or-your-key-here') raise typer.Exit(code=1) # Setup both API keys for load balancing os.environ["OPENAI_API_BASE"] = "https://openrouter.ai/api/v1" os.environ["CHAT_MODEL"] = os.getenv("OPENROUTER_MODEL", "openrouter/qwen/qwen3.6-plus:free") # If second key exists, configure LiteLLM for load balancing if api_key_2: os.environ["OPENAI_API_KEY"] = f"{api_key},{api_key_2}" os.environ["LITELLM_PARALLEL_CALLS"] = "2" console.print(f"\n[bold blue]🌐 Using OpenRouter (2 API Keys):[/bold blue] [cyan]{os.environ['CHAT_MODEL']}[/cyan]") console.print(f" [dim]Keys: {api_key[:15]}*** + {api_key_2[:15]}***[/dim]") console.print(f" [dim]Parallel: 2 concurrent requests[/dim]") else: os.environ["OPENAI_API_KEY"] = api_key console.print(f"\n[bold blue]🌐 Using OpenRouter:[/bold blue] [cyan]{os.environ['CHAT_MODEL']}[/cyan]") console.print(f" [dim]Key: {api_key[:15]}***[/dim]") elif model == "local": os.environ["OPENAI_API_KEY"] = os.getenv("OPENAI_API_KEY", "local") os.environ["OPENAI_API_BASE"] = os.getenv("OPENAI_API_BASE", "http://localhost:8081/v1") os.environ["CHAT_MODEL"] = os.getenv("CHAT_MODEL", "openai/qwen3.5-35b") console.print(f"\n[bold green]🏠 Using local LLM:[/bold green] [cyan]{os.environ['CHAT_MODEL']}[/cyan]") console.print(f" [dim]Base: {os.environ['OPENAI_API_BASE']}[/dim]") else: console.print(f"\n[yellow]⚠️ Unknown model: '{model}'. Using .env settings.[/yellow]") # ---- Dashboards ---- if dashboard: def start_web_dashboard(): console.print(f"\n[bold green]πŸš€ Web Dashboard: http://localhost:5000[/bold green]") subprocess.run( ["python", "web/dashboard_api.py"], cwd=str(Path(__file__).parent), env={**os.environ, "FLASK_ENV": "development"}, ) threading.Thread(target=start_web_dashboard, daemon=True).start() time.sleep(2) if cli_dashboard: def start_cli_dash(): from rdagent.log.ui.predix_dashboard import run_dashboard run_dashboard(log_path="fin_quant.log", refresh_interval=3) threading.Thread(target=start_cli_dash, daemon=True).start() time.sleep(1) # ---- Start fin_quant ---- from rdagent.app.qlib_rd_loop.quant import main as fin_quant console.print(f"\n[bold cyan]πŸ“Š Starting EURUSD Trading Loop...[/bold cyan]\n") fin_quant( step_n=step_n, loop_n=loop_n, ) @app.command() def evaluate( top: int = typer.Option( 100, "--top", "-n", help="Number of factors to evaluate (default: 100)", ), all_factors: bool = typer.Option( False, "--all", "-a", help="Evaluate all undiscovered factors", ), parallel: int = typer.Option( 4, "--parallel", "-p", help="Number of parallel workers (default: 4)", ), force: bool = typer.Option( False, "--force", "-f", help="Force re-evaluation of ALL factors (even already evaluated)", ), ): """ Evaluate existing factors with full 1min data (2020-2026). Computes IC, Sharpe, Max DD, Win Rate for each factor. Automatically skips already evaluated factors (use --force to re-evaluate). Examples: predix evaluate # Evaluate 100 NEW factors predix evaluate --top 500 # Evaluate 500 NEW factors predix evaluate --all # Evaluate all NEW factors predix evaluate --force --top 50 # Re-evaluate 50 factors predix evaluate -p 8 # Use 8 parallel workers """ console.print(Panel( "[bold cyan]πŸ“Š Predix Factor Evaluator[/bold cyan]\n" "Evaluating factors with FULL 1min data (2020-2026)\n" "Skips already evaluated factors automatically", border_style="cyan", )) # Import and run the evaluator from predix_full_eval import main as eval_main try: eval_main( top=top, all_factors=all_factors, parallel=parallel, force=force, ) except KeyboardInterrupt: console.print("\n[yellow]Evaluation interrupted by user[/yellow]") except Exception as e: console.print(f"\n[bold red]Evaluation failed: {e}[/bold red]") import traceback console.print(traceback.format_exc()) @app.command() def top( n: int = typer.Option( 20, "--num", "-n", help="Number of top factors to show (default: 20)", ), metric: str = typer.Option( "ic", "--metric", "-m", help="Sort by metric: 'ic' or 'sharpe'", ), ): """ Show top-performing factors by IC or Sharpe. Examples: predix top # Top 20 by IC predix top -n 50 # Top 50 by IC predix top -m sharpe # Top 20 by Sharpe """ import json import glob as glob_module import numpy as np from rich.table import Table from rich.panel import Panel factors_dir = Path(__file__).parent / "results" / "factors" if not factors_dir.exists(): console.print("[red]No results found in results/factors/[/red]") return # Load all factor JSON files results = [] for f in glob_module.glob(str(factors_dir / "*.json")): try: with open(f) as fh: data = json.load(fh) # Only include factors with valid IC if data.get("status") == "success" and data.get("ic") is not None: results.append(data) except Exception: continue if not results: console.print("[yellow]No evaluated factors found with valid IC[/yellow]") return # Sort by metric if metric == "sharpe": results.sort(key=lambda x: abs(x.get("sharpe", 0) or 0), reverse=True) sort_label = "Sharpe" else: results.sort(key=lambda x: abs(x.get("ic", 0) or 0), reverse=True) sort_label = "IC" # Display as table table = Table( title=f"Top {min(n, len(results))} Factors by {sort_label}", show_header=True, header_style="bold cyan", ) table.add_column("#", justify="center", width=4) table.add_column("Factor", width=40) table.add_column("IC", justify="right", width=10) table.add_column("Sharpe", justify="right", width=10) table.add_column("Ann. Return %", justify="right", width=12) table.add_column("Max DD", justify="right", width=10) table.add_column("Win Rate", justify="right", width=10) for i, r in enumerate(results[:n], 1): ic = r.get("ic") sharpe = r.get("sharpe") ann_ret = r.get("annualized_return") max_dd = r.get("max_drawdown") win_rate = r.get("win_rate") table.add_row( str(i), r["factor_name"][:38], f"{ic:.6f}" if ic is not None else "N/A", f"{sharpe:.4f}" if sharpe is not None else "N/A", f"{ann_ret:.4f}" if ann_ret is not None else "N/A", f"{max_dd:.4f}" if max_dd is not None else "N/A", f"{win_rate:.2%}" if win_rate is not None else "N/A", ) console.print(table) # Summary valid_ic = [r.get("ic") for r in results if r.get("ic") is not None] valid_sharpe = [r.get("sharpe") for r in results if r.get("sharpe") is not None] # Filter extreme outliers for average valid_sharpe_filtered = [s for s in valid_sharpe if abs(s or 0) < 1e6] console.print(Panel( f"[bold]Summary[/bold]\n" f"Total evaluated: {len(results)}\n" f"Avg IC: {np.mean(valid_ic):.6f} (n={len(valid_ic)})\n" f"Best IC: {max(valid_ic, key=abs, default=0):.6f}\n" f"Avg Sharpe: {np.mean(valid_sharpe_filtered):.4f} (n={len(valid_sharpe_filtered)})\n" f"Best Sharpe: {max(valid_sharpe, key=abs, default=0):.4f}", border_style="green", )) @app.command() def health(): """Check system health and configuration.""" from rdagent.app.utils.health_check import health_check health_check() @app.command() def status(): """Show current trading loop status.""" import sqlite3 # Process check result = subprocess.run( ["pgrep", "-f", "fin_quant"], capture_output=True, text=True ) if result.returncode == 0: console.print("[bold green]βœ… Trading Loop: RUNNING[/bold green]") else: console.print("[bold yellow]⏸️ Trading Loop: STOPPED[/bold yellow]") # DB stats db_path = Path(__file__).parent / "results" / "db" / "backtest_results.db" if db_path.exists(): conn = sqlite3.connect(str(db_path)) c = conn.cursor() c.execute("SELECT COUNT(*) FROM backtest_runs") runs = c.fetchone()[0] c.execute("SELECT COUNT(*) FROM factors") factors = c.fetchone()[0] conn.close() console.print(f"\nπŸ“Š Results:") console.print(f" Backtest runs: {runs}") console.print(f" Factors: {factors}") if __name__ == "__main__": app()