mirror of
https://github.com/NicolasBohn/NexQuant.git
synced 2026-07-27 23:47:46 +00:00
fix(auto-fixer): add four new factor code fixes for common runtime errors
- _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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 = <anything>.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.
|
||||
|
||||
@@ -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."
|
||||
|
||||
|
||||
Reference in New Issue
Block a user