mirror of
https://github.com/NicolasBohn/NexQuant.git
synced 2026-07-27 15:37:44 +00:00
fix(loop): compress old experiment history in proposal prompt to reduce context size
- Summarize all but the 2 most recent experiments to compact bullet lines (factor name, PASS/FAIL, IC value, 120-char observation snippet) instead of including full verbatim traces; reduces prompt from ~121k to ~40-60k tokens - Fix _evaluate_factor_directly and _save_factor_values to look for result.h5 and factor.py in sub_workspace_list instead of experiment_workspace - Fix Series.to_parquet() → Series.to_frame().to_parquet() in _save_factor_values - Update factor_data_template README: correct bars-per-day (1440, not 96) - Update prompts to accept 2024-only debug dataset output as valid factor result - Fix factor_coder prompts: allow 2024 debug data in date-range instruction Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."
|
||||
|
||||
|
||||
Reference in New Issue
Block a user