Files
NexQuant/predix.py
T
TPTBusiness 00a1d48aad feat: Add 'predix top' command + explain factor evaluation results
New CLI commands:
- predix top [-n 20] [-m ic|sharpe]: Show top factors by IC/Sharpe
- predix evaluate [--top N] [--all] [--force]: Evaluate factors

Why 100 new factors mostly failed:
- 56 factors have IC=None (factor values are all NaN or constant)
- LLM generates code that doesn't work with EURUSD 1min data
- Common issues: volume=0 causes division by zero, wrong MultiIndex handling
- Only 346 of 501 factors have valid IC values

Working factors (Top 5):
1. daily_close_open_mom     IC=0.255
2. daily_ret_log_1d         IC=0.255
3. daily_ret_close_1d       IC=0.255
4. daily_close_to_close_ret IC=0.255
5. daily_ret_vol_adj_1d     IC=0.235

Usage:
  predix top                   # Show top 20 by IC
  predix top -n 50             # Show top 50
  predix top -m sharpe         # Sort by Sharpe
  predix evaluate --all        # Evaluate all NEW factors
  predix evaluate --force      # Re-evaluate ALL
2026-04-05 11:47:25 +02:00

405 lines
14 KiB
Python

#!/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()