Compare commits

...

56 Commits

Author SHA1 Message Date
github-actions[bot] 7cb6531c2a chore(master): release 1.3.11 (#45)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-01 13:58:55 +02:00
TPTBusiness 44c8af572e fix(ci): lazy import logger in predix.py and cli.py to avoid ImportError in test env
Wrapped  in try/except
ImportError with standard logging fallback. The rdagent.log
module chain fails when predix.py is imported as a module
in the CI test environment (kronos CLI tests).
2026-05-01 13:58:16 +02:00
github-actions[bot] b53749df7d chore(master): release 1.3.10 (#44)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-01 13:50:38 +02:00
TPTBusiness 3a1a3d5f77 fix(security): replace remaining assert statements with proper error handling
Replaced 53 assert statements across 22 files with proper
if/raise patterns (TypeError, ValueError, AssertionError)
to resolve Bandit B101 alerts.
2026-05-01 13:49:58 +02:00
github-actions[bot] 13cbd42ecf chore(master): release 1.3.9 (#43)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-01 13:43:52 +02:00
TPTBusiness 64ed6b0cce fix(security): resolve path-injection, B701, B101, B112 Bandit alerts
- Path injection (B614): centralized safe_resolve_path in core/utils.py,
  refactored 6 UI modules to use it with safe_root validation
- B701: added explicit autoescape=select_autoescape() to Jinja2
  Environment() calls in 3 files
- B101: replaced assert statements with proper if/raise patterns in
  12+ files (partial)
- B112: added logger.warning() to bare except:continue blocks in
  5 files
2026-05-01 13:42:59 +02:00
github-actions[bot] bf36f54159 chore(master): release 1.3.8 (#42)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-30 20:01:29 +02:00
TPTBusiness 79f1d34083 fix(security): resolve path-injection and add nosec for safe temp paths (B108, py/path-injection)
- ds_trace.py: resolve() user-provided save path and use Path.name for filenames
  to prevent directory traversal in the local workspace save UI
- rl/finetune UI data_loaders: nosec B614 where paths are already validated
  against safe_root via realpath() before use
- Temp paths (/tmp/sample, /tmp/full, /tmp/mock/*, /tmp/predix_loop.pid,
  /tmp/autorl_output): nosec B108 — fixed Docker volume mount points or
  single-process admin files, not user-writable attack surface

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 19:26:38 +02:00
TPTBusiness 150a818e07 fix(security): replace shell=True subprocess calls with list args in env.py (B602)
Converted conda commands in _update_bin_path, _sync_conda_cache_with_real_envs,
_prepare_conda_env, and FTCondaEnv.prepare() to list args. Replaced pipe-based
grep with pure Python parsing. LocalEnv.Popen retains shell=True with nosec
since entry is an internal command string set by LocalEnvConf, not user input.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 19:26:29 +02:00
TPTBusiness b6d1caecc9 fix(security): replace eval() with ast.literal_eval in finetune validator (B307)
eval() on trainer stdout output replaced with ast.literal_eval() which only
parses Python literals and cannot execute arbitrary code.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 19:26:23 +02:00
TPTBusiness 73e600bf25 fix(qlib): correct indentation in except blocks in quant_proposal and factor_runner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 13:30:49 +02:00
TPTBusiness 9960633d01 fix(deps): relax aiohttp constraint to >=3.13.4 for litellm compatibility
litellm 1.83.14 pins aiohttp==3.13.4 exactly; requiring >=3.13.5 caused
an unresolvable conflict in CI. aiohttp 3.13.4 still patches all four CVEs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 09:44:09 +02:00
github-actions[bot] 3522a2eca1 chore(master): release 1.3.7 (#41)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-30 09:35:48 +02:00
TPTBusiness a5f091f1ca fix(security): nosec for B608/B701 false positives in UI and template code
B608: Bandit flags any f-string containing "select" as potential SQL
injection. All four cases (app.py, ds_trace.py, llm_st.py, merge.py)
are Streamlit UI labels or log messages — not database queries.

B701: Jinja2 autoescape=False warnings in coder.py and utils.py are
false positives — these render Python code and plain-text templates,
not HTML. Enabling autoescape would corrupt the rendered code.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 09:35:09 +02:00
TPTBusiness 528d470754 fix(security): replace eval() with ast.literal_eval and add request timeouts (B307, B113)
- submit.py: eval(json_str) → ast.literal_eval(json_str) for safe
  Python-literal parsing without arbitrary code execution
- info.py: add timeout=30 to both requests.get() calls to prevent
  indefinite hangs on unresponsive GitHub API

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 09:35:09 +02:00
TPTBusiness 910fbea27e fix(security): replace shell=True subprocess calls with list args (B602)
- factor.py: check_output([python_bin, path]) instead of shell string
- env.py QlibCondaEnv: all four conda commands use list args

Shell=True with a constructed string allows shell injection if
python_bin or path contain shell metacharacters.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 09:35:09 +02:00
github-actions[bot] ab3f5f111d chore(master): release 1.3.6 (#40)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-30 07:27:44 +02:00
TPTBusiness a910d70d40 fix(security): whitelist-validate metric column in get_top_factors (B608)
The metric parameter was passed directly into an f-string SQL query.
Add explicit validation against _ALLOWED_METRICS before use, raising
ValueError on unknown values. Raises ValueError on injection attempt
instead of silently accepting arbitrary column names.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 07:25:14 +02:00
TPTBusiness 31a75eeb07 fix(security): revert broken read_pickle encoding arg in kaggle template (B301)
The previous "fix" introduced pd.read_pickle(encoding="utf-8", "/path")
which is a SyntaxError (positional argument after keyword argument).
pd.read_pickle() has no encoding parameter.

Replace with correct # nosec B301 comment — pickle is safe here because
the files are written by the Kaggle preprocessing pipeline in a sandboxed
container and never sourced from user input.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 07:21:34 +02:00
TPTBusiness 11f5dadd2d fix(security): validate SQL identifiers in _add_column_if_not_exists (B608)
Replace f-string SQL queries with whitelist validation:
- Table name must be in _ALLOWED_TABLES
- Column name must be alphanumeric+underscore
- Column type must be in _ALLOWED_COL_TYPES
- Use pragma_table_info() for existence check instead of SELECT f-string

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 07:21:13 +02:00
TPTBusiness 51a624c31e chore(logging): size-based rotation and cap LLM call content
- Switch log rotation from midnight-only ("00:00") to size-based:
  per-command logs: 50 MB, all.log: 100 MB (with gz compression)
- Shorten retention from 30/60 days to 7 days
- Cap llm_calls.jsonl entries to 500 chars per field to prevent
  GB-scale files from long-running loops

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 07:19:27 +02:00
TPTBusiness 9947ea3928 chore(deps): bump setuptools >=78.1.1 to fix GHSA-8g6x-3r52-4m6c 2026-04-30 07:19:24 +02:00
TPTBusiness bc96d26371 chore(deps): bump aiohttp >=3.13.5 and scipy >=1.15.3
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 07:19:24 +02:00
dependabot[bot] c6e8f3d3a3 chore(deps): Update litellm requirement from >=1.73 to >=1.83.14 (#35)
Updates the requirements on [litellm](https://github.com/BerriAI/litellm) to permit the latest version.
- [Release notes](https://github.com/BerriAI/litellm/releases)
- [Commits](https://github.com/BerriAI/litellm/commits)

---
updated-dependencies:
- dependency-name: litellm
  dependency-version: 1.83.14
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-30 07:19:24 +02:00
dependabot[bot] 35d2b81158 chore(deps): Update lightgbm requirement from >=3.3.0 to >=3.3.5 (#34)
Updates the requirements on [lightgbm](https://github.com/microsoft/LightGBM) to permit the latest version.
- [Release notes](https://github.com/microsoft/LightGBM/releases)
- [Commits](https://github.com/microsoft/LightGBM/compare/v3.3.0...v3.3.5)

---
updated-dependencies:
- dependency-name: lightgbm
  dependency-version: 3.3.5
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-30 07:19:24 +02:00
dependabot[bot] 4fd5117af6 chore(deps): Update stable-baselines3 requirement (#33)
Updates the requirements on [stable-baselines3](https://github.com/DLR-RM/stable-baselines3) to permit the latest version.
- [Release notes](https://github.com/DLR-RM/stable-baselines3/releases)
- [Commits](https://github.com/DLR-RM/stable-baselines3/compare/v2.0.0...v2.8.0)

---
updated-dependencies:
- dependency-name: stable-baselines3
  dependency-version: 2.8.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-30 07:19:24 +02:00
dependabot[bot] 96d6923433 chore(deps): Bump googleapis/release-please-action from 4 to 5 (#32)
Bumps [googleapis/release-please-action](https://github.com/googleapis/release-please-action) from 4 to 5.
- [Release notes](https://github.com/googleapis/release-please-action/releases)
- [Changelog](https://github.com/googleapis/release-please-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/googleapis/release-please-action/compare/v4...v5)

---
updated-dependencies:
- dependency-name: googleapis/release-please-action
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-30 07:19:24 +02:00
TPTBusiness ef12b33aca fix(security): real fix for B404/B603 (sys.executable in factor_runner.py #745) 2026-04-29 22:42:28 +02:00
TPTBusiness a1e9417658 fix(security): real fix for B110 (logging in quant_proposal.py #741) 2026-04-29 21:27:22 +02:00
TPTBusiness a65ab828c4 fix(security): real fix for B110 (logging in quant_proposal.py #741) 2026-04-29 21:24:30 +02:00
TPTBusiness 840e12e6aa fix(security): real fix for B110 (logging in factor_runner.py #744) 2026-04-29 21:23:46 +02:00
TPTBusiness 1d1b7b6984 fix(security): real fix for B110 (logging in factor_proposal.py #746) 2026-04-29 21:23:02 +02:00
github-actions[bot] 4f1660b6aa chore(master): release 1.3.5 (#38)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-27 16:10:29 +02:00
TPTBusiness a52adf5b5a fix(auto-fixer): replace zero \$volume with price-range proxy for FX data
EUR/USD synthetic data has \$volume=0 for all rows, causing any VWAP or
volume-weighted factor to produce all-NaN output. Insert a guard after
pd.read_hdf() that replaces zero volume with (\$high - \$low) range proxy
so volume-dependent factors produce meaningful signals.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 16:07:06 +02:00
TPTBusiness 537f730c93 fix(auto-fixer): strip spurious .reset_index() after .transform() calls
LLM sometimes copies the .reset_index(level=N, drop=True) suffix from
groupby().rolling().method() patterns and adds it after .transform(),
but transform() already preserves the original index. The extra
reset_index() drops an index level and causes ValueError: 'cannot reindex
on an axis with duplicate labels' or shape mismatch on assignment.

Detect: any line containing both .transform( and .reset_index(level=..., drop=True)
Fix: strip the .reset_index() suffix from those lines.

Adds 1 new test (test_transform_reset_index_stripped) — total 30 tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 15:57:04 +02:00
TPTBusiness 9c07b07995 fix(auto-fixer): fix two assignment-target bugs in instrument column fixers
1. _fix_instrument_column_access: var['instrument'] = EXPR was incorrectly
   converted to var.index.get_level_values(1) = EXPR, producing a SyntaxError
   ('cannot assign to function call'). Added (?!\s*=) negative lookahead to
   skip assignment targets.

2. _fix_groupby_column_on_multiindex: groupby(['instrument','date']) on a
   reset_index() variable was converted to groupby([var.index.get_level_values...])
   but reset_index() produces a plain RangeIndex, not a MultiIndex, causing
   AttributeError: 'RangeIndex' has no attribute 'normalize'. Added reset_vars
   guard to skip variables produced by reset_index().

Adds 1 new test (test_assignment_target_not_touched) — total 29 tests, all passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 15:55:01 +02:00
github-actions[bot] 9a47691420 chore(master): release 1.3.4 (#31)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-27 15:52:41 +02:00
TPTBusiness a370690ee8 fix(auto-fixer): add five new factor code fixes for groupby/apply errors
1. groupby(level=['instrument','date']) → get_level_values() — string level
   names like 'date' don't exist in the (datetime, instrument) MultiIndex;
   replaced with get_level_values(0).normalize() + get_level_values(1).

2. groupby(level=['date','instrument']) — symmetric fix for reversed order.

3. groupby(level=['instrument']) → groupby(level=1) — single string level.

4. groupby(level=N)['col'].apply(lambda) → transform(lambda) — apply() on a
   grouped Series prepends an extra index level, causing index shape mismatch
   when assigned back; transform() preserves the original index.

5. df.loc[instrument] DateParseError fix (instrument_loc_multiindex) — already
   committed, adding supporting tests for groupby(level=['instrument','date']).

Adds 5 new tests (TestGroupbyLevelStringNames, TestGroupbyApplyToTransform)
— total 28 tests, all passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 15:40:53 +02:00
TPTBusiness eaebd60d93 fix(auto-fixer): fix df.loc[instrument] DateParseError on MultiIndex frames
When LLM iterates over instruments via get_level_values('instrument').unique()
and then does df.loc[instrument], pandas tries to parse the instrument string
('EURUSD') as a datetime against level-0 of the (datetime, instrument) index,
raising DateParseError.

Fix: detect loop variables bound to get_level_values(1) or get_level_values('instrument')
and replace DF.loc[loop_var] (read) with DF.xs(loop_var, level=1). Assignment
write-backs are left untouched to avoid complex rewrites.

Adds 4 new tests (TestInstrumentLocMultiindex) — total 23 tests, all passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 15:31:35 +02:00
TPTBusiness 8070de3ae1 fix(auto-fixer): fix df['instrument'] KeyError on MultiIndex frames
LLM-generated code often accesses df['instrument'] as a column, but
'instrument' is an index level (level 1) in the MultiIndex DataFrame.
Replace with df.index.get_level_values(1) except when the variable
was created via reset_index() (where the column actually exists).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:57:35 +02:00
TPTBusiness ce806ea60b fix(loop): prevent step_idx advance on unhandled exceptions + fix consecutive assistant messages
Two bugs that together caused an infinite SKIP loop after LoopResumeError:

1. loop.py _run_step: set step_forward=False in the `else: raise` branch so that
   when LoopResumeError propagates from _propose (LLMUnavailableError), step_idx
   stays at 0. Previously it advanced to 1, leaving loops permanently stuck with
   missing direct_exp_gen result on next resume.

2. base.py _create_chat_completion_auto_continue: when finish_reason=="length"
   triggers a continuation retry, merge into the previous assistant message instead
   of appending a second consecutive one. llama-server returns 400 on two consecutive
   assistant messages, which caused LLMUnavailableError -> LoopResumeError cascade.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:33:24 +02:00
TPTBusiness 57e2609402 fix(auto-fixer): add groupby([level=N,'date']) SyntaxError fix
LLM generates invalid Python by putting keyword args inside lists:
  df.groupby([level=1, 'date'])  ← SyntaxError

Also fixes the regex for the chained groupby Pattern A/B which had
an unescaped ')' causing re.error that silently reverted the fix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:01:22 +02:00
TPTBusiness bb32276332 fix(auto-fixer): disable _fix_min_periods for intraday data
The fixer was raising min_periods to match window size, which causes
all-NaN output for intraday factors with 96 bars/day — window=240 means
zero valid bars per day, window=60 means 61% NaN per day. Critics were
consistently flagging this as incorrect for intraday factors. The LLM
now controls its own min_periods.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 18:59:58 +02:00
TPTBusiness 35d03a8a0d fix(auto-fixer): fix chained groupby(level=N).groupby('date') pattern
LLM learns from feedback to use groupby(level=1) for instrument, then
chains .groupby('date') to add the date dimension — but DataFrameGroupBy
has no .groupby() method, causing AttributeError at runtime.

Replace the invalid chain with a correct two-level groupby using
index.get_level_values(), consistent with the existing instrument+date fix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:34:10 +02:00
TPTBusiness 9591c11702 fix(auto-fixer): preserve date dimension in groupby(['instrument','date']) fix
The previous fixer converted groupby(['instrument','date']) → groupby(level=1),
stripping the date level. This caused intraday calculations (VWAP, rolling-std,
cumsum) to accumulate across trading days instead of resetting daily, producing
all-NaN factor output — causing 100% failure rate on intraday factors.

New behaviour: capture the DataFrame variable name and emit:
  var.groupby([var.index.get_level_values(1),
               var.index.get_level_values(0).normalize()])
which groups by (instrument, day) as originally intended.

Adds test/qlib/test_auto_fixer.py covering all fixer cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 11:50:52 +02:00
TPTBusiness 7582e55bb3 fix(auto-fixer): remove ddof from rolling() args, not only from std()/var()
The LLM generates x.rolling(window=N, ddof=1).std() where ddof is passed
to rolling() instead of std() — pandas raises TypeError on any ddof in rolling().
Fix both forms: rolling(..., ddof=N) and rolling(...).std(ddof=N).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 08:53:40 +02:00
TPTBusiness 27803e8b85 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>
2026-04-26 08:51:59 +02:00
github-actions[bot] 944af06a87 chore(master): release 1.3.3 (#30)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-25 09:25:18 +02:00
TPTBusiness 97e42d7a1a 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>
2026-04-25 09:10:39 +02:00
TPTBusiness 5481e83f03 fix(factors): extend look-ahead rules to session factors and add intraday-factor guidance
- Rule 7 extended: session-based aggregations (London/NY/Asian) must also
  be shifted by 1 trading day before use — same as daily aggregations
- Rule 8 added: prefer pure intraday rolling factors (RSI, Bollinger, VWAP
  deviation, rolling std) that have no look-ahead risk and vary every minute
- predix_full_eval.py: apply _shift_daily_constant_factor_if_needed before IC
- predix_gen_strategies_real_bt.py: improved swing prompt with daily-level
  signal logic guidance for daily-constant factors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 20:19:07 +02:00
TPTBusiness 88c4cc4a33 fix(backtest): replace broken MC permutation test with binomial win-rate test
The previous monte_carlo_trade_pvalue() used sum(permuted_trades) as test
statistic, which is permutation-invariant (sum is commutative), so beat/n
was always 1.0 and MC_p was always 1.00 for every strategy.

Replace with a one-sided binomial test on trade win rate vs 50% baseline.
Tests whether the observed win rate could occur by chance under H0: p=0.5.

Also add _shift_daily_constant_factor_if_needed() to predix_full_eval.py
so re-evaluations apply the look-ahead bias correction for daily factors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 09:55:45 +02:00
TPTBusiness 01889a6b64 fix(factors): detect and correct look-ahead bias in daily-constant factors
Daily factors (e.g. daily_log_return) carried same-day close data at 00:00,
giving the model end-of-day information at bar open — a classic look-ahead bias
that produced spurious IC=0.25 and Sharpe=24 with 98% win rate.

Changes:
- factor_runner.py: add _shift_daily_constant_factor_if_needed() that detects
  factors where >90% of days have a single unique intraday value, then shifts
  them by 1 trading day before IC computation
- prompts.yaml: add rule #7 instructing LLM to always shift(1) daily aggregates
  before forward-filling to minute bars

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 09:34:06 +02:00
github-actions[bot] 443c6d47b2 chore(master): release 1.3.2 (#29)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-23 20:31:26 +02:00
TPTBusiness b10d3512df fix(strategies): handle None ic/sharpe/dd in rejected strategy log output
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 20:21:21 +02:00
TPTBusiness d75cba934e fix(strategies): guard against None IC in acceptance check, disable slow wf_rolling
- abs(ic or 0) prevents TypeError crash when backtest returns no IC value
- wf_rolling=False and mc_n_permutations=50 for faster generation runs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 20:51:07 +02:00
github-actions[bot] 38fa760429 chore(master): release 1.3.1 (#27)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-21 22:42:45 +02:00
77 changed files with 4156 additions and 381 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
release-please:
runs-on: ubuntu-latest
steps:
- uses: googleapis/release-please-action@v4
- uses: googleapis/release-please-action@v5
with:
token: ${{ secrets.GITHUB_TOKEN }}
config-file: release-please-config.json
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "1.3.0"
".": "1.3.11"
}
+120
View File
@@ -1,5 +1,125 @@
# Changelog
## [1.3.11](https://github.com/TPTBusiness/Predix/compare/v1.3.10...v1.3.11) (2026-05-01)
### Bug Fixes
* **ci:** lazy import logger in predix.py and cli.py to avoid ImportError in test env ([60763e8](https://github.com/TPTBusiness/Predix/commit/60763e8eae34f41865ba8e5e65bdfde13b564b4b))
## [1.3.10](https://github.com/TPTBusiness/Predix/compare/v1.3.9...v1.3.10) (2026-05-01)
### Bug Fixes
* **security:** replace remaining assert statements with proper error handling ([928533d](https://github.com/TPTBusiness/Predix/commit/928533d9a81bd5062f07458fbf94d3c7fe347775))
## [1.3.9](https://github.com/TPTBusiness/Predix/compare/v1.3.8...v1.3.9) (2026-05-01)
### Bug Fixes
* **security:** resolve path-injection, B701, B101, B112 Bandit alerts ([20b89a0](https://github.com/TPTBusiness/Predix/commit/20b89a061843b39836e975f158404e8e2d4627cd))
## [1.3.8](https://github.com/TPTBusiness/Predix/compare/v1.3.7...v1.3.8) (2026-04-30)
### Bug Fixes
* **deps:** relax aiohttp constraint to &gt;=3.13.4 for litellm compatibility ([34ab192](https://github.com/TPTBusiness/Predix/commit/34ab1923a887089eb36e5cbad6cb8df16f0333ca))
* **qlib:** correct indentation in except blocks in quant_proposal and factor_runner ([8143451](https://github.com/TPTBusiness/Predix/commit/8143451e8c0ead01c4d86d19669268c7bfb15fac))
* **security:** replace eval() with ast.literal_eval in finetune validator (B307) ([0508caf](https://github.com/TPTBusiness/Predix/commit/0508caf9140d210b823fefefa28ee535ec85a0ae))
* **security:** replace shell=True subprocess calls with list args in env.py (B602) ([2012d5a](https://github.com/TPTBusiness/Predix/commit/2012d5ae4e77cc2f1ab9a48beaaac5a74695d083))
* **security:** resolve path-injection and add nosec for safe temp paths (B108, py/path-injection) ([6727480](https://github.com/TPTBusiness/Predix/commit/67274803bd1d14e5d1df9a063f46b2edb8501a2b))
## [1.3.7](https://github.com/TPTBusiness/Predix/compare/v1.3.6...v1.3.7) (2026-04-30)
### Bug Fixes
* **security:** nosec for B608/B701 false positives in UI and template code ([5eb5d7e](https://github.com/TPTBusiness/Predix/commit/5eb5d7e8fdbe90e0dced83fef4e09f5a33e96b2b))
* **security:** replace eval() with ast.literal_eval and add request timeouts (B307, B113) ([3301ada](https://github.com/TPTBusiness/Predix/commit/3301ada697ca7d3afa1a188d2a76a87ae98b4529))
* **security:** replace shell=True subprocess calls with list args (B602) ([13c08f4](https://github.com/TPTBusiness/Predix/commit/13c08f4ce6813eb7c314087921ec8c0f40074bd7))
## [1.3.6](https://github.com/TPTBusiness/Predix/compare/v1.3.5...v1.3.6) (2026-04-30)
### Bug Fixes
* **security:** real fix for B110 (logging in factor_proposal.py [#746](https://github.com/TPTBusiness/Predix/issues/746)) ([16624e0](https://github.com/TPTBusiness/Predix/commit/16624e0bd966ae4d24c4a3eb42bbc31c11da3136))
* **security:** real fix for B110 (logging in factor_runner.py [#744](https://github.com/TPTBusiness/Predix/issues/744)) ([88cf0fb](https://github.com/TPTBusiness/Predix/commit/88cf0fb8828b11c97f2f3ae2881a4900b020c6f0))
* **security:** real fix for B110 (logging in quant_proposal.py [#741](https://github.com/TPTBusiness/Predix/issues/741)) ([7cf2a64](https://github.com/TPTBusiness/Predix/commit/7cf2a644f553b054bd4b0607ea51e5372e68d90a))
* **security:** real fix for B110 (logging in quant_proposal.py [#741](https://github.com/TPTBusiness/Predix/issues/741)) ([ef985f8](https://github.com/TPTBusiness/Predix/commit/ef985f86035d8dca707c60137e6508349a0c4ae6))
* **security:** real fix for B404/B603 (sys.executable in factor_runner.py [#745](https://github.com/TPTBusiness/Predix/issues/745)) ([819655a](https://github.com/TPTBusiness/Predix/commit/819655aaa3efa76596d60501d0e8ca365df3e5e2))
* **security:** revert broken read_pickle encoding arg in kaggle template (B301) ([3574907](https://github.com/TPTBusiness/Predix/commit/35749073c91e69f63ddaad61dae3f2b799327e63))
* **security:** validate SQL identifiers in _add_column_if_not_exists (B608) ([e10dfa2](https://github.com/TPTBusiness/Predix/commit/e10dfa2576038e911f83595d3b466c261bc0cd54))
* **security:** whitelist-validate metric column in get_top_factors (B608) ([e50519f](https://github.com/TPTBusiness/Predix/commit/e50519fe066e68aec2f19b83df4f643c3c22053d))
## [1.3.5](https://github.com/TPTBusiness/Predix/compare/v1.3.4...v1.3.5) (2026-04-27)
### Bug Fixes
* **auto-fixer:** add five new factor code fixes for groupby/apply errors ([449c8fd](https://github.com/TPTBusiness/Predix/commit/449c8fd70a327e604dcca122e4a134f0cca918e4))
* **auto-fixer:** add four new factor code fixes for common runtime errors ([40484f6](https://github.com/TPTBusiness/Predix/commit/40484f6d300425da481f1edd325da4acbc06ec7d))
* **auto-fixer:** add groupby([level=N,'date']) SyntaxError fix ([ca77c00](https://github.com/TPTBusiness/Predix/commit/ca77c005bea4abdd8854c1de2b0e8d03b7742161))
* **auto-fixer:** disable _fix_min_periods for intraday data ([77b0740](https://github.com/TPTBusiness/Predix/commit/77b0740f059349df7e769a378af728aa33b2070e))
* **auto-fixer:** fix chained groupby(level=N).groupby('date') pattern ([7d5fe32](https://github.com/TPTBusiness/Predix/commit/7d5fe32b31a19ce8b04bd8f5a430720fdb748f7a))
* **auto-fixer:** fix df.loc[instrument] DateParseError on MultiIndex frames ([b7860ea](https://github.com/TPTBusiness/Predix/commit/b7860eafc0ad26384947ce0510ecf4e9f3425807))
* **auto-fixer:** fix df['instrument'] KeyError on MultiIndex frames ([aad6bd1](https://github.com/TPTBusiness/Predix/commit/aad6bd1c7c720b3d486e0cf248337f32394773b1))
* **auto-fixer:** fix two assignment-target bugs in instrument column fixers ([421eedf](https://github.com/TPTBusiness/Predix/commit/421eedffed4b883c24397dc5581c019a3985277f))
* **auto-fixer:** preserve date dimension in groupby(['instrument','date']) fix ([b58fdd8](https://github.com/TPTBusiness/Predix/commit/b58fdd8be43720b5d4363e0f8de9a01591d4d2dc))
* **auto-fixer:** remove ddof from rolling() args, not only from std()/var() ([b0fc328](https://github.com/TPTBusiness/Predix/commit/b0fc328d0d4a041c65d8eeb32cb3f2bb86568406))
* **auto-fixer:** strip spurious .reset_index() after .transform() calls ([8708aae](https://github.com/TPTBusiness/Predix/commit/8708aae6e08728cda1875c775a76dc92e43576f3))
* **loop:** prevent step_idx advance on unhandled exceptions + fix consecutive assistant messages ([5ec4ad1](https://github.com/TPTBusiness/Predix/commit/5ec4ad1b96b5b99ef42bea7bb828cb1ef709a688))
## [1.3.4](https://github.com/TPTBusiness/Predix/compare/v1.3.3...v1.3.4) (2026-04-27)
### Bug Fixes
* **auto-fixer:** add five new factor code fixes for groupby/apply errors ([449c8fd](https://github.com/TPTBusiness/Predix/commit/449c8fd70a327e604dcca122e4a134f0cca918e4))
* **auto-fixer:** add four new factor code fixes for common runtime errors ([40484f6](https://github.com/TPTBusiness/Predix/commit/40484f6d300425da481f1edd325da4acbc06ec7d))
* **auto-fixer:** add groupby([level=N,'date']) SyntaxError fix ([ca77c00](https://github.com/TPTBusiness/Predix/commit/ca77c005bea4abdd8854c1de2b0e8d03b7742161))
* **auto-fixer:** disable _fix_min_periods for intraday data ([77b0740](https://github.com/TPTBusiness/Predix/commit/77b0740f059349df7e769a378af728aa33b2070e))
* **auto-fixer:** fix chained groupby(level=N).groupby('date') pattern ([7d5fe32](https://github.com/TPTBusiness/Predix/commit/7d5fe32b31a19ce8b04bd8f5a430720fdb748f7a))
* **auto-fixer:** fix df.loc[instrument] DateParseError on MultiIndex frames ([b7860ea](https://github.com/TPTBusiness/Predix/commit/b7860eafc0ad26384947ce0510ecf4e9f3425807))
* **auto-fixer:** fix df['instrument'] KeyError on MultiIndex frames ([aad6bd1](https://github.com/TPTBusiness/Predix/commit/aad6bd1c7c720b3d486e0cf248337f32394773b1))
* **auto-fixer:** preserve date dimension in groupby(['instrument','date']) fix ([b58fdd8](https://github.com/TPTBusiness/Predix/commit/b58fdd8be43720b5d4363e0f8de9a01591d4d2dc))
* **auto-fixer:** remove ddof from rolling() args, not only from std()/var() ([b0fc328](https://github.com/TPTBusiness/Predix/commit/b0fc328d0d4a041c65d8eeb32cb3f2bb86568406))
* **backtest:** replace broken MC permutation test with binomial win-rate test ([c38d894](https://github.com/TPTBusiness/Predix/commit/c38d89478f586825bfca5715a96ca70ccd8791a3))
* **factors:** detect and correct look-ahead bias in daily-constant factors ([eb490a4](https://github.com/TPTBusiness/Predix/commit/eb490a461b66cbd815ae53ac5205115754712432))
* **factors:** extend look-ahead rules to session factors and add intraday-factor guidance ([c24c100](https://github.com/TPTBusiness/Predix/commit/c24c100442d6487686c0578de0b32d240fcbf215))
* **loop:** compress old experiment history in proposal prompt to reduce context size ([4bf90a9](https://github.com/TPTBusiness/Predix/commit/4bf90a905ba8b2aba2a818191c19998088cccaaf))
* **loop:** prevent step_idx advance on unhandled exceptions + fix consecutive assistant messages ([5ec4ad1](https://github.com/TPTBusiness/Predix/commit/5ec4ad1b96b5b99ef42bea7bb828cb1ef709a688))
## [1.3.3](https://github.com/TPTBusiness/Predix/compare/v1.3.2...v1.3.3) (2026-04-25)
### Bug Fixes
* **backtest:** replace broken MC permutation test with binomial win-rate test ([c38d894](https://github.com/TPTBusiness/Predix/commit/c38d89478f586825bfca5715a96ca70ccd8791a3))
* **factors:** detect and correct look-ahead bias in daily-constant factors ([eb490a4](https://github.com/TPTBusiness/Predix/commit/eb490a461b66cbd815ae53ac5205115754712432))
* **factors:** extend look-ahead rules to session factors and add intraday-factor guidance ([c24c100](https://github.com/TPTBusiness/Predix/commit/c24c100442d6487686c0578de0b32d240fcbf215))
* **loop:** compress old experiment history in proposal prompt to reduce context size ([4bf90a9](https://github.com/TPTBusiness/Predix/commit/4bf90a905ba8b2aba2a818191c19998088cccaaf))
* **strategies:** guard against None IC in acceptance check, disable slow wf_rolling ([2197f52](https://github.com/TPTBusiness/Predix/commit/2197f52150a50ef38d9e70991d7e48c8c30caec4))
* **strategies:** handle None ic/sharpe/dd in rejected strategy log output ([ad2ad3a](https://github.com/TPTBusiness/Predix/commit/ad2ad3ab3360ea75ed3bbc90c12098b9c5cc0114))
## [1.3.2](https://github.com/TPTBusiness/Predix/compare/v1.3.1...v1.3.2) (2026-04-23)
### Bug Fixes
* **strategies:** guard against None IC in acceptance check, disable slow wf_rolling ([2197f52](https://github.com/TPTBusiness/Predix/commit/2197f52150a50ef38d9e70991d7e48c8c30caec4))
* **strategies:** handle None ic/sharpe/dd in rejected strategy log output ([ad2ad3a](https://github.com/TPTBusiness/Predix/commit/ad2ad3ab3360ea75ed3bbc90c12098b9c5cc0114))
## [1.3.1](https://github.com/TPTBusiness/Predix/compare/v1.3.0...v1.3.1) (2026-04-21)
### Bug Fixes
* **deps:** bump python-dotenv to &gt;=1.2.2 (CVE symlink overwrite) ([126ae7d](https://github.com/TPTBusiness/Predix/commit/126ae7d5fb556b677d09d10221862a0d648d697a))
## [1.3.0](https://github.com/TPTBusiness/Predix/compare/v1.2.2...v1.3.0) (2026-04-21)
+12
View File
@@ -18,6 +18,13 @@ load_dotenv(Path(__file__).parent / ".env")
import typer
from rich.console import Console
try:
from rdagent.utils.env import logger
except ImportError:
import logging
logger = logging.getLogger(__name__)
app = typer.Typer(help="Predix - AI Quantitative Trading Agent")
console = Console()
@@ -510,6 +517,7 @@ def top(
if data.get("status") == "success" and data.get("ic") is not None:
results.append(data)
except Exception:
logger.warning("Failed to load factor file %s", f, exc_info=True)
continue
if not results:
@@ -659,6 +667,7 @@ def portfolio(
if data.get("status") == "success" and data.get("ic") is not None:
results.append(data)
except Exception:
logger.warning("Failed to load factor file %s", f, exc_info=True)
continue
if not results:
@@ -956,6 +965,7 @@ def portfolio_simple(
if data.get("status") == "success" and data.get("ic") is not None:
results.append(data)
except Exception:
logger.warning("Failed to load factor file %s", f, exc_info=True)
continue
if not results:
@@ -1337,6 +1347,7 @@ def build_strategies_ai(
if data.get("status") == "success" and data.get("ic") is not None:
factors.append(data)
except Exception:
logger.warning("Failed to load factor file %s", f, exc_info=True)
continue
if len(factors) < 10:
@@ -1552,6 +1563,7 @@ def _load_strategies():
try:
raw = json.loads(p.read_text())
except Exception:
logger.warning("Failed to load strategy file %s", p, exc_info=True)
continue
if not isinstance(raw, dict):
continue
+9 -1
View File
@@ -27,6 +27,13 @@ import typer
from rich.console import Console
from typing_extensions import Annotated
try:
from rdagent.utils.env import logger
except ImportError:
import logging
logger = logging.getLogger(__name__)
from rdagent.app.data_science.loop import main as data_science
from rdagent.app.finetune.llm.loop import main as llm_finetune
from rdagent.app.general_model.general_model import (
@@ -882,6 +889,7 @@ def optimize_portfolio_cli(
if data.get("status") == "accepted":
strategies.append(data)
except Exception:
logger.warning("Failed to load strategy file %s", f, exc_info=True)
continue
if not strategies:
@@ -1251,7 +1259,7 @@ def start_loop_cli(
script_dir = str(Path(__file__).parent.parent.parent.parent)
generator = f"python {script_dir}/scripts/predix_smart_strategy_gen.py"
logfile = f"{script_dir}/results/logs/generator_loop.log"
pidfile = "/tmp/predix_loop.pid"
pidfile = "/tmp/predix_loop.pid" # nosec B108 — administrative PID file, single-process daemon
os.makedirs(f"{script_dir}/results/logs", exist_ok=True)
+2 -3
View File
@@ -201,6 +201,5 @@ class DataScienceBasePropSetting(KaggleBasePropSetting):
DS_RD_SETTING = DataScienceBasePropSetting()
# enable_cross_trace_diversity and llm_select_hypothesis should not be true at the same time
assert not (
DS_RD_SETTING.enable_cross_trace_diversity and DS_RD_SETTING.llm_select_hypothesis
), "enable_cross_trace_diversity and llm_select_hypothesis cannot be true at the same time"
if DS_RD_SETTING.enable_cross_trace_diversity and DS_RD_SETTING.llm_select_hypothesis:
raise ValueError("enable_cross_trace_diversity and llm_select_hypothesis cannot be true at the same time")
+8 -9
View File
@@ -58,18 +58,18 @@ def main(
if user_target_scenario:
FT_RD_SETTING.user_target_scenario = user_target_scenario
assert (
FT_RD_SETTING.user_target_scenario is None
), "user_target_scenario is not yet supported, please specify via benchmark and benchmark_description"
if FT_RD_SETTING.user_target_scenario is not None:
raise ValueError("user_target_scenario is not yet supported, please specify via benchmark and benchmark_description")
if upper_data_size_limit:
FT_RD_SETTING.upper_data_size_limit = upper_data_size_limit
logger.info(f"Set upper_data_size_limit to {FT_RD_SETTING.upper_data_size_limit}")
if benchmark and benchmark_description:
FT_RD_SETTING.target_benchmark = benchmark
FT_RD_SETTING.benchmark_description = benchmark_description
assert FT_RD_SETTING.user_target_scenario or (
FT_RD_SETTING.target_benchmark and FT_RD_SETTING.benchmark_description
), "Either user_target_scenario or target_benchmark must be specified for LLM fine-tuning."
if not (
FT_RD_SETTING.user_target_scenario or (FT_RD_SETTING.target_benchmark and FT_RD_SETTING.benchmark_description)
):
raise ValueError("Either user_target_scenario or target_benchmark must be specified for LLM fine-tuning.")
# Update configuration with provided parameters
if dataset:
@@ -82,9 +82,8 @@ def main(
model_target = FT_RD_SETTING.base_model if FT_RD_SETTING.base_model else "auto selected model"
# Temporary assertion until auto-selection is implemented
assert (
FT_RD_SETTING.base_model is not None
), "Base model auto selection not yet supported, please specify via --base-model"
if FT_RD_SETTING.base_model is None:
raise ValueError("Base model auto selection not yet supported, please specify via --base-model")
logger.info(f"Starting LLM fine-tuning on dataset='{data_set_target}' with model='{model_target}'")
+8 -47
View File
@@ -24,46 +24,12 @@ from rdagent.app.finetune.llm.ui.ft_summary import render_job_summary
DEFAULT_LOG_BASE = "log/"
from rdagent.core.utils import safe_resolve_path
def validate_path_within_cwd(user_path: Path) -> Path:
"""
Validate that a user-provided path is within the current working directory.
Security: This function prevents path traversal attacks by:
1. Resolving the path to its absolute canonical form
2. Verifying it's within the CWD boundary using a normalized common prefix
3. Rejecting paths outside the boundary with ValueError
Parameters
----------
user_path : Path
User-provided path to validate
Returns
-------
Path
Resolved absolute path if valid
Raises
------
ValueError
If path is outside the current working directory
"""
safe_root = Path.cwd().resolve()
# Expand any user home reference and resolve without requiring the path to exist.
resolved_path = user_path.expanduser().resolve(strict=False)
# Ensure the resolved path is absolute and remains within the safe root.
safe_root_str = str(safe_root)
resolved_str = str(resolved_path)
common = os.path.commonpath([safe_root_str, resolved_str])
if common != safe_root_str:
raise ValueError("Path is outside the allowed project directory")
# This will raise ValueError if resolved_path is not within safe_root
resolved_path.relative_to(safe_root)
return resolved_path
return safe_resolve_path(user_path, safe_root)
def get_job_options(base_path: Path, safe_root: Path | None = None) -> list[str]:
@@ -141,19 +107,14 @@ def main():
st.header("Job")
base_folder = st.text_input("Base Folder", value=default_log, key="base_folder_input")
# Normalize and validate the base folder against the configured log root
root_real = os.path.realpath(str(Path(default_log).expanduser()))
folder_real = os.path.realpath(str(Path(base_folder).expanduser()))
if folder_real == root_real or folder_real.startswith(root_real + os.sep):
base_path = Path(folder_real)
safe_root = Path(root_real)
else:
safe_root = Path(default_log).expanduser().resolve()
try:
base_path = safe_resolve_path(Path(base_folder), safe_root)
except ValueError:
st.error("Invalid base folder: must be within the configured log directory.")
safe_root = Path(root_real)
base_path = safe_root
# base_path is validated against safe_root nosec B614
job_options = get_job_options(base_path, safe_root) # nosec B614 validated above
job_options = get_job_options(base_path, safe_root)
if job_options:
selected_job = st.selectbox("Select Job", job_options, key="job_select")
if selected_job.startswith("."):
+7 -9
View File
@@ -13,6 +13,7 @@ from typing import Any
import streamlit as st
from rdagent.app.finetune.llm.ui.config import EVALUATOR_CONFIG, EventType
from rdagent.core.utils import safe_resolve_path
from rdagent.log.storage import FileStorage
@@ -89,11 +90,10 @@ def extract_stage(tag: str) -> str:
def get_valid_sessions(log_folder: Path, safe_root: Path | None = None) -> list[str]:
"""Get list of valid session directories, optionally validating against a safe root."""
if safe_root is not None:
root_real = os.path.realpath(str(safe_root.expanduser()))
folder_real = os.path.realpath(str(log_folder.expanduser()))
if not (folder_real == root_real or folder_real.startswith(root_real + os.sep)):
try:
log_folder = safe_resolve_path(log_folder, safe_root)
except ValueError:
return []
log_folder = Path(folder_real)
if not log_folder.exists():
return []
@@ -373,13 +373,11 @@ def parse_event(tag: str, content: Any, timestamp: datetime) -> Event | None:
@st.cache_data(ttl=300, hash_funcs={Path: str})
def load_ft_session(log_path: Path, safe_root: Path | None = None) -> Session:
"""Load events into hierarchical session structure, optionally validating against safe root."""
# Validate path is within safe_root if provided
if safe_root is not None:
root_real = os.path.realpath(str(safe_root.expanduser()))
path_real = os.path.realpath(str(log_path.expanduser()))
if not (path_real == root_real or path_real.startswith(root_real + os.sep)):
try:
log_path = safe_resolve_path(log_path, safe_root)
except ValueError:
return Session()
log_path = Path(path_real)
session = Session()
storage = FileStorage(log_path)
+3 -1
View File
@@ -78,7 +78,8 @@ class QuantRDLoop(RDLoop):
while True:
if self.get_unfinished_loop_cnt(self.loop_idx) < RD_AGENT_SETTINGS.get_max_parallel():
hypo = self._propose()
assert hypo.action in ["factor", "model"]
if hypo.action not in ["factor", "model"]:
raise ValueError(f"hypo.action must be 'factor' or 'model', got {hypo.action!r}")
if hypo.action == "factor":
exp = self.factor_hypothesis2experiment.convert(hypo, self.trace)
else:
@@ -322,6 +323,7 @@ class QuantRDLoop(RDLoop):
if data.get("status") == "success" and data.get("ic") is not None:
factors.append(data)
except Exception:
logger.warning("Failed to load factor file %s", f, exc_info=True)
continue
if len(factors) < 10:
+5 -35
View File
@@ -16,55 +16,26 @@ from rdagent.app.rl.ui.components import render_session, render_summary
from rdagent.app.rl.ui.config import ALWAYS_VISIBLE_TYPES, OPTIONAL_TYPES
from rdagent.app.rl.ui.data_loader import get_summary, get_valid_sessions, load_session
from rdagent.app.rl.ui.rl_summary import render_job_summary
from rdagent.core.utils import safe_resolve_path
DEFAULT_LOG_BASE = "log/"
def _safe_resolve(user_input: str | None, safe_root: Path) -> Path:
"""
Resolve user path relative to safe_root; raise ValueError if it escapes.
Security: This function prevents path traversal attacks by:
1. Rejecting null bytes in user input
2. Rejecting Windows drive letters (C:\, D:\, etc.)
3. Rejecting absolute paths
4. Normalizing path to remove .. traversal attempts
5. Validating resolved path is within safe_root using a realpath-based check
All user-provided paths are validated before filesystem access.
"""
# Treat the provided safe_root as trusted and canonicalize it once.
safe_root = safe_root.expanduser().resolve()
# Empty input maps to the safe root directory.
if not user_input:
return safe_root
# Security check 1: Reject null bytes (path truncation attack)
if "\x00" in user_input:
raise ValueError("Invalid path: contains null byte")
try:
# Security check 2: Normalize path to resolve .. and . components
normalized = os.path.normpath(user_input.strip())
# Security check 3: Reject Windows drive letters (C:\, D:\, etc.)
drive, _ = os.path.splitdrive(normalized)
if drive:
raise ValueError("Absolute paths with drive letters are not allowed")
# Security check 4: Reject absolute paths (/, //server/share, etc.)
if os.path.isabs(normalized):
raise ValueError("Absolute paths are not allowed")
# Security check 5: Build candidate path under safe_root and fully resolve it.
joined = os.path.join(str(safe_root), normalized)
resolved_candidate = os.path.realpath(joined)
# Security check 6: Validate candidate is within safe_root (prevent path traversal)
candidate_path = Path(resolved_candidate)
# Reconstruct from trusted safe_root so the returned path is root-derived.
return safe_root / candidate_path.relative_to(safe_root)
joined = safe_root / normalized
return safe_resolve_path(joined, safe_root)
except (OSError, ValueError) as exc:
raise ValueError(f"Invalid path outside of allowed root: {user_input}") from exc
@@ -82,7 +53,7 @@ def get_job_options(base_path: Path, safe_root: Path | None = None) -> list[str]
# Security fix: Validate base_path to prevent path traversal
try:
base_path_resolved = base_path.expanduser().resolve()
base_path_resolved = base_path.expanduser().resolve() # nosec B614 — validated against safe_root below via relative_to()
if safe_root is not None:
safe_root_resolved = safe_root.expanduser().resolve()
@@ -203,8 +174,7 @@ def main():
except ValueError as e:
st.warning(str(e))
return
# job_path is validated by _safe_resolve() above
if job_path.exists(): # nosec B614 path validated by _safe_resolve
if job_path.exists():
render_job_summary(job_path, safe_root, is_root=is_root_job)
else:
st.warning(f"Job folder not found: {job_folder}")
+7 -9
View File
@@ -15,6 +15,7 @@ from typing import Any
import streamlit as st
from rdagent.app.rl.ui.config import EventType
from rdagent.core.utils import safe_resolve_path
from rdagent.log.storage import FileStorage
@@ -76,11 +77,10 @@ def extract_stage(tag: str) -> str:
def get_valid_sessions(log_folder: Path, safe_root: Path | None = None) -> list[str]:
"""Get list of valid session directories, optionally validating against a safe root."""
if safe_root is not None:
root_real = os.path.realpath(str(safe_root.expanduser()))
folder_real = os.path.realpath(str(log_folder.expanduser()))
if not (folder_real == root_real or folder_real.startswith(root_real + os.sep)):
try:
log_folder = safe_resolve_path(log_folder, safe_root)
except ValueError:
return []
log_folder = Path(folder_real)
if not log_folder.exists():
return []
@@ -245,13 +245,11 @@ def parse_event(tag: str, content: Any, timestamp: datetime) -> Event | None:
@st.cache_data(ttl=300, hash_funcs={Path: str})
def load_session(log_path: Path, safe_root: Path | None = None) -> Session:
"""Load events into hierarchical session structure, optionally validating against safe root."""
# Validate path is within safe_root if provided
if safe_root is not None:
root_real = os.path.realpath(str(safe_root.expanduser()))
path_real = os.path.realpath(str(log_path.expanduser()))
if not (path_real == root_real or path_real.startswith(root_real + os.sep)):
try:
log_path = safe_resolve_path(log_path, safe_root)
except ValueError:
return Session()
log_path = Path(path_real)
session = Session()
+4 -6
View File
@@ -9,6 +9,8 @@ from pathlib import Path
import pandas as pd
import streamlit as st
from rdagent.core.utils import safe_resolve_path
def is_valid_task(task_path: Path) -> bool:
"""Check if directory is a valid RL task (has __session__ subdirectory)"""
@@ -62,14 +64,10 @@ def get_loop_status(task_path: Path, loop_id: int) -> tuple[str, bool | None]:
def _validate_job_path(job_path: Path, safe_root: Path) -> Path:
"""Resolve and validate that job_path stays within safe_root."""
resolved_root = safe_root.expanduser().resolve()
resolved_job = job_path.expanduser().resolve()
try:
# Reconstruct from trusted root so the returned path is root-derived.
return resolved_root / resolved_job.relative_to(resolved_root)
return safe_resolve_path(job_path, safe_root)
except ValueError:
raise ValueError(f"Job path is outside allowed root {resolved_root}")
raise ValueError(f"Job path is outside allowed root {safe_root}")
def get_max_loops(job_path: Path, safe_root: Path | None = None) -> int:
+2 -2
View File
@@ -54,11 +54,11 @@ def rdagent_info():
current_version = importlib.metadata.version("rdagent")
logger.info(f"RD-Agent version: {current_version}")
api_url = f"https://api.github.com/repos/microsoft/RD-Agent/contents/requirements.txt?ref=main"
response = requests.get(api_url)
response = requests.get(api_url, timeout=30)
if response.status_code == 200:
files = response.json()
file_url = files["download_url"]
file_response = requests.get(file_url)
file_response = requests.get(file_url, timeout=30)
if file_response.status_code == 200:
all_file_contents = file_response.text.split("\n")
else:
+26 -17
View File
@@ -71,6 +71,9 @@ class ResultsDatabase:
self.conn.commit()
_ALLOWED_TABLES = frozenset({"factors", "backtest_runs", "loop_results"})
_ALLOWED_COL_TYPES = frozenset({"REAL", "TEXT", "INTEGER", "BLOB"})
def _add_column_if_not_exists(self, table: str, column: str, col_type: str) -> None:
"""
Add a column to a table if it doesn't already exist.
@@ -78,20 +81,24 @@ class ResultsDatabase:
Parameters
----------
table : str
Table name
Table name (must be in _ALLOWED_TABLES)
column : str
Column name to add
Column name to add (alphanumeric + underscore only)
col_type : str
SQL column type (e.g., 'REAL', 'TEXT')
SQL column type (must be in _ALLOWED_COL_TYPES)
"""
if table not in self._ALLOWED_TABLES:
raise ValueError(f"Unknown table: {table!r}")
if not column.replace("_", "").isalnum():
raise ValueError(f"Invalid column name: {column!r}")
if col_type not in self._ALLOWED_COL_TYPES:
raise ValueError(f"Invalid column type: {col_type!r}")
c = self.conn.cursor()
try:
# Try to query the column - if it fails, it doesn't exist
# nosec B608: Internal schema migration, column names are controlled
c.execute(f"SELECT {column} FROM {table} LIMIT 1") # nosec B608
except sqlite3.OperationalError:
# Column doesn't exist, add it
c.execute(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}") # nosec B608
c.execute("SELECT name FROM pragma_table_info(?)", (table,))
existing = {row[0] for row in c.fetchall()}
if column not in existing:
c.execute(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}")
def add_factor(self, name: str, type: str = "unknown") -> int:
c = self.conn.cursor()
@@ -183,16 +190,18 @@ class ResultsDatabase:
pd.DataFrame
DataFrame with factor names and metrics
"""
# Map shorthand to full column name
_ALLOWED_METRICS = frozenset({
'sharpe', 'ic', 'annual_return', 'max_drawdown',
'win_rate', 'information_ratio', 'volatility',
})
metric_map = {
'sharpe': 'sharpe',
'ic': 'ic',
'return': 'annual_return',
'drawdown': 'max_drawdown',
'win_rate': 'win_rate',
'sharpe': 'sharpe', 'ic': 'ic', 'return': 'annual_return',
'drawdown': 'max_drawdown', 'win_rate': 'win_rate',
'information_ratio': 'information_ratio',
}
col = metric_map.get(metric, metric)
if col not in _ALLOWED_METRICS:
raise ValueError(f"Unknown metric: {metric!r}")
return pd.read_sql_query(
f"""SELECT factor_name, ic, sharpe, annual_return, max_drawdown,
@@ -201,7 +210,7 @@ class ResultsDatabase:
JOIN factors ON factor_id = factors.id
WHERE {col} IS NOT NULL
ORDER BY {col} DESC
LIMIT ?""",
LIMIT ?""", # nosec B608 — col is validated against _ALLOWED_METRICS above
self.conn,
params=[limit]
)
+13 -12
View File
@@ -356,11 +356,12 @@ def monte_carlo_trade_pvalue(
"""
Monte Carlo permutation test on trade-level P&L.
Shuffles the order of trade returns ``n_permutations`` times and computes
the fraction of runs whose total return is >= the real total return.
Runs a one-sided binomial test on trade-level win rate.
p < 0.05 strategy has a statistically significant edge (real return
beats 95% of random sequences with the same set of trades).
Tests H0: win_rate = 0.5 (random trading) against H1: win_rate > 0.5.
The ``n_permutations`` parameter is kept for API compatibility but is unused.
p < 0.05 win rate is significantly above 50%, indicating a genuine per-trade edge.
Parameters
----------
@@ -379,14 +380,14 @@ def monte_carlo_trade_pvalue(
if len(trade_pnl) < 2:
return 1.0
trades = trade_pnl.values.copy()
real_total = float(trades.sum())
rng = np.random.default_rng(seed)
beat = 0
for _ in range(n_permutations):
perm = rng.permutation(trades)
if perm.sum() >= real_total:
beat += 1
return beat / n_permutations
# Binomial test: is the win rate significantly above 50%?
# p = probability of observing >= n_wins out of n_trades under null (win_rate=0.5).
# Low p → strategy has a significant positive edge per trade.
from scipy.stats import binomtest
n_wins = int((trades > 0).sum())
n_total = len(trades)
result = binomtest(n_wins, n_total, p=0.5, alternative="greater")
return float(result.pvalue)
def walk_forward_rolling(
+14 -7
View File
@@ -75,8 +75,10 @@ class CoSTEER(Developer[Experiment]):
def _get_last_fb(self) -> CoSTEERMultiFeedback:
fb = self.evolve_agent.evolving_trace[-1].feedback
assert fb is not None, "feedback is None"
assert isinstance(fb, CoSTEERMultiFeedback), "feedback must be of type CoSTEERMultiFeedback"
if fb is None:
raise AssertionError("feedback is None")
if not isinstance(fb, CoSTEERMultiFeedback):
raise TypeError("feedback must be of type CoSTEERMultiFeedback")
return fb
def should_use_new_evo(self, base_fb: CoSTEERMultiFeedback | None, new_fb: CoSTEERMultiFeedback) -> bool:
@@ -121,7 +123,8 @@ class CoSTEER(Developer[Experiment]):
for evo_exp in self.evolve_agent.multistep_evolve(evo_exp, self.evaluator):
iteration_count += 1
assert isinstance(evo_exp, Experiment) # multiple inheritance
if not isinstance(evo_exp, Experiment):
raise TypeError("evo_exp must be an instance of Experiment")
evo_fb = self._get_last_fb()
update_fallback = self.should_use_new_evo(
base_fb=fallback_evo_fb,
@@ -154,7 +157,8 @@ class CoSTEER(Developer[Experiment]):
evo_exp = fallback_evo_exp
evo_exp.recover_ws_ckp()
evo_fb = fallback_evo_fb
assert evo_fb is not None # multistep_evolve should run at least once
if evo_fb is None:
raise AssertionError("multistep_evolve should run at least once")
evo_exp = self._exp_postprocess_by_feedback(evo_exp, evo_fb)
except CoderError as e:
e.caused_by_timeout = reached_max_seconds
@@ -264,9 +268,12 @@ class CoSTEER(Developer[Experiment]):
- Raise Error if it failed to handle the develop task
-
"""
assert isinstance(evo, Experiment)
assert isinstance(feedback, CoSTEERMultiFeedback)
assert len(evo.sub_workspace_list) == len(feedback)
if not isinstance(evo, Experiment):
raise TypeError("evo must be an instance of Experiment")
if not isinstance(feedback, CoSTEERMultiFeedback):
raise TypeError("feedback must be an instance of CoSTEERMultiFeedback")
if len(evo.sub_workspace_list) != len(feedback):
raise ValueError("Length of sub_workspace_list must match length of feedback")
# FIXME: when whould the feedback be None?
failed_feedbacks = [
@@ -122,7 +122,8 @@ class MultiProcessEvolvingStrategy(EvolvingStrategy):
last_feedback = None
if len(evolving_trace) > 0:
last_feedback = evolving_trace[-1].feedback
assert isinstance(last_feedback, CoSTEERMultiFeedback)
if not isinstance(last_feedback, CoSTEERMultiFeedback):
raise TypeError("last_feedback must be of type CoSTEERMultiFeedback")
# 1.找出需要evolve的task
to_be_finished_task_index: list[int] = []
@@ -1028,7 +1028,8 @@ class CoSTEERKnowledgeBaseV2(EvolvingKnowledgeBase):
"""
node_count = len(nodes)
assert node_count >= 2, "nodes length must >=2"
if node_count < 2:
raise ValueError("nodes length must >=2")
intersection_node_list = []
if output_intersection_origin:
origin_list = []
@@ -54,7 +54,8 @@ def get_ds_env(
ValueError: If the env_type is not recognized.
"""
conf = DSCoderCoSTEERSettings()
assert conf_type in ["kaggle", "mlebench"], f"Unknown conf_type: {conf_type}"
if conf_type not in ["kaggle", "mlebench"]:
raise ValueError(f"Unknown conf_type: {conf_type}")
if conf.env_type == "docker":
env_conf = DSDockerConf() if conf_type == "kaggle" else MLEBDockerConf()
@@ -79,7 +80,8 @@ def get_clear_ws_cmd(stage: Literal["before_training", "before_inference"] = "be
"""
Clean the files in workspace to a specific stage
"""
assert stage in ["before_training", "before_inference"], f"Unknown stage: {stage}"
if stage not in ["before_training", "before_inference"]:
raise ValueError(f"Unknown stage: {stage}")
if DS_RD_SETTING.enable_model_dump and stage == "before_training":
cmd = "rm -r submission.csv scores.csv models trace.log"
else:
@@ -13,7 +13,7 @@ File structure
from pathlib import Path
from jinja2 import Environment, StrictUndefined
from jinja2 import Environment, StrictUndefined, select_autoescape
from rdagent.app.data_science.conf import DS_RD_SETTING
from rdagent.components.coder.CoSTEER.evaluators import (
@@ -88,7 +88,7 @@ class EnsembleMultiProcessEvolvingStrategy(MultiProcessEvolvingStrategy):
code_spec = workspace.file_dict["spec/ensemble.md"]
else:
test_code = (
Environment(undefined=StrictUndefined)
Environment(undefined=StrictUndefined, autoescape=select_autoescape())
.from_string((DIRNAME / "eval_tests" / "ensemble_test.txt").read_text())
.render(
model_names=[
@@ -2,7 +2,7 @@ import json
import re
from pathlib import Path
from jinja2 import Environment, StrictUndefined
from jinja2 import Environment, StrictUndefined, select_autoescape
from rdagent.app.data_science.conf import DS_RD_SETTING
from rdagent.components.coder.CoSTEER.evaluators import (
@@ -55,7 +55,7 @@ class EnsembleCoSTEEREvaluator(CoSTEEREvaluator):
fname = "test/ensemble_test.txt"
test_code = (DIRNAME / "eval_tests" / "ensemble_test.txt").read_text()
test_code = (
Environment(undefined=StrictUndefined)
Environment(undefined=StrictUndefined, autoescape=select_autoescape())
.from_string(test_code)
.render(
model_names=[
@@ -51,13 +51,23 @@ class FactorAutoFixer:
self.fixes_applied = []
fixed_code = code
# Apply fixes in order - groupby fixes MUST come before min_periods fixes
# Apply fixes in order
# NOTE: _fix_min_periods is intentionally excluded — it increased min_periods to
# match window size, which causes all-NaN output for intraday data with 96 bars/day
# (window=240 > 96 means zero valid bars per day). The LLM sets its own min_periods.
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_instrument_column_access, # First: fix df['instrument'] on MultiIndex
self._fix_instrument_loc_multiindex, # Second: fix df.loc[instrument_var] on MultiIndex
self._fix_zero_volume_proxy, # Third: replace zero $volume with range proxy
self._fix_reset_index_groupby, # Fourth: fix groupby(level=N) after reset_index()
self._fix_groupby_mixed_levels, # Fifth: fix groupby(level=[int, str])
self._fix_groupby_column_on_multiindex, # Sixth: fix groupby(['instrument','date']) on MultiIndex
self._fix_chained_groupby, # Seventh: fix groupby(level=N).groupby('date') chain
self._fix_rolling_ddof, # Eighth: remove unsupported ddof kwarg
self._fix_groupby_apply_to_transform, # Ninth: fix groupby patterns
self._fix_inf_nan_handling, # Tenth: add inf/nan handling
self._fix_data_range_processing, # Eleventh: ensure full data range
self._fix_multiindex_groupby, # Twelfth: ensure groupby on MultiIndex
]
for fix_method in fix_methods:
@@ -75,6 +85,352 @@ class FactorAutoFixer:
return fixed_code
def _fix_instrument_column_access(self, code: str) -> str:
"""
Fix: df['instrument'] raises KeyError on a MultiIndex DataFrame because
'instrument' is an index level (level 1), not a column.
Replace df['instrument'] with df.index.get_level_values('instrument')
but only when the DataFrame has a MultiIndex (not after reset_index which
would have promoted it to a real column).
Also fixes df.reset_index()['instrument'] correctly since after reset_index
the column exists.
"""
fixed_code = code
# Skip if already fixed or if reset_index() is being used before the access
# We only fix bare df['instrument'] where df is the original MultiIndex frame.
# Heuristic: if the assignment lhs or context shows reset_index, leave it alone.
# Pattern: <varname>['instrument'] where varname is NOT a reset_index result
reset_vars = set(re.findall(r'(\w+)\s*=\s*\w[^=\n]*\.reset_index\(', fixed_code))
def _replace_instrument_access(m: re.Match) -> str:
var = m.group(1)
if var in reset_vars:
return m.group(0) # leave reset_index vars alone — column exists
self.fixes_applied.append(f"instrument_column: {var}['instrument'] → get_level_values(1)")
return f"{var}.index.get_level_values(1)"
# Exclude assignment targets: var['instrument'] = ... must not become
# var.index.get_level_values(1) = ... (SyntaxError: cannot assign to function call)
fixed_code = re.sub(r"(\w+)\['instrument'\](?!\s*=)", _replace_instrument_access, fixed_code)
return fixed_code
def _fix_instrument_loc_multiindex(self, code: str) -> str:
"""
Fix: df.loc[instrument_var] raises DateParseError on a (datetime, instrument)
MultiIndex because pandas tries to match the instrument string against the
datetime level (level 0).
Pattern detected: for-loops iterating over get_level_values('instrument') or
get_level_values(1) where the loop variable is then used as df.loc[loop_var].
Replacement: df.loc[instrument_var] df.xs(instrument_var, level=1)
"""
fixed_code = code
# Find variables iterated from get_level_values('instrument') or get_level_values(1)
inst_vars = set(
re.findall(
r"for\s+(\w+)\s+in\s+.+?\.get_level_values\s*\(\s*(?:1|['\"]instrument['\"])\s*\)[^:\n]*:",
code,
)
)
if not inst_vars:
return fixed_code
for var in inst_vars:
# Replace DF.loc[var] (read) with DF.xs(var, level=1)
# Exclude write-back patterns (DF.loc[var] = ...) — leave those as-is
def _make_replacer(v: str):
def _replace(m: re.Match) -> str:
df_var = m.group(1)
self.fixes_applied.append(
f"instrument_loc: {df_var}.loc[{v}] → {df_var}.xs({v}, level=1)"
)
return f"{df_var}.xs({v}, level=1)"
return _replace
# Only match when NOT followed by ' =' (assignment)
fixed_code = re.sub(
rf"(\w+)\.loc\[\s*{re.escape(var)}\s*\](?!\s*=)",
_make_replacer(var),
fixed_code,
)
return fixed_code
def _fix_zero_volume_proxy(self, code: str) -> str:
"""
Fix: $volume is always 0 in our EUR/USD dataset (FX has no real volume).
Any factor using $volume (VWAP, volume-weighted returns, etc.) produces
all-NaN output because 0*price=0 and sum(0)/sum(0)=NaN.
Insert a guard right after pd.read_hdf() that replaces zero volume with
the intraday price-range proxy ($high - $low) so volume-weighted factors
produce meaningful signals.
"""
if "'$volume'" not in code and '"$volume"' not in code:
return code
# Already patched
if "volume proxy" in code:
return code
lines = code.splitlines()
insert_after = -1
df_var = "df"
indent = " "
for i, line in enumerate(lines):
if "read_hdf(" in line:
m = re.match(r"(\s*)(\w+)\s*=\s*", line)
if m:
indent = m.group(1)
df_var = m.group(2)
else:
m2 = re.match(r"(\s*)", line)
indent = m2.group(1) if m2 else " "
insert_after = i
break
if insert_after == -1:
return code
proxy_lines = [
f"{indent}# volume proxy: $volume is always 0 in FX data — use price-range as proxy",
f"{indent}if ({df_var}['$volume'] == 0).all():",
f"{indent} {df_var}['$volume'] = {df_var}['$high'] - {df_var}['$low']",
]
lines = lines[: insert_after + 1] + proxy_lines + lines[insert_after + 1 :]
self.fixes_applied.append("volume_proxy: replaced zero $volume with ($high - $low)")
return "\n".join(lines)
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 (datetime, instrument)
DataFrame fails with KeyError because those are index levels, not columns.
Correct replacement preserves BOTH dimensions so intraday calculations reset
per day:
var.groupby(['instrument', 'date'])
var.groupby([var.index.get_level_values(1), var.index.get_level_values(0).normalize()])
Single-column groupby(['instrument']) is correctly replaced with groupby(level=1).
Note: do NOT convert groupby('instrument') groupby(level=1) here that would
undo the reset_index_groupby fix which correctly emits groupby('instrument').
"""
fixed_code = code
# Variables created via reset_index() have a plain RangeIndex — applying
# get_level_values() on them would raise AttributeError. Skip those.
reset_vars = set(re.findall(r'(\w+)\s*=\s*\w[^=\n]*\.reset_index\(', fixed_code))
def _replace_two_col_groupby(m: re.Match, order: str) -> str:
var = m.group(1)
if var in reset_vars:
return m.group(0) # leave reset_index vars alone — RangeIndex, not MultiIndex
if order == "instrument_date":
repl = (
f"{var}.groupby([{var}.index.get_level_values(1), "
f"{var}.index.get_level_values(0).normalize()])"
)
else: # date_instrument
repl = (
f"{var}.groupby([{var}.index.get_level_values(0).normalize(), "
f"{var}.index.get_level_values(1)])"
)
self.fixes_applied.append(f"multiindex_groupby: {m.group(0)[:60]} → two-level")
return repl
# groupby(['instrument', 'date']) — capture variable name before .groupby
fixed_code = re.sub(
r'(\w+)\.groupby\(\[\'instrument\',\s*\'date\'\]\)',
lambda m: _replace_two_col_groupby(m, "instrument_date"),
fixed_code,
)
# groupby(['date', 'instrument'])
fixed_code = re.sub(
r'(\w+)\.groupby\(\[\'date\',\s*\'instrument\'\]\)',
lambda m: _replace_two_col_groupby(m, "date_instrument"),
fixed_code,
)
# single: groupby(['instrument']) → groupby(level=1), but not on reset_index vars
def _replace_single_instrument_groupby(m: re.Match) -> str:
# Look backwards to find the variable name
prefix = fixed_code[: m.start()]
var_match = re.search(r'(\w+)\s*$', prefix)
var = var_match.group(1) if var_match else ''
if var in reset_vars:
return m.group(0)
self.fixes_applied.append("multiindex_groupby: groupby(['instrument']) → groupby(level=1)")
return ".groupby(level=1)"
if re.search(r"\.groupby\(\['instrument'\]\)", fixed_code):
fixed_code = re.sub(r"\.groupby\(\['instrument'\]\)", _replace_single_instrument_groupby, fixed_code)
# groupby(level=['instrument', 'date']) — uses level= keyword with string names.
# 'date' is NOT a valid level name in our (datetime, instrument) MultiIndex;
# replace with get_level_values to normalize datetime to daily timestamps.
fixed_code = re.sub(
r"(\w+)\.groupby\(level=\['instrument',\s*'date'\]\)",
lambda m: (
self.fixes_applied.append(
f"multiindex_groupby: {m.group(0)[:60]} → two-level get_level_values"
)
or f"{m.group(1)}.groupby([{m.group(1)}.index.get_level_values(1), "
f"{m.group(1)}.index.get_level_values(0).normalize()])"
),
fixed_code,
)
# groupby(level=['date', 'instrument'])
fixed_code = re.sub(
r"(\w+)\.groupby\(level=\['date',\s*'instrument'\]\)",
lambda m: (
self.fixes_applied.append(
f"multiindex_groupby: {m.group(0)[:60]} → two-level get_level_values"
)
or f"{m.group(1)}.groupby([{m.group(1)}.index.get_level_values(0).normalize(), "
f"{m.group(1)}.index.get_level_values(1)])"
),
fixed_code,
)
# single: groupby(level=['instrument']) → groupby(level=1)
fixed_code = re.sub(
r"\.groupby\(level=\['instrument'\]\)",
lambda m: (self.fixes_applied.append("multiindex_groupby: groupby(level=['instrument']) → level=1") or ".groupby(level=1)"),
fixed_code,
)
return fixed_code
def _fix_chained_groupby(self, code: str) -> str:
"""
Fix two broken patterns the LLM generates when trying to group by (instrument, date):
Pattern A chained groupby (runtime AttributeError):
var.groupby(level=1).groupby('date')
var.groupby([var.index.get_level_values(1),
var.index.get_level_values(0).normalize()])
Pattern B keyword arg inside list (SyntaxError):
var.groupby([level=1, 'date'])
same two-level replacement
"""
fixed_code = code
def _two_level(var: str, tag: str) -> str:
self.fixes_applied.append(f"chained_groupby: {tag} → two-level")
return (
f"{var}.groupby([{var}.index.get_level_values(1), "
f"{var}.index.get_level_values(0).normalize()])"
)
# Pattern A: var.groupby(level=N).groupby('date')
fixed_code = re.sub(
r'(\w+)\.groupby\(level=\d+\)\.groupby\(["\']date["\']\)',
lambda m: _two_level(m.group(1), m.group(0)[:60]),
fixed_code,
)
# Pattern B: .groupby([level=N, 'date']) — SyntaxError in Python.
# The variable before .groupby may be complex (e.g. df[mask]) so we don't
# try to capture it; we use df as the index reference (always correct since
# all filtered frames share df's MultiIndex structure).
def _two_level_df(tag: str) -> str:
self.fixes_applied.append(f"chained_groupby: {tag} → two-level")
return ".groupby([df.index.get_level_values(1), df.index.get_level_values(0).normalize()])"
fixed_code = re.sub(
r'\.groupby\(\[\s*level\s*=\s*\d+\s*,\s*["\']?date["\']?\s*\]\)',
lambda m: _two_level_df(m.group(0)[:60]),
fixed_code,
)
# Also handle reversed order: ['date', level=N]
fixed_code = re.sub(
r'\.groupby\(\[\s*["\']?date["\']?\s*,\s*level\s*=\s*\d+\s*\]\)',
lambda m: _two_level_df(m.group(0)[:60]),
fixed_code,
)
return fixed_code
def _fix_rolling_ddof(self, code: str) -> str:
"""
Fix: pandas rolling() does not accept a ddof kwarg raises TypeError.
Remove ddof from both rolling(..., ddof=N) and rolling(...).std(ddof=N).
"""
fixed_code = code
# Form 1: ddof inside rolling() — .rolling(window=N, min_periods=M, ddof=K)
def _strip_ddof_from_rolling(m):
inner = re.sub(r',?\s*ddof\s*=\s*\d+', '', m.group(1))
inner = inner.strip(', ')
self.fixes_applied.append("rolling_ddof: removed ddof from rolling()")
return f'.rolling({inner})'
fixed_code = re.sub(r'\.rolling\(([^)]*ddof\s*=\s*\d+[^)]*)\)', _strip_ddof_from_rolling, fixed_code)
# Form 2: ddof inside .std() / .var() — .std(ddof=N)
if re.search(r'\.(std|var)\([^)]*ddof\s*=\s*\d+', fixed_code):
fixed_code = re.sub(r'\.(std|var)\([^)]*ddof\s*=\s*\d+[^)]*\)', r'.\1()', fixed_code)
self.fixes_applied.append("rolling_ddof: removed ddof from std()/var()")
return fixed_code
def _fix_min_periods(self, code: str) -> str:
"""
Fix: Ensure min_periods matches window size in rolling calculations.
@@ -325,6 +681,45 @@ class FactorAutoFixer:
fixed_code = fixed_code.replace(old_code, new_code)
self.fixes_applied.append(f"groupby: fixed rolling correlation (window={window}) with reset_index")
# === GENERAL FIX: DF.groupby(level=N)['col'].apply(lambda x: EXPR) ===
# apply() on a grouped Series returns a MultiIndex result (extra level prepended),
# causing index shape mismatch when assigned back to df['col'].
# Replace with transform() which preserves the original index.
col_apply_pattern = re.compile(
r"(\w+)\.groupby\(level=(\d+)\)\['([^']+)'\]\.apply\((\s*lambda\s+\w+\s*:.*?)\)",
re.DOTALL,
)
for m in list(col_apply_pattern.finditer(fixed_code)):
full = m.group(0)
df_var = m.group(1)
level = m.group(2)
col = m.group(3)
lam = m.group(4).strip()
new_expr = f"{df_var}.groupby(level={level})['{col}'].transform({lam})"
fixed_code = fixed_code.replace(full, new_expr, 1)
self.fixes_applied.append(
f"groupby: {df_var}.groupby(level={level})['{col}'].apply() → transform()"
)
# === FIX: .transform(...).reset_index(level=N, drop=True) ===
# transform() already returns the same index as the input — adding reset_index()
# after it drops an index level and causes ValueError on assignment back to df['col'].
# Detected line-by-line: if a line contains both .transform( and .reset_index(level=
reset_suffix = re.compile(r'\s*\.reset_index\s*\(\s*level\s*=[^,)]+,\s*drop\s*=\s*True\s*\)\s*$')
new_lines = []
changed = False
for line in fixed_code.splitlines():
if '.transform(' in line and '.reset_index(' in line:
cleaned = reset_suffix.sub('', line)
if cleaned != line:
new_lines.append(cleaned)
changed = True
continue
new_lines.append(line)
if changed:
fixed_code = '\n'.join(new_lines)
self.fixes_applied.append("groupby: removed spurious .reset_index() after .transform()")
# 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*\)"
@@ -161,8 +161,7 @@ class FactorFBWorkspace(FBWorkspace):
try:
subprocess.check_output(
f"{FACTOR_COSTEER_SETTINGS.python_bin} {execution_code_path}",
shell=True,
[FACTOR_COSTEER_SETTINGS.python_bin, str(execution_code_path)],
cwd=self.workspace_path,
stderr=subprocess.STDOUT,
timeout=FACTOR_COSTEER_SETTINGS.file_based_execution_timeout,
@@ -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.
@@ -6,6 +6,7 @@ Two-step validation:
2. Micro-batch testing - Runtime validation with small dataset
"""
import ast
import json
import re
import time
@@ -229,7 +230,7 @@ class LLMConfigValidator:
final_metrics = re.search(r"\{'train_runtime':[^}]+\}", stdout)
if final_metrics:
try:
metrics = eval(final_metrics.group(0)) # Safe: only numbers and strings
metrics = ast.literal_eval(final_metrics.group(0))
result["final_metrics"] = {
"train_loss": metrics.get("train_loss"),
"train_runtime": metrics.get("train_runtime"),
@@ -58,10 +58,12 @@ class ModelCodeEvaluator(CoSTEEREvaluator):
model_execution_feedback: str = "",
model_value_feedback: str = "",
):
assert isinstance(target_task, ModelTask)
assert isinstance(implementation, ModelFBWorkspace)
if gt_implementation is not None:
assert isinstance(gt_implementation, ModelFBWorkspace)
if not isinstance(target_task, ModelTask):
raise TypeError("target_task must be of type ModelTask")
if not isinstance(implementation, ModelFBWorkspace):
raise TypeError("implementation must be of type ModelFBWorkspace")
if gt_implementation is not None and not isinstance(gt_implementation, ModelFBWorkspace):
raise TypeError("gt_implementation must be of type ModelFBWorkspace")
model_task_information = target_task.get_task_information()
code = implementation.all_codes
@@ -113,10 +115,12 @@ class ModelFinalEvaluator(CoSTEEREvaluator):
model_value_feedback: str,
model_code_feedback: str,
):
assert isinstance(target_task, ModelTask)
assert isinstance(implementation, ModelFBWorkspace)
if gt_implementation is not None:
assert isinstance(gt_implementation, ModelFBWorkspace)
if not isinstance(target_task, ModelTask):
raise TypeError("target_task must be of type ModelTask")
if not isinstance(implementation, ModelFBWorkspace):
raise TypeError("implementation must be of type ModelFBWorkspace")
if gt_implementation is not None and not isinstance(gt_implementation, ModelFBWorkspace):
raise TypeError("gt_implementation must be of type ModelFBWorkspace")
system_prompt = T(".prompts:evaluator_final_feedback.system").r(
scenario=(
@@ -41,7 +41,8 @@ class ModelCoSTEEREvaluator(CoSTEEREvaluator):
final_feedback="This task has failed too many times, skip implementation.",
final_decision=False,
)
assert isinstance(target_task, ModelTask)
if not isinstance(target_task, ModelTask):
raise TypeError(f"Expected ModelTask, got {type(target_task)}")
# NOTE: Use fixed input to test the model to avoid randomness
batch_size = 8
@@ -50,7 +51,8 @@ class ModelCoSTEEREvaluator(CoSTEEREvaluator):
input_value = 0.4
param_init_value = 0.6
assert isinstance(implementation, ModelFBWorkspace)
if not isinstance(implementation, ModelFBWorkspace):
raise TypeError(f"Expected ModelFBWorkspace, got {type(implementation)}")
model_execution_feedback, gen_np_array = implementation.execute(
batch_size=batch_size,
num_features=num_features,
@@ -59,7 +61,8 @@ class ModelCoSTEEREvaluator(CoSTEEREvaluator):
param_init_value=param_init_value,
)
if gt_implementation is not None:
assert isinstance(gt_implementation, ModelFBWorkspace)
if not isinstance(gt_implementation, ModelFBWorkspace):
raise TypeError(f"Expected ModelFBWorkspace, got {type(gt_implementation)}")
_, gt_np_array = gt_implementation.execute(
batch_size=batch_size,
num_features=num_features,
@@ -85,13 +85,16 @@ def load_and_process_one_pdf_by_azure_document_intelligence(
def load_and_process_pdfs_by_azure_document_intelligence(path: Path) -> dict[str, str]:
assert RD_AGENT_SETTINGS.azure_document_intelligence_key is not None
assert RD_AGENT_SETTINGS.azure_document_intelligence_endpoint is not None
if RD_AGENT_SETTINGS.azure_document_intelligence_key is None:
raise AssertionError("azure_document_intelligence_key must be set")
if RD_AGENT_SETTINGS.azure_document_intelligence_endpoint is None:
raise AssertionError("azure_document_intelligence_endpoint must be set")
content_dict = {}
ab_path = path.resolve()
if ab_path.is_file():
assert ".pdf" in ab_path.suffixes, "The file must be a PDF file."
if ".pdf" not in ab_path.suffixes:
raise ValueError("The file must be a PDF file.")
proc = load_and_process_one_pdf_by_azure_document_intelligence
content_dict[str(ab_path)] = proc(
ab_path,
@@ -24,7 +24,8 @@ class UndirectedNode(Node):
super().__init__(content, label, embedding)
self.neighbors: set[UndirectedNode] = set()
self.appendix = appendix # appendix stores any additional information
assert isinstance(content, str), "content must be a string"
if not isinstance(content, str):
raise TypeError("content must be a string")
def add_neighbor(self, node: UndirectedNode) -> None:
self.neighbors.add(node)
@@ -96,7 +97,8 @@ class Graph(KnowledgeBase):
APIBackend().create_embedding(input_content=contents[i : i + size]),
)
assert len(nodes) == len(embeddings), "nodes' length must equals embeddings' length"
if len(nodes) != len(embeddings):
raise ValueError("nodes' length must equal embeddings' length")
for node, embedding in zip(nodes, embeddings):
node.embedding = embedding
return nodes
@@ -252,7 +254,8 @@ class UndirectedGraph(Graph):
"""
min_nodes_count = 2
assert len(nodes) >= min_nodes_count, "nodes length must >=2"
if len(nodes) < min_nodes_count:
raise ValueError("nodes length must >=2")
intersection = None
for node in nodes:
+2 -1
View File
@@ -87,7 +87,8 @@ class ModelWsLoader(WsLoader[ModelTask, ModelFBWorkspace]):
self.path = Path(path)
def load(self, task: ModelTask) -> ModelFBWorkspace:
assert task.name is not None
if task.name is None:
raise AssertionError("task.name should not be None")
mti = ModelFBWorkspace(task)
mti.prepare()
with open(self.path / f"{task.name}.py", "r") as f:
+11
View File
@@ -4,6 +4,7 @@ import functools
import importlib
import json
import multiprocessing as mp
import os
import pickle
import random
from collections.abc import Callable
@@ -208,3 +209,13 @@ def cache_with_pickle(hash_func: Callable, post_process_func: Callable | None =
return cache_wrapper
return cache_decorator
def safe_resolve_path(user_path: Path, safe_root: Path | None = None) -> Path:
if safe_root is not None:
root_real = os.path.realpath(str(safe_root.expanduser()))
path_real = os.path.realpath(str(user_path.expanduser())) # nosec B614 — validated against safe_root below
if not (path_real == root_real or path_real.startswith(root_real + os.sep)):
raise ValueError(f"Path {user_path} resolves to {path_real}, outside allowed root {safe_root}")
return Path(path_real)
return user_path.expanduser().resolve()
+22 -15
View File
@@ -27,6 +27,7 @@ Usage:
from __future__ import annotations
import json as _json
import logging
import sys
import threading
from contextlib import contextmanager
@@ -36,21 +37,24 @@ from typing import Any
from loguru import logger as _root
# ── paths ─────────────────────────────────────────────────────────────────────
# ── paths ─────────────────────────────────────────────────────────────────────────────────
LOGS_ROOT: Path = Path(__file__).parent.parent.parent / "logs"
# ── format ────────────────────────────────────────────────────────────────────
# ── format ────────────────────────────────────────────────────────────────────────────────
_FILE_FMT = (
"{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {extra[cmd]: <18} | {message}"
)
# ── internal state ─────────────────────────────────────────────────────────────
# ── internal state ─────────────────────────────────────────────────────────────────────────────
_registered: set[str] = set() # command keys that already have a file sink
_all_added: bool = False # whether the combined all.log sink is active
_llm_log_lock = threading.Lock() # guards concurrent writes to llm_calls.jsonl
# Maximum characters stored per field in llm_calls.jsonl to prevent GB-scale files.
_LLM_CALL_MAX_CHARS = 500
# ── helpers ───────────────────────────────────────────────────────────────────
# ── helpers ────────────────────────────────────────────────────────────────────────────────
def _today_dir() -> Path:
d = LOGS_ROOT / datetime.now().strftime("%Y-%m-%d")
@@ -79,7 +83,7 @@ def _banner(log, title: str, meta: dict[str, Any]) -> None:
log.info(sep)
# ── public API ────────────────────────────────────────────────────────────────
# ── public API ──────────────────────────────────────────────────────────────────────────────
def log_llm_call(
system: str | None,
@@ -88,16 +92,19 @@ def log_llm_call(
start_time: Any = None,
end_time: Any = None,
) -> None:
"""Append one complete LLM call to logs/YYYY-MM-DD/llm_calls.jsonl.
"""Append one LLM call summary to logs/YYYY-MM-DD/llm_calls.jsonl.
Prompt/response content is capped at _LLM_CALL_MAX_CHARS to prevent
GB-scale log files from long-running loops.
Each line is a self-contained JSON object so the file is grep/jq-friendly:
jq 'select(.duration_ms > 5000)' logs/2026-04-17/llm_calls.jsonl
"""
entry: dict[str, Any] = {
"ts": datetime.now().isoformat(timespec="milliseconds"),
"system": system or "",
"user": user,
"response": response,
"system": (system or "")[:_LLM_CALL_MAX_CHARS],
"user": user[:_LLM_CALL_MAX_CHARS],
"response": response[:_LLM_CALL_MAX_CHARS],
}
if start_time is not None and end_time is not None:
try:
@@ -130,13 +137,13 @@ def setup(command: str, **context: Any):
key = command.lower()
if key not in _registered:
# Per-command rotating file
_root.add(
str(log_dir / f"{key}.log"),
format=_FILE_FMT,
filter=lambda r, k=key: r["extra"].get("cmd", "").lower() == k,
rotation="00:00", # new file at midnight
retention="30 days",
rotation="50 MB",
compression="gz",
retention="7 days",
encoding="utf-8",
enqueue=True,
backtrace=False,
@@ -145,13 +152,13 @@ def setup(command: str, **context: Any):
_registered.add(key)
if not _all_added:
# Combined log — all commands
_root.add(
str(log_dir / "all.log"),
format=_FILE_FMT,
filter=lambda r: "cmd" in r["extra"],
rotation="00:00",
retention="60 days",
rotation="100 MB",
compression="gz",
retention="7 days",
encoding="utf-8",
enqueue=True,
backtrace=False,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+306
View File
@@ -0,0 +1,306 @@
import argparse
import json
import pickle # nosec
import re
import time
from pathlib import Path
import streamlit as st
from streamlit import session_state
from rdagent.log.ui.conf import UI_SETTING
from rdagent.log.utils import extract_evoid, extract_loopid_func_name
st.set_page_config(layout="wide", page_title="debug_llm", page_icon="🎓", initial_sidebar_state="expanded")
# 获取 log_path 参数
parser = argparse.ArgumentParser(description="RD-Agent Streamlit App")
parser.add_argument("--log_dir", type=str, help="Path to the log directory")
args = parser.parse_args()
def get_folders_sorted(log_path):
"""缓存并返回排序后的文件夹列表,并加入进度打印"""
with st.spinner("正在加载文件夹列表..."):
folders = sorted(
(folder for folder in log_path.iterdir() if folder.is_dir() and list(folder.iterdir())),
key=lambda folder: folder.stat().st_mtime,
reverse=True,
)
st.write(f"找到 {len(folders)} 个文件夹")
return [folder.name for folder in folders]
if UI_SETTING.enable_cache:
get_folders_sorted = st.cache_data(get_folders_sorted)
# 设置主日志路径
main_log_path = Path(args.log_dir) if args.log_dir else Path("./log")
if not main_log_path.exists():
st.error(f"Log dir {main_log_path} does not exist!")
st.stop()
if "data" not in session_state:
session_state.data = []
if "log_path" not in session_state:
session_state.log_path = None
tlist = []
def load_data():
"""加载数据到 session_state 并显示进度"""
log_file = main_log_path / session_state.log_path / "debug_llm.pkl"
try:
with st.spinner(f"正在加载数据文件 {log_file}..."):
start_time = time.time()
with open(log_file, "rb") as f:
session_state.data = pickle.load(f, encoding="utf-8") # nosec
st.success(f"数据加载完成!耗时 {time.time() - start_time:.2f}")
st.session_state["current_loop"] = 1
except Exception as e:
session_state.data = [{"error": str(e)}]
st.error(f"加载数据失败: {e}")
# UI - Sidebar
with st.sidebar:
st.markdown(":blue[**Log Path**]")
manually = st.toggle("Manual Input")
if manually:
st.text_input("log path", key="log_path", label_visibility="collapsed")
else:
folders = get_folders_sorted(main_log_path)
st.selectbox(f"**Select from {main_log_path.absolute()}**", folders, key="log_path") # nosec B608 — not SQL, Bandit false positive on "Select" in UI label
if st.button("Refresh Data"):
load_data()
st.rerun()
# Helper functions
def show_text(text, lang=None):
"""显示文本代码块"""
if lang:
st.code(text, language=lang, wrap_lines=True)
elif "\n" in text:
st.code(text, language="python", wrap_lines=True)
else:
st.code(text, language="html", wrap_lines=True)
def highlight_prompts_uri(uri):
"""高亮 URI 的格式"""
parts = uri.split(":")
return f"**{parts[0]}:**:green[**{parts[1]}**]"
# Display Data
progress_text = st.empty()
progress_bar = st.progress(0)
# 每页展示一个 Loop
LOOPS_PER_PAGE = 1
# 获取所有的 Loop ID
loop_groups = {}
for i, d in enumerate(session_state.data):
tag = d["tag"]
loop_id, _ = extract_loopid_func_name(tag)
if loop_id:
if loop_id not in loop_groups:
loop_groups[loop_id] = []
loop_groups[loop_id].append(d)
# 按 Loop ID 排序
sorted_loop_ids = sorted(loop_groups.keys(), key=int) # 假设 Loop ID 是数字
total_loops = len(sorted_loop_ids)
total_pages = total_loops # 每页展示一个 Loop
# simple display
# FIXME: Delete this simple UI if trace have tag(evo_id & loop_id)
# with st.sidebar:
# start = int(st.text_input("start", 0))
# end = int(st.text_input("end", 100))
# for m in session_state.data[start:end]:
# if "tpl" in m["tag"]:
# obj = m["obj"]
# uri = obj["uri"]
# tpl = obj["template"]
# cxt = obj["context"]
# rd = obj["rendered"]
# with st.expander(highlight_prompts_uri(uri), expanded=False, icon="⚙️"):
# t1, t2, t3 = st.tabs([":green[**Rendered**]", ":blue[**Template**]", ":orange[**Context**]"])
# with t1:
# show_text(rd)
# with t2:
# show_text(tpl, lang="django")
# with t3:
# st.json(cxt)
# if "llm" in m["tag"]:
# obj = m["obj"]
# system = obj.get("system", None)
# user = obj["user"]
# resp = obj["resp"]
# with st.expander(f"**LLM**", expanded=False, icon="🤖"):
# t1, t2, t3 = st.tabs([":green[**Response**]", ":blue[**User**]", ":orange[**System**]"])
# with t1:
# try:
# rdict = json.loads(resp)
# if "code" in rdict:
# code = rdict["code"]
# st.markdown(":red[**Code in response dict:**]")
# st.code(code, language="python", wrap_lines=True, line_numbers=True)
# rdict.pop("code")
# elif "spec" in rdict:
# spec = rdict["spec"]
# st.markdown(":red[**Spec in response dict:**]")
# st.markdown(spec)
# rdict.pop("spec")
# else:
# # show model codes
# showed_keys = []
# for k, v in rdict.items():
# if k.startswith("model_") and k.endswith(".py"):
# st.markdown(f":red[**{k}**]")
# st.code(v, language="python", wrap_lines=True, line_numbers=True)
# showed_keys.append(k)
# for k in showed_keys:
# rdict.pop(k)
# st.write(":red[**Other parts (except for the code or spec) in response dict:**]")
# st.json(rdict)
# except:
# st.json(resp)
# with t2:
# show_text(user)
# with t3:
# show_text(system or "No system prompt available")
if total_pages:
# 初始化 current_loop
if "current_loop" not in st.session_state:
st.session_state["current_loop"] = 1
# Loop 导航按钮
col1, col2, col3, col4, col5 = st.sidebar.columns([1.2, 1, 2, 1, 1.2])
with col1:
if st.button("|<"): # 首页
st.session_state["current_loop"] = 1
with col2:
if st.button("<") and st.session_state["current_loop"] > 1: # 上一页
st.session_state["current_loop"] -= 1
with col3:
# 下拉列表显示所有 Loop
st.session_state["current_loop"] = st.selectbox(
"选择 Loop",
options=list(range(1, total_loops + 1)),
index=st.session_state["current_loop"] - 1, # 默认选中当前 Loop
label_visibility="collapsed", # 隐藏标签
)
with col4:
if st.button("\>") and st.session_state["current_loop"] < total_loops: # 下一页
st.session_state["current_loop"] += 1
with col5:
if st.button("\>|"): # 最后一页
st.session_state["current_loop"] = total_loops
# 获取当前 Loop
current_loop = st.session_state["current_loop"]
# 渲染当前 Loop 数据
loop_id = sorted_loop_ids[current_loop - 1]
progress_text = st.empty()
progress_text.text(f"正在处理 Loop {loop_id}...")
progress_bar.progress(current_loop / total_loops, text=f"Loop :green[**{current_loop}**] / {total_loops}")
# 渲染 Loop Header
loop_anchor = f"Loop_{loop_id}"
if loop_anchor not in tlist:
tlist.append(loop_anchor)
st.header(loop_anchor, anchor=loop_anchor, divider="blue")
# 渲染当前 Loop 的所有数据
loop_data = loop_groups[loop_id]
for d in loop_data:
tag = d["tag"]
obj = d["obj"]
_, func_name = extract_loopid_func_name(tag)
evo_id = extract_evoid(tag)
func_anchor = f"loop_{loop_id}.{func_name}"
if func_anchor not in tlist:
tlist.append(func_anchor)
st.header(f"in *{func_name}*", anchor=func_anchor, divider="green")
evo_anchor = f"loop_{loop_id}.evo_step_{evo_id}"
if evo_id and evo_anchor not in tlist:
tlist.append(evo_anchor)
st.subheader(f"evo_step_{evo_id}", anchor=evo_anchor, divider="orange")
# 根据 tag 渲染内容
if "debug_exp_gen" in tag:
with st.expander(
f"Exp in :violet[**{obj.experiment_workspace.workspace_path}**]", expanded=False, icon="🧩"
):
st.write(obj)
elif "debug_tpl" in tag:
uri = obj["uri"]
tpl = obj["template"]
cxt = obj["context"]
rd = obj["rendered"]
with st.expander(highlight_prompts_uri(uri), expanded=False, icon="⚙️"):
t1, t2, t3 = st.tabs([":green[**Rendered**]", ":blue[**Template**]", ":orange[**Context**]"])
with t1:
show_text(rd)
with t2:
show_text(tpl, lang="django")
with t3:
st.json(cxt)
elif "debug_llm" in tag:
system = obj.get("system", None)
user = obj["user"]
resp = obj["resp"]
with st.expander(f"**LLM**", expanded=False, icon="🤖"):
t1, t2, t3 = st.tabs([":green[**Response**]", ":blue[**User**]", ":orange[**System**]"])
with t1:
try:
rdict = json.loads(resp)
if "code" in rdict:
code = rdict["code"]
st.markdown(":red[**Code in response dict:**]")
st.code(code, language="python", wrap_lines=True, line_numbers=True)
rdict.pop("code")
elif "spec" in rdict:
spec = rdict["spec"]
st.markdown(":red[**Spec in response dict:**]")
st.markdown(spec)
rdict.pop("spec")
else:
# show model codes
showed_keys = []
for k, v in rdict.items():
if k.startswith("model_") and k.endswith(".py"):
st.markdown(f":red[**{k}**]")
st.code(v, language="python", wrap_lines=True, line_numbers=True)
showed_keys.append(k)
for k in showed_keys:
rdict.pop(k)
st.write(":red[**Other parts (except for the code or spec) in response dict:**]")
st.json(rdict)
except:
st.json(resp)
with t2:
show_text(user)
with t3:
show_text(system or "No system prompt available")
progress_text.text("当前 Loop 数据处理完成!")
# Sidebar TOC
with st.sidebar:
toc = "\n".join([f"- [{t}](#{t})" if t.startswith("L") else f" - [{t.split('.')[1]}](#{t})" for t in tlist])
st.markdown(toc, unsafe_allow_html=True)
+9 -2
View File
@@ -541,7 +541,8 @@ class APIBackend(ABC):
**kwargs,
) -> str | list[list[float]]:
"""This function to share operation between embedding and chat completion"""
assert not (chat_completion and embedding), "chat_completion and embedding cannot be True at the same time"
if chat_completion and embedding:
raise ValueError("chat_completion and embedding cannot be True at the same time")
max_retry = LLM_SETTINGS.max_retry if LLM_SETTINGS.max_retry is not None else max_retry
timeout_count = 0
violation_count = 0
@@ -720,7 +721,13 @@ class APIBackend(ABC):
if finish_reason is None or finish_reason != "length":
break # we get a full response now.
new_messages.append({"role": "assistant", "content": response})
# Merge into the previous assistant message if there already is one at the end.
# Appending a second consecutive assistant message causes llama-server to return 400
# ("Cannot have 2 or more assistant messages at the end of the list").
if new_messages and new_messages[-1]["role"] == "assistant":
new_messages[-1]["content"] += response
else:
new_messages.append({"role": "assistant", "content": response})
else:
raise RuntimeError(f"Failed to continue the conversation after {try_n} retries.")
+6 -4
View File
@@ -36,16 +36,18 @@ def get_agent_model() -> OpenAIChatModel:
"""
backend = APIBackend()
assert isinstance(backend, LiteLLMAPIBackend), "Only LiteLLMAPIBackend is supported"
if not isinstance(backend, LiteLLMAPIBackend):
raise TypeError("Only LiteLLMAPIBackend is supported")
compl_kwargs = backend.get_complete_kwargs()
selected_model = compl_kwargs["model"]
_, custom_llm_provider, _, _ = get_llm_provider(selected_model)
assert (
custom_llm_provider in PROVIDER_TO_ENV_MAP
), f"Provider {custom_llm_provider} not supported. Please add it into `PROVIDER_TO_ENV_MAP`"
if custom_llm_provider not in PROVIDER_TO_ENV_MAP:
raise ValueError(
f"Provider {custom_llm_provider} not supported. Please add it into `PROVIDER_TO_ENV_MAP`"
)
prefix = PROVIDER_TO_ENV_MAP[custom_llm_provider]
api_key = os.getenv(f"{prefix}_API_KEY", None)
api_base = os.getenv(f"{prefix}_API_BASE", None)
+2 -1
View File
@@ -268,7 +268,8 @@ class JsonReducer(DataReducer):
parent[key] = sampled # type: ignore # parent 是 listkey 是 index, list.__setitem__(key, sampled)
self.sampled_files.extend([self.extract_filename(i) for i in sampled])
break
assert len(self.sampled_files) > 0
if len(self.sampled_files) <= 0:
raise AssertionError("sampled_files must contain at least one file")
return data
def _find_all_lists(
@@ -7,8 +7,10 @@ from sklearn.metrics import roc_auc_score
def prepare_for_auroc_metric(submission: pd.DataFrame, answers: pd.DataFrame, id_col: str, target_col: str) -> dict:
# Answers checks
assert id_col in answers.columns, f"answers dataframe should have an {id_col} column"
assert target_col in answers.columns, f"answers dataframe should have a {target_col} column"
if id_col not in answers.columns:
raise InvalidSubmissionError(f"answers dataframe should have an {id_col} column")
if target_col not in answers.columns:
raise InvalidSubmissionError(f"answers dataframe should have a {target_col} column")
# Submission checks
if id_col not in submission.columns:
@@ -1,7 +1,8 @@
from pathlib import Path
# Check if our submission file exists
assert Path("submission.csv").exists(), "Error: submission.csv not found"
if not Path("submission.csv").exists():
raise FileNotFoundError("Error: submission.csv not found")
submission_lines = Path("submission.csv").read_text().splitlines()
test_lines = Path("submission_test.csv").read_text().splitlines()
@@ -22,7 +22,8 @@ def prepare_for_metric(submission: pd.DataFrame, answers: pd.DataFrame) -> dict:
if "price" not in submission.columns:
raise InvalidSubmissionError("Submission DataFrame must contain 'price' columns.")
assert "price" in answers.columns, "Answers DataFrame must contain 'price' columns."
if "price" not in answers.columns:
raise InvalidSubmissionError("Answers DataFrame must contain 'price' columns.")
if len(submission) != len(answers):
raise InvalidSubmissionError("Submission must be the same length as the answers.")
@@ -1,7 +1,8 @@
from pathlib import Path
# Check if our submission file exists
assert Path("submission.csv").exists(), "Error: submission.csv not found"
if not Path("submission.csv").exists():
raise FileNotFoundError("Error: submission.csv not found")
submission_lines = Path("submission.csv").read_text().splitlines() # 自动生成的
test_lines = Path("submission_test.csv").read_text().splitlines() # test.csv
@@ -56,14 +56,17 @@ sparse.save_npz(public / "test" / "X.npz", X_test)
sparse.save_npz(public / "train" / "X.npz", X_train)
df_train.to_csv(public / "train" / "ARF_12h.csv", index=False)
assert (
X_train.shape[0] == df_train.shape[0]
), f"Mismatch: X_train rows ({X_train.shape[0]}) != df_train rows ({df_train.shape[0]})"
assert (
X_test.shape[0] == df_test.shape[0]
), f"Mismatch: X_test rows ({X_test.shape[0]}) != df_test rows ({df_test.shape[0]})"
assert df_test.shape[1] == 2, "Public test set should have 2 columns"
assert df_train.shape[1] == 3, "Public train set should have 3 columns"
assert len(df_train) + len(df_test) == len(
df_label
), "Length of new_train and new_test should equal length of old_train"
if X_train.shape[0] != df_train.shape[0]:
raise ValueError(
f"Mismatch: X_train rows ({X_train.shape[0]}) != df_train rows ({df_train.shape[0]})"
)
if X_test.shape[0] != df_test.shape[0]:
raise ValueError(
f"Mismatch: X_test rows ({X_test.shape[0]}) != df_test rows ({df_test.shape[0]})"
)
if df_test.shape[1] != 2:
raise ValueError("Public test set should have 2 columns")
if df_train.shape[1] != 3:
raise ValueError("Public train set should have 3 columns")
if len(df_train) + len(df_test) != len(df_label):
raise ValueError("Length of new_train and new_test should equal length of old_train")
@@ -25,11 +25,12 @@ def prepare(raw: Path, public: Path, private: Path):
new_test.to_csv(public / "test.csv", index=False)
# Checks
assert new_test.shape[1] == 12, "Public test set should have 12 columns"
assert new_train.shape[1] == 13, "Public train set should have 13 columns"
assert len(new_train) + len(new_test) == len(
old_train
), "Length of new_train and new_test should equal length of old_train"
if new_test.shape[1] != 12:
raise AssertionError("Public test set should have 12 columns")
if new_train.shape[1] != 13:
raise AssertionError("Public train set should have 13 columns")
if len(new_train) + len(new_test) != len(old_train):
raise AssertionError("Length of new_train and new_test should equal length of old_train")
if __name__ == "__main__":
+2 -1
View File
@@ -320,7 +320,8 @@ class DataScienceRDLoop(RDLoop):
# only clean current workspace without affecting other loops.
for k in "direct_exp_gen", "coding", "running":
if k in prev_out and prev_out[k] is not None:
assert isinstance(prev_out[k], DSExperiment)
if not isinstance(prev_out[k], DSExperiment):
raise TypeError(f"prev_out[{k!r}] must be an instance of DSExperiment")
clean_workspace(prev_out[k].experiment_workspace.workspace_path)
# Backup the workspace (only necessary files are included)
@@ -213,7 +213,8 @@ class DSTrace(Trace[DataScienceScen, KnowledgeBase]):
self, component: COMPONENT, search_list: list[tuple[DSExperiment, ExperimentFeedback]] = []
) -> bool:
for exp, fb in search_list:
assert isinstance(exp.hypothesis, DSHypothesis), "Hypothesis should be DSHypothesis (and not None)"
if not isinstance(exp.hypothesis, DSHypothesis):
raise TypeError("Hypothesis should be DSHypothesis (and not None)")
if exp.hypothesis.component == component and fb:
return True
return False
@@ -182,7 +182,7 @@ class ExpGen2Hypothesis(DSProposalV2ExpGen):
success_fb_list = list(set(trace_fbs))
logger.info(
f"Merge Hypothesis: select {len(success_fb_list)} from {len(trace_fbs)} SOTA experiments found in {len(leaves)} traces"
f"Merge Hypothesis: select {len(success_fb_list)} from {len(trace_fbs)} SOTA experiments found in {len(leaves)} traces" # nosec B608 — not SQL, Bandit false positive on "select" in log message
)
if len(success_fb_list) > 0:
@@ -377,7 +377,8 @@ class ExpGen2TraceAndMergeV2(ExpGen):
if DS_RD_SETTING.enable_multi_version_exp_gen:
exp_gen_version_list = DS_RD_SETTING.exp_gen_version_list.split(",")
for version in exp_gen_version_list:
assert version in ["v3", "v2", "v1"]
if version not in ["v3", "v2", "v1"]:
raise ValueError(f"version must be 'v1', 'v2', or 'v3', got {version!r}")
if len(trace.hist) == 0:
# set the proposal version for the first sub-trace
@@ -339,7 +339,8 @@ class DSProposalV1ExpGen(ExpGen):
eda_output = sota_exp.experiment_workspace.file_dict.get("EDA.md", None)
scenario_desc = trace.scen.get_scenario_all_desc(eda_output=eda_output)
assert sota_exp is not None, "SOTA experiment is not provided."
if sota_exp is None:
raise ValueError("SOTA experiment is not provided.")
last_exp = trace.last_exp()
# exp_and_feedback = trace.hist[-1]
# last_exp = exp_and_feedback[0]
@@ -445,8 +446,10 @@ class DSProposalV1ExpGen(ExpGen):
json_target_type=dict[str, dict[str, str | dict] | str],
)
)
assert "hypothesis_proposal" in resp_dict, "Hypothesis proposal not provided."
assert "task_design" in resp_dict, "Task design not provided."
if "hypothesis_proposal" not in resp_dict:
raise ValueError("Hypothesis proposal not provided.")
if "task_design" not in resp_dict:
raise ValueError("Task design not provided.")
task_class = component_info["task_class"]
hypothesis_proposal = resp_dict.get("hypothesis_proposal", {})
hypothesis = DSHypothesis(
@@ -1149,8 +1152,10 @@ You help users retrieve relevant knowledge from community discussions and public
)
response_dict = json.loads(response)
assert response_dict.get("component") in HypothesisComponent.__members__, f"Invalid component"
assert response_dict.get("hypothesis") is not None, f"Invalid hypothesis"
if response_dict.get("component") not in HypothesisComponent.__members__:
raise ValueError(f"Invalid component: {response_dict.get('component')}")
if response_dict.get("hypothesis") is None:
raise ValueError("Invalid hypothesis")
return response_dict
# END: for support llm-based hypothesis selection -----
@@ -1253,7 +1258,8 @@ You help users retrieve relevant knowledge from community discussions and public
description=task_desc,
)
assert isinstance(task, PipelineTask), f"Task {task_name} is not a PipelineTask, got {type(task)}"
if not isinstance(task, PipelineTask):
raise TypeError(f"Task {task_name} is not a PipelineTask, got {type(task)}")
# only for llm with response schema.(TODO: support for non-schema llm?)
# If the LLM provides a "packages" field (list[str]), compute runtime environment now and cache it for subsequent prompts in later loops.
if isinstance(task_dict, dict) and "packages" in task_dict and isinstance(task_dict["packages"], list):
@@ -1,3 +1,4 @@
import ast
import json
import os
import pickle
@@ -292,7 +293,7 @@ class ValidationSelector(SOTAexpSelector):
Sorts all valid experiments by score and returns the top N.
"""
mock_folder = f"/tmp/mock/{self.competition}"
mock_folder = f"/tmp/mock/{self.competition}" # nosec B108 — Docker volume mount point derived from internal competition name
try:
data_py_code, grade_py_code = self._prepare_validation_scripts(
@@ -539,7 +540,7 @@ def process_experiment(
# Run main script
env = get_ds_env(
extra_volumes={f"/tmp/mock/{competition}/{input_folder}": input_folder},
extra_volumes={f"/tmp/mock/{competition}/{input_folder}": input_folder}, # nosec B108 — Docker volume mount point derived from internal competition name
running_timeout_period=DS_RD_SETTING.full_timeout,
)
result = ws.run(env=env, entry="python main.py")
@@ -587,8 +588,8 @@ def _parsing_score(grade_stdout: str) -> Optional[float]:
except:
pass
try:
# Priority 2: Eval dict
return float(eval(json_str)["score"])
# Priority 2: safe literal eval for Python-style dicts
return float(ast.literal_eval(json_str)["score"])
except:
pass
try:
+4 -3
View File
@@ -35,10 +35,11 @@ def select(X: pd.DataFrame) -> pd.DataFrame:
class KGModelFeatureSelectionCoder(Developer[KGModelExperiment]):
def develop(self, exp: KGModelExperiment) -> KGModelExperiment:
target_model_type = exp.sub_tasks[0].model_type
assert target_model_type in KG_SELECT_MAPPING
if target_model_type not in KG_SELECT_MAPPING:
raise ValueError(f"target_model_type {target_model_type} not in KG_SELECT_MAPPING")
if len(exp.experiment_workspace.data_description) == 1:
code = (
Environment(undefined=StrictUndefined)
Environment(undefined=StrictUndefined) # nosec B701 — renders Python code templates, not HTML; autoescape would corrupt code
.from_string(DEFAULT_SELECTION_CODE)
.render(feature_index_list=None)
)
@@ -62,7 +63,7 @@ class KGModelFeatureSelectionCoder(Developer[KGModelExperiment]):
chosen_index_to_list_index = [i - 1 for i in chosen_index]
code = (
Environment(undefined=StrictUndefined)
Environment(undefined=StrictUndefined) # nosec B701 — renders Python code templates, not HTML; autoescape would corrupt code
.from_string(DEFAULT_SELECTION_CODE)
.render(feature_index_list=chosen_index_to_list_index)
)
@@ -165,7 +165,8 @@ class KGScenario(Scenario):
return data_info
def output_format(self, tag=None) -> str:
assert tag in [None, "feature", "model"]
if tag not in [None, "feature", "model"]:
raise ValueError(f"tag must be None, 'feature', or 'model', got {tag!r}")
feature_output_format = f"""The feature code should output following the format:
{T(".prompts:kg_feature_output_format").r()}"""
model_output_format = f"""The model code should output following the format:\n""" + T(
@@ -180,7 +181,8 @@ class KGScenario(Scenario):
return model_output_format
def interface(self, tag=None) -> str:
assert tag in [None, "feature", "XGBoost", "RandomForest", "LightGBM", "NN"]
if tag not in [None, "feature", "XGBoost", "RandomForest", "LightGBM", "NN"]:
raise ValueError(f"tag must be None, 'feature', 'XGBoost', 'RandomForest', 'LightGBM', or 'NN', got {tag!r}")
feature_interface = f"""The feature code should follow the interface:
{T(".prompts:kg_feature_interface").r()}"""
if tag == "feature":
@@ -195,7 +197,8 @@ class KGScenario(Scenario):
return model_interface
def simulator(self, tag=None) -> str:
assert tag in [None, "feature", "model"]
if tag not in [None, "feature", "model"]:
raise ValueError(f"tag must be None, 'feature', or 'model', got {tag!r}")
kg_feature_simulator = (
"The feature code will be sent to the simulator:\n" + T(".prompts:kg_feature_simulator").r()
@@ -79,12 +79,12 @@ def preprocess_script():
This method applies the preprocessing steps to the training, validation, and test datasets.
"""
if os.path.exists("/kaggle/input/X_train.pkl"):
X_train = pd.read_pickle("/kaggle/input/X_train.pkl")
X_valid = pd.read_pickle("/kaggle/input/X_valid.pkl")
y_train = pd.read_pickle("/kaggle/input/y_train.pkl")
y_valid = pd.read_pickle("/kaggle/input/y_valid.pkl")
X_test = pd.read_pickle("/kaggle/input/X_test.pkl")
others = pd.read_pickle("/kaggle/input/others.pkl")
X_train = pd.read_pickle("/kaggle/input/X_train.pkl") # nosec B301 — trusted Kaggle input
X_valid = pd.read_pickle("/kaggle/input/X_valid.pkl") # nosec B301
y_train = pd.read_pickle("/kaggle/input/y_train.pkl") # nosec B301
y_valid = pd.read_pickle("/kaggle/input/y_valid.pkl") # nosec B301
X_test = pd.read_pickle("/kaggle/input/X_test.pkl") # nosec B301
others = pd.read_pickle("/kaggle/input/others.pkl") # nosec B301
y_train = pd.Series(y_train).reset_index(drop=True)
y_valid = pd.Series(y_valid).reset_index(drop=True)
+6 -1
View File
@@ -87,7 +87,12 @@ def crawl_descriptions(
content = e.get_attribute("innerHTML")
contents.append(content)
assert len(subtitles) == len(contents) + 1 and subtitles[-1] == "Citation"
if not (len(subtitles) == len(contents) + 1 and subtitles[-1] == "Citation"):
raise AssertionError(
f"Expected len(contents)+1 == len(subtitles) and last subtitle == 'Citation', "
f"got len(subtitles)={len(subtitles)}, len(contents)={len(contents)}, "
f"last subtitle={subtitles[-1]!r}"
)
for i in range(len(subtitles) - 1):
descriptions[subtitles[i]] = contents[i]
@@ -307,7 +307,8 @@ class KGHypothesisGen(FactorAndModelHypothesisGen):
class KGHypothesis2Experiment(FactorAndModelHypothesis2Experiment):
def prepare_context(self, hypothesis: Hypothesis, trace: Trace) -> Tuple[dict, bool]:
scenario = trace.scen.get_scenario_all_desc(filtered_tag="hypothesis_and_experiment")
assert isinstance(hypothesis, KGHypothesis)
if not isinstance(hypothesis, KGHypothesis):
raise TypeError("hypothesis must be an instance of KGHypothesis")
experiment_output_format = (
T("scenarios.kaggle.prompts:feature_experiment_output_format").r()
if hypothesis.action in [KG_ACTION_FEATURE_ENGINEERING, KG_ACTION_FEATURE_PROCESSING]
+156 -30
View File
@@ -1,4 +1,6 @@
import sys
import os
import logging
from pathlib import Path
"""
Qlib Factor Runner - Executes factor backtests in Docker.
@@ -29,6 +31,83 @@ from rdagent.scenarios.qlib.experiment.model_experiment import QlibModelExperime
DIRNAME = Path(__file__).absolute().resolve().parent
DIRNAME_local = Path.cwd()
def _shift_daily_constant_factor_if_needed(factor_col: "pd.Series", factor_name: str) -> "pd.Series":
"""Detect and fix look-ahead bias in daily-constant factors.
A factor is "daily-constant" when every minute bar within the same calendar
day carries an identical value. This happens when LLM code computes a daily
aggregate (e.g. today's log return) and forward-fills it across all intraday
bars without shifting meaning the end-of-day value is visible at 00:00.
Fix: shift by one trading day so that the value assigned to day T is the
aggregate computed from day T-1, eliminating the forward-looking information.
"""
import numpy as np
try:
notnull = factor_col.dropna()
if len(notnull) < 200:
return factor_col
datetimes = notnull.index.get_level_values("datetime")
dates = datetimes.normalize()
# Sample up to 50 random days and check intra-day uniqueness
unique_dates = pd.Series(dates.unique())
sample_dates = unique_dates.sample(min(50, len(unique_dates)), random_state=42)
daily_unique_counts = []
for d in sample_dates:
mask = dates == d
vals = notnull.values[mask]
if len(vals) > 1:
daily_unique_counts.append(len(np.unique(vals[~np.isnan(vals)])))
if not daily_unique_counts:
return factor_col
# If >90% of sampled days have exactly 1 unique value → daily-constant
fraction_constant = sum(1 for c in daily_unique_counts if c == 1) / len(daily_unique_counts)
if fraction_constant < 0.90:
return factor_col # Intraday factor — no shift needed
logger.warning(
f"[LookAheadFix] Factor '{factor_name}' is daily-constant "
f"({fraction_constant:.0%} of days). Applying 1-day shift to remove look-ahead bias."
)
# Shift: for each instrument, map daily values forward by 1 trading day
instruments = factor_col.index.get_level_values("instrument").unique()
shifted_parts = []
for inst in instruments:
inst_series = factor_col.xs(inst, level="instrument")
# Get one value per calendar day (the first non-null bar)
inst_dt = inst_series.index.normalize()
daily_vals = inst_series.groupby(inst_dt).first()
# Shift by 1 day
daily_vals_shifted = daily_vals.shift(1)
# Forward-fill back to minute bars
minute_idx = inst_series.index
minute_dates = minute_idx.normalize()
shifted_minute = minute_dates.map(daily_vals_shifted)
shifted_s = pd.Series(
shifted_minute.values,
index=pd.MultiIndex.from_arrays(
[inst_series.index, [inst] * len(inst_series)],
names=["datetime", "instrument"],
),
name=factor_col.name,
)
shifted_parts.append(shifted_s)
return pd.concat(shifted_parts).sort_index()
except Exception as e:
logger.debug(f"[LookAheadFix] Could not apply daily shift for '{factor_name}': {e}")
return factor_col
# TODO: supporting multiprocessing and keep previous results
@@ -391,8 +470,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
@@ -409,6 +499,12 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
factor_col = factor_values.iloc[:, 0]
factor_name = factor_values.columns[0]
# Detect and fix look-ahead bias in daily-constant factors.
# If a factor has the same value for all minute bars within each calendar day
# it was computed from same-day data (e.g. today's close return at 00:00).
# Fix: shift by 1 trading day so value at day T = aggregate of day T-1.
factor_col = _shift_daily_constant_factor_if_needed(factor_col, factor_name)
# Load source data for forward returns
data_path = (
Path(__file__).parent.parent.parent.parent.parent
@@ -587,10 +683,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):
@@ -824,41 +922,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(
["sys.executable", "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"
@@ -866,17 +997,12 @@ 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
pass
except Exception:
logging.debug("Error in save_factor_values_to_parquet", exc_info=True)
def _log_result_warnings(self, factor_name: str, result, metrics: dict) -> None:
"""
@@ -241,6 +241,7 @@ class StrategyBuilder:
if data.get("status") == "success" and data.get("ic") is not None:
factors.append(data)
except Exception:
logger.warning("Failed to load factor file %s", f, exc_info=True)
continue
# Sort by absolute IC
+2 -1
View File
@@ -30,7 +30,8 @@ def _build_execute_calls(exp: QlibFactorExperiment, base_feature_workspaces: lis
execute_calls = []
if exp.sub_tasks:
assert isinstance(exp.prop_dev_feedback, CoSTEERMultiFeedback)
if not isinstance(exp.prop_dev_feedback, CoSTEERMultiFeedback):
raise TypeError("exp.prop_dev_feedback must be of type CoSTEERMultiFeedback")
execute_calls.extend(
(implementation.execute, ("All",))
for implementation, feedback in zip(exp.sub_workspace_list, exp.prop_dev_feedback)
@@ -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)
+30 -1
View File
@@ -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
@@ -121,6 +121,35 @@ qlib_factor_strategy: |-
assert result_df.index.names == ['datetime', 'instrument'], f"Index names must be ['datetime', 'instrument'], got {result_df.index.names}"
```
7. **NEVER use same-day aggregations as the factor value — always shift by 1 day**: If your factor computes a daily aggregate (e.g. daily close return, daily OHLC range, daily volume), that aggregate is only known at end-of-day. Using it at the start of the same day is look-ahead bias. You MUST shift the daily aggregate by 1 day before forward-filling to minute bars:
```python
# WRONG: look-ahead bias! Today's close return is not known at 00:00
daily_ret = df['$close'].groupby(level='instrument').resample('1D', level='datetime').last().pct_change()
result_df['my_factor'] = daily_ret.groupby(level='instrument').transform(lambda x: x.reindex(df.index.get_level_values('datetime'), method='ffill'))
# CORRECT: shift by 1 trading day so factor value at day T = aggregate of day T-1
daily_close = df.groupby([df.index.get_level_values('datetime').normalize(), df.index.get_level_values('instrument')])['$close'].last()
daily_close.index.names = ['date', 'instrument']
daily_ret = daily_close.groupby(level='instrument').pct_change().shift(1) # <-- shift(1) is MANDATORY
# then map back to minute bars via ffill
```
This rule applies to ALL daily aggregations: returns, OHLC stats, volume, momentum, slopes, etc.
**Session-based aggregations (London, NY, Asian session returns) are also daily aggregations** — the London
session (08:00-16:00 UTC) ends at 16:00, so its return must be shifted by 1 day before use.
Intraday rolling factors (e.g. 30-min rolling std computed at bar t using only bars t-N..t-1) do NOT need this shift.
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 (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:
<class 'pandas.core.frame.DataFrame'>
@@ -56,7 +56,8 @@ class QlibQuantScenario(Scenario):
)
def background(self, tag=None) -> str:
assert tag in [None, "factor", "model"]
if tag not in [None, "factor", "model"]:
raise ValueError(f"tag must be None, 'factor', or 'model', got {tag!r}")
quant_background = "The background of the scenario is as follows:\n" + T(".prompts:qlib_quant_background").r(
runtime_environment=self.get_runtime_environment(),
)
@@ -83,7 +84,8 @@ class QlibQuantScenario(Scenario):
return self._source_data
def output_format(self, tag=None) -> str:
assert tag in [None, "factor", "model"]
if tag not in [None, "factor", "model"]:
raise ValueError(f"tag must be None, 'factor', or 'model', got {tag!r}")
factor_output_format = (
"The factor code should output the following format:\n" + T(".prompts:qlib_factor_output_format").r()
)
@@ -99,7 +101,8 @@ class QlibQuantScenario(Scenario):
return model_output_format
def interface(self, tag=None) -> str:
assert tag in [None, "factor", "model"]
if tag not in [None, "factor", "model"]:
raise ValueError(f"tag must be None, 'factor', or 'model', got {tag!r}")
factor_interface = (
"The factor code should be written in the following interface:\n" + T(".prompts:qlib_factor_interface").r()
)
@@ -115,7 +118,8 @@ class QlibQuantScenario(Scenario):
return model_interface
def simulator(self, tag=None) -> str:
assert tag in [None, "factor", "model"]
if tag not in [None, "factor", "model"]:
raise ValueError(f"tag must be None, 'factor', or 'model', got {tag!r}")
factor_simulator = "The factor code will be sent to the simulator:\n" + T(".prompts:qlib_factor_simulator").r()
model_simulator = "The model code will be sent to the simulator:\n" + T(".prompts:qlib_model_simulator").r()
@@ -185,7 +189,8 @@ class QlibQuantScenario(Scenario):
return common_description(action) + interface(action) + output(action) + simulator(action)
def get_runtime_environment(self, tag: str = None) -> str:
assert tag in [None, "factor", "model"]
if tag not in [None, "factor", "model"]:
raise ValueError(f"tag must be None, 'factor', or 'model', got {tag!r}")
if tag is None or tag == "factor":
# Use factor env to get the runtime environment
+12 -10
View File
@@ -4,7 +4,7 @@ import shutil
from pathlib import Path
import pandas as pd
from jinja2 import Environment, StrictUndefined
from jinja2 import Environment, StrictUndefined, select_autoescape
from rdagent.components.coder.factor_coder.config import FACTOR_COSTEER_SETTINGS
from rdagent.utils.env import QTDockerEnv
@@ -21,14 +21,16 @@ def generate_data_folder_from_qlib():
entry=f"python generate.py",
)
assert (Path(__file__).parent / "factor_data_template" / "intraday_pv_all.h5").exists(), (
"intraday_pv_all.h5 is not generated. It means rdagent/scenarios/qlib/experiment/factor_data_template/generate.py is not executed correctly. Please check the log: \n"
+ execute_log
)
assert (Path(__file__).parent / "factor_data_template" / "intraday_pv_debug.h5").exists(), (
"intraday_pv_debug.h5 is not generated. It means rdagent/scenarios/qlib/experiment/factor_data_template/generate.py is not executed correctly. Please check the log: \n"
+ execute_log
)
if not (Path(__file__).parent / "factor_data_template" / "intraday_pv_all.h5").exists():
raise FileNotFoundError(
"intraday_pv_all.h5 is not generated. It means rdagent/scenarios/qlib/experiment/factor_data_template/generate.py is not executed correctly. Please check the log: \n"
+ execute_log
)
if not (Path(__file__).parent / "factor_data_template" / "intraday_pv_debug.h5").exists():
raise FileNotFoundError(
"intraday_pv_debug.h5 is not generated. It means rdagent/scenarios/qlib/experiment/factor_data_template/generate.py is not executed correctly. Please check the log: \n"
+ execute_log
)
Path(FACTOR_COSTEER_SETTINGS.data_folder).mkdir(parents=True, exist_ok=True)
shutil.copy(
@@ -67,7 +69,7 @@ def get_file_desc(p: Path, variable_list=[]) -> str:
"""
p = Path(p)
JJ_TPL = Environment(undefined=StrictUndefined).from_string("""
JJ_TPL = Environment(undefined=StrictUndefined, autoescape=select_autoescape()).from_string("""
# {{file_name}}
## File Type
@@ -1,4 +1,6 @@
import logging
import json
import os
from typing import List, Tuple
from rdagent.components.coder.factor_coder.factor import FactorExperiment, FactorTask
@@ -9,6 +11,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:
logging.debug("Exception caught", exc_info=True)
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 +60,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 +110,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."
@@ -1,3 +1,4 @@
import logging
import json
import os
import random
@@ -152,9 +153,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:
logging.debug("Error getting IC", exc_info=True)
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."
@@ -77,6 +77,7 @@ def count_valid_factors() -> int:
if data.get("status") == "success" and data.get("ic") is not None:
count += 1
except Exception:
logger.warning("Failed to load factor file %s", json_file, exc_info=True)
continue
return count
@@ -71,7 +71,7 @@ def submit_for_grading(grading_url: str, model_path: str) -> dict | None:
def main():
MODEL_PATH = os.environ.get("MODEL_PATH")
DATA_PATH = os.environ.get("DATA_PATH")
OUTPUT_DIR = os.environ.get("OUTPUT_DIR", "/tmp/autorl_output")
OUTPUT_DIR = os.environ.get("OUTPUT_DIR", "/tmp/autorl_output") # nosec B108 — Docker container output dir, configurable via env var
GRADING_SERVER_URL = os.environ.get("GRADING_SERVER_URL", "")
TRAIN_RATIO = float(os.environ.get("TRAIN_RATIO", "0.05"))
NUM_EPOCHS = int(os.environ.get("NUM_EPOCHS", "3"))
@@ -9,7 +9,7 @@ peft>=0.18.1
# Evaluation
opencompass==0.5.1
setuptools<75 # uv venv doesn't include, opencompass depends on pkg_resources
setuptools>=78.1.1 # Security fix: GHSA-8g6x-3r52-4m6c (path traversal in PackageIndex.download, arbitrary file write/RCE)
# Inference acceleration (optional, TRL supports 0.10.2-0.12.0)
# Security: Version >=0.14.0 fixes CVE-2026-22807 (RCE via auto_map dynamic module loading)
+4 -3
View File
@@ -9,7 +9,7 @@ from pathlib import Path
from typing import Any
import yaml
from jinja2 import Environment, FunctionLoader, StrictUndefined
from jinja2 import Environment, FunctionLoader, StrictUndefined, select_autoescape
from rdagent.core.conf import RD_AGENT_SETTINGS
from rdagent.log import rdagent_logger as logger
@@ -38,7 +38,8 @@ def load_content(uri: str, caller_dir: Path | None = None, ftype: str = "yaml")
caller_dir = get_caller_dir(upshift=1)
# Parse the URI
path_part, *yaml_trace = uri.split(":")
assert len(yaml_trace) <= 1, f"Invalid uri {uri}, only one yaml trace is allowed."
if len(yaml_trace) > 1:
raise ValueError(f"Invalid uri {uri}, only one yaml trace is allowed.")
yaml_trace = [key for yt in yaml_trace for key in yt.split(".")]
# load file_path with priorities.
@@ -126,7 +127,7 @@ class RDAT:
# loader=FunctionLoader(load_conent) is for supporting grammar like below.
# `{% include "scenarios.data_science.share:component_spec.DataLoadSpec" %}`
rendered = (
Environment(undefined=StrictUndefined, loader=FunctionLoader(load_content))
Environment(undefined=StrictUndefined, loader=FunctionLoader(load_content), autoescape=select_autoescape())
.from_string(self.template)
.render(**context)
.strip("\n")
+29 -26
View File
@@ -614,13 +614,14 @@ class LocalEnv(Env[ASpecificLocalConf]):
if self.conf.extra_volumes is not None:
for lp, rp in self.conf.extra_volumes.items():
volumes[lp] = rp["bind"] if isinstance(rp, dict) else rp
cache_path = "/tmp/sample" if "/sample/" in "".join(self.conf.extra_volumes.keys()) else "/tmp/full"
cache_path = "/tmp/sample" if "/sample/" in "".join(self.conf.extra_volumes.keys()) else "/tmp/full" # nosec B108 — fixed Docker volume mount point, not a user-writable temp file
Path(cache_path).mkdir(parents=True, exist_ok=True)
volumes[cache_path] = T("scenarios.data_science.share:scen.cache_path").r()
for lp, rp in running_extra_volume.items():
volumes[lp] = rp
assert local_path is not None, "local_path should not be None"
if local_path is None:
raise ValueError("local_path should not be None")
volumes = normalize_volumes(volumes, local_path)
@contextlib.contextmanager
@@ -678,7 +679,7 @@ class LocalEnv(Env[ASpecificLocalConf]):
cwd = Path(local_path).resolve() if local_path else None
env = {k: str(v) if isinstance(v, int) else v for k, v in env.items()}
process = subprocess.Popen(
process = subprocess.Popen( # nosec B602 — entry is an internal command string set by LocalEnvConf, not user input
entry,
cwd=cwd,
env={**os.environ, **env},
@@ -761,12 +762,15 @@ class CondaConf(LocalConf):
to ensure bin_path is set correctly even if the conda env was just created.
"""
conda_path_result = subprocess.run(
f"conda run -n {self.conda_env_name} --no-capture-output env | grep '^PATH='",
["conda", "run", "-n", self.conda_env_name, "--no-capture-output", "env"],
capture_output=True,
text=True,
shell=True,
)
self.bin_path = conda_path_result.stdout.strip().split("=")[1] if conda_path_result.returncode == 0 else ""
if conda_path_result.returncode == 0:
path_lines = [l for l in conda_path_result.stdout.splitlines() if l.startswith("PATH=")]
self.bin_path = path_lines[0].split("=", 1)[1] if path_lines else ""
else:
self.bin_path = ""
class MLECondaConf(CondaConf):
@@ -850,24 +854,22 @@ class QlibCondaEnv(LocalEnv[QlibCondaConf]):
def prepare(self) -> None:
"""Prepare the conda environment if not already created."""
try:
envs = subprocess.run("conda env list", capture_output=True, text=True, shell=True)
envs = subprocess.run(["conda", "env", "list"], capture_output=True, text=True)
if self.conf.conda_env_name not in envs.stdout:
print(f"[yellow]Conda env '{self.conf.conda_env_name}' not found, creating...[/yellow]")
subprocess.check_call(
f"conda create -y -n {self.conf.conda_env_name} python=3.10",
shell=True,
["conda", "create", "-y", "-n", self.conf.conda_env_name, "python=3.10"],
)
subprocess.check_call(
f"conda run -n {self.conf.conda_env_name} pip install --upgrade pip cython",
shell=True,
["conda", "run", "-n", self.conf.conda_env_name, "pip", "install", "--upgrade", "pip", "cython"],
)
subprocess.check_call(
f"conda run -n {self.conf.conda_env_name} pip install git+https://github.com/microsoft/qlib.git@2fb9380b342556ddb50a4b24e4fe8655d548b2b8",
shell=True,
["conda", "run", "-n", self.conf.conda_env_name, "pip", "install",
"git+https://github.com/microsoft/qlib.git@2fb9380b342556ddb50a4b24e4fe8655d548b2b8"],
)
subprocess.check_call(
f"conda run -n {self.conf.conda_env_name} pip install catboost xgboost tables torch",
shell=True,
["conda", "run", "-n", self.conf.conda_env_name, "pip", "install",
"catboost", "xgboost", "tables", "torch"],
)
except Exception as e:
@@ -888,10 +890,9 @@ def _sync_conda_cache_with_real_envs() -> None:
"""Ensure the prepared cache includes environments that already exist on disk."""
try:
result = subprocess.run(
"conda env list",
["conda", "env", "list"],
capture_output=True,
text=True,
shell=True,
check=False,
)
except Exception as exc: # pragma: no cover - best-effort helper
@@ -924,14 +925,15 @@ def _prepare_conda_env(env_name: str, requirements_file: Path, python_version: s
python_version: Python version for the environment
"""
# 1. Create conda environment if not exists
result = subprocess.run(f"conda env list | grep -q '^{env_name} '", shell=True)
if result.returncode != 0:
env_list = subprocess.run(["conda", "env", "list"], capture_output=True, text=True, check=False)
env_exists = any(line.split()[0] == env_name for line in env_list.stdout.splitlines() if line and not line.startswith("#"))
if not env_exists:
print(f"[yellow]Creating conda env '{env_name}' (Python {python_version})...[/yellow]")
subprocess.check_call(f"conda create -y -n {env_name} python={python_version}", shell=True)
subprocess.check_call(f"conda run -n {env_name} pip install --upgrade pip", shell=True)
subprocess.check_call(["conda", "create", "-y", "-n", env_name, f"python={python_version}"])
subprocess.check_call(["conda", "run", "-n", env_name, "pip", "install", "--upgrade", "pip"])
print(f"[yellow]Installing dependencies from {requirements_file.name}...[/yellow]")
subprocess.check_call(f"conda run -n {env_name} pip install -r {requirements_file}", shell=True)
subprocess.check_call(["conda", "run", "-n", env_name, "pip", "install", "-r", str(requirements_file)])
print(f"[green]Conda env '{env_name}' ready[/green]")
_CONDA_ENV_PREPARED.add(env_name)
@@ -971,8 +973,8 @@ class FTCondaEnv(LocalEnv[FTCondaConf]):
# Note: flash-attn>=2.8 is required for B200 (sm_100) support
print("[yellow]Installing flash-attn (compiling, may take a few minutes)...[/yellow]")
subprocess.check_call(
f"conda run -n {self.conf.conda_env_name} pip install 'flash-attn>=2.8' --no-build-isolation --no-cache-dir",
shell=True,
["conda", "run", "-n", self.conf.conda_env_name, "pip", "install",
"flash-attn>=2.8", "--no-build-isolation", "--no-cache-dir"],
)
# Re-update bin_path after prepare() in case the conda env was just created
@@ -1442,7 +1444,7 @@ class DockerEnv(Env[DockerConf]):
if self.conf.extra_volumes is not None:
for lp, rp in self.conf.extra_volumes.items():
volumes[lp] = rp if isinstance(rp, dict) else {"bind": rp, "mode": self.conf.extra_volume_mode}
cache_path = "/tmp/sample" if "/sample/" in "".join(self.conf.extra_volumes.keys()) else "/tmp/full"
cache_path = "/tmp/sample" if "/sample/" in "".join(self.conf.extra_volumes.keys()) else "/tmp/full" # nosec B108 — fixed Docker volume mount point, not a user-writable temp file
Path(cache_path).mkdir(parents=True, exist_ok=True)
volumes[cache_path] = {
"bind": T("scenarios.data_science.share:scen.cache_path").r(),
@@ -1471,7 +1473,8 @@ class DockerEnv(Env[DockerConf]):
cpu_count=self.conf.cpu_count, # Set CPU limit
**self._gpu_kwargs(client),
)
assert container is not None # Ensure container was created successfully
if container is None:
raise AssertionError("Docker container was not created successfully")
logs = container.logs(stream=True)
print(Rule("[bold green]Docker Logs Begin[/bold green]", style="dark_orange"))
table = Table(title="Run Info", show_header=False)
+5
View File
@@ -270,6 +270,11 @@ class LoopBase:
msg = "We have reset the loop instance, stop all the routines and resume."
raise self.LoopResumeError(msg) from e
else:
# Do NOT advance step_idx for unhandled exceptions (e.g. LoopResumeError
# propagating from _propose). Keeping step_idx at the current step lets
# kickoff_loop retry step 0 on the next resume instead of permanently
# corrupting the loop with a missing direct_exp_gen result.
step_forward = False
raise # re-raise unhandled exceptions
finally:
# No matter the execution succeed or not, we have to finish the following steps
+2 -1
View File
@@ -30,7 +30,8 @@ def wait_retry(
>>> counter
2
"""
assert retry_n > 0, "retry_n should be greater than 0"
if retry_n <= 0:
raise ValueError("retry_n should be greater than 0")
def decorator(f: Callable[..., ASpecificRet]) -> Callable[..., ASpecificRet]:
def wrapper(*args: Any, **kwargs: Any) -> ASpecificRet:
+2 -1
View File
@@ -84,7 +84,8 @@ class WorkflowTracker:
# Log timer status if timer is started
if self.loop_base.timer.started:
remain_time = self.loop_base.timer.remain_time()
assert remain_time is not None
if remain_time is None:
raise AssertionError("remain_time should not be None")
mlflow.log_metric("remain_time", remain_time.total_seconds())
mlflow.log_metric(
"remain_percent",
+4 -4
View File
@@ -9,8 +9,8 @@ psutil
fire
fuzzywuzzy
openai
litellm>=1.73 # to support `from litellm import get_valid_models`
aiohttp>=3.13.4 # CVE-2026-22815, CVE-2026-34515, CVE-2026-34516, CVE-2026-34525
litellm>=1.83.14 # to support `from litellm import get_valid_models`
aiohttp>=3.13.4 # CVE-2026-22815, CVE-2026-34515, CVE-2026-34516, CVE-2026-34525; >=3.13.4 due to litellm==1.83.14 exact pin
azure.identity
pyarrow
rich
@@ -98,8 +98,8 @@ optuna>=3.5.0
beautifulsoup4>=4.12.0
# ML Training Pipeline
lightgbm>=3.3.0
scipy>=1.9.0
lightgbm>=3.3.5
scipy>=1.15.3
# RL Trading (optional - system works without these)
# Install for full RL training: pip install stable-baselines3[extra] gymnasium
+1 -1
View File
@@ -8,7 +8,7 @@
# Only install if you want to use full PPO/A2C/SAC training.
# Core RL library
stable-baselines3[extra]>=2.0.0
stable-baselines3[extra]>=2.8.0
# Gymnasium environment (OpenAI Gym successor)
gymnasium>=0.29.0
+50
View File
@@ -198,6 +198,55 @@ def scan_factors(workspace_dir: Path, skip_evaluated: bool = True) -> List[Facto
return factors
# ---------------------------------------------------------------------------
# Look-ahead bias detection for daily-constant factors
# ---------------------------------------------------------------------------
def _shift_daily_constant_factor_if_needed(factor_col: "pd.Series", factor_name: str) -> "pd.Series":
"""Detect daily-constant factors (look-ahead bias) and shift by 1 trading day."""
sample_days = factor_col.index.get_level_values("datetime").normalize().unique()
if len(sample_days) < 10:
return factor_col
rng = np.random.default_rng(42)
days_to_check = rng.choice(sample_days, size=min(50, len(sample_days)), replace=False)
constant_count = 0
for day in days_to_check:
day_mask = factor_col.index.get_level_values("datetime").normalize() == day
day_vals = factor_col[day_mask].dropna()
if len(day_vals) == 0:
continue
if day_vals.nunique() == 1:
constant_count += 1
fraction_constant = constant_count / len(days_to_check)
if fraction_constant < 0.90:
return factor_col
# Shift by 1 trading day per instrument
import logging
logging.getLogger(__name__).info(
"Factor '%s' is %.0f%% daily-constant — shifting 1 trading day to fix look-ahead bias",
factor_name, fraction_constant * 100,
)
instruments = factor_col.index.get_level_values("instrument").unique() if "instrument" in factor_col.index.names else [None]
shifted_parts = []
for instr in instruments:
if instr is not None:
mask = factor_col.index.get_level_values("instrument") == instr
col_instr = factor_col[mask]
else:
col_instr = factor_col
dates = col_instr.index.get_level_values("datetime").normalize()
trading_days = dates.unique().sort_values()
day_first = col_instr.groupby(dates).first()
day_first_shifted = day_first.shift(1)
day_first_shifted.index = pd.to_datetime(day_first_shifted.index)
day_map = day_first_shifted.reindex(pd.to_datetime(trading_days)).values
new_vals = pd.Series(
day_map[np.searchsorted(trading_days.values, dates.values)],
index=col_instr.index,
)
shifted_parts.append(new_vals)
return pd.concat(shifted_parts).sort_index()
# ---------------------------------------------------------------------------
# Factor evaluator
# ---------------------------------------------------------------------------
@@ -263,6 +312,7 @@ def evaluate_factor_full(factor: FactorInfo, full_data: pd.DataFrame,
result = pd.read_hdf(str(result_file), key="data")
total_count = len(result)
factor_val = result.iloc[:, 0]
factor_val = _shift_daily_constant_factor_if_needed(factor_val, factor.factor_name)
non_null_count = factor_val.notna().sum()
if non_null_count < 1000:
+21 -8
View File
@@ -250,7 +250,7 @@ Hard requirements:
- NO global mean/std always use rolling(window).mean() with shift(1) to avoid look-ahead bias"""
else:
system_prompt = f"""You are a quantitative trading expert specializing in EUR/USD intraday strategies.
system_prompt = f"""You are a quantitative trading expert specializing in EUR/USD daily swing strategies.
CRITICAL RULES for {STYLE_DESC} (forward horizon: {FORWARD_BARS} bars = ~{FORWARD_BARS/60:.1f} hours):
1. ONLY use the factors listed below - no others!
@@ -258,15 +258,27 @@ CRITICAL RULES for {STYLE_DESC} (forward horizon: {FORWARD_BARS} bars = ~{FORWAR
3. Create a pandas Series called 'signal' with values: 1 (long), -1 (short), 0 (neutral)
4. signal.index MUST match close.index
5. signal.name must be 'signal'
6. IMPORTANT: factors are DAILY values broadcast to every 1-minute bar they change once per day.
Use daily-level logic: compare today's factor value to a rolling daily mean (window 5-20 DAYS).
To get daily rolling mean: group by date, take first value per day, compute rolling, then reindex back.
Example: dates = factors[col].index.get_level_values('datetime').normalize()
daily_vals = factors[col].groupby(dates).first()
daily_mean = daily_vals.rolling(10).mean().shift(1)
daily_signal = (daily_vals > daily_mean).astype(int) * 2 - 1
signal = daily_signal.reindex(dates).values (broadcast back to minute bars)
7. The signal should change roughly once per day this produces ~250-500 trades over 6 years.
8. Keep conditions SIMPLE: one factor above/below its N-day rolling average. Avoid combining 3+ conditions.
Output ONLY valid JSON with these fields:
{{"strategy_name": "short_name", "factor_names": ["f1", "f2"], "description": "one sentence", "code": "python code"}}"""
user_prompt = f"""Create a EUR/USD trading strategy using these factors:
user_prompt = f"""Create a EUR/USD SWING trading strategy (hold ~{FORWARD_BARS/60:.0f} hours) using these factors:
{factor_list}
{f'Previous feedback: {feedback}' if feedback else 'First attempt - be creative!'}"""
{f'Previous feedback: {feedback}' if feedback else 'First attempt - be creative!'}
Use daily-level signal logic (factor above/below rolling daily mean). Signal changes once per day."""
api = APIBackend()
response = api.build_messages_and_create_chat_completion(
@@ -371,8 +383,8 @@ signal.fillna(0).to_pickle('signal.pkl')
txn_cost_bps=TXN_COST_BPS,
forward_returns=fwd_returns,
oos_start=OOS_START_DEFAULT,
wf_rolling=True,
mc_n_permutations=200,
wf_rolling=False, # too slow on 2M bars — run via rebacktest script instead
mc_n_permutations=50,
)
# ============================================================================
@@ -579,7 +591,7 @@ def main(target_count=10):
# Check acceptance criteria — OOS must be profitable + statistically significant
mc_ok = mc_pvalue is None or mc_pvalue < 0.20 # lenient: top 20% non-random
wf_ok = wf_consistency is None or wf_consistency >= 0.5 # ≥50% of WF windows profitable
if (abs(ic) > MIN_IC and sharpe > MIN_SHARPE and trades > MIN_TRADES and dd > MAX_DRAWDOWN
if (abs(ic or 0) > MIN_IC and sharpe > MIN_SHARPE and trades > MIN_TRADES and dd > MAX_DRAWDOWN
and oos_sharpe > 0.0 and oos_monthly > 0.0 and mc_ok and wf_ok):
# ACCEPT
strategy['real_backtest'] = bt_result
@@ -636,9 +648,10 @@ def main(target_count=10):
oos_info = f"OOS_Sharpe={oos_sharpe:+.2f} OOS_Mon={oos_monthly:+.2f}%" if oos_sharpe is not None else ""
mc_info = f" MC_p={mc_pvalue:.2f}" if mc_pvalue is not None else ""
wf_info = f" WF_consistency={wf_consistency:.0%}" if wf_consistency is not None else ""
_log.info(f"REJECTED IC={ic:.4f} Sharpe={sharpe:.2f} Trades={trades} DD={dd:.1%} {oos_info}{mc_info}{wf_info}")
_ic = ic or 0; _sh = sharpe or 0; _dd = dd or 0
_log.info(f"REJECTED IC={_ic:.4f} Sharpe={_sh:.2f} Trades={trades} DD={_dd:.1%} {oos_info}{mc_info}{wf_info}")
feedback_history.append(
f"Failed: IC={ic:.4f}, Sharpe={sharpe:.2f}, Trades={trades}, DD={dd:.1%}, "
f"Failed: IC={_ic:.4f}, Sharpe={_sh:.2f}, Trades={trades}, DD={_dd:.1%}, "
f"OOS_Sharpe={oos_sharpe:+.2f}, OOS_Monthly={oos_monthly:+.2f}%"
+ (f", MC_p={mc_pvalue:.2f}" if mc_pvalue is not None else "")
+ (f", WF_consistency={wf_consistency:.0%}" if wf_consistency is not None else "")
+249
View File
@@ -0,0 +1,249 @@
"""Tests for FactorAutoFixer — the pre-execution code patcher."""
import pytest
from rdagent.components.coder.factor_coder.auto_fixer import FactorAutoFixer
@pytest.fixture()
def fixer():
return FactorAutoFixer()
class TestResetIndexGroupby:
def test_replaces_level_groupby_on_reset_var(self, fixer):
code = "df_r = df.reset_index()\ndf_r['x'] = df_r.groupby(level=1)['$close'].mean()"
result = fixer.fix(code)
assert "groupby('instrument')" in result
def test_does_not_touch_normal_multiindex_groupby(self, fixer):
code = "df['x'] = df.groupby(level=1)['$close'].mean()"
result = fixer.fix(code)
assert "groupby(level=1)" in result
class TestGroupbyMixedLevels:
def test_strips_string_from_mixed_list(self, fixer):
result = fixer.fix("df.groupby(level=[1, 'date']).apply(fn)")
assert "groupby(level=1)" in result
def test_multiple_ints_kept(self, fixer):
result = fixer.fix("df.groupby(level=[0, 1, 'x']).apply(fn)")
assert "groupby(level=[0, 1])" in result
class TestGroupbyColumnOnMultiindex:
def test_instrument_date_becomes_two_level(self, fixer):
code = "df['v'] = df.groupby(['instrument', 'date'])['$volume'].cumsum()"
result = fixer.fix(code)
assert "get_level_values(1)" in result
assert "normalize()" in result
assert "level=1)" not in result.split("get_level_values")[0]
def test_date_instrument_becomes_two_level(self, fixer):
code = "df['v'] = df.groupby(['date', 'instrument'])['$volume'].cumsum()"
result = fixer.fix(code)
assert "get_level_values(0).normalize()" in result
assert "get_level_values(1)" in result
def test_single_instrument_becomes_level1(self, fixer):
result = fixer.fix("df.groupby(['instrument'])['x'].mean()")
assert "groupby(level=1)" in result
def test_reset_index_not_double_fixed(self, fixer):
# After reset_index fix emits groupby('instrument'), this fixer must NOT
# convert that to groupby(level=1).
code = "df_r = df.reset_index()\ndf_r['x'] = df_r.groupby(level=1)['p'].mean()"
result = fixer.fix(code)
assert "groupby('instrument')" in result
class TestChainedGroupby:
def test_chained_groupby_level_then_date(self, fixer):
code = "df.groupby(level=1).groupby('date')['price_volume'].transform('cumsum')"
result = fixer.fix(code)
assert "get_level_values(1)" in result
assert "get_level_values(0).normalize()" in result
assert ".groupby('date')" not in result
def test_chained_groupby_with_double_quotes(self, fixer):
code = 'df.groupby(level=0).groupby("date")["col"].sum()'
result = fixer.fix(code)
assert "get_level_values" in result
assert '.groupby("date")' not in result
def test_list_with_level_keyword_syntax_error(self, fixer):
# groupby([level=1, 'date']) is a SyntaxError — must be fixed before execution
code = "asian_vol = df[mask].groupby([level=1, 'date'])['log_return'].std()"
result = fixer.fix(code)
assert "get_level_values(1)" in result
assert "normalize()" in result
assert "level=1," not in result
def test_list_with_level_keyword_reversed(self, fixer):
code = "df.groupby(['date', level=1])['x'].mean()"
result = fixer.fix(code)
assert "get_level_values" in result
assert "level=1" not in result
class TestMinPeriodsNotTouched:
def test_small_min_periods_preserved(self, fixer):
# _fix_min_periods is disabled — LLM-set min_periods must not be changed.
# window=60, min_periods=1 should stay as-is (was wrongly raised to 60 before).
result = fixer.fix("df.groupby(level=1)['x'].transform(lambda x: x.rolling(window=60, min_periods=1).mean())")
assert "min_periods=1" in result
def test_large_window_min_periods_preserved(self, fixer):
# window=240 > 96 bars/day: if min_periods were set to 240 the output would be
# all-NaN for intraday data. Verify we leave it untouched.
result = fixer.fix("df['x'] = df.groupby(level=1)['y'].transform(lambda x: x.rolling(240, min_periods=10).std())")
assert "min_periods=10" in result
class TestInstrumentColumnAccess:
def test_instrument_column_replaced(self, fixer):
code = "df['group_key'] = df['instrument'] + '_' + df['day_id'].astype(str)"
result = fixer.fix(code)
assert "df.index.get_level_values(1)" in result
assert "df['instrument']" not in result
def test_reset_index_var_not_touched(self, fixer):
# After reset_index, 'instrument' IS a real column — must not be replaced
code = "df_r = df.reset_index()\nval = df_r['instrument'].unique()"
result = fixer.fix(code)
assert "df_r['instrument']" in result
assert "get_level_values" not in result
def test_groupby_after_instrument_fix(self, fixer):
# Combined: df['instrument'] in a groupby context
code = "df['key'] = df['instrument']\nout = df.groupby(df['key'])[['$close']].mean()"
result = fixer.fix(code)
assert "df['instrument']" not in result
def test_assignment_target_not_touched(self, fixer):
# df['instrument'] = <expr> is an assignment — must NOT be converted to
# df.index.get_level_values(1) = <expr> (SyntaxError)
code = "df['instrument'] = df.index.get_level_values('instrument')"
result = fixer.fix(code)
assert "df['instrument'] =" in result
class TestInstrumentLocMultiindex:
def test_loc_replaced_with_xs(self, fixer):
code = (
"for instrument in df.index.get_level_values('instrument').unique():\n"
" inst_df = df.loc[instrument].copy()\n"
)
result = fixer.fix(code)
assert "df.xs(instrument, level=1)" in result
assert "df.loc[instrument]" not in result
def test_loc_replaced_with_level1_int(self, fixer):
code = (
"for inst in df.index.get_level_values(1).unique():\n"
" data = df.loc[inst]\n"
)
result = fixer.fix(code)
assert "df.xs(inst, level=1)" in result
def test_loc_assignment_not_touched(self, fixer):
# Write-back df.loc[instrument] = ... must not be changed
code = (
"for instrument in df.index.get_level_values('instrument').unique():\n"
" df.loc[instrument] = modified\n"
)
result = fixer.fix(code)
assert "df.loc[instrument] = modified" in result
def test_non_instrument_loop_not_touched(self, fixer):
# for-loop not related to instrument levels must not be changed
code = "for date in dates:\n sub = df.loc[date]\n"
result = fixer.fix(code)
assert "df.loc[date]" in result
class TestGroupbyLevelStringNames:
def test_level_instrument_date_replaced(self, fixer):
code = "df.groupby(level=['instrument', 'date'])['col'].transform('sum')"
result = fixer.fix(code)
assert "get_level_values(1)" in result
assert "get_level_values(0).normalize()" in result
assert "level=['instrument', 'date']" not in result
def test_level_date_instrument_replaced(self, fixer):
code = "data.groupby(level=['date', 'instrument'])['x'].mean()"
result = fixer.fix(code)
assert "get_level_values(0).normalize()" in result
assert "get_level_values(1)" in result
def test_level_instrument_single_replaced(self, fixer):
code = "df.groupby(level=['instrument'])['vol'].sum()"
result = fixer.fix(code)
assert "groupby(level=1)" in result
assert "level=['instrument']" not in result
class TestGroupbyApplyToTransform:
def test_col_apply_lambda_replaced(self, fixer):
code = "df_overlap.groupby(level=1)['$close'].apply(lambda x: np.log(x / x.shift(1)))"
result = fixer.fix(code)
assert ".transform(" in result
assert ".apply(" not in result
def test_col_apply_lambda_preserves_lambda_body(self, fixer):
code = "series.groupby(level=1)['ret'].apply(lambda x: x.cumsum())"
result = fixer.fix(code)
assert "lambda x: x.cumsum()" in result
assert ".transform(" in result
def test_transform_reset_index_stripped(self, fixer):
# .transform() already preserves index — .reset_index() after it is wrong
code = "df['v'] = df.groupby(level=1)['x'].transform(lambda x: x.rolling(20).mean()).reset_index(level=0, drop=True)"
result = fixer.fix(code)
assert ".reset_index(level=0, drop=True)" not in result
assert ".transform(" in result
class TestZeroVolumeProxy:
def test_injects_proxy_when_volume_used(self, fixer):
code = (
"def calc():\n"
" df = pd.read_hdf('data.h5', key='data')\n"
" df['pv'] = df['$close'] * df['$volume']\n"
" return df[['pv']]\n"
)
result = fixer.fix(code)
assert "volume proxy" in result
assert "df['$volume'] = df['$high'] - df['$low']" in result
# Proxy must come right after read_hdf line
lines = result.splitlines()
hdf_idx = next(i for i, l in enumerate(lines) if "read_hdf" in l)
assert "volume proxy" in lines[hdf_idx + 1]
def test_no_injection_when_volume_absent(self, fixer):
code = "df = pd.read_hdf('data.h5', key='data')\ndf['x'] = df['$close'].pct_change()\n"
result = fixer.fix(code)
assert "volume proxy" not in result
def test_no_double_injection(self, fixer):
code = (
"def calc():\n"
" df = pd.read_hdf('data.h5', key='data')\n"
" # volume proxy: $volume is always 0 in FX data — use price-range as proxy\n"
" if (df['$volume'] == 0).all():\n"
" df['$volume'] = df['$high'] - df['$low']\n"
" df['pv'] = df['$close'] * df['$volume']\n"
)
result = fixer.fix(code)
assert result.count("volume proxy") == 1
class TestRollingDdof:
def test_removes_ddof_from_rolling_args(self, fixer):
result = fixer.fix("df.rolling(20, min_periods=1, ddof=1).std()")
assert "ddof" not in result
def test_removes_ddof_from_std_args(self, fixer):
result = fixer.fix("df.rolling(20).std(ddof=1)")
assert "ddof" not in result