diff --git a/rdagent/components/coder/factor_coder/prompts.yaml b/rdagent/components/coder/factor_coder/prompts.yaml index 6146b938..6a1acfef 100644 --- a/rdagent/components/coder/factor_coder/prompts.yaml +++ b/rdagent/components/coder/factor_coder/prompts.yaml @@ -53,7 +53,7 @@ evolving_strategy_factor_implementation_v1_system: |- - 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 + - Process the COMPLETE date range available in the HDF5 file (do NOT filter by date — the file may contain 2024 debug data or full 2020-2026 data) - 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. diff --git a/rdagent/scenarios/qlib/developer/factor_runner.py b/rdagent/scenarios/qlib/developer/factor_runner.py index eb63bfc5..99398988 100644 --- a/rdagent/scenarios/qlib/developer/factor_runner.py +++ b/rdagent/scenarios/qlib/developer/factor_runner.py @@ -468,8 +468,19 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]): import numpy as np try: - # Get workspace path - workspace_path = exp.experiment_workspace.workspace_path + # Get workspace path — factor code and result.h5 live in sub_workspace_list[0], + # not in experiment_workspace (which is the Qlib template workspace). + workspace_path = None + if exp.sub_workspace_list: + for ws in exp.sub_workspace_list: + if ws is not None and hasattr(ws, 'workspace_path'): + candidate = ws.workspace_path / "result.h5" + if candidate.exists(): + workspace_path = ws.workspace_path + break + if workspace_path is None: + # Fallback to experiment_workspace + workspace_path = exp.experiment_workspace.workspace_path if workspace_path is None: return None @@ -670,10 +681,12 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]): from pathlib import Path from rdagent.components.backtesting import ResultsDatabase - # Get factor name from hypothesis + # Get factor name: prefer hypothesis, fallback to result Series 'factor_name' key factor_name = "unknown" if hasattr(exp, 'hypothesis') and exp.hypothesis is not None: factor_name = getattr(exp.hypothesis, 'hypothesis', 'unknown') + if factor_name == 'unknown' and isinstance(result, pd.Series) and 'factor_name' in result.index: + factor_name = str(result['factor_name']) # Check if already rejected by protection if getattr(exp, 'rejected_by_protection', False): @@ -907,41 +920,74 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]): """ Save factor time-series values as parquet for strategy building. - This is essential for walk-forward validation and strategy combination. - - Parameters - ---------- - factor_name : str - Name of the factor - exp : QlibFactorExperiment - The experiment with factor values + Reruns the factor code on the FULL 6-year dataset so the parquet covers + the complete backtest range (not just the debug 2024 subset). """ import os as _os + import subprocess + import shutil + import tempfile try: - # Get workspace path - workspace_path = exp.experiment_workspace.workspace_path + # factor.py lives in sub_workspace_list[0], not experiment_workspace + workspace_path = None + if exp.sub_workspace_list: + for ws in exp.sub_workspace_list: + if ws is not None and hasattr(ws, 'workspace_path'): + fp = ws.workspace_path / "factor.py" + if fp.exists(): + workspace_path = ws.workspace_path + break + if workspace_path is None: + workspace_path = exp.experiment_workspace.workspace_path if workspace_path is None: return - result_h5 = workspace_path / "result.h5" - if not result_h5.exists(): + factor_py = workspace_path / "factor.py" + if not factor_py.exists(): return - # Read factor values + project_root = Path(__file__).parent.parent.parent.parent.parent + full_data = ( + project_root + / "git_ignore_folder" + / "factor_implementation_source_data" + / "intraday_pv.h5" + ) + if not full_data.exists(): + return + + # Run factor code on full data in a temp workspace import pandas as pd - df = pd.read_hdf(str(result_h5), key="data") + with tempfile.TemporaryDirectory(prefix="predix_fullval_") as tmp_dir: + tmp = Path(tmp_dir) + shutil.copy(str(factor_py), str(tmp / "factor.py")) + shutil.copy(str(full_data), str(tmp / "intraday_pv.h5")) + + ret = subprocess.run( + ["python", "factor.py"], + cwd=str(tmp), + capture_output=True, + timeout=300, + ) + if ret.returncode != 0: + # Fall back to debug-data result if full-data run fails + result_h5 = workspace_path / "result.h5" + if not result_h5.exists(): + return + df = pd.read_hdf(str(result_h5), key="data") + else: + result_h5_full = tmp / "result.h5" + if not result_h5_full.exists(): + return + df = pd.read_hdf(str(result_h5_full), key="data") + if df is None or df.empty: return - # Get the factor series (first column) series = df.iloc[:, 0] series.name = factor_name - # Save to results/factors/values/ - project_root = Path(__file__).parent.parent.parent.parent.parent - - # Parallel run isolation parallel_run_id = _os.getenv("PARALLEL_RUN_ID", "0") if parallel_run_id != "0": values_dir = project_root / "results" / "runs" / f"run{parallel_run_id}" / "factors" / "values" @@ -949,16 +995,11 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]): values_dir = project_root / "results" / "factors" / "values" values_dir.mkdir(parents=True, exist_ok=True) - - # Safe filename safe_name = factor_name.replace("/", "_").replace("\\", "_").replace(" ", "_")[:100] parquet_path = values_dir / f"{safe_name}.parquet" + series.to_frame().to_parquet(str(parquet_path)) - # Save as parquet (with datetime index) - series.to_parquet(str(parquet_path)) - - except Exception as e: - # Don't let factor value saving break the main workflow + except Exception: pass def _log_result_warnings(self, factor_name: str, result, metrics: dict) -> None: diff --git a/rdagent/scenarios/qlib/experiment/factor_data_template/README.md b/rdagent/scenarios/qlib/experiment/factor_data_template/README.md index 68e8b341..def96e3e 100755 --- a/rdagent/scenarios/qlib/experiment/factor_data_template/README.md +++ b/rdagent/scenarios/qlib/experiment/factor_data_template/README.md @@ -23,14 +23,25 @@ $low: low price at 1-minute bar. $volume: volume at 1-minute bar (tick volume for FX). ## Important Notes for 1min Data -- 96 bars = 1 trading day (24 hours for FX) +- 1 bar = 1 minute (confirmed) - 16 bars = 16 minutes -- 4 bars = 4 minutes -- 1 bar = 1 minute +- 60 bars = 1 hour +- ~1440 bars = 1 full trading day (FX trades nearly 24h, Mon 00:00 - Fri 22:00 UTC approx.) +- Typical bars per calendar day: ~1200-1440 (varies by weekday, holidays have fewer) +- Do NOT assume 96 bars/day — the actual count depends on the date - Data range: 2020-01-01 to 2026-03-20 - Instrument: EURUSD - Timezone: UTC +## IMPORTANT: Bars per Day Correction +The dataset has approximately 1440 bars per full trading day (1 bar = 1 minute, ~24h of FX trading). +Some older documentation incorrectly stated "96 bars = 1 day" — this is WRONG. Always use: +- 60 bars = 1 hour +- 480 bars = 8 hours (London session 08:00-16:00 UTC) +- 180 bars = 3 hours (London/NY overlap 13:00-16:00 UTC) +Use datetime hour filtering (e.g., `df[df.index.get_level_values('datetime').hour.between(8, 15)]`) +to select session bars — do NOT use bar-count offsets to define sessions. + ## Session Times (UTC) - Asian: 00:00-08:00 UTC (low volatility) - London: 08:00-16:00 UTC (high volatility) diff --git a/rdagent/scenarios/qlib/experiment/prompts.yaml b/rdagent/scenarios/qlib/experiment/prompts.yaml index 736b1c97..b6da09df 100644 --- a/rdagent/scenarios/qlib/experiment/prompts.yaml +++ b/rdagent/scenarios/qlib/experiment/prompts.yaml @@ -104,7 +104,7 @@ qlib_factor_strategy: |- result_df.columns = ['daily_volume_price_divergence'] ``` - 4. **Process ALL data — do not filter dates**: The source HDF5 contains data from 2020-01-01 to 2026-03-20. Do NOT filter to a single year. If your output has only 314 entries (one year of daily data), the factor will be rejected. Expected output: ~1500+ daily entries for 2020-2026. + 4. **Process ALL data — do not filter dates**: The source HDF5 contains data from 2020-01-01 to 2026-03-20 (development runs may use a 2024-only debug dataset with ~300 entries, which is acceptable). Do NOT filter to a single year in your code. Write your code to process whatever date range is available in the HDF5 file — do not hardcode date filters. Expected output for production data: ~1500+ daily entries for 2020-2026. Expected output for debug data: ~300 daily entries for 2024. Both are valid. 5. **Use `transform()` instead of `apply()` for per-group calculations**: `transform()` preserves the original index while `apply()` may reduce the number of rows unexpectedly: ```python @@ -140,14 +140,15 @@ qlib_factor_strategy: |- 8. **PREFER pure intraday rolling factors**: Factors that use only a trailing window of recent bars (e.g. rolling(30).mean() of returns, RSI(14), Bollinger Band z-score) have NO look-ahead risk and vary every - minute. These are the best candidates for short-horizon (96-bar) prediction. Examples: - - Rolling 15-min / 30-min / 60-min return momentum + minute. These are the best candidates for short-horizon (60-180 bar) prediction. Examples: + - Rolling 15-min / 30-min / 60-min return momentum (15, 30, 60 bars respectively) - Rolling volatility (std of returns over 20-60 bars) - Distance of close from N-bar moving average (z-score) - RSI or similar oscillators computed on 1-min bars - VWAP deviation (requires volume — use $volume column) Always use `.shift(1)` on the lagged window (e.g. `rolling(N).mean().shift(1)`) to avoid using the current bar's own price in its own feature value. + NOTE: 1 bar = 1 minute. The data has ~1440 bars per full trading day. Do NOT use 96 as a day proxy. qlib_factor_output_format: |- Your output should be a pandas dataframe similar to the following example information: diff --git a/rdagent/scenarios/qlib/proposal/quant_proposal.py b/rdagent/scenarios/qlib/proposal/quant_proposal.py index ff9c26c4..b42d926f 100644 --- a/rdagent/scenarios/qlib/proposal/quant_proposal.py +++ b/rdagent/scenarios/qlib/proposal/quant_proposal.py @@ -152,9 +152,41 @@ class QlibQuantHypothesisGen(FactorAndModelHypothesisGen): factor_inserted = True if len(specific_trace.hist) > 0: specific_trace.hist.reverse() - hypothesis_and_feedback = T("scenarios.qlib.prompts:hypothesis_and_feedback").r( - trace=specific_trace, - ) + # Keep only the 2 most recent experiments in full detail; compress older ones + # to brief bullet points to stay within the LLM context window. + FULL_DETAIL_COUNT = 2 + old_hist = specific_trace.hist[:-FULL_DETAIL_COUNT] if len(specific_trace.hist) > FULL_DETAIL_COUNT else [] + recent_hist = specific_trace.hist[-FULL_DETAIL_COUNT:] if len(specific_trace.hist) > FULL_DETAIL_COUNT else specific_trace.hist + + parts = [] + if old_hist: + summary_lines = ["## Earlier experiments (summarized):"] + for exp, fb in old_hist: + factor_names = [] + for task in exp.sub_tasks: + if task is not None and hasattr(task, "factor_name"): + factor_names.append(task.factor_name) + elif task is not None and hasattr(task, "model_type"): + factor_names.append(getattr(task, "model_type", "model")) + names_str = ", ".join(factor_names) if factor_names else "unknown" + ic_str = "" + try: + if exp.result is not None: + ic_val = exp.result.loc["IC"] if "IC" in exp.result.index else "" + ic_str = f" IC={ic_val:.4f}" if ic_val != "" else "" + except Exception: + pass + decision_str = "PASS" if fb.decision else "FAIL" + obs_short = (fb.observations or "")[:120].replace("\n", " ") + summary_lines.append(f"- [{decision_str}]{ic_str} {names_str}: {obs_short}") + parts.append("\n".join(summary_lines)) + + if recent_hist: + recent_trace = Trace(specific_trace.scen) + recent_trace.hist = recent_hist + parts.append(T("scenarios.qlib.prompts:hypothesis_and_feedback").r(trace=recent_trace)) + + hypothesis_and_feedback = "\n\n".join(parts) else: hypothesis_and_feedback = "No previous hypothesis and feedback available."