Compare commits

...

43 Commits

Author SHA1 Message Date
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
53 changed files with 3674 additions and 257 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
release-please: release-please:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: googleapis/release-please-action@v4 - uses: googleapis/release-please-action@v5
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
config-file: release-please-config.json config-file: release-please-config.json
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
".": "1.3.3" ".": "1.3.9"
} }
+79
View File
@@ -1,5 +1,84 @@
# Changelog # Changelog
## [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) ## [1.3.3](https://github.com/TPTBusiness/Predix/compare/v1.3.2...v1.3.3) (2026-04-25)
+7
View File
@@ -18,6 +18,8 @@ load_dotenv(Path(__file__).parent / ".env")
import typer import typer
from rich.console import Console from rich.console import Console
from rdagent.utils.env import logger
app = typer.Typer(help="Predix - AI Quantitative Trading Agent") app = typer.Typer(help="Predix - AI Quantitative Trading Agent")
console = Console() console = Console()
@@ -510,6 +512,7 @@ def top(
if data.get("status") == "success" and data.get("ic") is not None: if data.get("status") == "success" and data.get("ic") is not None:
results.append(data) results.append(data)
except Exception: except Exception:
logger.warning("Failed to load factor file %s", f, exc_info=True)
continue continue
if not results: if not results:
@@ -659,6 +662,7 @@ def portfolio(
if data.get("status") == "success" and data.get("ic") is not None: if data.get("status") == "success" and data.get("ic") is not None:
results.append(data) results.append(data)
except Exception: except Exception:
logger.warning("Failed to load factor file %s", f, exc_info=True)
continue continue
if not results: if not results:
@@ -956,6 +960,7 @@ def portfolio_simple(
if data.get("status") == "success" and data.get("ic") is not None: if data.get("status") == "success" and data.get("ic") is not None:
results.append(data) results.append(data)
except Exception: except Exception:
logger.warning("Failed to load factor file %s", f, exc_info=True)
continue continue
if not results: if not results:
@@ -1337,6 +1342,7 @@ def build_strategies_ai(
if data.get("status") == "success" and data.get("ic") is not None: if data.get("status") == "success" and data.get("ic") is not None:
factors.append(data) factors.append(data)
except Exception: except Exception:
logger.warning("Failed to load factor file %s", f, exc_info=True)
continue continue
if len(factors) < 10: if len(factors) < 10:
@@ -1552,6 +1558,7 @@ def _load_strategies():
try: try:
raw = json.loads(p.read_text()) raw = json.loads(p.read_text())
except Exception: except Exception:
logger.warning("Failed to load strategy file %s", p, exc_info=True)
continue continue
if not isinstance(raw, dict): if not isinstance(raw, dict):
continue continue
+4 -1
View File
@@ -27,6 +27,8 @@ import typer
from rich.console import Console from rich.console import Console
from typing_extensions import Annotated from typing_extensions import Annotated
from rdagent.utils.env import logger
from rdagent.app.data_science.loop import main as data_science 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.finetune.llm.loop import main as llm_finetune
from rdagent.app.general_model.general_model import ( from rdagent.app.general_model.general_model import (
@@ -882,6 +884,7 @@ def optimize_portfolio_cli(
if data.get("status") == "accepted": if data.get("status") == "accepted":
strategies.append(data) strategies.append(data)
except Exception: except Exception:
logger.warning("Failed to load strategy file %s", f, exc_info=True)
continue continue
if not strategies: if not strategies:
@@ -1251,7 +1254,7 @@ def start_loop_cli(
script_dir = str(Path(__file__).parent.parent.parent.parent) script_dir = str(Path(__file__).parent.parent.parent.parent)
generator = f"python {script_dir}/scripts/predix_smart_strategy_gen.py" generator = f"python {script_dir}/scripts/predix_smart_strategy_gen.py"
logfile = f"{script_dir}/results/logs/generator_loop.log" 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) os.makedirs(f"{script_dir}/results/logs", exist_ok=True)
+8 -47
View File
@@ -24,46 +24,12 @@ from rdagent.app.finetune.llm.ui.ft_summary import render_job_summary
DEFAULT_LOG_BASE = "log/" DEFAULT_LOG_BASE = "log/"
from rdagent.core.utils import safe_resolve_path
def validate_path_within_cwd(user_path: Path) -> 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() safe_root = Path.cwd().resolve()
# Expand any user home reference and resolve without requiring the path to exist. return safe_resolve_path(user_path, safe_root)
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
def get_job_options(base_path: Path, safe_root: Path | None = None) -> list[str]: def get_job_options(base_path: Path, safe_root: Path | None = None) -> list[str]:
@@ -141,19 +107,14 @@ def main():
st.header("Job") st.header("Job")
base_folder = st.text_input("Base Folder", value=default_log, key="base_folder_input") 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 safe_root = Path(default_log).expanduser().resolve()
root_real = os.path.realpath(str(Path(default_log).expanduser())) try:
folder_real = os.path.realpath(str(Path(base_folder).expanduser())) base_path = safe_resolve_path(Path(base_folder), safe_root)
if folder_real == root_real or folder_real.startswith(root_real + os.sep): except ValueError:
base_path = Path(folder_real)
safe_root = Path(root_real)
else:
st.error("Invalid base folder: must be within the configured log directory.") st.error("Invalid base folder: must be within the configured log directory.")
safe_root = Path(root_real)
base_path = safe_root base_path = safe_root
# base_path is validated against safe_root nosec B614 job_options = get_job_options(base_path, safe_root)
job_options = get_job_options(base_path, safe_root) # nosec B614 validated above
if job_options: if job_options:
selected_job = st.selectbox("Select Job", job_options, key="job_select") selected_job = st.selectbox("Select Job", job_options, key="job_select")
if selected_job.startswith("."): if selected_job.startswith("."):
+7 -9
View File
@@ -13,6 +13,7 @@ from typing import Any
import streamlit as st import streamlit as st
from rdagent.app.finetune.llm.ui.config import EVALUATOR_CONFIG, EventType 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 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]: 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.""" """Get list of valid session directories, optionally validating against a safe root."""
if safe_root is not None: if safe_root is not None:
root_real = os.path.realpath(str(safe_root.expanduser())) try:
folder_real = os.path.realpath(str(log_folder.expanduser())) log_folder = safe_resolve_path(log_folder, safe_root)
if not (folder_real == root_real or folder_real.startswith(root_real + os.sep)): except ValueError:
return [] return []
log_folder = Path(folder_real)
if not log_folder.exists(): if not log_folder.exists():
return [] 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}) @st.cache_data(ttl=300, hash_funcs={Path: str})
def load_ft_session(log_path: Path, safe_root: Path | None = None) -> Session: def load_ft_session(log_path: Path, safe_root: Path | None = None) -> Session:
"""Load events into hierarchical session structure, optionally validating against safe root.""" """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: if safe_root is not None:
root_real = os.path.realpath(str(safe_root.expanduser())) try:
path_real = os.path.realpath(str(log_path.expanduser())) log_path = safe_resolve_path(log_path, safe_root)
if not (path_real == root_real or path_real.startswith(root_real + os.sep)): except ValueError:
return Session() return Session()
log_path = Path(path_real)
session = Session() session = Session()
storage = FileStorage(log_path) storage = FileStorage(log_path)
+1
View File
@@ -322,6 +322,7 @@ class QuantRDLoop(RDLoop):
if data.get("status") == "success" and data.get("ic") is not None: if data.get("status") == "success" and data.get("ic") is not None:
factors.append(data) factors.append(data)
except Exception: except Exception:
logger.warning("Failed to load factor file %s", f, exc_info=True)
continue continue
if len(factors) < 10: 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.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.data_loader import get_summary, get_valid_sessions, load_session
from rdagent.app.rl.ui.rl_summary import render_job_summary from rdagent.app.rl.ui.rl_summary import render_job_summary
from rdagent.core.utils import safe_resolve_path
DEFAULT_LOG_BASE = "log/" DEFAULT_LOG_BASE = "log/"
def _safe_resolve(user_input: str | None, safe_root: Path) -> Path: 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() safe_root = safe_root.expanduser().resolve()
# Empty input maps to the safe root directory.
if not user_input: if not user_input:
return safe_root return safe_root
# Security check 1: Reject null bytes (path truncation attack)
if "\x00" in user_input: if "\x00" in user_input:
raise ValueError("Invalid path: contains null byte") raise ValueError("Invalid path: contains null byte")
try: try:
# Security check 2: Normalize path to resolve .. and . components
normalized = os.path.normpath(user_input.strip()) normalized = os.path.normpath(user_input.strip())
# Security check 3: Reject Windows drive letters (C:\, D:\, etc.)
drive, _ = os.path.splitdrive(normalized) drive, _ = os.path.splitdrive(normalized)
if drive: if drive:
raise ValueError("Absolute paths with drive letters are not allowed") raise ValueError("Absolute paths with drive letters are not allowed")
# Security check 4: Reject absolute paths (/, //server/share, etc.)
if os.path.isabs(normalized): if os.path.isabs(normalized):
raise ValueError("Absolute paths are not allowed") raise ValueError("Absolute paths are not allowed")
joined = safe_root / normalized
# Security check 5: Build candidate path under safe_root and fully resolve it. return safe_resolve_path(joined, safe_root)
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)
except (OSError, ValueError) as exc: except (OSError, ValueError) as exc:
raise ValueError(f"Invalid path outside of allowed root: {user_input}") from 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 # Security fix: Validate base_path to prevent path traversal
try: 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: if safe_root is not None:
safe_root_resolved = safe_root.expanduser().resolve() safe_root_resolved = safe_root.expanduser().resolve()
@@ -203,8 +174,7 @@ def main():
except ValueError as e: except ValueError as e:
st.warning(str(e)) st.warning(str(e))
return return
# job_path is validated by _safe_resolve() above if job_path.exists():
if job_path.exists(): # nosec B614 path validated by _safe_resolve
render_job_summary(job_path, safe_root, is_root=is_root_job) render_job_summary(job_path, safe_root, is_root=is_root_job)
else: else:
st.warning(f"Job folder not found: {job_folder}") 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 import streamlit as st
from rdagent.app.rl.ui.config import EventType from rdagent.app.rl.ui.config import EventType
from rdagent.core.utils import safe_resolve_path
from rdagent.log.storage import FileStorage 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]: 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.""" """Get list of valid session directories, optionally validating against a safe root."""
if safe_root is not None: if safe_root is not None:
root_real = os.path.realpath(str(safe_root.expanduser())) try:
folder_real = os.path.realpath(str(log_folder.expanduser())) log_folder = safe_resolve_path(log_folder, safe_root)
if not (folder_real == root_real or folder_real.startswith(root_real + os.sep)): except ValueError:
return [] return []
log_folder = Path(folder_real)
if not log_folder.exists(): if not log_folder.exists():
return [] 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}) @st.cache_data(ttl=300, hash_funcs={Path: str})
def load_session(log_path: Path, safe_root: Path | None = None) -> Session: def load_session(log_path: Path, safe_root: Path | None = None) -> Session:
"""Load events into hierarchical session structure, optionally validating against safe root.""" """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: if safe_root is not None:
root_real = os.path.realpath(str(safe_root.expanduser())) try:
path_real = os.path.realpath(str(log_path.expanduser())) log_path = safe_resolve_path(log_path, safe_root)
if not (path_real == root_real or path_real.startswith(root_real + os.sep)): except ValueError:
return Session() return Session()
log_path = Path(path_real)
session = Session() session = Session()
+4 -6
View File
@@ -9,6 +9,8 @@ from pathlib import Path
import pandas as pd import pandas as pd
import streamlit as st import streamlit as st
from rdagent.core.utils import safe_resolve_path
def is_valid_task(task_path: Path) -> bool: def is_valid_task(task_path: Path) -> bool:
"""Check if directory is a valid RL task (has __session__ subdirectory)""" """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: 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: try:
# Reconstruct from trusted root so the returned path is root-derived. return safe_resolve_path(job_path, safe_root)
return resolved_root / resolved_job.relative_to(resolved_root)
except ValueError: 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: 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") current_version = importlib.metadata.version("rdagent")
logger.info(f"RD-Agent version: {current_version}") logger.info(f"RD-Agent version: {current_version}")
api_url = f"https://api.github.com/repos/microsoft/RD-Agent/contents/requirements.txt?ref=main" 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: if response.status_code == 200:
files = response.json() files = response.json()
file_url = files["download_url"] 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: if file_response.status_code == 200:
all_file_contents = file_response.text.split("\n") all_file_contents = file_response.text.split("\n")
else: else:
+26 -17
View File
@@ -71,6 +71,9 @@ class ResultsDatabase:
self.conn.commit() 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: 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. Add a column to a table if it doesn't already exist.
@@ -78,20 +81,24 @@ class ResultsDatabase:
Parameters Parameters
---------- ----------
table : str table : str
Table name Table name (must be in _ALLOWED_TABLES)
column : str column : str
Column name to add Column name to add (alphanumeric + underscore only)
col_type : str 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() c = self.conn.cursor()
try: c.execute("SELECT name FROM pragma_table_info(?)", (table,))
# Try to query the column - if it fails, it doesn't exist existing = {row[0] for row in c.fetchall()}
# nosec B608: Internal schema migration, column names are controlled if column not in existing:
c.execute(f"SELECT {column} FROM {table} LIMIT 1") # nosec B608 c.execute(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}")
except sqlite3.OperationalError:
# Column doesn't exist, add it
c.execute(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}") # nosec B608
def add_factor(self, name: str, type: str = "unknown") -> int: def add_factor(self, name: str, type: str = "unknown") -> int:
c = self.conn.cursor() c = self.conn.cursor()
@@ -183,16 +190,18 @@ class ResultsDatabase:
pd.DataFrame pd.DataFrame
DataFrame with factor names and metrics 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 = { metric_map = {
'sharpe': 'sharpe', 'sharpe': 'sharpe', 'ic': 'ic', 'return': 'annual_return',
'ic': 'ic', 'drawdown': 'max_drawdown', 'win_rate': 'win_rate',
'return': 'annual_return',
'drawdown': 'max_drawdown',
'win_rate': 'win_rate',
'information_ratio': 'information_ratio', 'information_ratio': 'information_ratio',
} }
col = metric_map.get(metric, metric) col = metric_map.get(metric, metric)
if col not in _ALLOWED_METRICS:
raise ValueError(f"Unknown metric: {metric!r}")
return pd.read_sql_query( return pd.read_sql_query(
f"""SELECT factor_name, ic, sharpe, annual_return, max_drawdown, f"""SELECT factor_name, ic, sharpe, annual_return, max_drawdown,
@@ -201,7 +210,7 @@ class ResultsDatabase:
JOIN factors ON factor_id = factors.id JOIN factors ON factor_id = factors.id
WHERE {col} IS NOT NULL WHERE {col} IS NOT NULL
ORDER BY {col} DESC ORDER BY {col} DESC
LIMIT ?""", LIMIT ?""", # nosec B608 — col is validated against _ALLOWED_METRICS above
self.conn, self.conn,
params=[limit] params=[limit]
) )
@@ -54,7 +54,8 @@ def get_ds_env(
ValueError: If the env_type is not recognized. ValueError: If the env_type is not recognized.
""" """
conf = DSCoderCoSTEERSettings() 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": if conf.env_type == "docker":
env_conf = DSDockerConf() if conf_type == "kaggle" else MLEBDockerConf() 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 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": if DS_RD_SETTING.enable_model_dump and stage == "before_training":
cmd = "rm -r submission.csv scores.csv models trace.log" cmd = "rm -r submission.csv scores.csv models trace.log"
else: else:
@@ -13,7 +13,7 @@ File structure
from pathlib import Path 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.app.data_science.conf import DS_RD_SETTING
from rdagent.components.coder.CoSTEER.evaluators import ( from rdagent.components.coder.CoSTEER.evaluators import (
@@ -88,7 +88,7 @@ class EnsembleMultiProcessEvolvingStrategy(MultiProcessEvolvingStrategy):
code_spec = workspace.file_dict["spec/ensemble.md"] code_spec = workspace.file_dict["spec/ensemble.md"]
else: else:
test_code = ( test_code = (
Environment(undefined=StrictUndefined) Environment(undefined=StrictUndefined, autoescape=select_autoescape())
.from_string((DIRNAME / "eval_tests" / "ensemble_test.txt").read_text()) .from_string((DIRNAME / "eval_tests" / "ensemble_test.txt").read_text())
.render( .render(
model_names=[ model_names=[
@@ -2,7 +2,7 @@ import json
import re import re
from pathlib import Path 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.app.data_science.conf import DS_RD_SETTING
from rdagent.components.coder.CoSTEER.evaluators import ( from rdagent.components.coder.CoSTEER.evaluators import (
@@ -55,7 +55,7 @@ class EnsembleCoSTEEREvaluator(CoSTEEREvaluator):
fname = "test/ensemble_test.txt" fname = "test/ensemble_test.txt"
test_code = (DIRNAME / "eval_tests" / "ensemble_test.txt").read_text() test_code = (DIRNAME / "eval_tests" / "ensemble_test.txt").read_text()
test_code = ( test_code = (
Environment(undefined=StrictUndefined) Environment(undefined=StrictUndefined, autoescape=select_autoescape())
.from_string(test_code) .from_string(test_code)
.render( .render(
model_names=[ model_names=[
@@ -51,13 +51,23 @@ class FactorAutoFixer:
self.fixes_applied = [] self.fixes_applied = []
fixed_code = code 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 = [ fix_methods = [
self._fix_groupby_apply_to_transform, # First: fix groupby patterns self._fix_instrument_column_access, # First: fix df['instrument'] on MultiIndex
self._fix_min_periods, # Second: fix min_periods in resulting rolling calls self._fix_instrument_loc_multiindex, # Second: fix df.loc[instrument_var] on MultiIndex
self._fix_inf_nan_handling, # Third: add inf/nan handling self._fix_zero_volume_proxy, # Third: replace zero $volume with range proxy
self._fix_data_range_processing, # Fourth: ensure full data range self._fix_reset_index_groupby, # Fourth: fix groupby(level=N) after reset_index()
self._fix_multiindex_groupby, # Fifth: ensure groupby on MultiIndex 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: for fix_method in fix_methods:
@@ -75,6 +85,352 @@ class FactorAutoFixer:
return fixed_code 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: def _fix_min_periods(self, code: str) -> str:
""" """
Fix: Ensure min_periods matches window size in rolling calculations. 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) fixed_code = fixed_code.replace(old_code, new_code)
self.fixes_applied.append(f"groupby: fixed rolling correlation (window={window}) with reset_index") 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() # Pattern: Simple groupby().apply() with rolling().method()
# df.groupby(level=N).apply(lambda x: x['col'].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*\)" 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: try:
subprocess.check_output( subprocess.check_output(
f"{FACTOR_COSTEER_SETTINGS.python_bin} {execution_code_path}", [FACTOR_COSTEER_SETTINGS.python_bin, str(execution_code_path)],
shell=True,
cwd=self.workspace_path, cwd=self.workspace_path,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
timeout=FACTOR_COSTEER_SETTINGS.file_based_execution_timeout, timeout=FACTOR_COSTEER_SETTINGS.file_based_execution_timeout,
@@ -6,6 +6,7 @@ Two-step validation:
2. Micro-batch testing - Runtime validation with small dataset 2. Micro-batch testing - Runtime validation with small dataset
""" """
import ast
import json import json
import re import re
import time import time
@@ -229,7 +230,7 @@ class LLMConfigValidator:
final_metrics = re.search(r"\{'train_runtime':[^}]+\}", stdout) final_metrics = re.search(r"\{'train_runtime':[^}]+\}", stdout)
if final_metrics: if final_metrics:
try: try:
metrics = eval(final_metrics.group(0)) # Safe: only numbers and strings metrics = ast.literal_eval(final_metrics.group(0))
result["final_metrics"] = { result["final_metrics"] = {
"train_loss": metrics.get("train_loss"), "train_loss": metrics.get("train_loss"),
"train_runtime": metrics.get("train_runtime"), "train_runtime": metrics.get("train_runtime"),
@@ -41,7 +41,8 @@ class ModelCoSTEEREvaluator(CoSTEEREvaluator):
final_feedback="This task has failed too many times, skip implementation.", final_feedback="This task has failed too many times, skip implementation.",
final_decision=False, 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 # NOTE: Use fixed input to test the model to avoid randomness
batch_size = 8 batch_size = 8
@@ -50,7 +51,8 @@ class ModelCoSTEEREvaluator(CoSTEEREvaluator):
input_value = 0.4 input_value = 0.4
param_init_value = 0.6 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( model_execution_feedback, gen_np_array = implementation.execute(
batch_size=batch_size, batch_size=batch_size,
num_features=num_features, num_features=num_features,
@@ -59,7 +61,8 @@ class ModelCoSTEEREvaluator(CoSTEEREvaluator):
param_init_value=param_init_value, param_init_value=param_init_value,
) )
if gt_implementation is not None: 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( _, gt_np_array = gt_implementation.execute(
batch_size=batch_size, batch_size=batch_size,
num_features=num_features, num_features=num_features,
@@ -24,7 +24,8 @@ class UndirectedNode(Node):
super().__init__(content, label, embedding) super().__init__(content, label, embedding)
self.neighbors: set[UndirectedNode] = set() self.neighbors: set[UndirectedNode] = set()
self.appendix = appendix # appendix stores any additional information 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: def add_neighbor(self, node: UndirectedNode) -> None:
self.neighbors.add(node) self.neighbors.add(node)
@@ -96,7 +97,8 @@ class Graph(KnowledgeBase):
APIBackend().create_embedding(input_content=contents[i : i + size]), 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): for node, embedding in zip(nodes, embeddings):
node.embedding = embedding node.embedding = embedding
return nodes return nodes
@@ -252,7 +254,8 @@ class UndirectedGraph(Graph):
""" """
min_nodes_count = 2 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 intersection = None
for node in nodes: for node in nodes:
+11
View File
@@ -4,6 +4,7 @@ import functools
import importlib import importlib
import json import json
import multiprocessing as mp import multiprocessing as mp
import os
import pickle import pickle
import random import random
from collections.abc import Callable 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_wrapper
return cache_decorator 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 from __future__ import annotations
import json as _json import json as _json
import logging
import sys import sys
import threading import threading
from contextlib import contextmanager from contextlib import contextmanager
@@ -36,21 +37,24 @@ from typing import Any
from loguru import logger as _root from loguru import logger as _root
# ── paths ───────────────────────────────────────────────────────────────────── # ── paths ─────────────────────────────────────────────────────────────────────────────────
LOGS_ROOT: Path = Path(__file__).parent.parent.parent / "logs" LOGS_ROOT: Path = Path(__file__).parent.parent.parent / "logs"
# ── format ──────────────────────────────────────────────────────────────────── # ── format ────────────────────────────────────────────────────────────────────────────────
_FILE_FMT = ( _FILE_FMT = (
"{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {extra[cmd]: <18} | {message}" "{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 _registered: set[str] = set() # command keys that already have a file sink
_all_added: bool = False # whether the combined all.log sink is active _all_added: bool = False # whether the combined all.log sink is active
_llm_log_lock = threading.Lock() # guards concurrent writes to llm_calls.jsonl _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: def _today_dir() -> Path:
d = LOGS_ROOT / datetime.now().strftime("%Y-%m-%d") 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) log.info(sep)
# ── public API ──────────────────────────────────────────────────────────────── # ── public API ──────────────────────────────────────────────────────────────────────────────
def log_llm_call( def log_llm_call(
system: str | None, system: str | None,
@@ -88,16 +92,19 @@ def log_llm_call(
start_time: Any = None, start_time: Any = None,
end_time: Any = None, end_time: Any = None,
) -> 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: 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 jq 'select(.duration_ms > 5000)' logs/2026-04-17/llm_calls.jsonl
""" """
entry: dict[str, Any] = { entry: dict[str, Any] = {
"ts": datetime.now().isoformat(timespec="milliseconds"), "ts": datetime.now().isoformat(timespec="milliseconds"),
"system": system or "", "system": (system or "")[:_LLM_CALL_MAX_CHARS],
"user": user, "user": user[:_LLM_CALL_MAX_CHARS],
"response": response, "response": response[:_LLM_CALL_MAX_CHARS],
} }
if start_time is not None and end_time is not None: if start_time is not None and end_time is not None:
try: try:
@@ -130,13 +137,13 @@ def setup(command: str, **context: Any):
key = command.lower() key = command.lower()
if key not in _registered: if key not in _registered:
# Per-command rotating file
_root.add( _root.add(
str(log_dir / f"{key}.log"), str(log_dir / f"{key}.log"),
format=_FILE_FMT, format=_FILE_FMT,
filter=lambda r, k=key: r["extra"].get("cmd", "").lower() == k, filter=lambda r, k=key: r["extra"].get("cmd", "").lower() == k,
rotation="00:00", # new file at midnight rotation="50 MB",
retention="30 days", compression="gz",
retention="7 days",
encoding="utf-8", encoding="utf-8",
enqueue=True, enqueue=True,
backtrace=False, backtrace=False,
@@ -145,13 +152,13 @@ def setup(command: str, **context: Any):
_registered.add(key) _registered.add(key)
if not _all_added: if not _all_added:
# Combined log — all commands
_root.add( _root.add(
str(log_dir / "all.log"), str(log_dir / "all.log"),
format=_FILE_FMT, format=_FILE_FMT,
filter=lambda r: "cmd" in r["extra"], filter=lambda r: "cmd" in r["extra"],
rotation="00:00", rotation="100 MB",
retention="60 days", compression="gz",
retention="7 days",
encoding="utf-8", encoding="utf-8",
enqueue=True, enqueue=True,
backtrace=False, 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)
+7 -1
View File
@@ -720,7 +720,13 @@ class APIBackend(ABC):
if finish_reason is None or finish_reason != "length": if finish_reason is None or finish_reason != "length":
break # we get a full response now. 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: else:
raise RuntimeError(f"Failed to continue the conversation after {try_n} retries.") 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() 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() compl_kwargs = backend.get_complete_kwargs()
selected_model = compl_kwargs["model"] selected_model = compl_kwargs["model"]
_, custom_llm_provider, _, _ = get_llm_provider(selected_model) _, custom_llm_provider, _, _ = get_llm_provider(selected_model)
assert ( if custom_llm_provider not in PROVIDER_TO_ENV_MAP:
custom_llm_provider in PROVIDER_TO_ENV_MAP raise ValueError(
), f"Provider {custom_llm_provider} not supported. Please add it into `PROVIDER_TO_ENV_MAP`" f"Provider {custom_llm_provider} not supported. Please add it into `PROVIDER_TO_ENV_MAP`"
)
prefix = PROVIDER_TO_ENV_MAP[custom_llm_provider] prefix = PROVIDER_TO_ENV_MAP[custom_llm_provider]
api_key = os.getenv(f"{prefix}_API_KEY", None) api_key = os.getenv(f"{prefix}_API_KEY", None)
api_base = os.getenv(f"{prefix}_API_BASE", None) api_base = os.getenv(f"{prefix}_API_BASE", None)
@@ -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: def prepare_for_auroc_metric(submission: pd.DataFrame, answers: pd.DataFrame, id_col: str, target_col: str) -> dict:
# Answers checks # Answers checks
assert id_col in answers.columns, f"answers dataframe should have an {id_col} column" if id_col not in answers.columns:
assert target_col in answers.columns, f"answers dataframe should have a {target_col} column" 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 # Submission checks
if id_col not in submission.columns: if id_col not in submission.columns:
@@ -1,7 +1,8 @@
from pathlib import Path from pathlib import Path
# Check if our submission file exists # 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() submission_lines = Path("submission.csv").read_text().splitlines()
test_lines = Path("submission_test.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: if "price" not in submission.columns:
raise InvalidSubmissionError("Submission DataFrame must contain 'price' 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): if len(submission) != len(answers):
raise InvalidSubmissionError("Submission must be the same length as the answers.") raise InvalidSubmissionError("Submission must be the same length as the answers.")
@@ -1,7 +1,8 @@
from pathlib import Path from pathlib import Path
# Check if our submission file exists # 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() # 自动生成的 submission_lines = Path("submission.csv").read_text().splitlines() # 自动生成的
test_lines = Path("submission_test.csv").read_text().splitlines() # test.csv test_lines = Path("submission_test.csv").read_text().splitlines() # test.csv
@@ -25,11 +25,12 @@ def prepare(raw: Path, public: Path, private: Path):
new_test.to_csv(public / "test.csv", index=False) new_test.to_csv(public / "test.csv", index=False)
# Checks # Checks
assert new_test.shape[1] == 12, "Public test set should have 12 columns" if new_test.shape[1] != 12:
assert new_train.shape[1] == 13, "Public train set should have 13 columns" raise AssertionError("Public test set should have 12 columns")
assert len(new_train) + len(new_test) == len( if new_train.shape[1] != 13:
old_train raise AssertionError("Public train set should have 13 columns")
), "Length of new_train and new_test should equal length of old_train" 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__": if __name__ == "__main__":
@@ -182,7 +182,7 @@ class ExpGen2Hypothesis(DSProposalV2ExpGen):
success_fb_list = list(set(trace_fbs)) success_fb_list = list(set(trace_fbs))
logger.info( 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: if len(success_fb_list) > 0:
@@ -1,3 +1,4 @@
import ast
import json import json
import os import os
import pickle import pickle
@@ -292,7 +293,7 @@ class ValidationSelector(SOTAexpSelector):
Sorts all valid experiments by score and returns the top N. 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: try:
data_py_code, grade_py_code = self._prepare_validation_scripts( data_py_code, grade_py_code = self._prepare_validation_scripts(
@@ -539,7 +540,7 @@ def process_experiment(
# Run main script # Run main script
env = get_ds_env( 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, running_timeout_period=DS_RD_SETTING.full_timeout,
) )
result = ws.run(env=env, entry="python main.py") result = ws.run(env=env, entry="python main.py")
@@ -587,8 +588,8 @@ def _parsing_score(grade_stdout: str) -> Optional[float]:
except: except:
pass pass
try: try:
# Priority 2: Eval dict # Priority 2: safe literal eval for Python-style dicts
return float(eval(json_str)["score"]) return float(ast.literal_eval(json_str)["score"])
except: except:
pass pass
try: try:
+4 -3
View File
@@ -35,10 +35,11 @@ def select(X: pd.DataFrame) -> pd.DataFrame:
class KGModelFeatureSelectionCoder(Developer[KGModelExperiment]): class KGModelFeatureSelectionCoder(Developer[KGModelExperiment]):
def develop(self, exp: KGModelExperiment) -> KGModelExperiment: def develop(self, exp: KGModelExperiment) -> KGModelExperiment:
target_model_type = exp.sub_tasks[0].model_type 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: if len(exp.experiment_workspace.data_description) == 1:
code = ( code = (
Environment(undefined=StrictUndefined) Environment(undefined=StrictUndefined) # nosec B701 — renders Python code templates, not HTML; autoescape would corrupt code
.from_string(DEFAULT_SELECTION_CODE) .from_string(DEFAULT_SELECTION_CODE)
.render(feature_index_list=None) .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] chosen_index_to_list_index = [i - 1 for i in chosen_index]
code = ( code = (
Environment(undefined=StrictUndefined) Environment(undefined=StrictUndefined) # nosec B701 — renders Python code templates, not HTML; autoescape would corrupt code
.from_string(DEFAULT_SELECTION_CODE) .from_string(DEFAULT_SELECTION_CODE)
.render(feature_index_list=chosen_index_to_list_index) .render(feature_index_list=chosen_index_to_list_index)
) )
@@ -79,12 +79,12 @@ def preprocess_script():
This method applies the preprocessing steps to the training, validation, and test datasets. This method applies the preprocessing steps to the training, validation, and test datasets.
""" """
if os.path.exists("/kaggle/input/X_train.pkl"): if os.path.exists("/kaggle/input/X_train.pkl"):
X_train = pd.read_pickle("/kaggle/input/X_train.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") X_valid = pd.read_pickle("/kaggle/input/X_valid.pkl") # nosec B301
y_train = pd.read_pickle("/kaggle/input/y_train.pkl") y_train = pd.read_pickle("/kaggle/input/y_train.pkl") # nosec B301
y_valid = pd.read_pickle("/kaggle/input/y_valid.pkl") y_valid = pd.read_pickle("/kaggle/input/y_valid.pkl") # nosec B301
X_test = pd.read_pickle("/kaggle/input/X_test.pkl") X_test = pd.read_pickle("/kaggle/input/X_test.pkl") # nosec B301
others = pd.read_pickle("/kaggle/input/others.pkl") others = pd.read_pickle("/kaggle/input/others.pkl") # nosec B301
y_train = pd.Series(y_train).reset_index(drop=True) y_train = pd.Series(y_train).reset_index(drop=True)
y_valid = pd.Series(y_valid).reset_index(drop=True) y_valid = pd.Series(y_valid).reset_index(drop=True)
@@ -1,4 +1,6 @@
import sys
import os import os
import logging
from pathlib import Path from pathlib import Path
""" """
Qlib Factor Runner - Executes factor backtests in Docker. Qlib Factor Runner - Executes factor backtests in Docker.
@@ -965,7 +967,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
shutil.copy(str(full_data), str(tmp / "intraday_pv.h5")) shutil.copy(str(full_data), str(tmp / "intraday_pv.h5"))
ret = subprocess.run( ret = subprocess.run(
["python", "factor.py"], ["sys.executable", "factor.py"],
cwd=str(tmp), cwd=str(tmp),
capture_output=True, capture_output=True,
timeout=300, timeout=300,
@@ -1000,7 +1002,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
series.to_frame().to_parquet(str(parquet_path)) series.to_frame().to_parquet(str(parquet_path))
except Exception: except Exception:
pass logging.debug("Error in save_factor_values_to_parquet", exc_info=True)
def _log_result_warnings(self, factor_name: str, result, metrics: dict) -> None: 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: if data.get("status") == "success" and data.get("ic") is not None:
factors.append(data) factors.append(data)
except Exception: except Exception:
logger.warning("Failed to load factor file %s", f, exc_info=True)
continue continue
# Sort by absolute IC # Sort by absolute IC
+2 -1
View File
@@ -30,7 +30,8 @@ def _build_execute_calls(exp: QlibFactorExperiment, base_feature_workspaces: lis
execute_calls = [] execute_calls = []
if exp.sub_tasks: 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( execute_calls.extend(
(implementation.execute, ("All",)) (implementation.execute, ("All",))
for implementation, feedback in zip(exp.sub_workspace_list, exp.prop_dev_feedback) for implementation, feedback in zip(exp.sub_workspace_list, exp.prop_dev_feedback)
+12 -10
View File
@@ -4,7 +4,7 @@ import shutil
from pathlib import Path from pathlib import Path
import pandas as pd 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.components.coder.factor_coder.config import FACTOR_COSTEER_SETTINGS
from rdagent.utils.env import QTDockerEnv from rdagent.utils.env import QTDockerEnv
@@ -21,14 +21,16 @@ def generate_data_folder_from_qlib():
entry=f"python generate.py", entry=f"python generate.py",
) )
assert (Path(__file__).parent / "factor_data_template" / "intraday_pv_all.h5").exists(), ( if not (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" raise FileNotFoundError(
+ execute_log "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" if not (Path(__file__).parent / "factor_data_template" / "intraday_pv_debug.h5").exists():
+ execute_log 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) Path(FACTOR_COSTEER_SETTINGS.data_folder).mkdir(parents=True, exist_ok=True)
shutil.copy( shutil.copy(
@@ -67,7 +69,7 @@ def get_file_desc(p: Path, variable_list=[]) -> str:
""" """
p = Path(p) p = Path(p)
JJ_TPL = Environment(undefined=StrictUndefined).from_string(""" JJ_TPL = Environment(undefined=StrictUndefined, autoescape=select_autoescape()).from_string("""
# {{file_name}} # {{file_name}}
## File Type ## File Type
@@ -1,4 +1,6 @@
import logging
import json import json
import os
from typing import List, Tuple from typing import List, Tuple
from rdagent.components.coder.factor_coder.factor import FactorExperiment, FactorTask 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.scenarios.qlib.experiment.quant_experiment import QlibQuantScenario
from rdagent.utils.agent.tpl import T 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 QlibFactorHypothesis = Hypothesis
@@ -17,13 +60,10 @@ class QlibFactorHypothesisGen(FactorHypothesisGen):
super().__init__(scen) super().__init__(scen)
def prepare_context(self, trace: Trace) -> Tuple[dict, bool]: def prepare_context(self, trace: Trace) -> Tuple[dict, bool]:
hypothesis_and_feedback = ( max_h = int(os.environ.get("QLIB_QUANT_MAX_FACTOR_HISTORY", "20"))
T("scenarios.qlib.prompts:hypothesis_and_feedback").r( limited = Trace(trace.scen)
trace=trace, limited.hist = trace.hist[-max_h:] if len(trace.hist) > max_h else trace.hist
) hypothesis_and_feedback = _build_compressed_history(limited, max_h)
if len(trace.hist) > 0
else "No previous hypothesis and feedback available since it's the first round."
)
last_hypothesis_and_feedback = ( last_hypothesis_and_feedback = (
T("scenarios.qlib.prompts:last_hypothesis_and_feedback").r( T("scenarios.qlib.prompts:last_hypothesis_and_feedback").r(
experiment=trace.hist[-1][0], feedback=trace.hist[-1][1] experiment=trace.hist[-1][0], feedback=trace.hist[-1][1]
@@ -70,15 +110,15 @@ class QlibFactorHypothesis2Experiment(FactorHypothesis2Experiment):
if len(trace.hist) == 0: if len(trace.hist) == 0:
hypothesis_and_feedback = "No previous hypothesis and feedback available since it's the first round." hypothesis_and_feedback = "No previous hypothesis and feedback available since it's the first round."
else: 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) specific_trace = Trace(trace.scen)
for i in range(len(trace.hist) - 1, -1, -1): specific_trace.hist = factor_hist
if not hasattr(trace.hist[i][0].hypothesis, "action") or trace.hist[i][0].hypothesis.action == "factor": if specific_trace.hist:
specific_trace.hist.insert(0, trace.hist[i]) hypothesis_and_feedback = _build_compressed_history(specific_trace, max_h)
if len(specific_trace.hist) > 0:
specific_trace.hist.reverse()
hypothesis_and_feedback = T("scenarios.qlib.prompts:hypothesis_and_feedback").r(
trace=specific_trace,
)
else: else:
hypothesis_and_feedback = "No previous hypothesis and feedback available." hypothesis_and_feedback = "No previous hypothesis and feedback available."
@@ -1,3 +1,4 @@
import logging
import json import json
import os import os
import random import random
@@ -175,7 +176,7 @@ class QlibQuantHypothesisGen(FactorAndModelHypothesisGen):
ic_val = exp.result.loc["IC"] if "IC" in exp.result.index else "" ic_val = exp.result.loc["IC"] if "IC" in exp.result.index else ""
ic_str = f" IC={ic_val:.4f}" if ic_val != "" else "" ic_str = f" IC={ic_val:.4f}" if ic_val != "" else ""
except Exception: except Exception:
pass logging.debug("Error getting IC", exc_info=True)
decision_str = "PASS" if fb.decision else "FAIL" decision_str = "PASS" if fb.decision else "FAIL"
obs_short = (fb.observations or "")[:120].replace("\n", " ") obs_short = (fb.observations or "")[:120].replace("\n", " ")
summary_lines.append(f"- [{decision_str}]{ic_str} {names_str}: {obs_short}") summary_lines.append(f"- [{decision_str}]{ic_str} {names_str}: {obs_short}")
@@ -77,6 +77,7 @@ def count_valid_factors() -> int:
if data.get("status") == "success" and data.get("ic") is not None: if data.get("status") == "success" and data.get("ic") is not None:
count += 1 count += 1
except Exception: except Exception:
logger.warning("Failed to load factor file %s", json_file, exc_info=True)
continue continue
return count return count
@@ -71,7 +71,7 @@ def submit_for_grading(grading_url: str, model_path: str) -> dict | None:
def main(): def main():
MODEL_PATH = os.environ.get("MODEL_PATH") MODEL_PATH = os.environ.get("MODEL_PATH")
DATA_PATH = os.environ.get("DATA_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", "") GRADING_SERVER_URL = os.environ.get("GRADING_SERVER_URL", "")
TRAIN_RATIO = float(os.environ.get("TRAIN_RATIO", "0.05")) TRAIN_RATIO = float(os.environ.get("TRAIN_RATIO", "0.05"))
NUM_EPOCHS = int(os.environ.get("NUM_EPOCHS", "3")) NUM_EPOCHS = int(os.environ.get("NUM_EPOCHS", "3"))
@@ -9,7 +9,7 @@ peft>=0.18.1
# Evaluation # Evaluation
opencompass==0.5.1 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) # 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) # 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 from typing import Any
import yaml 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.core.conf import RD_AGENT_SETTINGS
from rdagent.log import rdagent_logger as logger 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) caller_dir = get_caller_dir(upshift=1)
# Parse the URI # Parse the URI
path_part, *yaml_trace = uri.split(":") 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(".")] yaml_trace = [key for yt in yaml_trace for key in yt.split(".")]
# load file_path with priorities. # load file_path with priorities.
@@ -126,7 +127,7 @@ class RDAT:
# loader=FunctionLoader(load_conent) is for supporting grammar like below. # loader=FunctionLoader(load_conent) is for supporting grammar like below.
# `{% include "scenarios.data_science.share:component_spec.DataLoadSpec" %}` # `{% include "scenarios.data_science.share:component_spec.DataLoadSpec" %}`
rendered = ( rendered = (
Environment(undefined=StrictUndefined, loader=FunctionLoader(load_content)) Environment(undefined=StrictUndefined, loader=FunctionLoader(load_content), autoescape=select_autoescape())
.from_string(self.template) .from_string(self.template)
.render(**context) .render(**context)
.strip("\n") .strip("\n")
+25 -24
View File
@@ -614,7 +614,7 @@ class LocalEnv(Env[ASpecificLocalConf]):
if self.conf.extra_volumes is not None: if self.conf.extra_volumes is not None:
for lp, rp in self.conf.extra_volumes.items(): for lp, rp in self.conf.extra_volumes.items():
volumes[lp] = rp["bind"] if isinstance(rp, dict) else rp 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) Path(cache_path).mkdir(parents=True, exist_ok=True)
volumes[cache_path] = T("scenarios.data_science.share:scen.cache_path").r() volumes[cache_path] = T("scenarios.data_science.share:scen.cache_path").r()
for lp, rp in running_extra_volume.items(): for lp, rp in running_extra_volume.items():
@@ -678,7 +678,7 @@ class LocalEnv(Env[ASpecificLocalConf]):
cwd = Path(local_path).resolve() if local_path else None 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()} 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, entry,
cwd=cwd, cwd=cwd,
env={**os.environ, **env}, env={**os.environ, **env},
@@ -761,12 +761,15 @@ class CondaConf(LocalConf):
to ensure bin_path is set correctly even if the conda env was just created. to ensure bin_path is set correctly even if the conda env was just created.
""" """
conda_path_result = subprocess.run( 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, capture_output=True,
text=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): class MLECondaConf(CondaConf):
@@ -850,24 +853,22 @@ class QlibCondaEnv(LocalEnv[QlibCondaConf]):
def prepare(self) -> None: def prepare(self) -> None:
"""Prepare the conda environment if not already created.""" """Prepare the conda environment if not already created."""
try: 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: if self.conf.conda_env_name not in envs.stdout:
print(f"[yellow]Conda env '{self.conf.conda_env_name}' not found, creating...[/yellow]") print(f"[yellow]Conda env '{self.conf.conda_env_name}' not found, creating...[/yellow]")
subprocess.check_call( subprocess.check_call(
f"conda create -y -n {self.conf.conda_env_name} python=3.10", ["conda", "create", "-y", "-n", self.conf.conda_env_name, "python=3.10"],
shell=True,
) )
subprocess.check_call( subprocess.check_call(
f"conda run -n {self.conf.conda_env_name} pip install --upgrade pip cython", ["conda", "run", "-n", self.conf.conda_env_name, "pip", "install", "--upgrade", "pip", "cython"],
shell=True,
) )
subprocess.check_call( subprocess.check_call(
f"conda run -n {self.conf.conda_env_name} pip install git+https://github.com/microsoft/qlib.git@2fb9380b342556ddb50a4b24e4fe8655d548b2b8", ["conda", "run", "-n", self.conf.conda_env_name, "pip", "install",
shell=True, "git+https://github.com/microsoft/qlib.git@2fb9380b342556ddb50a4b24e4fe8655d548b2b8"],
) )
subprocess.check_call( subprocess.check_call(
f"conda run -n {self.conf.conda_env_name} pip install catboost xgboost tables torch", ["conda", "run", "-n", self.conf.conda_env_name, "pip", "install",
shell=True, "catboost", "xgboost", "tables", "torch"],
) )
except Exception as e: except Exception as e:
@@ -888,10 +889,9 @@ def _sync_conda_cache_with_real_envs() -> None:
"""Ensure the prepared cache includes environments that already exist on disk.""" """Ensure the prepared cache includes environments that already exist on disk."""
try: try:
result = subprocess.run( result = subprocess.run(
"conda env list", ["conda", "env", "list"],
capture_output=True, capture_output=True,
text=True, text=True,
shell=True,
check=False, check=False,
) )
except Exception as exc: # pragma: no cover - best-effort helper except Exception as exc: # pragma: no cover - best-effort helper
@@ -924,14 +924,15 @@ def _prepare_conda_env(env_name: str, requirements_file: Path, python_version: s
python_version: Python version for the environment python_version: Python version for the environment
""" """
# 1. Create conda environment if not exists # 1. Create conda environment if not exists
result = subprocess.run(f"conda env list | grep -q '^{env_name} '", shell=True) env_list = subprocess.run(["conda", "env", "list"], capture_output=True, text=True, check=False)
if result.returncode != 0: 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]") 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(["conda", "create", "-y", "-n", env_name, f"python={python_version}"])
subprocess.check_call(f"conda run -n {env_name} pip install --upgrade pip", shell=True) subprocess.check_call(["conda", "run", "-n", env_name, "pip", "install", "--upgrade", "pip"])
print(f"[yellow]Installing dependencies from {requirements_file.name}...[/yellow]") 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]") print(f"[green]Conda env '{env_name}' ready[/green]")
_CONDA_ENV_PREPARED.add(env_name) _CONDA_ENV_PREPARED.add(env_name)
@@ -971,8 +972,8 @@ class FTCondaEnv(LocalEnv[FTCondaConf]):
# Note: flash-attn>=2.8 is required for B200 (sm_100) support # Note: flash-attn>=2.8 is required for B200 (sm_100) support
print("[yellow]Installing flash-attn (compiling, may take a few minutes)...[/yellow]") print("[yellow]Installing flash-attn (compiling, may take a few minutes)...[/yellow]")
subprocess.check_call( subprocess.check_call(
f"conda run -n {self.conf.conda_env_name} pip install 'flash-attn>=2.8' --no-build-isolation --no-cache-dir", ["conda", "run", "-n", self.conf.conda_env_name, "pip", "install",
shell=True, "flash-attn>=2.8", "--no-build-isolation", "--no-cache-dir"],
) )
# Re-update bin_path after prepare() in case the conda env was just created # Re-update bin_path after prepare() in case the conda env was just created
@@ -1442,7 +1443,7 @@ class DockerEnv(Env[DockerConf]):
if self.conf.extra_volumes is not None: if self.conf.extra_volumes is not None:
for lp, rp in self.conf.extra_volumes.items(): 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} 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) Path(cache_path).mkdir(parents=True, exist_ok=True)
volumes[cache_path] = { volumes[cache_path] = {
"bind": T("scenarios.data_science.share:scen.cache_path").r(), "bind": T("scenarios.data_science.share:scen.cache_path").r(),
+5
View File
@@ -270,6 +270,11 @@ class LoopBase:
msg = "We have reset the loop instance, stop all the routines and resume." msg = "We have reset the loop instance, stop all the routines and resume."
raise self.LoopResumeError(msg) from e raise self.LoopResumeError(msg) from e
else: 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 raise # re-raise unhandled exceptions
finally: finally:
# No matter the execution succeed or not, we have to finish the following steps # 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 >>> counter
2 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 decorator(f: Callable[..., ASpecificRet]) -> Callable[..., ASpecificRet]:
def wrapper(*args: Any, **kwargs: Any) -> ASpecificRet: def wrapper(*args: Any, **kwargs: Any) -> ASpecificRet:
+4 -4
View File
@@ -9,8 +9,8 @@ psutil
fire fire
fuzzywuzzy fuzzywuzzy
openai openai
litellm>=1.73 # to support `from litellm import get_valid_models` 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 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 azure.identity
pyarrow pyarrow
rich rich
@@ -98,8 +98,8 @@ optuna>=3.5.0
beautifulsoup4>=4.12.0 beautifulsoup4>=4.12.0
# ML Training Pipeline # ML Training Pipeline
lightgbm>=3.3.0 lightgbm>=3.3.5
scipy>=1.9.0 scipy>=1.15.3
# RL Trading (optional - system works without these) # RL Trading (optional - system works without these)
# Install for full RL training: pip install stable-baselines3[extra] gymnasium # 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. # Only install if you want to use full PPO/A2C/SAC training.
# Core RL library # Core RL library
stable-baselines3[extra]>=2.0.0 stable-baselines3[extra]>=2.8.0
# Gymnasium environment (OpenAI Gym successor) # Gymnasium environment (OpenAI Gym successor)
gymnasium>=0.29.0 gymnasium>=0.29.0
+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