diff --git a/prompts/strategy_generation_v4.yaml b/prompts/strategy_generation_v4.yaml new file mode 100644 index 00000000..3f34fabe --- /dev/null +++ b/prompts/strategy_generation_v4.yaml @@ -0,0 +1,89 @@ +strategy_generation: + system: | + You are a CODE GENERATOR for quantitative trading strategies. You are NOT a chat assistant. + + CRITICAL RULES - READ CAREFULLY: + 1. You are a CODE GENERATOR, NOT a chat assistant. + 2. NEVER greet the user, NEVER ask questions, NEVER say "Hello" or "How can I help". + 3. ONLY output a valid JSON object. NOTHING else. No markdown, no explanation, no text before or after the JSON. + 4. Your entire response MUST be parseable by json.loads() in Python. + 5. The JSON must have exactly these fields: "strategy_name", "factors_used", "description", "code" + 6. The "code" field must contain executable Python code as a SINGLE STRING (use \n for newlines). + 7. DO NOT wrap the code in markdown code blocks (no ```python ... ```). + 8. DO NOT define functions with def - write DIRECT EXECUTABLE CODE that creates a 'signal' variable. + + If you output ANY text other than a valid JSON object, the system will REJECT your response and retry. + Your ONLY job is to output JSON. Nothing else. + + --- + + Task: Generate a trading strategy by combining the provided EUR/USD factors. + + EUR/USD Domain Knowledge: + - London session (08:00-16:00 UTC): highest volume, trending behavior + - NY session (13:00-21:00 UTC): second volume peak, continuation + - Asian session (00:00-08:00 UTC): lower volume, mean-reverting + - London/NY overlap (13:00-16:00 UTC): strongest directional moves + - Spread cost: ~1.5 bps per trade - signals must overcome this + + Factor Usage Rules: + 1. ONLY use the factors provided below - no others! + 2. The code will execute with a DataFrame called 'factors' and a Series called 'close' + 3. You MUST create a pandas Series called 'signal' with values: 1 (long), -1 (short), 0 (neutral) + 4. signal.index MUST match factors.index exactly + 5. signal.name must be 'signal' + + IC-Guided Factor Selection: + - Factors with |IC| > 0.10 are highly predictive - PRIORITIZE these + - Factors with |IC| > 0.05 are moderately predictive - USE these + - Factors with |IC| < 0.05 are weak - AVOID unless complementary + - Combine factors with different signs of IC for diversification + - Weight factors proportionally to their |IC| values + + IMPORTANT: Understanding IC Sign + - Factors with POSITIVE IC (e.g., IC=+0.25): HIGH factor value means price goes UP - go LONG + - Factors with NEGATIVE IC (e.g., IC=-0.20): HIGH factor value means price goes DOWN - go SHORT + - Best strategies COMBINE both types: use positive IC for trend, negative IC for divergence + + Signal Quality Requirements: + - Generate balanced signals (40-60% in each direction) + - Use rolling z-scores: (x - rolling.mean()) / rolling.std() + - Apply thresholds based on signal distribution (e.g., z > 0.5 for long, z < -0.5 for short) + - Combine factors respecting their IC SIGN (multiply negative IC factors by -1) + - Consider regime filters (trend vs mean-reversion) + + user: | + Generate a EUR/USD trading strategy using these factors: + + {{ factors }} + + {{ additional_context }} + + TRADING STYLE: {{ trading_style }} + TARGET SHARPE: > {{ min_sharpe }} + MAX DRAWDOWN: {{ max_drawdown }} + + CRITICAL CODE RULES: + 1. DO NOT define functions - write direct executable code + 2. DO NOT use 'def' - just write code that creates 'signal' + 3. The code runs with 'factors' DataFrame and 'close' Series already in scope + 4. You MUST create a variable called 'signal' as a pandas Series + 5. signal must have values 1 (LONG), -1 (SHORT), or 0 (NEUTRAL) + 6. signal.index must equal factors.index + 7. RESPECT IC SIGN: Negative IC factors should be INVERTED (multiplied by -1) + + --- + + CORRECT OUTPUT FORMAT (EXACTLY THIS - JSON ONLY): + + {"strategy_name": "MomentumDivergence_v1", "factors_used": ["daily_close_return_96", "daily_session_momentum_divergence_1d"], "description": "Combines positive IC momentum with inverted negative IC divergence using rolling z-scores.", "code": "import pandas as pd\nimport numpy as np\n\nmom = factors['daily_close_return_96']\ndiv = factors['daily_session_momentum_divergence_1d']\n\nz_mom = (mom - mom.rolling(20).mean()) / mom.rolling(20).std()\nz_div = -(div - div.rolling(20).mean()) / div.rolling(20).std()\n\ncomposite = 0.56 * z_mom + 0.44 * z_div\nsignal = pd.Series(0, index=factors.index)\nsignal[composite > 0.5] = 1\nsignal[composite < -0.5] = -1\nsignal.name = 'signal'"} + + --- + + WRONG OUTPUT (NEVER DO THIS): + - "Hello! Here is your strategy:" (NO GREETINGS) + - "```python\n...\n```" (NO MARKDOWN BLOCKS) + - "def generate_signal(...)" (NO FUNCTION DEFINITIONS) + - Any text before or after the JSON + + Output ONLY the JSON object. Nothing else. Start with { and end with }. diff --git a/rdagent/app/cli.py b/rdagent/app/cli.py index 295c1689..172777f3 100644 --- a/rdagent/app/cli.py +++ b/rdagent/app/cli.py @@ -567,7 +567,7 @@ def rl_trading_cli( @app.command(name="generate_strategies") def generate_strategies_cli( count: int = typer.Option(10, "--count", "-n", help="Number of strategies to generate"), - workers: int = typer.Option(4, "--workers", "-w", help="Parallel workers"), + workers: int = typer.Option(2, "--workers", "-w", help="Parallel workers (default: 2 to avoid LLM overload)"), style: str = typer.Option("swing", "--style", "-s", help="Trading style: daytrading or swing"), optuna: bool = typer.Option(True, "--optuna/--no-optuna", help="Enable Optuna optimization"), optuna_trials: int = typer.Option(30, "--optuna-trials", help="Number of Optuna trials per strategy"), diff --git a/rdagent/components/coder/strategy_orchestrator.py b/rdagent/components/coder/strategy_orchestrator.py index d678a60b..96339ba9 100644 --- a/rdagent/components/coder/strategy_orchestrator.py +++ b/rdagent/components/coder/strategy_orchestrator.py @@ -30,6 +30,7 @@ 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 @@ -289,7 +290,12 @@ class StrategyOrchestrator: 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) -> Optional[str]: + 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. @@ -299,6 +305,8 @@ class StrategyOrchestrator: 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 ------- @@ -308,7 +316,22 @@ class StrategyOrchestrator: 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 + # 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, @@ -316,213 +339,314 @@ class StrategyOrchestrator: "trading_style": self.trading_style, "min_sharpe": self.min_sharpe, "max_drawdown": self.max_drawdown, - "system_prompt": self.strategy_prompt.get("system", "") if isinstance(self.strategy_prompt, dict) else "", - "user_prompt": self.strategy_prompt.get("user", "").replace("{{ factors }}", str(factor_ics)).replace("{{ additional_context }}", f"Strategy name: {strategy_name}") if isinstance(self.strategy_prompt, dict) else "", + "system_prompt": system_prompt, + "user_prompt": user_prompt, } - # Try LLM first + # Try LLM first with retry logic if self.strategy_prompt is not None: - try: - code = self._generate_with_llm(context) - if code: - return code - except Exception as e: - logger.warning(f"LLM strategy generation failed: {e}") + 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]) -> Optional[str]: - """Generate strategy code using LLM.""" - import os - import requests - - # Use local llama.cpp server (running on port 8081) - api_url = "http://localhost:8081/v1" - api_key = "local" - model = "" - - headers = { - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json", - } - - payload = { - - "model": "Qwen3.5-35B-A3B-Q3_K_M.gguf", - "messages": [ - {"role": "system", "content": context.get("system_prompt", "")}, - {"role": "user", "content": context.get("user_prompt", "")}, - ], - "max_tokens": 4096, - "temperature": 0.5, - "include_reasoning": False, - } - - # Build API URL - api_base = api_url.rstrip("/") - if not api_base.endswith("/v1"): - api_base = f"{api_base}/v1" - api_endpoint = f"{api_base}/chat/completions" - - response = requests.post( - api_endpoint, - headers=headers, - json=payload, - timeout=120, - ) - - if response.status_code != 200: - logger.warning(f"LLM API error: {response.text[:200]}") + 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 - - data = response.json() - message = data.get("choices", [{}])[0].get("message", {}) - content = message.get("content", "") or message.get("reasoning_content", "") - - if not content: - # Try fallback: some models put content in different fields - content = data.get("output", "") or data.get("text", "") - if not content: - logger.warning(f"LLM returned empty response. Model: {model}, Full response: {str(data)[:500]}") - return None - - # Debug: log what we got}, first 100 chars: {content[:100]}") - - code = content.strip() - - # Extract code from markdown blocks or reasoning content + + 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 - - # First try to find code between ``` markers - if "```" in code: - match = re.search(r'```python\s*\n(.*?)\n```', code, re.DOTALL) - if match: - code = match.group(1) - else: - match = re.search(r'```\s*\n(.*?)\n```', code, re.DOTALL) - if match: - code = match.group(1) - - # If code has indent from reasoning, dedent it - if code: - # Find code before first ``` if present - match = re.search(r'^(.*?)(?:```)', code, re.DOTALL) - if match: - code = match.group(1).strip() - - # Smart dedent: find most common indent - lines = code.split('\n') - indents = Counter() - for line in lines: - if line.strip(): - indent = len(line) - len(line.lstrip()) - indents[indent] += 1 - - if len(indents) > 1 and indents.get(0, 0) <= 1: - 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 lines (bullets, commentary after code) - final_lines = [] - for line in code.split('\n'): - stripped = line.strip() - if not stripped: - continue - if stripped.startswith('*') or stripped.startswith('\u2022') or stripped.startswith('Wait') or stripped.startswith('Also') or stripped.startswith('One more'): - break - final_lines.append(line) - - code = '\n'.join(final_lines).strip() - - # Remove non-ASCII (emojis etc) - code = code.encode('ascii', 'ignore').decode('ascii').strip() - - - if not code: - logger.warning("LLM returned empty code after cleaning") + + 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 - - # Try to parse as JSON and extract code field - import json - if code.startswith('{'): - try: - data = json.loads(code) - # Extract code from JSON response - if 'code' in data: - code = data['code'] - logger.info(f"Extracted code from JSON response ({len(code)} chars)") - elif 'strategy_code' in data: - code = data['strategy_code'] - logger.info(f"Extracted strategy_code from JSON response ({len(code)} chars)") - except json.JSONDecodeError: - pass # Not valid JSON, treat as raw code - - # Validate it's valid Python}") + + # 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 code + return True except SyntaxError as e: - logger.warning(f"LLM generated invalid Python code: {e}") - logger.warning(f"Code was: {code[:500]}") - return None - - system_prompt = """You are an expert quantitative trading developer. -Generate a complete Python trading strategy that: -1. Takes factor values as input -2. Produces trading signals (1=LONG, -1=SHORT, 0=NEUTRAL) -3. Includes proper risk management -4. Uses the provided factors optimally - -The strategy code will be executed with a 'factors' DataFrame available in scope. -Output ONLY valid Python code, no markdown formatting.""" - - user_prompt = f"""Generate a {context['trading_style']} trading strategy named '{context['strategy_name']}'. - -Factors to use (with IC scores): -{json.dumps(context['factor_ics'], indent=2)} - -Requirements: -- The strategy must output a 'signal' variable (1, -1, or 0) -- Use z-score normalization for factor combination -- Include entry/exit logic based on signal thresholds -- Add risk management: position sizing, stop loss awareness -- Target Sharpe ratio > {context['min_sharpe']} -- Maximum drawdown tolerance: {context['max_drawdown']} - -Output the complete strategy code.""" - - code = api.build_messages_and_create_chat_completion( - user_prompt=user_prompt, - system_prompt=system_prompt, - json_mode=False, - ).strip() - - # Remove markdown code blocks if present - code = code.replace("```python\n", "").replace("```", "").strip() - - # Validate it's valid Python - try: - compile(code, "", "exec") - return code - except SyntaxError: - logger.warning("LLM generated invalid Python code") - return None + logger.debug(f"Python syntax error: {e}") + return False def _generate_fallback_code(self, context: Dict[str, Any]) -> str: """Generate fallback strategy code programmatically.""" @@ -850,7 +974,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int) def generate_strategies( self, count: int = 10, - workers: int = 4, + workers: int = 2, # Reduced from 4 to 2 to avoid LLM server overload progress_callback=None, ) -> List[Dict[str, Any]]: """ diff --git a/rdagent/components/prompt_loader.py b/rdagent/components/prompt_loader.py index d24e97c7..e9d4e9f6 100644 --- a/rdagent/components/prompt_loader.py +++ b/rdagent/components/prompt_loader.py @@ -39,8 +39,8 @@ def get_local_prompt_path(name: str) -> Optional[Path]: if not LOCAL_PROMPTS_DIR.exists(): return None - # Try versioned files first (v3, v2, v1, etc.) - for version in ["v3", "v2", "v1"]: + # Try versioned files first (v4, v3, v2, v1, etc.) + for version in ["v4", "v3", "v2", "v1"]: for ext in ["yaml", "yml"]: path = LOCAL_PROMPTS_DIR / f"{name}_{version}.{ext}" if path.exists(): @@ -101,12 +101,15 @@ def load_prompt( if local_path: print(f"✓ Loading prompt '{name}' from local: {local_path}") data = load_yaml_file(local_path) - + if section: return data.get(section, "") - - # If data is dict with 'system' and 'user', return full dict + + # If data is dict, unwrap single-key dicts (e.g., {'strategy_generation': {'system': ...}}) if isinstance(data, dict): + # If only one key and it matches the name, unwrap it + if len(data) == 1 and name in data: + return data[name] return data return str(data)