From 27803e8b8585292209c3d588158fecb11f5a1e01 Mon Sep 17 00:00:00 2001 From: TPTBusiness Date: Sun, 26 Apr 2026 08:48:17 +0200 Subject: [PATCH] fix(auto-fixer): add four new factor code fixes for common runtime errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _fix_reset_index_groupby: replace groupby(level=N) on reset_index'd variables with groupby('instrument') — fixes ValueError: level > 0 only valid with MultiIndex - _fix_groupby_mixed_levels: strip string level names from groupby(level=[int, 'str']) to fix AssertionError: Level 'date' not in index - _fix_groupby_column_on_multiindex: convert groupby(['instrument','date']) on MultiIndex DataFrames to groupby(level=1) — fixes KeyError on column access - _fix_rolling_ddof: remove unsupported ddof kwarg from rolling().std()/var() - fix(proposal): apply history compression to factor_proposal.py (was causing 131k-token prompts from QlibFactorHypothesis2Experiment; pycache had stale .pyc) Co-Authored-By: Claude Sonnet 4.6 --- .../coder/factor_coder/auto_fixer.py | 100 +++++++++++++++++- .../qlib/proposal/factor_proposal.py | 69 +++++++++--- 2 files changed, 149 insertions(+), 20 deletions(-) diff --git a/rdagent/components/coder/factor_coder/auto_fixer.py b/rdagent/components/coder/factor_coder/auto_fixer.py index 8bb605e4..12656a09 100644 --- a/rdagent/components/coder/factor_coder/auto_fixer.py +++ b/rdagent/components/coder/factor_coder/auto_fixer.py @@ -53,11 +53,15 @@ class FactorAutoFixer: # 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 + self._fix_reset_index_groupby, # First: fix groupby(level=N) after reset_index() + self._fix_groupby_mixed_levels, # Second: fix groupby(level=[int, str]) + self._fix_groupby_column_on_multiindex, # Third: fix groupby(['instrument','date']) on MultiIndex + self._fix_rolling_ddof, # Fourth: remove unsupported ddof kwarg + self._fix_groupby_apply_to_transform, # Fifth: fix groupby patterns + self._fix_min_periods, # Sixth: fix min_periods in rolling calls + self._fix_inf_nan_handling, # Seventh: add inf/nan handling + self._fix_data_range_processing, # Eighth: ensure full data range + self._fix_multiindex_groupby, # Ninth: ensure groupby on MultiIndex ] for fix_method in fix_methods: @@ -75,6 +79,92 @@ class FactorAutoFixer: return fixed_code + def _fix_reset_index_groupby(self, code: str) -> str: + """ + Fix: groupby(level=N) on a variable created by .reset_index() fails because + reset_index() converts the MultiIndex into regular columns, leaving a plain + RangeIndex. Replace groupby(level=N) on such variables with + groupby('instrument'). + + Detected pattern: + varname = .reset_index(...) + ... + varname.groupby(level=0|1) + """ + fixed_code = code + + # Find all variables assigned via reset_index() + reset_vars = set(re.findall(r'(\w+)\s*=\s*\w[^=\n]*\.reset_index\(', fixed_code)) + + for var in reset_vars: + # Replace var.groupby(level=N) with var.groupby('instrument') + pattern = rf'{re.escape(var)}\.groupby\(level\s*=\s*\d+\)' + if re.search(pattern, fixed_code): + fixed_code = re.sub(pattern, f"{var}.groupby('instrument')", fixed_code) + self.fixes_applied.append(f"reset_index_groupby: {var}.groupby(level=N) → groupby('instrument')") + + return fixed_code + + def _fix_groupby_mixed_levels(self, code: str) -> str: + """ + Fix: groupby(level=[int, 'str']) raises AssertionError because string level + names don't exist on an unnamed MultiIndex. Keep only integer levels. + + Pattern: .groupby(level=[0, 'date']) → .groupby(level=0) + .groupby(level=[1, 'date']) → .groupby(level=1) + """ + fixed_code = code + + def _keep_int_levels(m): + inner = m.group(1) + ints = re.findall(r'\b(\d+)\b', inner) + if not ints: + return m.group(0) + replacement = f'.groupby(level={ints[0]})' if len(ints) == 1 else f'.groupby(level=[{", ".join(ints)}])' + self.fixes_applied.append(f"mixed_levels: groupby(level=[...,str]) → {replacement}") + return replacement + + fixed_code = re.sub(r'\.groupby\(level=\[([^\]]+)\]\)', _keep_int_levels, fixed_code) + return fixed_code + + def _fix_groupby_column_on_multiindex(self, code: str) -> str: + """ + Fix: groupby(['instrument', 'date']) on a MultiIndex DataFrame fails with + KeyError because 'instrument' and 'date' are index levels, not columns. + + Replace with groupby(level=1) (instrument is level 1). + Also handle groupby(['date', 'instrument']) and single groupby('instrument'). + """ + fixed_code = code + + # groupby(['instrument', 'date']) or groupby(['date', 'instrument']) + # Note: do NOT convert groupby('instrument') → groupby(level=1) here — + # that would undo the reset_index_groupby fix which correctly emits groupby('instrument'). + for pat, repl in [ + (r"\.groupby\(\['instrument',\s*'date'\]\)", ".groupby(level=1)"), + (r"\.groupby\(\['date',\s*'instrument'\]\)", ".groupby(level=1)"), + (r"\.groupby\(\['instrument'\]\)", ".groupby(level=1)"), + ]: + if re.search(pat, fixed_code): + fixed_code = re.sub(pat, repl, fixed_code) + self.fixes_applied.append(f"multiindex_groupby: {pat} → {repl}") + + return fixed_code + + def _fix_rolling_ddof(self, code: str) -> str: + """ + Fix: pandas rolling().std(ddof=N) is not supported — ddof is ignored or + raises TypeError depending on pandas version. Remove the ddof kwarg. + """ + fixed_code = code + pattern = r'(\.rolling\([^)]+\)\.\w+\([^)]*),\s*ddof\s*=\s*\d+([^)]*\))' + if re.search(pattern, fixed_code): + fixed_code = re.sub(pattern, r'\1\2', fixed_code) + self.fixes_applied.append("rolling_ddof: removed unsupported ddof kwarg") + # Also handle ddof as only arg: .std(ddof=1) → .std() + fixed_code = re.sub(r'\.(std|var)\(ddof\s*=\s*\d+\)', r'.\1()', fixed_code) + return fixed_code + def _fix_min_periods(self, code: str) -> str: """ Fix: Ensure min_periods matches window size in rolling calculations. diff --git a/rdagent/scenarios/qlib/proposal/factor_proposal.py b/rdagent/scenarios/qlib/proposal/factor_proposal.py index 5ce9b70d..fa8bda9c 100644 --- a/rdagent/scenarios/qlib/proposal/factor_proposal.py +++ b/rdagent/scenarios/qlib/proposal/factor_proposal.py @@ -1,4 +1,5 @@ import json +import os from typing import List, Tuple from rdagent.components.coder.factor_coder.factor import FactorExperiment, FactorTask @@ -9,6 +10,47 @@ from rdagent.scenarios.qlib.experiment.model_experiment import QlibModelExperime from rdagent.scenarios.qlib.experiment.quant_experiment import QlibQuantScenario from rdagent.utils.agent.tpl import T + +def _build_compressed_history(trace: Trace, max_history: int) -> str: + """Return hypothesis_and_feedback string with only `max_history` entries. + + Older entries beyond the last 2 are compressed to one bullet line each. + """ + if len(trace.hist) == 0: + return "No previous hypothesis and feedback available since it's the first round." + + FULL_DETAIL = 2 + old_hist = trace.hist[:-FULL_DETAIL] if len(trace.hist) > FULL_DETAIL else [] + recent_hist = trace.hist[-FULL_DETAIL:] if len(trace.hist) > FULL_DETAIL else trace.hist + + parts = [] + if old_hist: + lines = ["## Earlier experiments (summarized):"] + for exp, fb in old_hist: + names = [] + for task in exp.sub_tasks: + if task is not None and hasattr(task, "factor_name"): + names.append(task.factor_name) + elif task is not None and hasattr(task, "model_type"): + names.append(getattr(task, "model_type", "model")) + ic_str = "" + try: + if exp.result is not None and "IC" in exp.result.index: + ic_str = f" IC={exp.result.loc['IC']:.4f}" + except Exception: + pass + decision = "PASS" if fb.decision else "FAIL" + obs = (fb.observations or "")[:120].replace("\n", " ") + lines.append(f"- [{decision}]{ic_str} {', '.join(names) or 'unknown'}: {obs}") + parts.append("\n".join(lines)) + + if recent_hist: + rt = Trace(trace.scen) + rt.hist = recent_hist + parts.append(T("scenarios.qlib.prompts:hypothesis_and_feedback").r(trace=rt)) + + return "\n\n".join(parts) + QlibFactorHypothesis = Hypothesis @@ -17,13 +59,10 @@ class QlibFactorHypothesisGen(FactorHypothesisGen): super().__init__(scen) def prepare_context(self, trace: Trace) -> Tuple[dict, bool]: - hypothesis_and_feedback = ( - T("scenarios.qlib.prompts:hypothesis_and_feedback").r( - trace=trace, - ) - if len(trace.hist) > 0 - else "No previous hypothesis and feedback available since it's the first round." - ) + max_h = int(os.environ.get("QLIB_QUANT_MAX_FACTOR_HISTORY", "20")) + limited = Trace(trace.scen) + limited.hist = trace.hist[-max_h:] if len(trace.hist) > max_h else trace.hist + hypothesis_and_feedback = _build_compressed_history(limited, max_h) last_hypothesis_and_feedback = ( T("scenarios.qlib.prompts:last_hypothesis_and_feedback").r( experiment=trace.hist[-1][0], feedback=trace.hist[-1][1] @@ -70,15 +109,15 @@ class QlibFactorHypothesis2Experiment(FactorHypothesis2Experiment): if len(trace.hist) == 0: hypothesis_and_feedback = "No previous hypothesis and feedback available since it's the first round." else: + max_h = int(os.environ.get("QLIB_QUANT_MAX_FACTOR_HISTORY", "20")) + factor_hist = [ + e for e in trace.hist + if not hasattr(e[0].hypothesis, "action") or e[0].hypothesis.action == "factor" + ][-max_h:] specific_trace = Trace(trace.scen) - for i in range(len(trace.hist) - 1, -1, -1): - if not hasattr(trace.hist[i][0].hypothesis, "action") or trace.hist[i][0].hypothesis.action == "factor": - specific_trace.hist.insert(0, trace.hist[i]) - if len(specific_trace.hist) > 0: - specific_trace.hist.reverse() - hypothesis_and_feedback = T("scenarios.qlib.prompts:hypothesis_and_feedback").r( - trace=specific_trace, - ) + specific_trace.hist = factor_hist + if specific_trace.hist: + hypothesis_and_feedback = _build_compressed_history(specific_trace, max_h) else: hypothesis_and_feedback = "No previous hypothesis and feedback available."