Files
TPTBusiness e0000a18d2 feat: 15% monthly return target — infrastructure + daily signal resampling
Phase 1 — Infrastructure:
- RiskMgmt_RISK_PER_TRADE 0.5% → 1.5% (vbt_backtest.py)
- min_monthly_return_pct=15% acceptance filter (strategy_orchestrator)
- --min-monthly-return 15 CLI option (nexquant.py)
- {{ min_monthly_return }}% in strategy prompts
- MIN_MONTHLY_RETURN_PCT=15.0 in gen_strategies_real_bt + smart_strategy_gen
- realistic_backtest_all.py target_monthly 4→15%

Phase 2 — Factor quality:
- IC thresholds: prompt 0.05→0.08, bandit IC weight 0.10→0.20
- Explicite IC > 0.04 target in RAG prompt
- min_ic filters: data_loader 0.0→0.04, strategy_worker 0.02→0.04, ml_trainer 0.01→0.04

Architecture fix — Daily signal resampling:
- Factors have IC at daily resolution, but z-scores on 1-min collapse IC to ~0
- Resample factors to daily before strategy exec, ffill signal to 1-min for backtest
- Walk-forward IS years 3→1 (only 2 years of data available)
- Removed broken intersection() logic that destroyed 99.99% of 1-min data
- ffill stale propagation limited to 2880 bars (2 trading days)
- Fixed logger crash in _load_strategies
- Preflight: removed constant-signal check (false positive on random sandbox data)
- Tests: test_daily_signal_resampling.py (8 tests)

Non-negotiable rules: R1-R10 in AGENTS.md
2026-05-16 19:06:09 +02:00

