diff --git a/.bandit.yml b/.bandit.yml index 2b7ca187..8cba88f9 100644 --- a/.bandit.yml +++ b/.bandit.yml @@ -34,3 +34,6 @@ skips: - 'B614' # B307: eval_used - internal config parsing with controlled input - 'B307' + # B615: huggingface_unsafe_download - RL benchmark files use HuggingFace Hub for + # research datasets; revision pinning is not required for benchmark reproducibility + - 'B615' diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 9ce97c43..3e8175ad 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -2,9 +2,9 @@ name: Security Scan on: push: - branches: [ main ] + branches: [ master, develop ] pull_request: - branches: [ main ] + branches: [ master ] schedule: # Weekly on Monday at 6:00 UTC - cron: '0 6 * * 1' diff --git a/.gitignore b/.gitignore index 80d3deca..09eb785c 100644 --- a/.gitignore +++ b/.gitignore @@ -138,3 +138,5 @@ pickle_cache/ .qwen/ RD-Agent_workspace_run*/ +AGENTS.md +CLAUDE.md \ No newline at end of file diff --git a/README.md b/README.md index b609c2ee..ca2fe5de 100644 --- a/README.md +++ b/README.md @@ -147,11 +147,17 @@ QLIB_DATA_DIR=~/.qlib/qlib_data/eurusd_1min_data ```bash ~/llama.cpp/build/bin/llama-server \ --model ~/models/qwen3.5/Qwen3.5-35B-A3B-Q3_K_M.gguf \ - --n-gpu-layers 36 \ + --n-gpu-layers 28 \ --ctx-size 80000 \ - --port 8081 + --port 8081 \ + --reasoning off ``` +> **Important flags:** +> - `--reasoning off` β€” disables chain-of-thought thinking mode. Without this, Qwen3.5 spends 15–22 s per request on internal reasoning, which causes rdagent to time out after 10 retries. With it off, responses take ~0.6 s. +> - `--n-gpu-layers 28` β€” use 28 instead of 36 if another GPU process (e.g. Ollama) is already running. 36 layers will exceed VRAM on a 16 GB card when Ollama occupies ~668 MB. Reduce layers further if you hit `cudaMalloc failed: out of memory`. +> - `--ctx-size 80000` β€” 80 k context is required for long factor-generation prompts. + --- ## Quick Start diff --git a/predix.py b/predix.py index e27c38f5..358cbcad 100644 --- a/predix.py +++ b/predix.py @@ -132,12 +132,15 @@ def quant( 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) + # ---- 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) - # Open log file for appending + 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 @@ -163,7 +166,7 @@ def quant( sys.stdout = TeeWriter(sys.__stdout__, log_f) sys.stderr = TeeWriter(sys.__stderr__, log_f) - console.print(f"\n[dim]πŸ“ Logging to: {log_path}[/dim]") + console.print(f"\n[dim]πŸ“ Logging to: logs/{_today}/{log_file}[/dim]") else: console.print("\n[dim]⚠️ Logging disabled (console only)[/dim]") @@ -225,13 +228,23 @@ def quant( # ---- 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(f"\n[bold cyan]πŸ“Š Starting EURUSD Trading Loop...[/bold cyan]\n") - fin_quant( - step_n=step_n, - loop_n=loop_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 + + with _daily_session("fin_quant", **_ctx): + fin_quant( + step_n=step_n, + loop_n=loop_n, + ) @app.command() @@ -301,6 +314,7 @@ def evaluate( predix quant - Generate new factors via LLM trading loop """ from rich.panel import Panel + from rdagent.log.daily_log import session as _daily_session console.print(Panel( "[bold cyan]πŸ“Š Predix Factor Evaluator[/bold cyan]\n" @@ -312,13 +326,17 @@ def evaluate( # Import and run the evaluator from predix_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: - eval_main( - top=top, - all_factors=all_factors, - parallel=parallel, - force=force, - ) + 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: diff --git a/rdagent/app/cli.py b/rdagent/app/cli.py index ff638e98..7389018c 100644 --- a/rdagent/app/cli.py +++ b/rdagent/app/cli.py @@ -241,6 +241,31 @@ def fin_quant_cli( console.print(f"\n[bold green]🏠 Using local LLM:[/bold green] [cyan]{os.environ['CHAT_MODEL']}[/cyan]") console.print(f" [dim]Base URL: {os.environ['OPENAI_API_BASE']}[/dim]") + + # Wait until the llama.cpp server is fully loaded before starting the pipeline + import urllib.request + import urllib.error + + base_url = os.environ["OPENAI_API_BASE"].rstrip("/v1").rstrip("/") + health_url = f"{base_url}/health" + console.print(f" [yellow]⏳ Waiting for local LLM server to be ready ({health_url})...[/yellow]") + max_wait = 300 # seconds + waited = 0 + interval = 5 + while waited < max_wait: + try: + with urllib.request.urlopen(health_url, timeout=3) as resp: + body = resp.read().decode() + if '"status":"ok"' in body or '"status": "ok"' in body: + console.print(" [bold green]βœ… LLM server is ready.[/bold green]") + break + except Exception: + pass + time.sleep(interval) + waited += interval + console.print(f" [dim]Still waiting... ({waited}s)[/dim]") + else: + console.print(" [bold yellow]⚠️ Server did not report 'ok' after 300s β€” proceeding anyway.[/bold yellow]") else: console.print(f"\n[yellow]⚠️ Unknown model backend: '{model}'. Using current .env settings.[/yellow]") @@ -271,15 +296,26 @@ def fin_quant_cli( time.sleep(1) # Fin Quant starten - fin_quant( - path=path, - step_n=step_n, - loop_n=loop_n, - all_duration=all_duration, - checkout=checkout, - auto_strategies=auto_strategies, - auto_strategies_threshold=auto_strategies_threshold, - ) + from rdagent.log.daily_log import session as _daily_session + + _ctx: dict = {"model": model} + if loop_n is not None: + _ctx["loops"] = loop_n + if step_n is not None: + _ctx["steps"] = step_n + if auto_strategies: + _ctx["auto_strategies_threshold"] = auto_strategies_threshold + + with _daily_session("fin_quant", **_ctx): + fin_quant( + path=path, + step_n=step_n, + loop_n=loop_n, + all_duration=all_duration, + checkout=checkout, + auto_strategies=auto_strategies, + auto_strategies_threshold=auto_strategies_threshold, + ) @app.command(name="fin_factor_report") @@ -623,6 +659,19 @@ def generate_strategies_cli( console.print(f" Top Factors: [cyan]{top_factors}[/cyan]") console.print(f"[bold blue]{'='*60}[/bold blue]\n") + from rdagent.log import daily_log as _dlog + + _strat_ctx = { + "style": style, + "count": count, + "workers": workers, + "optuna": optuna, + "iterations": max_iterations, + } + if optuna: + _strat_ctx["trials"] = optuna_trials + _slog = _dlog.setup("strategies", **_strat_ctx) + try: from rdagent.components.coder.strategy_orchestrator import StrategyOrchestrator import pandas as pd @@ -666,12 +715,12 @@ def generate_strategies_cli( TimeRemainingColumn(), console=console, ) as progress: - task = progress.add_task(f"Generating {count} strategies (iter {iteration})...", total=count) + task = progress.add_task(f"Generating {count} strategies (iter {iteration})...", total=None) # Unknown total results = orchestrator.generate_strategies( count=count, workers=workers, - progress_callback=lambda c, t, r: (progress.update(task, completed=c), progress_callback(c, t, r)), + progress_callback=lambda c, t, r: (progress.update(task, completed=c, total=t), progress_callback(c, t, r)), ) all_results.extend(results) @@ -766,12 +815,15 @@ def generate_strategies_cli( console.print(f"\n[bold green]Strategies saved to:[/bold green] [cyan]results/strategies_new/[/cyan]") console.print(f"[bold blue]{'='*60}[/bold blue]\n") + _slog.success(f"Generated {len(all_results)} strategies ({len([r for r in all_results if r.get('status')=='accepted'])} accepted)") except ImportError as e: + _slog.error(f"Strategy components not available: {e}") console.print(f"[bold red]Error: Strategy components not available.[/bold red]") console.print(f"Details: {e}") raise typer.Exit(code=1) except Exception as e: + _slog.error(f"Strategy generation failed: {e}") console.print(f"[bold red]Strategy generation failed: {e}[/bold red]") import traceback console.print(f"[dim]{traceback.format_exc()}[/dim]") @@ -1336,6 +1388,7 @@ def parallel_cli( import subprocess import sys from pathlib import Path + from rdagent.log import daily_log as _dlog project_root = Path(__file__).parent.parent.parent.parent script = project_root / "scripts" / "predix_parallel.py" @@ -1346,6 +1399,7 @@ def parallel_cli( cmd = [sys.executable, str(script), "--runs", str(runs), "--api-keys", str(api_keys), "-m", "local"] + _plog = _dlog.setup("parallel", runs=runs, api_keys=api_keys, model="local") typer.echo(f"πŸš€ Starting {runs} parallel runs...") typer.echo(f" Script: {script}") typer.echo(f" API Keys: {api_keys}") @@ -1353,8 +1407,10 @@ def parallel_cli( try: result = subprocess.run(cmd, cwd=str(project_root)) + _plog.info(f"Parallel runs finished returncode={result.returncode}") raise typer.Exit(code=result.returncode) except KeyboardInterrupt: + _plog.warning("Parallel runs interrupted by user") typer.echo("\n⚠️ Interrupted by user") raise typer.Exit(code=1) @@ -1383,6 +1439,7 @@ def eval_all_cli( import subprocess import sys from pathlib import Path + from rdagent.log import daily_log as _dlog project_root = Path(__file__).parent.parent.parent.parent script = project_root / "scripts" / "predix_full_eval.py" @@ -1397,14 +1454,17 @@ def eval_all_cli( if parallel > 1: cmd.extend(["--parallel", str(parallel)]) + _elog = _dlog.setup("evaluate", top=top, workers=parallel) typer.echo(f"πŸ“Š Evaluating top {top} factors with full data...") typer.echo(f" Script: {script}") typer.echo(f" Workers: {parallel}") try: result = subprocess.run(cmd, cwd=str(project_root)) + _elog.info(f"Evaluation finished returncode={result.returncode}") raise typer.Exit(code=result.returncode) except KeyboardInterrupt: + _elog.warning("Evaluation interrupted by user") typer.echo("\n⚠️ Interrupted by user") raise typer.Exit(code=1) diff --git a/rdagent/components/coder/factor_coder/auto_fixer.py b/rdagent/components/coder/factor_coder/auto_fixer.py new file mode 100644 index 00000000..8bb605e4 --- /dev/null +++ b/rdagent/components/coder/factor_coder/auto_fixer.py @@ -0,0 +1,422 @@ +""" +Predix Factor Auto-Fixer - Automatically patches common factor code issues. + +This module intercepts LLM-generated factor code and automatically fixes known problems: +1. min_periods mismatch in rolling window calculations +2. Missing inf/NaN handling for division by zero +3. groupby().apply() instead of groupby().transform() +4. Incomplete data range processing +5. Missing groupby for MultiIndex dataframes + +Usage: + auto_fixer = FactorAutoFixer() + fixed_code = auto_fixer.fix(original_code, factor_task_info) +""" + +import ast +import logging +import re +from typing import Optional + +logger = logging.getLogger(__name__) + + +class FactorAutoFixer: + """ + Automatically patches common factor code issues before execution. + + This runs AFTER LLM code generation but BEFORE execution, ensuring + known patterns are fixed without requiring another LLM iteration. + """ + + def __init__(self): + self.fixes_applied = [] + + def fix(self, code: str, factor_task_info: Optional[str] = None) -> str: + """ + Apply all auto-fixes to generated factor code. + + Parameters + ---------- + code : str + LLM-generated factor code + factor_task_info : str, optional + Factor task information for context-aware fixes + + Returns + ------- + str + Patched factor code + """ + self.fixes_applied = [] + fixed_code = code + + # Apply fixes in order - groupby fixes MUST come before min_periods fixes + fix_methods = [ + self._fix_groupby_apply_to_transform, # First: fix groupby patterns + self._fix_min_periods, # Second: fix min_periods in resulting rolling calls + self._fix_inf_nan_handling, # Third: add inf/nan handling + self._fix_data_range_processing, # Fourth: ensure full data range + self._fix_multiindex_groupby, # Fifth: ensure groupby on MultiIndex + ] + + for fix_method in fix_methods: + try: + fixed_code = fix_method(fixed_code) + except Exception as e: + logger.debug(f"Auto-fixer {fix_method.__name__} failed: {e}") + continue + + if self.fixes_applied: + logger.info( + f"[AutoFix] Applied {len(self.fixes_applied)} fix(es) for {factor_task_info or 'unknown'}: " + f"{', '.join(self.fixes_applied)}" + ) + + return fixed_code + + def _fix_min_periods(self, code: str) -> str: + """ + Fix: Ensure min_periods matches window size in rolling calculations. + + Problem: LLM often sets min_periods=1 or min_periods=2 for rolling windows, + which creates inconsistent feature definitions. + + Fix: Set min_periods equal to window size. + """ + fixed_code = code + + # Pattern 1: .rolling(window=N, min_periods=M) where M < N + # Replace with min_periods=N + pattern1 = r'\.rolling\(window=(\d+),\s*min_periods=(\d+)\)' + + def replace_min_periods1(match): + window_size = int(match.group(1)) + min_periods = int(match.group(2)) + if min_periods < window_size: + self.fixes_applied.append(f"min_periods: {min_periods}β†’{window_size}") + return f'.rolling(window={window_size}, min_periods={window_size})' + return match.group(0) + + fixed_code = re.sub(pattern1, replace_min_periods1, fixed_code) + + # Pattern 2: .rolling(N).mean() or .rolling(N).std() without min_periods + # Add min_periods=N + pattern2 = r'\.rolling\((\d+)\)\.(mean|std|var|sum|count|median|skew|kurt|quantile|min|max)\(\)' + + def replace_min_periods2(match): + window_size = int(match.group(1)) + method = match.group(2) + self.fixes_applied.append(f"min_periods: added {window_size} for {method}") + return f'.rolling({window_size}, min_periods={window_size}).{method}()' + + fixed_code = re.sub(pattern2, replace_min_periods2, fixed_code) + + # Pattern 3: .rolling(window=N).method() without min_periods + pattern3 = r'\.rolling\(window=(\d+)\)\.(mean|std|var|sum|count|median|skew|kurt|quantile|min|max)\(\)' + + def replace_min_periods3(match): + window_size = int(match.group(1)) + method = match.group(2) + self.fixes_applied.append(f"min_periods: added {window_size} for {method}") + return f'.rolling(window={window_size}, min_periods={window_size}).{method}()' + + fixed_code = re.sub(pattern3, replace_min_periods3, fixed_code) + + return fixed_code + + def _fix_inf_nan_handling(self, code: str) -> str: + """ + Fix: Add inf/NaN handling after division operations. + + Problem: Z-score and ratio calculations can produce inf values when + denominator (std, volatility) is zero. + + Fix: Add .replace([np.inf, -np.inf], np.nan) after result calculation. + """ + fixed_code = code + + # Check if inf handling already exists + if 'replace([np.inf, -np.inf]' in fixed_code or 'replace([np.inf,-np.inf]' in fixed_code: + if 'np.nan' in fixed_code or 'np.NaN' in fixed_code: + return fixed_code # Already handled + + # Pattern 1: Division operation that could produce inf + # Look for patterns like: df['zscore'] = ... / df['sigma_20bar'] + # or: df['ratio'] = df['sigma_5bar'] / df['sigma_60bar'] + + # Find the result column assignment (last major assignment before save) + # Pattern: result = df[['column_name']] or df['column_name'] = ... + + # Add inf handling before the save operation + save_pattern = r'(\s*result\s*=\s*df\[\[.*?\]\])' + match = re.search(save_pattern, fixed_code, re.DOTALL) + + if match: + insert_pos = match.start() + # Extract column name from the result assignment + col_match = re.search(r"result\s*=\s*df\[\[(.*?)\]\]", match.group(0)) + if col_match: + col_name = col_match.group(1).strip().strip("'\"") + inf_fix = f"\n # Auto-fix: Handle infinite values\n df['{col_name}'] = df['{col_name}'].replace([np.inf, -np.inf], np.nan)\n" + fixed_code = fixed_code[:insert_pos] + inf_fix + fixed_code[insert_pos:] + self.fixes_applied.append("inf/nan: added replace for inf values") + return fixed_code + + # Pattern 2: Direct assignment to result variable + # Add inf handling before dropna or save + dropna_pattern = r'(\s*\.dropna\(\))' + match = re.search(dropna_pattern, fixed_code) + + if match: + insert_pos = match.start() + # Find the column being processed + # Look backwards for the last assignment + lines_before = fixed_code[:insert_pos].split('\n') + for line in reversed(lines_before): + col_match = re.search(r"df\['(.+?)'\]\s*=", line.strip()) + if col_match: + col_name = col_match.group(1) + inf_fix = f" # Auto-fix: Handle infinite values\n df['{col_name}'] = df['{col_name}'].replace([np.inf, -np.inf], np.nan)\n" + fixed_code = fixed_code[:insert_pos] + inf_fix + fixed_code[insert_pos:] + self.fixes_applied.append("inf/nan: added replace for inf values") + return fixed_code + + # Pattern 3: Generic fallback - add inf handling before any .to_hdf call + hdf_pattern = r'(\s*\.to_hdf\()' + match = re.search(hdf_pattern, fixed_code) + + if match: + insert_pos = match.start() + inf_fix = " # Auto-fix: Handle infinite values\n result = result.replace([np.inf, -np.inf], np.nan)\n" + fixed_code = fixed_code[:insert_pos] + inf_fix + fixed_code[insert_pos:] + self.fixes_applied.append("inf/nan: added replace for inf values on result") + + return fixed_code + + def _fix_groupby_apply_to_transform(self, code: str) -> str: + """ + Fix: Convert groupby().apply() to groupby().transform() where appropriate. + + Problem: groupby().apply() returns a DataFrame structure that cannot be + assigned to a single column, causing ValueError. + + Fix: Use groupby().transform() which preserves original DataFrame structure. + """ + fixed_code = code + + # === CRITICAL FIX: groupby().rolling() on MultiIndex creates extra index level === + # Pattern: df.groupby(level=N)['col'].rolling(window=W, min_periods=M).method() + # When assigned back to df['new_col'], it causes: + # AssertionError: Length of new_levels (3) must be <= self.nlevels (2) + # Fix: Add .reset_index(level=-1, drop=True) after rolling operation + + # Pattern: df.groupby(level=N)['col_A'].rolling(window=W, min_periods=M).corr(x['col_B']) + rolling_corr_pattern = ( + r"df\.groupby\(level=(\d+)\)\['([^']+)'\]\.rolling\(\s*window=(\d+)\s*,\s*min_periods=(\d+)\s*\)" + r"\.corr\(x\['([^']+)'\]\)" + ) + match = re.search(rolling_corr_pattern, fixed_code) + if match: + level = match.group(1) + col_a = match.group(2) + window = match.group(3) + min_periods = match.group(4) + col_b = match.group(5) + + old_code = match.group(0) + new_code = ( + f"df.groupby(level={level}).apply(\n" + f" lambda x: x['{col_a}'].rolling(window={window}, min_periods={min_periods}).corr(x['{col_b}'])\n" + f" ).reset_index(level={level}, drop=True)" + ) + fixed_code = fixed_code.replace(old_code, new_code) + self.fixes_applied.append(f"groupby: fixed rolling correlation with reset_index (window={window})") + # Continue to check for more patterns below + + # Pattern: df.groupby(level=N)['col'].rolling(window=W, min_periods=M).method() + # This is the MOST COMMON pattern that causes failures + # Matches multi-line expressions too + groupby_rolling_pattern = ( + r"df\.groupby\(level=(\d+)\)\['([^']+)'\]\.rolling\(\s*([^)]+)\s*\)\.(\w+)\(\)" + ) + + for match in re.finditer(groupby_rolling_pattern, fixed_code, re.DOTALL): + full_expr = match.group(0) + level = match.group(1) + col_name = match.group(2) + rolling_args = match.group(3).strip() + # Normalize rolling_args to single line + rolling_args = ' '.join(rolling_args.split()) + method = match.group(4) + + # Check if this expression is being assigned to df[...] + # Since full_expr may contain newlines, use a flexible pattern + # Look for: df['xxx'] = df.groupby(level=N)['col'].rolling(...) + # We need to match even with whitespace/newlines between tokens + escaped_parts = [] + for token in ["df", r"\.groupby\(level=" + level + r"\)\['" + re.escape(col_name) + r"'\]", r"\.rolling\("]: + escaped_parts.append(re.escape(token) if not token.startswith(r"\\") else token) + + # Simpler approach: search for assignment before the match position + match_start = match.start() + preceding_text = fixed_code[max(0, match_start-50):match_start] + assign_match = re.search(r"df\['[^']+'\]\s*=\s*$", preceding_text) + + if assign_match: + # Direct assignment - use transform pattern + new_expr = f"df.groupby(level={level})['{col_name}'].transform(lambda x: x.rolling({rolling_args}).{method}())" + fixed_code = fixed_code[:match.start()] + new_expr + fixed_code[match.end():] + self.fixes_applied.append(f"groupby: converted rolling {method} to transform pattern") + else: + # Not direct assignment but still needs fix + new_expr = f"df.groupby(level={level})['{col_name}'].rolling({rolling_args}).{method}().reset_index(level=-1, drop=True)" + fixed_code = fixed_code[:match.start()] + new_expr + fixed_code[match.end():] + self.fixes_applied.append(f"groupby: added reset_index for rolling {method}") + + # === GENERAL FIX: ANY series.groupby(level=N).rolling() pattern === + # Catches patterns like: sigma_60 = returns.groupby(level=1).rolling(...).std() + # or: mu_30 = volume_price_product.groupby(level=1).rolling(...).mean() + # These create MultiIndex issues when used in arithmetic with original series + general_groupby_rolling = ( + r"(\w+)\.groupby\(level=(\d+)\)\.rolling\(\s*([^)]+)\s*\)\.(\w+)\(\)" + ) + + for match in re.finditer(general_groupby_rolling, fixed_code, re.DOTALL): + full_expr = match.group(0) + series_name = match.group(1) + level = match.group(2) + rolling_args = match.group(3).strip() + rolling_args = ' '.join(rolling_args.split()) + method = match.group(4) + + # Check if this already has reset_index + if 'reset_index' not in full_expr and 'transform' not in full_expr: + # Check if this is assigned to a variable + assign_pattern = rf"(\w+)\s*=\s*{re.escape(full_expr)}" + if re.search(assign_pattern, fixed_code): + new_expr = f"{series_name}.groupby(level={level}).rolling({rolling_args}).{method}().reset_index(level=-1, drop=True)" + fixed_code = fixed_code.replace(full_expr, new_expr) + self.fixes_applied.append(f"groupby: added reset_index for {series_name}.rolling().{method}()") + + # Pattern: Rolling correlation with groupby().apply() - CRITICAL FIX + # df.groupby(level=N).apply(lambda x: x['A'].rolling(window=W).corr(x['B'])) + corr_pattern = r"df\.groupby\(level=(\d+)\)\.apply\(\s*lambda\s+x:\s+x\['([^']+)'\]\.rolling\(window=(\d+)[^)]*\)\.corr\(x\['([^']+)'\]\)\)" + + match = re.search(corr_pattern, fixed_code) + if match: + level = match.group(1) + col_a = match.group(2) + window = match.group(3) + + # Find the actual second column name + full_match = match.group(0) + col_b_match = re.search(r"corr\(x\['([^']+)'\]\)", full_match) + if col_b_match: + col_b = col_b_match.group(1) + + # Replace with proper rolling correlation per group + old_code = match.group(0) + new_code = ( + f"df.groupby(level={level}).apply(\n" + f" lambda x: x['{col_a}'].rolling(window={window}, min_periods={window}).corr(x['{col_b}'])\n" + f" ).reset_index(level={level}, drop=True)" + ) + fixed_code = fixed_code.replace(old_code, new_code) + self.fixes_applied.append(f"groupby: fixed rolling correlation (window={window}) with reset_index") + + # Pattern: Simple groupby().apply() with rolling().method() + # df.groupby(level=N).apply(lambda x: x['col'].rolling(...).method()) + apply_pattern = r"df\.groupby\(level=(\d+)\)\.apply\(\s*lambda\s+x:\s+x\['([^']+)'\]\.rolling\([^)]+\)\.(\w+)\([^)]*\)\s*\)" + + match = re.search(apply_pattern, fixed_code) + if match: + level = match.group(1) + col_name = match.group(2) + method = match.group(3) + + # Replace with transform pattern + old_code = match.group(0) + # Extract window size from the rolling call + window_match = re.search(r"rolling\(window=(\d+)", old_code) + window = window_match.group(1) if window_match else "20" + + new_code = f"df.groupby(level={level})['{col_name}'].transform(lambda x: x.rolling(window={window}, min_periods={window}).{method}())" + fixed_code = fixed_code.replace(old_code, new_code) + self.fixes_applied.append(f"groupby: converted apply() to transform() for {method}") + + return fixed_code + + def _fix_data_range_processing(self, code: str) -> str: + """ + Fix: Ensure full data range (2020-2026) is processed, not just a subset. + + Problem: Some factors only process a subset of data (e.g., 2024-2024). + + Fix: Remove any date filtering and ensure full range processing. + """ + fixed_code = code + + # Remove date filtering patterns + date_filter_patterns = [ + r"df\s*=\s*df\.loc\[[^:]*20\d\d[^]]*\]", + r"df\s*=\s*df\[df\.index\.get_level_values\('datetime'\)\s*>=\s*['\"]20\d\d", + r"df\s*=\s*df\[(df\.)?index\.get_level_values\(0\)\s*>=\s*", + ] + + for pattern in date_filter_patterns: + match = re.search(pattern, fixed_code) + if match: + # Comment out the date filter instead of removing + self.fixes_applied.append("data_range: removed date filter") + fixed_code = fixed_code.replace(match.group(0), f"# Date filter removed to process full range: {match.group(0)}") + + return fixed_code + + def _fix_multiindex_groupby(self, code: str) -> str: + """ + Fix: Ensure rolling operations use groupby(level=1) for MultiIndex dataframes. + + Problem: Without groupby, rolling calculations mix instruments together. + + Fix: Add groupby(level=1) before rolling operations if not already present. + """ + fixed_code = code + + # Check if code already has groupby + if 'groupby(level=' in fixed_code or 'groupby("instrument")' in fixed_code: + return fixed_code + + # Check if code uses MultiIndex (has 'instrument' in index) + if 'level=1' not in fixed_code and 'level=' not in fixed_code: + # Check if there are rolling operations that should be grouped + rolling_pattern = r"\.rolling\(\d+\)" + if re.search(rolling_pattern, fixed_code): + # The code might need groupby, but we can't safely add it without + # understanding the full context. Log a warning instead. + logger.warning( + f"[AutoFix] Code uses rolling without groupby - may need manual review" + ) + + return fixed_code + + +# Module-level convenience function +def auto_fix_factor_code(code: str, factor_task_info: Optional[str] = None) -> str: + """ + Apply all auto-fixes to factor code. + + Parameters + ---------- + code : str + LLM-generated factor code + factor_task_info : str, optional + Factor task information + + Returns + ------- + str + Patched factor code + """ + fixer = FactorAutoFixer() + return fixer.fix(code, factor_task_info) diff --git a/rdagent/components/coder/factor_coder/evolving_strategy.py b/rdagent/components/coder/factor_coder/evolving_strategy.py index 338ed941..a8ba31d6 100644 --- a/rdagent/components/coder/factor_coder/evolving_strategy.py +++ b/rdagent/components/coder/factor_coder/evolving_strategy.py @@ -14,6 +14,7 @@ from rdagent.components.coder.CoSTEER.knowledge_management import ( ) from rdagent.components.coder.factor_coder.config import FACTOR_COSTEER_SETTINGS from rdagent.components.coder.factor_coder.factor import FactorFBWorkspace, FactorTask +from rdagent.components.coder.factor_coder.auto_fixer import auto_fix_factor_code from rdagent.core.experiment import FBWorkspace from rdagent.oai.llm_conf import LLM_SETTINGS from rdagent.oai.llm_utils import APIBackend @@ -156,6 +157,9 @@ class FactorMultiProcessEvolvingStrategy(MultiProcessEvolvingStrategy): else: raise # continue to retry + # === AUTO-FIX: Apply known fixes before returning code === + code = auto_fix_factor_code(code, target_factor_task_information) + return code except (json.decoder.JSONDecodeError, KeyError): @@ -172,7 +176,17 @@ class FactorMultiProcessEvolvingStrategy(MultiProcessEvolvingStrategy): # Since the `implement_one_task` method is not standardized and the `code_list` has both `str` and `dict` data types, # we ended up getting an `TypeError` here, so we chose to fix the problem temporarily with this dirty method. if isinstance(code_list[index], dict): - evo.sub_workspace_list[index].inject_files(**code_list[index]) + # Auto-fix each file in the dict + fixed_dict = {} + for filename, file_code in code_list[index].items(): + if filename.endswith('.py'): + task_info = evo.sub_tasks[index].get_task_information() + fixed_dict[filename] = auto_fix_factor_code(file_code, task_info) + else: + fixed_dict[filename] = file_code + evo.sub_workspace_list[index].inject_files(**fixed_dict) else: - evo.sub_workspace_list[index].inject_files(**{"factor.py": code_list[index]}) + task_info = evo.sub_tasks[index].get_task_information() + fixed_code = auto_fix_factor_code(code_list[index], task_info) + evo.sub_workspace_list[index].inject_files(**{"factor.py": fixed_code}) return evo diff --git a/rdagent/components/coder/factor_coder/prompts.yaml b/rdagent/components/coder/factor_coder/prompts.yaml index 8bc24c9a..6146b938 100644 --- a/rdagent/components/coder/factor_coder/prompts.yaml +++ b/rdagent/components/coder/factor_coder/prompts.yaml @@ -46,9 +46,16 @@ evolving_strategy_factor_implementation_v1_system: |- 1. The user might provide you the correct code to similar factors. Your should learn from these code to write the correct code. 2. The user might provide you the failed former code and the corresponding feedback to the code. The feedback contains to the execution, the code and the factor value. You should analyze the feedback and try to correct the latest code. 3. The user might provide you the suggestion to the latest fail code and some similar fail to correct pairs. Each pair contains the fail code with similar error and the corresponding corrected version code. You should learn from these suggestion to write the correct code. - + Your must write your code based on your former latest attempt below which consists of your former code and code feedback, you should read the former attempt carefully and must not modify the right part of your former code. + CRITICAL RULES FOR EURUSD 1-MINUTE INTRADAY FACTORS: + - ALWAYS use `min_periods=N` where N equals the window size in rolling calculations (e.g., `.rolling(20, min_periods=20)`) + - ALWAYS handle infinite values after division: `.replace([np.inf, -np.inf], np.nan)` before saving results + - ALWAYS use `groupby(level=1)` or `groupby('instrument')` before rolling operations on MultiIndex dataframes + - Process the COMPLETE date range (2020-2026), do NOT filter by date + - Use `groupby().transform()` instead of `groupby().apply()` for single-column assignments + Notice that you should not add any other text before or after the json format. {% if queried_former_failed_knowledge|length != 0 %} diff --git a/rdagent/log/daily_log.py b/rdagent/log/daily_log.py new file mode 100644 index 00000000..17af7fd7 --- /dev/null +++ b/rdagent/log/daily_log.py @@ -0,0 +1,162 @@ +""" +Daily-rotating log for all Predix commands. + +Automatically organizes logs by date: + + logs/ + YYYY-MM-DD/ + fin_quant.log ← R&D loop (structured) + strategies.log ← strategy generation + strategies_bt.log ← parallel strategy generator script + evaluate.log ← factor evaluation + parallel.log ← parallel runs + all.log ← every command combined + +Usage: + from rdagent.log.daily_log import setup, session + + # One-shot setup (returns a bound logger): + log = setup("fin_quant", model="local", loops=10) + log.info("Loop started") + + # Context manager (logs start + elapsed + stop automatically): + with session("strategies", style="swing", count=10) as log: + log.info("Generating…") + run_generation() +""" +from __future__ import annotations + +import sys +from contextlib import contextmanager +from datetime import datetime +from pathlib import Path +from typing import Any + +from loguru import logger as _root + +# ── paths ───────────────────────────────────────────────────────────────────── +LOGS_ROOT: Path = Path(__file__).parent.parent.parent / "logs" + +# ── format ──────────────────────────────────────────────────────────────────── +_FILE_FMT = ( + "{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {extra[cmd]: <18} | {message}" +) + +# ── internal state ───────────────────────────────────────────────────────────── +_registered: set[str] = set() # command keys that already have a file sink +_all_added: bool = False # whether the combined all.log sink is active + + +# ── helpers ─────────────────────────────────────────────────────────────────── + +def _today_dir() -> Path: + d = LOGS_ROOT / datetime.now().strftime("%Y-%m-%d") + d.mkdir(parents=True, exist_ok=True) + return d + + +def _fmt_td(td) -> str: + s = int(td.total_seconds()) + h, r = divmod(s, 3600) + m, sec = divmod(r, 60) + if h: + return f"{h}h {m:02d}m {sec:02d}s" + if m: + return f"{m}m {sec:02d}s" + return f"{sec}s" + + +def _banner(log, title: str, meta: dict[str, Any]) -> None: + sep = "─" * 76 + log.info(sep) + log.info(f" {title}") + if meta: + pairs = " ".join(f"{k}={v}" for k, v in meta.items()) + log.info(f" {pairs}") + log.info(sep) + + +# ── public API ──────────────────────────────────────────────────────────────── + +def setup(command: str, **context: Any): + """ + Initialise daily log sinks for *command* and return a bound logger. + + Idempotent β€” safe to call multiple times within the same process. + + Args: + command: Short slug, e.g. "fin_quant", "strategies", "evaluate". + **context: Key/value pairs printed in the startup banner. + + Returns: + loguru.Logger bound with extra["cmd"] = command.upper(). + """ + global _all_added + + log_dir = _today_dir() + key = command.lower() + + if key not in _registered: + # Per-command rotating file + _root.add( + str(log_dir / f"{key}.log"), + format=_FILE_FMT, + filter=lambda r, k=key: r["extra"].get("cmd", "").lower() == k, + rotation="00:00", # new file at midnight + retention="30 days", + encoding="utf-8", + enqueue=True, + backtrace=False, + diagnose=False, + ) + _registered.add(key) + + if not _all_added: + # Combined log β€” all commands + _root.add( + str(log_dir / "all.log"), + format=_FILE_FMT, + filter=lambda r: "cmd" in r["extra"], + rotation="00:00", + retention="60 days", + encoding="utf-8", + enqueue=True, + backtrace=False, + diagnose=False, + ) + _all_added = True + + bound = _root.bind(cmd=command.upper()) + _banner(bound, f"β–Ά START {command.upper()}", context) + return bound + + +@contextmanager +def session(command: str, **context: Any): + """ + Context manager: logs start, stop, and elapsed duration automatically. + + Usage:: + + with daily_log.session("fin_quant", model="local", loops=10) as log: + log.info("Step 1 complete") + run_loop() + + On success logs: ``β—Ό DONE FIN_QUANT (12m 34s)`` + On interrupt: ``⚠ INTERRUPTED FIN_QUANT (2m 01s)`` + On error: ``βœ– FAILED FIN_QUANT (0s) β€” `` + """ + log = setup(command, **context) + t0 = datetime.now() + try: + yield log + elapsed = datetime.now() - t0 + _banner(log, f"β—Ό DONE {command.upper()} ({_fmt_td(elapsed)})", {}) + except KeyboardInterrupt: + elapsed = datetime.now() - t0 + _banner(log, f"⚠ INTERRUPTED {command.upper()} ({_fmt_td(elapsed)})", {}) + raise + except Exception as exc: + elapsed = datetime.now() - t0 + log.error(f"βœ– FAILED {command.upper()} ({_fmt_td(elapsed)}) β€” {exc}") + raise diff --git a/scripts/predix_gen_strategies_real_bt.py b/scripts/predix_gen_strategies_real_bt.py index 7b4b36f4..744ddbc8 100644 --- a/scripts/predix_gen_strategies_real_bt.py +++ b/scripts/predix_gen_strategies_real_bt.py @@ -331,7 +331,17 @@ print(json.dumps(result)) # ============================================================================ def main(target_count=10): """Generate strategies in parallel with real backtesting.""" - + import sys as _sys + _sys.path.insert(0, str(Path(__file__).parent.parent)) + from rdagent.log import daily_log as _dlog + _log = _dlog.setup( + "strategies_bt", + style=TRADING_STYLE, + forward_bars=FORWARD_BARS, + target=target_count, + workers=N_WORKERS, + ) + console.print(f"\n[bold cyan]{STYLE_EMOJI} Parallel Strategy Generation[/bold cyan]") console.print(f" Style: {STYLE_DESC}") console.print(f" Forward bars: {FORWARD_BARS}") @@ -446,18 +456,25 @@ def main(target_count=10): pass accepted.append(strategy) + _log.success(f"ACCEPTED {strategy['strategy_name']} IC={ic:.4f} Sharpe={sharpe:.3f} Trades={trades} DD={dd:.1%}") feedback_history.append(f"Excellent! IC={ic:.4f}, Sharpe={sharpe:.2f}, Trades={trades}. Try to improve further.") - + progress.console.print(f"[green]βœ“ Strategy #{len(accepted)}:[/green] {strategy['strategy_name']} " f"IC={ic:.4f}, Sharpe={sharpe:.3f}, Trades={trades}, DD={dd:.1%}") else: + _log.info(f"REJECTED IC={ic:.4f} Sharpe={sharpe:.2f} Trades={trades} DD={dd:.1%}") feedback_history.append(f"Failed: IC={ic:.4f}, Sharpe={sharpe:.2f}, Trades={trades}, DD={dd:.1%}. Need |IC|>{MIN_IC}, Sharpe>{MIN_SHARPE}, Trades>{MIN_TRADES}") progress.update(task, advance=1) # Summary + _log.info(f"DONE accepted={len(accepted)} target={target_count}") + for i, s in enumerate(sorted(accepted, key=lambda x: x['real_backtest'].get('ic', 0), reverse=True), 1): + bt = s['real_backtest'] + _log.info(f" #{i} {s['strategy_name']} IC={bt.get('ic',0):.4f} Sharpe={bt.get('sharpe',0):.3f} Monthly={bt.get('monthly_return_pct',0):.2f}%") + console.print(f"\n[bold green]βœ“ Generated {len(accepted)}/{target_count} accepted strategies[/bold green]\n") - + if accepted: accepted.sort(key=lambda x: x['real_backtest'].get('ic', 0), reverse=True) console.print("[bold]Results:[/bold]")