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
This commit is contained in:
TPTBusiness
2026-04-06 12:11:49 +02:00
parent 51aebefe7a
commit cb27e02a66
3 changed files with 419 additions and 2 deletions
+253
View File
@@ -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)
+165
View File
@@ -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)
@@ -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):