1951 lines
79 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python
"""
NexQuant CLI - Wrapper for rdagent with LLM model selection.
Usage:
nexquant quant # Local llama.cpp (default)
nexquant quant --model local # Explicit local
nexquant quant --model openrouter # OpenRouter cloud model
nexquant 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
try:
from rdagent.utils.env import logger
except ImportError:
import logging
logger = logging.getLogger(__name__)
app = typer.Typer(help="NexQuant - AI Quantitative Trading Agent")
console = Console()
def _ensure_kronos_factor_in_pool(con) -> None:
"""Auto-generate Kronos factor and register it in the StrategyOrchestrator pool.
Runs before fin_quant starts. If the Kronos parquet already exists in
results/factors/values/ and has a matching JSON with ic, it's a no-op.
Otherwise, generates the factor (stride=500 for speed) and computes IC.
"""
import json as _json
from datetime import datetime as _dt
data_path = Path("git_ignore_folder/factor_implementation_source_data/intraday_pv.h5")
if not data_path.exists():
return # No data — skip silently
factor_name = "KronosPredReturn_p96"
factors_dir = Path("results/factors")
values_dir = factors_dir / "values"
json_path = factors_dir / f"{factor_name}.json"
parquet_path = values_dir / f"{factor_name}.parquet"
# Already in pool with IC — nothing to do
if json_path.exists() and parquet_path.exists():
try:
existing = _json.loads(json_path.read_text())
if existing.get("ic") is not None:
return
except Exception:
pass
con.print("\n[bold yellow]Kronos Factor[/bold yellow] not in pool — generating automatically...")
con.print(" [dim]stride=500 (~4500 windows), batch=32 — ~5-10 min on GPU[/dim]")
try:
from rdagent.components.coder.kronos_adapter import _cuda_available, build_kronos_factor, evaluate_kronos_model
_device = "cuda" if _cuda_available() else "cpu"
# Generate factor values
factor_df = build_kronos_factor(
hdf5_path=data_path,
context_bars=100,
pred_bars=96,
stride_bars=500,
device=_device,
batch_size=32,
)
# Save parquet to values/ directory (where StrategyOrchestrator looks)
values_dir.mkdir(parents=True, exist_ok=True)
factor_df.to_parquet(parquet_path)
# Quick IC evaluation (stride=2000 → ~1100 windows, fast)
con.print(" [dim]Computing IC...[/dim]")
metrics = evaluate_kronos_model(
hdf5_path=data_path,
context_bars=100,
pred_bars=96,
stride_bars=2000,
device=_device,
batch_size=32,
)
ic = metrics.get("IC_mean", 0.0) or 0.0
hit_rate = metrics.get("hit_rate", 0.5)
# Write JSON metadata compatible with StrategyOrchestrator
factors_dir.mkdir(parents=True, exist_ok=True)
meta = {
"factor_name": factor_name,
"status": "success",
"ic": ic,
"hit_rate": hit_rate,
"model": "NeoQuasar/Kronos-mini",
"context_bars": 100,
"pred_bars": 96,
"stride_bars": 500,
"device": _device,
"generated_at": _dt.now().isoformat(),
"n_bars": len(factor_df),
"n_non_nan": int(factor_df["KronosPredReturn"].notna().sum()),
}
json_path.write_text(_json.dumps(meta, indent=2))
color = "green" if abs(ic) > 0.01 else "yellow"
con.print(
f" [bold {color}]Kronos Factor ready:[/bold {color}] IC={ic:.4f}, "
f"Hit-Rate={hit_rate:.1%} — added to strategy pool",
)
except Exception as e:
con.print(f" [yellow]Kronos Factor generation failed ({e}) — continuing without it[/yellow]")
@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 EUR/USD quantitative trading loop with LLM-powered factor generation.
Executes the RD-Agent quantitative trading loop that uses large language models
to generate, test, and iterate on alpha factors for EUR/USD trading. Supports
both local llama.cpp inference and cloud-based OpenRouter models. Results are
automatically logged and stored in the results directory.
Args:
model: LLM backend to use. 'local' for llama.cpp (requires local server
running on OPENAI_API_BASE), 'openrouter' for cloud API. (default: "local")
dashboard: If True, starts the Flask-based web dashboard on port 5000
for real-time monitoring of the trading loop. (default: False)
cli_dashboard: If True, starts the Rich-based CLI dashboard with a 3-second
refresh interval for terminal-based monitoring. (default: False)
log_file: Path for the log file. If None, auto-detects based on run_id
(e.g., 'fin_quant.log' or 'fin_quant_run1.log'). Use 'none' to disable.
step_n: Number of individual steps to execute within the loop. None means
use the default from configuration.
loop_n: Number of complete loops to run. Each loop generates and evaluates
new alpha factors. None means use the default from configuration.
run_id: Parallel run identifier for isolated execution. When > 0, creates
separate log files, results directories, and workspace directories.
0 = single run mode (default: 0)
Examples:
$ nexquant quant # Local llama.cpp, single run
$ nexquant quant -m openrouter # OpenRouter cloud model
$ nexquant quant -d # With web dashboard on :5000
$ nexquant quant -m openrouter -d # Cloud model + web dashboard
$ nexquant quant --run-id 1 # Parallel run #1 (isolated)
$ nexquant quant --run-id 2 --loop-n 50 # Parallel run #2, 50 loops
$ nexquant quant --log-file custom.log # Custom log file path
Expected Output:
- Generated alpha factors saved to results/factors/ as JSON files
- Backtest results stored in results/db/backtest_results.db
- Log file created in project root (e.g., fin_quant.log)
- Optional: Web dashboard at http://localhost:5000
Estimated Time:
~5-15 minutes per loop depending on model and data size.
Local models are faster but may have lower quality than cloud models.
See Also:
nexquant evaluate - Evaluate existing factors with full 1min data
nexquant top - Show top-performing factors by IC or Sharpe
nexquant health - Check system health and configuration
"""
import subprocess
import sys
import threading
import time
# ---- 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]")
# Single run mode: default log file
elif log_file is None:
log_file = "fin_quant.log"
# ---- Log File Setup (daily-rotated) ----
from datetime import datetime as _dt
_today = _dt.now().strftime("%Y-%m-%d")
_daily_dir = Path(__file__).parent / "logs" / _today
_daily_dir.mkdir(parents=True, exist_ok=True)
_log_f = None
_orig_stdout = sys.stdout
_orig_stderr = sys.stderr
if log_file.lower() != "none":
log_path = _daily_dir / log_file
# Open log file for appending (raw stdout/stderr capture)
_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 Exception:
pass
def flush(self):
for s in self._streams:
try:
s.flush()
except Exception:
pass
sys.stdout = TeeWriter(_orig_stdout, _log_f)
sys.stderr = TeeWriter(_orig_stderr, _log_f)
console.print(f"\n[dim]📝 Logging to: logs/{_today}/{log_file}[/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/google/gemma-4-26b-a4b-it: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(" [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("\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.nexquant_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)
# ---- Kronos Factor: CPU inference to avoid GPU conflict with llama-server ----
try:
_ensure_kronos_factor_in_pool(console)
except Exception:
console.print("[dim]Kronos Factor skipped — torch not available[/dim]")
# ---- Start fin_quant ----
from rdagent.app.qlib_rd_loop.quant import main as fin_quant
from rdagent.log.daily_log import session as _daily_session
console.print("\n[bold cyan]📊 Starting EURUSD Trading Loop...[/bold cyan]\n")
_ctx = {"model": model}
if run_id:
_ctx["run_id"] = run_id
if loop_n:
_ctx["loops"] = loop_n
if step_n:
_ctx["steps"] = step_n
try:
with _daily_session("fin_quant", **_ctx):
fin_quant(
step_n=step_n,
loop_n=loop_n,
)
finally:
if _log_f is not None:
sys.stdout = _orig_stdout
sys.stderr = _orig_stderr
_log_f.close()
@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 alpha factors with full 1-minute intraday data (2020-2026).
Computes comprehensive performance metrics including Information Coefficient (IC),
Sharpe Ratio, Maximum Drawdown, and Win Rate for each factor. Factors are loaded
from JSON files in results/factors/ and executed against historical data to produce
out-of-sample performance estimates. Already evaluated factors are automatically
skipped unless --force is specified.
Args:
top: Number of unevaluated factors to process. Only applies when --all is
not set. Higher values increase total runtime linearly. (default: 100)
all_factors: If True, evaluates ALL unevaluated factors in the factors
directory, ignoring the --top parameter. Use with caution as this
may take hours for large factor sets. (default: False)
parallel: Number of parallel worker processes for factor evaluation.
Higher values speed up evaluation but increase memory usage.
Recommended: 4-8 for most systems. (default: 4)
force: If True, re-evaluates ALL factors including those that already
have valid results. Useful when underlying data has changed or
when recalculating with updated methodology. (default: False)
Examples:
$ nexquant evaluate # Evaluate 100 NEW factors
$ nexquant evaluate --top 500 # Evaluate 500 NEW factors
$ nexquant evaluate --all # Evaluate all remaining factors
$ nexquant evaluate --force --top 50 # Re-evaluate 50 factors
$ nexquant evaluate -p 8 # Use 8 parallel workers
Expected Output:
- Updated JSON files in results/factors/ with IC, Sharpe, Max DD, Win Rate
- Summary statistics printed to console
- Factors with errors are logged and skipped gracefully
Estimated Time:
~2-10 minutes per factor depending on complexity and data size.
With --parallel 4, expect ~30-60 seconds per factor wall-clock time.
See Also:
nexquant top - Show top-performing factors by IC or Sharpe
nexquant portfolio - Select a diversified portfolio of uncorrelated factors
nexquant quant - Generate new factors via LLM trading loop
"""
from rdagent.log.daily_log import session as _daily_session
from rich.panel import Panel
console.print(Panel(
"[bold cyan]📊 NexQuant 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 nexquant_full_eval import main as eval_main
_eval_ctx = {"top": "all" if all_factors else top, "workers": parallel}
if force:
_eval_ctx["force"] = True
try:
with _daily_session("evaluate", **_eval_ctx):
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'",
),
):
"""
Display top-performing alpha factors ranked by IC or Sharpe ratio.
Loads all evaluated factor results from results/factors/ and presents them
in a formatted table sorted by the chosen metric. Only factors with valid
IC values (status='success') are included. This is useful for quickly
identifying the most promising factors before building portfolios or strategies.
Args:
n: Number of top factors to display. Shows fewer if fewer exist in
the results directory. (default: 20)
metric: Sorting metric for ranking factors. 'ic' sorts by absolute
Information Coefficient, 'sharpe' sorts by absolute Sharpe Ratio.
IC measures predictive power, Sharpe measures risk-adjusted returns.
(default: "ic")
Examples:
$ nexquant top # Top 20 factors by absolute IC
$ nexquant top -n 50 # Top 50 factors by absolute IC
$ nexquant top -m sharpe # Top 20 factors by absolute Sharpe
$ nexquant top -n 100 -m sharpe # Top 100 factors by Sharpe
Expected Output:
- Formatted table showing Factor name, IC, Sharpe, Annualized Return,
Max Drawdown, and Win Rate for each factor
- Summary panel with average and best IC/Sharpe across all factors
Estimated Time:
Nearly instantaneous (< 1 second) for typical factor counts.
May take a few seconds with thousands of factor files.
See Also:
nexquant evaluate - Evaluate factors to generate performance metrics
nexquant portfolio - Select diversified portfolio from top factors
nexquant build-strategies - Combine factors into trading strategies
"""
import glob as glob_module
import json
import numpy as np
from rich.panel import Panel
from rich.table import Table
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:
logger.warning("Failed to load factor file %s", f, exc_info=True)
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 — filter None, NaN, and non-numeric values
valid_ic = [v for v in (r.get("ic") for r in results)
if isinstance(v, (int, float)) and v is not None and not np.isnan(v)]
valid_sharpe = [v for v in (r.get("sharpe") for r in results)
if isinstance(v, (int, float)) and v is not None and not np.isnan(v)]
# 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 portfolio(
top: int = typer.Option(
50,
"--top", "-n",
help="Number of candidate factors to consider (default: 50)",
),
target: int = typer.Option(
10,
"--target", "-t",
help="Number of factors to select (default: 10)",
),
max_corr: float = typer.Option(
0.3,
"--max-corr", "-c",
help="Maximum allowed correlation between factors (default: 0.3)",
),
):
"""
Select a diversified portfolio of uncorrelated alpha factors.
Analyzes the top factors by IC and selects a subset that minimizes redundancy
by calculating the correlation matrix of factor values. Uses a greedy selection
algorithm that prioritizes high-IC factors while ensuring pairwise correlations
stay below the specified threshold. This reduces overfitting risk and creates
more robust composite signals.
Args:
top: Number of candidate factors to consider for portfolio construction.
Factors are pre-selected by absolute IC before correlation analysis.
Higher values provide more diversity but increase computation time.
(default: 50)
target: Number of factors to include in the final portfolio. The algorithm
will attempt to select this many uncorrelated factors from the candidate
pool. May return fewer if insufficient uncorrelated factors exist.
(default: 10)
max_corr: Maximum allowed absolute correlation between any two selected
factors. Lower values produce more diverse portfolios but may exclude
high-IC factors. Typical range: 0.2-0.5. (default: 0.3)
Examples:
$ nexquant portfolio # Select top 10 from top 50 candidates
$ nexquant portfolio -n 100 -t 20 # Select top 20 from top 100
$ nexquant portfolio -c 0.5 # Allow higher correlation (0.5)
$ nexquant portfolio -n 200 -t 15 -c 0.2 # Strict diversification
Expected Output:
- Formatted table showing selected factors with IC, Sharpe, and max correlation
- Portfolio saved to results/portfolio/selected_factors.json
- Summary of skipped factors and errors (if any)
Estimated Time:
~2-10 minutes depending on candidate count.
Each factor must be re-evaluated to compute time-series values for correlation.
See Also:
nexquant portfolio-simple - Faster category-based diversification
nexquant top - View top factors before portfolio selection
nexquant build-strategies - Build strategies from selected factors
"""
import glob as glob_module
import json
import shutil
import subprocess
import tempfile
import pandas as pd
from rich.panel import Panel
from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn, TimeElapsedColumn
from rich.table import Table
factors_dir = Path(__file__).parent / "results" / "factors"
if not factors_dir.exists():
console.print("[red]No results found in results/factors/[/red]")
return
# 1. Load top factors by IC
results = []
for f in glob_module.glob(str(factors_dir / "*.json")):
try:
with open(f) as fh:
data = json.load(fh)
if data.get("status") == "success" and data.get("ic") is not None:
results.append(data)
except Exception:
logger.warning("Failed to load factor file %s", f, exc_info=True)
continue
if not results:
console.print("[red]No evaluated factors found with valid IC[/red]")
return
# Sort and select candidates
results.sort(key=lambda x: abs(x.get("ic", 0) or 0), reverse=True)
candidates = results[:top]
console.print(f"Loaded {len(results)} factors. Selecting top {top} candidates...")
# 2. Evaluate candidates to get time-series values for correlation
# We need to run the factor code to get the series of values.
# We do this sequentially to avoid OOM.
# Locate data file
data_file = Path(__file__).parent / "git_ignore_folder" / "factor_implementation_source_data" / "intraday_pv.h5"
if not data_file.exists():
data_file = Path(__file__).parent / "git_ignore_folder" / "factor_implementation_source_data_debug" / "intraday_pv.h5"
if not data_file.exists():
console.print("[red]Source data file (intraday_pv.h5) not found.[/red]")
return
factor_series = {} # name -> pd.Series
errors = []
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TaskProgressColumn(),
TimeElapsedColumn(),
console=console,
) as progress:
task = progress.add_task(f"Computing values for {len(candidates)} factors...", total=len(candidates))
for cand in candidates:
fname = cand.get("factor_name", "unknown")
fcode = cand.get("factor_code", "")
if not fcode:
errors.append((fname, "No code in JSON"))
progress.advance(task)
continue
# Create temp workspace
with tempfile.TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
# Symlink data
try:
os.symlink(str(data_file), str(tmp_path / "intraday_pv.h5"))
except OSError:
# If symlink fails, copy the file
import shutil
shutil.copy(str(data_file), str(tmp_path / "intraday_pv.h5"))
# Write code
(tmp_path / "factor.py").write_text(fcode)
try:
# Run factor
result = subprocess.run(
[sys.executable, "factor.py"],
cwd=tmp_path,
capture_output=True,
text=True,
timeout=120, # 2 min timeout per factor
)
# Read result
res_file = tmp_path / "result.h5"
if res_file.exists():
df = pd.read_hdf(str(res_file), key="data")
# Get the series (first column)
series = df.iloc[:, 0]
# Count non-NaN values
non_nan = series.count()
if non_nan < 1000:
errors.append((fname, f"Only {non_nan} valid values"))
progress.update(task, description=f"{fname}: {non_nan} values ⚠️")
else:
factor_series[fname] = series
progress.update(task, description=f"Computed {fname} ✅ ({non_nan} values)")
else:
# Check stderr for errors
stderr = result.stderr[:200] if result.stderr else "Unknown"
errors.append((fname, f"No result.h5. Error: {stderr}"))
progress.update(task, description=f"{fname} ❌ (No result)")
except subprocess.TimeoutExpired:
errors.append((fname, "Timeout (2 min)"))
progress.update(task, description=f"{fname} ⏱️ (Timeout)")
except Exception as e:
errors.append((fname, str(e)[:100]))
progress.update(task, description=f"{fname} ❌ (Error)")
progress.advance(task)
# Show summary of errors
if errors:
console.print(f"\n[yellow]Skipped {len(errors)} factors:[/yellow]")
for fname, reason in errors[:5]:
console.print(f" • {fname}: {reason}")
if len(errors) > 5:
console.print(f" ... and {len(errors)-5} more")
if len(factor_series) < 3:
console.print("[red]Not enough valid factor series to build portfolio (need at least 3).[/red]")
console.print("[yellow]Tip: Factors might be producing mostly NaN values or failing execution.[/yellow]")
# Fallback: Show top factors by IC without diversification
console.print("\n[dim]Showing top factors by IC instead:[/dim]")
table = Table(
title=f"Top {min(20, len(candidates))} Factors by IC (No Diversification)",
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)
for i, cand in enumerate(candidates[:20], 1):
table.add_row(
str(i),
cand.get("factor_name", "unknown")[:38],
f"{cand.get('ic', 0):.6f}",
f"{cand.get('sharpe', 0):.4f}" if cand.get("sharpe") else "N/A",
)
console.print(table)
return
# 3. Build Correlation Matrix
console.print(f"\n[dim]Building correlation matrix from {len(factor_series)} factors...[/dim]")
# Align indices and drop NaN
combined = pd.DataFrame(factor_series).dropna()
if combined.empty or len(combined) < 100:
console.print("[red]Not enough valid overlapping data to compute correlation.[/red]")
console.print("[dim]This means the factors produce values at different times or have too many NaN values.[/dim]")
return
corr_matrix = combined.corr().fillna(0)
ic_map = {cand["factor_name"]: cand.get("ic", 0) for cand in candidates}
# 4. Greedy Selection
selected = []
remaining = list(corr_matrix.columns)
# Sort remaining by IC to prioritize high IC factors
remaining.sort(key=lambda x: abs(ic_map.get(x, 0)), reverse=True)
for factor in remaining:
if len(selected) >= target:
break
# If it's the first one, just take it
if not selected:
selected.append(factor)
continue
# Check correlation with already selected
# We want max(|corr|) < max_corr
max_c = 0
for sel in selected:
c = abs(corr_matrix.loc[factor, sel])
max_c = max(max_c, c)
if max_c < max_corr:
selected.append(factor)
# 5. Display Results
table = Table(
title=f"Selected Diversified Portfolio (Top {len(selected)})",
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("Max Corr", justify="right", width=10)
for i, fname in enumerate(selected, 1):
# Find original data for display
data = next((c for c in candidates if c["factor_name"] == fname), {})
ic = data.get("ic")
sharpe = data.get("sharpe")
# Calculate max corr with other selected factors
max_c_val = 0
for s in selected:
if s != fname:
val = abs(corr_matrix.loc[fname, s])
max_c_val = max(max_c_val, val)
table.add_row(
str(i),
fname[:38],
f"{ic:.6f}" if ic is not None else "N/A",
f"{sharpe:.4f}" if sharpe is not None else "N/A",
f"{max_c_val:.4f}" if max_c_val > 0 else "-",
)
console.print(table)
# 6. Save Result
portfolio_data = {
"selected_factors": selected,
"max_correlation": max_corr,
"pool_size": top,
"timestamp": pd.Timestamp.now().isoformat(),
}
out_dir = Path(__file__).parent / "results" / "portfolio"
out_dir.mkdir(parents=True, exist_ok=True)
out_file = out_dir / "selected_factors.json"
with open(out_file, "w") as f:
json.dump(portfolio_data, f, indent=2)
console.print(Panel(
f"[bold]Portfolio saved to results/portfolio/selected_factors.json[/bold]\n"
f"Selected {len(selected)} unique factors from {top} candidates.",
border_style="green",
))
@app.command()
def portfolio_simple(
top: int = typer.Option(
100,
"--top", "-n",
help="Number of candidate factors to consider (default: 100)",
),
):
"""
Select a diversified portfolio using keyword-based category grouping (fast method).
Instead of computing expensive correlation matrices, this method groups factors
by their names into categories (momentum, volatility, mean_reversion, session,
volume, pattern) and selects the highest-IC factor from each category. This
provides a quick approximation of diversification without re-evaluating factors.
Falls back to 'other' category for factors that don't match any keywords.
Args:
top: Number of candidate factors to consider before categorization.
Factors are pre-selected by absolute IC. Higher values increase
the chance of finding factors in all categories. (default: 100)
Examples:
$ nexquant portfolio-simple # Top factors from different categories
$ nexquant portfolio-simple -n 200 # Consider top 200 factors
$ nexquant portfolio-simple -n 50 # Quick selection from top 50
Expected Output:
- Formatted table showing selected factors with their category, IC, and Sharpe
- Portfolio saved to results/portfolio/portfolio_simple.json
- Categories include: Momentum, Volatility, Mean Reversion, Session,
Volume, Pattern, and Other
Estimated Time:
Nearly instantaneous (< 1 second). No factor re-evaluation required.
Only loads existing JSON results and performs keyword matching.
See Also:
nexquant portfolio - Correlation-based diversification (more accurate but slower)
nexquant top - View top factors before portfolio selection
nexquant build-strategies - Build strategies from selected factors
"""
import glob as glob_module
import json
import pandas as pd
from rich.panel import Panel
from rich.table import Table
factors_dir = Path(__file__).parent / "results" / "factors"
if not factors_dir.exists():
console.print("[red]No results found in results/factors/[/red]")
return
# 1. Load top factors by IC
results = []
for f in glob_module.glob(str(factors_dir / "*.json")):
try:
with open(f) as fh:
data = json.load(fh)
if data.get("status") == "success" and data.get("ic") is not None:
results.append(data)
except Exception:
logger.warning("Failed to load factor file %s", f, exc_info=True)
continue
if not results:
console.print("[red]No evaluated factors found with valid IC[/red]")
return
# Sort by absolute IC
results.sort(key=lambda x: abs(x.get("ic", 0) or 0), reverse=True)
candidates = results[:top]
# 2. Define categories based on keywords in factor names
categories = {
"momentum": ["mom", "return", "ret", "trend", "directional", "drift", "slope", "roc"],
"volatility": ["vol", "std", "range", "dev", "risk", "variance"],
"mean_reversion": ["ridge", "mean", "reversion", "revert", "resid", "resi", "norm"],
"session": ["session", "london", "ny", "overlap", "asian", "intraday"],
"volume": ["vol_", "volume", "flow", "pressure", "toxicity", "imbalance"],
"pattern": ["pattern", "shape", "structure", "fractal"],
}
# 3. Assign each factor to a category
categorized = {cat: [] for cat in categories}
categorized["other"] = []
for cand in candidates:
fname = cand.get("factor_name", "").lower()
assigned = False
# Check each category's keywords
for cat, keywords in categories.items():
if any(kw in fname for kw in keywords):
categorized[cat].append(cand)
assigned = True
break
if not assigned:
categorized["other"].append(cand)
# 4. Select best factor from each category
selected = []
for cat in list(categories.keys()) + ["other"]:
if categorized[cat]:
best = categorized[cat][0] # Already sorted by IC
selected.append({
"factor": best,
"category": cat.capitalize() if cat != "other" else "Other",
})
# 5. Display Results
table = Table(
title=f"Simple Diversified Portfolio (Selected {len(selected)} factors)",
show_header=True,
header_style="bold cyan",
)
table.add_column("#", justify="center", width=4)
table.add_column("Factor", width=40)
table.add_column("Category", width=15)
table.add_column("IC", justify="right", width=10)
table.add_column("Sharpe", justify="right", width=10)
for i, item in enumerate(selected, 1):
cand = item["factor"]
cat = item["category"]
table.add_row(
str(i),
cand.get("factor_name", "unknown")[:38],
cat,
f"{cand.get('ic', 0):.6f}",
f"{cand.get('sharpe', 0):.4f}" if cand.get("sharpe") else "N/A",
)
console.print(table)
# 6. Save Result
portfolio_data = {
"selected_factors": [item["factor"]["factor_name"] for item in selected],
"categories": {item["category"]: item["factor"]["factor_name"] for item in selected},
"method": "simple_keyword_categorization",
"timestamp": str(pd.Timestamp.now().isoformat()),
}
out_dir = Path(__file__).parent / "results" / "portfolio"
out_dir.mkdir(parents=True, exist_ok=True)
out_file = out_dir / "portfolio_simple.json"
with open(out_file, "w") as f:
json.dump(portfolio_data, f, indent=2)
console.print(Panel(
f"[bold]Simple Portfolio saved to results/portfolio/portfolio_simple.json[/bold]\n"
f"Selected {len(selected)} factors across {len([c for c in categorized if categorized[c]])} categories.",
border_style="green",
))
@app.command()
def build_strategies(
top: int = typer.Option(
50,
"--top", "-n",
help="Number of top factors to consider (default: 50)",
),
max_combo: int = typer.Option(
2,
"--max-combo", "-c",
help="Maximum combination size: 2=pairs, 3=triplets (default: 2)",
),
diversified: bool = typer.Option(
False,
"--diversified/-d",
help="Only generate cross-category combinations",
),
):
"""
Build trading strategies by systematically combining alpha factors.
This command loads top evaluated factors, generates systematic combinations
(pairs, triplets, etc.), and evaluates each combination using walk-forward
validation. Results are ranked by Sharpe ratio and the best strategies are
saved for later use. This is ideal for discovering synergies between factors
that individually may have modest performance but work well together.
Args:
top: Number of top factors (by IC) to use as building blocks for
strategy combinations. Higher values increase the number of
combinations exponentially. (default: 50)
max_combo: Maximum number of factors per combination. 2 creates only
pairs, 3 creates pairs and triplets, etc. Higher values dramatically
increase the combination count (n choose k). (default: 2)
diversified: If True, only generates cross-category combinations,
ensuring factors come from different groups (momentum, volatility,
etc.). This reduces redundancy but may miss strong single-category
strategies. (default: False)
Examples:
$ nexquant build-strategies # Build from top 50, pairs only
$ nexquant build-strategies -n 100 -c 3 # Top 100, up to triplets
$ nexquant build-strategies -d # Diversified (cross-category) only
$ nexquant build-strategies -n 30 -c 2 -d # Top 30, diversified pairs
Expected Output:
- Formatted table of top strategies ranked by Sharpe ratio
- Strategy files saved to results/strategies/
- Summary with total combinations, success rate, avg/best Sharpe
Estimated Time:
~1-5 minutes for pairs, ~10-30 minutes for triplets.
Scales with O(n^k) where n=factors, k=max_combo_size.
See Also:
nexquant build-strategies-ai - AI-powered strategy generation via LLM
nexquant portfolio - Select diversified factors before combining
nexquant top - View top factors before building strategies
"""
import numpy as np
from rdagent.scenarios.qlib.developer.strategy_builder import StrategyBuilder
from rich.panel import Panel
from rich.table import Table
console.print(Panel(
"[bold cyan]🏗️ NexQuant Strategy Builder[/bold cyan]\n"
"Systematically combining factors into trading strategies",
border_style="cyan",
))
builder = StrategyBuilder()
try:
results = builder.build_strategies(
top_n=top,
max_combo_size=max_combo,
diversified_only=diversified,
)
except Exception as e:
console.print(f"[bold red]Strategy building failed: {e}[/bold red]")
import traceback
console.print(traceback.format_exc())
return
if not results:
console.print("[yellow]No strategies built. Check if factor values exist.[/yellow]")
return
# Display top strategies
successful = [r for r in results if r.get("status") == "success"]
if successful:
table = Table(
title=f"Top {min(20, len(successful))} Strategies by Sharpe",
show_header=True,
header_style="bold cyan",
)
table.add_column("#", justify="center", width=4)
table.add_column("Factors", width=50)
table.add_column("Sharpe", justify="right", width=8)
table.add_column("Ann. Ret %", justify="right", width=10)
table.add_column("Max DD", justify="right", width=8)
table.add_column("Win Rate", justify="right", width=8)
for i, strat in enumerate(successful[:20], 1):
factors_str = " + ".join(strat["factors"][:3])
if len(strat["factors"]) > 3:
factors_str += f" +{len(strat['factors'])-3}"
table.add_row(
str(i),
factors_str,
f"{strat.get('sharpe', 0):.4f}",
f"{strat.get('annualized_return', 0):.4f}",
f"{strat.get('max_drawdown', 0):.4f}",
f"{strat.get('win_rate', 0):.2%}",
)
console.print(table)
# Summary
avg_sharpe = np.mean([s.get("sharpe", 0) for s in successful])
best_sharpe = max(s.get("sharpe", 0) for s in successful)
avg_dd = np.mean([s.get("max_drawdown", 0) for s in successful])
console.print(Panel(
f"[bold]Strategy Building Summary[/bold]\n"
f"Total combinations: {len(results)}\n"
f"Successful: {len(successful)}\n"
f"Failed: {len(results) - len(successful)}\n"
f"Avg Sharpe: {avg_sharpe:.4f}\n"
f"Best Sharpe: {best_sharpe:.4f}\n"
f"Avg Max DD: {avg_dd:.4f}\n"
f"Saved to: results/strategies/",
border_style="green",
))
else:
console.print("[yellow]No successful strategies. Check factor values exist.[/yellow]")
@app.command()
def build_strategies_ai(
top: int = typer.Option(
50,
"--top", "-t",
help="Number of top factors to use (default: 50)",
),
max_loops: int = typer.Option(
5,
"--max-loops", "-l",
help="Maximum improvement cycles (default: 5)",
),
min_sharpe: float = typer.Option(
1.5,
"--min-sharpe",
help="Minimum Sharpe ratio for acceptance (default: 1.5)",
),
max_drawdown: float = typer.Option(
-0.20,
"--max-dd",
help="Maximum acceptable drawdown (default: -0.20)",
),
count: int = typer.Option(
1,
"--count", "-c",
help="Number of strategies to generate (default: 1, use 0 for unlimited)",
),
):
"""
Build trading strategies using AI-powered iterative improvement (StrategyCoSTEER).
Uses a large language model to generate, test, and refine trading strategies
from existing alpha factors. Follows the CoSTEER (Continuous Strategy
Evolution via Evaluative Refinement) pattern: the LLM proposes strategy
hypotheses and code, backtests are executed, results are fed back to the
LLM for analysis and improvement, and the cycle repeats until acceptance
criteria are met or max loops are reached. Requires OpenRouter API key.
Args:
top: Number of top factors (by IC) to provide as building blocks for
the AI. The LLM will select from this pool to construct strategies.
(default: 50)
max_loops: Maximum number of improvement cycles per strategy. Each loop
the LLM receives previous results and refines its approach. Higher
values may find better strategies but cost more API calls. (default: 5)
min_sharpe: Minimum Sharpe ratio threshold for strategy acceptance.
Strategies below this threshold are rejected and the LLM attempts
to improve them in subsequent loops. (default: 1.5)
max_drawdown: Maximum acceptable drawdown threshold. Strategies exceeding
this drawdown (more negative) are rejected. Expressed as a negative
decimal (e.g., -0.20 = 20% max drawdown). (default: -0.20)
count: Number of accepted strategies to generate. Set to 0 for unlimited
mode (runs until max_batches or Ctrl+C). Each accepted strategy
may require multiple improvement loops. (default: 1)
Examples:
$ nexquant build-strategies-ai # Generate 1 strategy, 5 loops max
$ nexquant build-strategies-ai -t 100 # Use top 100 factors as pool
$ nexquant build-strategies-ai -l 10 # Allow 10 improvement loops
$ nexquant build-strategies-ai --min-sharpe 2.0 # Stricter Sharpe requirement
$ nexquant build-strategies-ai --max-dd -0.15 # Tighter drawdown limit
$ nexquant build-strategies-ai -c 5 # Generate 5 accepted strategies
Expected Output:
- Formatted table of accepted strategies with Sharpe, return, drawdown,
win rate, and real IC from backtest
- Strategy files saved to results/strategies/
- Each strategy includes LLM-generated hypothesis and implementation code
Estimated Time:
~5-20 minutes per accepted strategy depending on max_loops and backtest size.
Each loop requires a full backtest execution plus LLM API calls.
See Also:
nexquant build-strategies - Systematic (non-AI) strategy combination
nexquant quant - Generate new alpha factors via LLM trading loop
nexquant evaluate - Evaluate factors before strategy building
"""
from pathlib import Path
from rich.panel import Panel
console.print(Panel(
"[bold cyan]🧠 StrategyCoSTEER - AI Strategy Builder[/bold cyan]\n"
"Generating trading strategies from existing factors\n"
"Uses LLM to combine factors, backtest, and improve",
border_style="cyan",
))
# Check if local module exists
local_module = Path(__file__).parent / "rdagent" / "scenarios" / "qlib" / "local"
if not local_module.exists():
console.print("[bold red]❌ StrategyCoSTEER not available: local/ directory not found[/bold red]")
console.print("[yellow]This is a closed-source feature. Contact development team.[/yellow]")
return
costeer_file = local_module / "strategy_coster.py"
if not costeer_file.exists():
console.print("[bold red]❌ strategy_coster.py not found[/bold red]")
return
# Load top factors
factors_dir = Path(__file__).parent / "results" / "factors"
# Setup LLM environment (same as quant command)
api_key = os.getenv("OPENROUTER_API_KEY") or os.getenv("OPENAI_API_KEY", "")
api_key_2 = os.getenv("OPENROUTER_API_KEY_2", "")
if api_key and not api_key.startswith("sk-or-"):
# OPENROUTER_API_KEY not set, try to use what we have
api_key = os.getenv("OPENROUTER_API_KEY", api_key)
if "openrouter" in os.getenv("CHAT_MODEL", "").lower() or "openrouter" in os.getenv("OPENAI_API_BASE", "").lower():
# Already configured for OpenRouter
console.print(f"\n[bold blue]🌐 Using OpenRouter: {os.getenv('CHAT_MODEL', 'unknown')}[/bold blue]")
elif api_key:
# Configure OpenRouter
if api_key_2:
os.environ["OPENAI_API_KEY"] = f"{api_key},{api_key_2}"
else:
os.environ["OPENAI_API_KEY"] = api_key
os.environ["OPENAI_API_BASE"] = "https://openrouter.ai/api/v1"
os.environ["CHAT_MODEL"] = os.getenv("OPENROUTER_MODEL", "openrouter/google/gemma-4-26b-a4b-it:free")
console.print(f"\n[bold blue]🌐 Using OpenRouter: {os.environ['CHAT_MODEL']}[/bold blue]")
else:
console.print("[bold red]❌ No API key found. Set OPENROUTER_API_KEY in .env[/bold red]")
return
if not factors_dir.exists():
console.print("[bold red]❌ No factors directory found at results/factors/[/bold red]")
console.print("[yellow]Run 'nexquant quant' to generate factors first.[/yellow]")
return
# Load evaluated factors
import glob as glob_module
import json
factors = []
for f in glob_module.glob(str(factors_dir / "*.json")):
try:
with open(f) as fh:
data = json.load(fh)
if data.get("status") == "success" and data.get("ic") is not None:
factors.append(data)
except Exception:
logger.warning("Failed to load factor file %s", f, exc_info=True)
continue
if len(factors) < 10:
console.print(f"[bold red]❌ Only {len(factors)} evaluated factors found. Need at least 10.[/bold red]")
console.print("[yellow]Run 'nexquant evaluate' or 'nexquant quant' to generate more factors.[/yellow]")
return
# Sort by IC and take top factors
factors.sort(key=lambda x: abs(x.get("ic", 0) or 0), reverse=True)
top_factors = factors[:top]
console.print(f"\n[bold green]✓ Loaded {len(top_factors)} top factors[/bold green]")
console.print(f" Max loops: {max_loops}")
console.print(f" Target Sharpe: ≥ {min_sharpe}")
console.print(f" Max Drawdown: ≥ {max_drawdown:.2%}\n")
# Run StrategyCoSTEER
try:
from rdagent.scenarios.qlib.local.strategy_coster import StrategyCoSTEER
strategies_dir = Path(__file__).parent / "results" / "strategies"
strategies_dir.mkdir(parents=True, exist_ok=True)
costeer = StrategyCoSTEER(
factors_dir=str(factors_dir),
strategies_dir=str(strategies_dir),
max_loops=max_loops,
min_sharpe=min_sharpe,
max_drawdown=max_drawdown,
)
# Generate strategies until we have enough
all_results = []
batch_idx = 0
max_batches = count if count > 0 else 999 # Unlimited if count=0
while len(all_results) < count or count == 0:
if count == 0 and batch_idx >= max_batches:
break # Safety limit for unlimited mode
if count > 0 and batch_idx >= count:
break # Already tried enough times
batch_idx += 1
console.print(f"\n[dim]━━━ Strategy Batch {batch_idx}/{count if count > 0 else '∞'} ━━━[/dim]")
results = costeer.run(top_factors)
all_results.extend(results)
if count == 0:
console.print(f"\n[dim]Generated {len(all_results)} strategies so far. Press Ctrl+C to stop.[/dim]")
elif len(all_results) < count:
console.print(f"\n[dim]Need {count - len(all_results)} more strategies...[/dim]")
results = all_results[:count] if count > 0 else all_results # Trim to exact count
# Display results
if results:
console.print(f"\n[bold green]✓ Generated {len(results)} accepted strategies![/bold green]\n")
from rich.table import Table
table = Table(title="Accepted Strategies")
table.add_column("#", style="dim")
table.add_column("Strategy", style="cyan")
table.add_column("Monthly %", justify="right", style="green")
table.add_column("Trades", justify="right")
table.add_column("Sharpe", justify="right")
table.add_column("Max DD", justify="right", style="red")
table.add_column("Win Rate", justify="right")
table.add_column("Real IC", justify="right", style="magenta")
table.add_column("Loop", justify="center")
for i, r in enumerate(results, 1):
# Monthly return: use real backtest if available, else estimate
rb = r.get("real_backtest", {})
if isinstance(rb, dict) and rb.get("status") == "success":
monthly_pct = rb.get("monthly_return_pct", r.get("monthly_return_pct", 0))
n_trades = rb.get("n_trades", "-")
real_ic = rb.get("ic", 0)
else:
monthly_pct = r.get("monthly_return_pct", r.get("real_monthly_return", 0))
n_trades = "-"
real_ic = rb.get("ic", 0) if isinstance(rb, dict) else 0
table.add_row(
str(i),
r.get("strategy_name", "unknown")[:30],
f"{monthly_pct:.2f}%",
str(n_trades),
f"{r.get('sharpe', r.get('sharpe_ratio', 0)):.3f}",
f"{r.get('max_drawdown', r.get('est_max_drawdown', 0)):.2%}",
f"{r.get('win_rate', r.get('est_win_rate', 0)):.2%}",
f"{real_ic:.4f}" if real_ic else "-",
str(r.get("loop", "?")),
)
console.print(table)
console.print(f"\n[dim]Strategies saved to: {strategies_dir}/[/dim]")
else:
console.print("[yellow]No strategies met acceptance criteria.[/yellow]")
console.print("[dim]Check factor values in results/factors/values/[/dim]")
except ImportError as e:
console.print(f"[bold red]❌ Import failed: {e}[/bold red]")
except Exception as e:
console.print(f"[bold red]❌ Strategy building failed: {e}[/bold red]")
import traceback
console.print(traceback.format_exc())
@app.command()
def generate_strategies(
count: int = typer.Option(10, "--count", "-n", help="Number of strategies to generate"),
workers: int = typer.Option(2, "--workers", "-w", help="Parallel workers"),
style: str = typer.Option("swing", "--style", "-s", help="Trading style: daytrading or swing"),
optuna: bool = typer.Option(True, "--optuna/--no-optuna", help="Enable Optuna optimization"),
optuna_trials: int = typer.Option(30, "--optuna-trials", help="Number of Optuna trials per strategy"),
top_factors: int = typer.Option(20, "--top-factors", help="Number of top factors to consider"),
min_sharpe: float = typer.Option(1.5, "--min-sharpe", help="Minimum Sharpe for acceptance"),
max_drawdown: float = typer.Option(-0.30, "--max-dd", help="Maximum drawdown allowed"),
min_win_rate: float = typer.Option(0.40, "--min-winrate", help="Minimum win rate for acceptance"),
min_monthly_return: float = typer.Option(15.0, "--min-monthly-return", help="Minimum OOS monthly return %% for acceptance"),
):
"""
Generate trading strategies from top factors using LLM + Optuna optimization.
Loads top evaluated factors, uses LLM to generate strategy code,
evaluates with real EUR/USD OHLCV backtest (2.26M 1min bars),
and optimizes hyperparameters with Optuna (3-stage: 10→15→5 trials).
Uses the verified backtest engine (Sharpe on strategy returns,
MaxDD on equity curve, WinRate on trade P&L) with runtime verification.
Examples:
$ nexquant generate-strategies # 10 strategies, Optuna, swing
$ nexquant generate-strategies -n 20 -w 4 # 20 strategies, 4 workers
$ nexquant generate-strategies --min-sharpe 3.0 # Stricter acceptance
$ nexquant generate-strategies -s daytrading # Day trading style
$ nexquant generate-strategies --no-optuna # Skip optimization
$ nexquant generate-strategies --min-monthly-return 15 # 15% OOS monthly target
"""
from rich.table import Table as RichTable
console.print(f"\n[bold cyan]{'='*60}[/bold cyan]")
console.print("[bold cyan] NexQuant Strategy Generator[/bold cyan]")
console.print(f"[bold cyan]{'='*60}[/bold cyan]")
console.print(f" Strategies: [cyan]{count}[/cyan] Workers: [cyan]{workers}[/cyan] Style: [cyan]{style}[/cyan]")
console.print(f" Optuna: {'[green]Yes[/green]' if optuna else '[yellow]No[/yellow]'} (trials={optuna_trials}) Factors: [cyan]{top_factors}[/cyan]")
console.print(f" Accept: Sharpe≥[green]{min_sharpe}[/green] DD≥[green]{max_drawdown}[/green] WR≥[green]{min_win_rate}[/green] Mon≥[green]{min_monthly_return}%[/green]")
console.print(f"[bold cyan]{'='*60}[/bold cyan]\n")
try:
from rdagent.scenarios.qlib.local.strategy_orchestrator import StrategyOrchestrator
orchestrator = StrategyOrchestrator(
top_factors=top_factors,
trading_style=style,
min_sharpe=min_sharpe,
max_drawdown=max_drawdown,
min_win_rate=min_win_rate,
min_monthly_return_pct=min_monthly_return,
use_optuna=optuna,
optuna_trials=optuna_trials,
continuous_optimization=optuna,
)
results = orchestrator.generate_strategies(count=count, workers=workers)
accepted = [r for r in results if r.get("status") == "success"]
rejected = len(results) - len(accepted)
console.print(f"\n[bold green]✓ {len(accepted)} accepted[/bold green] [yellow]{rejected} rejected[/yellow]")
if accepted:
accepted.sort(key=lambda r: r.get("sharpe_ratio", 0), reverse=True)
table = RichTable(title="Top Generated Strategies", show_header=True, header_style="bold cyan")
table.add_column("#", width=4)
table.add_column("Strategy", width=30)
table.add_column("Sharpe", width=8, justify="right")
table.add_column("MaxDD", width=8, justify="right")
table.add_column("WinRate", width=8, justify="right")
table.add_column("Trades", width=7, justify="right")
for i, r in enumerate(accepted[:10], 1):
table.add_row(
str(i), r.get("strategy_name", "?")[:28],
f"{r.get('sharpe_ratio', 0):.2f}", f"{r.get('max_drawdown', 0):.1%}",
f"{r.get('win_rate', 0):.1%}", str(r.get("num_trades", "?")),
)
console.print(table)
except ImportError as e:
console.print(f"[yellow]Strategy generator not available: {e}[/yellow]")
except Exception as e:
console.print(f"[bold red]❌ {e}[/bold red]")
@app.command()
def health():
"""Check system health and configuration status.
Runs a comprehensive diagnostic check of the PREDIX trading system including
Python version, installed dependencies, environment variables, database
connectivity, data file availability, and LLM API configuration. This command
helps identify setup issues before running computationally expensive operations.
Examples:
$ nexquant health # Run full system health check
$ nexquant health --verbose # Detailed output (if supported)
Expected Output:
- Python version and dependency status
- Environment variable check (API keys, API base URLs)
- Database connectivity test
- Data file availability (OHLCV data)
- LLM model connectivity test (if configured)
- Overall health status: PASS or FAIL per check
Estimated Time:
~5-15 seconds depending on network and database checks.
See Also:
nexquant status - Show current trading loop status and statistics
nexquant quant - Main trading loop command
"""
from rdagent.app.utils.health_check import health_check
health_check()
@app.command()
def status():
"""Show current trading loop status and database statistics.
Displays whether the quantitative trading loop (fin_quant) is currently
running by checking active processes. Also connects to the SQLite results
database and shows summary statistics including total backtest runs and
number of evaluated factors. Useful for monitoring long-running sessions
and verifying data persistence.
Examples:
$ nexquant status # Show current trading loop status
$ nexquant status --json # JSON output (if supported)
Expected Output:
- Trading loop process status: RUNNING or STOPPED
- Number of backtest runs in database
- Number of evaluated factors in database
- Database file path
Estimated Time:
Nearly instantaneous (< 1 second).
See Also:
nexquant health - Check system health and configuration
nexquant quant - Start the quantitative trading loop
nexquant top - View top evaluated factors
"""
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("\n📊 Results:")
console.print(f" Backtest runs: {runs}")
console.print(f" Factors: {factors}")
_STRATEGY_DIRS = (
Path(__file__).parent / "results" / "strategies_new",
Path(__file__).parent / "results" / "strategies",
)
_SAFE_KEYS = ("strategy_name", "factor_names", "description", "real_backtest", "metrics", "summary")
def _load_strategies():
import json
items = []
seen = set()
for d in _STRATEGY_DIRS:
if not d.exists():
continue
for p in d.glob("*.json"):
try:
raw = json.loads(p.read_text())
except Exception:
logger.warning(f"Failed to load strategy file {p}")
continue
if not isinstance(raw, dict):
continue
name = raw.get("strategy_name") or p.stem
if name in seen:
continue
seen.add(name)
metrics = raw.get("summary") or raw.get("metrics") or raw.get("real_backtest") or {}
if metrics.get("status") and metrics.get("status") != "success":
if metrics.get("real_backtest_status") != "success":
continue
items.append({
"file": p.name,
"name": name,
"factors": raw.get("factor_names") or [],
"description": raw.get("description") or "",
"sharpe": float(metrics.get("sharpe", 0) or 0),
"ic": float(metrics.get("ic", metrics.get("real_ic", 0)) or 0),
"max_drawdown": float(metrics.get("max_drawdown", 0) or 0),
"win_rate": float(metrics.get("win_rate", 0) or 0),
"n_trades": int(metrics.get("n_trades", metrics.get("real_n_trades", 0)) or 0),
"monthly_return_pct": float(metrics.get("monthly_return_pct", 0) or 0),
"annual_return_pct": float(metrics.get("annual_return_pct", 0) or 0),
"total_return": float(metrics.get("total_return", 0) or 0),
})
return items
def _composite_score(s):
dd_penalty = max(0.1, 1.0 + min(s["max_drawdown"], 0))
trade_penalty = 1.0 if s["n_trades"] >= 30 else 0.5
return s["sharpe"] * dd_penalty * trade_penalty
@app.command()
def best(
n: int = typer.Option(10, "--num", "-n", help="Number of strategies to show"),
metric: str = typer.Option("composite", "--metric", "-m", help="sharpe|ic|composite|monthly_return|annual_return"),
min_trades: int = typer.Option(30, "--min-trades", help="Filter: minimum trade count"),
realistic: bool = typer.Option(True, "--realistic/--no-realistic", help="Exclude DD<-50%% or total_return>100x (suspected numerical bugs)"),
show: str = typer.Option(None, "--show", "-s", help="Show details for one strategy by name or file id"),
export: Path = typer.Option(None, "--export", "-e", help="Export top-N metadata (without source code) to JSON"),
):
"""Rank backtested strategies by performance — source code is never exposed.
Examples:
$ nexquant best # Top 10 by composite score
$ nexquant best -n 20 -m sharpe # Top 20 by Sharpe
$ nexquant best --no-realistic # Include numerically suspicious runs
$ nexquant best --show TrendMomentumHybrid
$ nexquant best -n 50 --export /tmp/top.json
"""
import json
from rich.table import Table
items = _load_strategies()
if not items:
console.print("[red]No strategies found in results/strategies_new or results/strategies[/red]")
raise typer.Exit(1)
if show:
hit = next((s for s in items if s["name"] == show or s["file"].startswith(show)), None)
if not hit:
console.print(f"[red]Strategy not found: {show}[/red]")
raise typer.Exit(1)
console.print(f"\n[bold cyan]{hit['name']}[/bold cyan] ({hit['file']})")
console.print(f"[dim]{hit['description']}[/dim]\n")
console.print(f" Factors : {', '.join(hit['factors'])}")
console.print(f" Sharpe : {hit['sharpe']:.3f}")
console.print(f" Max Drawdown : {hit['max_drawdown']:.2%}")
console.print(f" Win Rate : {hit['win_rate']:.2%}")
console.print(f" IC : {hit['ic']:.4f}")
console.print(f" Trades : {hit['n_trades']}")
console.print(f" Monthly Ret : {hit['monthly_return_pct']:.2f}%")
console.print(f" Annual Ret : {hit['annual_return_pct']:.2f}%")
console.print(f" Composite : {_composite_score(hit):.3f}")
return
pool = [s for s in items if s["n_trades"] >= min_trades]
if realistic:
pool = [s for s in pool if s["max_drawdown"] > -0.5 and abs(s["total_return"]) < 100]
key_map = {
"sharpe": lambda s: s["sharpe"],
"ic": lambda s: abs(s["ic"]),
"composite": _composite_score,
"monthly_return": lambda s: s["monthly_return_pct"],
"annual_return": lambda s: s["annual_return_pct"],
}
if metric not in key_map:
console.print(f"[red]Unknown metric: {metric}. Use one of: {', '.join(key_map)}[/red]")
raise typer.Exit(1)
pool.sort(key=key_map[metric], reverse=True)
top = pool[:n]
if not top:
console.print("[yellow]No strategies match the filters.[/yellow]")
raise typer.Exit(0)
table = Table(title=f"Top {len(top)} Strategies (metric={metric}, min_trades={min_trades}, realistic={realistic})")
table.add_column("#", justify="right")
table.add_column("Name", style="cyan")
table.add_column("Sharpe", justify="right")
table.add_column("DD", justify="right")
table.add_column("WinRate", justify="right")
table.add_column("IC", justify="right")
table.add_column("Trades", justify="right")
table.add_column("Mon%", justify="right")
table.add_column("Factors", justify="right")
for i, s in enumerate(top, 1):
table.add_row(
str(i), s["name"], f"{s['sharpe']:.2f}", f"{s['max_drawdown']:.1%}",
f"{s['win_rate']:.1%}", f"{s['ic']:.3f}", str(s["n_trades"]),
f"{s['monthly_return_pct']:.2f}", str(len(s["factors"])),
)
console.print(table)
console.print(f"\n[dim]{len(pool)} strategies matched filters (of {len(items)} total). "
f"Use [bold]nexquant best --show NAME[/bold] for details.[/dim]")
if export:
payload = [{k: v for k, v in s.items() if k != "code"} for s in top]
export.parent.mkdir(parents=True, exist_ok=True)
export.write_text(json.dumps(payload, indent=2, default=float))
console.print(f"[green]Exported {len(top)} strategies (code stripped) → {export}[/green]")
@app.command("kronos-factor")
def kronos_factor(
context: int = typer.Option(512, "--context", "-c", help="Context window in bars (max 512 for Kronos-mini)"),
pred: int = typer.Option(96, "--pred", "-p", help="Prediction horizon in bars (default 96 = 1 trading day at 1-min)"),
stride: int = typer.Option(None, "--stride", "-s", help="Stride between windows (default: same as --pred)"),
device: str = typer.Option(None, "--device", "-d", help="Device: cuda or cpu (default: auto-detect)"),
batch_size: int = typer.Option(32, "--batch-size", "-b", help="Windows per GPU batch (higher = faster on GPU, more VRAM)"),
output: str = typer.Option(None, "--output", "-o", help="Output parquet path (default: results/factors/kronos_pred_return_p<pred>.parquet)"),
):
"""Generate Kronos-mini predicted-return alpha factor (Option A).
Runs Kronos-mini (4.1M params OHLCV foundation model, AAAI 2026) on rolling
windows of EUR/USD 1-min data and saves a predicted-return factor in NexQuant's
standard MultiIndex (datetime, instrument) format.
Strategy: every STRIDE bars, use the previous CONTEXT bars as input and
predict the next PRED bars. Windows are processed in GPU batches of BATCH_SIZE
for full GPU utilization (5-20x faster than sequential). Default (--pred 96) =
one trading day at 1-min frequency, ~2 000 windows total.
Requires:
~/Kronos repo (git clone https://github.com/shiyu-coder/Kronos ~/Kronos)
git_ignore_folder/factor_implementation_source_data/intraday_pv.h5
Examples:
$ nexquant kronos-factor # Default: daily stride, GPU
$ nexquant kronos-factor --pred 30 --device cpu # 30-bar horizon, CPU
$ nexquant kronos-factor --context 256 --pred 48
See Also:
nexquant kronos-eval - Evaluate Kronos as model and compute IC vs LightGBM
nexquant top - Show top factors by IC
"""
from rdagent.components.coder.kronos_adapter import _cuda_available
_device = device or ("cuda" if _cuda_available() else "cpu")
_stride = stride or pred
data_path = Path("git_ignore_folder/factor_implementation_source_data/intraday_pv.h5")
if not data_path.exists():
console.print(f"[red]ERROR: Data not found at {data_path}[/red]")
console.print("Run data conversion first — see README Data Setup section.")
raise typer.Exit(1)
console.print("[bold]Kronos Factor Generator[/bold]")
console.print(f" Context: [cyan]{context}[/cyan] bars | Pred: [cyan]{pred}[/cyan] bars | Device: [cyan]{_device}[/cyan]")
from rdagent.components.coder.kronos_adapter import build_kronos_factor
factor_df = build_kronos_factor(
hdf5_path=data_path,
context_bars=context,
pred_bars=pred,
stride_bars=_stride,
device=_device,
batch_size=batch_size,
)
out_dir = Path("results/factors")
out_dir.mkdir(parents=True, exist_ok=True)
out_path = Path(output) if output else out_dir / f"kronos_pred_return_p{pred}.parquet"
factor_df.to_parquet(out_path)
import json as _json
from datetime import datetime as _dt
meta = {
"factor_name": f"KronosPredReturn_p{pred}",
"description": f"Kronos-mini predicted return, {pred}-bar horizon",
"model": "NeoQuasar/Kronos-mini",
"context_bars": context,
"pred_bars": pred,
"stride_bars": _stride,
"device": _device,
"generated_at": _dt.now().isoformat(),
"n_bars": len(factor_df),
"n_non_nan": int(factor_df["KronosPredReturn"].notna().sum()),
"parquet_path": str(out_path),
}
meta_path = out_path.with_suffix(".json")
meta_path.write_text(_json.dumps(meta, indent=2))
console.print(f"\n[green]Factor saved:[/green] {out_path}")
console.print(f" Shape: {factor_df.shape} | Non-NaN: {meta['n_non_nan']}")
console.print(f" Metadata: {meta_path}")
console.print("\n[dim]Use 'nexquant top' to compare with other factors.[/dim]")
@app.command("kronos-eval")
def kronos_eval(
context: int = typer.Option(512, "--context", "-c", help="Context window in bars"),
pred: int = typer.Option(30, "--pred", "-p", help="Prediction horizon in bars"),
stride: int = typer.Option(None, "--stride", "-s", help="Stride between evaluations (default: same as --pred)"),
device: str = typer.Option(None, "--device", "-d", help="Device: cuda or cpu (default: auto-detect)"),
batch_size: int = typer.Option(32, "--batch-size", "-b", help="Windows per GPU batch (higher = faster on GPU, more VRAM)"),
):
"""Evaluate Kronos-mini as standalone model — IC and hit rate vs LightGBM (Option B).
Runs Kronos inference on the full EUR/USD dataset and computes:
- IC (Information Coefficient): correlation between predicted and actual returns
- IC IR: IC / std — risk-adjusted signal strength (>0.5 = good)
- Hit Rate: directional accuracy (>50% = useful signal)
Results are printed and saved to results/kronos/ for comparison with LightGBM
models generated by fin_quant.
Requires:
~/Kronos repo (git clone https://github.com/shiyu-coder/Kronos ~/Kronos)
git_ignore_folder/factor_implementation_source_data/intraday_pv.h5
Examples:
$ nexquant kronos-eval # Default: 30-bar horizon
$ nexquant kronos-eval --pred 96 --device cuda # Daily horizon, GPU
$ nexquant kronos-eval --context 256 --pred 15 # Shorter horizon
See Also:
nexquant kronos-factor - Generate Kronos factor for the factor pipeline
nexquant best - Show top strategies
"""
from rdagent.components.coder.kronos_adapter import _cuda_available
_device = device or ("cuda" if _cuda_available() else "cpu")
_stride = stride or pred
data_path = Path("git_ignore_folder/factor_implementation_source_data/intraday_pv.h5")
if not data_path.exists():
console.print(f"[red]ERROR: Data not found at {data_path}[/red]")
raise typer.Exit(1)
console.print("[bold]Kronos Model Evaluator[/bold] (alongside LightGBM)")
console.print(f" Context: [cyan]{context}[/cyan] bars | Pred: [cyan]{pred}[/cyan] bars | Device: [cyan]{_device}[/cyan]")
console.print(" Running evaluation...")
from rdagent.components.coder.kronos_adapter import evaluate_kronos_model
metrics = evaluate_kronos_model(
hdf5_path=data_path,
context_bars=context,
pred_bars=pred,
stride_bars=_stride,
device=_device,
batch_size=batch_size,
)
console.print("\n[bold]Kronos-mini Results[/bold]")
console.print(f" Predictions: [cyan]{metrics['n_predictions']}[/cyan]")
console.print(f" IC (mean): [{'green' if metrics['IC_mean'] > 0.02 else 'yellow'}]{metrics['IC_mean']:.4f}[/]")
console.print(f" IC IR: [{'green' if metrics['IC_IR'] > 0.5 else 'yellow'}]{metrics['IC_IR']:.4f}[/] (>0.5 = strong signal)")
console.print(f" Hit Rate: [{'green' if metrics['hit_rate'] > 0.52 else 'yellow'}]{metrics['hit_rate']:.2%}[/] (>50% = directionally useful)")
console.print("\n[dim]Reference: LightGBM baseline IC typically 0.010.05 on 1-min EUR/USD[/dim]")
import json as _json
out_dir = Path("results/kronos")
out_dir.mkdir(parents=True, exist_ok=True)
out_path = out_dir / f"kronos_eval_ctx{context}_pred{pred}.json"
out_path.write_text(_json.dumps({**metrics, "context_bars": context, "pred_bars": pred}, indent=2))
console.print(f"\n[green]Results saved:[/green] {out_path}")
if __name__ == "__main__":
app()