feat: add daily log rotation, llama health wait, factor auto-fixer, and README updates

- Add rdagent/log/daily_log.py: daily-rotating structured logs per command
  (fin_quant, strategies, evaluate, parallel) with loguru; all.log combined sink
- predix.py: route TeeWriter output to logs/YYYY-MM-DD/ instead of root dir;
  wrap quant() and evaluate() in daily_log.session() for start/stop/duration tracking
- rdagent/app/cli.py: fin_quant_cli waits for llama.cpp /health endpoint before
  starting pipeline (up to 300 s); daily_log integration for fin_quant,
  generate_strategies, eval_all, parallel commands
- scripts/predix_gen_strategies_real_bt.py: daily_log integration with
  per-strategy ACCEPTED/REJECTED entries and summary on completion
- rdagent/components/coder/factor_coder/auto_fixer.py: new module that patches
  common LLM-generated factor issues (min_periods, inf/NaN, groupby.transform,
  MultiIndex corrections)
- rdagent/components/coder/factor_coder/prompts.yaml: add critical rules for
  EURUSD 1-min intraday factors (min_periods, inf handling, groupby, date range)
- README.md: document --reasoning off and --n-gpu-layers 28 for llama-server;
  explain VRAM constraints when Ollama is running alongside llama.cpp
