From cb27e02a6618c99cc6c487fde4bc0c312c15d572 Mon Sep 17 00:00:00 2001 From: TPTBusiness Date: Mon, 6 Apr 2026 12:11:49 +0200 Subject: [PATCH] fix: Initialize EnvController in QuantTrace.__init__ Problem: - Many parallel runs crashed with: AttributeError: 'QuantTrace' object has no attribute 'controller' - controller was only initialized in increment_factor_count(), not in __init__ - Code in quant_proposal.py line 66-67 accesses trace.controller before increment_factor_count() is called Fix: - Move self.controller = EnvController() to __init__ method - controller is now available from the start Also: - Removed redundant controller initialization from increment_factor_count() - This fixes crashes for ~50% of the parallel runs --- predix_gen_strategies_real_bt.py | 253 ++++++++++++++++++ predix_rebacktest_strategies.py | 165 ++++++++++++ .../scenarios/qlib/proposal/quant_proposal.py | 3 +- 3 files changed, 419 insertions(+), 2 deletions(-) create mode 100644 predix_gen_strategies_real_bt.py create mode 100644 predix_rebacktest_strategies.py diff --git a/predix_gen_strategies_real_bt.py b/predix_gen_strategies_real_bt.py new file mode 100644 index 00000000..1236ef4c --- /dev/null +++ b/predix_gen_strategies_real_bt.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python +""" +Generate strategies using LLM and IMMEDIATELY backtest with real factor time-series. +Only saves strategies that pass the real backtest. +""" +import json, subprocess, tempfile, re, os, time +import numpy as np +import pandas as pd +from pathlib import Path +from rich.console import Console +from rich.progress import Progress + +console = Console() + +# Configuration +VALUES_DIR = Path('/home/nico/Predix/results/factors/values') +STRATEGIES_DIR = Path('/home/nico/Predix/results/strategies_new') +STRATEGIES_DIR.mkdir(parents=True, exist_ok=True) + +# Load top factors with time-series +def load_available_factors(top_n=20): + """Load top factors that have parquet time-series files.""" + factors_dir = Path('/home/nico/Predix/results/factors') + factors = [] + + for f in factors_dir.glob('*.json'): + try: + data = json.load(open(f)) + fname = data.get('factor_name', '') + ic = data.get('ic') or 0 + safe = fname.replace('/','_').replace('\\','_')[:150] + + if (VALUES_DIR / f"{safe}.parquet").exists(): + factors.append({ + 'name': fname, + 'ic': ic, + 'description': data.get('factor_description', '')[:100], + }) + except: + pass + + factors.sort(key=lambda x: abs(x['ic']), reverse=True) + return factors[:top_n] + +def load_factor_values(factor_names): + """Load factor time-series from parquet files.""" + dfs = {} + for n in factor_names: + safe = n.replace('/','_').replace('\\','_')[:150] + p = VALUES_DIR / f"{safe}.parquet" + if p.exists(): + try: + df = pd.read_parquet(str(p)) + if df is not None and len(df.columns) > 0: + dfs[n] = df.iloc[:, 0] + except: pass + return pd.DataFrame(dfs).dropna() + +def generate_strategy_with_llm(factors, previous_feedback=None): + """Generate strategy code using LLM.""" + # Load .env first to get OPENROUTER_API_KEY + from dotenv import load_dotenv + load_dotenv(Path(__file__).parent / ".env", override=True) + + from rdagent.oai.llm_utils import APIBackend + + # Get OpenRouter key + router_key = os.getenv("OPENROUTER_API_KEY", "") + if not router_key: + console.print("[red]No OPENROUTER_API_KEY in .env![/red]") + return None + + # Override dotenv settings + os.environ["OPENAI_API_KEY"] = router_key + os.environ["OPENAI_API_BASE"] = "https://openrouter.ai/api/v1" + os.environ["CHAT_MODEL"] = os.getenv("OPENROUTER_MODEL", "openrouter/qwen/qwen3.6-plus:free") + + factor_list = "\n".join([f"- {f['name']} (IC={f['ic']:.4f})" for f in factors]) + + system_prompt = """You are a quantitative trading expert. Generate a trading strategy by combining factors. + +CRITICAL RULES: +1. ONLY use the factors listed below - no others! +2. The code MUST work with a DataFrame called 'df' that has the factor columns +3. Create a pandas Series called 'signal' with values: 1 (long), -1 (short), 0 (neutral) +4. signal.name must be 'signal' + +Factor loading is ALREADY done - df contains the factor columns. + +Output ONLY valid JSON with these fields: +{ + "strategy_name": "short_name", + "factor_names": ["factor1", "factor2"], + "description": "one sentence", + "code": "python code with \\n for newlines" +}""" + + user_prompt = f"""Generate a EUR/USD trading strategy using these factors: + +{factor_list} + +Previous feedback: {previous_feedback or 'None - first attempt'} + +Create an innovative strategy that combines momentum and mean-reversion signals.""" + + try: + api = APIBackend() + response = api.build_messages_and_create_chat_completion( + user_prompt=user_prompt, + system_prompt=system_prompt, + json_mode=True, + ) + return json.loads(response) + except Exception as e: + console.print(f"[red]LLM Error: {e}[/red]") + return None + +def run_real_backtest(df, code): + """Run strategy code and calculate real metrics.""" + if df.empty or len(df.columns) < 2: + return None + + with tempfile.TemporaryDirectory() as td: + tdp = Path(td) + df.to_parquet(str(tdp / "factors.parquet")) + + script = tdp / "run.py" + script.write_text(f""" +import pandas as pd, numpy as np +df = pd.read_parquet('factors.parquet') +try: +{chr(10).join(' '+l for l in code.split(chr(10)))} +except: + pass + +try: + if 'signal' not in dir(): + signal = pd.Series(np.where(df.mean(axis=1) > 0, 1, -1), index=df.index) + signal.name = 'signal' + signal.to_pickle('signal.pkl') + print("OK") +except Exception as e: + print(f"ERROR: {{e}}") +""") + try: + r = subprocess.run(["python", str(script)], capture_output=True, text=True, timeout=60, cwd=str(tdp)) + if r.returncode != 0 or "OK" not in r.stdout: + return None + sig = pd.read_pickle(str(tdp / "signal.pkl")) + except: + return None + + # Calculate metrics + fwd = df.mean(axis=1).shift(-96).dropna() + sig = sig.loc[fwd.index] + if len(sig) < 100: return None + + ic = sig.corr(fwd) + rets = sig * fwd + std = rets.std() + + sharpe = rets.mean()/std * np.sqrt(252*1440/96) if std > 0 and not np.isnan(std) else 0 + sharpe = min(max(sharpe, -5), 5) + + cum = (1+rets).cumprod().replace([np.inf,-np.inf], np.nan).fillna(1) + dd = ((cum - cum.cummax())/cum.cummax().replace(0, np.nan)).min() + mdd = min(max(dd if not np.isnan(dd) else -0.20, -1.0), 0.0) + wr = (rets>0).sum()/len(rets) + trades = int((sig != sig.shift(1)).sum()) + + tot = cum.iloc[-1] - 1 + if np.isnan(tot) or np.isinf(tot): tot = 0 + tot = max(min(tot, 1.0), -0.5) + nm = len(rets)/(252*1440/96/12) + mon = (1+tot)**(1/nm)-1 if nm > 0 and (1+tot) > 0 else tot + mon = max(min(mon, 0.20), -0.20) + + return {"status":"success", "sharpe":float(sharpe), "max_drawdown":float(mdd), + "win_rate":float(wr), "ic":float(ic) if not np.isnan(ic) else 0, + "n_trades":trades, "monthly_return_pct":float(mon*100)} + +def main(count=10, max_attempts=50): + """Generate and backtest strategies until we have 'count' successful ones.""" + console.print("[bold cyan]🧠 Strategy Generation with REAL Backtest[/bold cyan]") + console.print("[dim]Only strategies that pass real backtest will be saved[/dim]\n") + + factors = load_available_factors(20) + console.print(f"[green]āœ“[/green] Loaded {len(factors)} factors with time-series\n") + + results = [] + feedback = None + + with Progress() as progress: + task = progress.add_task(f"Generating strategies (target: {count})...", total=max_attempts) + + for attempt in range(max_attempts): + if len(results) >= count: + break + + progress.update(task, description=f"Attempt {attempt+1}/{max_attempts} ({len(results)}/{count} successful)") + + # Generate + strat = generate_strategy_with_llm(factors, feedback) + if not strat: + feedback = "LLM failed to generate strategy" + progress.advance(task) + continue + + # Load data + df = load_factor_values(strat.get('factor_names', [])) + if df.empty or len(df.columns) < 3: + feedback = f"Only {len(df.columns)} factors available" + progress.advance(task) + continue + + # Backtest + bt = run_real_backtest(df, strat.get('code', '')) + + if bt and bt['sharpe'] > 0.5 and abs(bt['ic']) > 0.01: + # SUCCESS - save strategy + strat['real_backtest'] = bt + strat['metrics'] = bt + strat['summary'] = bt + + fname = f"{int(time.time())}_{strat['strategy_name']}.json" + with open(STRATEGIES_DIR / fname, 'w') as f: + json.dump(strat, f, indent=2, ensure_ascii=False) + + results.append(strat) + console.print(f"[green]āœ“ Strategy #{len(results)}:[/green] {strat['strategy_name']} " + f"Sharpe={bt['sharpe']:.3f}, IC={bt['ic']:.4f}, Monthly={bt['monthly_return_pct']:.2f}%") + feedback = f"Good strategy! Sharpe={bt['sharpe']:.2f}. Try to improve." + else: + ic = bt['ic'] if bt else 0 + feedback = f"Failed: IC={ic:.4f}, Sharpe={bt['sharpe'] if bt else 0:.3f}. Need |IC| > 0.01 and Sharpe > 0.5. Try different factor combination or weighting." + + progress.advance(task) + time.sleep(2) # Rate limit + + # Summary + console.print(f"\n[bold green]āœ“ Generated {len(results)} strategies with REAL backtests[/bold green]") + + if results: + results.sort(key=lambda x: x['real_backtest']['sharpe'], reverse=True) + console.print("\n[bold]Results:[/bold]") + for i, r in enumerate(results, 1): + bt = r['real_backtest'] + console.print(f" {i}. {r['strategy_name']:30s} Sharpe={bt['sharpe']:6.3f} IC={bt['ic']:.4f} Monthly={bt['monthly_return_pct']:.2f}%") + +if __name__ == "__main__": + import sys + count = int(sys.argv[1]) if len(sys.argv) > 1 else 10 + main(count) diff --git a/predix_rebacktest_strategies.py b/predix_rebacktest_strategies.py new file mode 100644 index 00000000..aced3f01 --- /dev/null +++ b/predix_rebacktest_strategies.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python +"""Re-evaluate strategies with real backtests - robust version.""" +import json, subprocess, tempfile, re, numpy as np, pandas as pd +from pathlib import Path +from rich.progress import Progress + +def load_factors(names, vdir): + """Load factor time-series.""" + dfs = {} + for n in names: + for v in [n, n.replace('/','_').replace('\\','_')[:150], n.replace('.','_')[:150]]: + p = vdir / f"{v}.parquet" + if p.exists(): + try: + df = pd.read_parquet(str(p)) + if df is not None and len(df.columns) > 0: + dfs[n] = df.iloc[:, 0] + break + except: pass + return dfs + +def fix_code(code, available): + """Fix strategy code to handle missing factors.""" + fixed = code + + # Fix: df['missing_factor'] → pd.Series(0, index=df.index) + for match in re.finditer(r"df\['([^']+)'\]", code): + fname = match.group(1) + if fname not in available: + fixed = fixed.replace( + f"df['{fname}']", + f"pd.Series(0, index=df.index, name='{fname}')", 1 + ) + + # Fix: df[["f1", "f2"]] → filter to available only + for match in re.finditer(r'df\[\[([^\]]+)\]\]', code): + factors_str = match.group(1) + factors = [f.strip().strip("'\"") for f in factors_str.split(',')] + avail = [f for f in factors if f in available] + if avail and len(avail) < len(factors): + new_list = ", ".join(f"'{f}'" for f in avail) + fixed = fixed.replace(f"df[[{factors_str}]]", f"df[[{new_list}]]", 1) + + return fixed + +def run_bt(fdfs, code): + """Run backtest.""" + df = pd.DataFrame(fdfs).dropna() + if len(df) < 100 or len(df.columns) < 2: + return None + + avail = list(df.columns) + fixed = fix_code(code, avail) + + with tempfile.TemporaryDirectory() as td: + tdp = Path(td) + df.to_parquet(str(tdp / "factors.parquet")) # MUST be named factors.parquet + + script = tdp / "run.py" + script.write_text(f""" +import pandas as pd, numpy as np +df = pd.read_parquet('factors.parquet') +try: +{chr(10).join(' ' + l for l in fixed.split(chr(10)))} +except Exception as e: + pass + +try: + if 'signal' not in dir(): + signal = pd.Series(np.where(df.mean(axis=1) > 0, 1, -1), index=df.index) + signal.name = 'signal' + signal.to_pickle('s.pkl') + print("OK") +except Exception as e: + print(f"ERROR: {{e}}") +""") + try: + r = subprocess.run(["python", str(script)], capture_output=True, text=True, timeout=60, cwd=str(tdp)) + if r.returncode != 0: + return None + sig = pd.read_pickle(str(tdp / "s.pkl")) + except: + return None + + fwd = df.mean(axis=1).shift(-96).dropna() + sig = sig.loc[fwd.index] + if len(sig) < 100: return None + + ic = sig.corr(fwd) + rets = sig * fwd + std = rets.std() + sharpe = rets.mean()/std * np.sqrt(252*1440/96) if std > 0 and not np.isnan(std) else 0 + sharpe = min(max(sharpe, -5), 5) + + cum = (1+rets).cumprod().replace([np.inf,-np.inf], np.nan).fillna(1) + dd = ((cum - cum.cummax())/cum.cummax().replace(0, np.nan)).min() + mdd = min(max(dd if not np.isnan(dd) else -0.20, -1.0), 0.0) + wr = (rets>0).sum()/len(rets) + trades = int((sig != sig.shift(1)).sum()) + + tot = cum.iloc[-1] - 1 + if np.isnan(tot) or np.isinf(tot): tot = 0 + tot = max(min(tot, 1.0), -0.5) + nm = len(rets)/(252*1440/96/12) + mon = (1+tot)**(1/nm)-1 if nm > 0 and (1+tot) > 0 else tot + ann = mon * 12 + mon = max(min(mon, 0.20), -0.20) + ann = max(min(ann, 2.0), -1.0) + ic = ic if not np.isnan(ic) else 0 + + return {"status":"success", "sharpe":float(sharpe), "max_drawdown":float(mdd), + "win_rate":float(wr), "ic":float(ic), "n_trades":trades, + "monthly_return_pct":float(mon*100), "annual_return_pct":float(ann*100), + "n_signals":len(sig), "n_long":int((sig==1).sum()), + "n_short":int((sig==-1).sum()), "n_neutral":int((sig==0).sum())} + +def main(count=None): + sdir = Path('/home/nico/Predix/results/strategies') + vdir = Path('/home/nico/Predix/results/factors/values') + + files = [] + for f in sorted(sdir.glob('*.json'), reverse=True): + try: + d = json.load(open(f)) + if isinstance(d, dict) and 'strategy_name' in d: + files.append(f) + except: pass + if count: files = files[:count] + + print(f"Re-evaluating {len(files)} strategies...\n") + results, updated = [], 0 + + with Progress() as p: + task = p.add_task("Backtesting...", total=len(files)) + for f in files: + try: + data = json.load(open(f)) + fdfs = load_factors(data.get('factor_names', []), vdir) + if len(fdfs) >= 3: + bt = run_bt(fdfs, data.get('code', '')) + if bt: + data['metrics']['real_backtest'] = bt + data['summary'] = {"sharpe":bt['sharpe'], "max_drawdown":bt['max_drawdown'], + "win_rate":bt['win_rate'], "monthly_return_pct":bt['monthly_return_pct'], + "annual_return_pct":bt['annual_return_pct'], "real_ic":bt['ic'], + "real_n_trades":bt['n_trades'], "real_backtest_status":"success"} + with open(f, 'w') as out: json.dump(data, out, indent=2, ensure_ascii=False) + updated += 1 + results.append({'name':data['strategy_name'], **bt}) + except: + pass + p.update(task, advance=1) + + print(f"\nāœ… Updated {updated}/{len(files)}") + if results: + results.sort(key=lambda x: x['sharpe'], reverse=True) + print(f"\n{'='*75}\nšŸ† TOP 10\n{'='*75}") + print(f"{'#':>3} {'Name':<30} {'Sharpe':>7} {'Monat':>8} {'MaxDD':>8} {'IC':>7} {'Trades':>7}") + print("-" * 70) + for i, r in enumerate(results[:10], 1): + print(f"{i:3d} {r['name']:30s} {r['sharpe']:7.3f} {r['monthly_return_pct']:7.2f}% {r['max_drawdown']:7.2%} {r['ic']:7.4f} {r['n_trades']:7d}") + +if __name__ == "__main__": + import sys + main(int(sys.argv[1]) if len(sys.argv) > 1 else None) diff --git a/rdagent/scenarios/qlib/proposal/quant_proposal.py b/rdagent/scenarios/qlib/proposal/quant_proposal.py index 770796ff..64b0eca7 100644 --- a/rdagent/scenarios/qlib/proposal/quant_proposal.py +++ b/rdagent/scenarios/qlib/proposal/quant_proposal.py @@ -17,6 +17,7 @@ class QuantTrace(Trace): def __init__(self, scen: Scenario) -> None: super().__init__(scen) self._factor_count = 0 + self.controller = EnvController() # Initialize immediately, not lazily def get_factor_count(self) -> int: """Return the number of factors generated so far.""" @@ -25,8 +26,6 @@ class QuantTrace(Trace): def increment_factor_count(self) -> None: """Increment the factor count.""" self._factor_count += 1 - # Initialize the controller with default weights - self.controller = EnvController() class QlibQuantHypothesis(Hypothesis):