mirror of
https://github.com/NicolasBohn/NexQuant.git
synced 2026-07-27 23:47:46 +00:00
00a1d48aad
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
405 lines
14 KiB
Python
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()
|