""" Predix Strategy Orchestrator - Generate trading strategies from factors. This module: 1. Loads top evaluated factors from the results database 2. Generates LLM-powered trading strategy code 3. Evaluates strategies using real OHLCV backtest 4. Accepts/rejects based on performance thresholds 5. Saves accepted strategies as JSON files Usage: orchestrator = StrategyOrchestrator( top_factors=20, trading_style='swing', min_sharpe=0.3, max_drawdown=-0.30, ) results = orchestrator.generate_strategies(count=10, workers=4) """ import json import logging import os import time import traceback from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional import numpy as np import pandas as pd import requests from rdagent.components.prompt_loader import load_prompt from rdagent.components.coder.optuna_optimizer import OptunaOptimizer # OHLCV data path OHLCV_PATH = Path(os.getenv( 'PREDIX_OHLCV_PATH', '/home/nico/Predix/git_ignore_folder/factor_implementation_source_data/intraday_pv.h5' )) from rdagent.log import rdagent_logger as logger logger = logging.getLogger(__name__) class StrategyOrchestrator: """ Orchestrates strategy generation from evaluated factors. Uses LLM to generate strategy code from factor combinations, then evaluates each strategy using real OHLCV backtest data. """ def __init__( self, top_factors: int = 20, trading_style: str = "swing", min_sharpe: float = 0.3, max_drawdown: float = -0.30, min_win_rate: float = 0.40, results_dir: Optional[str] = None, use_optuna: bool = True, optuna_trials: int = 20, continuous_optimization: bool = True, ): """ Parameters ---------- top_factors : int Number of top factors to consider for strategy generation trading_style : str Trading style: 'daytrading' or 'swing' min_sharpe : float Minimum Sharpe ratio for strategy acceptance max_drawdown : float Maximum allowed drawdown (negative value) min_win_rate : float Minimum win rate for strategy acceptance results_dir : str, optional Path to results directory use_optuna : bool Enable Optuna hyperparameter optimization optuna_trials : int Number of Optuna trials per strategy continuous_optimization : bool If True, optimize ALL strategies (including rejected ones) Optuna can often rescue strategies with bad initial parameters """ self.top_factors = top_factors self.trading_style = trading_style.lower() self.min_sharpe = min_sharpe self.max_drawdown = max_drawdown self.min_win_rate = min_win_rate self.use_optuna = use_optuna self.optuna_trials = optuna_trials self.continuous_optimization = continuous_optimization if results_dir is None: project_root = Path(__file__).parent.parent.parent.parent self.results_dir = project_root / "results" else: self.results_dir = Path(results_dir) self.strategies_dir = self.results_dir / "strategies_new" self.strategies_dir.mkdir(parents=True, exist_ok=True) self.factors_dir = self.results_dir / "factors" self.values_dir = self.factors_dir / "values" # Load prompt for strategy generation try: self.strategy_prompt = load_prompt("strategy_generation") except Exception: self.strategy_prompt = None logger.warning("Strategy generation prompt not found. Using fallback template.") logger.info( f"StrategyOrchestrator initialized: style={self.trading_style}, " f"top_factors={self.top_factors}, min_sharpe={self.min_sharpe}" ) def load_ohlcv_close(self) -> pd.Series: """Load OHLCV close prices from HDF5 file.""" if not OHLCV_PATH.exists(): logger.warning(f"OHLCV data not found: {OHLCV_PATH}") return None try: ohlcv = pd.read_hdf(str(OHLCV_PATH), key='data') if '$close' in ohlcv.columns: close = ohlcv['$close'].dropna() elif 'close' in ohlcv.columns: close = ohlcv['close'].dropna() else: close = ohlcv.select_dtypes(include=[np.number]).iloc[:, 0].dropna() # Handle MultiIndex if isinstance(close.index, pd.MultiIndex): try: close = close.xs('EURUSD', level='instrument') except KeyError: idx = close.index.get_level_values('instrument') == 'EURUSD' close = close[idx] close.index = close.index.droplevel('instrument') return close except Exception as e: logger.warning(f"Failed to load OHLCV data: {e}") return None def load_top_factors(self) -> List[Dict[str, Any]]: """ Load top evaluated factors from JSON files. Returns ------- List[Dict[str, Any]] List of factor info dicts sorted by IC """ if not self.factors_dir.exists(): logger.warning(f"Factors directory not found: {self.factors_dir}") return [] factors = [] for f in self.factors_dir.glob("*.json"): try: with open(f, encoding="utf-8") as fh: data = json.load(fh) if data.get("status") == "success" and data.get("ic") is not None: data["_source_file"] = str(f) factors.append(data) except Exception as e: logger.debug(f"Failed to load {f}: {e}") continue # Sort by absolute IC and take top N factors.sort(key=lambda x: abs(x.get("ic", 0) or 0), reverse=True) # Filter to only include factors that have parquet files factors_with_files = [] for f in factors: fname = f.get("factor_name", "") safe = fname.replace("/", "_").replace("\\", "_").replace(" ", "_")[:100] pf = self.values_dir / f"{safe}.parquet" if pf.exists(): factors_with_files.append(f) else: logger.debug(f"Skipping {fname} - no parquet file") # Select diverse factor TYPES, not just top IC # This ensures we get momentum, volatility, session, volume, etc. type_keywords = { "momentum": [], "trend": [], "volatility": [], "volume": [], "session": [], "london": [], "range": [], "vwap": [], "return": [], "ofi": [], "spread": [], "close": [], "divergence": [], "other": [] } for f in factors_with_files: name = f.get("factor_name", "").lower() matched = False for kw in type_keywords: if kw in name: type_keywords[kw].append(f) matched = True break if not matched: type_keywords["other"].append(f) # Select best from each type (ensures diversity) selected = [] already_names = set() # Priority order: momentum, divergence, volatility, session, volume, etc. priority_types = ["momentum", "divergence", "volatility", "session", "london", "range", "vwap", "volume", "ofi", "spread", "return", "trend", "close", "other"] per_type = max(2, self.top_factors // len(priority_types)) for kw in priority_types: for f in sorted(type_keywords[kw], key=lambda x: abs(x.get("ic", 0)), reverse=True): if f["factor_name"] not in already_names: selected.append(f) already_names.add(f["factor_name"]) if len([s for s in selected if s["factor_name"] in [x["factor_name"] for x in type_keywords[kw]]]) >= per_type: break # Fill remaining with highest IC not yet selected if len(selected) < self.top_factors: remaining = [f for f in factors_with_files if f["factor_name"] not in already_names] remaining.sort(key=lambda x: abs(x.get("ic", 0)), reverse=True) selected.extend(remaining[:self.top_factors - len(selected)]) # Log diversity type_counts = {} for f in selected: name = f.get("factor_name", "").lower() matched = False for kw in type_keywords: if kw in name: type_counts[kw] = type_counts.get(kw, 0) + 1 matched = True break if not matched: type_counts["other"] = type_counts.get("other", 0) + 1 logger.info(f"Selected {len(selected)} diverse factors: {type_counts}") return selected[:self.top_factors] def load_factor_values(self, factor_name: str) -> Optional[pd.Series]: """ Load factor time-series values from parquet file. Parameters ---------- factor_name : str Name of the factor Returns ------- pd.Series or None Factor values indexed by timestamp """ safe_name = factor_name.replace("/", "_").replace("\\", "_").replace(" ", "_")[:100] parquet_path = self.values_dir / f"{safe_name}.parquet" if not parquet_path.exists(): return None try: df = pd.read_parquet(str(parquet_path)) # Handle empty DataFrame if df.empty or len(df.columns) == 0: logger.warning(f"Empty parquet file: {parquet_path}") return None # Handle MultiIndex (datetime, instrument) if isinstance(df.index, pd.MultiIndex): # Get the factor column name (should be the only column) factor_col = df.columns[0] # Extract EURUSD series try: series = df.xs('EURUSD', level='instrument')[factor_col] except KeyError: # Try alternative extraction df_reset = df.reset_index() if 'instrument' in df_reset.columns: df_eur = df_reset[df_reset['instrument'] == 'EURUSD'].set_index('datetime') series = df_eur[factor_col] if factor_col in df_eur.columns else df_eur.iloc[:, -1] else: series = df.iloc[:, 0] else: series = df.iloc[:, 0] # Ensure numeric series = pd.to_numeric(series, errors='coerce') series.name = factor_name return series except Exception as e: logger.warning(f"Failed to load factor values for {factor_name}: {e}") return None def generate_strategy_code( self, factors: List[Dict[str, Any]], strategy_name: str, max_retries: int = 3, ) -> Optional[str]: """ Generate strategy code using LLM from factor combinations. Parameters ---------- factors : List[Dict[str, Any]] List of factor info dicts to combine strategy_name : str Name for the generated strategy max_retries : int Maximum number of retry attempts with feedback (default: 3) Returns ------- str or None Generated Python strategy code """ factor_names = [f["factor_name"] for f in factors] factor_ics = {f["factor_name"]: f.get("ic", 0) for f in factors} # Build prompt context with proper template variable replacement if isinstance(self.strategy_prompt, dict): user_prompt_raw = self.strategy_prompt.get("user", "") # Replace ALL template variables user_prompt = user_prompt_raw user_prompt = user_prompt.replace("{{ factors }}", str(factor_ics)) user_prompt = user_prompt.replace("{{ ic_values }}", str(factor_ics)) # IC values for each factor user_prompt = user_prompt.replace("{{ additional_context }}", f"Strategy name: {strategy_name}") user_prompt = user_prompt.replace("{{ trading_style }}", self.trading_style) user_prompt = user_prompt.replace("{{ min_sharpe }}", str(self.min_sharpe)) user_prompt = user_prompt.replace("{{ max_drawdown }}", str(self.max_drawdown)) system_prompt = self.strategy_prompt.get("system", "") else: user_prompt = "" system_prompt = "" context = { "strategy_name": strategy_name, "factor_names": factor_names, "factor_ics": factor_ics, "trading_style": self.trading_style, "min_sharpe": self.min_sharpe, "max_drawdown": self.max_drawdown, "system_prompt": system_prompt, "user_prompt": user_prompt, } # Try LLM first with retry logic if self.strategy_prompt is not None: last_error = None for attempt in range(1, max_retries + 1): try: feedback = last_error if attempt > 1 else None code = self._generate_with_llm(context, attempt=attempt, feedback=feedback) if code: logger.info(f"LLM generated valid code on attempt {attempt} ({len(code)} chars)") return code last_error = f"Attempt {attempt}: LLM returned empty or invalid code" logger.warning(f"LLM attempt {attempt}/{max_retries} failed: {last_error}") except Exception as e: last_error = f"Attempt {attempt}: {str(e)}" logger.warning(f"LLM attempt {attempt}/{max_retries} failed with exception: {e}") logger.warning( f"LLM strategy generation failed after {max_retries} attempts. " f"Last error: {last_error}" ) # Fallback: generate template code programmatically logger.info("Using fallback code generation") return self._generate_fallback_code(context) def _generate_with_llm( self, context: Dict[str, Any], attempt: int = 1, feedback: Optional[str] = None, ) -> Optional[str]: """ Generate strategy code using LLM with APIBackend (same as Factor Coder). Parameters ---------- context : Dict[str, Any] Prompt context including system and user prompts attempt : int Current retry attempt number (1-based) feedback : str, optional Feedback message from previous failed attempts Returns ------- str or None Validated Python strategy code, or None if invalid """ import json as json_module # Build user message with optional feedback user_content = context.get("user_prompt", "") if feedback: user_content += f"\n\nPREVIOUS ATTEMPT FAILED: {feedback}\n\nPlease output ONLY valid JSON with a 'code' field." system_content = context.get("system_prompt", "") # Use APIBackend factory function (same as Factor Coder) from rdagent.oai.llm_utils import APIBackend try: response = APIBackend().build_messages_and_create_chat_completion( user_prompt=user_content, system_prompt=system_content, json_mode=True, ) except Exception as e: logger.warning(f"APIBackend call failed (attempt {attempt}): {e}") return None if not response: logger.warning(f"APIBackend returned empty response (attempt {attempt})") return None # Log response for debugging logger.info(f"[DEBUG] APIBackend response (attempt {attempt}): {len(response)} chars, preview: {response[:100]!r}") # === STEP 1: Try JSON extraction FIRST === content = response.strip() json_data = self._extract_json(content) if json_data is not None: logger.info(f"[DEBUG] JSON extracted successfully, keys: {list(json_data.keys())}") code = self._extract_code_from_json(json_data) if code: if self._validate_python_code(code): logger.info(f"[DEBUG] Valid Python code extracted ({len(code)} chars)") return code else: logger.warning(f"JSON 'code' field contains invalid Python (attempt {attempt}). Preview: {code[:200]}") else: logger.warning(f"JSON parsed but no valid 'code' field found (attempt {attempt}). Keys: {list(json_data.keys())}") # === STEP 2: Fallback - Extract Python code block directly (like Factor Coder) === import re code_block_match = re.search(r'```python\s*\n(.*?)\n```', content, re.DOTALL) if code_block_match: code = code_block_match.group(1).strip() if code and self._validate_python_code(code): logger.info(f"[DEBUG] Python code block extracted ({len(code)} chars)") return code logger.warning(f"All extraction methods failed (attempt {attempt}). Response preview: {response[:200]}") return None def _extract_json(self, content: str) -> Optional[Dict[str, Any]]: """ Extract JSON object from LLM response content. Tries multiple strategies: 1. Direct parse if content starts with { 2. Find ```json ... ``` blocks 3. Find ```python ... ``` blocks (Qwen often wraps JSON in python blocks) 4. Find first { to last } in content 5. Find ``` ... ``` blocks (any language tag) Returns ------- Dict or None Parsed JSON data, or None if no valid JSON found """ import json as json_module import re # Strategy 1: Direct parse stripped = content.strip() if stripped.startswith("{") and stripped.endswith("}"): try: return json_module.loads(stripped) except json_module.JSONDecodeError: pass # Strategy 2: Find ```json ... ``` blocks json_block_match = re.search(r'```json\s*\n(.*?)\n```', content, re.DOTALL) if json_block_match: try: return json_module.loads(json_block_match.group(1)) except json_module.JSONDecodeError: pass # Strategy 3: Find ```python ... ``` blocks (Qwen often puts JSON in python blocks) python_block_match = re.search(r'```python\s*\n(.*?)\n```', content, re.DOTALL) if python_block_match: block = python_block_match.group(1).strip() if block.startswith("{") and block.endswith("}"): try: return json_module.loads(block) except json_module.JSONDecodeError: pass # Strategy 4: Find first { to last } first_brace = content.find("{") last_brace = content.rfind("}") if first_brace != -1 and last_brace > first_brace: json_str = content[first_brace : last_brace + 1] try: return json_module.loads(json_str) except json_module.JSONDecodeError: # Try to fix common JSON issues (trailing commas, unescaped newlines) try: # Remove trailing commas before } or ] json_str_fixed = re.sub(r',\s*([}\]])', r'\1', json_str) return json_module.loads(json_str_fixed) except json_module.JSONDecodeError: pass # Strategy 5: Find ``` ... ``` blocks (any language tag) code_block_match = re.search(r'```\w*\s*\n(.*?)\n```', content, re.DOTALL) if code_block_match: block = code_block_match.group(1).strip() if block.startswith("{") and block.endswith("}"): try: return json_module.loads(block) except json_module.JSONDecodeError: pass return None def _extract_code_from_json(self, json_data: Dict[str, Any]) -> Optional[str]: """ Extract Python code from parsed JSON data. Checks multiple possible field names: 'code', 'strategy_code', 'python_code' Also handles nested JSON where code might have literal \n characters. Returns ------- str or None Extracted Python code """ # Try known field names in priority order for key in ("code", "strategy_code", "python_code", "strategy"): if key in json_data: code_value = json_data[key] if isinstance(code_value, str) and code_value.strip(): # Handle JSON-escaped newlines code = code_value.replace("\\n", "\n") return code.strip() return None def _extract_code_from_raw(self, content: str) -> Optional[str]: """ Extract Python code from raw (non-JSON) LLM response. Tries: 1. Extract from ```python ... ``` blocks 2. Extract from ``` ... ``` blocks 3. Use entire content as code Returns ------- str or None Extracted Python code """ import re from collections import Counter code = content.strip() # Try to find ```python blocks python_match = re.search(r'```python\s*\n(.*?)\n```', code, re.DOTALL) if python_match: code = python_match.group(1) else: # Try generic ``` blocks block_match = re.search(r'```\s*\n(.*?)\n```', code, re.DOTALL) if block_match: code = block_match.group(1) if not code or not code.strip(): return None # Remove markdown code fences if still present code = code.replace("```python", "").replace("```", "").strip() # Smart dedent: find most common non-zero indent lines = code.split("\n") indents = Counter() for line in lines: if line.strip(): # Non-empty line indent = len(line) - len(line.lstrip()) indents[indent] += 1 # Remove empty lines from consideration indents.pop(0, None) if indents: common_indent = indents.most_common(1)[0][0] else: common_indent = 0 dedented = [] for line in lines: if len(line) >= common_indent and line[:common_indent].isspace(): dedented.append(line[common_indent:]) else: dedented.append(line.lstrip()) code = "\n".join(dedented).strip() # Remove non-code commentary lines final_lines = [] for line in code.split("\n"): stripped = line.strip() if not stripped: continue # Stop at commentary (bullets, conversational text) commentary_patterns = ["*", "\u2022", "Wait", "Also", "One more", "Note:", "Here", "This strategy"] if any(stripped.startswith(p) for p in commentary_patterns): break final_lines.append(line) code = "\n".join(final_lines).strip() # Remove non-ASCII characters (emojis, etc.) code = code.encode("ascii", "ignore").decode("ascii").strip() return code if code else None def _validate_python_code(self, code: str) -> bool: """ Validate that a string is valid Python code. Parameters ---------- code : str Code string to validate Returns ------- bool True if code compiles successfully """ if not code or len(code) < 10: logger.debug("Code too short to be valid") return False try: compile(code, "", "exec") return True except SyntaxError as e: logger.debug(f"Python syntax error: {e}") return False def _generate_fallback_code(self, context: Dict[str, Any]) -> str: """Generate fallback strategy code programmatically.""" factor_names = context["factor_names"] style_config = "daytrading" if context["trading_style"] == "daytrading" else "swing" # Build factor assignment code factor_assignments = "\n ".join( [f'"{name}": factors["{name}"]' for name in factor_names if name != "timestamp"] ) code = f'''""" {context['strategy_name']} - {style_config.title()} Strategy Auto-generated by Predix Strategy Orchestrator Factors: {', '.join(factor_names)} """ import numpy as np import pandas as pd # Strategy configuration STRATEGY_NAME = "{context['strategy_name']}" TRADING_STYLE = "{style_config}" FACTOR_NAMES = {json.dumps(factor_names)} # Calculate combined signal factor_data = pd.DataFrame({{ {factor_assignments} }}) # Normalize factors to z-scores factor_norm = (factor_data - factor_data.mean()) / factor_data.std() # Weighted combination (weight by IC) weights = np.array([{", ".join([str(abs(context["factor_ics"].get(n, 0.01))) for n in factor_names if n != "timestamp"])}]) weights = weights / weights.sum() combined_signal = (factor_norm * weights).sum(axis=1) # Generate trading signals # Entry: signal crosses above/below threshold # Exit: signal crosses back toward zero entry_threshold = 0.5 exit_threshold = 0.2 signal = pd.Series(0, index=combined_signal.index) signal[combined_signal > entry_threshold] = 1 signal[combined_signal < -entry_threshold] = -1 signal[abs(combined_signal) < exit_threshold] = 0 # Smooth signals to reduce turnover signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int) ''' return code def evaluate_strategy( self, strategy_code: str, strategy_name: str, factors: List[Dict[str, Any]] ) -> Dict[str, Any]: """ Evaluate a strategy by executing its code and calculating metrics. Parameters ---------- strategy_code : str Python strategy code to execute strategy_name : str Name of the strategy factors : List[Dict[str, Any]] List of factor info dicts used by this strategy Returns ------- Dict[str, Any] Strategy evaluation metrics """ try: # Load factor values factor_names = [f["factor_name"] for f in factors if f["factor_name"] != "timestamp"] factor_values = {} for fname in factor_names: series = self.load_factor_values(fname) if series is not None: factor_values[fname] = series if not factor_values: return { "strategy_name": strategy_name, "status": "rejected", "reason": "No factor values available", "factors_used": factor_names, } # Align factor values with common index if not factor_values: df_factors = pd.DataFrame() else: # Find common index across all series common_idx = None for name, s in factor_values.items(): if common_idx is None: common_idx = s.index else: common_idx = common_idx.intersection(s.index) if common_idx is not None and len(common_idx) > 100: df_factors = pd.DataFrame({ name: s.reindex(common_idx) for name, s in factor_values.items() }).dropna() else: df_factors = pd.DataFrame() if len(df_factors) < 100: return { "strategy_name": strategy_name, "status": "rejected", "reason": "Insufficient aligned data", "factors_used": factor_names, } # Convert all factor columns to numeric for col in df_factors.columns: df_factors[col] = pd.to_numeric(df_factors[col], errors='coerce') # Forward-fill daily factors to match OHLCV 1-min index # Many factors are daily (1 value per day), need to ffill to 1-min # FIX 6: Track ffill ratio for data quality monitoring close = self.load_ohlcv_close() if close is not None: original_len = len(df_factors) df_factors = df_factors.reindex(close.index).ffill() # Log how much was ffill'd ffill_ratio = 1.0 - (original_len / len(df_factors)) if len(df_factors) > original_len else 0.0 logger.info( f"[DEBUG] {strategy_name}: data quality: " f"original_rows={original_len}, " f"ffill_rows={len(df_factors) - original_len}, " f"ffill_ratio={ffill_ratio:.2%}" ) df_factors = df_factors.dropna() if len(df_factors) < 1000: return { "strategy_name": strategy_name, "status": "rejected", "reason": f"Insufficient numeric data after conversion ({len(df_factors)} rows)", "factors_used": factor_names, } # close is already loaded above for ffill, reuse it # Reindex close to match factor index if close is not None: close = close.reindex(df_factors.index) # Execute strategy code with factor data and close prices local_vars = {"factors": df_factors} if close is not None: local_vars["close"] = close try: exec(strategy_code, {"np": np, "pd": pd, "numpy": np}, local_vars) except Exception as e: return { "strategy_name": strategy_name, "status": "rejected", "reason": f"Code execution error: {str(e)}", "factors_used": factor_names, } if "signal" not in local_vars: return { "strategy_name": strategy_name, "status": "rejected", "reason": "Strategy did not produce 'signal' variable", "factors_used": factor_names, } signal = local_vars["signal"] logger.info( f"[DEBUG] {strategy_name}: signal stats: " f"len={len(signal)}, " f"long={int((signal > 0).sum())}, " f"short={int((signal < 0).sum())}, " f"flat={int((signal == 0).sum())}, " f"unique={signal.nunique()}" ) # Delegate all metric computation to the single source of truth. # Same formulas as every other backtest path in the repo. from rdagent.components.backtesting.vbt_backtest import ( backtest_signal, DEFAULT_TXN_COST_BPS, ) close = self.load_ohlcv_close() if close is None: logger.warning("OHLCV data unavailable, using factor-mean proxy") proxy = df_factors.mean(axis=1).astype(float) synthetic_close = (1 + proxy.pct_change().fillna(0) * 0.0001).cumprod() * 100.0 close_for_bt = synthetic_close else: close_for_bt = close.reindex(signal.index).ffill() txn_cost_bps = float(os.getenv("TXN_COST_BPS", DEFAULT_TXN_COST_BPS)) bt = backtest_signal( close=close_for_bt, signal=signal, txn_cost_bps=txn_cost_bps, freq="1min", ) if bt.get("status") != "success": return { "strategy_name": strategy_name, "status": "rejected", "reason": bt.get("reason", "backtest failed"), "factors_used": factor_names, } sharpe = bt["sharpe"] max_dd = bt["max_drawdown"] win_rate = bt["win_rate"] num_real_trades = bt["n_trades"] logger.info( f"[DEBUG] {strategy_name}: bt stats: " f"sharpe={sharpe:.4f} dd={max_dd:.4f} wr={win_rate:.4f} " f"trades={num_real_trades} total_ret={bt['total_return']:.4%}" ) metrics = { "strategy_name": strategy_name, "status": "accepted" if self._check_acceptance(sharpe, max_dd, win_rate) else "rejected", "sharpe_ratio": round(sharpe, 4), "annualized_return": round(bt["annualized_return"], 6), "annual_return_cagr": round(bt["annual_return_cagr"], 6), "max_drawdown": round(max_dd, 6), "win_rate": round(win_rate, 4), "volatility": round(bt["volatility"], 6), "total_return": round(bt["total_return"], 6), "num_periods": bt["n_bars"], "num_real_trades": num_real_trades, "profit_factor": round(bt["profit_factor"], 4) if np.isfinite(bt["profit_factor"]) else None, "sortino": round(bt["sortino"], 4), "calmar": round(bt["calmar"], 4), "factors_used": factor_names, "trading_style": self.trading_style, "generated_at": datetime.now().isoformat(), } if "data_quality_flag" in bt: metrics["data_quality_flag"] = bt["data_quality_flag"] if metrics["status"] == "rejected": logger.info( f"[DEBUG] {strategy_name}: rejection breakdown: " f"sharpe={sharpe:.4f} (need>={self.min_sharpe}), " f"dd={max_dd:.4f} (need>={self.max_drawdown}), " f"wr={win_rate:.4f} (need>={self.min_win_rate})" ) metrics["reason"] = self._get_rejection_reason(sharpe, max_dd, win_rate) return metrics except Exception as e: logger.error(f"Strategy evaluation failed for {strategy_name}: {e}") logger.debug(traceback.format_exc()) return { "strategy_name": strategy_name, "status": "rejected", "reason": f"Evaluation error: {str(e)}", "factors_used": [], } def _check_acceptance(self, sharpe: float, max_dd: float, win_rate: float) -> bool: """Check if strategy meets acceptance criteria.""" return sharpe >= self.min_sharpe and max_dd >= self.max_drawdown and win_rate >= self.min_win_rate def _get_rejection_reason(self, sharpe: float, max_dd: float, win_rate: float) -> str: """Get human-readable rejection reason.""" reasons = [] if sharpe < self.min_sharpe: reasons.append(f"Sharpe {sharpe:.2f} < {self.min_sharpe}") if max_dd < self.max_drawdown: reasons.append(f"Max DD {max_dd:.2%} < {self.max_drawdown:.2%}") if win_rate < self.min_win_rate: reasons.append(f"Win Rate {win_rate:.2%} < {self.min_win_rate:.2%}") return "; ".join(reasons) if reasons else "Unknown" def _generate_strategy_name(self, factors: List[Dict[str, Any]], idx: int) -> str: """Generate a strategy name from its factors.""" # Extract key words from factor names words = [] for f in factors: name = f["factor_name"] # Split on underscores and camelCase parts = name.replace("_", " ").split() for p in parts: # Extract capitalized words cap_words = [w for w in p.split() if w[0:1].isupper()] words.extend(cap_words if cap_words else [p]) # Take up to 3 unique words unique_words = list(dict.fromkeys(words))[:3] if unique_words: return f"{''.join(unique_words)}_v{idx}" return f"Strategy_{idx}" def generate_strategies( self, count: int = 10, workers: int = 2, # Reduced from 4 to 2 to avoid LLM server overload progress_callback=None, ) -> List[Dict[str, Any]]: """ Generate and evaluate trading strategies. Parameters ---------- count : int Number of strategies to generate workers : int Number of parallel workers progress_callback : callable, optional Callback function(current, total, result) for progress updates Returns ------- List[Dict[str, Any]] List of strategy results (accepted and rejected) """ # Load factors factors = self.load_top_factors() if not factors: logger.warning("No factors available for strategy generation") return [] logger.info(f"Loaded {len(factors)} top factors for strategy generation") results = [] strategies_generated = 0 strategies_accepted = 0 # Generate strategies using factor combinations strategy_configs = self._generate_strategy_configs(factors, count) # Execute strategies with thread pool with ThreadPoolExecutor(max_workers=workers) as executor: futures = {} for i, config in enumerate(strategy_configs): future = executor.submit(self._generate_and_evaluate_single, i, config) futures[future] = config for future in as_completed(futures): strategies_generated += 1 try: result = future.result() results.append(result) if result["status"] == "accepted": strategies_accepted += 1 self._save_strategy(result) logger.info( f"Strategy ACCEPTED: {result['strategy_name']} | " f"Sharpe={result['sharpe_ratio']:.2f} | " f"DD={result['max_drawdown']:.2%}" ) else: # Also save rejected strategies for debugging self._save_strategy(result) logger.warning( f"Strategy REJECTED: {result['strategy_name']} - {result.get('reason', 'unknown')} | " f"Sharpe={result.get('sharpe_ratio', 'N/A')} | " f"DD={result.get('max_drawdown', 'N/A')}" ) if progress_callback: progress_callback(strategies_generated, len(strategy_configs), result) except Exception as e: logger.error(f"Strategy generation failed: {e}") results.append({ "strategy_name": f"Failed_{strategies_generated}", "status": "rejected", "reason": str(e), }) logger.info( f"Strategy generation complete: {strategies_accepted}/{strategies_generated} accepted " f"({strategies_accepted/max(strategies_generated,1)*100:.1f}%)" ) return results def _generate_strategy_configs(self, factors: List[Dict], count: int) -> List[List[Dict]]: """ Generate strategy configurations from factor combinations. Creates combinations of 2-4 factors, prioritizing high-IC factors and diversity across factor categories. """ from itertools import combinations configs = [] # Generate 2-factor combinations for combo in combinations(factors, 2): if len(configs) >= count * 2: # Generate extras for rejection buffer break configs.append(list(combo)) # Generate 3-factor combinations if needed if len(configs) < count and len(factors) >= 3: for combo in combinations(factors, 3): if len(configs) >= count * 2: break configs.append(list(combo)) # Shuffle to add randomness, then take what we need np.random.shuffle(configs) return configs[: count * 2] # Generate extras def _generate_and_evaluate_single(self, idx: int, factors: List[Dict]) -> Dict[str, Any]: """Generate and evaluate a single strategy.""" strategy_name = self._generate_strategy_name(factors, idx + 1) # Generate code code = self.generate_strategy_code(factors, strategy_name) if not code: return { "strategy_name": strategy_name, "status": "rejected", "reason": "Code generation failed", } # Evaluate result = self.evaluate_strategy(code, strategy_name, factors) result["code"] = code # Optimize with Optuna if enabled # KEY CHANGE: Optimize ALL strategies, not just accepted ones # Optuna can often rescue strategies with bad initial parameters # by finding optimal entry/exit thresholds, signal smoothing, etc. if self.use_optuna: initial_status = result.get("status", "rejected") initial_sharpe = result.get("sharpe_ratio", float('-inf')) logger.info(f"Running Optuna optimization for {strategy_name} (initial: {initial_status}, Sharpe={initial_sharpe:.4f})...") optimizer = OptunaOptimizer(n_trials=self.optuna_trials) # Prepare factor values for optimization factor_values = self._prepare_factor_values(factors) if factor_values is not None: optimized = optimizer.optimize_strategy(result, factor_values) optimized_sharpe = optimized.get("sharpe_ratio", float('-inf')) optimized_status = optimized.get("status", "rejected") best_params = optimized.get("best_params", {}) # Check if Optuna improved the strategy if optimized_sharpe > initial_sharpe: improvement = optimized_sharpe - initial_sharpe logger.info( f"Optuna {'RESCUED' if optimized_status == 'accepted' and initial_status == 'rejected' else 'improved'} " f"{strategy_name}: Sharpe {initial_sharpe:.4f} → {optimized_sharpe:.4f} (+{improvement:.4f})" ) # Re-evaluate with best parameters to get comparable metrics if best_params: patched_code = self._patch_strategy_code(code, best_params) re_eval = self._evaluate_with_patched_code(patched_code, strategy_name, factors) if re_eval.get("sharpe_ratio", float('-inf')) > initial_sharpe: result.update(re_eval) result["code"] = patched_code result["best_params"] = best_params # Clear old rejection reason if now accepted if result.get("status") == "accepted": result.pop("reason", None) logger.info( f"Re-evaluated {strategy_name} with best params: " f"Sharpe {initial_sharpe:.4f} → {re_eval.get('sharpe_ratio', 0):.4f}" ) else: result.update(optimized) result["best_params"] = best_params # Clear old rejection reason if now accepted if result.get("status") == "accepted": result.pop("reason", None) else: result.update(optimized) # Clear old rejection reason if now accepted if result.get("status") == "accepted": result.pop("reason", None) else: logger.debug(f"Optuna did not improve {strategy_name}: {initial_sharpe:.4f} vs {optimized_sharpe:.4f}") else: logger.warning(f"No factor values available for Optuna optimization of {strategy_name}") return result def _prepare_factor_values(self, factors: List[Dict]) -> Optional[pd.DataFrame]: """Prepare factor values DataFrame for Optuna optimization.""" factor_values = {} for f in factors: fname = f.get("factor_name", "") if fname: series = self.load_factor_values(fname) if series is not None: factor_values[fname] = series if factor_values: df = pd.DataFrame(factor_values) # Forward-fill to OHLCV index close = self.load_ohlcv_close() if close is not None: df = df.reindex(close.index).ffill() return df.dropna() return None def _patch_strategy_code(self, code: str, params: Dict[str, Any]) -> str: """Patch strategy code with Optuna's best parameters.""" import re patched = code entry_thresh = params.get("entry_threshold", 0.8) exit_thresh = params.get("exit_threshold", 0.3) zscore_window = params.get("zscore_window", 50) signal_window = params.get("signal_window", 3) param_patterns = [ (r'entry_thresh\s*=\s*[\d.]+', f'entry_thresh = {entry_thresh}'), (r'exit_thresh\s*=\s*[\d.]+', f'exit_thresh = {exit_thresh}'), (r'window\s*=\s*\d+', f'window = {zscore_window}'), (r'signal_window\s*=\s*\d+', f'signal_window = {signal_window}'), ] for pattern, replacement in param_patterns: patched = re.sub(pattern, replacement, patched) # Patch .rolling(N) calls for common window sizes rolling_pattern = r'\.rolling\((\d+)\)' def replace_rolling(match): val = int(match.group(1)) if val in (20, 30, 50, 100, 200): return f'.rolling({zscore_window})' return match.group(0) patched = re.sub(rolling_pattern, replace_rolling, patched) return patched def _evaluate_with_patched_code(self, patched_code: str, strategy_name: str, factors: List[Dict]) -> Dict[str, Any]: """Re-evaluate strategy with patched parameters using full OHLCV backtest.""" try: factor_names = [f["factor_name"] for f in factors if f["factor_name"] != "timestamp"] factor_values = {} for fname in factor_names: series = self.load_factor_values(fname) if series is not None: factor_values[fname] = series if not factor_values: return {"sharpe_ratio": float('-inf'), "status": "rejected"} common_idx = None for name, s in factor_values.items(): if common_idx is None: common_idx = s.index else: common_idx = common_idx.intersection(s.index) if common_idx is None or len(common_idx) < 100: return {"sharpe_ratio": float('-inf'), "status": "rejected"} df_factors = pd.DataFrame({ name: s.reindex(common_idx) for name, s in factor_values.items() }).dropna() for col in df_factors.columns: df_factors[col] = pd.to_numeric(df_factors[col], errors='coerce') close = self.load_ohlcv_close() if close is not None: df_factors = df_factors.reindex(close.index).ffill() df_factors = df_factors.dropna() if len(df_factors) < 1000: return {"sharpe_ratio": float('-inf'), "status": "rejected"} if close is not None: close = close.reindex(df_factors.index) local_vars = {"factors": df_factors} try: exec(patched_code, {"np": np, "pd": pd, "numpy": np}, local_vars) except Exception: return {"sharpe_ratio": float('-inf'), "status": "rejected"} if "signal" not in local_vars: return {"sharpe_ratio": float('-inf'), "status": "rejected"} signal = local_vars["signal"] from rdagent.components.backtesting.vbt_backtest import ( backtest_signal, DEFAULT_TXN_COST_BPS, ) close_for_bt = close.reindex(signal.index).ffill() if close is not None else None if close_for_bt is None: return {"sharpe_ratio": float('-inf'), "status": "rejected"} bt = backtest_signal( close=close_for_bt, signal=signal, txn_cost_bps=float(os.getenv("TXN_COST_BPS", DEFAULT_TXN_COST_BPS)), freq="1min", ) if bt.get("status") != "success": return {"sharpe_ratio": float('-inf'), "status": "rejected"} sharpe = bt["sharpe"] max_dd = bt["max_drawdown"] win_rate = bt["win_rate"] status = ( "accepted" if sharpe >= self.min_sharpe and max_dd >= self.max_drawdown and win_rate >= self.min_win_rate else "rejected" ) return { "strategy_name": strategy_name, "status": status, "sharpe_ratio": round(sharpe, 4), "annualized_return": round(bt["annualized_return"], 6), "max_drawdown": round(max_dd, 6), "win_rate": round(win_rate, 4), "volatility": round(bt["volatility"], 6), "total_return": round(bt["total_return"], 6), "num_periods": bt["n_bars"], "num_real_trades": bt["n_trades"], } except Exception as e: logger.debug(f"Re-evaluation failed for {strategy_name}: {e}") return {"sharpe_ratio": float('-inf'), "status": "rejected"} def _save_strategy(self, result: Dict[str, Any]) -> None: """Save accepted strategy to JSON file.""" timestamp = int(time.time()) safe_name = result["strategy_name"].replace("/", "_").replace(" ", "_")[:60] filename = f"{timestamp}_{safe_name}.json" filepath = self.strategies_dir / filename # Prepare serializable result save_data = {k: v for k, v in result.items() if k != "code"} save_data["code"] = result.get("code", "") with open(filepath, "w", encoding="utf-8") as f: json.dump(save_data, f, indent=2, default=str, ensure_ascii=False) logger.info(f"Saved strategy to {filepath}") def get_strategy_summary(self, results: List[Dict[str, Any]]) -> Dict[str, Any]: """ Generate summary statistics from strategy generation results. Parameters ---------- results : List[Dict[str, Any]] List of strategy results Returns ------- Dict[str, Any] Summary statistics """ if not results: return {"total": 0, "accepted": 0, "rejected": 0} accepted = [r for r in results if r["status"] == "accepted"] rejected = [r for r in results if r["status"] == "rejected"] summary = { "total": len(results), "accepted": len(accepted), "rejected": len(rejected), "acceptance_rate": len(accepted) / len(results) if results else 0, } if accepted: sharpe_values = [r.get("sharpe_ratio", 0) for r in accepted if "sharpe_ratio" in r] dd_values = [r.get("max_drawdown", 0) for r in accepted if "max_drawdown" in r] wr_values = [r.get("win_rate", 0) for r in accepted if "win_rate" in r] summary["best_sharpe"] = max(sharpe_values) if sharpe_values else 0 summary["avg_sharpe"] = np.mean(sharpe_values) if sharpe_values else 0 summary["worst_drawdown"] = min(dd_values) if dd_values else 0 summary["avg_win_rate"] = np.mean(wr_values) if wr_values else 0 return summary