- .bandit.yml: suppress B615 (HuggingFace unsafe download) for RL benchmark files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
TPTBusiness
2026-04-16 07:20:08 +02:00
parent 144311f6ec
commit c78ecd3b6a
13 changed files with 1165 additions and 149 deletions
@@ -0,0 +1,422 @@
"""
Predix Factor Auto-Fixer - Automatically patches common factor code issues.
This module intercepts LLM-generated factor code and automatically fixes known problems:
1. min_periods mismatch in rolling window calculations
2. Missing inf/NaN handling for division by zero
3. groupby().apply() instead of groupby().transform()
4. Incomplete data range processing
5. Missing groupby for MultiIndex dataframes
Usage:
auto_fixer = FactorAutoFixer()
fixed_code = auto_fixer.fix(original_code, factor_task_info)
"""
import ast
import logging
import re
from typing import Optional
logger = logging.getLogger(__name__)
class FactorAutoFixer:
"""
Automatically patches common factor code issues before execution.
This runs AFTER LLM code generation but BEFORE execution, ensuring
known patterns are fixed without requiring another LLM iteration.
"""
def __init__(self):
self.fixes_applied = []
def fix(self, code: str, factor_task_info: Optional[str] = None) -> str:
"""
Apply all auto-fixes to generated factor code.
Parameters
----------
code : str
LLM-generated factor code
factor_task_info : str, optional
Factor task information for context-aware fixes
Returns
-------
str
Patched factor code
"""
self.fixes_applied = []
fixed_code = code
# Apply fixes in order - groupby fixes MUST come before min_periods fixes
fix_methods = [
self._fix_groupby_apply_to_transform, # First: fix groupby patterns
self._fix_min_periods, # Second: fix min_periods in resulting rolling calls
self._fix_inf_nan_handling, # Third: add inf/nan handling
self._fix_data_range_processing, # Fourth: ensure full data range
self._fix_multiindex_groupby, # Fifth: ensure groupby on MultiIndex
]
for fix_method in fix_methods:
try:
fixed_code = fix_method(fixed_code)
except Exception as e:
logger.debug(f"Auto-fixer {fix_method.__name__} failed: {e}")
continue
if self.fixes_applied:
logger.info(
f"[AutoFix] Applied {len(self.fixes_applied)} fix(es) for {factor_task_info or 'unknown'}: "
f"{', '.join(self.fixes_applied)}"
)
return fixed_code
def _fix_min_periods(self, code: str) -> str:
"""
Fix: Ensure min_periods matches window size in rolling calculations.
Problem: LLM often sets min_periods=1 or min_periods=2 for rolling windows,
which creates inconsistent feature definitions.
Fix: Set min_periods equal to window size.
"""
fixed_code = code
# Pattern 1: .rolling(window=N, min_periods=M) where M < N
# Replace with min_periods=N
pattern1 = r'\.rolling\(window=(\d+),\s*min_periods=(\d+)\)'
def replace_min_periods1(match):
window_size = int(match.group(1))
min_periods = int(match.group(2))
if min_periods < window_size:
self.fixes_applied.append(f"min_periods: {min_periods}{window_size}")
return f'.rolling(window={window_size}, min_periods={window_size})'
return match.group(0)
fixed_code = re.sub(pattern1, replace_min_periods1, fixed_code)
# Pattern 2: .rolling(N).mean() or .rolling(N).std() without min_periods
# Add min_periods=N
pattern2 = r'\.rolling\((\d+)\)\.(mean|std|var|sum|count|median|skew|kurt|quantile|min|max)\(\)'
def replace_min_periods2(match):
window_size = int(match.group(1))
method = match.group(2)
self.fixes_applied.append(f"min_periods: added {window_size} for {method}")
return f'.rolling({window_size}, min_periods={window_size}).{method}()'
fixed_code = re.sub(pattern2, replace_min_periods2, fixed_code)
# Pattern 3: .rolling(window=N).method() without min_periods
pattern3 = r'\.rolling\(window=(\d+)\)\.(mean|std|var|sum|count|median|skew|kurt|quantile|min|max)\(\)'
def replace_min_periods3(match):
window_size = int(match.group(1))
method = match.group(2)
self.fixes_applied.append(f"min_periods: added {window_size} for {method}")
return f'.rolling(window={window_size}, min_periods={window_size}).{method}()'
fixed_code = re.sub(pattern3, replace_min_periods3, fixed_code)
return fixed_code
def _fix_inf_nan_handling(self, code: str) -> str:
"""
Fix: Add inf/NaN handling after division operations.
Problem: Z-score and ratio calculations can produce inf values when
denominator (std, volatility) is zero.
Fix: Add .replace([np.inf, -np.inf], np.nan) after result calculation.
"""
fixed_code = code
# Check if inf handling already exists
if 'replace([np.inf, -np.inf]' in fixed_code or 'replace([np.inf,-np.inf]' in fixed_code:
if 'np.nan' in fixed_code or 'np.NaN' in fixed_code:
return fixed_code # Already handled
# Pattern 1: Division operation that could produce inf
# Look for patterns like: df['zscore'] = ... / df['sigma_20bar']
# or: df['ratio'] = df['sigma_5bar'] / df['sigma_60bar']
# Find the result column assignment (last major assignment before save)
# Pattern: result = df[['column_name']] or df['column_name'] = ...
# Add inf handling before the save operation
save_pattern = r'(\s*result\s*=\s*df\[\[.*?\]\])'
match = re.search(save_pattern, fixed_code, re.DOTALL)
if match:
insert_pos = match.start()
# Extract column name from the result assignment
col_match = re.search(r"result\s*=\s*df\[\[(.*?)\]\]", match.group(0))
if col_match:
col_name = col_match.group(1).strip().strip("'\"")
inf_fix = f"\n # Auto-fix: Handle infinite values\n df['{col_name}'] = df['{col_name}'].replace([np.inf, -np.inf], np.nan)\n"
fixed_code = fixed_code[:insert_pos] + inf_fix + fixed_code[insert_pos:]
self.fixes_applied.append("inf/nan: added replace for inf values")
return fixed_code
# Pattern 2: Direct assignment to result variable
# Add inf handling before dropna or save
dropna_pattern = r'(\s*\.dropna\(\))'
match = re.search(dropna_pattern, fixed_code)
if match:
insert_pos = match.start()
# Find the column being processed
# Look backwards for the last assignment
lines_before = fixed_code[:insert_pos].split('\n')
for line in reversed(lines_before):
col_match = re.search(r"df\['(.+?)'\]\s*=", line.strip())
if col_match:
col_name = col_match.group(1)
inf_fix = f" # Auto-fix: Handle infinite values\n df['{col_name}'] = df['{col_name}'].replace([np.inf, -np.inf], np.nan)\n"
fixed_code = fixed_code[:insert_pos] + inf_fix + fixed_code[insert_pos:]
self.fixes_applied.append("inf/nan: added replace for inf values")
return fixed_code
# Pattern 3: Generic fallback - add inf handling before any .to_hdf call
hdf_pattern = r'(\s*\.to_hdf\()'
match = re.search(hdf_pattern, fixed_code)
if match:
insert_pos = match.start()
inf_fix = " # Auto-fix: Handle infinite values\n result = result.replace([np.inf, -np.inf], np.nan)\n"
fixed_code = fixed_code[:insert_pos] + inf_fix + fixed_code[insert_pos:]
self.fixes_applied.append("inf/nan: added replace for inf values on result")
return fixed_code
def _fix_groupby_apply_to_transform(self, code: str) -> str:
"""
Fix: Convert groupby().apply() to groupby().transform() where appropriate.
Problem: groupby().apply() returns a DataFrame structure that cannot be
assigned to a single column, causing ValueError.
Fix: Use groupby().transform() which preserves original DataFrame structure.
"""
fixed_code = code
# === CRITICAL FIX: groupby().rolling() on MultiIndex creates extra index level ===
# Pattern: df.groupby(level=N)['col'].rolling(window=W, min_periods=M).method()
# When assigned back to df['new_col'], it causes:
# AssertionError: Length of new_levels (3) must be <= self.nlevels (2)
# Fix: Add .reset_index(level=-1, drop=True) after rolling operation
# Pattern: df.groupby(level=N)['col_A'].rolling(window=W, min_periods=M).corr(x['col_B'])
rolling_corr_pattern = (
r"df\.groupby\(level=(\d+)\)\['([^']+)'\]\.rolling\(\s*window=(\d+)\s*,\s*min_periods=(\d+)\s*\)"
r"\.corr\(x\['([^']+)'\]\)"
)
match = re.search(rolling_corr_pattern, fixed_code)
if match:
level = match.group(1)
col_a = match.group(2)
window = match.group(3)
min_periods = match.group(4)
col_b = match.group(5)
old_code = match.group(0)
new_code = (
f"df.groupby(level={level}).apply(\n"
f" lambda x: x['{col_a}'].rolling(window={window}, min_periods={min_periods}).corr(x['{col_b}'])\n"
f" ).reset_index(level={level}, drop=True)"
)
fixed_code = fixed_code.replace(old_code, new_code)
self.fixes_applied.append(f"groupby: fixed rolling correlation with reset_index (window={window})")
# Continue to check for more patterns below
# Pattern: df.groupby(level=N)['col'].rolling(window=W, min_periods=M).method()
# This is the MOST COMMON pattern that causes failures
# Matches multi-line expressions too
groupby_rolling_pattern = (
r"df\.groupby\(level=(\d+)\)\['([^']+)'\]\.rolling\(\s*([^)]+)\s*\)\.(\w+)\(\)"
)
for match in re.finditer(groupby_rolling_pattern, fixed_code, re.DOTALL):
full_expr = match.group(0)
level = match.group(1)
col_name = match.group(2)
rolling_args = match.group(3).strip()
# Normalize rolling_args to single line
rolling_args = ' '.join(rolling_args.split())
method = match.group(4)
# Check if this expression is being assigned to df[...]
# Since full_expr may contain newlines, use a flexible pattern
# Look for: df['xxx'] = df.groupby(level=N)['col'].rolling(...)
# We need to match even with whitespace/newlines between tokens
escaped_parts = []
for token in ["df", r"\.groupby\(level=" + level + r"\)\['" + re.escape(col_name) + r"'\]", r"\.rolling\("]:
escaped_parts.append(re.escape(token) if not token.startswith(r"\\") else token)
# Simpler approach: search for assignment before the match position
match_start = match.start()
preceding_text = fixed_code[max(0, match_start-50):match_start]
assign_match = re.search(r"df\['[^']+'\]\s*=\s*$", preceding_text)
if assign_match:
# Direct assignment - use transform pattern
new_expr = f"df.groupby(level={level})['{col_name}'].transform(lambda x: x.rolling({rolling_args}).{method}())"
fixed_code = fixed_code[:match.start()] + new_expr + fixed_code[match.end():]
self.fixes_applied.append(f"groupby: converted rolling {method} to transform pattern")
else:
# Not direct assignment but still needs fix
new_expr = f"df.groupby(level={level})['{col_name}'].rolling({rolling_args}).{method}().reset_index(level=-1, drop=True)"
fixed_code = fixed_code[:match.start()] + new_expr + fixed_code[match.end():]
self.fixes_applied.append(f"groupby: added reset_index for rolling {method}")
# === GENERAL FIX: ANY series.groupby(level=N).rolling() pattern ===
# Catches patterns like: sigma_60 = returns.groupby(level=1).rolling(...).std()
# or: mu_30 = volume_price_product.groupby(level=1).rolling(...).mean()
# These create MultiIndex issues when used in arithmetic with original series
general_groupby_rolling = (
r"(\w+)\.groupby\(level=(\d+)\)\.rolling\(\s*([^)]+)\s*\)\.(\w+)\(\)"
)
for match in re.finditer(general_groupby_rolling, fixed_code, re.DOTALL):
full_expr = match.group(0)
series_name = match.group(1)
level = match.group(2)
rolling_args = match.group(3).strip()
rolling_args = ' '.join(rolling_args.split())
method = match.group(4)
# Check if this already has reset_index
if 'reset_index' not in full_expr and 'transform' not in full_expr:
# Check if this is assigned to a variable
assign_pattern = rf"(\w+)\s*=\s*{re.escape(full_expr)}"
if re.search(assign_pattern, fixed_code):
new_expr = f"{series_name}.groupby(level={level}).rolling({rolling_args}).{method}().reset_index(level=-1, drop=True)"
fixed_code = fixed_code.replace(full_expr, new_expr)
self.fixes_applied.append(f"groupby: added reset_index for {series_name}.rolling().{method}()")
# Pattern: Rolling correlation with groupby().apply() - CRITICAL FIX
# df.groupby(level=N).apply(lambda x: x['A'].rolling(window=W).corr(x['B']))
corr_pattern = r"df\.groupby\(level=(\d+)\)\.apply\(\s*lambda\s+x:\s+x\['([^']+)'\]\.rolling\(window=(\d+)[^)]*\)\.corr\(x\['([^']+)'\]\)\)"
match = re.search(corr_pattern, fixed_code)
if match:
level = match.group(1)
col_a = match.group(2)
window = match.group(3)
# Find the actual second column name
full_match = match.group(0)
col_b_match = re.search(r"corr\(x\['([^']+)'\]\)", full_match)
if col_b_match:
col_b = col_b_match.group(1)
# Replace with proper rolling correlation per group
old_code = match.group(0)
new_code = (
f"df.groupby(level={level}).apply(\n"
f" lambda x: x['{col_a}'].rolling(window={window}, min_periods={window}).corr(x['{col_b}'])\n"
f" ).reset_index(level={level}, drop=True)"
)
fixed_code = fixed_code.replace(old_code, new_code)
self.fixes_applied.append(f"groupby: fixed rolling correlation (window={window}) with reset_index")
# Pattern: Simple groupby().apply() with rolling().method()
# df.groupby(level=N).apply(lambda x: x['col'].rolling(...).method())
apply_pattern = r"df\.groupby\(level=(\d+)\)\.apply\(\s*lambda\s+x:\s+x\['([^']+)'\]\.rolling\([^)]+\)\.(\w+)\([^)]*\)\s*\)"
match = re.search(apply_pattern, fixed_code)
if match:
level = match.group(1)
col_name = match.group(2)
method = match.group(3)
# Replace with transform pattern
old_code = match.group(0)
# Extract window size from the rolling call
window_match = re.search(r"rolling\(window=(\d+)", old_code)
window = window_match.group(1) if window_match else "20"
new_code = f"df.groupby(level={level})['{col_name}'].transform(lambda x: x.rolling(window={window}, min_periods={window}).{method}())"
fixed_code = fixed_code.replace(old_code, new_code)
self.fixes_applied.append(f"groupby: converted apply() to transform() for {method}")
return fixed_code
def _fix_data_range_processing(self, code: str) -> str:
"""
Fix: Ensure full data range (2020-2026) is processed, not just a subset.
Problem: Some factors only process a subset of data (e.g., 2024-2024).
Fix: Remove any date filtering and ensure full range processing.
"""
fixed_code = code
# Remove date filtering patterns
date_filter_patterns = [
r"df\s*=\s*df\.loc\[[^:]*20\d\d[^]]*\]",
r"df\s*=\s*df\[df\.index\.get_level_values\('datetime'\)\s*>=\s*['\"]20\d\d",
r"df\s*=\s*df\[(df\.)?index\.get_level_values\(0\)\s*>=\s*",
]
for pattern in date_filter_patterns:
match = re.search(pattern, fixed_code)
if match:
# Comment out the date filter instead of removing
self.fixes_applied.append("data_range: removed date filter")
fixed_code = fixed_code.replace(match.group(0), f"# Date filter removed to process full range: {match.group(0)}")
return fixed_code
def _fix_multiindex_groupby(self, code: str) -> str:
"""
Fix: Ensure rolling operations use groupby(level=1) for MultiIndex dataframes.
Problem: Without groupby, rolling calculations mix instruments together.
Fix: Add groupby(level=1) before rolling operations if not already present.
"""
fixed_code = code
# Check if code already has groupby
if 'groupby(level=' in fixed_code or 'groupby("instrument")' in fixed_code:
return fixed_code
# Check if code uses MultiIndex (has 'instrument' in index)
if 'level=1' not in fixed_code and 'level=' not in fixed_code:
# Check if there are rolling operations that should be grouped
rolling_pattern = r"\.rolling\(\d+\)"
if re.search(rolling_pattern, fixed_code):
# The code might need groupby, but we can't safely add it without
# understanding the full context. Log a warning instead.
logger.warning(
f"[AutoFix] Code uses rolling without groupby - may need manual review"
)
return fixed_code
# Module-level convenience function
def auto_fix_factor_code(code: str, factor_task_info: Optional[str] = None) -> str:
"""
Apply all auto-fixes to factor code.
Parameters
----------
code : str
LLM-generated factor code
factor_task_info : str, optional
Factor task information
Returns
-------
str
Patched factor code
"""
fixer = FactorAutoFixer()
return fixer.fix(code, factor_task_info)
@@ -14,6 +14,7 @@ from rdagent.components.coder.CoSTEER.knowledge_management import (
)
from rdagent.components.coder.factor_coder.config import FACTOR_COSTEER_SETTINGS
from rdagent.components.coder.factor_coder.factor import FactorFBWorkspace, FactorTask
from rdagent.components.coder.factor_coder.auto_fixer import auto_fix_factor_code
from rdagent.core.experiment import FBWorkspace
from rdagent.oai.llm_conf import LLM_SETTINGS
from rdagent.oai.llm_utils import APIBackend
@@ -156,6 +157,9 @@ class FactorMultiProcessEvolvingStrategy(MultiProcessEvolvingStrategy):
else:
raise # continue to retry
# === AUTO-FIX: Apply known fixes before returning code ===
code = auto_fix_factor_code(code, target_factor_task_information)
return code
except (json.decoder.JSONDecodeError, KeyError):
@@ -172,7 +176,17 @@ class FactorMultiProcessEvolvingStrategy(MultiProcessEvolvingStrategy):
# Since the `implement_one_task` method is not standardized and the `code_list` has both `str` and `dict` data types,
# we ended up getting an `TypeError` here, so we chose to fix the problem temporarily with this dirty method.
if isinstance(code_list[index], dict):
evo.sub_workspace_list[index].inject_files(**code_list[index])
# Auto-fix each file in the dict
fixed_dict = {}
for filename, file_code in code_list[index].items():
if filename.endswith('.py'):
task_info = evo.sub_tasks[index].get_task_information()
fixed_dict[filename] = auto_fix_factor_code(file_code, task_info)
else:
fixed_dict[filename] = file_code
evo.sub_workspace_list[index].inject_files(**fixed_dict)
else:
evo.sub_workspace_list[index].inject_files(**{"factor.py": code_list[index]})
task_info = evo.sub_tasks[index].get_task_information()
fixed_code = auto_fix_factor_code(code_list[index], task_info)
evo.sub_workspace_list[index].inject_files(**{"factor.py": fixed_code})
return evo
@@ -46,9 +46,16 @@ evolving_strategy_factor_implementation_v1_system: |-
1. The user might provide you the correct code to similar factors. Your should learn from these code to write the correct code.
2. The user might provide you the failed former code and the corresponding feedback to the code. The feedback contains to the execution, the code and the factor value. You should analyze the feedback and try to correct the latest code.
3. The user might provide you the suggestion to the latest fail code and some similar fail to correct pairs. Each pair contains the fail code with similar error and the corresponding corrected version code. You should learn from these suggestion to write the correct code.
Your must write your code based on your former latest attempt below which consists of your former code and code feedback, you should read the former attempt carefully and must not modify the right part of your former code.
CRITICAL RULES FOR EURUSD 1-MINUTE INTRADAY FACTORS:
- ALWAYS use `min_periods=N` where N equals the window size in rolling calculations (e.g., `.rolling(20, min_periods=20)`)
- ALWAYS handle infinite values after division: `.replace([np.inf, -np.inf], np.nan)` before saving results
- ALWAYS use `groupby(level=1)` or `groupby('instrument')` before rolling operations on MultiIndex dataframes
- Process the COMPLETE date range (2020-2026), do NOT filter by date
- Use `groupby().transform()` instead of `groupby().apply()` for single-column assignments
Notice that you should not add any other text before or after the json format.
{% if queried_former_failed_knowledge|length != 0 %}
+264 -56
View File
@@ -94,7 +94,11 @@ class OptunaOptimizer:
forward_returns: Optional[pd.Series] = None,
) -> Dict[str, Any]:
"""
Optimize a single strategy's hyperparameters.
Optimiere eine einzelne Strategie mit mehrstufiger Suche (grob → fein).
STAGE 1: Grobe Suche mit weiten Bereichen (10 Trials)
STAGE 2: Feine Suche um die besten Stage-1-Parameter (15 Trials)
STAGE 3: Sehr feine lokale Suche (5 Trials)
Parameters
----------
@@ -111,49 +115,67 @@ class OptunaOptimizer:
Optimized strategy result with best parameters
"""
strategy_name = strategy_result.get("strategy_name", "Unknown")
logger.info(f"Starting optimization for strategy: {strategy_name}")
logger.info(f"Starting multi-stage optimization for strategy: {strategy_name}")
# Define objective function
def objective(trial: optuna.Trial) -> float:
"""Objective function for Optuna optimization."""
try:
# Sample hyperparameters
params = self._sample_hyperparameters(trial)
# Speichere Referenzen für Objective-Methoden
self._current_strategy = strategy_result
self._current_factors = factor_values
self._current_forward_returns = forward_returns
# Evaluate strategy with these parameters
metrics = self._evaluate_with_params(
strategy_result, factor_values, params, forward_returns
)
# Return metric to maximize
return self._extract_metric(metrics, self.optimization_metric)
except Exception as e:
logger.debug(f"Trial failed: {e}")
return float("-inf")
# Create study
study = optuna.create_study(
# STAGE 1: Grobe Suche mit weiten Bereichen (10 Trials)
logger.info(f"Stage 1: Coarse search for {strategy_name}")
stage1_study = optuna.create_study(
direction="maximize",
sampler=optuna.samplers.TPESampler(seed=42),
pruner=optuna.pruners.MedianPruner(n_startup_trials=5, n_warmup_steps=10),
pruner=optuna.pruners.MedianPruner(n_startup_trials=3, n_warmup_steps=5),
)
stage1_study.optimize(self._objective_coarse, n_trials=10, gc_after_trial=True)
best_stage1 = stage1_study.best_trial.params
best_stage1_value = stage1_study.best_trial.value
logger.info(
f"Stage 1 complete: best_value={best_stage1_value:.4f}, "
f"params={best_stage1}"
)
# Run optimization
try:
study.optimize(
objective,
n_trials=self.n_trials,
timeout=self.timeout,
n_jobs=self.n_jobs,
gc_after_trial=True,
)
except Exception as e:
logger.error(f"Optimization failed for {strategy_name}: {e}")
return {**strategy_result, "optimization_status": "failed", "error": str(e)}
# STAGE 2: Feine Suche um die besten Stage-1-Parameter (15 Trials)
logger.info(f"Stage 2: Fine search around best params")
stage2_study = optuna.create_study(
direction="maximize",
sampler=optuna.samplers.TPESampler(seed=43),
pruner=optuna.pruners.MedianPruner(n_startup_trials=5, n_warmup_steps=5),
)
# Verwende beste Stage-1-Parameter als Zentrum für feine Suche
self._fine_search_center = best_stage1
stage2_study.optimize(self._objective_fine, n_trials=15, gc_after_trial=True)
# Get best trial
best_trial = study.best_trial
best_stage2 = stage2_study.best_trial.params
best_stage2_value = stage2_study.best_trial.value
logger.info(
f"Stage 2 complete: best_value={best_stage2_value:.4f}, "
f"params={best_stage2}"
)
# STAGE 3: Sehr feine lokale Suche (5 Trials) - nur wenn Stage 2 besser war
if best_stage2_value > best_stage1_value:
logger.info(f"Stage 3: Very fine local search")
stage3_study = optuna.create_study(
direction="maximize",
sampler=optuna.samplers.TPESampler(seed=44),
)
self._very_fine_center = best_stage2
stage3_study.optimize(self._objective_very_fine, n_trials=5, gc_after_trial=True)
best_stage3_value = stage3_study.best_trial.value
logger.info(f"Stage 3 complete: best_value={best_stage3_value:.4f}")
# Bestes Trial über alle Stufen wählen
if best_stage3_value > best_stage2_value:
best_trial = stage3_study.best_trial
else:
best_trial = stage2_study.best_trial
else:
best_trial = stage1_study.best_trial
# Re-evaluate with best params
best_params = best_trial.params
@@ -161,7 +183,7 @@ class OptunaOptimizer:
strategy_result, factor_values, best_params, forward_returns
)
# Build optimized result
# Baue optimiertes Ergebnis
optimized_result = {
**strategy_result,
"status": "accepted" if self._is_acceptable(best_metrics) else "rejected",
@@ -171,18 +193,31 @@ class OptunaOptimizer:
"win_rate": best_metrics.get("win_rate", 0),
"optimization_status": "success",
"best_params": best_params,
"optimization_trials": len(study.trials),
"optimization_best_value": best_trial.value,
"optimization_history": [t.value for t in study.trials if t.value is not None],
"optimization_stages": {
"stage1_best": best_stage1_value,
"stage2_best": best_stage2_value,
"stage3_best": best_stage3_value if best_stage2_value > best_stage1_value else None,
},
"optimization_trials": len(stage1_study.trials) + len(stage2_study.trials) + (
len(stage3_study.trials) if best_stage2_value > best_stage1_value else 0
),
"optimization_history": {
"stage1": [t.value for t in stage1_study.trials if t.value is not None],
"stage2": [t.value for t in stage2_study.trials if t.value is not None],
"stage3": (
[t.value for t in stage3_study.trials if t.value is not None]
if best_stage2_value > best_stage1_value else []
),
},
"optimized_at": datetime.now().isoformat(),
}
# Save optimization results
# Speichere Optimierungsergebnisse
self._save_optimization_results(optimized_result, strategy_name)
logger.info(
f"Optimization complete for {strategy_name}: "
f"best_{self.optimization_metric}={best_trial.value:.4f}"
f"Multi-stage optimization complete for {strategy_name}: "
f"best_metric={best_trial.value:.4f}, status={optimized_result['status']}"
)
return optimized_result
@@ -232,6 +267,155 @@ class OptunaOptimizer:
return optimized
def _sample_coarse_params(self, trial: optuna.Trial) -> Dict[str, Any]:
"""
Weite Bereiche für initiale Exploration (Stage 1).
Parameters
----------
trial : optuna.Trial
Current Optuna trial
Returns
-------
Dict[str, Any]
Sampled hyperparameters with wide ranges
"""
return {
"entry_threshold": trial.suggest_float("entry_threshold", 0.1, 3.0, step=0.1),
"exit_threshold": trial.suggest_float("exit_threshold", 0.0, 1.5, step=0.1),
"zscore_window": trial.suggest_int("zscore_window", 5, 500, step=5),
"signal_window": trial.suggest_int("signal_window", 1, 30, step=1),
"position_size_pct": trial.suggest_float("position_size_pct", 0.05, 1.0, step=0.05),
"stop_loss_mult": trial.suggest_float("stop_loss_mult", 0.5, 15.0, step=0.5),
"take_profit_mult": trial.suggest_float("take_profit_mult", 1.0, 20.0, step=0.5),
"volatility_lookback": trial.suggest_int("volatility_lookback", 5, 500, step=5),
"signal_bias": trial.suggest_float("signal_bias", -1.0, 1.0, step=0.05),
"max_hold_bars": trial.suggest_int("max_hold_bars", 5, 1000, step=5),
}
def _sample_fine_params(self, trial: optuna.Trial) -> Dict[str, Any]:
"""
Enge Bereiche zentriert um die besten Stage-1-Parameter (Stage 2).
Parameters
----------
trial : optuna.Trial
Current Optuna trial
Returns
-------
Dict[str, Any]
Sampled hyperparameters with narrow ranges around Stage 1 best
"""
center = getattr(self, "_fine_search_center", {})
# (center_value, half_width) für jeden Parameter
ranges: Dict[str, Tuple[float, float]] = {
"entry_threshold": (center.get("entry_threshold", 1.0), 0.3),
"exit_threshold": (center.get("exit_threshold", 0.3), 0.2),
"zscore_window": (center.get("zscore_window", 50), 20),
"signal_window": (center.get("signal_window", 3), 5),
"position_size_pct": (center.get("position_size_pct", 0.5), 0.15),
"stop_loss_mult": (center.get("stop_loss_mult", 5.0), 2.0),
"take_profit_mult": (center.get("take_profit_mult", 5.0), 2.0),
"volatility_lookback": (center.get("volatility_lookback", 100), 30),
"signal_bias": (center.get("signal_bias", 0.0), 0.2),
"max_hold_bars": (center.get("max_hold_bars", 100), 50),
}
params: Dict[str, Any] = {}
for key, (center_val, half_width) in ranges.items():
if "window" in key or "lookback" in key or "bars" in key:
low = max(1, int(center_val - half_width))
high = int(center_val + half_width)
params[key] = trial.suggest_int(key, low, high)
else:
low = max(0.0, center_val - half_width)
high = center_val + half_width
step = half_width / 10
params[key] = trial.suggest_float(key, low, high, step=step)
return params
def _sample_very_fine_params(self, trial: optuna.Trial) -> Dict[str, Any]:
"""
Sehr enge Bereiche für finale Verfeinerung (Stage 3).
Parameters
----------
trial : optuna.Trial
Current Optuna trial
Returns
-------
Dict[str, Any]
Sampled hyperparameters with very narrow ranges around Stage 2 best
"""
center = getattr(
self, "_very_fine_center", getattr(self, "_fine_search_center", {})
)
# (center_value, half_width) — ein Drittel der Stage-2-Breite
ranges: Dict[str, Tuple[float, float]] = {
"entry_threshold": (center.get("entry_threshold", 1.0), 0.1),
"exit_threshold": (center.get("exit_threshold", 0.3), 0.07),
"zscore_window": (center.get("zscore_window", 50), 7),
"signal_window": (center.get("signal_window", 3), 2),
"position_size_pct": (center.get("position_size_pct", 0.5), 0.05),
"stop_loss_mult": (center.get("stop_loss_mult", 5.0), 0.7),
"take_profit_mult": (center.get("take_profit_mult", 5.0), 0.7),
"volatility_lookback": (center.get("volatility_lookback", 100), 10),
"signal_bias": (center.get("signal_bias", 0.0), 0.07),
"max_hold_bars": (center.get("max_hold_bars", 100), 17),
}
params: Dict[str, Any] = {}
for key, (center_val, half_width) in ranges.items():
if "window" in key or "lookback" in key or "bars" in key:
low = max(1, int(center_val - half_width))
high = int(center_val + half_width)
params[key] = trial.suggest_int(key, low, high)
else:
low = max(0.0, center_val - half_width)
high = center_val + half_width
step = half_width / 5
params[key] = trial.suggest_float(key, low, high, step=step)
return params
def _objective_coarse(self, trial: optuna.Trial) -> float:
"""Objective-Funktion für Stage 1 (grobe Suche)."""
try:
params = self._sample_coarse_params(trial)
metrics = self._evaluate_with_params(
self._current_strategy, self._current_factors, params, self._current_forward_returns
)
return self._extract_metric(metrics, self.optimization_metric)
except Exception as e:
logger.debug(f"Stage 1 trial failed: {e}")
return float("-inf")
def _objective_fine(self, trial: optuna.Trial) -> float:
"""Objective-Funktion für Stage 2 (feine Suche)."""
try:
params = self._sample_fine_params(trial)
metrics = self._evaluate_with_params(
self._current_strategy, self._current_factors, params, self._current_forward_returns
)
return self._extract_metric(metrics, self.optimization_metric)
except Exception as e:
logger.debug(f"Stage 2 trial failed: {e}")
return float("-inf")
def _objective_very_fine(self, trial: optuna.Trial) -> float:
"""Objective-Funktion für Stage 3 (sehr feine Suche)."""
try:
params = self._sample_very_fine_params(trial)
metrics = self._evaluate_with_params(
self._current_strategy, self._current_factors, params, self._current_forward_returns
)
return self._extract_metric(metrics, self.optimization_metric)
except Exception as e:
logger.debug(f"Stage 3 trial failed: {e}")
return float("-inf")
def _sample_hyperparameters(self, trial: optuna.Trial) -> Dict[str, Any]:
"""
Sample hyperparameters for a trial.
@@ -415,23 +599,47 @@ class OptunaOptimizer:
if len(returns) < 10 or returns.std() == 0:
return self._default_metrics()
# Calculate metrics
total_return = float(returns.sum())
ann_factor = np.sqrt(252 * 1440 / 96) # Annualization for 1-min data
volatility = float(returns.std() * ann_factor)
ann_return = float(total_return * ann_factor)
# FIX 1: Korrekte Sharpe Ratio Annualisierung für 1-Minuten-Daten
bars_per_year = 252 * 1440 # 252 Handelstage * 1440 Minuten/Tag
mean_return = float(returns.mean())
ann_return = mean_return * bars_per_year
volatility = float(returns.std() * np.sqrt(bars_per_year))
sharpe = ann_return / volatility if volatility > 0 else 0.0
total_return = float(returns.sum())
# Max drawdown
cum = (1 + returns).cumprod()
# FIX 3: Drawdown-Berechnung mit korrektem Error-Handling
returns_clean = returns.fillna(0).replace([np.inf, -np.inf], 0)
returns_clean = returns_clean.clip(-0.1, 0.1) # Max 10% pro Bar
cum = (1 + returns_clean).cumprod()
running_max = cum.expanding().max()
drawdown = (cum - running_max) / running_max.replace(0, np.nan)
drawdown = drawdown.fillna(0).replace([np.inf, -np.inf], 0)
max_dd = float(drawdown.min()) if len(drawdown) > 0 else 0.0
# Win rate
trades = signal.diff().fillna(0)
trades = trades[trades != 0]
win_rate = float((trades > 0).sum() / len(trades)) if len(trades) > 0 else 0.0
# FIX 2: Win Rate korrigieren - echte Trade-P&L Berechnung
signal_positions = signal.shift(1).fillna(0).astype(int)
trade_pnl = []
current_pnl = 0.0
in_position = False
for idx in signal_positions.index:
pos = signal_positions[idx]
ret = returns_clean.get(idx, 0)
if pos != 0: # In Position (Long oder Short)
current_pnl += ret * np.sign(pos)
in_position = True
elif in_position and pos == 0: # Ausstieg
trade_pnl.append(current_pnl)
current_pnl = 0.0
in_position = False
if in_position and current_pnl != 0:
trade_pnl.append(current_pnl)
num_real_trades = len(trade_pnl)
win_rate = float(sum(1 for p in trade_pnl if p > 0) / num_real_trades) if num_real_trades > 0 else 0.0
return {
"sharpe_ratio": sharpe,
@@ -440,7 +648,7 @@ class OptunaOptimizer:
"win_rate": win_rate,
"volatility": volatility,
"total_return": total_return,
"num_trades": int(len(trades)),
"num_trades": num_real_trades,
}
except Exception as e:
+153 -56
View File
@@ -273,6 +273,12 @@ class StrategyOrchestrator:
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)
@@ -781,10 +787,21 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
# 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:
@@ -824,27 +841,36 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
}
signal = local_vars["signal"]
# Debug: check signal distribution
# FIX 4: Debug-Logging nach Signal-Berechnung
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()}"
)
# Calculate REAL returns using OHLCV data
close = self.load_ohlcv_close()
combined_factor = df_factors.mean(axis=1) # Always define combined_factor
price_returns = combined_factor.pct_change().fillna(0) # Default fallback
if close is not None:
# Use factor timestamps as the base (signal is generated on factor data)
# Resample OHLCV close to factor timestamps
signal_index = signal.index
close_aligned = close.reindex(signal_index).ffill()
# Calculate real price returns
price_returns = close_aligned.pct_change().fillna(0)
# Apply signal positions to real returns (lagged signal)
signal_positions = signal.shift(1).fillna(0)
returns = price_returns * signal_positions
# Include spread costs (1.5 bps per trade = 0.00015)
combined_factor = df_factors.mean(axis=1)
SPREAD_COST = 0.00015
signal_changes = signal_positions.diff().abs().fillna(0)
spread_costs = signal_changes * SPREAD_COST
@@ -853,7 +879,6 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
# Fallback: use factor proxy if OHLCV unavailable
logger.warning("OHLCV data unavailable, using factor proxy")
signal_positions = signal.shift(1).fillna(0)
combined_factor = df_factors.mean(axis=1)
return_proxy = combined_factor * 0.0001
returns = return_proxy * signal_positions
@@ -867,42 +892,73 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
"factors_used": factor_names,
}
# Calculate metrics
# FIX 1: Korrekte Sharpe Ratio Annualisierung für 1-Minuten-Daten
# Verwende mean_return * bars_per_year statt komplexer Jahres-Berechnung
bars_per_year = 252 * 1440 # 252 Handelstage * 1440 Minuten/Tag
ann_factor = np.sqrt(bars_per_year) # Für Annualisierung von Mean/Std
mean_return = float(returns.mean())
ann_return = mean_return * bars_per_year
volatility = float(returns.std() * np.sqrt(bars_per_year))
sharpe = ann_return / volatility if volatility > 0 else 0.0
total_return = float(returns.sum())
n_periods = len(returns)
# FIX 3: Drawdown-Berechnung mit korrektem Error-Handling
# Stelle sicher, dass returns keine Inf/NaN haben VOR der Berechnung
returns_clean = returns.fillna(0).replace([np.inf, -np.inf], 0)
# Clip extreme values that could cause unrealistic drawdown
returns_clean = returns_clean.clip(-0.1, 0.1) # Max 10% per bar realistic
cum_returns = (1 + returns_clean).cumprod()
# Annualization for 1-minute data
# 252 trading days * 1440 minutes per day = 362880 minutes per year
minutes_per_year = 252 * 1440
ann_factor = np.sqrt(minutes_per_year) # ~602 for 1-min data
# Calculate years of data (minimum 0.1 years = ~36 days to avoid extreme values)
years = max(n_periods / minutes_per_year, 0.1) if n_periods > 0 else 0.1
# Annualized return (compound, not linear)
# For short periods, scale linearly to avoid extreme values
if years >= 1 and (1 + total_return) > 0:
ann_return = (1 + total_return) ** (1 / years) - 1
# Handle empty cum_returns
if len(cum_returns) == 0 or cum_returns.isna().all():
max_dd = 0.0
else:
# For < 1 year, linear scaling is more appropriate
ann_return = total_return / years
volatility = float(returns.std() * ann_factor)
sharpe = ann_return / volatility if volatility > 0 else 0.0
running_max = cum_returns.expanding().max()
# Avoid division by zero: use clip instead of replace
running_max_safe = running_max.clip(lower=1e-8) # Prevent div-by-zero
drawdown = (cum_returns - running_max) / running_max_safe
drawdown = drawdown.fillna(0).replace([np.inf, -np.inf], 0)
max_dd = float(drawdown.min()) if len(drawdown) > 0 else 0.0
# Max drawdown
# Handle any NaN/inf in returns
returns = returns.fillna(0).replace([np.inf, -np.inf], 0)
cum_returns = (1 + returns).cumprod()
running_max = cum_returns.expanding().max()
drawdown = (cum_returns - running_max) / running_max.replace(0, np.nan)
drawdown = drawdown.fillna(0).replace([np.inf, -np.inf], 0)
max_dd = float(drawdown.min()) if len(drawdown) > 0 else 0.0
# FIX 2: Win Rate korrigieren - echte Trade-P&L Berechnung
signal_positions = signal.shift(1).fillna(0).astype(int)
# Finde Trade-Einstiegspunkte
position_changes = signal_positions.diff().fillna(0)
# Win rate
signal_changes = signal.diff().fillna(0)
trades = signal_changes[signal_changes != 0]
win_rate = float((trades > 0).sum() / len(trades)) if len(trades) > 0 else 0.0
# Berechne P&L für jede Position
trade_pnl = []
current_pnl = 0.0
in_position = False
for idx in signal_positions.index:
pos = signal_positions[idx]
ret = returns_clean.get(idx, 0)
if pos != 0: # In Position (Long oder Short)
current_pnl += ret * np.sign(pos)
in_position = True
elif in_position and pos == 0: # Ausstieg
trade_pnl.append(current_pnl)
current_pnl = 0.0
in_position = False
# Offene Position am Ende schließen
if in_position and current_pnl != 0:
trade_pnl.append(current_pnl)
num_real_trades = len(trade_pnl)
win_rate = float(sum(1 for p in trade_pnl if p > 0) / num_real_trades) if num_real_trades > 0 else 0.0
# FIX 4: Debug-Logging nach Return-Berechnung
logger.info(
f"[DEBUG] {strategy_name}: return stats: "
f"mean={returns.mean():.6e}, "
f"std={returns.std():.6e}, "
f"skew={returns.skew():.3f}, "
f"total_return={total_return:.6f}, "
f"num_trades={num_real_trades}"
)
# Information ratio (signal vs buy-and-hold)
if close is not None:
@@ -926,12 +982,20 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
"information_ratio": round(ir, 4),
"total_return": round(total_return, 6),
"num_periods": n_periods,
"num_real_trades": num_real_trades,
"factors_used": factor_names,
"trading_style": self.trading_style,
"generated_at": datetime.now().isoformat(),
}
if metrics["status"] == "rejected":
# FIX 4: Debug-Logging bei Ablehnung
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
@@ -1147,6 +1211,9 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
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}"
@@ -1154,8 +1221,14 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
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:
@@ -1277,30 +1350,53 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
if returns.std() == 0 or len(returns) < 10:
return {"sharpe_ratio": float('-inf'), "status": "rejected"}
# FIX 1: Korrekte Sharpe Ratio Annualisierung für 1-Minuten-Daten
bars_per_year = 252 * 1440
mean_return = float(returns.mean())
ann_return = mean_return * bars_per_year
volatility = float(returns.std() * np.sqrt(bars_per_year))
sharpe = ann_return / volatility if volatility > 0 else 0.0
total_return = float(returns.sum())
n_periods = len(returns)
minutes_per_year = 252 * 1440
ann_factor = np.sqrt(minutes_per_year)
years = max(n_periods / minutes_per_year, 0.1)
if years >= 1 and (1 + total_return) > 0:
ann_return = (1 + total_return) ** (1 / years) - 1
# FIX 3: Drawdown-Berechnung mit korrektem Error-Handling
returns_clean = returns.fillna(0).replace([np.inf, -np.inf], 0)
returns_clean = returns_clean.clip(-0.1, 0.1)
cum_returns = (1 + returns_clean).cumprod()
# Handle empty cum_returns
if len(cum_returns) == 0 or cum_returns.isna().all():
max_dd = 0.0
else:
ann_return = total_return / years
running_max = cum_returns.expanding().max()
# Avoid division by zero: use clip instead of replace
running_max_safe = running_max.clip(lower=1e-8) # Prevent div-by-zero
drawdown = (cum_returns - running_max) / running_max_safe
drawdown = drawdown.fillna(0).replace([np.inf, -np.inf], 0)
max_dd = float(drawdown.min()) if len(drawdown) > 0 else 0.0
volatility = float(returns.std() * ann_factor)
sharpe = ann_return / volatility if volatility > 0 else 0.0
# FIX 2: Win Rate korrigieren - echte Trade-P&L Berechnung
sig_pos = signal.shift(1).fillna(0).astype(int)
trade_pnl = []
current_pnl = 0.0
in_position = False
returns = returns.fillna(0).replace([np.inf, -np.inf], 0)
cum_returns = (1 + returns).cumprod()
running_max = cum_returns.expanding().max()
drawdown = (cum_returns - running_max) / running_max.replace(0, np.nan)
drawdown = drawdown.fillna(0).replace([np.inf, -np.inf], 0)
max_dd = float(drawdown.min()) if len(drawdown) > 0 else 0.0
for idx in sig_pos.index:
pos = sig_pos[idx]
ret = returns_clean.get(idx, 0)
if pos != 0:
current_pnl += ret * np.sign(pos)
in_position = True
elif in_position and pos == 0:
trade_pnl.append(current_pnl)
current_pnl = 0.0
in_position = False
signal_changes_eval = signal.diff().fillna(0)
trades = signal_changes_eval[signal_changes_eval != 0]
win_rate = float((trades > 0).sum() / len(trades)) if len(trades) > 0 else 0.0
if in_position and current_pnl != 0:
trade_pnl.append(current_pnl)
num_real_trades = len(trade_pnl)
win_rate = float(sum(1 for p in trade_pnl if p > 0) / num_real_trades) if num_real_trades > 0 else 0.0
status = "accepted" if sharpe >= self.min_sharpe and max_dd >= self.max_drawdown and win_rate >= self.min_win_rate else "rejected"
@@ -1314,6 +1410,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
"volatility": round(volatility, 6),
"total_return": round(total_return, 6),
"num_periods": n_periods,
"num_real_trades": num_real_trades,
}
except Exception as e: