Compare commits

...

83 Commits

Author SHA1 Message Date
github-actions[bot] ff556fd228 chore(master): release 1.4.2 (#48)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-03 11:02:32 +02:00
TPTBusiness b23e145341 test: add 129 tests for critical untested code (core, CoSTEER, factor_coder, model_coder, qlib pipeline) 2026-05-03 10:59:24 +02:00
TPTBusiness 7d3765d0c0 fix: add missing sys import and fix undefined acc_rate in factor eval 2026-05-03 10:19:59 +02:00
github-actions[bot] d53f4bbbeb chore(master): release 1.4.1 (#47)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-03 09:39:56 +02:00
TPTBusiness 5bf84ff835 fix: 15 bug fixes across orchestrator, runner, backtest, and infrastructure
Critical:
- strategy_orchestrator: fix IndentationError that prevented import (line 764)
- factor_runner: fix literal 'sys.executable' string → variable (line 966)

High (path bugs causing wrong directories):
- backtest_engine: fix results_path depth (3→4 .parent hops)
- results_db: fix factors_dir/failed_dir depth (3→4 .parent hops)
- factor_runner: eliminate run_id variable shadowing (parallel_run_id/db_run_id)
- model_runner: fix DB connection leak on add_backtest exception
- optuna_optimizer: fix imported logger shadowed by module-level reassignment

Medium:
- env: handle non-UTF-8 Docker build output with errors='replace'
- env: guard conda env list parsing against empty lines
- factor_runner: add check=False + stderr logging for full-data subprocess
- strategy_orchestrator: log exec() exceptions at ERROR level with traceback
- strategy_orchestrator: warn on unreplaced {{template}} variables in prompts

Low:
- factor_runner: guard IC_max.index access against scalar (AttributeError)
- predix_parallel: close log file handle on Popen failure
- predix_rebacktest_strategies: replace 4 bare except: with except Exception:
2026-05-03 09:37:00 +02:00
TPTBusiness 571c902c2d test: add regression tests for background task path and env bugs
- Verify parallel runner project_root is repo root, not scripts/
- Verify .env loading from correct path
- Verify API key distribution (single key, multi-key comma-separated)
- Verify CLI project_root depth (3 .parent hops, not 4)
- Verify start_loop uses sys.executable and child_proc, not pkill
- Verify parallel_cli does not hardcode model=local
- Verify all referenced scripts exist at resolved paths
2026-05-03 08:57:56 +02:00
TPTBusiness 0a47a667e4 fix: correct project root paths and subprocess handling in parallel runner and CLI
- predix_parallel.py: fix project_root from scripts/ to repo root (parent.parent)
- predix_parallel.py: fix .env loading path and API key distribution logic
- cli.py: fix project_root depth from 4 to 3 .parent hops (7 locations)
- cli.py start_loop: use sys.executable instead of hardcoded python
- cli.py start_loop: replace broad pkill with targeted child process management
- cli.py parallel: remove hardcoded model=local
2026-05-03 08:49:18 +02:00
TPTBusiness 8a945b7ce0 fix: also catch ValueError in mean_variance for dimension mismatch 2026-05-03 00:39:22 +02:00
TPTBusiness 8f27854898 test: add direct unit tests for _apply_ftmo_mask, safe_resolve_path, import_class, and _add_column_if_not_exists 2026-05-03 00:35:57 +02:00
TPTBusiness dcd4697b75 fix: filter NaN in max(), remove redundant ternary, handle non-finite vbt results 2026-05-03 00:25:58 +02:00
TPTBusiness bc15434e02 fix: fix type annotation, remove unused parameter, improve import_class errors 2026-05-03 00:22:16 +02:00
TPTBusiness d44dcb7111 fix: close log file handle, fix FTMO equity double-count, remove bare except 2026-05-03 00:17:02 +02:00
TPTBusiness 15084f593c fix: resolve dead code, shell injection risk, mutable defaults, and other bugs
- strategy_orchestrator.py: remove unreachable dead 'if not factor_values' after early return
- strategy_orchestrator.py: eliminate duplicate OHLVC load in evaluate_strategy
- env.py: escape single-quotes in Docker entry to prevent shell injection (CWE-78)
- env.py: replace mutable default args with None pattern in DockerEnv subclasses
- factor_runner.py: move pandarallel.initialize() from import-time to lazy init
2026-05-02 23:21:38 +02:00
TPTBusiness 20428f7d91 fix: resolve unbound variable, logger shadowing, withdraw_loop edge case, and other bugs in main scripts
- quant.py: guard against empty orch_factors, move strategy_name before try block
- quant_proposal.py: fix __init__ return type Tuple[dict,bool] -> None
- strategy_orchestrator.py: remove dead rdagent_logger import shadowed by getLogger
- factor.py: replace unusual 'not x is None' with idiomatic 'x is not None'
- workflow/loop.py: withdraw_loop(0) raises RuntimeError instead of looking for folder -1
- workflow/tracking.py: replace crash-prone AssertionError with logger.warning + skip
- factor_from_report.py: fix misleading comment about loop_n/step_n dual use
2026-05-02 22:56:29 +02:00
github-actions[bot] 7d97d84100 chore(master): release 1.4.0 (#46)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-01 15:59:06 +02:00
TPTBusiness 9bc525a264 feat(optimizer): add max_positions parameter to Optuna search space
Add max_positions (1-5) as an optimizable hyperparameter across all
three Optuna search stages (coarse, fine, very fine). The parameter
scales effective position size as min(position_size_pct × max_positions,
1.0), allowing the optimizer to discover pyramiding strategies.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 08:53:40 +02:00
TPTBusiness 27803e8b85 fix(auto-fixer): add four new factor code fixes for common runtime errors
- _fix_reset_index_groupby: replace groupby(level=N) on reset_index'd variables
  with groupby('instrument') — fixes ValueError: level > 0 only valid with MultiIndex
- _fix_groupby_mixed_levels: strip string level names from groupby(level=[int, 'str'])
  to fix AssertionError: Level 'date' not in index
- _fix_groupby_column_on_multiindex: convert groupby(['instrument','date']) on
  MultiIndex DataFrames to groupby(level=1) — fixes KeyError on column access
- _fix_rolling_ddof: remove unsupported ddof kwarg from rolling().std()/var()
- fix(proposal): apply history compression to factor_proposal.py (was causing
  131k-token prompts from QlibFactorHypothesis2Experiment; pycache had stale .pyc)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 08:51:59 +02:00
github-actions[bot] 944af06a87 chore(master): release 1.3.3 (#30)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-25 09:25:18 +02:00
TPTBusiness 97e42d7a1a fix(loop): compress old experiment history in proposal prompt to reduce context size
- Summarize all but the 2 most recent experiments to compact bullet lines
  (factor name, PASS/FAIL, IC value, 120-char observation snippet) instead
  of including full verbatim traces; reduces prompt from ~121k to ~40-60k tokens
- Fix _evaluate_factor_directly and _save_factor_values to look for result.h5
  and factor.py in sub_workspace_list instead of experiment_workspace
- Fix Series.to_parquet() → Series.to_frame().to_parquet() in _save_factor_values
- Update factor_data_template README: correct bars-per-day (1440, not 96)
- Update prompts to accept 2024-only debug dataset output as valid factor result
- Fix factor_coder prompts: allow 2024 debug data in date-range instruction

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 09:10:39 +02:00
TPTBusiness 5481e83f03 fix(factors): extend look-ahead rules to session factors and add intraday-factor guidance
- Rule 7 extended: session-based aggregations (London/NY/Asian) must also
  be shifted by 1 trading day before use — same as daily aggregations
- Rule 8 added: prefer pure intraday rolling factors (RSI, Bollinger, VWAP
  deviation, rolling std) that have no look-ahead risk and vary every minute
- predix_full_eval.py: apply _shift_daily_constant_factor_if_needed before IC
- predix_gen_strategies_real_bt.py: improved swing prompt with daily-level
  signal logic guidance for daily-constant factors

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 20:51:07 +02:00
github-actions[bot] 38fa760429 chore(master): release 1.3.1 (#27)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-21 22:42:45 +02:00
TPTBusiness 0ce6f6ec6d fix(deps): bump python-dotenv to >=1.2.2 (CVE symlink overwrite)
Resolves last open Dependabot alert: python-dotenv symlink following
in set_key allows arbitrary file overwrite via cross-device rename.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 22:41:42 +02:00
github-actions[bot] d17d424ee9 chore(master): release 1.3.0 (#22)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-21 22:26:05 +02:00
TPTBusiness 5d8e53d208 fix(security): resolve all 30 Bandit security alerts (B301, B614, B104)
- B301 (pickle): add nosec B301 to pd.read_pickle calls in Kaggle templates
  — files are trusted Kaggle-environment inputs, not user-supplied
- B614 (torch.load): add weights_only=True to all torch.load calls in
  model benchmark GT code and gt_code.py
- B104 (binding 0.0.0.0): change run_server and CLI default to 127.0.0.1;
  add nosec comment where all-interface binding is required for Docker

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 22:24:57 +02:00
dependabot[bot] 7880a9315a chore(deps): Bump actions/setup-python from 5 to 6 (#23)
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '6'
  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-20 18:52:51 +02:00
dependabot[bot] 17bba1a920 chore(deps): Bump codacy/codacy-analysis-cli-action from 1.1.0 to 4.4.7 (#24)
Bumps [codacy/codacy-analysis-cli-action](https://github.com/codacy/codacy-analysis-cli-action) from 1.1.0 to 4.4.7.
- [Release notes](https://github.com/codacy/codacy-analysis-cli-action/releases)
- [Commits](https://github.com/codacy/codacy-analysis-cli-action/compare/d840f886c4bd4edc059706d09c6a1586111c540b...562ee3e92b8e92df8b67e0a5ff8aa8e261919c08)

---
updated-dependencies:
- dependency-name: codacy/codacy-analysis-cli-action
  dependency-version: 4.4.7
  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-20 18:52:48 +02:00
dependabot[bot] 360df4083a chore(deps): Bump actions/checkout from 4 to 6 (#25)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  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-20 18:52:45 +02:00
dependabot[bot] e2c2fefe9a chore(deps): Bump actions/upload-pages-artifact from 3 to 5 (#26)
Bumps [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) from 3 to 5.
- [Release notes](https://github.com/actions/upload-pages-artifact/releases)
- [Commits](https://github.com/actions/upload-pages-artifact/compare/v3...v5)

---
updated-dependencies:
- dependency-name: actions/upload-pages-artifact
  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-20 18:52:36 +02:00
TPTBusiness 32f7d66e07 feat(backtest): add rolling walk-forward validation and Monte Carlo trade permutation test
- monte_carlo_trade_pvalue(): shuffles trade P&L N times, returns fraction of
  permuted sequences that beat real total return (p<0.05 = genuine edge)
- walk_forward_rolling(): multiple IS/OOS windows (IS=3yr, OOS=1yr, step=1yr),
  computes wf_oos_sharpe_mean, wf_oos_consistency (% profitable windows)
- backtest_signal_ftmo(): new wf_rolling and mc_n_permutations params
- Strategy generator: enables both (200 MC permutations), adds mc_ok and wf_ok
  to acceptance filter (mc_p<0.20, wf_consistency>=50%)
- Rebacktest script: enables both, stores all wf_*/mc_* fields in write-back
- 6 new tests covering MC pvalue, disabled-by-default, zero-trades edge case,
  rolling WF key presence and consistency range

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 18:59:00 +02:00
TPTBusiness 4d6ef04411 test(backtest): add FTMO and OOS walk-forward validation tests
Covers backtest_signal_ftmo leverage caps, zero-signal, IS/OOS split keys,
bar counts, OOS independence from IS losses, and Monte Carlo permutation
tests (marked slow, excluded from default pytest run).

Also excludes slow-marked tests from default addopts in pyproject.toml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 18:26:32 +02:00
github-actions[bot] a757eb4b79 chore(master): release 1.2.2 (#21)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-19 15:47:03 +02:00
TPTBusiness 1dc44d33ed chore(release): reset version to 1.2.1 2026-04-19 15:32:47 +02:00
120 changed files with 7913 additions and 1107 deletions
+3 -3
View File
@@ -14,7 +14,7 @@ jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Run Bandit (Security Scan)
uses: PyCQA/bandit-action@v1
@@ -25,9 +25,9 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: "3.10"
cache: "pip"
+2 -2
View File
@@ -36,11 +36,11 @@ jobs:
steps:
# Checkout the repository to the GitHub Actions runner
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
# Execute Codacy Analysis CLI and generate a SARIF output with the security issues identified during the analysis
- name: Run Codacy Analysis CLI
uses: codacy/codacy-analysis-cli-action@d840f886c4bd4edc059706d09c6a1586111c540b
uses: codacy/codacy-analysis-cli-action@562ee3e92b8e92df8b67e0a5ff8aa8e261919c08
env:
JAVA_TOOL_OPTIONS: "-Dfile.encoding=UTF-8"
with:
+1 -1
View File
@@ -46,7 +46,7 @@ jobs:
name: Validate Commit Messages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
+3 -3
View File
@@ -25,10 +25,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.10"
@@ -64,7 +64,7 @@ jobs:
- name: Upload docs artifact
if: github.ref == 'refs/heads/main'
uses: actions/upload-pages-artifact@v3
uses: actions/upload-pages-artifact@v5
with:
path: docs/_build/html
+2 -2
View File
@@ -16,10 +16,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.10"
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
release-please:
runs-on: ubuntu-latest
steps:
- uses: googleapis/release-please-action@v4
- uses: googleapis/release-please-action@v5
with:
token: ${{ secrets.GITHUB_TOKEN }}
config-file: release-please-config.json
+4 -4
View File
@@ -19,9 +19,9 @@ jobs:
python-version: ["3.10", "3.11"]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: "pip"
@@ -49,9 +49,9 @@ jobs:
name: Dependency Audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: "3.10"
cache: "pip"
+2 -2
View File
@@ -19,10 +19,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.10"
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "2.2.0"
".": "1.4.2"
}
+167
View File
@@ -1,5 +1,172 @@
# Changelog
## [1.4.2](https://github.com/TPTBusiness/Predix/compare/v1.4.1...v1.4.2) (2026-05-03)
### Bug Fixes
* add missing sys import and fix undefined acc_rate in factor eval ([c45f990](https://github.com/TPTBusiness/Predix/commit/c45f9908ee321400f0a19c57f1482e4cd1394a50))
## [1.4.1](https://github.com/TPTBusiness/Predix/compare/v1.4.0...v1.4.1) (2026-05-03)
### Bug Fixes
* 15 bug fixes across orchestrator, runner, backtest, and infrastructure ([163687d](https://github.com/TPTBusiness/Predix/commit/163687d7e1c278a085d7052a3f958a3edb501e77))
* also catch ValueError in mean_variance for dimension mismatch ([ed73b72](https://github.com/TPTBusiness/Predix/commit/ed73b7253f7dc6459ee30dd81a1ce1194e46e9af))
* close log file handle, fix FTMO equity double-count, remove bare except ([76219a5](https://github.com/TPTBusiness/Predix/commit/76219a53efddaafc2b8bd48a0f76c1d4325e6ea5))
* correct project root paths and subprocess handling in parallel runner and CLI ([9735e3a](https://github.com/TPTBusiness/Predix/commit/9735e3a4d8f01e7b16fb9b185a002396a915cea4))
* filter NaN in max(), remove redundant ternary, handle non-finite vbt results ([f89fbb3](https://github.com/TPTBusiness/Predix/commit/f89fbb3421faf6ccdc8e68a911fd9db2c166120f))
* fix type annotation, remove unused parameter, improve import_class errors ([8b6ab73](https://github.com/TPTBusiness/Predix/commit/8b6ab735c05629bf6b76ddc2fd8b15617600cad7))
* resolve dead code, shell injection risk, mutable defaults, and other bugs ([afff262](https://github.com/TPTBusiness/Predix/commit/afff26287f7c4df7ddfde4e816d280fe845e11eb))
* resolve unbound variable, logger shadowing, withdraw_loop edge case, and other bugs in main scripts ([748cf9b](https://github.com/TPTBusiness/Predix/commit/748cf9b214a3e8447f1289fc4cf1e92ad6cc2f1a))
## [1.4.0](https://github.com/TPTBusiness/Predix/compare/v1.3.11...v1.4.0) (2026-05-01)
### Features
* **optimizer:** add max_positions parameter to Optuna search space ([fdb4be3](https://github.com/TPTBusiness/Predix/commit/fdb4be3b3ebd93325e7821f4251148424184a40d))
## [1.3.11](https://github.com/TPTBusiness/Predix/compare/v1.3.10...v1.3.11) (2026-05-01)
### Bug Fixes
* **ci:** lazy import logger in predix.py and cli.py to avoid ImportError in test env ([60763e8](https://github.com/TPTBusiness/Predix/commit/60763e8eae34f41865ba8e5e65bdfde13b564b4b))
## [1.3.10](https://github.com/TPTBusiness/Predix/compare/v1.3.9...v1.3.10) (2026-05-01)
### Bug Fixes
* **security:** replace remaining assert statements with proper error handling ([928533d](https://github.com/TPTBusiness/Predix/commit/928533d9a81bd5062f07458fbf94d3c7fe347775))
## [1.3.9](https://github.com/TPTBusiness/Predix/compare/v1.3.8...v1.3.9) (2026-05-01)
### Bug Fixes
* **security:** resolve path-injection, B701, B101, B112 Bandit alerts ([20b89a0](https://github.com/TPTBusiness/Predix/commit/20b89a061843b39836e975f158404e8e2d4627cd))
## [1.3.8](https://github.com/TPTBusiness/Predix/compare/v1.3.7...v1.3.8) (2026-04-30)
### Bug Fixes
* **deps:** relax aiohttp constraint to &gt;=3.13.4 for litellm compatibility ([34ab192](https://github.com/TPTBusiness/Predix/commit/34ab1923a887089eb36e5cbad6cb8df16f0333ca))
* **qlib:** correct indentation in except blocks in quant_proposal and factor_runner ([8143451](https://github.com/TPTBusiness/Predix/commit/8143451e8c0ead01c4d86d19669268c7bfb15fac))
* **security:** replace eval() with ast.literal_eval in finetune validator (B307) ([0508caf](https://github.com/TPTBusiness/Predix/commit/0508caf9140d210b823fefefa28ee535ec85a0ae))
* **security:** replace shell=True subprocess calls with list args in env.py (B602) ([2012d5a](https://github.com/TPTBusiness/Predix/commit/2012d5ae4e77cc2f1ab9a48beaaac5a74695d083))
* **security:** resolve path-injection and add nosec for safe temp paths (B108, py/path-injection) ([6727480](https://github.com/TPTBusiness/Predix/commit/67274803bd1d14e5d1df9a063f46b2edb8501a2b))
## [1.3.7](https://github.com/TPTBusiness/Predix/compare/v1.3.6...v1.3.7) (2026-04-30)
### Bug Fixes
* **security:** nosec for B608/B701 false positives in UI and template code ([5eb5d7e](https://github.com/TPTBusiness/Predix/commit/5eb5d7e8fdbe90e0dced83fef4e09f5a33e96b2b))
* **security:** replace eval() with ast.literal_eval and add request timeouts (B307, B113) ([3301ada](https://github.com/TPTBusiness/Predix/commit/3301ada697ca7d3afa1a188d2a76a87ae98b4529))
* **security:** replace shell=True subprocess calls with list args (B602) ([13c08f4](https://github.com/TPTBusiness/Predix/commit/13c08f4ce6813eb7c314087921ec8c0f40074bd7))
## [1.3.6](https://github.com/TPTBusiness/Predix/compare/v1.3.5...v1.3.6) (2026-04-30)
### Bug Fixes
* **security:** real fix for B110 (logging in factor_proposal.py [#746](https://github.com/TPTBusiness/Predix/issues/746)) ([16624e0](https://github.com/TPTBusiness/Predix/commit/16624e0bd966ae4d24c4a3eb42bbc31c11da3136))
* **security:** real fix for B110 (logging in factor_runner.py [#744](https://github.com/TPTBusiness/Predix/issues/744)) ([88cf0fb](https://github.com/TPTBusiness/Predix/commit/88cf0fb8828b11c97f2f3ae2881a4900b020c6f0))
* **security:** real fix for B110 (logging in quant_proposal.py [#741](https://github.com/TPTBusiness/Predix/issues/741)) ([7cf2a64](https://github.com/TPTBusiness/Predix/commit/7cf2a644f553b054bd4b0607ea51e5372e68d90a))
* **security:** real fix for B110 (logging in quant_proposal.py [#741](https://github.com/TPTBusiness/Predix/issues/741)) ([ef985f8](https://github.com/TPTBusiness/Predix/commit/ef985f86035d8dca707c60137e6508349a0c4ae6))
* **security:** real fix for B404/B603 (sys.executable in factor_runner.py [#745](https://github.com/TPTBusiness/Predix/issues/745)) ([819655a](https://github.com/TPTBusiness/Predix/commit/819655aaa3efa76596d60501d0e8ca365df3e5e2))
* **security:** revert broken read_pickle encoding arg in kaggle template (B301) ([3574907](https://github.com/TPTBusiness/Predix/commit/35749073c91e69f63ddaad61dae3f2b799327e63))
* **security:** validate SQL identifiers in _add_column_if_not_exists (B608) ([e10dfa2](https://github.com/TPTBusiness/Predix/commit/e10dfa2576038e911f83595d3b466c261bc0cd54))
* **security:** whitelist-validate metric column in get_top_factors (B608) ([e50519f](https://github.com/TPTBusiness/Predix/commit/e50519fe066e68aec2f19b83df4f643c3c22053d))
## [1.3.5](https://github.com/TPTBusiness/Predix/compare/v1.3.4...v1.3.5) (2026-04-27)
### Bug Fixes
* **auto-fixer:** add five new factor code fixes for groupby/apply errors ([449c8fd](https://github.com/TPTBusiness/Predix/commit/449c8fd70a327e604dcca122e4a134f0cca918e4))
* **auto-fixer:** add four new factor code fixes for common runtime errors ([40484f6](https://github.com/TPTBusiness/Predix/commit/40484f6d300425da481f1edd325da4acbc06ec7d))
* **auto-fixer:** add groupby([level=N,'date']) SyntaxError fix ([ca77c00](https://github.com/TPTBusiness/Predix/commit/ca77c005bea4abdd8854c1de2b0e8d03b7742161))
* **auto-fixer:** disable _fix_min_periods for intraday data ([77b0740](https://github.com/TPTBusiness/Predix/commit/77b0740f059349df7e769a378af728aa33b2070e))
* **auto-fixer:** fix chained groupby(level=N).groupby('date') pattern ([7d5fe32](https://github.com/TPTBusiness/Predix/commit/7d5fe32b31a19ce8b04bd8f5a430720fdb748f7a))
* **auto-fixer:** fix df.loc[instrument] DateParseError on MultiIndex frames ([b7860ea](https://github.com/TPTBusiness/Predix/commit/b7860eafc0ad26384947ce0510ecf4e9f3425807))
* **auto-fixer:** fix df['instrument'] KeyError on MultiIndex frames ([aad6bd1](https://github.com/TPTBusiness/Predix/commit/aad6bd1c7c720b3d486e0cf248337f32394773b1))
* **auto-fixer:** fix two assignment-target bugs in instrument column fixers ([421eedf](https://github.com/TPTBusiness/Predix/commit/421eedffed4b883c24397dc5581c019a3985277f))
* **auto-fixer:** preserve date dimension in groupby(['instrument','date']) fix ([b58fdd8](https://github.com/TPTBusiness/Predix/commit/b58fdd8be43720b5d4363e0f8de9a01591d4d2dc))
* **auto-fixer:** remove ddof from rolling() args, not only from std()/var() ([b0fc328](https://github.com/TPTBusiness/Predix/commit/b0fc328d0d4a041c65d8eeb32cb3f2bb86568406))
* **auto-fixer:** strip spurious .reset_index() after .transform() calls ([8708aae](https://github.com/TPTBusiness/Predix/commit/8708aae6e08728cda1875c775a76dc92e43576f3))
* **loop:** prevent step_idx advance on unhandled exceptions + fix consecutive assistant messages ([5ec4ad1](https://github.com/TPTBusiness/Predix/commit/5ec4ad1b96b5b99ef42bea7bb828cb1ef709a688))
## [1.3.4](https://github.com/TPTBusiness/Predix/compare/v1.3.3...v1.3.4) (2026-04-27)
### Bug Fixes
* **auto-fixer:** add five new factor code fixes for groupby/apply errors ([449c8fd](https://github.com/TPTBusiness/Predix/commit/449c8fd70a327e604dcca122e4a134f0cca918e4))
* **auto-fixer:** add four new factor code fixes for common runtime errors ([40484f6](https://github.com/TPTBusiness/Predix/commit/40484f6d300425da481f1edd325da4acbc06ec7d))
* **auto-fixer:** add groupby([level=N,'date']) SyntaxError fix ([ca77c00](https://github.com/TPTBusiness/Predix/commit/ca77c005bea4abdd8854c1de2b0e8d03b7742161))
* **auto-fixer:** disable _fix_min_periods for intraday data ([77b0740](https://github.com/TPTBusiness/Predix/commit/77b0740f059349df7e769a378af728aa33b2070e))
* **auto-fixer:** fix chained groupby(level=N).groupby('date') pattern ([7d5fe32](https://github.com/TPTBusiness/Predix/commit/7d5fe32b31a19ce8b04bd8f5a430720fdb748f7a))
* **auto-fixer:** fix df.loc[instrument] DateParseError on MultiIndex frames ([b7860ea](https://github.com/TPTBusiness/Predix/commit/b7860eafc0ad26384947ce0510ecf4e9f3425807))
* **auto-fixer:** fix df['instrument'] KeyError on MultiIndex frames ([aad6bd1](https://github.com/TPTBusiness/Predix/commit/aad6bd1c7c720b3d486e0cf248337f32394773b1))
* **auto-fixer:** preserve date dimension in groupby(['instrument','date']) fix ([b58fdd8](https://github.com/TPTBusiness/Predix/commit/b58fdd8be43720b5d4363e0f8de9a01591d4d2dc))
* **auto-fixer:** remove ddof from rolling() args, not only from std()/var() ([b0fc328](https://github.com/TPTBusiness/Predix/commit/b0fc328d0d4a041c65d8eeb32cb3f2bb86568406))
* **backtest:** replace broken MC permutation test with binomial win-rate test ([c38d894](https://github.com/TPTBusiness/Predix/commit/c38d89478f586825bfca5715a96ca70ccd8791a3))
* **factors:** detect and correct look-ahead bias in daily-constant factors ([eb490a4](https://github.com/TPTBusiness/Predix/commit/eb490a461b66cbd815ae53ac5205115754712432))
* **factors:** extend look-ahead rules to session factors and add intraday-factor guidance ([c24c100](https://github.com/TPTBusiness/Predix/commit/c24c100442d6487686c0578de0b32d240fcbf215))
* **loop:** compress old experiment history in proposal prompt to reduce context size ([4bf90a9](https://github.com/TPTBusiness/Predix/commit/4bf90a905ba8b2aba2a818191c19998088cccaaf))
* **loop:** prevent step_idx advance on unhandled exceptions + fix consecutive assistant messages ([5ec4ad1](https://github.com/TPTBusiness/Predix/commit/5ec4ad1b96b5b99ef42bea7bb828cb1ef709a688))
## [1.3.3](https://github.com/TPTBusiness/Predix/compare/v1.3.2...v1.3.3) (2026-04-25)
### Bug Fixes
* **backtest:** replace broken MC permutation test with binomial win-rate test ([c38d894](https://github.com/TPTBusiness/Predix/commit/c38d89478f586825bfca5715a96ca70ccd8791a3))
* **factors:** detect and correct look-ahead bias in daily-constant factors ([eb490a4](https://github.com/TPTBusiness/Predix/commit/eb490a461b66cbd815ae53ac5205115754712432))
* **factors:** extend look-ahead rules to session factors and add intraday-factor guidance ([c24c100](https://github.com/TPTBusiness/Predix/commit/c24c100442d6487686c0578de0b32d240fcbf215))
* **loop:** compress old experiment history in proposal prompt to reduce context size ([4bf90a9](https://github.com/TPTBusiness/Predix/commit/4bf90a905ba8b2aba2a818191c19998088cccaaf))
* **strategies:** guard against None IC in acceptance check, disable slow wf_rolling ([2197f52](https://github.com/TPTBusiness/Predix/commit/2197f52150a50ef38d9e70991d7e48c8c30caec4))
* **strategies:** handle None ic/sharpe/dd in rejected strategy log output ([ad2ad3a](https://github.com/TPTBusiness/Predix/commit/ad2ad3ab3360ea75ed3bbc90c12098b9c5cc0114))
## [1.3.2](https://github.com/TPTBusiness/Predix/compare/v1.3.1...v1.3.2) (2026-04-23)
### Bug Fixes
* **strategies:** guard against None IC in acceptance check, disable slow wf_rolling ([2197f52](https://github.com/TPTBusiness/Predix/commit/2197f52150a50ef38d9e70991d7e48c8c30caec4))
* **strategies:** handle None ic/sharpe/dd in rejected strategy log output ([ad2ad3a](https://github.com/TPTBusiness/Predix/commit/ad2ad3ab3360ea75ed3bbc90c12098b9c5cc0114))
## [1.3.1](https://github.com/TPTBusiness/Predix/compare/v1.3.0...v1.3.1) (2026-04-21)
### Bug Fixes
* **deps:** bump python-dotenv to &gt;=1.2.2 (CVE symlink overwrite) ([126ae7d](https://github.com/TPTBusiness/Predix/commit/126ae7d5fb556b677d09d10221862a0d648d697a))
## [1.3.0](https://github.com/TPTBusiness/Predix/compare/v1.2.2...v1.3.0) (2026-04-21)
### Features
* **backtest:** add rolling walk-forward validation and Monte Carlo trade permutation test ([637a94c](https://github.com/TPTBusiness/Predix/commit/637a94c1d987da763869f4f9b73372a3f37d873c))
### Bug Fixes
* **security:** resolve all 30 Bandit security alerts (B301, B614, B104) ([ce5983d](https://github.com/TPTBusiness/Predix/commit/ce5983d9d59c4c34341fb1ec749e44bbcfc4a1c4))
## [1.2.2](https://github.com/TPTBusiness/Predix/compare/v1.2.1...v1.2.2) (2026-04-19)
### Documentation
* **claude:** auto-merge release-please PR after every push ([f500917](https://github.com/TPTBusiness/Predix/commit/f500917b699ee78dc676e84e01574d49bdc8e796))
## [2.2.0](https://github.com/TPTBusiness/Predix/compare/v2.1.0...v2.2.0) (2026-04-18)
+118 -95
View File
@@ -13,11 +13,19 @@ import sys
from pathlib import Path
from dotenv import load_dotenv
load_dotenv(Path(__file__).parent / ".env")
import typer
from rich.console import Console
try:
from rdagent.utils.env import logger
except ImportError:
import logging
logger = logging.getLogger(__name__)
app = typer.Typer(help="Predix - AI Quantitative Trading Agent")
console = Console()
@@ -107,7 +115,7 @@ def _ensure_kronos_factor_in_pool(con) -> None:
color = "green" if abs(ic) > 0.01 else "yellow"
con.print(
f" [bold {color}]Kronos Factor ready:[/bold {color}] IC={ic:.4f}, "
f"Hit-Rate={hit_rate:.1%} — added to strategy pool"
f"Hit-Rate={hit_rate:.1%} — added to strategy pool",
)
except Exception as e:
@@ -194,9 +202,9 @@ def quant(
predix health - Check system health and configuration
"""
import subprocess
import sys
import threading
import time
import sys
# ---- Parallel Run Isolation ----
# When run_id > 0, isolate all outputs (logs, results, workspace)
@@ -219,10 +227,9 @@ def quant(
console.print(f" [dim]Log: {log_file}[/dim]")
console.print(f" [dim]Results: results/runs/run{run_id}/[/dim]")
console.print(f" [dim]Workspace: {workspace_dir.name}/[/dim]")
else:
# Single run mode: default log file
if log_file is None:
log_file = "fin_quant.log"
# Single run mode: default log file
elif log_file is None:
log_file = "fin_quant.log"
# ---- Log File Setup (daily-rotated) ----
from datetime import datetime as _dt
@@ -230,10 +237,14 @@ def quant(
_daily_dir = Path(__file__).parent / "logs" / _today
_daily_dir.mkdir(parents=True, exist_ok=True)
_log_f = None
_orig_stdout = sys.stdout
_orig_stderr = sys.stderr
if log_file.lower() != "none":
log_path = _daily_dir / log_file
# Open log file for appending (raw stdout/stderr capture)
log_f = open(log_path, "a", encoding="utf-8")
_log_f = open(log_path, "a", encoding="utf-8")
# Redirect stdout and stderr to both console and log file
class TeeWriter:
@@ -245,18 +256,18 @@ def quant(
try:
s.write(data)
s.flush()
except:
except Exception:
pass
def flush(self):
for s in self._streams:
try:
s.flush()
except:
except Exception:
pass
sys.stdout = TeeWriter(sys.__stdout__, log_f)
sys.stderr = TeeWriter(sys.__stderr__, log_f)
sys.stdout = TeeWriter(_orig_stdout, _log_f)
sys.stderr = TeeWriter(_orig_stderr, _log_f)
console.print(f"\n[dim]📝 Logging to: logs/{_today}/{log_file}[/dim]")
else:
@@ -269,7 +280,7 @@ def quant(
if not api_key:
console.print("\n[bold red]❌ OPENROUTER_API_KEY not set in .env[/bold red]")
console.print("[yellow]Add your API key to .env:[/yellow]")
console.print(' OPENROUTER_API_KEY=sk-or-your-key-here')
console.print(" OPENROUTER_API_KEY=sk-or-your-key-here")
raise typer.Exit(code=1)
# Setup both API keys for load balancing
@@ -282,7 +293,7 @@ def quant(
os.environ["LITELLM_PARALLEL_CALLS"] = "2"
console.print(f"\n[bold blue]🌐 Using OpenRouter (2 API Keys):[/bold blue] [cyan]{os.environ['CHAT_MODEL']}[/cyan]")
console.print(f" [dim]Keys: {api_key[:15]}*** + {api_key_2[:15]}***[/dim]")
console.print(f" [dim]Parallel: 2 concurrent requests[/dim]")
console.print(" [dim]Parallel: 2 concurrent requests[/dim]")
else:
os.environ["OPENAI_API_KEY"] = api_key
console.print(f"\n[bold blue]🌐 Using OpenRouter:[/bold blue] [cyan]{os.environ['CHAT_MODEL']}[/cyan]")
@@ -300,7 +311,7 @@ def quant(
# ---- Dashboards ----
if dashboard:
def start_web_dashboard():
console.print(f"\n[bold green]🚀 Web Dashboard: http://localhost:5000[/bold green]")
console.print("\n[bold green]🚀 Web Dashboard: http://localhost:5000[/bold green]")
subprocess.run(
["python", "web/dashboard_api.py"],
cwd=str(Path(__file__).parent),
@@ -325,7 +336,7 @@ def quant(
from rdagent.app.qlib_rd_loop.quant import main as fin_quant
from rdagent.log.daily_log import session as _daily_session
console.print(f"\n[bold cyan]📊 Starting EURUSD Trading Loop...[/bold cyan]\n")
console.print("\n[bold cyan]📊 Starting EURUSD Trading Loop...[/bold cyan]\n")
_ctx = {"model": model}
if run_id:
@@ -335,11 +346,17 @@ def quant(
if step_n:
_ctx["steps"] = step_n
with _daily_session("fin_quant", **_ctx):
fin_quant(
step_n=step_n,
loop_n=loop_n,
)
try:
with _daily_session("fin_quant", **_ctx):
fin_quant(
step_n=step_n,
loop_n=loop_n,
)
finally:
if _log_f is not None:
sys.stdout = _orig_stdout
sys.stderr = _orig_stderr
_log_f.close()
@app.command()
@@ -408,8 +425,8 @@ def evaluate(
predix portfolio - Select a diversified portfolio of uncorrelated factors
predix quant - Generate new factors via LLM trading loop
"""
from rich.panel import Panel
from rdagent.log.daily_log import session as _daily_session
from rich.panel import Panel
console.print(Panel(
"[bold cyan]📊 Predix Factor Evaluator[/bold cyan]\n"
@@ -489,11 +506,12 @@ def top(
predix portfolio - Select diversified portfolio from top factors
predix build-strategies - Combine factors into trading strategies
"""
import json
import glob as glob_module
import json
import numpy as np
from rich.table import Table
from rich.panel import Panel
from rich.table import Table
factors_dir = Path(__file__).parent / "results" / "factors"
if not factors_dir.exists():
@@ -510,6 +528,7 @@ def top(
if data.get("status") == "success" and data.get("ic") is not None:
results.append(data)
except Exception:
logger.warning("Failed to load factor file %s", f, exc_info=True)
continue
if not results:
@@ -557,9 +576,11 @@ def top(
console.print(table)
# Summary
valid_ic = [r.get("ic") for r in results if r.get("ic") is not None]
valid_sharpe = [r.get("sharpe") for r in results if r.get("sharpe") is not None]
# Summary — filter None, NaN, and non-numeric values
valid_ic = [v for v in (r.get("ic") for r in results)
if isinstance(v, (int, float)) and v is not None and not np.isnan(v)]
valid_sharpe = [v for v in (r.get("sharpe") for r in results)
if isinstance(v, (int, float)) and v is not None and not np.isnan(v)]
# Filter extreme outliers for average
valid_sharpe_filtered = [s for s in valid_sharpe if abs(s or 0) < 1e6]
@@ -634,16 +655,16 @@ def portfolio(
predix top - View top factors before portfolio selection
predix build-strategies - Build strategies from selected factors
"""
import json
import glob as glob_module
import json
import shutil
import subprocess
import tempfile
import shutil
import numpy as np
import pandas as pd
from rich.table import Table
from rich.panel import Panel
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn, TimeElapsedColumn
from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn, TimeElapsedColumn
from rich.table import Table
factors_dir = Path(__file__).parent / "results" / "factors"
if not factors_dir.exists():
@@ -659,6 +680,7 @@ def portfolio(
if data.get("status") == "success" and data.get("ic") is not None:
results.append(data)
except Exception:
logger.warning("Failed to load factor file %s", f, exc_info=True)
continue
if not results:
@@ -674,12 +696,12 @@ def portfolio(
# 2. Evaluate candidates to get time-series values for correlation
# We need to run the factor code to get the series of values.
# We do this sequentially to avoid OOM.
# Locate data file
data_file = Path(__file__).parent / "git_ignore_folder" / "factor_implementation_source_data" / "intraday_pv.h5"
if not data_file.exists():
data_file = Path(__file__).parent / "git_ignore_folder" / "factor_implementation_source_data_debug" / "intraday_pv.h5"
if not data_file.exists():
console.print("[red]Source data file (intraday_pv.h5) not found.[/red]")
return
@@ -696,11 +718,11 @@ def portfolio(
console=console,
) as progress:
task = progress.add_task(f"Computing values for {len(candidates)} factors...", total=len(candidates))
for cand in candidates:
fname = cand.get("factor_name", "unknown")
fcode = cand.get("factor_code", "")
if not fcode:
errors.append((fname, "No code in JSON"))
progress.advance(task)
@@ -716,10 +738,10 @@ def portfolio(
# If symlink fails, copy the file
import shutil
shutil.copy(str(data_file), str(tmp_path / "intraday_pv.h5"))
# Write code
(tmp_path / "factor.py").write_text(fcode)
try:
# Run factor
result = subprocess.run(
@@ -727,16 +749,16 @@ def portfolio(
cwd=tmp_path,
capture_output=True,
text=True,
timeout=120 # 2 min timeout per factor
timeout=120, # 2 min timeout per factor
)
# Read result
res_file = tmp_path / "result.h5"
if res_file.exists():
df = pd.read_hdf(str(res_file), key="data")
# Get the series (first column)
series = df.iloc[:, 0]
# Count non-NaN values
non_nan = series.count()
if non_nan < 1000:
@@ -756,7 +778,7 @@ def portfolio(
except Exception as e:
errors.append((fname, str(e)[:100]))
progress.update(task, description=f"{fname} ❌ (Error)")
progress.advance(task)
# Show summary of errors
@@ -770,7 +792,7 @@ def portfolio(
if len(factor_series) < 3:
console.print("[red]Not enough valid factor series to build portfolio (need at least 3).[/red]")
console.print("[yellow]Tip: Factors might be producing mostly NaN values or failing execution.[/yellow]")
# Fallback: Show top factors by IC without diversification
console.print("\n[dim]Showing top factors by IC instead:[/dim]")
table = Table(
@@ -788,50 +810,49 @@ def portfolio(
str(i),
cand.get("factor_name", "unknown")[:38],
f"{cand.get('ic', 0):.6f}",
f"{cand.get('sharpe', 0):.4f}" if cand.get('sharpe') else "N/A",
f"{cand.get('sharpe', 0):.4f}" if cand.get("sharpe") else "N/A",
)
console.print(table)
return
# 3. Build Correlation Matrix
console.print(f"\n[dim]Building correlation matrix from {len(factor_series)} factors...[/dim]")
# Align indices and drop NaN
combined = pd.DataFrame(factor_series).dropna()
if combined.empty or len(combined) < 100:
console.print("[red]Not enough valid overlapping data to compute correlation.[/red]")
console.print("[dim]This means the factors produce values at different times or have too many NaN values.[/dim]")
return
corr_matrix = combined.corr().fillna(0)
ic_map = {cand['factor_name']: cand.get('ic', 0) for cand in candidates}
ic_map = {cand["factor_name"]: cand.get("ic", 0) for cand in candidates}
# 4. Greedy Selection
selected = []
remaining = list(corr_matrix.columns)
# Sort remaining by IC to prioritize high IC factors
remaining.sort(key=lambda x: abs(ic_map.get(x, 0)), reverse=True)
for factor in remaining:
if len(selected) >= target:
break
# If it's the first one, just take it
if not selected:
selected.append(factor)
continue
# Check correlation with already selected
# We want max(|corr|) < max_corr
max_c = 0
for sel in selected:
c = abs(corr_matrix.loc[factor, sel])
if c > max_c:
max_c = c
max_c = max(max_c, c)
if max_c < max_corr:
selected.append(factor)
@@ -849,23 +870,23 @@ def portfolio(
for i, fname in enumerate(selected, 1):
# Find original data for display
data = next((c for c in candidates if c['factor_name'] == fname), {})
ic = data.get('ic')
sharpe = data.get('sharpe')
data = next((c for c in candidates if c["factor_name"] == fname), {})
ic = data.get("ic")
sharpe = data.get("sharpe")
# Calculate max corr with other selected factors
max_c_val = 0
for s in selected:
if s != fname:
val = abs(corr_matrix.loc[fname, s])
if val > max_c_val: max_c_val = val
max_c_val = max(max_c_val, val)
table.add_row(
str(i),
fname[:38],
f"{ic:.6f}" if ic is not None else "N/A",
f"{sharpe:.4f}" if sharpe is not None else "N/A",
f"{max_c_val:.4f}" if max_c_val > 0 else "-"
f"{max_c_val:.4f}" if max_c_val > 0 else "-",
)
console.print(table)
@@ -875,20 +896,20 @@ def portfolio(
"selected_factors": selected,
"max_correlation": max_corr,
"pool_size": top,
"timestamp": pd.Timestamp.now().isoformat()
"timestamp": pd.Timestamp.now().isoformat(),
}
out_dir = Path(__file__).parent / "results" / "portfolio"
out_dir.mkdir(parents=True, exist_ok=True)
out_file = out_dir / "selected_factors.json"
with open(out_file, "w") as f:
json.dump(portfolio_data, f, indent=2)
console.print(Panel(
f"[bold]Portfolio saved to results/portfolio/selected_factors.json[/bold]\n"
f"Selected {len(selected)} unique factors from {top} candidates.",
border_style="green"
border_style="green",
))
@@ -934,13 +955,12 @@ def portfolio_simple(
predix top - View top factors before portfolio selection
predix build-strategies - Build strategies from selected factors
"""
import json
import glob as glob_module
import re
import numpy as np
import json
import pandas as pd
from rich.table import Table
from rich.panel import Panel
from rich.table import Table
factors_dir = Path(__file__).parent / "results" / "factors"
if not factors_dir.exists():
@@ -956,6 +976,7 @@ def portfolio_simple(
if data.get("status") == "success" and data.get("ic") is not None:
results.append(data)
except Exception:
logger.warning("Failed to load factor file %s", f, exc_info=True)
continue
if not results:
@@ -983,14 +1004,14 @@ def portfolio_simple(
for cand in candidates:
fname = cand.get("factor_name", "").lower()
assigned = False
# Check each category's keywords
for cat, keywords in categories.items():
if any(kw in fname for kw in keywords):
categorized[cat].append(cand)
assigned = True
break
if not assigned:
categorized["other"].append(cand)
@@ -1001,7 +1022,7 @@ def portfolio_simple(
best = categorized[cat][0] # Already sorted by IC
selected.append({
"factor": best,
"category": cat.capitalize() if cat != "other" else "Other"
"category": cat.capitalize() if cat != "other" else "Other",
})
# 5. Display Results
@@ -1024,7 +1045,7 @@ def portfolio_simple(
cand.get("factor_name", "unknown")[:38],
cat,
f"{cand.get('ic', 0):.6f}",
f"{cand.get('sharpe', 0):.4f}" if cand.get('sharpe') else "N/A",
f"{cand.get('sharpe', 0):.4f}" if cand.get("sharpe") else "N/A",
)
console.print(table)
@@ -1034,7 +1055,7 @@ def portfolio_simple(
"selected_factors": [item["factor"]["factor_name"] for item in selected],
"categories": {item["category"]: item["factor"]["factor_name"] for item in selected},
"method": "simple_keyword_categorization",
"timestamp": str(pd.Timestamp.now().isoformat())
"timestamp": str(pd.Timestamp.now().isoformat()),
}
out_dir = Path(__file__).parent / "results" / "portfolio"
@@ -1047,7 +1068,7 @@ def portfolio_simple(
console.print(Panel(
f"[bold]Simple Portfolio saved to results/portfolio/portfolio_simple.json[/bold]\n"
f"Selected {len(selected)} factors across {len([c for c in categorized if categorized[c]])} categories.",
border_style="green"
border_style="green",
))
@@ -1110,12 +1131,10 @@ def build_strategies(
predix portfolio - Select diversified factors before combining
predix top - View top factors before building strategies
"""
import pandas as pd
import numpy as np
from rich.table import Table
from rich.panel import Panel
from rdagent.scenarios.qlib.developer.strategy_builder import StrategyBuilder
from rich.panel import Panel
from rich.table import Table
console.print(Panel(
"[bold cyan]🏗️ Predix Strategy Builder[/bold cyan]\n"
@@ -1271,9 +1290,10 @@ def build_strategies_ai(
predix quant - Generate new alpha factors via LLM trading loop
predix evaluate - Evaluate factors before strategy building
"""
from rich.panel import Panel
from pathlib import Path
from rich.panel import Panel
console.print(Panel(
"[bold cyan]🧠 StrategyCoSTEER - AI Strategy Builder[/bold cyan]\n"
"Generating trading strategies from existing factors\n"
@@ -1299,7 +1319,7 @@ def build_strategies_ai(
# Setup LLM environment (same as quant command)
api_key = os.getenv("OPENROUTER_API_KEY") or os.getenv("OPENAI_API_KEY", "")
api_key_2 = os.getenv("OPENROUTER_API_KEY_2", "")
if api_key and not api_key.startswith("sk-or-"):
# OPENROUTER_API_KEY not set, try to use what we have
api_key = os.getenv("OPENROUTER_API_KEY", api_key)
@@ -1326,8 +1346,8 @@ def build_strategies_ai(
return
# Load evaluated factors
import json
import glob as glob_module
import json
factors = []
for f in glob_module.glob(str(factors_dir / "*.json")):
@@ -1337,6 +1357,7 @@ def build_strategies_ai(
if data.get("status") == "success" and data.get("ic") is not None:
factors.append(data)
except Exception:
logger.warning("Failed to load factor file %s", f, exc_info=True)
continue
if len(factors) < 10:
@@ -1410,15 +1431,15 @@ def build_strategies_ai(
for i, r in enumerate(results, 1):
# Monthly return: use real backtest if available, else estimate
rb = r.get('real_backtest', {})
if isinstance(rb, dict) and rb.get('status') == 'success':
monthly_pct = rb.get('monthly_return_pct', r.get('monthly_return_pct', 0))
n_trades = rb.get('n_trades', '-')
real_ic = rb.get('ic', 0)
rb = r.get("real_backtest", {})
if isinstance(rb, dict) and rb.get("status") == "success":
monthly_pct = rb.get("monthly_return_pct", r.get("monthly_return_pct", 0))
n_trades = rb.get("n_trades", "-")
real_ic = rb.get("ic", 0)
else:
monthly_pct = r.get('monthly_return_pct', r.get('real_monthly_return', 0))
n_trades = '-'
real_ic = rb.get('ic', 0) if isinstance(rb, dict) else 0
monthly_pct = r.get("monthly_return_pct", r.get("real_monthly_return", 0))
n_trades = "-"
real_ic = rb.get("ic", 0) if isinstance(rb, dict) else 0
table.add_row(
str(i),
@@ -1511,7 +1532,7 @@ def status():
# Process check
result = subprocess.run(
["pgrep", "-f", "fin_quant"],
capture_output=True, text=True
capture_output=True, text=True,
)
if result.returncode == 0:
console.print("[bold green]✅ Trading Loop: RUNNING[/bold green]")
@@ -1529,7 +1550,7 @@ def status():
factors = c.fetchone()[0]
conn.close()
console.print(f"\n📊 Results:")
console.print("\n📊 Results:")
console.print(f" Backtest runs: {runs}")
console.print(f" Factors: {factors}")
@@ -1552,6 +1573,7 @@ def _load_strategies():
try:
raw = json.loads(p.read_text())
except Exception:
logger.warning("Failed to load strategy file %s", p, exc_info=True)
continue
if not isinstance(raw, dict):
continue
@@ -1605,6 +1627,7 @@ def best(
$ predix best -n 50 --export /tmp/top.json
"""
import json
from rich.table import Table
items = _load_strategies()
@@ -1721,7 +1744,7 @@ def kronos_factor(
console.print("Run data conversion first — see README Data Setup section.")
raise typer.Exit(1)
console.print(f"[bold]Kronos Factor Generator[/bold]")
console.print("[bold]Kronos Factor Generator[/bold]")
console.print(f" Context: [cyan]{context}[/cyan] bars | Pred: [cyan]{pred}[/cyan] bars | Device: [cyan]{_device}[/cyan]")
from rdagent.components.coder.kronos_adapter import build_kronos_factor
@@ -1804,7 +1827,7 @@ def kronos_eval(
console.print(f"[red]ERROR: Data not found at {data_path}[/red]")
raise typer.Exit(1)
console.print(f"[bold]Kronos Model Evaluator[/bold] (alongside LightGBM)")
console.print("[bold]Kronos Model Evaluator[/bold] (alongside LightGBM)")
console.print(f" Context: [cyan]{context}[/cyan] bars | Pred: [cyan]{pred}[/cyan] bars | Device: [cyan]{_device}[/cyan]")
console.print(" Running evaluation...")
@@ -1819,12 +1842,12 @@ def kronos_eval(
batch_size=batch_size,
)
console.print(f"\n[bold]Kronos-mini Results[/bold]")
console.print("\n[bold]Kronos-mini Results[/bold]")
console.print(f" Predictions: [cyan]{metrics['n_predictions']}[/cyan]")
console.print(f" IC (mean): [{'green' if metrics['IC_mean'] > 0.02 else 'yellow'}]{metrics['IC_mean']:.4f}[/]")
console.print(f" IC IR: [{'green' if metrics['IC_IR'] > 0.5 else 'yellow'}]{metrics['IC_IR']:.4f}[/] (>0.5 = strong signal)")
console.print(f" Hit Rate: [{'green' if metrics['hit_rate'] > 0.52 else 'yellow'}]{metrics['hit_rate']:.2%}[/] (>50% = directionally useful)")
console.print(f"\n[dim]Reference: LightGBM baseline IC typically 0.010.05 on 1-min EUR/USD[/dim]")
console.print("\n[dim]Reference: LightGBM baseline IC typically 0.010.05 on 1-min EUR/USD[/dim]")
import json as _json
out_dir = Path("results/kronos")
+1 -1
View File
@@ -68,7 +68,7 @@ ignore_missing_imports = true
module = "llama"
[tool.pytest.ini_options]
addopts = "-l -s --durations=0"
addopts = "-l -s --durations=0 -m 'not slow'"
log_cli = true
log_cli_level = "info"
log_date_format = "%Y-%m-%d %H:%M:%S"
+125 -108
View File
@@ -21,11 +21,17 @@ load_dotenv(".env")
import subprocess
from importlib.resources import path as rpath
from typing import Dict, Optional
from typing import Annotated
import typer
from rich.console import Console
from typing_extensions import Annotated
try:
from rdagent.utils.env import logger
except ImportError:
import logging
logger = logging.getLogger(__name__)
from rdagent.app.data_science.loop import main as data_science
from rdagent.app.finetune.llm.loop import main as llm_finetune
@@ -139,10 +145,10 @@ def ds_user_interact(port=19900):
@app.command(name="fin_factor")
def fin_factor_cli(
path: Optional[str] = None,
step_n: Optional[int] = None,
loop_n: Optional[int] = None,
all_duration: Optional[str] = None,
path: str | None = None,
step_n: int | None = None,
loop_n: int | None = None,
all_duration: str | None = None,
checkout: CheckoutOption = True,
):
fin_factor(path=path, step_n=step_n, loop_n=loop_n, all_duration=all_duration, checkout=checkout)
@@ -150,10 +156,10 @@ def fin_factor_cli(
@app.command(name="fin_model")
def fin_model_cli(
path: Optional[str] = None,
step_n: Optional[int] = None,
loop_n: Optional[int] = None,
all_duration: Optional[str] = None,
path: str | None = None,
step_n: int | None = None,
loop_n: int | None = None,
all_duration: str | None = None,
checkout: CheckoutOption = True,
):
fin_model(path=path, step_n=step_n, loop_n=loop_n, all_duration=all_duration, checkout=checkout)
@@ -161,10 +167,10 @@ def fin_model_cli(
@app.command(name="fin_quant")
def fin_quant_cli(
path: Optional[str] = None,
step_n: Optional[int] = None,
loop_n: Optional[int] = None,
all_duration: Optional[str] = None,
path: str | None = None,
step_n: int | None = None,
loop_n: int | None = None,
all_duration: str | None = None,
checkout: CheckoutOption = True,
with_dashboard: bool = typer.Option(False, "--with-dashboard/-d", help="Start web dashboard automatically"),
with_cli_dashboard: bool = typer.Option(False, "--cli-dashboard/-c", help="Show beautiful CLI dashboard"),
@@ -224,7 +230,7 @@ def fin_quant_cli(
if not api_key:
console.print("\n[bold red]❌ OPENROUTER_API_KEY not set in .env[/bold red]")
console.print("[yellow]Add your API key to .env and retry:[/yellow]")
console.print(' OPENROUTER_API_KEY=sk-or-your-key-here')
console.print(" OPENROUTER_API_KEY=sk-or-your-key-here")
raise typer.Exit(code=1)
os.environ["OPENAI_API_KEY"] = api_key
@@ -243,8 +249,8 @@ def fin_quant_cli(
console.print(f" [dim]Base URL: {os.environ['OPENAI_API_BASE']}[/dim]")
# Wait until the llama.cpp server is fully loaded before starting the pipeline
import urllib.request
import urllib.error
import urllib.request
base_url = os.environ["OPENAI_API_BASE"].removesuffix("/v1").rstrip("/")
health_url = f"{base_url}/health"
@@ -278,7 +284,7 @@ def fin_quant_cli(
subprocess.run(
["python", "web/dashboard_api.py"],
cwd=str(Path(__file__).parent.parent.parent),
env={**os.environ, "FLASK_ENV": "development"}
env={**os.environ, "FLASK_ENV": "development"},
)
dashboard_thread = threading.Thread(target=start_web_dashboard, daemon=True)
@@ -320,9 +326,9 @@ def fin_quant_cli(
@app.command(name="fin_factor_report")
def fin_factor_report_cli(
report_folder: Optional[str] = None,
path: Optional[str] = None,
all_duration: Optional[str] = None,
report_folder: str | None = None,
path: str | None = None,
all_duration: str | None = None,
checkout: CheckoutOption = True,
):
fin_factor_report(report_folder=report_folder, path=path, all_duration=all_duration, checkout=checkout)
@@ -335,12 +341,12 @@ def general_model_cli(report_file_path: str):
@app.command(name="data_science")
def data_science_cli(
path: Optional[str] = None,
path: str | None = None,
checkout: CheckoutOption = True,
step_n: Optional[int] = None,
loop_n: Optional[int] = None,
timeout: Optional[str] = None,
competition: Optional[str] = None,
step_n: int | None = None,
loop_n: int | None = None,
timeout: str | None = None,
competition: str | None = None,
):
data_science(
path=path,
@@ -354,16 +360,16 @@ def data_science_cli(
@app.command(name="llm_finetune")
def llm_finetune_cli(
path: Optional[str] = None,
path: str | None = None,
checkout: CheckoutOption = True,
benchmark: Optional[str] = None,
benchmark_description: Optional[str] = None,
dataset: Optional[str] = None,
base_model: Optional[str] = None,
upper_data_size_limit: Optional[int] = None,
step_n: Optional[int] = None,
loop_n: Optional[int] = None,
timeout: Optional[str] = None,
benchmark: str | None = None,
benchmark_description: str | None = None,
dataset: str | None = None,
base_model: str | None = None,
upper_data_size_limit: int | None = None,
step_n: int | None = None,
loop_n: int | None = None,
timeout: str | None = None,
):
llm_finetune(
path=path,
@@ -429,6 +435,7 @@ def rl_trading_cli(
rdagent rl_trading --mode backtest --no-with-protections
"""
from pathlib import Path
import yaml
console = Console()
@@ -440,18 +447,18 @@ def rl_trading_cli(
with open(config_path) as f:
config = yaml.safe_load(f) or {}
console.print(f"\n[bold blue]🤖 RL Trading Agent[/bold blue]")
console.print("\n[bold blue]🤖 RL Trading Agent[/bold blue]")
console.print(f"Mode: [cyan]{mode}[/cyan]")
console.print(f"Algorithm: [cyan]{algorithm.upper()}[/cyan]")
console.print(f"Protections: {'[green]Enabled[/green]' if with_protections else '[red]Disabled[/red]'}")
try:
from rdagent.components.coder.rl import RLTradingAgent, RLCosteer, TradingEnv
from rdagent.components.coder.rl import RLCosteer, RLTradingAgent, TradingEnv
except ImportError as e:
console.print(f"[bold red]Error: RL components not available.[/bold red]")
console.print("[bold red]Error: RL components not available.[/bold red]")
console.print(f"Details: {e}")
console.print(f"\n[yellow]Install RL dependencies:[/yellow]")
console.print(f" pip install stable-baselines3 gymnasium")
console.print("\n[yellow]Install RL dependencies:[/yellow]")
console.print(" pip install stable-baselines3 gymnasium")
raise typer.Exit(code=1)
if mode == "train":
@@ -467,8 +474,8 @@ def rl_trading_cli(
console.print("[dim]Loading market data...[/dim]")
# TODO: Load actual data from config
# For now, create mock environment
import numpy as np
import gymnasium as gym
import numpy as np
# Create simple mock environment for demonstration
class MockTradingEnv(gym.Env):
@@ -504,7 +511,7 @@ def rl_trading_cli(
model_path_out.parent.mkdir(parents=True, exist_ok=True)
agent.save(model_path_out)
console.print(f"\n[bold green]✅ Training complete![/bold green]")
console.print("\n[bold green]✅ Training complete![/bold green]")
console.print(f"Model saved to: [cyan]{model_path_out}[/cyan]")
console.print(f"Algorithm: {result['algorithm']}")
console.print(f"Timesteps: {result['total_timesteps']:,}")
@@ -530,9 +537,9 @@ def rl_trading_cli(
agent = RLTradingAgent(algorithm=algorithm.upper())
# Run backtest
from rdagent.components.backtesting import FactorBacktester
import pandas as pd
import numpy as np
import pandas as pd
from rdagent.components.backtesting import FactorBacktester
backtester = FactorBacktester()
@@ -541,8 +548,8 @@ def rl_trading_cli(
n_steps = 500
mock_prices = pd.Series(100 + np.cumsum(np.random.randn(n_steps) * 0.5))
mock_indicators = pd.DataFrame({
'rsi': np.random.uniform(30, 70, n_steps),
'macd': np.random.randn(n_steps) * 0.1,
"rsi": np.random.uniform(30, 70, n_steps),
"macd": np.random.randn(n_steps) * 0.1,
})
console.print("[yellow]Running backtest...[/yellow]")
@@ -553,7 +560,7 @@ def rl_trading_cli(
enable_protections=with_protections,
)
console.print(f"\n[bold green]✅ Backtest complete![/bold green]")
console.print("\n[bold green]✅ Backtest complete![/bold green]")
console.print(f" Final Equity: [green]${metrics.get('final_equity', 0):,.2f}[/green]")
console.print(f" Sharpe Ratio: {metrics.get('sharpe_ratio', 0):.3f}")
console.print(f" Max Drawdown: {metrics.get('max_drawdown', 0):.2%}")
@@ -627,7 +634,7 @@ def generate_strategies_cli(
rdagent generate_strategies -n 3 -i 10 --optuna-trials 50 # Deep optimization
"""
from rich.console import Console
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TimeRemainingColumn
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeRemainingColumn
from rich.table import Table
console = Console()
@@ -646,7 +653,7 @@ def generate_strategies_cli(
raise typer.Exit(code=1)
console.print(f"\n[bold blue]{'='*60}[/bold blue]")
console.print(f"[bold blue] PREDIX Strategy Generator[/bold blue]")
console.print("[bold blue] PREDIX Strategy Generator[/bold blue]")
console.print(f"[bold blue]{'='*60}[/bold blue]")
console.print(f" Strategies: [cyan]{count}[/cyan]")
console.print(f" Workers: [cyan]{workers}[/cyan]")
@@ -673,12 +680,12 @@ def generate_strategies_cli(
_slog = _dlog.setup("strategies", **_strat_ctx)
try:
from rdagent.components.coder.strategy_orchestrator import StrategyOrchestrator
import pandas as pd
from rdagent.components.coder.strategy_orchestrator import StrategyOrchestrator
all_results = []
best_strategy = None
best_sharpe = float('-inf')
best_sharpe = float("-inf")
# CONTINUOUS OPTIMIZATION LOOP
for iteration in range(1, max_iterations + 1):
@@ -727,7 +734,7 @@ def generate_strategies_cli(
# Track best strategy
for r in results:
sharpe = r.get("sharpe_ratio", float('-inf'))
sharpe = r.get("sharpe_ratio", float("-inf"))
if sharpe > best_sharpe:
best_sharpe = sharpe
best_strategy = r
@@ -747,7 +754,7 @@ def generate_strategies_cli(
rejected = [r for r in results if r.get("status") == "rejected"]
console.print(f"\n[bold green]{'='*60}[/bold green]")
console.print(f"[bold green] Strategy Generation Summary[/bold green]")
console.print("[bold green] Strategy Generation Summary[/bold green]")
console.print(f"[bold green]{'='*60}[/bold green]")
table = Table(show_header=True, header_style="bold magenta", show_lines=True)
@@ -776,7 +783,7 @@ def generate_strategies_cli(
# Show best strategy details
if best_strategy:
console.print(f"\n[bold gold1]{'='*60}[/bold gold1]")
console.print(f"[bold gold1] BEST STRATEGY[/bold gold1]")
console.print("[bold gold1] BEST STRATEGY[/bold gold1]")
console.print(f"[bold gold1]{'='*60}[/bold gold1]")
console.print(f" Name: [cyan]{best_strategy.get('strategy_name', 'Unknown')}[/cyan]")
console.print(f" Sharpe: [green]{best_strategy.get('sharpe_ratio', 0):.4f}[/green]")
@@ -784,13 +791,13 @@ def generate_strategies_cli(
console.print(f" Max DD: [yellow]{best_strategy.get('max_drawdown', 0):.2%}[/yellow]")
console.print(f" Win Rate: [cyan]{best_strategy.get('win_rate', 0):.2%}[/cyan]")
if best_strategy.get("best_params"):
console.print(f"\n [bold]Optimized Parameters:[/bold]")
console.print("\n [bold]Optimized Parameters:[/bold]")
for param, val in best_strategy["best_params"].items():
console.print(f" {param}: [cyan]{val}[/cyan]")
console.print(f"[bold gold1]{'='*60}[/bold gold1]")
if accepted:
console.print(f"\n[bold]Accepted Strategies:[/bold]")
console.print("\n[bold]Accepted Strategies:[/bold]")
acc_table = Table(show_header=True, header_style="bold cyan")
acc_table.add_column("#", width=4)
acc_table.add_column("Strategy", width=30)
@@ -813,13 +820,13 @@ def generate_strategies_cli(
)
console.print(acc_table)
console.print(f"\n[bold green]Strategies saved to:[/bold green] [cyan]results/strategies_new/[/cyan]")
console.print("\n[bold green]Strategies saved to:[/bold green] [cyan]results/strategies_new/[/cyan]")
console.print(f"[bold blue]{'='*60}[/bold blue]\n")
_slog.success(f"Generated {len(all_results)} strategies ({len([r for r in all_results if r.get('status')=='accepted'])} accepted)")
except ImportError as e:
_slog.error(f"Strategy components not available: {e}")
console.print(f"[bold red]Error: Strategy components not available.[/bold red]")
console.print("[bold red]Error: Strategy components not available.[/bold red]")
console.print(f"Details: {e}")
raise typer.Exit(code=1)
except Exception as e:
@@ -855,17 +862,18 @@ def optimize_portfolio_cli(
raise typer.Exit(code=1)
console.print(f"\n[bold blue]{'='*60}[/bold blue]")
console.print(f"[bold blue] PREDIX Portfolio Optimizer[/bold blue]")
console.print("[bold blue] PREDIX Portfolio Optimizer[/bold blue]")
console.print(f"[bold blue]{'='*60}[/bold blue]")
console.print(f" Top N: [cyan]{top_n}[/cyan]")
console.print(f" Method: [cyan]{method}[/cyan]")
console.print(f"[bold blue]{'='*60}[/bold blue]\n")
try:
from rdagent.components.backtesting.risk_management import PortfolioOptimizer
import json
from pathlib import Path
from rdagent.components.backtesting.risk_management import PortfolioOptimizer
project_root = Path(__file__).parent.parent.parent
strategies_dir = project_root / "results" / "strategies_new"
@@ -882,6 +890,7 @@ def optimize_portfolio_cli(
if data.get("status") == "accepted":
strategies.append(data)
except Exception:
logger.warning("Failed to load strategy file %s", f, exc_info=True)
continue
if not strategies:
@@ -1001,14 +1010,15 @@ def strategies_report_cli(
rdagent strategies_report -s path/to/strategy.json # Single strategy
rdagent strategies_report -o custom/reports/ # Custom output dir
"""
from pathlib import Path
from rich.console import Console
from rich.progress import Progress, SpinnerColumn, TextColumn
from pathlib import Path
console = Console()
console.print(f"\n[bold blue]{'='*60}[/bold blue]")
console.print(f"[bold blue] PREDIX Strategy Report Generator[/bold blue]")
console.print("[bold blue] PREDIX Strategy Report Generator[/bold blue]")
console.print(f"[bold blue]{'='*60}[/bold blue]\n")
project_root = Path(__file__).parent.parent.parent
@@ -1060,20 +1070,20 @@ def strategies_report_cli(
progress.update(task, completed=1)
console.print(f"\n[bold green]{'='*60}[/bold green]")
console.print(f"[bold green] Report Generation Complete[/bold green]")
console.print("[bold green] Report Generation Complete[/bold green]")
console.print(f"[bold green]{'='*60}[/bold green]")
console.print(f" Reports generated: [cyan]{reports_generated}/{len(strategy_files)}[/cyan]")
console.print(f" Output directory: [cyan]{output_dir_path}[/cyan]")
console.print(f"[bold green]{'='*60}[/bold green]\n")
def _generate_single_strategy_report(strategy_file: Path, output_dir: Path) -> Dict:
def _generate_single_strategy_report(strategy_file: Path, output_dir: Path) -> dict:
"""Generate a report for a single strategy."""
import json
import matplotlib
matplotlib.use("Agg") # Non-interactive backend
import matplotlib.pyplot as plt
import seaborn as sns
with open(strategy_file, encoding="utf-8") as f:
strategy = json.load(f)
@@ -1148,7 +1158,7 @@ if __name__ == "__main__":
@app.command(name="start_llama")
def start_llama_cli(
model: str = typer.Option(
None, "--model", "-m", help="Path to model file"
None, "--model", "-m", help="Path to model file",
),
port: int = typer.Option(8081, "--port", "-p", help="Server port"),
gpu_layers: int = typer.Option(30, "--gpu-layers", "-g", help="GPU layers"),
@@ -1170,8 +1180,6 @@ def start_llama_cli(
rdagent start_llama --gpu-layers 40 --ctx-size 4096
rdagent start_llama --reasoning
"""
import subprocess
import sys
import os
model_path = model or os.getenv(
@@ -1208,7 +1216,7 @@ def start_llama_cli(
if not reasoning:
cmd.extend(["--reasoning", "off"])
print(f"🚀 Starting llama.cpp server...")
print("🚀 Starting llama.cpp server...")
print(f" Model: {Path(model_path).name}")
print(f" Port: {port}")
print(f" GPU Layers: {gpu_layers}")
@@ -1241,17 +1249,16 @@ def start_loop_cli(
rdagent start_loop
rdagent start_loop --target 5 --max-wait 3600
"""
import subprocess
import signal
import sys
import os
from datetime import datetime
import signal
import subprocess
import time
from datetime import datetime
script_dir = str(Path(__file__).parent.parent.parent.parent)
generator = f"python {script_dir}/scripts/predix_smart_strategy_gen.py"
script_dir = str(Path(__file__).parent.parent.parent)
generator = [sys.executable, f"{script_dir}/scripts/predix_smart_strategy_gen.py"]
logfile = f"{script_dir}/results/logs/generator_loop.log"
pidfile = "/tmp/predix_loop.pid"
pidfile = "/tmp/predix_loop.pid" # nosec B108 — administrative PID file, single-process daemon
os.makedirs(f"{script_dir}/results/logs", exist_ok=True)
@@ -1262,12 +1269,19 @@ def start_loop_cli(
with open(logfile, "a") as f:
f.write(line + "\n")
child_proc = None # track current child PID for targeted cleanup
def cleanup(signum=None, frame=None):
log("Received termination signal. Cleaning up...")
try:
subprocess.run(["pkill", "-f", "predix_smart_strategy_gen.py"], capture_output=True)
except Exception:
pass
if child_proc is not None:
try:
child_proc.terminate()
child_proc.wait(timeout=10)
except Exception:
try:
child_proc.kill()
except Exception:
pass
try:
os.remove(pidfile)
except FileNotFoundError:
@@ -1313,26 +1327,32 @@ def start_loop_cli(
strat_count = len(list(strat_dir.glob("*.json"))) if strat_dir.exists() else 0
log(f"📁 Existing strategies: {strat_count}")
# Kill stale processes
try:
subprocess.run(["pkill", "-9", "-f", "predix_smart_strategy_gen.py"], capture_output=True)
except Exception:
pass
time.sleep(2)
# Kill stale child from previous iteration
if child_proc is not None:
try:
child_proc.terminate()
child_proc.wait(timeout=10)
except subprocess.TimeoutExpired:
child_proc.kill()
child_proc.wait()
except Exception:
pass
child_proc = None
time.sleep(2)
# Start generator
log("🤖 Starting generator...")
proc = subprocess.Popen(
generator.split(),
child_proc = subprocess.Popen(
generator,
cwd=script_dir,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
log(f" PID: {proc.pid}")
log(f" PID: {child_proc.pid}")
# Monitor progress
elapsed = 0
while proc.poll() is None:
while child_proc.poll() is None:
time.sleep(30)
elapsed += 30
@@ -1341,11 +1361,12 @@ def start_loop_cli(
if elapsed >= max_wait:
log(f" ⏰ Timeout after {elapsed}s. Killing...")
proc.kill()
child_proc.kill()
break
# Check results
exit_code = proc.wait()
exit_code = child_proc.wait()
child_proc = None
if exit_code == 0:
log("✅ Generator completed successfully")
elif exit_code == -9:
@@ -1386,24 +1407,24 @@ def parallel_cli(
rdagent parallel -n 10 -k 2
"""
import subprocess
import sys
from pathlib import Path
from rdagent.log import daily_log as _dlog
project_root = Path(__file__).parent.parent.parent.parent
project_root = Path(__file__).parent.parent.parent
script = project_root / "scripts" / "predix_parallel.py"
if not script.exists():
typer.echo(f"❌ Script not found: {script}")
raise typer.Exit(code=1)
cmd = [sys.executable, str(script), "--runs", str(runs), "--api-keys", str(api_keys), "-m", "local"]
cmd = [sys.executable, str(script), "--runs", str(runs), "--api-keys", str(api_keys)]
_plog = _dlog.setup("parallel", runs=runs, api_keys=api_keys, model="local")
typer.echo(f"🚀 Starting {runs} parallel runs...")
typer.echo(f" Script: {script}")
typer.echo(f" API Keys: {api_keys}")
typer.echo(f" Model: local (llama.cpp)")
typer.echo(" Model: local (llama.cpp)")
try:
result = subprocess.run(cmd, cwd=str(project_root))
@@ -1437,11 +1458,11 @@ def eval_all_cli(
rdagent eval_all -n 500 -p 8
"""
import subprocess
import sys
from pathlib import Path
from rdagent.log import daily_log as _dlog
project_root = Path(__file__).parent.parent.parent.parent
project_root = Path(__file__).parent.parent.parent
script = project_root / "scripts" / "predix_full_eval.py"
if not script.exists():
@@ -1492,10 +1513,9 @@ def batch_backtest_cli(
rdagent batch_backtest --all
"""
import subprocess
import sys
from pathlib import Path
project_root = Path(__file__).parent.parent.parent.parent
project_root = Path(__file__).parent.parent.parent
script = project_root / "scripts" / "predix_batch_backtest.py"
if not script.exists():
@@ -1545,10 +1565,9 @@ def simple_eval_cli(
rdagent simple_eval --all
"""
import subprocess
import sys
from pathlib import Path
project_root = Path(__file__).parent.parent.parent.parent
project_root = Path(__file__).parent.parent.parent
script = project_root / "scripts" / "predix_simple_eval.py"
if not script.exists():
@@ -1578,7 +1597,7 @@ def simple_eval_cli(
@app.command(name="rebacktest")
def rebacktest_cli(
strategies_dir: str = typer.Option(
None, "--strategies-dir", "-d", help="Directory containing strategy JSON files"
None, "--strategies-dir", "-d", help="Directory containing strategy JSON files",
),
):
"""
@@ -1592,10 +1611,9 @@ def rebacktest_cli(
rdagent rebacktest -d results/strategies_new/
"""
import subprocess
import sys
from pathlib import Path
project_root = Path(__file__).parent.parent.parent.parent
project_root = Path(__file__).parent.parent.parent
script = project_root / "scripts" / "predix_rebacktest_strategies.py"
if not script.exists():
@@ -1620,10 +1638,10 @@ def rebacktest_cli(
@app.command(name="report")
def report_cli(
strategy_path: str = typer.Option(
None, "--strategy", "-s", help="Path to single strategy JSON (default: all strategies)"
None, "--strategy", "-s", help="Path to single strategy JSON (default: all strategies)",
),
output: str = typer.Option(
None, "--output", "-o", help="Output directory (default: results/strategy_reports/)"
None, "--output", "-o", help="Output directory (default: results/strategy_reports/)",
),
):
"""
@@ -1646,10 +1664,9 @@ def report_cli(
rdagent report -o custom/reports/
"""
import subprocess
import sys
from pathlib import Path
project_root = Path(__file__).parent.parent.parent.parent
project_root = Path(__file__).parent.parent.parent
script = project_root / "scripts" / "predix_strategy_report.py"
if not script.exists():
+2 -3
View File
@@ -201,6 +201,5 @@ class DataScienceBasePropSetting(KaggleBasePropSetting):
DS_RD_SETTING = DataScienceBasePropSetting()
# enable_cross_trace_diversity and llm_select_hypothesis should not be true at the same time
assert not (
DS_RD_SETTING.enable_cross_trace_diversity and DS_RD_SETTING.llm_select_hypothesis
), "enable_cross_trace_diversity and llm_select_hypothesis cannot be true at the same time"
if DS_RD_SETTING.enable_cross_trace_diversity and DS_RD_SETTING.llm_select_hypothesis:
raise ValueError("enable_cross_trace_diversity and llm_select_hypothesis cannot be true at the same time")
+8 -9
View File
@@ -58,18 +58,18 @@ def main(
if user_target_scenario:
FT_RD_SETTING.user_target_scenario = user_target_scenario
assert (
FT_RD_SETTING.user_target_scenario is None
), "user_target_scenario is not yet supported, please specify via benchmark and benchmark_description"
if FT_RD_SETTING.user_target_scenario is not None:
raise ValueError("user_target_scenario is not yet supported, please specify via benchmark and benchmark_description")
if upper_data_size_limit:
FT_RD_SETTING.upper_data_size_limit = upper_data_size_limit
logger.info(f"Set upper_data_size_limit to {FT_RD_SETTING.upper_data_size_limit}")
if benchmark and benchmark_description:
FT_RD_SETTING.target_benchmark = benchmark
FT_RD_SETTING.benchmark_description = benchmark_description
assert FT_RD_SETTING.user_target_scenario or (
FT_RD_SETTING.target_benchmark and FT_RD_SETTING.benchmark_description
), "Either user_target_scenario or target_benchmark must be specified for LLM fine-tuning."
if not (
FT_RD_SETTING.user_target_scenario or (FT_RD_SETTING.target_benchmark and FT_RD_SETTING.benchmark_description)
):
raise ValueError("Either user_target_scenario or target_benchmark must be specified for LLM fine-tuning.")
# Update configuration with provided parameters
if dataset:
@@ -82,9 +82,8 @@ def main(
model_target = FT_RD_SETTING.base_model if FT_RD_SETTING.base_model else "auto selected model"
# Temporary assertion until auto-selection is implemented
assert (
FT_RD_SETTING.base_model is not None
), "Base model auto selection not yet supported, please specify via --base-model"
if FT_RD_SETTING.base_model is None:
raise ValueError("Base model auto selection not yet supported, please specify via --base-model")
logger.info(f"Starting LLM fine-tuning on dataset='{data_set_target}' with model='{model_target}'")
+8 -47
View File
@@ -24,46 +24,12 @@ from rdagent.app.finetune.llm.ui.ft_summary import render_job_summary
DEFAULT_LOG_BASE = "log/"
from rdagent.core.utils import safe_resolve_path
def validate_path_within_cwd(user_path: Path) -> Path:
"""
Validate that a user-provided path is within the current working directory.
Security: This function prevents path traversal attacks by:
1. Resolving the path to its absolute canonical form
2. Verifying it's within the CWD boundary using a normalized common prefix
3. Rejecting paths outside the boundary with ValueError
Parameters
----------
user_path : Path
User-provided path to validate
Returns
-------
Path
Resolved absolute path if valid
Raises
------
ValueError
If path is outside the current working directory
"""
safe_root = Path.cwd().resolve()
# Expand any user home reference and resolve without requiring the path to exist.
resolved_path = user_path.expanduser().resolve(strict=False)
# Ensure the resolved path is absolute and remains within the safe root.
safe_root_str = str(safe_root)
resolved_str = str(resolved_path)
common = os.path.commonpath([safe_root_str, resolved_str])
if common != safe_root_str:
raise ValueError("Path is outside the allowed project directory")
# This will raise ValueError if resolved_path is not within safe_root
resolved_path.relative_to(safe_root)
return resolved_path
return safe_resolve_path(user_path, safe_root)
def get_job_options(base_path: Path, safe_root: Path | None = None) -> list[str]:
@@ -141,19 +107,14 @@ def main():
st.header("Job")
base_folder = st.text_input("Base Folder", value=default_log, key="base_folder_input")
# Normalize and validate the base folder against the configured log root
root_real = os.path.realpath(str(Path(default_log).expanduser()))
folder_real = os.path.realpath(str(Path(base_folder).expanduser()))
if folder_real == root_real or folder_real.startswith(root_real + os.sep):
base_path = Path(folder_real)
safe_root = Path(root_real)
else:
safe_root = Path(default_log).expanduser().resolve()
try:
base_path = safe_resolve_path(Path(base_folder), safe_root)
except ValueError:
st.error("Invalid base folder: must be within the configured log directory.")
safe_root = Path(root_real)
base_path = safe_root
# base_path is validated against safe_root nosec B614
job_options = get_job_options(base_path, safe_root) # nosec B614 validated above
job_options = get_job_options(base_path, safe_root)
if job_options:
selected_job = st.selectbox("Select Job", job_options, key="job_select")
if selected_job.startswith("."):
+7 -9
View File
@@ -13,6 +13,7 @@ from typing import Any
import streamlit as st
from rdagent.app.finetune.llm.ui.config import EVALUATOR_CONFIG, EventType
from rdagent.core.utils import safe_resolve_path
from rdagent.log.storage import FileStorage
@@ -89,11 +90,10 @@ def extract_stage(tag: str) -> str:
def get_valid_sessions(log_folder: Path, safe_root: Path | None = None) -> list[str]:
"""Get list of valid session directories, optionally validating against a safe root."""
if safe_root is not None:
root_real = os.path.realpath(str(safe_root.expanduser()))
folder_real = os.path.realpath(str(log_folder.expanduser()))
if not (folder_real == root_real or folder_real.startswith(root_real + os.sep)):
try:
log_folder = safe_resolve_path(log_folder, safe_root)
except ValueError:
return []
log_folder = Path(folder_real)
if not log_folder.exists():
return []
@@ -373,13 +373,11 @@ def parse_event(tag: str, content: Any, timestamp: datetime) -> Event | None:
@st.cache_data(ttl=300, hash_funcs={Path: str})
def load_ft_session(log_path: Path, safe_root: Path | None = None) -> Session:
"""Load events into hierarchical session structure, optionally validating against safe root."""
# Validate path is within safe_root if provided
if safe_root is not None:
root_real = os.path.realpath(str(safe_root.expanduser()))
path_real = os.path.realpath(str(log_path.expanduser()))
if not (path_real == root_real or path_real.startswith(root_real + os.sep)):
try:
log_path = safe_resolve_path(log_path, safe_root)
except ValueError:
return Session()
log_path = Path(path_real)
session = Session()
storage = FileStorage(log_path)
+8 -9
View File
@@ -4,10 +4,9 @@ Factor workflow with session control
import asyncio
from pathlib import Path
from typing import Any, Optional
from typing import Any
import fire
from rdagent.app.qlib_rd_loop.conf import FACTOR_PROP_SETTING
from rdagent.components.workflow.rd_loop import RDLoop
from rdagent.core.exception import CoderError, FactorEmptyError
@@ -21,20 +20,20 @@ class FactorRDLoop(RDLoop):
def running(self, prev_out: dict[str, Any]):
exp = self.runner.develop(prev_out["coding"])
if exp is None:
logger.error(f"Factor extraction failed.")
logger.error("Factor extraction failed.")
raise FactorEmptyError("Factor extraction failed.")
logger.log_object(exp, tag="runner result")
return exp
def main(
path: Optional[str] = None,
step_n: Optional[int] = None,
loop_n: Optional[int] = None,
path: str | None = None,
step_n: int | None = None,
loop_n: int | None = None,
all_duration: str | None = None,
checkout: bool = True,
checkout_path: Optional[str] = None,
base_features_path: Optional[str] = None,
checkout_path: str | None = None,
base_features_path: str | None = None,
**kwargs,
):
"""
@@ -47,7 +46,7 @@ def main(
dotenv run -- python rdagent/app/qlib_rd_loop/factor.py $LOG_PATH/__session__/1/0_propose --step_n 1 # `step_n` is a optional paramter
"""
if not checkout_path is None:
if checkout_path is not None:
checkout = Path(checkout_path)
if path is None:
@@ -1,10 +1,9 @@
import asyncio
import json
from pathlib import Path
from typing import Any, Dict, Tuple
from typing import Any
import fire
from rdagent.app.qlib_rd_loop.conf import FACTOR_FROM_REPORT_PROP_SETTING
from rdagent.app.qlib_rd_loop.factor import FactorRDLoop
from rdagent.components.document_reader.document_reader import (
@@ -12,7 +11,7 @@ from rdagent.components.document_reader.document_reader import (
load_and_process_pdfs_by_langchain,
)
from rdagent.core.conf import RD_AGENT_SETTINGS
from rdagent.core.proposal import Hypothesis, HypothesisFeedback
from rdagent.core.proposal import Hypothesis
from rdagent.log import rdagent_logger as logger
from rdagent.oai.llm_utils import APIBackend
from rdagent.scenarios.qlib.experiment.factor_experiment import QlibFactorExperiment
@@ -36,14 +35,14 @@ def generate_hypothesis(factor_result: dict, report_content: str) -> str:
"""
system_prompt = T(".prompts:hypothesis_generation.system").r()
user_prompt = T(".prompts:hypothesis_generation.user").r(
factor_descriptions=json.dumps(factor_result), report_content=report_content
factor_descriptions=json.dumps(factor_result), report_content=report_content,
)
response = APIBackend().build_messages_and_create_chat_completion(
user_prompt=user_prompt,
system_prompt=system_prompt,
json_mode=True,
json_target_type=Dict[str, str],
json_target_type=dict[str, str],
)
response_json = json.loads(response)
@@ -99,7 +98,7 @@ class FactorReportLoop(FactorRDLoop, metaclass=LoopMeta):
super().__init__(PROP_SETTING=FACTOR_FROM_REPORT_PROP_SETTING)
if report_folder is None:
self.judge_pdf_data_items = json.load(
open(FACTOR_FROM_REPORT_PROP_SETTING.report_result_json_file_path, "r")
open(FACTOR_FROM_REPORT_PROP_SETTING.report_result_json_file_path),
)
else:
self.judge_pdf_data_items = [i for i in Path(report_folder).rglob("*.pdf")]
@@ -118,7 +117,7 @@ class FactorReportLoop(FactorRDLoop, metaclass=LoopMeta):
if exp is None:
self.shift_report += 1
self.loop_n -= 1
if self.loop_n < 0: # NOTE: on every step, we self.loop_n -= 1 at first.
if self.loop_n < 0: # loop_n is decremented above when reports are empty; prevents infinite skipping
raise self.LoopTerminationError("Reach stop criterion and stop loop")
continue
exp.based_experiments = [QlibFactorExperiment(sub_tasks=[], hypothesis=exp.hypothesis)] + [
+35 -32
View File
@@ -8,7 +8,6 @@ from pathlib import Path
from typing import Any
import fire
from rdagent.app.qlib_rd_loop.conf import QUANT_PROP_SETTING
from rdagent.components.workflow.conf import BasePropSetting
from rdagent.components.workflow.rd_loop import RDLoop
@@ -44,11 +43,11 @@ class QuantRDLoop(RDLoop):
logger.log_object(self.hypothesis_gen, tag="quant hypothesis generator")
self.factor_hypothesis2experiment: Hypothesis2Experiment = import_class(
PROP_SETTING.factor_hypothesis2experiment
PROP_SETTING.factor_hypothesis2experiment,
)()
logger.log_object(self.factor_hypothesis2experiment, tag="factor hypothesis2experiment")
self.model_hypothesis2experiment: Hypothesis2Experiment = import_class(
PROP_SETTING.model_hypothesis2experiment
PROP_SETTING.model_hypothesis2experiment,
)()
logger.log_object(self.model_hypothesis2experiment, tag="model hypothesis2experiment")
@@ -78,7 +77,8 @@ class QuantRDLoop(RDLoop):
while True:
if self.get_unfinished_loop_cnt(self.loop_idx) < RD_AGENT_SETTINGS.get_max_parallel():
hypo = self._propose()
assert hypo.action in ["factor", "model"]
if hypo.action not in ["factor", "model"]:
raise ValueError(f"hypo.action must be 'factor' or 'model', got {hypo.action!r}")
if hypo.action == "factor":
exp = self.factor_hypothesis2experiment.convert(hypo, self.trace)
else:
@@ -132,7 +132,6 @@ class QuantRDLoop(RDLoop):
"""
import json
from datetime import datetime
from pathlib import Path
try:
project_root = Path(__file__).parent.parent.parent.parent
@@ -195,11 +194,11 @@ class QuantRDLoop(RDLoop):
if prev_out["direct_exp_gen"]["propose"].action == "factor":
exp = self.factor_runner.develop(prev_out["coding"])
if exp is None:
logger.error(f"Factor extraction failed.")
logger.error("Factor extraction failed.")
raise FactorEmptyError("Factor extraction failed.")
# Increment factor count for tracking
if hasattr(self, 'trace') and hasattr(self.trace, 'increment_factor_count'):
if hasattr(self, "trace") and hasattr(self.trace, "increment_factor_count"):
self.trace.increment_factor_count()
# Handle failed experiments gracefully (don't break the loop)
@@ -210,7 +209,7 @@ class QuantRDLoop(RDLoop):
factor_name = getattr(exp.hypothesis, "hypothesis", "unknown")
logger.warning(
f"Factor '{factor_name}' failed evaluation: {reason}. "
f"Continuing with next factor."
f"Continuing with next factor.",
)
# Return exp anyway - loop will continue
elif prev_out["direct_exp_gen"]["propose"].action == "model":
@@ -219,7 +218,7 @@ class QuantRDLoop(RDLoop):
return exp
def feedback(self, prev_out: dict[str, Any]):
e = prev_out.get(self.EXCEPTION_KEY, None)
e = prev_out.get(self.EXCEPTION_KEY)
if e is not None:
feedback = HypothesisFeedback(
observations=str(e),
@@ -245,11 +244,10 @@ class QuantRDLoop(RDLoop):
reason=reason,
decision=False,
)
else:
if prev_out["direct_exp_gen"]["propose"].action == "factor":
feedback = self.factor_summarizer.generate_feedback(prev_out["running"], self.trace)
elif prev_out["direct_exp_gen"]["propose"].action == "model":
feedback = self.model_summarizer.generate_feedback(prev_out["running"], self.trace)
elif prev_out["direct_exp_gen"]["propose"].action == "factor":
feedback = self.factor_summarizer.generate_feedback(prev_out["running"], self.trace)
elif prev_out["direct_exp_gen"]["propose"].action == "model":
feedback = self.model_summarizer.generate_feedback(prev_out["running"], self.trace)
# NOTE: DB save is handled by factor_runner.py _save_result_to_database()
# which runs immediately after Docker execution. No duplicate save needed here.
@@ -258,20 +256,20 @@ class QuantRDLoop(RDLoop):
factor_count = self.trace.get_factor_count()
# Check for auto-strategies trigger
auto_strategies = getattr(self, '_auto_strategies', False)
auto_threshold = getattr(self, '_auto_strategies_threshold', 500)
auto_strategies = getattr(self, "_auto_strategies", False)
auto_threshold = getattr(self, "_auto_strategies_threshold", 500)
if auto_strategies and factor_count > 0 and factor_count % auto_threshold == 0:
logger.info(
f"Auto-strategy trigger: {factor_count} factors evaluated. "
f"Suggesting strategy generation now..."
f"Suggesting strategy generation now...",
)
self._build_strategies_with_ai()
elif factor_count > 0 and factor_count % 50 == 0 and not auto_strategies:
# Standard periodic suggestion (every 50 factors)
logger.info(
f"Periodic check: {factor_count} factors evaluated. "
f"Consider running 'rdagent generate_strategies' for AI strategy generation."
f"Consider running 'rdagent generate_strategies' for AI strategy generation.",
)
feedback = self._interact_feedback(feedback)
@@ -292,10 +290,11 @@ class QuantRDLoop(RDLoop):
- Optuna hyperparameter optimization
"""
try:
from rdagent.components.coder.strategy_orchestrator import StrategyOrchestrator
from pathlib import Path
import yaml
from rdagent.components.coder.strategy_orchestrator import StrategyOrchestrator
# Load improved prompt
project_root = Path(__file__).parent.parent.parent.parent
prompt_path = project_root / "prompts" / "strategy_generation_v2.yaml"
@@ -322,6 +321,7 @@ class QuantRDLoop(RDLoop):
if data.get("status") == "success" and data.get("ic") is not None:
factors.append(data)
except Exception:
logger.warning("Failed to load factor file %s", f, exc_info=True)
continue
if len(factors) < 10:
@@ -334,44 +334,47 @@ class QuantRDLoop(RDLoop):
logger.info(f"StrategyOrchestrator: Building strategies from {len(top_factors)} top factors...")
logger.info(f" - Using improved prompt: {improved_prompt is not None}")
logger.info(f" - Optuna optimization: enabled (20 trials)")
logger.info(f" - Real OHLCV backtest: enabled")
logger.info(" - Optuna optimization: enabled (20 trials)")
logger.info(" - Real OHLCV backtest: enabled")
# Initialize orchestrator with Optuna
orchestrator = StrategyOrchestrator(
top_factors=20,
trading_style='swing',
trading_style="swing",
min_sharpe=0.5,
max_drawdown=-0.20,
min_win_rate=0.40,
use_optuna=True,
optuna_trials=20,
)
# Override with improved prompt if available
if improved_prompt:
orchestrator.strategy_prompt = improved_prompt.get('strategy_generation', {})
orchestrator.strategy_prompt = improved_prompt.get("strategy_generation", {})
# Generate 3 strategies per cycle
n_strategies = 3
logger.info(f"Generating {n_strategies} strategies...")
# Load top factors for generation
orch_factors = orchestrator.load_top_factors()
if len(orch_factors) < 2:
logger.warning(f"Not enough factors for strategy generation (need >= 2, got {len(orch_factors)}). Skipping.")
return
for i in range(n_strategies):
strategy_name = f"auto_gen_v{i+1}"
try:
# Select random factor combination
import random
n_factors = random.randint(2, min(5, len(orch_factors)))
factor_subset = random.sample(orch_factors, n_factors)
strategy_name = f"auto_gen_v{i+1}"
code = orchestrator.generate_strategy_code(factor_subset, strategy_name)
if code:
result = orchestrator.evaluate_strategy(code, strategy_name, factor_subset)
if result.get("status") == "accepted":
logger.info(f"✅ Strategy {strategy_name} accepted!")
logger.info(f" Sharpe: {result.get('sharpe_ratio', 0):.2f}")
@@ -429,7 +432,7 @@ def main(
quant_loop._auto_strategies = True
quant_loop._auto_strategies_threshold = auto_strategies_threshold
logger.info(
f"Auto-strategies enabled. Will trigger after {auto_strategies_threshold} factors."
f"Auto-strategies enabled. Will trigger after {auto_strategies_threshold} factors.",
)
else:
quant_loop._auto_strategies = False
+5 -35
View File
@@ -16,55 +16,26 @@ from rdagent.app.rl.ui.components import render_session, render_summary
from rdagent.app.rl.ui.config import ALWAYS_VISIBLE_TYPES, OPTIONAL_TYPES
from rdagent.app.rl.ui.data_loader import get_summary, get_valid_sessions, load_session
from rdagent.app.rl.ui.rl_summary import render_job_summary
from rdagent.core.utils import safe_resolve_path
DEFAULT_LOG_BASE = "log/"
def _safe_resolve(user_input: str | None, safe_root: Path) -> Path:
"""
Resolve user path relative to safe_root; raise ValueError if it escapes.
Security: This function prevents path traversal attacks by:
1. Rejecting null bytes in user input
2. Rejecting Windows drive letters (C:\, D:\, etc.)
3. Rejecting absolute paths
4. Normalizing path to remove .. traversal attempts
5. Validating resolved path is within safe_root using a realpath-based check
All user-provided paths are validated before filesystem access.
"""
# Treat the provided safe_root as trusted and canonicalize it once.
safe_root = safe_root.expanduser().resolve()
# Empty input maps to the safe root directory.
if not user_input:
return safe_root
# Security check 1: Reject null bytes (path truncation attack)
if "\x00" in user_input:
raise ValueError("Invalid path: contains null byte")
try:
# Security check 2: Normalize path to resolve .. and . components
normalized = os.path.normpath(user_input.strip())
# Security check 3: Reject Windows drive letters (C:\, D:\, etc.)
drive, _ = os.path.splitdrive(normalized)
if drive:
raise ValueError("Absolute paths with drive letters are not allowed")
# Security check 4: Reject absolute paths (/, //server/share, etc.)
if os.path.isabs(normalized):
raise ValueError("Absolute paths are not allowed")
# Security check 5: Build candidate path under safe_root and fully resolve it.
joined = os.path.join(str(safe_root), normalized)
resolved_candidate = os.path.realpath(joined)
# Security check 6: Validate candidate is within safe_root (prevent path traversal)
candidate_path = Path(resolved_candidate)
# Reconstruct from trusted safe_root so the returned path is root-derived.
return safe_root / candidate_path.relative_to(safe_root)
joined = safe_root / normalized
return safe_resolve_path(joined, safe_root)
except (OSError, ValueError) as exc:
raise ValueError(f"Invalid path outside of allowed root: {user_input}") from exc
@@ -82,7 +53,7 @@ def get_job_options(base_path: Path, safe_root: Path | None = None) -> list[str]
# Security fix: Validate base_path to prevent path traversal
try:
base_path_resolved = base_path.expanduser().resolve()
base_path_resolved = base_path.expanduser().resolve() # nosec B614 — validated against safe_root below via relative_to()
if safe_root is not None:
safe_root_resolved = safe_root.expanduser().resolve()
@@ -203,8 +174,7 @@ def main():
except ValueError as e:
st.warning(str(e))
return
# job_path is validated by _safe_resolve() above
if job_path.exists(): # nosec B614 path validated by _safe_resolve
if job_path.exists():
render_job_summary(job_path, safe_root, is_root=is_root_job)
else:
st.warning(f"Job folder not found: {job_folder}")
+7 -9
View File
@@ -15,6 +15,7 @@ from typing import Any
import streamlit as st
from rdagent.app.rl.ui.config import EventType
from rdagent.core.utils import safe_resolve_path
from rdagent.log.storage import FileStorage
@@ -76,11 +77,10 @@ def extract_stage(tag: str) -> str:
def get_valid_sessions(log_folder: Path, safe_root: Path | None = None) -> list[str]:
"""Get list of valid session directories, optionally validating against a safe root."""
if safe_root is not None:
root_real = os.path.realpath(str(safe_root.expanduser()))
folder_real = os.path.realpath(str(log_folder.expanduser()))
if not (folder_real == root_real or folder_real.startswith(root_real + os.sep)):
try:
log_folder = safe_resolve_path(log_folder, safe_root)
except ValueError:
return []
log_folder = Path(folder_real)
if not log_folder.exists():
return []
@@ -245,13 +245,11 @@ def parse_event(tag: str, content: Any, timestamp: datetime) -> Event | None:
@st.cache_data(ttl=300, hash_funcs={Path: str})
def load_session(log_path: Path, safe_root: Path | None = None) -> Session:
"""Load events into hierarchical session structure, optionally validating against safe root."""
# Validate path is within safe_root if provided
if safe_root is not None:
root_real = os.path.realpath(str(safe_root.expanduser()))
path_real = os.path.realpath(str(log_path.expanduser()))
if not (path_real == root_real or path_real.startswith(root_real + os.sep)):
try:
log_path = safe_resolve_path(log_path, safe_root)
except ValueError:
return Session()
log_path = Path(path_real)
session = Session()
+4 -6
View File
@@ -9,6 +9,8 @@ from pathlib import Path
import pandas as pd
import streamlit as st
from rdagent.core.utils import safe_resolve_path
def is_valid_task(task_path: Path) -> bool:
"""Check if directory is a valid RL task (has __session__ subdirectory)"""
@@ -62,14 +64,10 @@ def get_loop_status(task_path: Path, loop_id: int) -> tuple[str, bool | None]:
def _validate_job_path(job_path: Path, safe_root: Path) -> Path:
"""Resolve and validate that job_path stays within safe_root."""
resolved_root = safe_root.expanduser().resolve()
resolved_job = job_path.expanduser().resolve()
try:
# Reconstruct from trusted root so the returned path is root-derived.
return resolved_root / resolved_job.relative_to(resolved_root)
return safe_resolve_path(job_path, safe_root)
except ValueError:
raise ValueError(f"Job path is outside allowed root {resolved_root}")
raise ValueError(f"Job path is outside allowed root {safe_root}")
def get_max_loops(job_path: Path, safe_root: Path | None = None) -> int:
+2 -2
View File
@@ -54,11 +54,11 @@ def rdagent_info():
current_version = importlib.metadata.version("rdagent")
logger.info(f"RD-Agent version: {current_version}")
api_url = f"https://api.github.com/repos/microsoft/RD-Agent/contents/requirements.txt?ref=main"
response = requests.get(api_url)
response = requests.get(api_url, timeout=30)
if response.status_code == 200:
files = response.json()
file_url = files["download_url"]
file_response = requests.get(file_url)
file_response = requests.get(file_url, timeout=30)
if file_response.status_code == 200:
all_file_contents = file_response.text.split("\n")
else:
@@ -11,16 +11,23 @@ from .vbt_backtest import (
FTMO_MAX_LEVERAGE,
FTMO_RISK_PER_TRADE,
OOS_START_DEFAULT,
WF_IS_YEARS,
WF_OOS_YEARS,
WF_STEP_YEARS,
backtest_from_forward_returns,
backtest_signal,
backtest_signal_ftmo,
monte_carlo_trade_pvalue,
walk_forward_rolling,
)
__all__ = [
'BacktestMetrics', 'FactorBacktester', 'ResultsDatabase',
'CorrelationAnalyzer', 'PortfolioOptimizer', 'AdvancedRiskManager',
'backtest_signal', 'backtest_signal_ftmo', 'backtest_from_forward_returns',
'monte_carlo_trade_pvalue', 'walk_forward_rolling',
'DEFAULT_BARS_PER_YEAR', 'DEFAULT_TXN_COST_BPS',
'FTMO_INITIAL_CAPITAL', 'FTMO_MAX_DAILY_LOSS', 'FTMO_MAX_TOTAL_LOSS',
'FTMO_MAX_LEVERAGE', 'FTMO_RISK_PER_TRADE', 'OOS_START_DEFAULT',
'WF_IS_YEARS', 'WF_OOS_YEARS', 'WF_STEP_YEARS',
]
@@ -72,7 +72,7 @@ class BacktestMetrics:
class FactorBacktester:
def __init__(self):
self.metrics = BacktestMetrics()
self.results_path = Path(__file__).parent.parent.parent / "results" / "backtests"
self.results_path = Path(__file__).parent.parent.parent.parent / "results" / "backtests"
self.results_path.mkdir(parents=True, exist_ok=True)
def run_backtest(
@@ -222,7 +222,7 @@ class FactorBacktester:
# Calculate return for this step
if step > 0:
prev_price = float(price_values[step - 1]) if step > 0 else current_price
prev_price = float(price_values[step - 1])
if prev_price > 0:
step_return = (current_price - prev_price) / prev_price * position
returns_history.append(step_return)
+29 -20
View File
@@ -71,6 +71,9 @@ class ResultsDatabase:
self.conn.commit()
_ALLOWED_TABLES = frozenset({"factors", "backtest_runs", "loop_results"})
_ALLOWED_COL_TYPES = frozenset({"REAL", "TEXT", "INTEGER", "BLOB"})
def _add_column_if_not_exists(self, table: str, column: str, col_type: str) -> None:
"""
Add a column to a table if it doesn't already exist.
@@ -78,20 +81,24 @@ class ResultsDatabase:
Parameters
----------
table : str
Table name
Table name (must be in _ALLOWED_TABLES)
column : str
Column name to add
Column name to add (alphanumeric + underscore only)
col_type : str
SQL column type (e.g., 'REAL', 'TEXT')
SQL column type (must be in _ALLOWED_COL_TYPES)
"""
if table not in self._ALLOWED_TABLES:
raise ValueError(f"Unknown table: {table!r}")
if not column.replace("_", "").isalnum():
raise ValueError(f"Invalid column name: {column!r}")
if col_type not in self._ALLOWED_COL_TYPES:
raise ValueError(f"Invalid column type: {col_type!r}")
c = self.conn.cursor()
try:
# Try to query the column - if it fails, it doesn't exist
# nosec B608: Internal schema migration, column names are controlled
c.execute(f"SELECT {column} FROM {table} LIMIT 1") # nosec B608
except sqlite3.OperationalError:
# Column doesn't exist, add it
c.execute(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}") # nosec B608
c.execute("SELECT name FROM pragma_table_info(?)", (table,))
existing = {row[0] for row in c.fetchall()}
if column not in existing:
c.execute(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}")
def add_factor(self, name: str, type: str = "unknown") -> int:
c = self.conn.cursor()
@@ -159,7 +166,7 @@ class ResultsDatabase:
self.conn.commit()
return c.lastrowid
def add_loop(self, loop_idx: int, success: int, fail: int, best_ic: float = None, status: str = "completed") -> int:
def add_loop(self, loop_idx: int, success: int, fail: int, best_ic: float | None = None, status: str = "completed") -> int:
c = self.conn.cursor()
rate = success / (success + fail) if (success + fail) > 0 else 0
c.execute("""INSERT INTO loop_results (loop_index, factors_success, factors_fail, success_rate, best_ic, status)
@@ -183,16 +190,18 @@ class ResultsDatabase:
pd.DataFrame
DataFrame with factor names and metrics
"""
# Map shorthand to full column name
_ALLOWED_METRICS = frozenset({
'sharpe', 'ic', 'annual_return', 'max_drawdown',
'win_rate', 'information_ratio', 'volatility',
})
metric_map = {
'sharpe': 'sharpe',
'ic': 'ic',
'return': 'annual_return',
'drawdown': 'max_drawdown',
'win_rate': 'win_rate',
'sharpe': 'sharpe', 'ic': 'ic', 'return': 'annual_return',
'drawdown': 'max_drawdown', 'win_rate': 'win_rate',
'information_ratio': 'information_ratio',
}
col = metric_map.get(metric, metric)
if col not in _ALLOWED_METRICS:
raise ValueError(f"Unknown metric: {metric!r}")
return pd.read_sql_query(
f"""SELECT factor_name, ic, sharpe, annual_return, max_drawdown,
@@ -201,7 +210,7 @@ class ResultsDatabase:
JOIN factors ON factor_id = factors.id
WHERE {col} IS NOT NULL
ORDER BY {col} DESC
LIMIT ?""",
LIMIT ?""", # nosec B608 — col is validated against _ALLOWED_METRICS above
self.conn,
params=[limit]
)
@@ -321,13 +330,13 @@ class ResultsDatabase:
worst_drawdown = all_results['max_drawdown'].min() if total_runs > 0 and all_results['max_drawdown'].notna().any() else None
# Scan factors directory for JSON files
factors_dir = Path(__file__).parent.parent.parent / "results" / "factors"
factors_dir = Path(__file__).parent.parent.parent.parent / "results" / "factors"
json_factor_files = 0
if factors_dir.exists():
json_factor_files = len(list(factors_dir.glob("*.json")))
# Scan failed runs
failed_dir = Path(__file__).parent.parent.parent / "results" / "failed_runs"
failed_dir = Path(__file__).parent.parent.parent.parent / "results" / "failed_runs"
failed_runs_file = failed_dir / "failed_runs.json"
failed_runs_count = 0
failed_runs_data = []
@@ -1,21 +1,19 @@
"""
Predix Risk Management - Korrelation, Portfolio-Optimierung
"""
import numpy as np
import pandas as pd
from pathlib import Path
from typing import Dict, List, Optional
from datetime import datetime
import json
class CorrelationAnalyzer:
def __init__(self, lookback: int = 60):
self.lookback = lookback
def calculate_matrix(self, returns: pd.DataFrame) -> pd.DataFrame:
return returns.dropna().corr()
def find_uncorrelated(self, corr: pd.DataFrame, threshold: float = 0.3) -> List[str]:
def find_uncorrelated(self, corr: pd.DataFrame, threshold: float = 0.3) -> list[str]:
result = []
for f in corr.columns:
others = [x for x in corr.columns if x != f]
@@ -28,9 +26,9 @@ class PortfolioOptimizer:
try:
w = np.linalg.inv(cov.values) @ exp_ret.values
return w / np.sum(w)
except:
except (np.linalg.LinAlgError, ValueError):
return np.ones(len(exp_ret)) / len(exp_ret)
def risk_parity(self, cov: pd.DataFrame, max_iter: int = 100) -> np.ndarray:
n = cov.shape[0]
w = np.ones(n) / n
@@ -53,36 +51,36 @@ class AdvancedRiskManager:
self.max_dd = max_dd
self.corr_analyzer = CorrelationAnalyzer()
self.optimizer = PortfolioOptimizer()
def check_limits(self, weights: np.ndarray, vol: float, dd: float) -> Dict[str, bool]:
def check_limits(self, weights: np.ndarray, vol: float, dd: float) -> dict[str, bool]:
return {
'position_limit': np.max(np.abs(weights)) <= self.max_pos,
'leverage_limit': np.sum(np.abs(weights)) <= self.max_lev,
'drawdown_limit': abs(dd) <= self.max_dd,
"position_limit": np.max(np.abs(weights)) <= self.max_pos,
"leverage_limit": np.sum(np.abs(weights)) <= self.max_lev,
"drawdown_limit": abs(dd) <= self.max_dd,
}
if __name__ == "__main__":
print("=== Risk Test ===")
np.random.seed(42)
n, names = 252, ['Mom', 'MeanRev', 'Vol', 'Volu', 'ML']
n, names = 252, ["Mom", "MeanRev", "Vol", "Volu", "ML"]
ret = pd.DataFrame(np.random.randn(n, 5), columns=names)
corr = CorrelationAnalyzer().calculate_matrix(ret)
print("Korrelationsmatrix:")
print(corr.round(2))
opt = PortfolioOptimizer()
exp_ret = pd.Series([0.1, 0.08, 0.06, 0.07, 0.12], index=names)
cov = ret.cov() * 252
mv = opt.mean_variance(exp_ret, cov)
print("\nMean-Variance:")
for n, w in zip(names, mv): print(f" {n}: {w:.2%}")
rp = opt.risk_parity(cov)
print("\nRisk Parity:")
for n, w in zip(names, rp): print(f" {n}: {w:.2%}")
rm = AdvancedRiskManager()
checks = rm.check_limits(mv, 0.15, -0.08)
print(f"\nLimits OK: {all(checks.values())}")
+174 -24
View File
@@ -19,7 +19,7 @@ Design goals
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from typing import Any
import numpy as np
import pandas as pd
@@ -67,9 +67,8 @@ def _cross_check_with_vbt(
close: pd.Series,
position: pd.Series,
txn_cost: float,
manual_total_return: float,
freq: str,
) -> Optional[float]:
) -> float | None:
"""Run a vectorbt simulation and return its total_return for comparison."""
if not VBT_AVAILABLE:
return None
@@ -84,7 +83,8 @@ def _cross_check_with_vbt(
init_cash=10_000.0,
freq=freq,
)
return float(pf.total_return())
tr = float(pf.total_return())
return tr if np.isfinite(tr) else None
except Exception:
return None
@@ -95,9 +95,9 @@ def backtest_signal(
txn_cost_bps: float = DEFAULT_TXN_COST_BPS,
freq: str = "1min",
bars_per_year: int = DEFAULT_BARS_PER_YEAR,
forward_returns: Optional[pd.Series] = None,
forward_returns: pd.Series | None = None,
cross_check: bool = False,
) -> Dict[str, Any]:
) -> dict[str, Any]:
"""
Run a single-asset backtest from a position signal.
@@ -204,7 +204,7 @@ def backtest_signal(
calmar = ann_return_arith / abs(max_dd) if max_dd < 0 else 0.0
trade_pnl = _compute_trade_pnl(position, strategy_returns)
n_trades = int(len(trade_pnl))
n_trades = len(trade_pnl)
n_position_changes = int((position.diff().fillna(0) != 0).sum())
if n_trades > 0:
@@ -216,7 +216,7 @@ def backtest_signal(
win_rate = 0.0
profit_factor = 0.0
ic: Optional[float] = None
ic: float | None = None
if forward_returns is not None:
fwd = pd.to_numeric(forward_returns, errors="coerce")
common = signal.index.intersection(fwd.dropna().index)
@@ -227,7 +227,7 @@ def backtest_signal(
ic_val = float(s.corr(f))
ic = ic_val if np.isfinite(ic_val) else None
result: Dict[str, Any] = {
result: dict[str, Any] = {
"status": "success",
"sharpe": sharpe,
"sortino": sortino,
@@ -244,7 +244,7 @@ def backtest_signal(
"volatility": volatility,
"n_trades": n_trades,
"n_position_changes": n_position_changes,
"n_bars": int(len(strategy_returns)),
"n_bars": len(strategy_returns),
"n_months": float(n_months),
"signal_long": int((signal > 0).sum()),
"signal_short": int((signal < 0).sum()),
@@ -264,7 +264,6 @@ def backtest_signal(
close=close,
position=position,
txn_cost=txn_cost,
manual_total_return=total_return,
freq=freq,
)
@@ -293,7 +292,7 @@ def _apply_ftmo_mask(
daily_breaches = 0
total_breached = False
total_breach_ts: Optional[pd.Timestamp] = None
total_breach_ts: pd.Timestamp | None = None
current_day = None
day_start_eq = FTMO_INITIAL_CAPITAL
@@ -308,11 +307,8 @@ def _apply_ftmo_mask(
pos_i = float(signal.at[ts]) * leverage
ret_i = float(bar_ret.get(ts, 0.0))
cost_i = abs(pos_i - pos_prev) * txn_cost
ret_net = pos_prev * ret_i - cost_i
equity = equity * (1.0 + ret_net / FTMO_INITIAL_CAPITAL * FTMO_INITIAL_CAPITAL / equity
if equity > 0 else 1.0)
# Simpler: track as fraction
equity += FTMO_INITIAL_CAPITAL * ret_net
ret_frac = pos_prev * ret_i - cost_i
equity *= 1.0 + ret_frac if equity > 0 else 1.0
pos_prev = pos_i
if total_breached:
@@ -342,6 +338,129 @@ def _apply_ftmo_mask(
OOS_START_DEFAULT = "2024-01-01"
# Rolling walk-forward default windows (IS years, OOS years, step years)
WF_IS_YEARS = 3
WF_OOS_YEARS = 1
WF_STEP_YEARS = 1
def monte_carlo_trade_pvalue(
trade_pnl: pd.Series,
n_permutations: int = 1000,
seed: int = 0,
) -> float:
"""
Monte Carlo permutation test on trade-level P&L.
Runs a one-sided binomial test on trade-level win rate.
Tests H0: win_rate = 0.5 (random trading) against H1: win_rate > 0.5.
The ``n_permutations`` parameter is kept for API compatibility but is unused.
p < 0.05 win rate is significantly above 50%, indicating a genuine per-trade edge.
Parameters
----------
trade_pnl : pd.Series
Per-trade net returns (output of ``_compute_trade_pnl``).
n_permutations : int
Number of random permutations (default 1000).
seed : int
RNG seed for reproducibility.
Returns
-------
float
p-value in [0, 1]. Lower is better.
"""
if len(trade_pnl) < 2:
return 1.0
trades = trade_pnl.values.copy()
# Binomial test: is the win rate significantly above 50%?
# p = probability of observing >= n_wins out of n_trades under null (win_rate=0.5).
# Low p → strategy has a significant positive edge per trade.
from scipy.stats import binomtest
n_wins = int((trades > 0).sum())
n_total = len(trades)
result = binomtest(n_wins, n_total, p=0.5, alternative="greater")
return float(result.pvalue)
def walk_forward_rolling(
close: pd.Series,
signal: pd.Series,
leverage: float,
txn_cost_bps: float = DEFAULT_TXN_COST_BPS,
bars_per_year: int = DEFAULT_BARS_PER_YEAR,
is_years: int = WF_IS_YEARS,
oos_years: int = WF_OOS_YEARS,
step_years: int = WF_STEP_YEARS,
) -> dict[str, Any]:
"""
Rolling walk-forward validation: multiple IS/OOS windows shifted by ``step_years``.
Each window runs an independent FTMO simulation on the IS and OOS slices.
Produces aggregate OOS statistics to measure cross-time consistency.
Returns
-------
dict with keys:
wf_n_windows, wf_oos_sharpe_mean, wf_oos_sharpe_std,
wf_oos_monthly_return_mean, wf_oos_consistency (fraction of windows
with OOS Sharpe > 0), wf_windows (list of per-window dicts)
"""
if not isinstance(close.index, pd.DatetimeIndex):
return {"wf_n_windows": 0}
start_year = close.index[0].year
end_year = close.index[-1].year
windows = []
yr = start_year
while True:
is_start = pd.Timestamp(f"{yr}-01-01")
is_end = pd.Timestamp(f"{yr + is_years}-01-01")
oos_end = pd.Timestamp(f"{yr + is_years + oos_years}-01-01")
if oos_end.year > end_year + 1:
break
is_mask = (close.index >= is_start) & (close.index < is_end)
oos_mask = (close.index >= is_end) & (close.index < oos_end)
if is_mask.sum() < 1000 or oos_mask.sum() < 1000:
yr += step_years
continue
window: dict[str, Any] = {
"is_start": str(is_start.date()),
"is_end": str(is_end.date()),
"oos_start": str(is_end.date()),
"oos_end": str(oos_end.date()),
}
for mask, prefix in [(is_mask, "is"), (oos_mask, "oos")]:
close_s = close.loc[mask]
signal_s = signal.loc[mask]
masked_s, _ = _apply_ftmo_mask(signal_s, close_s, leverage, txn_cost_bps)
r = backtest_signal(close=close_s, signal=masked_s,
txn_cost_bps=txn_cost_bps, bars_per_year=bars_per_year)
window[f"{prefix}_sharpe"] = r.get("sharpe", 0.0)
window[f"{prefix}_monthly_return_pct"] = r.get("monthly_return_pct", 0.0)
window[f"{prefix}_n_trades"] = r.get("n_trades", 0)
windows.append(window)
yr += step_years
if not windows:
return {"wf_n_windows": 0}
oos_sharpes = [w["oos_sharpe"] for w in windows]
oos_monthly = [w["oos_monthly_return_pct"] for w in windows]
return {
"wf_n_windows": len(windows),
"wf_oos_sharpe_mean": float(np.mean(oos_sharpes)),
"wf_oos_sharpe_std": float(np.std(oos_sharpes)),
"wf_oos_monthly_return_mean": float(np.mean(oos_monthly)),
"wf_oos_consistency": float(np.mean([s > 0 for s in oos_sharpes])),
"wf_windows": windows,
}
def backtest_signal_ftmo(
close: pd.Series,
@@ -352,9 +471,11 @@ def backtest_signal_ftmo(
stop_pips: float = FTMO_STOP_PIPS,
max_leverage: float = FTMO_MAX_LEVERAGE,
bars_per_year: int = DEFAULT_BARS_PER_YEAR,
forward_returns: Optional[pd.Series] = None,
oos_start: Optional[str] = OOS_START_DEFAULT,
) -> Dict[str, Any]:
forward_returns: pd.Series | None = None,
oos_start: str | None = OOS_START_DEFAULT,
wf_rolling: bool = False,
mc_n_permutations: int = 0,
) -> dict[str, Any]:
"""
FTMO-compliant backtest of a strategy signal on EUR/USD.
@@ -385,6 +506,13 @@ def backtest_signal_ftmo(
Maximum leverage (default 30 = FTMO 1:30).
oos_start : str or None
Start of out-of-sample period (ISO date). None disables OOS split.
wf_rolling : bool
If True, run rolling walk-forward validation (multiple IS/OOS windows).
Results are stored under ``wf_*`` keys. Default False.
mc_n_permutations : int
Number of Monte Carlo trade permutations. 0 = disabled (default).
When > 0, computes ``mc_pvalue``: fraction of permuted sequences whose
total return >= real total return. p < 0.05 indicates a genuine edge.
"""
stop_price = stop_pips * FTMO_PIP
leverage_by_risk = risk_pct / (stop_price / eurusd_price)
@@ -415,7 +543,7 @@ def backtest_signal_ftmo(
is_mask = close.index < oos_ts
oos_mask = close.index >= oos_ts
def _split_bt(mask: "pd.Series[bool]", prefix: str) -> None:
def _split_bt(mask: pd.Series[bool], prefix: str) -> None:
if mask.sum() < 100:
return
close_s = close.loc[mask]
@@ -440,6 +568,28 @@ def backtest_signal_ftmo(
result["is_n_bars"] = int(is_mask.sum())
result["oos_n_bars"] = int(oos_mask.sum())
# Rolling walk-forward validation
if wf_rolling:
wf = walk_forward_rolling(
close=close,
signal=signal,
leverage=leverage,
txn_cost_bps=txn_cost_bps,
bars_per_year=bars_per_year,
)
result.update(wf)
# Monte Carlo trade permutation test
if mc_n_permutations > 0:
position = masked_signal.shift(1).fillna(0)
bar_ret = close.pct_change().fillna(0)
txn_cost = txn_cost_bps / 10_000.0
position_change = position.diff().abs().fillna(position.abs())
strat_ret = position * bar_ret - position_change * txn_cost
trade_pnl = _compute_trade_pnl(position, strat_ret)
result["mc_pvalue"] = monte_carlo_trade_pvalue(trade_pnl, mc_n_permutations)
result["mc_n_permutations"] = mc_n_permutations
return result
@@ -448,7 +598,7 @@ def backtest_from_forward_returns(
forward_returns: pd.Series,
txn_cost_bps: float = DEFAULT_TXN_COST_BPS,
bars_per_year: int = DEFAULT_BARS_PER_YEAR,
) -> Dict[str, Any]:
) -> dict[str, Any]:
"""
Backtest a factor using sign(factor) as signal against forward returns.
@@ -486,7 +636,7 @@ def backtest_from_forward_returns(
ic = ic_val if np.isfinite(ic_val) else 0.0
trade_pnl = _compute_trade_pnl(position, strategy_returns)
n_trades = int(len(trade_pnl))
n_trades = len(trade_pnl)
win_rate = float((trade_pnl > 0).mean()) if n_trades > 0 else 0.0
ann_return = float(strategy_returns.mean() * bars_per_year)
@@ -502,7 +652,7 @@ def backtest_from_forward_returns(
"win_rate": win_rate,
"n_trades": n_trades,
"ic": ic,
"n_bars": int(len(strategy_returns)),
"n_bars": len(strategy_returns),
"txn_cost_bps": txn_cost_bps,
"bars_per_year": bars_per_year,
}
+14 -7
View File
@@ -75,8 +75,10 @@ class CoSTEER(Developer[Experiment]):
def _get_last_fb(self) -> CoSTEERMultiFeedback:
fb = self.evolve_agent.evolving_trace[-1].feedback
assert fb is not None, "feedback is None"
assert isinstance(fb, CoSTEERMultiFeedback), "feedback must be of type CoSTEERMultiFeedback"
if fb is None:
raise AssertionError("feedback is None")
if not isinstance(fb, CoSTEERMultiFeedback):
raise TypeError("feedback must be of type CoSTEERMultiFeedback")
return fb
def should_use_new_evo(self, base_fb: CoSTEERMultiFeedback | None, new_fb: CoSTEERMultiFeedback) -> bool:
@@ -121,7 +123,8 @@ class CoSTEER(Developer[Experiment]):
for evo_exp in self.evolve_agent.multistep_evolve(evo_exp, self.evaluator):
iteration_count += 1
assert isinstance(evo_exp, Experiment) # multiple inheritance
if not isinstance(evo_exp, Experiment):
raise TypeError("evo_exp must be an instance of Experiment")
evo_fb = self._get_last_fb()
update_fallback = self.should_use_new_evo(
base_fb=fallback_evo_fb,
@@ -154,7 +157,8 @@ class CoSTEER(Developer[Experiment]):
evo_exp = fallback_evo_exp
evo_exp.recover_ws_ckp()
evo_fb = fallback_evo_fb
assert evo_fb is not None # multistep_evolve should run at least once
if evo_fb is None:
raise AssertionError("multistep_evolve should run at least once")
evo_exp = self._exp_postprocess_by_feedback(evo_exp, evo_fb)
except CoderError as e:
e.caused_by_timeout = reached_max_seconds
@@ -264,9 +268,12 @@ class CoSTEER(Developer[Experiment]):
- Raise Error if it failed to handle the develop task
-
"""
assert isinstance(evo, Experiment)
assert isinstance(feedback, CoSTEERMultiFeedback)
assert len(evo.sub_workspace_list) == len(feedback)
if not isinstance(evo, Experiment):
raise TypeError("evo must be an instance of Experiment")
if not isinstance(feedback, CoSTEERMultiFeedback):
raise TypeError("feedback must be an instance of CoSTEERMultiFeedback")
if len(evo.sub_workspace_list) != len(feedback):
raise ValueError("Length of sub_workspace_list must match length of feedback")
# FIXME: when whould the feedback be None?
failed_feedbacks = [
@@ -122,7 +122,8 @@ class MultiProcessEvolvingStrategy(EvolvingStrategy):
last_feedback = None
if len(evolving_trace) > 0:
last_feedback = evolving_trace[-1].feedback
assert isinstance(last_feedback, CoSTEERMultiFeedback)
if not isinstance(last_feedback, CoSTEERMultiFeedback):
raise TypeError("last_feedback must be of type CoSTEERMultiFeedback")
# 1.找出需要evolve的task
to_be_finished_task_index: list[int] = []
@@ -1028,7 +1028,8 @@ class CoSTEERKnowledgeBaseV2(EvolvingKnowledgeBase):
"""
node_count = len(nodes)
assert node_count >= 2, "nodes length must >=2"
if node_count < 2:
raise ValueError("nodes length must >=2")
intersection_node_list = []
if output_intersection_origin:
origin_list = []
@@ -54,7 +54,8 @@ def get_ds_env(
ValueError: If the env_type is not recognized.
"""
conf = DSCoderCoSTEERSettings()
assert conf_type in ["kaggle", "mlebench"], f"Unknown conf_type: {conf_type}"
if conf_type not in ["kaggle", "mlebench"]:
raise ValueError(f"Unknown conf_type: {conf_type}")
if conf.env_type == "docker":
env_conf = DSDockerConf() if conf_type == "kaggle" else MLEBDockerConf()
@@ -79,7 +80,8 @@ def get_clear_ws_cmd(stage: Literal["before_training", "before_inference"] = "be
"""
Clean the files in workspace to a specific stage
"""
assert stage in ["before_training", "before_inference"], f"Unknown stage: {stage}"
if stage not in ["before_training", "before_inference"]:
raise ValueError(f"Unknown stage: {stage}")
if DS_RD_SETTING.enable_model_dump and stage == "before_training":
cmd = "rm -r submission.csv scores.csv models trace.log"
else:
@@ -13,7 +13,7 @@ File structure
from pathlib import Path
from jinja2 import Environment, StrictUndefined
from jinja2 import Environment, StrictUndefined, select_autoescape
from rdagent.app.data_science.conf import DS_RD_SETTING
from rdagent.components.coder.CoSTEER.evaluators import (
@@ -88,7 +88,7 @@ class EnsembleMultiProcessEvolvingStrategy(MultiProcessEvolvingStrategy):
code_spec = workspace.file_dict["spec/ensemble.md"]
else:
test_code = (
Environment(undefined=StrictUndefined)
Environment(undefined=StrictUndefined, autoescape=select_autoescape())
.from_string((DIRNAME / "eval_tests" / "ensemble_test.txt").read_text())
.render(
model_names=[
@@ -2,7 +2,7 @@ import json
import re
from pathlib import Path
from jinja2 import Environment, StrictUndefined
from jinja2 import Environment, StrictUndefined, select_autoescape
from rdagent.app.data_science.conf import DS_RD_SETTING
from rdagent.components.coder.CoSTEER.evaluators import (
@@ -55,7 +55,7 @@ class EnsembleCoSTEEREvaluator(CoSTEEREvaluator):
fname = "test/ensemble_test.txt"
test_code = (DIRNAME / "eval_tests" / "ensemble_test.txt").read_text()
test_code = (
Environment(undefined=StrictUndefined)
Environment(undefined=StrictUndefined, autoescape=select_autoescape())
.from_string(test_code)
.render(
model_names=[
@@ -51,13 +51,23 @@ class FactorAutoFixer:
self.fixes_applied = []
fixed_code = code
# Apply fixes in order - groupby fixes MUST come before min_periods fixes
# Apply fixes in order
# NOTE: _fix_min_periods is intentionally excluded — it increased min_periods to
# match window size, which causes all-NaN output for intraday data with 96 bars/day
# (window=240 > 96 means zero valid bars per day). The LLM sets its own min_periods.
fix_methods = [
self._fix_groupby_apply_to_transform, # First: fix groupby patterns
self._fix_min_periods, # Second: fix min_periods in resulting rolling calls
self._fix_inf_nan_handling, # Third: add inf/nan handling
self._fix_data_range_processing, # Fourth: ensure full data range
self._fix_multiindex_groupby, # Fifth: ensure groupby on MultiIndex
self._fix_instrument_column_access, # First: fix df['instrument'] on MultiIndex
self._fix_instrument_loc_multiindex, # Second: fix df.loc[instrument_var] on MultiIndex
self._fix_zero_volume_proxy, # Third: replace zero $volume with range proxy
self._fix_reset_index_groupby, # Fourth: fix groupby(level=N) after reset_index()
self._fix_groupby_mixed_levels, # Fifth: fix groupby(level=[int, str])
self._fix_groupby_column_on_multiindex, # Sixth: fix groupby(['instrument','date']) on MultiIndex
self._fix_chained_groupby, # Seventh: fix groupby(level=N).groupby('date') chain
self._fix_rolling_ddof, # Eighth: remove unsupported ddof kwarg
self._fix_groupby_apply_to_transform, # Ninth: fix groupby patterns
self._fix_inf_nan_handling, # Tenth: add inf/nan handling
self._fix_data_range_processing, # Eleventh: ensure full data range
self._fix_multiindex_groupby, # Twelfth: ensure groupby on MultiIndex
]
for fix_method in fix_methods:
@@ -75,6 +85,352 @@ class FactorAutoFixer:
return fixed_code
def _fix_instrument_column_access(self, code: str) -> str:
"""
Fix: df['instrument'] raises KeyError on a MultiIndex DataFrame because
'instrument' is an index level (level 1), not a column.
Replace df['instrument'] with df.index.get_level_values('instrument')
but only when the DataFrame has a MultiIndex (not after reset_index which
would have promoted it to a real column).
Also fixes df.reset_index()['instrument'] correctly since after reset_index
the column exists.
"""
fixed_code = code
# Skip if already fixed or if reset_index() is being used before the access
# We only fix bare df['instrument'] where df is the original MultiIndex frame.
# Heuristic: if the assignment lhs or context shows reset_index, leave it alone.
# Pattern: <varname>['instrument'] where varname is NOT a reset_index result
reset_vars = set(re.findall(r'(\w+)\s*=\s*\w[^=\n]*\.reset_index\(', fixed_code))
def _replace_instrument_access(m: re.Match) -> str:
var = m.group(1)
if var in reset_vars:
return m.group(0) # leave reset_index vars alone — column exists
self.fixes_applied.append(f"instrument_column: {var}['instrument'] → get_level_values(1)")
return f"{var}.index.get_level_values(1)"
# Exclude assignment targets: var['instrument'] = ... must not become
# var.index.get_level_values(1) = ... (SyntaxError: cannot assign to function call)
fixed_code = re.sub(r"(\w+)\['instrument'\](?!\s*=)", _replace_instrument_access, fixed_code)
return fixed_code
def _fix_instrument_loc_multiindex(self, code: str) -> str:
"""
Fix: df.loc[instrument_var] raises DateParseError on a (datetime, instrument)
MultiIndex because pandas tries to match the instrument string against the
datetime level (level 0).
Pattern detected: for-loops iterating over get_level_values('instrument') or
get_level_values(1) where the loop variable is then used as df.loc[loop_var].
Replacement: df.loc[instrument_var] df.xs(instrument_var, level=1)
"""
fixed_code = code
# Find variables iterated from get_level_values('instrument') or get_level_values(1)
inst_vars = set(
re.findall(
r"for\s+(\w+)\s+in\s+.+?\.get_level_values\s*\(\s*(?:1|['\"]instrument['\"])\s*\)[^:\n]*:",
code,
)
)
if not inst_vars:
return fixed_code
for var in inst_vars:
# Replace DF.loc[var] (read) with DF.xs(var, level=1)
# Exclude write-back patterns (DF.loc[var] = ...) — leave those as-is
def _make_replacer(v: str):
def _replace(m: re.Match) -> str:
df_var = m.group(1)
self.fixes_applied.append(
f"instrument_loc: {df_var}.loc[{v}] → {df_var}.xs({v}, level=1)"
)
return f"{df_var}.xs({v}, level=1)"
return _replace
# Only match when NOT followed by ' =' (assignment)
fixed_code = re.sub(
rf"(\w+)\.loc\[\s*{re.escape(var)}\s*\](?!\s*=)",
_make_replacer(var),
fixed_code,
)
return fixed_code
def _fix_zero_volume_proxy(self, code: str) -> str:
"""
Fix: $volume is always 0 in our EUR/USD dataset (FX has no real volume).
Any factor using $volume (VWAP, volume-weighted returns, etc.) produces
all-NaN output because 0*price=0 and sum(0)/sum(0)=NaN.
Insert a guard right after pd.read_hdf() that replaces zero volume with
the intraday price-range proxy ($high - $low) so volume-weighted factors
produce meaningful signals.
"""
if "'$volume'" not in code and '"$volume"' not in code:
return code
# Already patched
if "volume proxy" in code:
return code
lines = code.splitlines()
insert_after = -1
df_var = "df"
indent = " "
for i, line in enumerate(lines):
if "read_hdf(" in line:
m = re.match(r"(\s*)(\w+)\s*=\s*", line)
if m:
indent = m.group(1)
df_var = m.group(2)
else:
m2 = re.match(r"(\s*)", line)
indent = m2.group(1) if m2 else " "
insert_after = i
break
if insert_after == -1:
return code
proxy_lines = [
f"{indent}# volume proxy: $volume is always 0 in FX data — use price-range as proxy",
f"{indent}if ({df_var}['$volume'] == 0).all():",
f"{indent} {df_var}['$volume'] = {df_var}['$high'] - {df_var}['$low']",
]
lines = lines[: insert_after + 1] + proxy_lines + lines[insert_after + 1 :]
self.fixes_applied.append("volume_proxy: replaced zero $volume with ($high - $low)")
return "\n".join(lines)
def _fix_reset_index_groupby(self, code: str) -> str:
"""
Fix: groupby(level=N) on a variable created by .reset_index() fails because
reset_index() converts the MultiIndex into regular columns, leaving a plain
RangeIndex. Replace groupby(level=N) on such variables with
groupby('instrument').
Detected pattern:
varname = <anything>.reset_index(...)
...
varname.groupby(level=0|1)
"""
fixed_code = code
# Find all variables assigned via reset_index()
reset_vars = set(re.findall(r'(\w+)\s*=\s*\w[^=\n]*\.reset_index\(', fixed_code))
for var in reset_vars:
# Replace var.groupby(level=N) with var.groupby('instrument')
pattern = rf'{re.escape(var)}\.groupby\(level\s*=\s*\d+\)'
if re.search(pattern, fixed_code):
fixed_code = re.sub(pattern, f"{var}.groupby('instrument')", fixed_code)
self.fixes_applied.append(f"reset_index_groupby: {var}.groupby(level=N) → groupby('instrument')")
return fixed_code
def _fix_groupby_mixed_levels(self, code: str) -> str:
"""
Fix: groupby(level=[int, 'str']) raises AssertionError because string level
names don't exist on an unnamed MultiIndex. Keep only integer levels.
Pattern: .groupby(level=[0, 'date']) .groupby(level=0)
.groupby(level=[1, 'date']) .groupby(level=1)
"""
fixed_code = code
def _keep_int_levels(m):
inner = m.group(1)
ints = re.findall(r'\b(\d+)\b', inner)
if not ints:
return m.group(0)
replacement = f'.groupby(level={ints[0]})' if len(ints) == 1 else f'.groupby(level=[{", ".join(ints)}])'
self.fixes_applied.append(f"mixed_levels: groupby(level=[...,str]) → {replacement}")
return replacement
fixed_code = re.sub(r'\.groupby\(level=\[([^\]]+)\]\)', _keep_int_levels, fixed_code)
return fixed_code
def _fix_groupby_column_on_multiindex(self, code: str) -> str:
"""
Fix: groupby(['instrument', 'date']) on a MultiIndex (datetime, instrument)
DataFrame fails with KeyError because those are index levels, not columns.
Correct replacement preserves BOTH dimensions so intraday calculations reset
per day:
var.groupby(['instrument', 'date'])
var.groupby([var.index.get_level_values(1), var.index.get_level_values(0).normalize()])
Single-column groupby(['instrument']) is correctly replaced with groupby(level=1).
Note: do NOT convert groupby('instrument') groupby(level=1) here that would
undo the reset_index_groupby fix which correctly emits groupby('instrument').
"""
fixed_code = code
# Variables created via reset_index() have a plain RangeIndex — applying
# get_level_values() on them would raise AttributeError. Skip those.
reset_vars = set(re.findall(r'(\w+)\s*=\s*\w[^=\n]*\.reset_index\(', fixed_code))
def _replace_two_col_groupby(m: re.Match, order: str) -> str:
var = m.group(1)
if var in reset_vars:
return m.group(0) # leave reset_index vars alone — RangeIndex, not MultiIndex
if order == "instrument_date":
repl = (
f"{var}.groupby([{var}.index.get_level_values(1), "
f"{var}.index.get_level_values(0).normalize()])"
)
else: # date_instrument
repl = (
f"{var}.groupby([{var}.index.get_level_values(0).normalize(), "
f"{var}.index.get_level_values(1)])"
)
self.fixes_applied.append(f"multiindex_groupby: {m.group(0)[:60]} → two-level")
return repl
# groupby(['instrument', 'date']) — capture variable name before .groupby
fixed_code = re.sub(
r'(\w+)\.groupby\(\[\'instrument\',\s*\'date\'\]\)',
lambda m: _replace_two_col_groupby(m, "instrument_date"),
fixed_code,
)
# groupby(['date', 'instrument'])
fixed_code = re.sub(
r'(\w+)\.groupby\(\[\'date\',\s*\'instrument\'\]\)',
lambda m: _replace_two_col_groupby(m, "date_instrument"),
fixed_code,
)
# single: groupby(['instrument']) → groupby(level=1), but not on reset_index vars
def _replace_single_instrument_groupby(m: re.Match) -> str:
# Look backwards to find the variable name
prefix = fixed_code[: m.start()]
var_match = re.search(r'(\w+)\s*$', prefix)
var = var_match.group(1) if var_match else ''
if var in reset_vars:
return m.group(0)
self.fixes_applied.append("multiindex_groupby: groupby(['instrument']) → groupby(level=1)")
return ".groupby(level=1)"
if re.search(r"\.groupby\(\['instrument'\]\)", fixed_code):
fixed_code = re.sub(r"\.groupby\(\['instrument'\]\)", _replace_single_instrument_groupby, fixed_code)
# groupby(level=['instrument', 'date']) — uses level= keyword with string names.
# 'date' is NOT a valid level name in our (datetime, instrument) MultiIndex;
# replace with get_level_values to normalize datetime to daily timestamps.
fixed_code = re.sub(
r"(\w+)\.groupby\(level=\['instrument',\s*'date'\]\)",
lambda m: (
self.fixes_applied.append(
f"multiindex_groupby: {m.group(0)[:60]} → two-level get_level_values"
)
or f"{m.group(1)}.groupby([{m.group(1)}.index.get_level_values(1), "
f"{m.group(1)}.index.get_level_values(0).normalize()])"
),
fixed_code,
)
# groupby(level=['date', 'instrument'])
fixed_code = re.sub(
r"(\w+)\.groupby\(level=\['date',\s*'instrument'\]\)",
lambda m: (
self.fixes_applied.append(
f"multiindex_groupby: {m.group(0)[:60]} → two-level get_level_values"
)
or f"{m.group(1)}.groupby([{m.group(1)}.index.get_level_values(0).normalize(), "
f"{m.group(1)}.index.get_level_values(1)])"
),
fixed_code,
)
# single: groupby(level=['instrument']) → groupby(level=1)
fixed_code = re.sub(
r"\.groupby\(level=\['instrument'\]\)",
lambda m: (self.fixes_applied.append("multiindex_groupby: groupby(level=['instrument']) → level=1") or ".groupby(level=1)"),
fixed_code,
)
return fixed_code
def _fix_chained_groupby(self, code: str) -> str:
"""
Fix two broken patterns the LLM generates when trying to group by (instrument, date):
Pattern A chained groupby (runtime AttributeError):
var.groupby(level=1).groupby('date')
var.groupby([var.index.get_level_values(1),
var.index.get_level_values(0).normalize()])
Pattern B keyword arg inside list (SyntaxError):
var.groupby([level=1, 'date'])
same two-level replacement
"""
fixed_code = code
def _two_level(var: str, tag: str) -> str:
self.fixes_applied.append(f"chained_groupby: {tag} → two-level")
return (
f"{var}.groupby([{var}.index.get_level_values(1), "
f"{var}.index.get_level_values(0).normalize()])"
)
# Pattern A: var.groupby(level=N).groupby('date')
fixed_code = re.sub(
r'(\w+)\.groupby\(level=\d+\)\.groupby\(["\']date["\']\)',
lambda m: _two_level(m.group(1), m.group(0)[:60]),
fixed_code,
)
# Pattern B: .groupby([level=N, 'date']) — SyntaxError in Python.
# The variable before .groupby may be complex (e.g. df[mask]) so we don't
# try to capture it; we use df as the index reference (always correct since
# all filtered frames share df's MultiIndex structure).
def _two_level_df(tag: str) -> str:
self.fixes_applied.append(f"chained_groupby: {tag} → two-level")
return ".groupby([df.index.get_level_values(1), df.index.get_level_values(0).normalize()])"
fixed_code = re.sub(
r'\.groupby\(\[\s*level\s*=\s*\d+\s*,\s*["\']?date["\']?\s*\]\)',
lambda m: _two_level_df(m.group(0)[:60]),
fixed_code,
)
# Also handle reversed order: ['date', level=N]
fixed_code = re.sub(
r'\.groupby\(\[\s*["\']?date["\']?\s*,\s*level\s*=\s*\d+\s*\]\)',
lambda m: _two_level_df(m.group(0)[:60]),
fixed_code,
)
return fixed_code
def _fix_rolling_ddof(self, code: str) -> str:
"""
Fix: pandas rolling() does not accept a ddof kwarg raises TypeError.
Remove ddof from both rolling(..., ddof=N) and rolling(...).std(ddof=N).
"""
fixed_code = code
# Form 1: ddof inside rolling() — .rolling(window=N, min_periods=M, ddof=K)
def _strip_ddof_from_rolling(m):
inner = re.sub(r',?\s*ddof\s*=\s*\d+', '', m.group(1))
inner = inner.strip(', ')
self.fixes_applied.append("rolling_ddof: removed ddof from rolling()")
return f'.rolling({inner})'
fixed_code = re.sub(r'\.rolling\(([^)]*ddof\s*=\s*\d+[^)]*)\)', _strip_ddof_from_rolling, fixed_code)
# Form 2: ddof inside .std() / .var() — .std(ddof=N)
if re.search(r'\.(std|var)\([^)]*ddof\s*=\s*\d+', fixed_code):
fixed_code = re.sub(r'\.(std|var)\([^)]*ddof\s*=\s*\d+[^)]*\)', r'.\1()', fixed_code)
self.fixes_applied.append("rolling_ddof: removed ddof from std()/var()")
return fixed_code
def _fix_min_periods(self, code: str) -> str:
"""
Fix: Ensure min_periods matches window size in rolling calculations.
@@ -325,6 +681,45 @@ class FactorAutoFixer:
fixed_code = fixed_code.replace(old_code, new_code)
self.fixes_applied.append(f"groupby: fixed rolling correlation (window={window}) with reset_index")
# === GENERAL FIX: DF.groupby(level=N)['col'].apply(lambda x: EXPR) ===
# apply() on a grouped Series returns a MultiIndex result (extra level prepended),
# causing index shape mismatch when assigned back to df['col'].
# Replace with transform() which preserves the original index.
col_apply_pattern = re.compile(
r"(\w+)\.groupby\(level=(\d+)\)\['([^']+)'\]\.apply\((\s*lambda\s+\w+\s*:.*?)\)",
re.DOTALL,
)
for m in list(col_apply_pattern.finditer(fixed_code)):
full = m.group(0)
df_var = m.group(1)
level = m.group(2)
col = m.group(3)
lam = m.group(4).strip()
new_expr = f"{df_var}.groupby(level={level})['{col}'].transform({lam})"
fixed_code = fixed_code.replace(full, new_expr, 1)
self.fixes_applied.append(
f"groupby: {df_var}.groupby(level={level})['{col}'].apply() → transform()"
)
# === FIX: .transform(...).reset_index(level=N, drop=True) ===
# transform() already returns the same index as the input — adding reset_index()
# after it drops an index level and causes ValueError on assignment back to df['col'].
# Detected line-by-line: if a line contains both .transform( and .reset_index(level=
reset_suffix = re.compile(r'\s*\.reset_index\s*\(\s*level\s*=[^,)]+,\s*drop\s*=\s*True\s*\)\s*$')
new_lines = []
changed = False
for line in fixed_code.splitlines():
if '.transform(' in line and '.reset_index(' in line:
cleaned = reset_suffix.sub('', line)
if cleaned != line:
new_lines.append(cleaned)
changed = True
continue
new_lines.append(line)
if changed:
fixed_code = '\n'.join(new_lines)
self.fixes_applied.append("groupby: removed spurious .reset_index() after .transform()")
# Pattern: Simple groupby().apply() with rolling().method()
# df.groupby(level=N).apply(lambda x: x['col'].rolling(...).method())
apply_pattern = r"df\.groupby\(level=(\d+)\)\.apply\(\s*lambda\s+x:\s+x\['([^']+)'\]\.rolling\([^)]+\)\.(\w+)\([^)]*\)\s*\)"
@@ -328,12 +328,13 @@ class FactorEqualValueRatioEvaluator(FactorEvaluator):
"The source dataframe is None. Please check the implementation.",
-1,
)
acc_rate = -1
try:
close_values = gen_df.sub(gt_df).abs().lt(1e-6)
result_int = close_values.astype(int)
pos_num = result_int.sum().sum()
acc_rate = pos_num / close_values.size
except:
except Exception:
close_values = gen_df
if close_values.all().iloc[0]:
return (
@@ -161,8 +161,7 @@ class FactorFBWorkspace(FBWorkspace):
try:
subprocess.check_output(
f"{FACTOR_COSTEER_SETTINGS.python_bin} {execution_code_path}",
shell=True,
[FACTOR_COSTEER_SETTINGS.python_bin, str(execution_code_path)],
cwd=self.workspace_path,
stderr=subprocess.STDOUT,
timeout=FACTOR_COSTEER_SETTINGS.file_based_execution_timeout,
@@ -53,7 +53,7 @@ evolving_strategy_factor_implementation_v1_system: |-
- ALWAYS use `min_periods=N` where N equals the window size in rolling calculations (e.g., `.rolling(20, min_periods=20)`)
- ALWAYS handle infinite values after division: `.replace([np.inf, -np.inf], np.nan)` before saving results
- ALWAYS use `groupby(level=1)` or `groupby('instrument')` before rolling operations on MultiIndex dataframes
- Process the COMPLETE date range (2020-2026), do NOT filter by date
- Process the COMPLETE date range available in the HDF5 file (do NOT filter by date — the file may contain 2024 debug data or full 2020-2026 data)
- Use `groupby().transform()` instead of `groupby().apply()` for single-column assignments
Notice that you should not add any other text before or after the json format.
@@ -6,6 +6,7 @@ Two-step validation:
2. Micro-batch testing - Runtime validation with small dataset
"""
import ast
import json
import re
import time
@@ -229,7 +230,7 @@ class LLMConfigValidator:
final_metrics = re.search(r"\{'train_runtime':[^}]+\}", stdout)
if final_metrics:
try:
metrics = eval(final_metrics.group(0)) # Safe: only numbers and strings
metrics = ast.literal_eval(final_metrics.group(0))
result["final_metrics"] = {
"train_loss": metrics.get("train_loss"),
"train_runtime": metrics.get("train_runtime"),
@@ -123,8 +123,8 @@ model_cls = AntiSymmetricConv
if __name__ == "__main__":
node_features = torch.load("node_features.pt")
edge_index = torch.load("edge_index.pt")
node_features = torch.load("node_features.pt", weights_only=True)
edge_index = torch.load("edge_index.pt", weights_only=True)
# Model instantiation and forward pass
model = AntiSymmetricConv(in_channels=node_features.size(-1))
@@ -78,8 +78,8 @@ model_cls = DirGNNConv
if __name__ == "__main__":
node_features = torch.load("node_features.pt")
edge_index = torch.load("edge_index.pt")
node_features = torch.load("node_features.pt", weights_only=True)
edge_index = torch.load("edge_index.pt", weights_only=True)
# Model instantiation and forward pass
model = DirGNNConv(MessagePassing())
@@ -187,8 +187,8 @@ model_cls = GPSConv
if __name__ == "__main__":
node_features = torch.load("node_features.pt")
edge_index = torch.load("edge_index.pt")
node_features = torch.load("node_features.pt", weights_only=True)
edge_index = torch.load("edge_index.pt", weights_only=True)
# Model instantiation and forward pass
model = GPSConv(channels=node_features.size(-1), conv=MessagePassing())
@@ -170,8 +170,8 @@ class LINKX(torch.nn.Module):
model_cls = LINKX
if __name__ == "__main__":
node_features = torch.load("node_features.pt")
edge_index = torch.load("edge_index.pt")
node_features = torch.load("node_features.pt", weights_only=True)
edge_index = torch.load("edge_index.pt", weights_only=True)
# Model instantiation and forward pass
model = LINKX(
@@ -102,8 +102,8 @@ class PMLP(torch.nn.Module):
model_cls = PMLP
if __name__ == "__main__":
node_features = torch.load("node_features.pt")
edge_index = torch.load("edge_index.pt")
node_features = torch.load("node_features.pt", weights_only=True)
edge_index = torch.load("edge_index.pt", weights_only=True)
# Model instantiation and forward pass
model = PMLP(
@@ -1180,8 +1180,8 @@ model_cls = ViSNet
if __name__ == "__main__":
node_features = torch.load("node_features.pt")
edge_index = torch.load("edge_index.pt")
node_features = torch.load("node_features.pt", weights_only=True)
edge_index = torch.load("edge_index.pt", weights_only=True)
# Model instantiation and forward pass
model = ViSNet()
@@ -58,10 +58,12 @@ class ModelCodeEvaluator(CoSTEEREvaluator):
model_execution_feedback: str = "",
model_value_feedback: str = "",
):
assert isinstance(target_task, ModelTask)
assert isinstance(implementation, ModelFBWorkspace)
if gt_implementation is not None:
assert isinstance(gt_implementation, ModelFBWorkspace)
if not isinstance(target_task, ModelTask):
raise TypeError("target_task must be of type ModelTask")
if not isinstance(implementation, ModelFBWorkspace):
raise TypeError("implementation must be of type ModelFBWorkspace")
if gt_implementation is not None and not isinstance(gt_implementation, ModelFBWorkspace):
raise TypeError("gt_implementation must be of type ModelFBWorkspace")
model_task_information = target_task.get_task_information()
code = implementation.all_codes
@@ -113,10 +115,12 @@ class ModelFinalEvaluator(CoSTEEREvaluator):
model_value_feedback: str,
model_code_feedback: str,
):
assert isinstance(target_task, ModelTask)
assert isinstance(implementation, ModelFBWorkspace)
if gt_implementation is not None:
assert isinstance(gt_implementation, ModelFBWorkspace)
if not isinstance(target_task, ModelTask):
raise TypeError("target_task must be of type ModelTask")
if not isinstance(implementation, ModelFBWorkspace):
raise TypeError("implementation must be of type ModelFBWorkspace")
if gt_implementation is not None and not isinstance(gt_implementation, ModelFBWorkspace):
raise TypeError("gt_implementation must be of type ModelFBWorkspace")
system_prompt = T(".prompts:evaluator_final_feedback.system").r(
scenario=(
@@ -41,7 +41,8 @@ class ModelCoSTEEREvaluator(CoSTEEREvaluator):
final_feedback="This task has failed too many times, skip implementation.",
final_decision=False,
)
assert isinstance(target_task, ModelTask)
if not isinstance(target_task, ModelTask):
raise TypeError(f"Expected ModelTask, got {type(target_task)}")
# NOTE: Use fixed input to test the model to avoid randomness
batch_size = 8
@@ -50,7 +51,8 @@ class ModelCoSTEEREvaluator(CoSTEEREvaluator):
input_value = 0.4
param_init_value = 0.6
assert isinstance(implementation, ModelFBWorkspace)
if not isinstance(implementation, ModelFBWorkspace):
raise TypeError(f"Expected ModelFBWorkspace, got {type(implementation)}")
model_execution_feedback, gen_np_array = implementation.execute(
batch_size=batch_size,
num_features=num_features,
@@ -59,7 +61,8 @@ class ModelCoSTEEREvaluator(CoSTEEREvaluator):
param_init_value=param_init_value,
)
if gt_implementation is not None:
assert isinstance(gt_implementation, ModelFBWorkspace)
if not isinstance(gt_implementation, ModelFBWorkspace):
raise TypeError(f"Expected ModelFBWorkspace, got {type(gt_implementation)}")
_, gt_np_array = gt_implementation.execute(
batch_size=batch_size,
num_features=num_features,
@@ -125,8 +125,8 @@ class AntiSymmetricConv(torch.nn.Module):
if __name__ == "__main__":
node_features = torch.load("node_features.pt")
edge_index = torch.load("edge_index.pt")
node_features = torch.load("node_features.pt", weights_only=True)
edge_index = torch.load("edge_index.pt", weights_only=True)
# Model instantiation and forward pass
model = AntiSymmetricConv(in_channels=node_features.size(-1))
+15 -1
View File
@@ -23,7 +23,7 @@ import pandas as pd
from rdagent.log import rdagent_logger as logger
logger = logging.getLogger(__name__)
_optuna_logger = logging.getLogger(__name__)
try:
import optuna
@@ -292,6 +292,7 @@ class OptunaOptimizer:
"volatility_lookback": trial.suggest_int("volatility_lookback", 5, 500, step=5),
"signal_bias": trial.suggest_float("signal_bias", -1.0, 1.0, step=0.05),
"max_hold_bars": trial.suggest_int("max_hold_bars", 5, 1000, step=5),
"max_positions": trial.suggest_int("max_positions", 1, 5, step=1),
}
# Parameters that are allowed to be negative (not clamped to 0).
@@ -308,6 +309,7 @@ class OptunaOptimizer:
"volatility_lookback": 1.0,
"signal_bias": -1.0,
"max_hold_bars": 1.0,
"max_positions": 1.0,
}
def _suggest_bounded(
@@ -357,6 +359,7 @@ class OptunaOptimizer:
"volatility_lookback": (center.get("volatility_lookback", 100), 30),
"signal_bias": (center.get("signal_bias", 0.0), 0.2),
"max_hold_bars": (center.get("max_hold_bars", 100), 50),
"max_positions": (center.get("max_positions", 1), 2),
}
return {key: self._suggest_bounded(trial, key, c, hw) for key, (c, hw) in ranges.items()}
@@ -388,6 +391,7 @@ class OptunaOptimizer:
"volatility_lookback": (center.get("volatility_lookback", 100), 10),
"signal_bias": (center.get("signal_bias", 0.0), 0.07),
"max_hold_bars": (center.get("max_hold_bars", 100), 17),
"max_positions": (center.get("max_positions", 1), 1),
}
return {key: self._suggest_bounded(trial, key, c, hw) for key, (c, hw) in ranges.items()}
@@ -467,6 +471,9 @@ class OptunaOptimizer:
# Max holding periods (in bars)
"max_hold_bars": trial.suggest_int("max_hold_bars", 10, 500, step=10),
# Max concurrent positions (1 = no pyramiding, 2-5 = scale-in)
"max_positions": trial.suggest_int("max_positions", 1, 5, step=1),
}
return params
@@ -597,6 +604,13 @@ class OptunaOptimizer:
if signal_bias != 0.0:
signal = (signal.astype(float) + signal_bias).round().astype(int).clip(-1, 1)
# Apply max_positions: scale signal by position_size_pct and cap exposure
max_positions = int(params.get("max_positions", 1))
position_size_pct = float(params.get("position_size_pct", 1.0))
# Each "position" is position_size_pct of equity; total exposure capped at max_positions × size
effective_size = min(position_size_pct * max_positions, 1.0)
signal = (signal.astype(float) * effective_size).clip(-1.0, 1.0)
# Build a synthetic close from the factor-mean so we can route
# through the same unified engine as every other backtest path.
# Backtest formulas must match the orchestrator's real-OHLCV path.
+138 -137
View File
@@ -26,22 +26,18 @@ import traceback
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any
import numpy as np
import pandas as pd
import requests
from rdagent.components.prompt_loader import load_prompt
from rdagent.components.coder.optuna_optimizer import OptunaOptimizer
from rdagent.components.prompt_loader import load_prompt
# OHLCV data path
OHLCV_PATH = Path(os.getenv(
'PREDIX_OHLCV_PATH',
'/home/nico/Predix/git_ignore_folder/factor_implementation_source_data/intraday_pv.h5'
"PREDIX_OHLCV_PATH",
"/home/nico/Predix/git_ignore_folder/factor_implementation_source_data/intraday_pv.h5",
))
from rdagent.log import rdagent_logger as logger
logger = logging.getLogger(__name__)
@@ -60,7 +56,7 @@ class StrategyOrchestrator:
min_sharpe: float = 0.3,
max_drawdown: float = -0.30,
min_win_rate: float = 0.40,
results_dir: Optional[str] = None,
results_dir: str | None = None,
use_optuna: bool = True,
optuna_trials: int = 20,
continuous_optimization: bool = True,
@@ -118,7 +114,7 @@ class StrategyOrchestrator:
logger.info(
f"StrategyOrchestrator initialized: style={self.trading_style}, "
f"top_factors={self.top_factors}, min_sharpe={self.min_sharpe}"
f"top_factors={self.top_factors}, min_sharpe={self.min_sharpe}",
)
def load_ohlcv_close(self) -> pd.Series:
@@ -126,31 +122,31 @@ class StrategyOrchestrator:
if not OHLCV_PATH.exists():
logger.warning(f"OHLCV data not found: {OHLCV_PATH}")
return None
try:
ohlcv = pd.read_hdf(str(OHLCV_PATH), key='data')
if '$close' in ohlcv.columns:
close = ohlcv['$close'].dropna()
elif 'close' in ohlcv.columns:
close = ohlcv['close'].dropna()
ohlcv = pd.read_hdf(str(OHLCV_PATH), key="data")
if "$close" in ohlcv.columns:
close = ohlcv["$close"].dropna()
elif "close" in ohlcv.columns:
close = ohlcv["close"].dropna()
else:
close = ohlcv.select_dtypes(include=[np.number]).iloc[:, 0].dropna()
# Handle MultiIndex
if isinstance(close.index, pd.MultiIndex):
try:
close = close.xs('EURUSD', level='instrument')
close = close.xs("EURUSD", level="instrument")
except KeyError:
idx = close.index.get_level_values('instrument') == 'EURUSD'
idx = close.index.get_level_values("instrument") == "EURUSD"
close = close[idx]
close.index = close.index.droplevel('instrument')
close.index = close.index.droplevel("instrument")
return close
except Exception as e:
logger.warning(f"Failed to load OHLCV data: {e}")
return None
def load_top_factors(self) -> List[Dict[str, Any]]:
def load_top_factors(self) -> list[dict[str, Any]]:
"""
Load top evaluated factors from JSON files.
@@ -177,7 +173,7 @@ class StrategyOrchestrator:
# Sort by absolute IC and take top N
factors.sort(key=lambda x: abs(x.get("ic", 0) or 0), reverse=True)
# Filter to only include factors that have parquet files
factors_with_files = []
for f in factors:
@@ -188,16 +184,16 @@ class StrategyOrchestrator:
factors_with_files.append(f)
else:
logger.debug(f"Skipping {fname} - no parquet file")
# Select diverse factor TYPES, not just top IC
# This ensures we get momentum, volatility, session, volume, etc.
type_keywords = {
"momentum": [], "trend": [], "volatility": [], "volume": [],
"session": [], "london": [], "range": [], "vwap": [],
"return": [], "ofi": [], "spread": [], "close": [],
"divergence": [], "other": []
"divergence": [], "other": [],
}
for f in factors_with_files:
name = f.get("factor_name", "").lower()
matched = False
@@ -208,18 +204,18 @@ class StrategyOrchestrator:
break
if not matched:
type_keywords["other"].append(f)
# Select best from each type (ensures diversity)
selected = []
already_names = set()
# Priority order: momentum, divergence, volatility, session, volume, etc.
priority_types = ["momentum", "divergence", "volatility", "session",
"london", "range", "vwap", "volume", "ofi", "spread",
priority_types = ["momentum", "divergence", "volatility", "session",
"london", "range", "vwap", "volume", "ofi", "spread",
"return", "trend", "close", "other"]
per_type = max(2, self.top_factors // len(priority_types))
for kw in priority_types:
for f in sorted(type_keywords[kw], key=lambda x: abs(x.get("ic", 0)), reverse=True):
if f["factor_name"] not in already_names:
@@ -227,13 +223,13 @@ class StrategyOrchestrator:
already_names.add(f["factor_name"])
if len([s for s in selected if s["factor_name"] in [x["factor_name"] for x in type_keywords[kw]]]) >= per_type:
break
# Fill remaining with highest IC not yet selected
if len(selected) < self.top_factors:
remaining = [f for f in factors_with_files if f["factor_name"] not in already_names]
remaining.sort(key=lambda x: abs(x.get("ic", 0)), reverse=True)
selected.extend(remaining[:self.top_factors - len(selected)])
# Log diversity
type_counts = {}
for f in selected:
@@ -246,12 +242,12 @@ class StrategyOrchestrator:
break
if not matched:
type_counts["other"] = type_counts.get("other", 0) + 1
logger.info(f"Selected {len(selected)} diverse factors: {type_counts}")
return selected[:self.top_factors]
def load_factor_values(self, factor_name: str) -> Optional[pd.Series]:
def load_factor_values(self, factor_name: str) -> pd.Series | None:
"""
Load factor time-series values from parquet file.
@@ -273,32 +269,32 @@ class StrategyOrchestrator:
try:
df = pd.read_parquet(str(parquet_path))
# Handle empty DataFrame
if df.empty or len(df.columns) == 0:
logger.warning(f"Empty parquet file: {parquet_path}")
return None
# Handle MultiIndex (datetime, instrument)
if isinstance(df.index, pd.MultiIndex):
# Get the factor column name (should be the only column)
factor_col = df.columns[0]
# Extract EURUSD series
try:
series = df.xs('EURUSD', level='instrument')[factor_col]
series = df.xs("EURUSD", level="instrument")[factor_col]
except KeyError:
# Try alternative extraction
df_reset = df.reset_index()
if 'instrument' in df_reset.columns:
df_eur = df_reset[df_reset['instrument'] == 'EURUSD'].set_index('datetime')
if "instrument" in df_reset.columns:
df_eur = df_reset[df_reset["instrument"] == "EURUSD"].set_index("datetime")
series = df_eur[factor_col] if factor_col in df_eur.columns else df_eur.iloc[:, -1]
else:
series = df.iloc[:, 0]
else:
series = df.iloc[:, 0]
# Ensure numeric
series = pd.to_numeric(series, errors='coerce')
series = pd.to_numeric(series, errors="coerce")
series.name = factor_name
return series
except Exception as e:
@@ -307,10 +303,10 @@ class StrategyOrchestrator:
def generate_strategy_code(
self,
factors: List[Dict[str, Any]],
factors: list[dict[str, Any]],
strategy_name: str,
max_retries: int = 3,
) -> Optional[str]:
) -> str | None:
"""
Generate strategy code using LLM from factor combinations.
@@ -341,6 +337,12 @@ class StrategyOrchestrator:
user_prompt = user_prompt.replace("{{ additional_context }}", f"Strategy name: {strategy_name}")
user_prompt = user_prompt.replace("{{ trading_style }}", self.trading_style)
user_prompt = user_prompt.replace("{{ min_sharpe }}", str(self.min_sharpe))
if "{{" in user_prompt:
unreplaced = [w for w in user_prompt.split() if "{{" in w]
logger.warning(
f"Unreplaced template variables in prompt for '{strategy_name}': {unreplaced}"
)
user_prompt = user_prompt.replace("{{ max_drawdown }}", str(self.max_drawdown))
system_prompt = self.strategy_prompt.get("system", "")
else:
@@ -371,12 +373,12 @@ class StrategyOrchestrator:
last_error = f"Attempt {attempt}: LLM returned empty or invalid code"
logger.warning(f"LLM attempt {attempt}/{max_retries} failed: {last_error}")
except Exception as e:
last_error = f"Attempt {attempt}: {str(e)}"
last_error = f"Attempt {attempt}: {e!s}"
logger.warning(f"LLM attempt {attempt}/{max_retries} failed with exception: {e}")
logger.warning(
f"LLM strategy generation failed after {max_retries} attempts. "
f"Last error: {last_error}"
f"Last error: {last_error}",
)
# Fallback: generate template code programmatically
@@ -385,10 +387,10 @@ class StrategyOrchestrator:
def _generate_with_llm(
self,
context: Dict[str, Any],
context: dict[str, Any],
attempt: int = 1,
feedback: Optional[str] = None,
) -> Optional[str]:
feedback: str | None = None,
) -> str | None:
"""
Generate strategy code using LLM with APIBackend (same as Factor Coder).
@@ -406,7 +408,6 @@ class StrategyOrchestrator:
str or None
Validated Python strategy code, or None if invalid
"""
import json as json_module
# Build user message with optional feedback
user_content = context.get("user_prompt", "")
@@ -446,14 +447,13 @@ class StrategyOrchestrator:
if self._validate_python_code(code):
logger.info(f"[DEBUG] Valid Python code extracted ({len(code)} chars)")
return code
else:
logger.warning(f"JSON 'code' field contains invalid Python (attempt {attempt}). Preview: {code[:200]}")
logger.warning(f"JSON 'code' field contains invalid Python (attempt {attempt}). Preview: {code[:200]}")
else:
logger.warning(f"JSON parsed but no valid 'code' field found (attempt {attempt}). Keys: {list(json_data.keys())}")
# === STEP 2: Fallback - Extract Python code block directly (like Factor Coder) ===
import re
code_block_match = re.search(r'```python\s*\n(.*?)\n```', content, re.DOTALL)
code_block_match = re.search(r"```python\s*\n(.*?)\n```", content, re.DOTALL)
if code_block_match:
code = code_block_match.group(1).strip()
if code and self._validate_python_code(code):
@@ -463,7 +463,7 @@ class StrategyOrchestrator:
logger.warning(f"All extraction methods failed (attempt {attempt}). Response preview: {response[:200]}")
return None
def _extract_json(self, content: str) -> Optional[Dict[str, Any]]:
def _extract_json(self, content: str) -> dict[str, Any] | None:
"""
Extract JSON object from LLM response content.
@@ -491,7 +491,7 @@ class StrategyOrchestrator:
pass
# Strategy 2: Find ```json ... ``` blocks
json_block_match = re.search(r'```json\s*\n(.*?)\n```', content, re.DOTALL)
json_block_match = re.search(r"```json\s*\n(.*?)\n```", content, re.DOTALL)
if json_block_match:
try:
return json_module.loads(json_block_match.group(1))
@@ -499,7 +499,7 @@ class StrategyOrchestrator:
pass
# Strategy 3: Find ```python ... ``` blocks (Qwen often puts JSON in python blocks)
python_block_match = re.search(r'```python\s*\n(.*?)\n```', content, re.DOTALL)
python_block_match = re.search(r"```python\s*\n(.*?)\n```", content, re.DOTALL)
if python_block_match:
block = python_block_match.group(1).strip()
if block.startswith("{") and block.endswith("}"):
@@ -519,13 +519,13 @@ class StrategyOrchestrator:
# Try to fix common JSON issues (trailing commas, unescaped newlines)
try:
# Remove trailing commas before } or ]
json_str_fixed = re.sub(r',\s*([}\]])', r'\1', json_str)
json_str_fixed = re.sub(r",\s*([}\]])", r"\1", json_str)
return json_module.loads(json_str_fixed)
except json_module.JSONDecodeError:
pass
# Strategy 5: Find ``` ... ``` blocks (any language tag)
code_block_match = re.search(r'```\w*\s*\n(.*?)\n```', content, re.DOTALL)
code_block_match = re.search(r"```\w*\s*\n(.*?)\n```", content, re.DOTALL)
if code_block_match:
block = code_block_match.group(1).strip()
if block.startswith("{") and block.endswith("}"):
@@ -536,7 +536,7 @@ class StrategyOrchestrator:
return None
def _extract_code_from_json(self, json_data: Dict[str, Any]) -> Optional[str]:
def _extract_code_from_json(self, json_data: dict[str, Any]) -> str | None:
"""
Extract Python code from parsed JSON data.
@@ -559,7 +559,7 @@ class StrategyOrchestrator:
return None
def _extract_code_from_raw(self, content: str) -> Optional[str]:
def _extract_code_from_raw(self, content: str) -> str | None:
"""
Extract Python code from raw (non-JSON) LLM response.
@@ -579,12 +579,12 @@ class StrategyOrchestrator:
code = content.strip()
# Try to find ```python blocks
python_match = re.search(r'```python\s*\n(.*?)\n```', code, re.DOTALL)
python_match = re.search(r"```python\s*\n(.*?)\n```", code, re.DOTALL)
if python_match:
code = python_match.group(1)
else:
# Try generic ``` blocks
block_match = re.search(r'```\s*\n(.*?)\n```', code, re.DOTALL)
block_match = re.search(r"```\s*\n(.*?)\n```", code, re.DOTALL)
if block_match:
code = block_match.group(1)
@@ -636,7 +636,7 @@ class StrategyOrchestrator:
# Remove non-ASCII characters (emojis, etc.)
code = code.encode("ascii", "ignore").decode("ascii").strip()
return code if code else None
return code or None
def _validate_python_code(self, code: str) -> bool:
"""
@@ -663,14 +663,14 @@ class StrategyOrchestrator:
logger.debug(f"Python syntax error: {e}")
return False
def _generate_fallback_code(self, context: Dict[str, Any]) -> str:
def _generate_fallback_code(self, context: dict[str, Any]) -> str:
"""Generate fallback strategy code programmatically."""
factor_names = context["factor_names"]
style_config = "daytrading" if context["trading_style"] == "daytrading" else "swing"
# Build factor assignment code
factor_assignments = "\n ".join(
[f'"{name}": factors["{name}"]' for name in factor_names if name != "timestamp"]
[f'"{name}": factors["{name}"]' for name in factor_names if name != "timestamp"],
)
code = f'''"""
@@ -717,8 +717,8 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
return code
def evaluate_strategy(
self, strategy_code: str, strategy_name: str, factors: List[Dict[str, Any]]
) -> Dict[str, Any]:
self, strategy_code: str, strategy_name: str, factors: list[dict[str, Any]],
) -> dict[str, Any]:
"""
Evaluate a strategy by executing its code and calculating metrics.
@@ -755,23 +755,20 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
}
# Align factor values with common index
if not factor_values:
df_factors = pd.DataFrame()
else:
# Find common index across all series
common_idx = None
for name, s in factor_values.items():
if common_idx is None:
common_idx = s.index
else:
common_idx = common_idx.intersection(s.index)
if common_idx is not None and len(common_idx) > 100:
df_factors = pd.DataFrame({
name: s.reindex(common_idx) for name, s in factor_values.items()
}).dropna()
# Find common index across all series
common_idx = None
for name, s in factor_values.items():
if common_idx is None:
common_idx = s.index
else:
df_factors = pd.DataFrame()
common_idx = common_idx.intersection(s.index)
if common_idx is not None and len(common_idx) > 100:
df_factors = pd.DataFrame({
name: s.reindex(common_idx) for name, s in factor_values.items()
}).dropna()
else:
df_factors = pd.DataFrame()
if len(df_factors) < 100:
return {
@@ -783,8 +780,8 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
# Convert all factor columns to numeric
for col in df_factors.columns:
df_factors[col] = pd.to_numeric(df_factors[col], errors='coerce')
df_factors[col] = pd.to_numeric(df_factors[col], errors="coerce")
# Forward-fill daily factors to match OHLCV 1-min index
# Many factors are daily (1 value per day), need to ffill to 1-min
# FIX 6: Track ffill ratio for data quality monitoring
@@ -799,11 +796,11 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
f"[DEBUG] {strategy_name}: data quality: "
f"original_rows={original_len}, "
f"ffill_rows={len(df_factors) - original_len}, "
f"ffill_ratio={ffill_ratio:.2%}"
f"ffill_ratio={ffill_ratio:.2%}",
)
df_factors = df_factors.dropna()
if len(df_factors) < 1000:
return {
"strategy_name": strategy_name,
@@ -811,54 +808,58 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
"reason": f"Insufficient numeric data after conversion ({len(df_factors)} rows)",
"factors_used": factor_names,
}
# close is already loaded above for ffill, reuse it
# Reindex close to match factor index
if close is not None:
close = close.reindex(df_factors.index)
# Execute strategy code with factor data and close prices
local_vars = {"factors": df_factors}
if close is not None:
local_vars["close"] = close
try:
exec(strategy_code, {"np": np, "pd": pd, "numpy": np}, local_vars)
except Exception as e:
import traceback
logger.error(
f"Strategy code execution failed for '{strategy_name}': {e}\n"
f"{traceback.format_exc()[-2000:]}"
)
return {
"strategy_name": strategy_name,
"status": "rejected",
"reason": f"Code execution error: {str(e)}",
"reason": f"Code execution error: {e!s}",
"factors_used": factor_names,
}
if "signal" not in local_vars:
signal = local_vars.get("signal")
if signal is None or (isinstance(signal, pd.Series) and signal.empty):
return {
"strategy_name": strategy_name,
"status": "rejected",
"reason": "Strategy did not produce 'signal' variable",
"reason": "Strategy did not produce valid 'signal' variable",
"factors_used": factor_names,
}
signal = local_vars["signal"]
logger.info(
f"[DEBUG] {strategy_name}: signal stats: "
f"len={len(signal)}, "
f"long={int((signal > 0).sum())}, "
f"short={int((signal < 0).sum())}, "
f"flat={int((signal == 0).sum())}, "
f"unique={signal.nunique()}"
f"unique={signal.nunique()}",
)
# Delegate all metric computation to the single source of truth.
# Same formulas as every other backtest path in the repo.
from rdagent.components.backtesting.vbt_backtest import (
backtest_signal_ftmo,
DEFAULT_TXN_COST_BPS,
backtest_signal_ftmo,
)
close = self.load_ohlcv_close()
# Reuse the already-loaded close from above; create a synthetic proxy if unavailable
if close is None:
logger.warning("OHLCV data unavailable, using factor-mean proxy")
proxy = df_factors.mean(axis=1).astype(float)
@@ -890,7 +891,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
logger.info(
f"[DEBUG] {strategy_name}: bt stats: "
f"sharpe={sharpe:.4f} dd={max_dd:.4f} wr={win_rate:.4f} "
f"trades={num_real_trades} total_ret={bt['total_return']:.4%}"
f"trades={num_real_trades} total_ret={bt['total_return']:.4%}",
)
metrics = {
@@ -920,7 +921,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
f"[DEBUG] {strategy_name}: rejection breakdown: "
f"sharpe={sharpe:.4f} (need>={self.min_sharpe}), "
f"dd={max_dd:.4f} (need>={self.max_drawdown}), "
f"wr={win_rate:.4f} (need>={self.min_win_rate})"
f"wr={win_rate:.4f} (need>={self.min_win_rate})",
)
metrics["reason"] = self._get_rejection_reason(sharpe, max_dd, win_rate)
@@ -932,7 +933,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
return {
"strategy_name": strategy_name,
"status": "rejected",
"reason": f"Evaluation error: {str(e)}",
"reason": f"Evaluation error: {e!s}",
"factors_used": [],
}
@@ -951,7 +952,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
reasons.append(f"Win Rate {win_rate:.2%} < {self.min_win_rate:.2%}")
return "; ".join(reasons) if reasons else "Unknown"
def _generate_strategy_name(self, factors: List[Dict[str, Any]], idx: int) -> str:
def _generate_strategy_name(self, factors: list[dict[str, Any]], idx: int) -> str:
"""Generate a strategy name from its factors."""
# Extract key words from factor names
words = []
@@ -962,7 +963,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
for p in parts:
# Extract capitalized words
cap_words = [w for w in p.split() if w[0:1].isupper()]
words.extend(cap_words if cap_words else [p])
words.extend(cap_words or [p])
# Take up to 3 unique words
unique_words = list(dict.fromkeys(words))[:3]
@@ -975,7 +976,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
count: int = 10,
workers: int = 2, # Reduced from 4 to 2 to avoid LLM server overload
progress_callback=None,
) -> List[Dict[str, Any]]:
) -> list[dict[str, Any]]:
"""
Generate and evaluate trading strategies.
@@ -1028,7 +1029,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
logger.info(
f"Strategy ACCEPTED: {result['strategy_name']} | "
f"Sharpe={result['sharpe_ratio']:.2f} | "
f"DD={result['max_drawdown']:.2%}"
f"DD={result['max_drawdown']:.2%}",
)
else:
# Also save rejected strategies for debugging
@@ -1036,7 +1037,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
logger.warning(
f"Strategy REJECTED: {result['strategy_name']} - {result.get('reason', 'unknown')} | "
f"Sharpe={result.get('sharpe_ratio', 'N/A')} | "
f"DD={result.get('max_drawdown', 'N/A')}"
f"DD={result.get('max_drawdown', 'N/A')}",
)
if progress_callback:
@@ -1052,12 +1053,12 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
logger.info(
f"Strategy generation complete: {strategies_accepted}/{strategies_generated} accepted "
f"({strategies_accepted/max(strategies_generated,1)*100:.1f}%)"
f"({strategies_accepted/max(strategies_generated,1)*100:.1f}%)",
)
return results
def _generate_strategy_configs(self, factors: List[Dict], count: int) -> List[List[Dict]]:
def _generate_strategy_configs(self, factors: list[dict], count: int) -> list[list[dict]]:
"""
Generate strategy configurations from factor combinations.
@@ -1085,7 +1086,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
np.random.shuffle(configs)
return configs[: count * 2] # Generate extras
def _generate_and_evaluate_single(self, idx: int, factors: List[Dict]) -> Dict[str, Any]:
def _generate_and_evaluate_single(self, idx: int, factors: list[dict]) -> dict[str, Any]:
"""Generate and evaluate a single strategy."""
strategy_name = self._generate_strategy_name(factors, idx + 1)
@@ -1108,7 +1109,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
# by finding optimal entry/exit thresholds, signal smoothing, etc.
if self.use_optuna:
initial_status = result.get("status", "rejected")
initial_sharpe = result.get("sharpe_ratio", float('-inf'))
initial_sharpe = result.get("sharpe_ratio", float("-inf"))
logger.info(f"Running Optuna optimization for {strategy_name} (initial: {initial_status}, Sharpe={initial_sharpe:.4f})...")
optimizer = OptunaOptimizer(n_trials=self.optuna_trials)
@@ -1117,7 +1118,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
if factor_values is not None:
optimized = optimizer.optimize_strategy(result, factor_values)
optimized_sharpe = optimized.get("sharpe_ratio", float('-inf'))
optimized_sharpe = optimized.get("sharpe_ratio", float("-inf"))
optimized_status = optimized.get("status", "rejected")
best_params = optimized.get("best_params", {})
@@ -1126,14 +1127,14 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
improvement = optimized_sharpe - initial_sharpe
logger.info(
f"Optuna {'RESCUED' if optimized_status == 'accepted' and initial_status == 'rejected' else 'improved'} "
f"{strategy_name}: Sharpe {initial_sharpe:.4f}{optimized_sharpe:.4f} (+{improvement:.4f})"
f"{strategy_name}: Sharpe {initial_sharpe:.4f}{optimized_sharpe:.4f} (+{improvement:.4f})",
)
# Re-evaluate with best parameters to get comparable metrics
if best_params:
patched_code = self._patch_strategy_code(code, best_params)
re_eval = self._evaluate_with_patched_code(patched_code, strategy_name, factors)
if re_eval.get("sharpe_ratio", float('-inf')) > initial_sharpe:
if re_eval.get("sharpe_ratio", float("-inf")) > initial_sharpe:
result.update(re_eval)
result["code"] = patched_code
result["best_params"] = best_params
@@ -1142,7 +1143,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
result.pop("reason", None)
logger.info(
f"Re-evaluated {strategy_name} with best params: "
f"Sharpe {initial_sharpe:.4f}{re_eval.get('sharpe_ratio', 0):.4f}"
f"Sharpe {initial_sharpe:.4f}{re_eval.get('sharpe_ratio', 0):.4f}",
)
else:
result.update(optimized)
@@ -1162,7 +1163,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
return result
def _prepare_factor_values(self, factors: List[Dict]) -> Optional[pd.DataFrame]:
def _prepare_factor_values(self, factors: list[dict]) -> pd.DataFrame | None:
"""Prepare factor values DataFrame for Optuna optimization."""
factor_values = {}
for f in factors:
@@ -1181,7 +1182,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
return df.dropna()
return None
def _patch_strategy_code(self, code: str, params: Dict[str, Any]) -> str:
def _patch_strategy_code(self, code: str, params: dict[str, Any]) -> str:
"""Patch strategy code with Optuna's best parameters."""
import re
patched = code
@@ -1192,26 +1193,26 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
signal_window = params.get("signal_window", 3)
param_patterns = [
(r'entry_thresh\s*=\s*[\d.]+', f'entry_thresh = {entry_thresh}'),
(r'exit_thresh\s*=\s*[\d.]+', f'exit_thresh = {exit_thresh}'),
(r'window\s*=\s*\d+', f'window = {zscore_window}'),
(r'signal_window\s*=\s*\d+', f'signal_window = {signal_window}'),
(r"entry_thresh\s*=\s*[\d.]+", f"entry_thresh = {entry_thresh}"),
(r"exit_thresh\s*=\s*[\d.]+", f"exit_thresh = {exit_thresh}"),
(r"window\s*=\s*\d+", f"window = {zscore_window}"),
(r"signal_window\s*=\s*\d+", f"signal_window = {signal_window}"),
]
for pattern, replacement in param_patterns:
patched = re.sub(pattern, replacement, patched)
# Patch .rolling(N) calls for common window sizes
rolling_pattern = r'\.rolling\((\d+)\)'
rolling_pattern = r"\.rolling\((\d+)\)"
def replace_rolling(match):
val = int(match.group(1))
if val in (20, 30, 50, 100, 200):
return f'.rolling({zscore_window})'
return f".rolling({zscore_window})"
return match.group(0)
patched = re.sub(rolling_pattern, replace_rolling, patched)
return patched
def _evaluate_with_patched_code(self, patched_code: str, strategy_name: str, factors: List[Dict]) -> Dict[str, Any]:
def _evaluate_with_patched_code(self, patched_code: str, strategy_name: str, factors: list[dict]) -> dict[str, Any]:
"""Re-evaluate strategy with patched parameters using full OHLCV backtest."""
try:
factor_names = [f["factor_name"] for f in factors if f["factor_name"] != "timestamp"]
@@ -1222,7 +1223,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
factor_values[fname] = series
if not factor_values:
return {"sharpe_ratio": float('-inf'), "status": "rejected"}
return {"sharpe_ratio": float("-inf"), "status": "rejected"}
common_idx = None
for name, s in factor_values.items():
@@ -1232,14 +1233,14 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
common_idx = common_idx.intersection(s.index)
if common_idx is None or len(common_idx) < 100:
return {"sharpe_ratio": float('-inf'), "status": "rejected"}
return {"sharpe_ratio": float("-inf"), "status": "rejected"}
df_factors = pd.DataFrame({
name: s.reindex(common_idx) for name, s in factor_values.items()
}).dropna()
for col in df_factors.columns:
df_factors[col] = pd.to_numeric(df_factors[col], errors='coerce')
df_factors[col] = pd.to_numeric(df_factors[col], errors="coerce")
close = self.load_ohlcv_close()
if close is not None:
@@ -1247,7 +1248,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
df_factors = df_factors.dropna()
if len(df_factors) < 1000:
return {"sharpe_ratio": float('-inf'), "status": "rejected"}
return {"sharpe_ratio": float("-inf"), "status": "rejected"}
if close is not None:
close = close.reindex(df_factors.index)
@@ -1256,21 +1257,21 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
try:
exec(patched_code, {"np": np, "pd": pd, "numpy": np}, local_vars)
except Exception:
return {"sharpe_ratio": float('-inf'), "status": "rejected"}
return {"sharpe_ratio": float("-inf"), "status": "rejected"}
if "signal" not in local_vars:
return {"sharpe_ratio": float('-inf'), "status": "rejected"}
return {"sharpe_ratio": float("-inf"), "status": "rejected"}
signal = local_vars["signal"]
from rdagent.components.backtesting.vbt_backtest import (
backtest_signal_ftmo,
DEFAULT_TXN_COST_BPS,
backtest_signal_ftmo,
)
close_for_bt = close.reindex(signal.index).ffill() if close is not None else None
if close_for_bt is None:
return {"sharpe_ratio": float('-inf'), "status": "rejected"}
return {"sharpe_ratio": float("-inf"), "status": "rejected"}
bt = backtest_signal_ftmo(
close=close_for_bt,
@@ -1278,7 +1279,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
txn_cost_bps=float(os.getenv("TXN_COST_BPS", DEFAULT_TXN_COST_BPS)),
)
if bt.get("status") != "success":
return {"sharpe_ratio": float('-inf'), "status": "rejected"}
return {"sharpe_ratio": float("-inf"), "status": "rejected"}
sharpe = bt["sharpe"]
max_dd = bt["max_drawdown"]
@@ -1307,9 +1308,9 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
except Exception as e:
logger.debug(f"Re-evaluation failed for {strategy_name}: {e}")
return {"sharpe_ratio": float('-inf'), "status": "rejected"}
return {"sharpe_ratio": float("-inf"), "status": "rejected"}
def _save_strategy(self, result: Dict[str, Any]) -> None:
def _save_strategy(self, result: dict[str, Any]) -> None:
"""Save accepted strategy to JSON file."""
timestamp = int(time.time())
safe_name = result["strategy_name"].replace("/", "_").replace(" ", "_")[:60]
@@ -1325,7 +1326,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
logger.info(f"Saved strategy to {filepath}")
def get_strategy_summary(self, results: List[Dict[str, Any]]) -> Dict[str, Any]:
def get_strategy_summary(self, results: list[dict[str, Any]]) -> dict[str, Any]:
"""
Generate summary statistics from strategy generation results.
@@ -85,13 +85,16 @@ def load_and_process_one_pdf_by_azure_document_intelligence(
def load_and_process_pdfs_by_azure_document_intelligence(path: Path) -> dict[str, str]:
assert RD_AGENT_SETTINGS.azure_document_intelligence_key is not None
assert RD_AGENT_SETTINGS.azure_document_intelligence_endpoint is not None
if RD_AGENT_SETTINGS.azure_document_intelligence_key is None:
raise AssertionError("azure_document_intelligence_key must be set")
if RD_AGENT_SETTINGS.azure_document_intelligence_endpoint is None:
raise AssertionError("azure_document_intelligence_endpoint must be set")
content_dict = {}
ab_path = path.resolve()
if ab_path.is_file():
assert ".pdf" in ab_path.suffixes, "The file must be a PDF file."
if ".pdf" not in ab_path.suffixes:
raise ValueError("The file must be a PDF file.")
proc = load_and_process_one_pdf_by_azure_document_intelligence
content_dict[str(ab_path)] = proc(
ab_path,
@@ -24,7 +24,8 @@ class UndirectedNode(Node):
super().__init__(content, label, embedding)
self.neighbors: set[UndirectedNode] = set()
self.appendix = appendix # appendix stores any additional information
assert isinstance(content, str), "content must be a string"
if not isinstance(content, str):
raise TypeError("content must be a string")
def add_neighbor(self, node: UndirectedNode) -> None:
self.neighbors.add(node)
@@ -96,7 +97,8 @@ class Graph(KnowledgeBase):
APIBackend().create_embedding(input_content=contents[i : i + size]),
)
assert len(nodes) == len(embeddings), "nodes' length must equals embeddings' length"
if len(nodes) != len(embeddings):
raise ValueError("nodes' length must equal embeddings' length")
for node, embedding in zip(nodes, embeddings):
node.embedding = embedding
return nodes
@@ -252,7 +254,8 @@ class UndirectedGraph(Graph):
"""
min_nodes_count = 2
assert len(nodes) >= min_nodes_count, "nodes length must >=2"
if len(nodes) < min_nodes_count:
raise ValueError("nodes length must >=2")
intersection = None
for node in nodes:
+2 -1
View File
@@ -87,7 +87,8 @@ class ModelWsLoader(WsLoader[ModelTask, ModelFBWorkspace]):
self.path = Path(path)
def load(self, task: ModelTask) -> ModelFBWorkspace:
assert task.name is not None
if task.name is None:
raise AssertionError("task.name should not be None")
mti = ModelFBWorkspace(task)
mti.prepare()
with open(self.path / f"{task.name}.py", "r") as f:
+28 -3
View File
@@ -4,6 +4,7 @@ import functools
import importlib
import json
import multiprocessing as mp
import os
import pickle
import random
from collections.abc import Callable
@@ -82,10 +83,24 @@ def import_class(class_path: str) -> Any:
Returns
-------
class of `class_path`
Raises
------
ImportError
If module or class cannot be found.
"""
module_path, class_name = class_path.rsplit(".", 1)
module = importlib.import_module(module_path)
return getattr(module, class_name)
try:
module_path, class_name = class_path.rsplit(".", 1)
except ValueError:
raise ImportError(f"Invalid class path: {class_path!r}")
try:
module = importlib.import_module(module_path)
except ModuleNotFoundError as e:
raise ImportError(f"Module not found: {module_path!r}") from e
try:
return getattr(module, class_name)
except AttributeError as e:
raise ImportError(f"Class not found: {class_name!r} in {module_path!r}") from e
class CacheSeedGen:
@@ -208,3 +223,13 @@ def cache_with_pickle(hash_func: Callable, post_process_func: Callable | None =
return cache_wrapper
return cache_decorator
def safe_resolve_path(user_path: Path, safe_root: Path | None = None) -> Path:
if safe_root is not None:
root_real = os.path.realpath(str(safe_root.expanduser()))
path_real = os.path.realpath(str(user_path.expanduser())) # nosec B614 — validated against safe_root below
if not (path_real == root_real or path_real.startswith(root_real + os.sep)):
raise ValueError(f"Path {user_path} resolves to {path_real}, outside allowed root {safe_root}")
return Path(path_real)
return user_path.expanduser().resolve()
+22 -15
View File
@@ -27,6 +27,7 @@ Usage:
from __future__ import annotations
import json as _json
import logging
import sys
import threading
from contextlib import contextmanager
@@ -36,21 +37,24 @@ from typing import Any
from loguru import logger as _root
# ── paths ─────────────────────────────────────────────────────────────────────
# ── paths ─────────────────────────────────────────────────────────────────────────────────
LOGS_ROOT: Path = Path(__file__).parent.parent.parent / "logs"
# ── format ────────────────────────────────────────────────────────────────────
# ── format ────────────────────────────────────────────────────────────────────────────────
_FILE_FMT = (
"{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {extra[cmd]: <18} | {message}"
)
# ── internal state ─────────────────────────────────────────────────────────────
# ── internal state ─────────────────────────────────────────────────────────────────────────────
_registered: set[str] = set() # command keys that already have a file sink
_all_added: bool = False # whether the combined all.log sink is active
_llm_log_lock = threading.Lock() # guards concurrent writes to llm_calls.jsonl
# Maximum characters stored per field in llm_calls.jsonl to prevent GB-scale files.
_LLM_CALL_MAX_CHARS = 500
# ── helpers ───────────────────────────────────────────────────────────────────
# ── helpers ────────────────────────────────────────────────────────────────────────────────
def _today_dir() -> Path:
d = LOGS_ROOT / datetime.now().strftime("%Y-%m-%d")
@@ -79,7 +83,7 @@ def _banner(log, title: str, meta: dict[str, Any]) -> None:
log.info(sep)
# ── public API ────────────────────────────────────────────────────────────────
# ── public API ──────────────────────────────────────────────────────────────────────────────
def log_llm_call(
system: str | None,
@@ -88,16 +92,19 @@ def log_llm_call(
start_time: Any = None,
end_time: Any = None,
) -> None:
"""Append one complete LLM call to logs/YYYY-MM-DD/llm_calls.jsonl.
"""Append one LLM call summary to logs/YYYY-MM-DD/llm_calls.jsonl.
Prompt/response content is capped at _LLM_CALL_MAX_CHARS to prevent
GB-scale log files from long-running loops.
Each line is a self-contained JSON object so the file is grep/jq-friendly:
jq 'select(.duration_ms > 5000)' logs/2026-04-17/llm_calls.jsonl
"""
entry: dict[str, Any] = {
"ts": datetime.now().isoformat(timespec="milliseconds"),
"system": system or "",
"user": user,
"response": response,
"system": (system or "")[:_LLM_CALL_MAX_CHARS],
"user": user[:_LLM_CALL_MAX_CHARS],
"response": response[:_LLM_CALL_MAX_CHARS],
}
if start_time is not None and end_time is not None:
try:
@@ -130,13 +137,13 @@ def setup(command: str, **context: Any):
key = command.lower()
if key not in _registered:
# Per-command rotating file
_root.add(
str(log_dir / f"{key}.log"),
format=_FILE_FMT,
filter=lambda r, k=key: r["extra"].get("cmd", "").lower() == k,
rotation="00:00", # new file at midnight
retention="30 days",
rotation="50 MB",
compression="gz",
retention="7 days",
encoding="utf-8",
enqueue=True,
backtrace=False,
@@ -145,13 +152,13 @@ def setup(command: str, **context: Any):
_registered.add(key)
if not _all_added:
# Combined log — all commands
_root.add(
str(log_dir / "all.log"),
format=_FILE_FMT,
filter=lambda r: "cmd" in r["extra"],
rotation="00:00",
retention="60 days",
rotation="100 MB",
compression="gz",
retention="7 days",
encoding="utf-8",
enqueue=True,
backtrace=False,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+306
View File
@@ -0,0 +1,306 @@
import argparse
import json
import pickle # nosec
import re
import time
from pathlib import Path
import streamlit as st
from streamlit import session_state
from rdagent.log.ui.conf import UI_SETTING
from rdagent.log.utils import extract_evoid, extract_loopid_func_name
st.set_page_config(layout="wide", page_title="debug_llm", page_icon="🎓", initial_sidebar_state="expanded")
# 获取 log_path 参数
parser = argparse.ArgumentParser(description="RD-Agent Streamlit App")
parser.add_argument("--log_dir", type=str, help="Path to the log directory")
args = parser.parse_args()
def get_folders_sorted(log_path):
"""缓存并返回排序后的文件夹列表,并加入进度打印"""
with st.spinner("正在加载文件夹列表..."):
folders = sorted(
(folder for folder in log_path.iterdir() if folder.is_dir() and list(folder.iterdir())),
key=lambda folder: folder.stat().st_mtime,
reverse=True,
)
st.write(f"找到 {len(folders)} 个文件夹")
return [folder.name for folder in folders]
if UI_SETTING.enable_cache:
get_folders_sorted = st.cache_data(get_folders_sorted)
# 设置主日志路径
main_log_path = Path(args.log_dir) if args.log_dir else Path("./log")
if not main_log_path.exists():
st.error(f"Log dir {main_log_path} does not exist!")
st.stop()
if "data" not in session_state:
session_state.data = []
if "log_path" not in session_state:
session_state.log_path = None
tlist = []
def load_data():
"""加载数据到 session_state 并显示进度"""
log_file = main_log_path / session_state.log_path / "debug_llm.pkl"
try:
with st.spinner(f"正在加载数据文件 {log_file}..."):
start_time = time.time()
with open(log_file, "rb") as f:
session_state.data = pickle.load(f, encoding="utf-8") # nosec
st.success(f"数据加载完成!耗时 {time.time() - start_time:.2f}")
st.session_state["current_loop"] = 1
except Exception as e:
session_state.data = [{"error": str(e)}]
st.error(f"加载数据失败: {e}")
# UI - Sidebar
with st.sidebar:
st.markdown(":blue[**Log Path**]")
manually = st.toggle("Manual Input")
if manually:
st.text_input("log path", key="log_path", label_visibility="collapsed")
else:
folders = get_folders_sorted(main_log_path)
st.selectbox(f"**Select from {main_log_path.absolute()}**", folders, key="log_path") # nosec B608 — not SQL, Bandit false positive on "Select" in UI label
if st.button("Refresh Data"):
load_data()
st.rerun()
# Helper functions
def show_text(text, lang=None):
"""显示文本代码块"""
if lang:
st.code(text, language=lang, wrap_lines=True)
elif "\n" in text:
st.code(text, language="python", wrap_lines=True)
else:
st.code(text, language="html", wrap_lines=True)
def highlight_prompts_uri(uri):
"""高亮 URI 的格式"""
parts = uri.split(":")
return f"**{parts[0]}:**:green[**{parts[1]}**]"
# Display Data
progress_text = st.empty()
progress_bar = st.progress(0)
# 每页展示一个 Loop
LOOPS_PER_PAGE = 1
# 获取所有的 Loop ID
loop_groups = {}
for i, d in enumerate(session_state.data):
tag = d["tag"]
loop_id, _ = extract_loopid_func_name(tag)
if loop_id:
if loop_id not in loop_groups:
loop_groups[loop_id] = []
loop_groups[loop_id].append(d)
# 按 Loop ID 排序
sorted_loop_ids = sorted(loop_groups.keys(), key=int) # 假设 Loop ID 是数字
total_loops = len(sorted_loop_ids)
total_pages = total_loops # 每页展示一个 Loop
# simple display
# FIXME: Delete this simple UI if trace have tag(evo_id & loop_id)
# with st.sidebar:
# start = int(st.text_input("start", 0))
# end = int(st.text_input("end", 100))
# for m in session_state.data[start:end]:
# if "tpl" in m["tag"]:
# obj = m["obj"]
# uri = obj["uri"]
# tpl = obj["template"]
# cxt = obj["context"]
# rd = obj["rendered"]
# with st.expander(highlight_prompts_uri(uri), expanded=False, icon="⚙️"):
# t1, t2, t3 = st.tabs([":green[**Rendered**]", ":blue[**Template**]", ":orange[**Context**]"])
# with t1:
# show_text(rd)
# with t2:
# show_text(tpl, lang="django")
# with t3:
# st.json(cxt)
# if "llm" in m["tag"]:
# obj = m["obj"]
# system = obj.get("system", None)
# user = obj["user"]
# resp = obj["resp"]
# with st.expander(f"**LLM**", expanded=False, icon="🤖"):
# t1, t2, t3 = st.tabs([":green[**Response**]", ":blue[**User**]", ":orange[**System**]"])
# with t1:
# try:
# rdict = json.loads(resp)
# if "code" in rdict:
# code = rdict["code"]
# st.markdown(":red[**Code in response dict:**]")
# st.code(code, language="python", wrap_lines=True, line_numbers=True)
# rdict.pop("code")
# elif "spec" in rdict:
# spec = rdict["spec"]
# st.markdown(":red[**Spec in response dict:**]")
# st.markdown(spec)
# rdict.pop("spec")
# else:
# # show model codes
# showed_keys = []
# for k, v in rdict.items():
# if k.startswith("model_") and k.endswith(".py"):
# st.markdown(f":red[**{k}**]")
# st.code(v, language="python", wrap_lines=True, line_numbers=True)
# showed_keys.append(k)
# for k in showed_keys:
# rdict.pop(k)
# st.write(":red[**Other parts (except for the code or spec) in response dict:**]")
# st.json(rdict)
# except:
# st.json(resp)
# with t2:
# show_text(user)
# with t3:
# show_text(system or "No system prompt available")
if total_pages:
# 初始化 current_loop
if "current_loop" not in st.session_state:
st.session_state["current_loop"] = 1
# Loop 导航按钮
col1, col2, col3, col4, col5 = st.sidebar.columns([1.2, 1, 2, 1, 1.2])
with col1:
if st.button("|<"): # 首页
st.session_state["current_loop"] = 1
with col2:
if st.button("<") and st.session_state["current_loop"] > 1: # 上一页
st.session_state["current_loop"] -= 1
with col3:
# 下拉列表显示所有 Loop
st.session_state["current_loop"] = st.selectbox(
"选择 Loop",
options=list(range(1, total_loops + 1)),
index=st.session_state["current_loop"] - 1, # 默认选中当前 Loop
label_visibility="collapsed", # 隐藏标签
)
with col4:
if st.button("\>") and st.session_state["current_loop"] < total_loops: # 下一页
st.session_state["current_loop"] += 1
with col5:
if st.button("\>|"): # 最后一页
st.session_state["current_loop"] = total_loops
# 获取当前 Loop
current_loop = st.session_state["current_loop"]
# 渲染当前 Loop 数据
loop_id = sorted_loop_ids[current_loop - 1]
progress_text = st.empty()
progress_text.text(f"正在处理 Loop {loop_id}...")
progress_bar.progress(current_loop / total_loops, text=f"Loop :green[**{current_loop}**] / {total_loops}")
# 渲染 Loop Header
loop_anchor = f"Loop_{loop_id}"
if loop_anchor not in tlist:
tlist.append(loop_anchor)
st.header(loop_anchor, anchor=loop_anchor, divider="blue")
# 渲染当前 Loop 的所有数据
loop_data = loop_groups[loop_id]
for d in loop_data:
tag = d["tag"]
obj = d["obj"]
_, func_name = extract_loopid_func_name(tag)
evo_id = extract_evoid(tag)
func_anchor = f"loop_{loop_id}.{func_name}"
if func_anchor not in tlist:
tlist.append(func_anchor)
st.header(f"in *{func_name}*", anchor=func_anchor, divider="green")
evo_anchor = f"loop_{loop_id}.evo_step_{evo_id}"
if evo_id and evo_anchor not in tlist:
tlist.append(evo_anchor)
st.subheader(f"evo_step_{evo_id}", anchor=evo_anchor, divider="orange")
# 根据 tag 渲染内容
if "debug_exp_gen" in tag:
with st.expander(
f"Exp in :violet[**{obj.experiment_workspace.workspace_path}**]", expanded=False, icon="🧩"
):
st.write(obj)
elif "debug_tpl" in tag:
uri = obj["uri"]
tpl = obj["template"]
cxt = obj["context"]
rd = obj["rendered"]
with st.expander(highlight_prompts_uri(uri), expanded=False, icon="⚙️"):
t1, t2, t3 = st.tabs([":green[**Rendered**]", ":blue[**Template**]", ":orange[**Context**]"])
with t1:
show_text(rd)
with t2:
show_text(tpl, lang="django")
with t3:
st.json(cxt)
elif "debug_llm" in tag:
system = obj.get("system", None)
user = obj["user"]
resp = obj["resp"]
with st.expander(f"**LLM**", expanded=False, icon="🤖"):
t1, t2, t3 = st.tabs([":green[**Response**]", ":blue[**User**]", ":orange[**System**]"])
with t1:
try:
rdict = json.loads(resp)
if "code" in rdict:
code = rdict["code"]
st.markdown(":red[**Code in response dict:**]")
st.code(code, language="python", wrap_lines=True, line_numbers=True)
rdict.pop("code")
elif "spec" in rdict:
spec = rdict["spec"]
st.markdown(":red[**Spec in response dict:**]")
st.markdown(spec)
rdict.pop("spec")
else:
# show model codes
showed_keys = []
for k, v in rdict.items():
if k.startswith("model_") and k.endswith(".py"):
st.markdown(f":red[**{k}**]")
st.code(v, language="python", wrap_lines=True, line_numbers=True)
showed_keys.append(k)
for k in showed_keys:
rdict.pop(k)
st.write(":red[**Other parts (except for the code or spec) in response dict:**]")
st.json(rdict)
except:
st.json(resp)
with t2:
show_text(user)
with t3:
show_text(system or "No system prompt available")
progress_text.text("当前 Loop 数据处理完成!")
# Sidebar TOC
with st.sidebar:
toc = "\n".join([f"- [{t}](#{t})" if t.startswith("L") else f" - [{t.split('.')[1]}](#{t})" for t in tlist])
st.markdown(toc, unsafe_allow_html=True)
+9 -2
View File
@@ -541,7 +541,8 @@ class APIBackend(ABC):
**kwargs,
) -> str | list[list[float]]:
"""This function to share operation between embedding and chat completion"""
assert not (chat_completion and embedding), "chat_completion and embedding cannot be True at the same time"
if chat_completion and embedding:
raise ValueError("chat_completion and embedding cannot be True at the same time")
max_retry = LLM_SETTINGS.max_retry if LLM_SETTINGS.max_retry is not None else max_retry
timeout_count = 0
violation_count = 0
@@ -720,7 +721,13 @@ class APIBackend(ABC):
if finish_reason is None or finish_reason != "length":
break # we get a full response now.
new_messages.append({"role": "assistant", "content": response})
# Merge into the previous assistant message if there already is one at the end.
# Appending a second consecutive assistant message causes llama-server to return 400
# ("Cannot have 2 or more assistant messages at the end of the list").
if new_messages and new_messages[-1]["role"] == "assistant":
new_messages[-1]["content"] += response
else:
new_messages.append({"role": "assistant", "content": response})
else:
raise RuntimeError(f"Failed to continue the conversation after {try_n} retries.")
+6 -4
View File
@@ -36,16 +36,18 @@ def get_agent_model() -> OpenAIChatModel:
"""
backend = APIBackend()
assert isinstance(backend, LiteLLMAPIBackend), "Only LiteLLMAPIBackend is supported"
if not isinstance(backend, LiteLLMAPIBackend):
raise TypeError("Only LiteLLMAPIBackend is supported")
compl_kwargs = backend.get_complete_kwargs()
selected_model = compl_kwargs["model"]
_, custom_llm_provider, _, _ = get_llm_provider(selected_model)
assert (
custom_llm_provider in PROVIDER_TO_ENV_MAP
), f"Provider {custom_llm_provider} not supported. Please add it into `PROVIDER_TO_ENV_MAP`"
if custom_llm_provider not in PROVIDER_TO_ENV_MAP:
raise ValueError(
f"Provider {custom_llm_provider} not supported. Please add it into `PROVIDER_TO_ENV_MAP`"
)
prefix = PROVIDER_TO_ENV_MAP[custom_llm_provider]
api_key = os.getenv(f"{prefix}_API_KEY", None)
api_base = os.getenv(f"{prefix}_API_BASE", None)
+2 -1
View File
@@ -268,7 +268,8 @@ class JsonReducer(DataReducer):
parent[key] = sampled # type: ignore # parent 是 listkey 是 index, list.__setitem__(key, sampled)
self.sampled_files.extend([self.extract_filename(i) for i in sampled])
break
assert len(self.sampled_files) > 0
if len(self.sampled_files) <= 0:
raise AssertionError("sampled_files must contain at least one file")
return data
def _find_all_lists(
@@ -7,8 +7,10 @@ from sklearn.metrics import roc_auc_score
def prepare_for_auroc_metric(submission: pd.DataFrame, answers: pd.DataFrame, id_col: str, target_col: str) -> dict:
# Answers checks
assert id_col in answers.columns, f"answers dataframe should have an {id_col} column"
assert target_col in answers.columns, f"answers dataframe should have a {target_col} column"
if id_col not in answers.columns:
raise InvalidSubmissionError(f"answers dataframe should have an {id_col} column")
if target_col not in answers.columns:
raise InvalidSubmissionError(f"answers dataframe should have a {target_col} column")
# Submission checks
if id_col not in submission.columns:
@@ -1,7 +1,8 @@
from pathlib import Path
# Check if our submission file exists
assert Path("submission.csv").exists(), "Error: submission.csv not found"
if not Path("submission.csv").exists():
raise FileNotFoundError("Error: submission.csv not found")
submission_lines = Path("submission.csv").read_text().splitlines()
test_lines = Path("submission_test.csv").read_text().splitlines()
@@ -22,7 +22,8 @@ def prepare_for_metric(submission: pd.DataFrame, answers: pd.DataFrame) -> dict:
if "price" not in submission.columns:
raise InvalidSubmissionError("Submission DataFrame must contain 'price' columns.")
assert "price" in answers.columns, "Answers DataFrame must contain 'price' columns."
if "price" not in answers.columns:
raise InvalidSubmissionError("Answers DataFrame must contain 'price' columns.")
if len(submission) != len(answers):
raise InvalidSubmissionError("Submission must be the same length as the answers.")
@@ -1,7 +1,8 @@
from pathlib import Path
# Check if our submission file exists
assert Path("submission.csv").exists(), "Error: submission.csv not found"
if not Path("submission.csv").exists():
raise FileNotFoundError("Error: submission.csv not found")
submission_lines = Path("submission.csv").read_text().splitlines() # 自动生成的
test_lines = Path("submission_test.csv").read_text().splitlines() # test.csv
@@ -56,14 +56,17 @@ sparse.save_npz(public / "test" / "X.npz", X_test)
sparse.save_npz(public / "train" / "X.npz", X_train)
df_train.to_csv(public / "train" / "ARF_12h.csv", index=False)
assert (
X_train.shape[0] == df_train.shape[0]
), f"Mismatch: X_train rows ({X_train.shape[0]}) != df_train rows ({df_train.shape[0]})"
assert (
X_test.shape[0] == df_test.shape[0]
), f"Mismatch: X_test rows ({X_test.shape[0]}) != df_test rows ({df_test.shape[0]})"
assert df_test.shape[1] == 2, "Public test set should have 2 columns"
assert df_train.shape[1] == 3, "Public train set should have 3 columns"
assert len(df_train) + len(df_test) == len(
df_label
), "Length of new_train and new_test should equal length of old_train"
if X_train.shape[0] != df_train.shape[0]:
raise ValueError(
f"Mismatch: X_train rows ({X_train.shape[0]}) != df_train rows ({df_train.shape[0]})"
)
if X_test.shape[0] != df_test.shape[0]:
raise ValueError(
f"Mismatch: X_test rows ({X_test.shape[0]}) != df_test rows ({df_test.shape[0]})"
)
if df_test.shape[1] != 2:
raise ValueError("Public test set should have 2 columns")
if df_train.shape[1] != 3:
raise ValueError("Public train set should have 3 columns")
if len(df_train) + len(df_test) != len(df_label):
raise ValueError("Length of new_train and new_test should equal length of old_train")
@@ -25,11 +25,12 @@ def prepare(raw: Path, public: Path, private: Path):
new_test.to_csv(public / "test.csv", index=False)
# Checks
assert new_test.shape[1] == 12, "Public test set should have 12 columns"
assert new_train.shape[1] == 13, "Public train set should have 13 columns"
assert len(new_train) + len(new_test) == len(
old_train
), "Length of new_train and new_test should equal length of old_train"
if new_test.shape[1] != 12:
raise AssertionError("Public test set should have 12 columns")
if new_train.shape[1] != 13:
raise AssertionError("Public train set should have 13 columns")
if len(new_train) + len(new_test) != len(old_train):
raise AssertionError("Length of new_train and new_test should equal length of old_train")
if __name__ == "__main__":
+2 -1
View File
@@ -320,7 +320,8 @@ class DataScienceRDLoop(RDLoop):
# only clean current workspace without affecting other loops.
for k in "direct_exp_gen", "coding", "running":
if k in prev_out and prev_out[k] is not None:
assert isinstance(prev_out[k], DSExperiment)
if not isinstance(prev_out[k], DSExperiment):
raise TypeError(f"prev_out[{k!r}] must be an instance of DSExperiment")
clean_workspace(prev_out[k].experiment_workspace.workspace_path)
# Backup the workspace (only necessary files are included)
@@ -213,7 +213,8 @@ class DSTrace(Trace[DataScienceScen, KnowledgeBase]):
self, component: COMPONENT, search_list: list[tuple[DSExperiment, ExperimentFeedback]] = []
) -> bool:
for exp, fb in search_list:
assert isinstance(exp.hypothesis, DSHypothesis), "Hypothesis should be DSHypothesis (and not None)"
if not isinstance(exp.hypothesis, DSHypothesis):
raise TypeError("Hypothesis should be DSHypothesis (and not None)")
if exp.hypothesis.component == component and fb:
return True
return False
@@ -182,7 +182,7 @@ class ExpGen2Hypothesis(DSProposalV2ExpGen):
success_fb_list = list(set(trace_fbs))
logger.info(
f"Merge Hypothesis: select {len(success_fb_list)} from {len(trace_fbs)} SOTA experiments found in {len(leaves)} traces"
f"Merge Hypothesis: select {len(success_fb_list)} from {len(trace_fbs)} SOTA experiments found in {len(leaves)} traces" # nosec B608 — not SQL, Bandit false positive on "select" in log message
)
if len(success_fb_list) > 0:
@@ -377,7 +377,8 @@ class ExpGen2TraceAndMergeV2(ExpGen):
if DS_RD_SETTING.enable_multi_version_exp_gen:
exp_gen_version_list = DS_RD_SETTING.exp_gen_version_list.split(",")
for version in exp_gen_version_list:
assert version in ["v3", "v2", "v1"]
if version not in ["v3", "v2", "v1"]:
raise ValueError(f"version must be 'v1', 'v2', or 'v3', got {version!r}")
if len(trace.hist) == 0:
# set the proposal version for the first sub-trace
@@ -339,7 +339,8 @@ class DSProposalV1ExpGen(ExpGen):
eda_output = sota_exp.experiment_workspace.file_dict.get("EDA.md", None)
scenario_desc = trace.scen.get_scenario_all_desc(eda_output=eda_output)
assert sota_exp is not None, "SOTA experiment is not provided."
if sota_exp is None:
raise ValueError("SOTA experiment is not provided.")
last_exp = trace.last_exp()
# exp_and_feedback = trace.hist[-1]
# last_exp = exp_and_feedback[0]
@@ -445,8 +446,10 @@ class DSProposalV1ExpGen(ExpGen):
json_target_type=dict[str, dict[str, str | dict] | str],
)
)
assert "hypothesis_proposal" in resp_dict, "Hypothesis proposal not provided."
assert "task_design" in resp_dict, "Task design not provided."
if "hypothesis_proposal" not in resp_dict:
raise ValueError("Hypothesis proposal not provided.")
if "task_design" not in resp_dict:
raise ValueError("Task design not provided.")
task_class = component_info["task_class"]
hypothesis_proposal = resp_dict.get("hypothesis_proposal", {})
hypothesis = DSHypothesis(
@@ -1149,8 +1152,10 @@ You help users retrieve relevant knowledge from community discussions and public
)
response_dict = json.loads(response)
assert response_dict.get("component") in HypothesisComponent.__members__, f"Invalid component"
assert response_dict.get("hypothesis") is not None, f"Invalid hypothesis"
if response_dict.get("component") not in HypothesisComponent.__members__:
raise ValueError(f"Invalid component: {response_dict.get('component')}")
if response_dict.get("hypothesis") is None:
raise ValueError("Invalid hypothesis")
return response_dict
# END: for support llm-based hypothesis selection -----
@@ -1253,7 +1258,8 @@ You help users retrieve relevant knowledge from community discussions and public
description=task_desc,
)
assert isinstance(task, PipelineTask), f"Task {task_name} is not a PipelineTask, got {type(task)}"
if not isinstance(task, PipelineTask):
raise TypeError(f"Task {task_name} is not a PipelineTask, got {type(task)}")
# only for llm with response schema.(TODO: support for non-schema llm?)
# If the LLM provides a "packages" field (list[str]), compute runtime environment now and cache it for subsequent prompts in later loops.
if isinstance(task_dict, dict) and "packages" in task_dict and isinstance(task_dict["packages"], list):
@@ -1,3 +1,4 @@
import ast
import json
import os
import pickle
@@ -292,7 +293,7 @@ class ValidationSelector(SOTAexpSelector):
Sorts all valid experiments by score and returns the top N.
"""
mock_folder = f"/tmp/mock/{self.competition}"
mock_folder = f"/tmp/mock/{self.competition}" # nosec B108 — Docker volume mount point derived from internal competition name
try:
data_py_code, grade_py_code = self._prepare_validation_scripts(
@@ -539,7 +540,7 @@ def process_experiment(
# Run main script
env = get_ds_env(
extra_volumes={f"/tmp/mock/{competition}/{input_folder}": input_folder},
extra_volumes={f"/tmp/mock/{competition}/{input_folder}": input_folder}, # nosec B108 — Docker volume mount point derived from internal competition name
running_timeout_period=DS_RD_SETTING.full_timeout,
)
result = ws.run(env=env, entry="python main.py")
@@ -587,8 +588,8 @@ def _parsing_score(grade_stdout: str) -> Optional[float]:
except:
pass
try:
# Priority 2: Eval dict
return float(eval(json_str)["score"])
# Priority 2: safe literal eval for Python-style dicts
return float(ast.literal_eval(json_str)["score"])
except:
pass
try:
+4 -3
View File
@@ -35,10 +35,11 @@ def select(X: pd.DataFrame) -> pd.DataFrame:
class KGModelFeatureSelectionCoder(Developer[KGModelExperiment]):
def develop(self, exp: KGModelExperiment) -> KGModelExperiment:
target_model_type = exp.sub_tasks[0].model_type
assert target_model_type in KG_SELECT_MAPPING
if target_model_type not in KG_SELECT_MAPPING:
raise ValueError(f"target_model_type {target_model_type} not in KG_SELECT_MAPPING")
if len(exp.experiment_workspace.data_description) == 1:
code = (
Environment(undefined=StrictUndefined)
Environment(undefined=StrictUndefined) # nosec B701 — renders Python code templates, not HTML; autoescape would corrupt code
.from_string(DEFAULT_SELECTION_CODE)
.render(feature_index_list=None)
)
@@ -62,7 +63,7 @@ class KGModelFeatureSelectionCoder(Developer[KGModelExperiment]):
chosen_index_to_list_index = [i - 1 for i in chosen_index]
code = (
Environment(undefined=StrictUndefined)
Environment(undefined=StrictUndefined) # nosec B701 — renders Python code templates, not HTML; autoescape would corrupt code
.from_string(DEFAULT_SELECTION_CODE)
.render(feature_index_list=chosen_index_to_list_index)
)
@@ -165,7 +165,8 @@ class KGScenario(Scenario):
return data_info
def output_format(self, tag=None) -> str:
assert tag in [None, "feature", "model"]
if tag not in [None, "feature", "model"]:
raise ValueError(f"tag must be None, 'feature', or 'model', got {tag!r}")
feature_output_format = f"""The feature code should output following the format:
{T(".prompts:kg_feature_output_format").r()}"""
model_output_format = f"""The model code should output following the format:\n""" + T(
@@ -180,7 +181,8 @@ class KGScenario(Scenario):
return model_output_format
def interface(self, tag=None) -> str:
assert tag in [None, "feature", "XGBoost", "RandomForest", "LightGBM", "NN"]
if tag not in [None, "feature", "XGBoost", "RandomForest", "LightGBM", "NN"]:
raise ValueError(f"tag must be None, 'feature', 'XGBoost', 'RandomForest', 'LightGBM', or 'NN', got {tag!r}")
feature_interface = f"""The feature code should follow the interface:
{T(".prompts:kg_feature_interface").r()}"""
if tag == "feature":
@@ -195,7 +197,8 @@ class KGScenario(Scenario):
return model_interface
def simulator(self, tag=None) -> str:
assert tag in [None, "feature", "model"]
if tag not in [None, "feature", "model"]:
raise ValueError(f"tag must be None, 'feature', or 'model', got {tag!r}")
kg_feature_simulator = (
"The feature code will be sent to the simulator:\n" + T(".prompts:kg_feature_simulator").r()
@@ -79,12 +79,12 @@ def preprocess_script():
This method applies the preprocessing steps to the training, validation, and test datasets.
"""
if os.path.exists("/kaggle/input/X_train.pkl"):
X_train = pd.read_pickle("/kaggle/input/X_train.pkl")
X_valid = pd.read_pickle("/kaggle/input/X_valid.pkl")
y_train = pd.read_pickle("/kaggle/input/y_train.pkl")
y_valid = pd.read_pickle("/kaggle/input/y_valid.pkl")
X_test = pd.read_pickle("/kaggle/input/X_test.pkl")
others = pd.read_pickle("/kaggle/input/others.pkl")
X_train = pd.read_pickle("/kaggle/input/X_train.pkl") # nosec B301 — trusted Kaggle input
X_valid = pd.read_pickle("/kaggle/input/X_valid.pkl") # nosec B301
y_train = pd.read_pickle("/kaggle/input/y_train.pkl") # nosec B301
y_valid = pd.read_pickle("/kaggle/input/y_valid.pkl") # nosec B301
X_test = pd.read_pickle("/kaggle/input/X_test.pkl") # nosec B301
others = pd.read_pickle("/kaggle/input/others.pkl") # nosec B301
y_train = pd.Series(y_train).reset_index(drop=True)
y_valid = pd.Series(y_valid).reset_index(drop=True)
@@ -85,12 +85,12 @@ def preprocess_script():
This method applies the preprocessing steps to the training, validation, and test datasets.
"""
if os.path.exists("/kaggle/input/X_train.pkl"):
X_train = pd.read_pickle("/kaggle/input/X_train.pkl")
X_valid = pd.read_pickle("/kaggle/input/X_valid.pkl")
y_train = pd.read_pickle("/kaggle/input/y_train.pkl")
y_valid = pd.read_pickle("/kaggle/input/y_valid.pkl")
X_test = pd.read_pickle("/kaggle/input/X_test.pkl")
others = pd.read_pickle("/kaggle/input/others.pkl")
X_train = pd.read_pickle("/kaggle/input/X_train.pkl") # nosec B301
X_valid = pd.read_pickle("/kaggle/input/X_valid.pkl") # nosec B301
y_train = pd.read_pickle("/kaggle/input/y_train.pkl") # nosec B301
y_valid = pd.read_pickle("/kaggle/input/y_valid.pkl") # nosec B301
X_test = pd.read_pickle("/kaggle/input/X_test.pkl") # nosec B301
others = pd.read_pickle("/kaggle/input/others.pkl") # nosec B301
return X_train, X_valid, y_train, y_valid, X_test, *others
X_train, X_valid, y_train, y_valid = prepreprocess()
@@ -82,11 +82,11 @@ def preprocess_script():
This method applies the preprocessing steps to the training, validation, and test datasets.
"""
if os.path.exists("X_train.pkl"):
X_train = pd.read_pickle("X_train.pkl")
X_valid = pd.read_pickle("X_valid.pkl")
y_train = pd.read_pickle("y_train.pkl")
y_valid = pd.read_pickle("y_valid.pkl")
X_test = pd.read_pickle("X_test.pkl")
X_train = pd.read_pickle("X_train.pkl") # nosec B301
X_valid = pd.read_pickle("X_valid.pkl") # nosec B301
y_train = pd.read_pickle("y_train.pkl") # nosec B301
y_valid = pd.read_pickle("y_valid.pkl") # nosec B301
X_test = pd.read_pickle("X_test.pkl") # nosec B301
return X_train, X_valid, y_train, y_valid, X_test
X_train, X_valid, y_train, y_valid, test, status_encoder, test_ids = prepreprocess()
@@ -73,12 +73,12 @@ def preprocess_script():
This method applies the preprocessing steps to the training, validation, and test datasets.
"""
if os.path.exists("/kaggle/input/X_train.pkl"):
X_train = pd.read_pickle("/kaggle/input/X_train.pkl")
X_valid = pd.read_pickle("/kaggle/input/X_valid.pkl")
y_train = pd.read_pickle("/kaggle/input/y_train.pkl")
y_valid = pd.read_pickle("/kaggle/input/y_valid.pkl")
X_test = pd.read_pickle("/kaggle/input/X_test.pkl")
others = pd.read_pickle("/kaggle/input/others.pkl")
X_train = pd.read_pickle("/kaggle/input/X_train.pkl") # nosec B301
X_valid = pd.read_pickle("/kaggle/input/X_valid.pkl") # nosec B301
y_train = pd.read_pickle("/kaggle/input/y_train.pkl") # nosec B301
y_valid = pd.read_pickle("/kaggle/input/y_valid.pkl") # nosec B301
X_test = pd.read_pickle("/kaggle/input/X_test.pkl") # nosec B301
others = pd.read_pickle("/kaggle/input/others.pkl") # nosec B301
y_train = pd.Series(y_train).reset_index(drop=True)
y_valid = pd.Series(y_valid).reset_index(drop=True)
+6 -1
View File
@@ -87,7 +87,12 @@ def crawl_descriptions(
content = e.get_attribute("innerHTML")
contents.append(content)
assert len(subtitles) == len(contents) + 1 and subtitles[-1] == "Citation"
if not (len(subtitles) == len(contents) + 1 and subtitles[-1] == "Citation"):
raise AssertionError(
f"Expected len(contents)+1 == len(subtitles) and last subtitle == 'Citation', "
f"got len(subtitles)={len(subtitles)}, len(contents)={len(contents)}, "
f"last subtitle={subtitles[-1]!r}"
)
for i in range(len(subtitles) - 1):
descriptions[subtitles[i]] = contents[i]
@@ -307,7 +307,8 @@ class KGHypothesisGen(FactorAndModelHypothesisGen):
class KGHypothesis2Experiment(FactorAndModelHypothesis2Experiment):
def prepare_context(self, hypothesis: Hypothesis, trace: Trace) -> Tuple[dict, bool]:
scenario = trace.scen.get_scenario_all_desc(filtered_tag="hypothesis_and_experiment")
assert isinstance(hypothesis, KGHypothesis)
if not isinstance(hypothesis, KGHypothesis):
raise TypeError("hypothesis must be an instance of KGHypothesis")
experiment_output_format = (
T("scenarios.kaggle.prompts:feature_experiment_output_format").r()
if hypothesis.action in [KG_ACTION_FEATURE_ENGINEERING, KG_ACTION_FEATURE_PROCESSING]
+293 -163
View File
@@ -1,5 +1,7 @@
import logging
import os
from pathlib import Path
"""
Qlib Factor Runner - Executes factor backtests in Docker.
@@ -9,15 +11,8 @@ NOTE: The @cache_with_pickle decorator was REMOVED from develop() because:
- Docker-level caching (QlibDockerConf.enable_cache=False) is sufficient
- The pickle cache caused 240+ factor generations but ZERO Docker backtests
"""
from pathlib import Path
from typing import Optional
import pandas as pd
from pandarallel import pandarallel
pandarallel.initialize(verbose=1)
from rdagent.app.qlib_rd_loop.conf import FactorBasePropSetting
from rdagent.components.runner import CachedRunner
from rdagent.core.exception import FactorEmptyError
@@ -29,6 +24,83 @@ from rdagent.scenarios.qlib.experiment.model_experiment import QlibModelExperime
DIRNAME = Path(__file__).absolute().resolve().parent
DIRNAME_local = Path.cwd()
def _shift_daily_constant_factor_if_needed(factor_col: "pd.Series", factor_name: str) -> "pd.Series":
"""Detect and fix look-ahead bias in daily-constant factors.
A factor is "daily-constant" when every minute bar within the same calendar
day carries an identical value. This happens when LLM code computes a daily
aggregate (e.g. today's log return) and forward-fills it across all intraday
bars without shifting meaning the end-of-day value is visible at 00:00.
Fix: shift by one trading day so that the value assigned to day T is the
aggregate computed from day T-1, eliminating the forward-looking information.
"""
import numpy as np
try:
notnull = factor_col.dropna()
if len(notnull) < 200:
return factor_col
datetimes = notnull.index.get_level_values("datetime")
dates = datetimes.normalize()
# Sample up to 50 random days and check intra-day uniqueness
unique_dates = pd.Series(dates.unique())
sample_dates = unique_dates.sample(min(50, len(unique_dates)), random_state=42)
daily_unique_counts = []
for d in sample_dates:
mask = dates == d
vals = notnull.values[mask]
if len(vals) > 1:
daily_unique_counts.append(len(np.unique(vals[~np.isnan(vals)])))
if not daily_unique_counts:
return factor_col
# If >90% of sampled days have exactly 1 unique value → daily-constant
fraction_constant = sum(1 for c in daily_unique_counts if c == 1) / len(daily_unique_counts)
if fraction_constant < 0.90:
return factor_col # Intraday factor — no shift needed
logger.warning(
f"[LookAheadFix] Factor '{factor_name}' is daily-constant "
f"({fraction_constant:.0%} of days). Applying 1-day shift to remove look-ahead bias.",
)
# Shift: for each instrument, map daily values forward by 1 trading day
instruments = factor_col.index.get_level_values("instrument").unique()
shifted_parts = []
for inst in instruments:
inst_series = factor_col.xs(inst, level="instrument")
# Get one value per calendar day (the first non-null bar)
inst_dt = inst_series.index.normalize()
daily_vals = inst_series.groupby(inst_dt).first()
# Shift by 1 day
daily_vals_shifted = daily_vals.shift(1)
# Forward-fill back to minute bars
minute_idx = inst_series.index
minute_dates = minute_idx.normalize()
shifted_minute = minute_dates.map(daily_vals_shifted)
shifted_s = pd.Series(
shifted_minute.values,
index=pd.MultiIndex.from_arrays(
[inst_series.index, [inst] * len(inst_series)],
names=["datetime", "instrument"],
),
name=factor_col.name,
)
shifted_parts.append(shifted_s)
return pd.concat(shifted_parts).sort_index()
except Exception as e:
logger.debug(f"[LookAheadFix] Could not apply daily shift for '{factor_name}': {e}")
return factor_col
# TODO: supporting multiprocessing and keep previous results
@@ -43,13 +115,13 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
"""
def calculate_information_coefficient(
self, concat_feature: pd.DataFrame, SOTA_feature_column_size: int, new_feature_columns_size: int
self, concat_feature: pd.DataFrame, SOTA_feature_column_size: int, new_feature_columns_size: int,
) -> pd.DataFrame:
res = pd.Series(index=range(SOTA_feature_column_size * new_feature_columns_size))
for col1 in range(SOTA_feature_column_size):
for col2 in range(SOTA_feature_column_size, SOTA_feature_column_size + new_feature_columns_size):
res.loc[col1 * new_feature_columns_size + col2 - SOTA_feature_column_size] = concat_feature.iloc[
:, col1
:, col1,
].corr(concat_feature.iloc[:, col2])
return res
@@ -58,16 +130,21 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
# if the IC is larger than a threshold, remove the new_feature column
# return the new_feature
from pandarallel import pandarallel
pandarallel.initialize(verbose=1)
concat_feature = pd.concat([SOTA_feature, new_feature], axis=1)
IC_max = (
concat_feature.groupby("datetime")
.parallel_apply(
lambda x: self.calculate_information_coefficient(x, SOTA_feature.shape[1], new_feature.shape[1])
lambda x: self.calculate_information_coefficient(x, SOTA_feature.shape[1], new_feature.shape[1]),
)
.mean()
)
IC_max.index = pd.MultiIndex.from_product([range(SOTA_feature.shape[1]), range(new_feature.shape[1])])
IC_max = IC_max.unstack().max(axis=0)
if not hasattr(IC_max, "index"):
return new_feature
return new_feature.iloc[:, IC_max[IC_max < 0.99].index]
def develop(self, exp: QlibFactorExperiment) -> QlibFactorExperiment:
@@ -82,7 +159,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
self._ensure_results_dirs()
if exp.based_experiments and exp.based_experiments[-1].result is None:
logger.info(f"Baseline experiment execution ...")
logger.info("Baseline experiment execution ...")
exp.based_experiments[-1] = self.develop(exp.based_experiments[-1])
fbps = FactorBasePropSetting()
@@ -106,11 +183,11 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
base_exp for base_exp in exp.based_experiments if isinstance(base_exp, QlibFactorExperiment)
]
if len(sota_factor_experiments_list) > 1:
logger.info(f"SOTA factor processing ...")
logger.info("SOTA factor processing ...")
SOTA_factor = process_factor_data(sota_factor_experiments_list)
# Process the new factors data
logger.info(f"New factor processing ...")
logger.info("New factor processing ...")
new_factors = process_factor_data(exp)
if new_factors.empty:
@@ -121,7 +198,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
new_factors = self.deduplicate_new_factors(SOTA_factor, new_factors)
if new_factors.empty:
raise FactorEmptyError(
"The factors generated in this round are highly similar to the previous factors. Please change the direction for creating new factors."
"The factors generated in this round are highly similar to the previous factors. Please change the direction for creating new factors.",
)
combined_factors = pd.concat([SOTA_factor, new_factors], axis=1).dropna()
else:
@@ -132,7 +209,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
combined_factors = combined_factors.loc[:, ~combined_factors.columns.duplicated(keep="last")]
new_columns = pd.MultiIndex.from_product([["feature"], combined_factors.columns])
combined_factors.columns = new_columns
logger.info(f"Factor data processing completed.")
logger.info("Factor data processing completed.")
num_features = len(exp.base_features) + len(combined_factors.columns)
@@ -151,10 +228,10 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
sota_model_exp = base_exp
exist_sota_model_exp = True
break
logger.info(f"Experiment execution ...")
logger.info("Experiment execution ...")
if exist_sota_model_exp:
exp.experiment_workspace.inject_files(
**{"model.py": sota_model_exp.sub_workspace_list[0].file_dict["model.py"]}
**{"model.py": sota_model_exp.sub_workspace_list[0].file_dict["model.py"]},
)
sota_training_hyperparameters = sota_model_exp.sub_tasks[0].training_hyperparameters
if sota_training_hyperparameters:
@@ -165,19 +242,19 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
"early_stop": str(sota_training_hyperparameters.get("early_stop", 10)),
"batch_size": str(sota_training_hyperparameters.get("batch_size", 256)),
"weight_decay": str(sota_training_hyperparameters.get("weight_decay", 0.0001)),
}
},
)
sota_model_type = sota_model_exp.sub_tasks[0].model_type
if sota_model_type == "TimeSeries":
env_to_use.update(
{"dataset_cls": "TSDatasetH", "num_features": num_features, "step_len": 20, "num_timesteps": 20}
{"dataset_cls": "TSDatasetH", "num_features": num_features, "step_len": 20, "num_timesteps": 20},
)
elif sota_model_type == "Tabular":
env_to_use.update({"dataset_cls": "DatasetH", "num_features": num_features})
# model + combined factors
result, stdout = exp.experiment_workspace.execute(
qlib_config_name="conf_combined_factors_sota_model.yaml", run_env=env_to_use
qlib_config_name="conf_combined_factors_sota_model.yaml", run_env=env_to_use,
)
else:
# LGBM + combined factors
@@ -186,7 +263,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
run_env=env_to_use,
)
else:
logger.info(f"Experiment execution ...")
logger.info("Experiment execution ...")
if exp.base_feature_codes:
factors = process_factor_data(exp)
factors = factors.sort_index()
@@ -196,7 +273,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
target_path = exp.experiment_workspace.workspace_path / "combined_factors_df.parquet"
# Save the combined factors to the workspace
factors.to_parquet(target_path, engine="pyarrow")
logger.info(f"Factor data processing completed.")
logger.info("Factor data processing completed.")
result, stdout = exp.experiment_workspace.execute(
qlib_config_name="conf_combined_factors.yaml",
run_env=env_to_use,
@@ -209,10 +286,10 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
# Handle Qlib Docker backtest failure gracefully
if result is None:
factor_name = getattr(exp.hypothesis, 'hypothesis', 'unknown')
factor_name = getattr(exp.hypothesis, "hypothesis", "unknown")
logger.warning(
f"Qlib Docker backtest returned None for '{factor_name}'. "
f"Attempting direct factor evaluation..."
f"Attempting direct factor evaluation...",
)
# Try to compute metrics directly from the factor's result.h5
@@ -224,7 +301,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
else:
logger.error(
f"Both Qlib Docker backtest and direct evaluation failed for '{factor_name}'. "
f"Skipping this factor and continuing."
f"Skipping this factor and continuing.",
)
# Save failed run info for debugging
self._save_failed_run(exp, stdout, error_type="result_none")
@@ -242,7 +319,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
if validation_result.get("has_issues"):
logger.warning(
f"Result validation warnings for factor '{getattr(exp.hypothesis, 'hypothesis', 'unknown')}': "
f"{validation_result['warnings']}"
f"{validation_result['warnings']}",
)
# Save warning info for debugging
self._save_failed_run(exp, stdout, error_type="validation_warnings", validation=validation_result)
@@ -293,43 +370,43 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
details = {}
factor_name = "unknown"
if hasattr(exp, 'hypothesis') and exp.hypothesis is not None:
factor_name = getattr(exp.hypothesis, 'hypothesis', 'unknown')
if hasattr(exp, "hypothesis") and exp.hypothesis is not None:
factor_name = getattr(exp.hypothesis, "hypothesis", "unknown")
if isinstance(result, pd.Series):
# Check IC
ic_value = result.get('IC', None)
details['ic_raw'] = ic_value
ic_value = result.get("IC", None)
details["ic_raw"] = ic_value
if ic_value is None or (isinstance(ic_value, float) and (ic_value != ic_value)): # NaN check
warnings.append("IC is None/NaN — factor has no predictive power")
else:
try:
ic_float = float(ic_value)
details['ic'] = ic_float
details["ic"] = ic_float
if abs(ic_float) < 0.001:
warnings.append(
f"IC is near zero ({ic_float:.6f}) — factor may not predict returns"
f"IC is near zero ({ic_float:.6f}) — factor may not predict returns",
)
except (ValueError, TypeError):
warnings.append(f"IC value is not numeric: {ic_value}")
# Check positions (1day.pos)
pos_value = result.get('1day.pos', None)
details['positions_raw'] = pos_value
pos_value = result.get("1day.pos", None)
details["positions_raw"] = pos_value
if pos_value is not None:
try:
pos_float = float(pos_value)
details['positions'] = pos_float
details["positions"] = pos_float
if pos_float == 0:
warnings.append(
"1day.pos == 0 — model opened ZERO positions (stayed neutral). "
"Possible causes: (1) topk too high for single-asset, "
"(2) signal threshold too restrictive, (3) no valid predictions"
"(2) signal threshold too restrictive, (3) no valid predictions",
)
elif pos_float < 10:
warnings.append(
f"1day.pos = {pos_float:.0f} — very few positions opened. "
f"Check signal threshold and topk settings"
f"Check signal threshold and topk settings",
)
except (ValueError, TypeError):
pass # pos might be a string
@@ -337,24 +414,24 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
# Check if result is essentially empty (all values None or NaN)
non_null_count = result.notna().sum()
total_count = len(result)
details['non_null_metrics'] = int(non_null_count)
details['total_metrics'] = int(total_count)
details["non_null_metrics"] = int(non_null_count)
details["total_metrics"] = int(total_count)
if non_null_count < 3:
warnings.append(
f"Result has only {non_null_count}/{total_count} non-null metrics — "
f"backtest likely produced empty results"
f"backtest likely produced empty results",
)
# Check for key metrics
required_metrics = ['IC', '1day.excess_return_with_cost.shar', '1day.pos']
required_metrics = ["IC", "1day.excess_return_with_cost.shar", "1day.pos"]
for metric_name in required_metrics:
val = result.get(metric_name, None)
details[f'has_{metric_name}'] = val is not None
details[f"has_{metric_name}"] = val is not None
elif isinstance(result, dict):
# Dict-based result validation
ic_value = result.get('IC', result.get('ic', None))
details['ic_raw'] = ic_value
ic_value = result.get("IC", result.get("ic", None))
details["ic_raw"] = ic_value
if ic_value is None:
warnings.append("IC is None — factor has no predictive power")
@@ -364,7 +441,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
"details": details,
}
def _evaluate_factor_directly(self, exp, stdout: str) -> Optional[pd.Series]:
def _evaluate_factor_directly(self, exp, stdout: str) -> pd.Series | None:
"""
Evaluate factor directly from its result.h5 file when Qlib Docker fails.
@@ -391,8 +468,19 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
import numpy as np
try:
# Get workspace path
workspace_path = exp.experiment_workspace.workspace_path
# Get workspace path — factor code and result.h5 live in sub_workspace_list[0],
# not in experiment_workspace (which is the Qlib template workspace).
workspace_path = None
if exp.sub_workspace_list:
for ws in exp.sub_workspace_list:
if ws is not None and hasattr(ws, "workspace_path"):
candidate = ws.workspace_path / "result.h5"
if candidate.exists():
workspace_path = ws.workspace_path
break
if workspace_path is None:
# Fallback to experiment_workspace
workspace_path = exp.experiment_workspace.workspace_path
if workspace_path is None:
return None
@@ -409,6 +497,12 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
factor_col = factor_values.iloc[:, 0]
factor_name = factor_values.columns[0]
# Detect and fix look-ahead bias in daily-constant factors.
# If a factor has the same value for all minute bars within each calendar day
# it was computed from same-day data (e.g. today's close return at 00:00).
# Fix: shift by 1 trading day so value at day T = aggregate of day T-1.
factor_col = _shift_daily_constant_factor_if_needed(factor_col, factor_name)
# Load source data for forward returns
data_path = (
Path(__file__).parent.parent.parent.parent.parent
@@ -483,7 +577,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
logger.info(
f"Direct evaluation: IC={ic:.6f}, Sharpe={sharpe:.4f}, "
f"AnnRet={annualized_return:.4f}%, WR={win_rate:.2%}"
f"AnnRet={annualized_return:.4f}%, WR={win_rate:.2%}",
)
return result
@@ -492,7 +586,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
return None
def _save_failed_run(self, exp, stdout: str, error_type: str = "unknown",
validation: Optional[dict] = None) -> None:
validation: dict | None = None) -> None:
"""
Save failed run information to results/failed_runs.json for debugging.
@@ -519,20 +613,20 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
# Get factor name
factor_name = "unknown"
if hasattr(exp, 'hypothesis') and exp.hypothesis is not None:
factor_name = getattr(exp.hypothesis, 'hypothesis', 'unknown')
if hasattr(exp, "hypothesis") and exp.hypothesis is not None:
factor_name = getattr(exp.hypothesis, "hypothesis", "unknown")
# Build failed run record
failed_record = {
"timestamp": datetime.now().isoformat(),
"factor_name": factor_name,
"error_type": error_type,
"stdout": stdout if stdout else "(empty)",
"stdout": stdout or "(empty)",
"validation": validation,
"experiment_details": {
"base_features": list(getattr(exp, 'base_features', {}).keys()) if hasattr(exp, 'base_features') else [],
"hypothesis": getattr(exp.hypothesis, 'hypothesis', str(getattr(exp, 'hypothesis', 'N/A')))
if hasattr(exp, 'hypothesis') else "N/A",
"base_features": list(getattr(exp, "base_features", {}).keys()) if hasattr(exp, "base_features") else [],
"hypothesis": getattr(exp.hypothesis, "hypothesis", str(getattr(exp, "hypothesis", "N/A")))
if hasattr(exp, "hypothesis") else "N/A",
},
}
@@ -555,11 +649,11 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
failed_file.write_text(
json.dumps(existing_records, indent=2, default=str, ensure_ascii=False),
encoding="utf-8"
encoding="utf-8",
)
logger.info(
f"Failed run saved: {factor_name} (type={error_type}) "
f"{failed_file}"
f"{failed_file}",
)
except Exception as e:
@@ -582,21 +676,23 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
containing metric names like 'IC', '1day.excess_return_with_cost.shar', etc.
"""
try:
import json
import pandas as pd
from pathlib import Path
import pandas as pd
from rdagent.components.backtesting import ResultsDatabase
# Get factor name from hypothesis
# Get factor name: prefer hypothesis, fallback to result Series 'factor_name' key
factor_name = "unknown"
if hasattr(exp, 'hypothesis') and exp.hypothesis is not None:
factor_name = getattr(exp.hypothesis, 'hypothesis', 'unknown')
if hasattr(exp, "hypothesis") and exp.hypothesis is not None:
factor_name = getattr(exp.hypothesis, "hypothesis", "unknown")
if factor_name == "unknown" and isinstance(result, pd.Series) and "factor_name" in result.index:
factor_name = str(result["factor_name"])
# Check if already rejected by protection
if getattr(exp, 'rejected_by_protection', False):
if getattr(exp, "rejected_by_protection", False):
logger.info(
f"Factor rejected by protection, skipping DB save: "
f"{getattr(exp, 'protection_reason', 'unknown')}"
f"{getattr(exp, 'protection_reason', 'unknown')}",
)
return
@@ -612,47 +708,47 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
# Extract metrics from result (pd.Series from qlib_res.csv)
metrics = {}
if isinstance(result, pd.Series):
metrics['ic'] = self._safe_float(result.get('IC', None))
metrics['sharpe_ratio'] = self._safe_float(
result.get('1day.excess_return_with_cost.shar',
result.get('1day.excess_return_with_cost.sharpe', None))
metrics["ic"] = self._safe_float(result.get("IC", None))
metrics["sharpe_ratio"] = self._safe_float(
result.get("1day.excess_return_with_cost.shar",
result.get("1day.excess_return_with_cost.sharpe", None)),
)
metrics['annualized_return'] = self._safe_float(
result.get('1day.excess_return_with_cost.annualized_return', None)
metrics["annualized_return"] = self._safe_float(
result.get("1day.excess_return_with_cost.annualized_return", None),
)
metrics['max_drawdown'] = self._safe_float(
result.get('1day.excess_return_with_cost.max_drawdown', None)
metrics["max_drawdown"] = self._safe_float(
result.get("1day.excess_return_with_cost.max_drawdown", None),
)
metrics['win_rate'] = self._safe_float(result.get('win_rate', None))
metrics['information_ratio'] = self._safe_float(
result.get('1day.excess_return_with_cost.information_ratio', None)
metrics["win_rate"] = self._safe_float(result.get("win_rate", None))
metrics["information_ratio"] = self._safe_float(
result.get("1day.excess_return_with_cost.information_ratio", None),
)
metrics['volatility'] = self._safe_float(
result.get('1day.excess_return_with_cost.std',
result.get('1day.excess_return_with_cost.volatility', None))
metrics["volatility"] = self._safe_float(
result.get("1day.excess_return_with_cost.std",
result.get("1day.excess_return_with_cost.volatility", None)),
)
# Store raw metrics for JSON export
metrics['raw_metrics'] = result.to_dict()
metrics["raw_metrics"] = result.to_dict()
elif isinstance(result, dict):
metrics['ic'] = self._safe_float(result.get('IC', result.get('ic', None)))
metrics['sharpe_ratio'] = self._safe_float(
result.get('sharpe', result.get('sharpe_ratio', None))
metrics["ic"] = self._safe_float(result.get("IC", result.get("ic", None)))
metrics["sharpe_ratio"] = self._safe_float(
result.get("sharpe", result.get("sharpe_ratio", None)),
)
metrics['annualized_return'] = self._safe_float(result.get('annualized_return', None))
metrics['max_drawdown'] = self._safe_float(result.get('max_drawdown', None))
metrics['win_rate'] = self._safe_float(result.get('win_rate', None))
metrics['information_ratio'] = None
metrics['volatility'] = None
metrics['raw_metrics'] = result
metrics["annualized_return"] = self._safe_float(result.get("annualized_return", None))
metrics["max_drawdown"] = self._safe_float(result.get("max_drawdown", None))
metrics["win_rate"] = self._safe_float(result.get("win_rate", None))
metrics["information_ratio"] = None
metrics["volatility"] = None
metrics["raw_metrics"] = result
# Result validation before saving (warnings, not blocking)
self._log_result_warnings(factor_name, result, metrics)
# Only save if we have at least IC or Sharpe
if metrics.get('ic') is None and metrics.get('sharpe_ratio') is None:
if metrics.get("ic") is None and metrics.get("sharpe_ratio") is None:
logger.warning(
f"No valid IC/Sharpe for factor '{factor_name}', skipping DB save. "
f"IC={metrics.get('ic')}, Sharpe={metrics.get('sharpe_ratio')}"
f"IC={metrics.get('ic')}, Sharpe={metrics.get('sharpe_ratio')}",
)
return
@@ -663,19 +759,19 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
db_file = db_path / "backtest_results.db"
# Parallel run isolation: use run-specific subdirectory if PARALLEL_RUN_ID is set
run_id = os.getenv("PARALLEL_RUN_ID", "0")
if run_id != "0":
parallel_run_id = os.getenv("PARALLEL_RUN_ID", "0")
if parallel_run_id != "0":
# For parallel runs, save to isolated results directory
isolated_db_path = project_root / "results" / "runs" / f"run{run_id}" / "db"
isolated_db_path = project_root / "results" / "runs" / f"run{parallel_run_id}" / "db"
isolated_db_path.mkdir(parents=True, exist_ok=True)
db_file = isolated_db_path / "backtest_results.db"
# Save to database
db = ResultsDatabase(db_path=str(db_file))
run_id = db.add_backtest(factor_name=factor_name[:100], metrics=metrics)
db_run_id = db.add_backtest(factor_name=factor_name[:100], metrics=metrics)
logger.info(
f"Factor result saved to DB: {factor_name[:60]} "
f"(IC={metrics.get('ic')}, Sharpe={metrics.get('sharpe_ratio')}, run_id={run_id})"
f"(IC={metrics.get('ic')}, Sharpe={metrics.get('sharpe_ratio')}, run_id={db_run_id})"
)
# Extract factor code and description from experiment
@@ -683,10 +779,10 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
# Also write a JSON summary to results/factors/ for file-based access
self._save_factor_json(
factor_name, metrics, run_id,
factor_name, metrics, db_run_id,
factor_code=factor_code,
factor_description=factor_description,
exp=exp
exp=exp,
)
# Save factor values as parquet for strategy building
@@ -698,7 +794,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
import traceback
logger.error(
f"Database save failed for factor '{getattr(exp.hypothesis, 'hypothesis', 'unknown')}': {e}\n"
f"Traceback: {traceback.format_exc()}"
f"Traceback: {traceback.format_exc()}",
)
def _save_factor_json(self, factor_name: str, metrics: dict, run_id: int,
@@ -809,14 +905,14 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
factor_description = match.group(1).strip()[:500]
else:
# Try comments
lines = factor_code.split('\n')
lines = factor_code.split("\n")
desc_lines = []
for line in lines[:20]:
stripped = line.strip()
if stripped.startswith('#') and not stripped.startswith('#!'):
if stripped.startswith("#") and not stripped.startswith("#!"):
desc_lines.append(stripped[1:].strip())
if desc_lines:
factor_description = ' '.join(desc_lines)[:500]
factor_description = " ".join(desc_lines)[:500]
return factor_code, factor_description
@@ -824,41 +920,80 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
"""
Save factor time-series values as parquet for strategy building.
This is essential for walk-forward validation and strategy combination.
Parameters
----------
factor_name : str
Name of the factor
exp : QlibFactorExperiment
The experiment with factor values
Reruns the factor code on the FULL 6-year dataset so the parquet covers
the complete backtest range (not just the debug 2024 subset).
"""
import os as _os
import shutil
import subprocess
import sys
import tempfile
try:
# Get workspace path
workspace_path = exp.experiment_workspace.workspace_path
# factor.py lives in sub_workspace_list[0], not experiment_workspace
workspace_path = None
if exp.sub_workspace_list:
for ws in exp.sub_workspace_list:
if ws is not None and hasattr(ws, "workspace_path"):
fp = ws.workspace_path / "factor.py"
if fp.exists():
workspace_path = ws.workspace_path
break
if workspace_path is None:
workspace_path = exp.experiment_workspace.workspace_path
if workspace_path is None:
return
result_h5 = workspace_path / "result.h5"
if not result_h5.exists():
factor_py = workspace_path / "factor.py"
if not factor_py.exists():
return
# Read factor values
project_root = Path(__file__).parent.parent.parent.parent.parent
full_data = (
project_root
/ "git_ignore_folder"
/ "factor_implementation_source_data"
/ "intraday_pv.h5"
)
if not full_data.exists():
return
# Run factor code on full data in a temp workspace
import pandas as pd
df = pd.read_hdf(str(result_h5), key="data")
with tempfile.TemporaryDirectory(prefix="predix_fullval_") as tmp_dir:
tmp = Path(tmp_dir)
shutil.copy(str(factor_py), str(tmp / "factor.py"))
shutil.copy(str(full_data), str(tmp / "intraday_pv.h5"))
ret = subprocess.run(
[sys.executable, "factor.py"],
cwd=str(tmp),
capture_output=True,
timeout=300,
check=False,
)
if ret.returncode != 0:
logger.warning(
f"Full-data factor run failed (exit {ret.returncode}): "
f"{ret.stderr[:500] if ret.stderr else '(no stderr)'}"
)
# Fall back to debug-data result if full-data run fails
result_h5 = workspace_path / "result.h5"
if not result_h5.exists():
return
df = pd.read_hdf(str(result_h5), key="data")
else:
result_h5_full = tmp / "result.h5"
if not result_h5_full.exists():
return
df = pd.read_hdf(str(result_h5_full), key="data")
if df is None or df.empty:
return
# Get the factor series (first column)
series = df.iloc[:, 0]
series.name = factor_name
# Save to results/factors/values/
project_root = Path(__file__).parent.parent.parent.parent.parent
# Parallel run isolation
parallel_run_id = _os.getenv("PARALLEL_RUN_ID", "0")
if parallel_run_id != "0":
values_dir = project_root / "results" / "runs" / f"run{parallel_run_id}" / "factors" / "values"
@@ -866,17 +1001,12 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
values_dir = project_root / "results" / "factors" / "values"
values_dir.mkdir(parents=True, exist_ok=True)
# Safe filename
safe_name = factor_name.replace("/", "_").replace("\\", "_").replace(" ", "_")[:100]
parquet_path = values_dir / f"{safe_name}.parquet"
series.to_frame().to_parquet(str(parquet_path))
# Save as parquet (with datetime index)
series.to_parquet(str(parquet_path))
except Exception as e:
# Don't let factor value saving break the main workflow
pass
except Exception:
logging.debug("Error in save_factor_values_to_parquet", exc_info=True)
def _log_result_warnings(self, factor_name: str, result, metrics: dict) -> None:
"""
@@ -897,7 +1027,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
warnings_list = []
# Check IC
ic = metrics.get('ic')
ic = metrics.get("ic")
if ic is None:
warnings_list.append("IC is None — factor has no predictive power")
elif abs(ic) < 0.001:
@@ -905,7 +1035,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
# Check positions (1day.pos) — CRITICAL for EURUSD
if isinstance(result, pd.Series):
pos_value = result.get('1day.pos', None)
pos_value = result.get("1day.pos", None)
if pos_value is not None:
try:
pos_float = float(pos_value)
@@ -913,23 +1043,23 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
warnings_list.append(
"WARNING: 1day.pos == 0 — ZERO positions opened! "
"Model stayed completely neutral. Check Qlib config: "
"ensure topk=1 and market=eurusd for single-asset trading."
"ensure topk=1 and market=eurusd for single-asset trading.",
)
elif pos_float < 10:
warnings_list.append(
f"Low position count: 1day.pos = {pos_float:.0f}"
f"model traded very rarely"
f"model traded very rarely",
)
except (ValueError, TypeError):
pass
# Check Sharpe
sharpe = metrics.get('sharpe_ratio')
sharpe = metrics.get("sharpe_ratio")
if sharpe is not None and abs(sharpe) < 0.1:
warnings_list.append(f"Sharpe near zero ({sharpe:.4f}) — no risk-adjusted edge")
# Check max drawdown
mdd = metrics.get('max_drawdown')
mdd = metrics.get("max_drawdown")
if mdd is not None and mdd < -0.5:
warnings_list.append(f"Extreme drawdown: {mdd:.2%} — high risk factor")
@@ -944,7 +1074,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
return None
try:
f = float(value)
if pd.isna(f) or f == float('inf') or f == float('-inf'):
if pd.isna(f) or f == float("inf") or f == float("-inf"):
return None
return f
except (ValueError, TypeError):
@@ -987,7 +1117,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
if protection_result.should_block:
logger.warning(
f"Factor {factor_name} rejected by protection manager: {protection_result.reason}"
f"Factor {factor_name} rejected by protection manager: {protection_result.reason}",
)
# Mark factor as rejected by protection
exp.rejected_by_protection = True
@@ -1012,8 +1142,8 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
from pathlib import Path
factor_name = "unknown"
if hasattr(exp, 'hypothesis') and exp.hypothesis is not None:
factor_name = getattr(exp.hypothesis, 'hypothesis', 'unknown')
if hasattr(exp, "hypothesis") and exp.hypothesis is not None:
factor_name = getattr(exp.hypothesis, "hypothesis", "unknown")
# Build log entry
log_entry = {
@@ -1025,42 +1155,42 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
"annualized_return": None,
"max_drawdown": None,
"win_rate": None,
"rejected_by_protection": getattr(exp, 'rejected_by_protection', False),
"protection_reason": getattr(exp, 'protection_reason', None),
"rejected_by_protection": getattr(exp, "rejected_by_protection", False),
"protection_reason": getattr(exp, "protection_reason", None),
}
# Extract metrics if available
if result is not None:
if hasattr(result, 'get'): # pd.Series or dict
ic_val = result.get('IC', result.get('ic', None))
log_entry['ic'] = self._safe_float(ic_val) if ic_val is not None else None
if hasattr(result, "get"): # pd.Series or dict
ic_val = result.get("IC", result.get("ic", None))
log_entry["ic"] = self._safe_float(ic_val) if ic_val is not None else None
sharpe_val = result.get('1day.excess_return_with_cost.shar',
result.get('1day.excess_return_with_cost.sharpe',
result.get('sharpe', None)))
log_entry['sharpe'] = self._safe_float(sharpe_val) if sharpe_val is not None else None
sharpe_val = result.get("1day.excess_return_with_cost.shar",
result.get("1day.excess_return_with_cost.sharpe",
result.get("sharpe", None)))
log_entry["sharpe"] = self._safe_float(sharpe_val) if sharpe_val is not None else None
ann_ret = result.get('1day.excess_return_with_cost.annualized_return',
result.get('annualized_return', None))
log_entry['annualized_return'] = self._safe_float(ann_ret) if ann_ret is not None else None
ann_ret = result.get("1day.excess_return_with_cost.annualized_return",
result.get("annualized_return", None))
log_entry["annualized_return"] = self._safe_float(ann_ret) if ann_ret is not None else None
mdd = result.get('1day.excess_return_with_cost.max_drawdown',
result.get('max_drawdown', None))
log_entry['max_drawdown'] = self._safe_float(mdd) if mdd is not None else None
mdd = result.get("1day.excess_return_with_cost.max_drawdown",
result.get("max_drawdown", None))
log_entry["max_drawdown"] = self._safe_float(mdd) if mdd is not None else None
wr = result.get('win_rate', None)
log_entry['win_rate'] = self._safe_float(wr) if wr is not None else None
wr = result.get("win_rate", None)
log_entry["win_rate"] = self._safe_float(wr) if wr is not None else None
# Determine status
if log_entry['ic'] is not None or log_entry['sharpe'] is not None:
log_entry['status'] = "success"
elif getattr(exp, 'rejected_by_protection', False):
log_entry['status'] = "rejected_protection"
if log_entry["ic"] is not None or log_entry["sharpe"] is not None:
log_entry["status"] = "success"
elif getattr(exp, "rejected_by_protection", False):
log_entry["status"] = "rejected_protection"
else:
log_entry['status'] = "no_valid_metrics"
log_entry["status"] = "no_valid_metrics"
else:
log_entry['status'] = "execution_failed"
log_entry['reason'] = "Result was None"
log_entry["status"] = "execution_failed"
log_entry["reason"] = "Result was None"
# Write to results/logs/
try:
@@ -1083,7 +1213,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
logger.info(
f"Run log written for '{factor_name[:50]}': "
f"status={log_entry['status']}, IC={log_entry['ic']}, Sharpe={log_entry['sharpe']}"
f"status={log_entry['status']}, IC={log_entry['ic']}, Sharpe={log_entry['sharpe']}",
)
except Exception as e:
logger.error(f"Failed to write run log: {e}")
@@ -203,12 +203,14 @@ class QlibModelRunner(CachedRunner[QlibModelExperiment]):
# Save to database
db = ResultsDatabase()
run_id = db.add_backtest(factor_name=factor_name[:100], metrics=metrics)
logger.info(
f"Model result saved to DB: {factor_name[:50]} "
f"(IC={metrics.get('ic')}, Sharpe={metrics.get('sharpe_ratio')}, run_id={run_id})"
)
db.close()
try:
run_id = db.add_backtest(factor_name=factor_name[:100], metrics=metrics)
logger.info(
f"Model result saved to DB: {factor_name[:50]} "
f"(IC={metrics.get('ic')}, Sharpe={metrics.get('sharpe_ratio')}, run_id={run_id})"
)
finally:
db.close()
except Exception as e:
logger.warning(f"Database save failed for model {getattr(exp.hypothesis, 'hypothesis', 'unknown')}: {e}")
@@ -241,6 +241,7 @@ class StrategyBuilder:
if data.get("status") == "success" and data.get("ic") is not None:
factors.append(data)
except Exception:
logger.warning("Failed to load factor file %s", f, exc_info=True)
continue
# Sort by absolute IC
+2 -1
View File
@@ -30,7 +30,8 @@ def _build_execute_calls(exp: QlibFactorExperiment, base_feature_workspaces: lis
execute_calls = []
if exp.sub_tasks:
assert isinstance(exp.prop_dev_feedback, CoSTEERMultiFeedback)
if not isinstance(exp.prop_dev_feedback, CoSTEERMultiFeedback):
raise TypeError("exp.prop_dev_feedback must be of type CoSTEERMultiFeedback")
execute_calls.extend(
(implementation.execute, ("All",))
for implementation, feedback in zip(exp.sub_workspace_list, exp.prop_dev_feedback)
@@ -23,14 +23,25 @@ $low: low price at 1-minute bar.
$volume: volume at 1-minute bar (tick volume for FX).
## Important Notes for 1min Data
- 96 bars = 1 trading day (24 hours for FX)
- 1 bar = 1 minute (confirmed)
- 16 bars = 16 minutes
- 4 bars = 4 minutes
- 1 bar = 1 minute
- 60 bars = 1 hour
- ~1440 bars = 1 full trading day (FX trades nearly 24h, Mon 00:00 - Fri 22:00 UTC approx.)
- Typical bars per calendar day: ~1200-1440 (varies by weekday, holidays have fewer)
- Do NOT assume 96 bars/day — the actual count depends on the date
- Data range: 2020-01-01 to 2026-03-20
- Instrument: EURUSD
- Timezone: UTC
## IMPORTANT: Bars per Day Correction
The dataset has approximately 1440 bars per full trading day (1 bar = 1 minute, ~24h of FX trading).
Some older documentation incorrectly stated "96 bars = 1 day" — this is WRONG. Always use:
- 60 bars = 1 hour
- 480 bars = 8 hours (London session 08:00-16:00 UTC)
- 180 bars = 3 hours (London/NY overlap 13:00-16:00 UTC)
Use datetime hour filtering (e.g., `df[df.index.get_level_values('datetime').hour.between(8, 15)]`)
to select session bars — do NOT use bar-count offsets to define sessions.
## Session Times (UTC)
- Asian: 00:00-08:00 UTC (low volatility)
- London: 08:00-16:00 UTC (high volatility)
+30 -1
View File
@@ -104,7 +104,7 @@ qlib_factor_strategy: |-
result_df.columns = ['daily_volume_price_divergence']
```
4. **Process ALL data — do not filter dates**: The source HDF5 contains data from 2020-01-01 to 2026-03-20. Do NOT filter to a single year. If your output has only 314 entries (one year of daily data), the factor will be rejected. Expected output: ~1500+ daily entries for 2020-2026.
4. **Process ALL data — do not filter dates**: The source HDF5 contains data from 2020-01-01 to 2026-03-20 (development runs may use a 2024-only debug dataset with ~300 entries, which is acceptable). Do NOT filter to a single year in your code. Write your code to process whatever date range is available in the HDF5 file — do not hardcode date filters. Expected output for production data: ~1500+ daily entries for 2020-2026. Expected output for debug data: ~300 daily entries for 2024. Both are valid.
5. **Use `transform()` instead of `apply()` for per-group calculations**: `transform()` preserves the original index while `apply()` may reduce the number of rows unexpectedly:
```python
@@ -121,6 +121,35 @@ qlib_factor_strategy: |-
assert result_df.index.names == ['datetime', 'instrument'], f"Index names must be ['datetime', 'instrument'], got {result_df.index.names}"
```
7. **NEVER use same-day aggregations as the factor value — always shift by 1 day**: If your factor computes a daily aggregate (e.g. daily close return, daily OHLC range, daily volume), that aggregate is only known at end-of-day. Using it at the start of the same day is look-ahead bias. You MUST shift the daily aggregate by 1 day before forward-filling to minute bars:
```python
# WRONG: look-ahead bias! Today's close return is not known at 00:00
daily_ret = df['$close'].groupby(level='instrument').resample('1D', level='datetime').last().pct_change()
result_df['my_factor'] = daily_ret.groupby(level='instrument').transform(lambda x: x.reindex(df.index.get_level_values('datetime'), method='ffill'))
# CORRECT: shift by 1 trading day so factor value at day T = aggregate of day T-1
daily_close = df.groupby([df.index.get_level_values('datetime').normalize(), df.index.get_level_values('instrument')])['$close'].last()
daily_close.index.names = ['date', 'instrument']
daily_ret = daily_close.groupby(level='instrument').pct_change().shift(1) # <-- shift(1) is MANDATORY
# then map back to minute bars via ffill
```
This rule applies to ALL daily aggregations: returns, OHLC stats, volume, momentum, slopes, etc.
**Session-based aggregations (London, NY, Asian session returns) are also daily aggregations** — the London
session (08:00-16:00 UTC) ends at 16:00, so its return must be shifted by 1 day before use.
Intraday rolling factors (e.g. 30-min rolling std computed at bar t using only bars t-N..t-1) do NOT need this shift.
8. **PREFER pure intraday rolling factors**: Factors that use only a trailing window of recent bars (e.g.
rolling(30).mean() of returns, RSI(14), Bollinger Band z-score) have NO look-ahead risk and vary every
minute. These are the best candidates for short-horizon (60-180 bar) prediction. Examples:
- Rolling 15-min / 30-min / 60-min return momentum (15, 30, 60 bars respectively)
- Rolling volatility (std of returns over 20-60 bars)
- Distance of close from N-bar moving average (z-score)
- RSI or similar oscillators computed on 1-min bars
- VWAP deviation (requires volume — use $volume column)
Always use `.shift(1)` on the lagged window (e.g. `rolling(N).mean().shift(1)`) to avoid using the
current bar's own price in its own feature value.
NOTE: 1 bar = 1 minute. The data has ~1440 bars per full trading day. Do NOT use 96 as a day proxy.
qlib_factor_output_format: |-
Your output should be a pandas dataframe similar to the following example information:
<class 'pandas.core.frame.DataFrame'>
@@ -56,7 +56,8 @@ class QlibQuantScenario(Scenario):
)
def background(self, tag=None) -> str:
assert tag in [None, "factor", "model"]
if tag not in [None, "factor", "model"]:
raise ValueError(f"tag must be None, 'factor', or 'model', got {tag!r}")
quant_background = "The background of the scenario is as follows:\n" + T(".prompts:qlib_quant_background").r(
runtime_environment=self.get_runtime_environment(),
)
@@ -83,7 +84,8 @@ class QlibQuantScenario(Scenario):
return self._source_data
def output_format(self, tag=None) -> str:
assert tag in [None, "factor", "model"]
if tag not in [None, "factor", "model"]:
raise ValueError(f"tag must be None, 'factor', or 'model', got {tag!r}")
factor_output_format = (
"The factor code should output the following format:\n" + T(".prompts:qlib_factor_output_format").r()
)
@@ -99,7 +101,8 @@ class QlibQuantScenario(Scenario):
return model_output_format
def interface(self, tag=None) -> str:
assert tag in [None, "factor", "model"]
if tag not in [None, "factor", "model"]:
raise ValueError(f"tag must be None, 'factor', or 'model', got {tag!r}")
factor_interface = (
"The factor code should be written in the following interface:\n" + T(".prompts:qlib_factor_interface").r()
)
@@ -115,7 +118,8 @@ class QlibQuantScenario(Scenario):
return model_interface
def simulator(self, tag=None) -> str:
assert tag in [None, "factor", "model"]
if tag not in [None, "factor", "model"]:
raise ValueError(f"tag must be None, 'factor', or 'model', got {tag!r}")
factor_simulator = "The factor code will be sent to the simulator:\n" + T(".prompts:qlib_factor_simulator").r()
model_simulator = "The model code will be sent to the simulator:\n" + T(".prompts:qlib_model_simulator").r()
@@ -185,7 +189,8 @@ class QlibQuantScenario(Scenario):
return common_description(action) + interface(action) + output(action) + simulator(action)
def get_runtime_environment(self, tag: str = None) -> str:
assert tag in [None, "factor", "model"]
if tag not in [None, "factor", "model"]:
raise ValueError(f"tag must be None, 'factor', or 'model', got {tag!r}")
if tag is None or tag == "factor":
# Use factor env to get the runtime environment
+12 -10
View File
@@ -4,7 +4,7 @@ import shutil
from pathlib import Path
import pandas as pd
from jinja2 import Environment, StrictUndefined
from jinja2 import Environment, StrictUndefined, select_autoescape
from rdagent.components.coder.factor_coder.config import FACTOR_COSTEER_SETTINGS
from rdagent.utils.env import QTDockerEnv
@@ -21,14 +21,16 @@ def generate_data_folder_from_qlib():
entry=f"python generate.py",
)
assert (Path(__file__).parent / "factor_data_template" / "intraday_pv_all.h5").exists(), (
"intraday_pv_all.h5 is not generated. It means rdagent/scenarios/qlib/experiment/factor_data_template/generate.py is not executed correctly. Please check the log: \n"
+ execute_log
)
assert (Path(__file__).parent / "factor_data_template" / "intraday_pv_debug.h5").exists(), (
"intraday_pv_debug.h5 is not generated. It means rdagent/scenarios/qlib/experiment/factor_data_template/generate.py is not executed correctly. Please check the log: \n"
+ execute_log
)
if not (Path(__file__).parent / "factor_data_template" / "intraday_pv_all.h5").exists():
raise FileNotFoundError(
"intraday_pv_all.h5 is not generated. It means rdagent/scenarios/qlib/experiment/factor_data_template/generate.py is not executed correctly. Please check the log: \n"
+ execute_log
)
if not (Path(__file__).parent / "factor_data_template" / "intraday_pv_debug.h5").exists():
raise FileNotFoundError(
"intraday_pv_debug.h5 is not generated. It means rdagent/scenarios/qlib/experiment/factor_data_template/generate.py is not executed correctly. Please check the log: \n"
+ execute_log
)
Path(FACTOR_COSTEER_SETTINGS.data_folder).mkdir(parents=True, exist_ok=True)
shutil.copy(
@@ -67,7 +69,7 @@ def get_file_desc(p: Path, variable_list=[]) -> str:
"""
p = Path(p)
JJ_TPL = Environment(undefined=StrictUndefined).from_string("""
JJ_TPL = Environment(undefined=StrictUndefined, autoescape=select_autoescape()).from_string("""
# {{file_name}}
## File Type
@@ -1,4 +1,6 @@
import logging
import json
import os
from typing import List, Tuple
from rdagent.components.coder.factor_coder.factor import FactorExperiment, FactorTask
@@ -9,6 +11,47 @@ from rdagent.scenarios.qlib.experiment.model_experiment import QlibModelExperime
from rdagent.scenarios.qlib.experiment.quant_experiment import QlibQuantScenario
from rdagent.utils.agent.tpl import T
def _build_compressed_history(trace: Trace, max_history: int) -> str:
"""Return hypothesis_and_feedback string with only `max_history` entries.
Older entries beyond the last 2 are compressed to one bullet line each.
"""
if len(trace.hist) == 0:
return "No previous hypothesis and feedback available since it's the first round."
FULL_DETAIL = 2
old_hist = trace.hist[:-FULL_DETAIL] if len(trace.hist) > FULL_DETAIL else []
recent_hist = trace.hist[-FULL_DETAIL:] if len(trace.hist) > FULL_DETAIL else trace.hist
parts = []
if old_hist:
lines = ["## Earlier experiments (summarized):"]
for exp, fb in old_hist:
names = []
for task in exp.sub_tasks:
if task is not None and hasattr(task, "factor_name"):
names.append(task.factor_name)
elif task is not None and hasattr(task, "model_type"):
names.append(getattr(task, "model_type", "model"))
ic_str = ""
try:
if exp.result is not None and "IC" in exp.result.index:
ic_str = f" IC={exp.result.loc['IC']:.4f}"
except Exception:
logging.debug("Exception caught", exc_info=True)
decision = "PASS" if fb.decision else "FAIL"
obs = (fb.observations or "")[:120].replace("\n", " ")
lines.append(f"- [{decision}]{ic_str} {', '.join(names) or 'unknown'}: {obs}")
parts.append("\n".join(lines))
if recent_hist:
rt = Trace(trace.scen)
rt.hist = recent_hist
parts.append(T("scenarios.qlib.prompts:hypothesis_and_feedback").r(trace=rt))
return "\n\n".join(parts)
QlibFactorHypothesis = Hypothesis
@@ -17,13 +60,10 @@ class QlibFactorHypothesisGen(FactorHypothesisGen):
super().__init__(scen)
def prepare_context(self, trace: Trace) -> Tuple[dict, bool]:
hypothesis_and_feedback = (
T("scenarios.qlib.prompts:hypothesis_and_feedback").r(
trace=trace,
)
if len(trace.hist) > 0
else "No previous hypothesis and feedback available since it's the first round."
)
max_h = int(os.environ.get("QLIB_QUANT_MAX_FACTOR_HISTORY", "20"))
limited = Trace(trace.scen)
limited.hist = trace.hist[-max_h:] if len(trace.hist) > max_h else trace.hist
hypothesis_and_feedback = _build_compressed_history(limited, max_h)
last_hypothesis_and_feedback = (
T("scenarios.qlib.prompts:last_hypothesis_and_feedback").r(
experiment=trace.hist[-1][0], feedback=trace.hist[-1][1]
@@ -70,15 +110,15 @@ class QlibFactorHypothesis2Experiment(FactorHypothesis2Experiment):
if len(trace.hist) == 0:
hypothesis_and_feedback = "No previous hypothesis and feedback available since it's the first round."
else:
max_h = int(os.environ.get("QLIB_QUANT_MAX_FACTOR_HISTORY", "20"))
factor_hist = [
e for e in trace.hist
if not hasattr(e[0].hypothesis, "action") or e[0].hypothesis.action == "factor"
][-max_h:]
specific_trace = Trace(trace.scen)
for i in range(len(trace.hist) - 1, -1, -1):
if not hasattr(trace.hist[i][0].hypothesis, "action") or trace.hist[i][0].hypothesis.action == "factor":
specific_trace.hist.insert(0, trace.hist[i])
if len(specific_trace.hist) > 0:
specific_trace.hist.reverse()
hypothesis_and_feedback = T("scenarios.qlib.prompts:hypothesis_and_feedback").r(
trace=specific_trace,
)
specific_trace.hist = factor_hist
if specific_trace.hist:
hypothesis_and_feedback = _build_compressed_history(specific_trace, max_h)
else:
hypothesis_and_feedback = "No previous hypothesis and feedback available."
@@ -1,7 +1,7 @@
import json
import logging
import os
import random
from typing import Tuple
from rdagent.app.qlib_rd_loop.conf import QUANT_PROP_SETTING
from rdagent.components.proposal import FactorAndModelHypothesisGen
@@ -41,7 +41,7 @@ class QlibQuantHypothesis(Hypothesis):
action: str,
) -> None:
super().__init__(
hypothesis, reason, concise_reason, concise_observation, concise_justification, concise_knowledge
hypothesis, reason, concise_reason, concise_observation, concise_justification, concise_knowledge,
)
self.action = action
@@ -53,10 +53,10 @@ Reason: {self.reason}
class QlibQuantHypothesisGen(FactorAndModelHypothesisGen):
def __init__(self, scen: Scenario) -> Tuple[dict, bool]:
def __init__(self, scen: Scenario) -> None:
super().__init__(scen)
def prepare_context(self, trace: Trace) -> Tuple[dict, bool]:
def prepare_context(self, trace: Trace) -> tuple[dict, bool]:
# ========= Bandit ==========
if QUANT_PROP_SETTING.action_selection == "bandit":
@@ -84,7 +84,7 @@ class QlibQuantHypothesisGen(FactorAndModelHypothesisGen):
last_hypothesis_and_feedback = (
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],
)
if len(trace.hist) > 0
else "No previous hypothesis and feedback available since it's the first round."
@@ -152,9 +152,41 @@ class QlibQuantHypothesisGen(FactorAndModelHypothesisGen):
factor_inserted = True
if len(specific_trace.hist) > 0:
specific_trace.hist.reverse()
hypothesis_and_feedback = T("scenarios.qlib.prompts:hypothesis_and_feedback").r(
trace=specific_trace,
)
# Keep only the 2 most recent experiments in full detail; compress older ones
# to brief bullet points to stay within the LLM context window.
FULL_DETAIL_COUNT = 2
old_hist = specific_trace.hist[:-FULL_DETAIL_COUNT] if len(specific_trace.hist) > FULL_DETAIL_COUNT else []
recent_hist = specific_trace.hist[-FULL_DETAIL_COUNT:] if len(specific_trace.hist) > FULL_DETAIL_COUNT else specific_trace.hist
parts = []
if old_hist:
summary_lines = ["## Earlier experiments (summarized):"]
for exp, fb in old_hist:
factor_names = []
for task in exp.sub_tasks:
if task is not None and hasattr(task, "factor_name"):
factor_names.append(task.factor_name)
elif task is not None and hasattr(task, "model_type"):
factor_names.append(getattr(task, "model_type", "model"))
names_str = ", ".join(factor_names) if factor_names else "unknown"
ic_str = ""
try:
if exp.result is not None:
ic_val = exp.result.loc["IC"] if "IC" in exp.result.index else ""
ic_str = f" IC={ic_val:.4f}" if ic_val != "" else ""
except Exception:
logging.debug("Error getting IC", exc_info=True)
decision_str = "PASS" if fb.decision else "FAIL"
obs_short = (fb.observations or "")[:120].replace("\n", " ")
summary_lines.append(f"- [{decision_str}]{ic_str} {names_str}: {obs_short}")
parts.append("\n".join(summary_lines))
if recent_hist:
recent_trace = Trace(specific_trace.scen)
recent_trace.hist = recent_hist
parts.append(T("scenarios.qlib.prompts:hypothesis_and_feedback").r(trace=recent_trace))
hypothesis_and_feedback = "\n\n".join(parts)
else:
hypothesis_and_feedback = "No previous hypothesis and feedback available."
@@ -162,7 +194,7 @@ class QlibQuantHypothesisGen(FactorAndModelHypothesisGen):
for i in range(len(trace.hist) - 1, -1, -1):
if trace.hist[i][0].hypothesis.action == action:
last_hypothesis_and_feedback = T("scenarios.qlib.prompts:last_hypothesis_and_feedback").r(
experiment=trace.hist[i][0], feedback=trace.hist[i][1]
experiment=trace.hist[i][0], feedback=trace.hist[i][1],
)
break
@@ -171,7 +203,7 @@ class QlibQuantHypothesisGen(FactorAndModelHypothesisGen):
for i in range(len(trace.hist) - 1, -1, -1):
if trace.hist[i][0].hypothesis.action == "model" and trace.hist[i][1].decision is True:
sota_hypothesis_and_feedback = T("scenarios.qlib.prompts:sota_hypothesis_and_feedback").r(
experiment=trace.hist[i][0], feedback=trace.hist[i][1]
experiment=trace.hist[i][0], feedback=trace.hist[i][1],
)
break
@@ -77,6 +77,7 @@ def count_valid_factors() -> int:
if data.get("status") == "success" and data.get("ic") is not None:
count += 1
except Exception:
logger.warning("Failed to load factor file %s", json_file, exc_info=True)
continue
return count
@@ -71,7 +71,7 @@ def submit_for_grading(grading_url: str, model_path: str) -> dict | None:
def main():
MODEL_PATH = os.environ.get("MODEL_PATH")
DATA_PATH = os.environ.get("DATA_PATH")
OUTPUT_DIR = os.environ.get("OUTPUT_DIR", "/tmp/autorl_output")
OUTPUT_DIR = os.environ.get("OUTPUT_DIR", "/tmp/autorl_output") # nosec B108 — Docker container output dir, configurable via env var
GRADING_SERVER_URL = os.environ.get("GRADING_SERVER_URL", "")
TRAIN_RATIO = float(os.environ.get("TRAIN_RATIO", "0.05"))
NUM_EPOCHS = int(os.environ.get("NUM_EPOCHS", "3"))
@@ -391,7 +391,7 @@ def set_baseline():
return jsonify({"baseline_score": score, "status": "set"})
def run_server(task: str, base_model: str, workspace: str, host: str = "0.0.0.0", port: int = 5000):
def run_server(task: str, base_model: str, workspace: str, host: str = "127.0.0.1", port: int = 5000):
"""启动服务器"""
init_server(task, base_model, workspace)
logger.info(f"Grading Server | task={task} | {host}:{port}")
@@ -435,7 +435,7 @@ class LocalServerContext(GradingServerContext):
logger.info(f"[Local Mode] Starting evaluation server on port {self.port}...")
self.server = init_server(self.task, self.base_model, self.workspace)
self._http_server = make_server("0.0.0.0", self.port, app, threaded=True)
self._http_server = make_server("0.0.0.0", self.port, app, threaded=True) # nosec B104 — intentional: Docker sandbox requires all-interface binding
self._thread = threading.Thread(target=self._http_server.serve_forever, daemon=True)
self._thread.start()
@@ -488,7 +488,7 @@ if __name__ == "__main__":
parser.add_argument("--base-model", type=str, default="")
parser.add_argument("--workspace", type=str, default=".")
parser.add_argument("--port", type=int, default=5000)
parser.add_argument("--host", type=str, default="0.0.0.0")
parser.add_argument("--host", type=str, default="127.0.0.1")
args = parser.parse_args()
run_server(args.task, args.base_model, args.workspace, args.host, args.port)
@@ -9,7 +9,7 @@ peft>=0.18.1
# Evaluation
opencompass==0.5.1
setuptools<75 # uv venv doesn't include, opencompass depends on pkg_resources
setuptools>=78.1.1 # Security fix: GHSA-8g6x-3r52-4m6c (path traversal in PackageIndex.download, arbitrary file write/RCE)
# Inference acceleration (optional, TRL supports 0.10.2-0.12.0)
# Security: Version >=0.14.0 fixes CVE-2026-22807 (RCE via auto_map dynamic module loading)
+4 -3
View File
@@ -9,7 +9,7 @@ from pathlib import Path
from typing import Any
import yaml
from jinja2 import Environment, FunctionLoader, StrictUndefined
from jinja2 import Environment, FunctionLoader, StrictUndefined, select_autoescape
from rdagent.core.conf import RD_AGENT_SETTINGS
from rdagent.log import rdagent_logger as logger
@@ -38,7 +38,8 @@ def load_content(uri: str, caller_dir: Path | None = None, ftype: str = "yaml")
caller_dir = get_caller_dir(upshift=1)
# Parse the URI
path_part, *yaml_trace = uri.split(":")
assert len(yaml_trace) <= 1, f"Invalid uri {uri}, only one yaml trace is allowed."
if len(yaml_trace) > 1:
raise ValueError(f"Invalid uri {uri}, only one yaml trace is allowed.")
yaml_trace = [key for yt in yaml_trace for key in yt.split(".")]
# load file_path with priorities.
@@ -126,7 +127,7 @@ class RDAT:
# loader=FunctionLoader(load_conent) is for supporting grammar like below.
# `{% include "scenarios.data_science.share:component_spec.DataLoadSpec" %}`
rendered = (
Environment(undefined=StrictUndefined, loader=FunctionLoader(load_content))
Environment(undefined=StrictUndefined, loader=FunctionLoader(load_content), autoescape=select_autoescape())
.from_string(self.template)
.render(**context)
.strip("\n")
+46 -42
View File
@@ -436,13 +436,10 @@ class Env(Generic[ASpecificEnvConf]):
else:
timeout_cmd = f"timeout --kill-after=10 {self.conf.running_timeout_period} {entry}"
entry_add_timeout = (
f"/bin/sh -c '" # start of the sh command
+ f"{timeout_cmd}; entry_exit_code=$?; "
"/bin/sh -c '" # start of the sh command
+ timeout_cmd.replace("'", "'\\''") + "; entry_exit_code=$?; "
+ (
f"{_get_chmod_cmd(self.conf.mount_path)}; "
# We don't have to change the permission of the cache and input folder to remove it
# + f"if [ -d {self.conf.mount_path}/cache ]; then chmod 777 {self.conf.mount_path}/cache; fi; " +
# f"if [ -d {self.conf.mount_path}/input ]; then chmod 777 {self.conf.mount_path}/input; fi; "
if isinstance(self.conf, DockerConf)
else ""
)
@@ -614,13 +611,14 @@ class LocalEnv(Env[ASpecificLocalConf]):
if self.conf.extra_volumes is not None:
for lp, rp in self.conf.extra_volumes.items():
volumes[lp] = rp["bind"] if isinstance(rp, dict) else rp
cache_path = "/tmp/sample" if "/sample/" in "".join(self.conf.extra_volumes.keys()) else "/tmp/full"
cache_path = "/tmp/sample" if "/sample/" in "".join(self.conf.extra_volumes.keys()) else "/tmp/full" # nosec B108 — fixed Docker volume mount point, not a user-writable temp file
Path(cache_path).mkdir(parents=True, exist_ok=True)
volumes[cache_path] = T("scenarios.data_science.share:scen.cache_path").r()
for lp, rp in running_extra_volume.items():
volumes[lp] = rp
assert local_path is not None, "local_path should not be None"
if local_path is None:
raise ValueError("local_path should not be None")
volumes = normalize_volumes(volumes, local_path)
@contextlib.contextmanager
@@ -678,7 +676,7 @@ class LocalEnv(Env[ASpecificLocalConf]):
cwd = Path(local_path).resolve() if local_path else None
env = {k: str(v) if isinstance(v, int) else v for k, v in env.items()}
process = subprocess.Popen(
process = subprocess.Popen( # nosec B602 — entry is an internal command string set by LocalEnvConf, not user input
entry,
cwd=cwd,
env={**os.environ, **env},
@@ -761,12 +759,15 @@ class CondaConf(LocalConf):
to ensure bin_path is set correctly even if the conda env was just created.
"""
conda_path_result = subprocess.run(
f"conda run -n {self.conda_env_name} --no-capture-output env | grep '^PATH='",
["conda", "run", "-n", self.conda_env_name, "--no-capture-output", "env"],
capture_output=True,
text=True,
shell=True,
)
self.bin_path = conda_path_result.stdout.strip().split("=")[1] if conda_path_result.returncode == 0 else ""
if conda_path_result.returncode == 0:
path_lines = [l for l in conda_path_result.stdout.splitlines() if l.startswith("PATH=")]
self.bin_path = path_lines[0].split("=", 1)[1] if path_lines else ""
else:
self.bin_path = ""
class MLECondaConf(CondaConf):
@@ -850,24 +851,22 @@ class QlibCondaEnv(LocalEnv[QlibCondaConf]):
def prepare(self) -> None:
"""Prepare the conda environment if not already created."""
try:
envs = subprocess.run("conda env list", capture_output=True, text=True, shell=True)
envs = subprocess.run(["conda", "env", "list"], capture_output=True, text=True)
if self.conf.conda_env_name not in envs.stdout:
print(f"[yellow]Conda env '{self.conf.conda_env_name}' not found, creating...[/yellow]")
subprocess.check_call(
f"conda create -y -n {self.conf.conda_env_name} python=3.10",
shell=True,
["conda", "create", "-y", "-n", self.conf.conda_env_name, "python=3.10"],
)
subprocess.check_call(
f"conda run -n {self.conf.conda_env_name} pip install --upgrade pip cython",
shell=True,
["conda", "run", "-n", self.conf.conda_env_name, "pip", "install", "--upgrade", "pip", "cython"],
)
subprocess.check_call(
f"conda run -n {self.conf.conda_env_name} pip install git+https://github.com/microsoft/qlib.git@2fb9380b342556ddb50a4b24e4fe8655d548b2b8",
shell=True,
["conda", "run", "-n", self.conf.conda_env_name, "pip", "install",
"git+https://github.com/microsoft/qlib.git@2fb9380b342556ddb50a4b24e4fe8655d548b2b8"],
)
subprocess.check_call(
f"conda run -n {self.conf.conda_env_name} pip install catboost xgboost tables torch",
shell=True,
["conda", "run", "-n", self.conf.conda_env_name, "pip", "install",
"catboost", "xgboost", "tables", "torch"],
)
except Exception as e:
@@ -888,10 +887,9 @@ def _sync_conda_cache_with_real_envs() -> None:
"""Ensure the prepared cache includes environments that already exist on disk."""
try:
result = subprocess.run(
"conda env list",
["conda", "env", "list"],
capture_output=True,
text=True,
shell=True,
check=False,
)
except Exception as exc: # pragma: no cover - best-effort helper
@@ -924,14 +922,19 @@ def _prepare_conda_env(env_name: str, requirements_file: Path, python_version: s
python_version: Python version for the environment
"""
# 1. Create conda environment if not exists
result = subprocess.run(f"conda env list | grep -q '^{env_name} '", shell=True)
if result.returncode != 0:
env_list = subprocess.run(["conda", "env", "list"], capture_output=True, text=True, check=False)
env_exists = any(
line.split()[0] == env_name
for line in env_list.stdout.splitlines()
if line and not line.startswith("#") and len(line.split()) > 0
)
if not env_exists:
print(f"[yellow]Creating conda env '{env_name}' (Python {python_version})...[/yellow]")
subprocess.check_call(f"conda create -y -n {env_name} python={python_version}", shell=True)
subprocess.check_call(f"conda run -n {env_name} pip install --upgrade pip", shell=True)
subprocess.check_call(["conda", "create", "-y", "-n", env_name, f"python={python_version}"])
subprocess.check_call(["conda", "run", "-n", env_name, "pip", "install", "--upgrade", "pip"])
print(f"[yellow]Installing dependencies from {requirements_file.name}...[/yellow]")
subprocess.check_call(f"conda run -n {env_name} pip install -r {requirements_file}", shell=True)
subprocess.check_call(["conda", "run", "-n", env_name, "pip", "install", "-r", str(requirements_file)])
print(f"[green]Conda env '{env_name}' ready[/green]")
_CONDA_ENV_PREPARED.add(env_name)
@@ -971,8 +974,8 @@ class FTCondaEnv(LocalEnv[FTCondaConf]):
# Note: flash-attn>=2.8 is required for B200 (sm_100) support
print("[yellow]Installing flash-attn (compiling, may take a few minutes)...[/yellow]")
subprocess.check_call(
f"conda run -n {self.conf.conda_env_name} pip install 'flash-attn>=2.8' --no-build-isolation --no-cache-dir",
shell=True,
["conda", "run", "-n", self.conf.conda_env_name, "pip", "install",
"flash-attn>=2.8", "--no-build-isolation", "--no-cache-dir"],
)
# Re-update bin_path after prepare() in case the conda env was just created
@@ -1190,7 +1193,7 @@ class DockerEnv(Env[DockerConf]):
with Progress(SpinnerColumn(), TextColumn("{task.description}")) as p:
task = p.add_task("[cyan]Building image...")
for part in resp_stream:
lines = part.decode("utf-8").split("\r\n")
lines = part.decode("utf-8", errors="replace").split("\r\n")
for line in lines:
if line.strip():
status_dict = json.loads(line)
@@ -1442,7 +1445,7 @@ class DockerEnv(Env[DockerConf]):
if self.conf.extra_volumes is not None:
for lp, rp in self.conf.extra_volumes.items():
volumes[lp] = rp if isinstance(rp, dict) else {"bind": rp, "mode": self.conf.extra_volume_mode}
cache_path = "/tmp/sample" if "/sample/" in "".join(self.conf.extra_volumes.keys()) else "/tmp/full"
cache_path = "/tmp/sample" if "/sample/" in "".join(self.conf.extra_volumes.keys()) else "/tmp/full" # nosec B108 — fixed Docker volume mount point, not a user-writable temp file
Path(cache_path).mkdir(parents=True, exist_ok=True)
volumes[cache_path] = {
"bind": T("scenarios.data_science.share:scen.cache_path").r(),
@@ -1471,7 +1474,8 @@ class DockerEnv(Env[DockerConf]):
cpu_count=self.conf.cpu_count, # Set CPU limit
**self._gpu_kwargs(client),
)
assert container is not None # Ensure container was created successfully
if container is None:
raise AssertionError("Docker container was not created successfully")
logs = container.logs(stream=True)
print(Rule("[bold green]Docker Logs Begin[/bold green]", style="dark_orange"))
table = Table(title="Run Info", show_header=False)
@@ -1521,8 +1525,8 @@ class DockerEnv(Env[DockerConf]):
class QTDockerEnv(DockerEnv):
"""Qlib Torch Docker"""
def __init__(self, conf: DockerConf = QlibDockerConf()):
super().__init__(conf)
def __init__(self, conf: DockerConf | None = None):
super().__init__(conf if conf is not None else QlibDockerConf())
def prepare(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
"""
@@ -1541,15 +1545,15 @@ class QTDockerEnv(DockerEnv):
class KGDockerEnv(DockerEnv):
"""Kaggle Competition Docker"""
def __init__(self, competition: str | None = None, conf: DockerConf = KGDockerConf()):
super().__init__(conf)
def __init__(self, competition: str | None = None, conf: DockerConf | None = None):
super().__init__(conf if conf is not None else KGDockerConf())
class MLEBDockerEnv(DockerEnv):
"""MLEBench Docker"""
def __init__(self, conf: DockerConf = MLEBDockerConf()):
super().__init__(conf)
def __init__(self, conf: DockerConf | None = None):
super().__init__(conf if conf is not None else MLEBDockerConf())
class FTDockerEnv(DockerEnv):
@@ -1565,8 +1569,8 @@ class FTDockerEnv(DockerEnv):
export FT_DOCKER_save_logs_to_file=false # disable log file
"""
def __init__(self, conf: DockerConf = FTDockerConf()):
super().__init__(conf)
def __init__(self, conf: DockerConf | None = None):
super().__init__(conf if conf is not None else FTDockerConf())
class BenchmarkDockerEnv(DockerEnv):
@@ -1583,5 +1587,5 @@ class BenchmarkDockerEnv(DockerEnv):
export BENCHMARK_DOCKER_terminal_tail_lines=100 # show last 100 lines
"""
def __init__(self, conf: DockerConf = BenchmarkDockerConf()):
super().__init__(conf)
def __init__(self, conf: DockerConf | None = None):
super().__init__(conf if conf is not None else BenchmarkDockerConf())
+28 -24
View File
@@ -15,19 +15,19 @@ import multiprocessing.queues
import os
import pickle
from collections import defaultdict
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, Optional, Union, cast
from typing import Any, cast
import psutil
from tqdm.auto import tqdm
from rdagent.core.conf import RD_AGENT_SETTINGS
from rdagent.log import rdagent_logger as logger
from rdagent.log.conf import LOG_SETTINGS
from rdagent.log.timer import RD_Agent_TIMER_wrapper, RDAgentTimer
from rdagent.utils.workflow.tracking import WorkflowTracker
from tqdm.auto import tqdm
class LoopMeta(type):
@@ -98,7 +98,7 @@ class LoopBase:
skip_loop_error: tuple[type[BaseException], ...] = () # you can define a list of error that will skip current loop
skip_loop_error_stepname: str | None = None # if skip_loop_error exception happens, what's the next step to work on
withdraw_loop_error: tuple[
type[BaseException], ...
type[BaseException], ...,
] = () # you can define a list of error that will withdraw current loop
EXCEPTION_KEY = "_EXCEPTION"
@@ -129,8 +129,8 @@ class LoopBase:
self.tracker = WorkflowTracker(self) # Initialize tracker with this LoopBase instance
# progress control
self.loop_n: Optional[int] = None # remain loop count
self.step_n: Optional[int] = None # remain step count
self.loop_n: int | None = None # remain loop count
self.step_n: int | None = None # remain step count
self.semaphores: dict[str, asyncio.Semaphore] = {}
@@ -169,7 +169,7 @@ class LoopBase:
self._pbar.close()
del self._pbar
def _check_exit_conditions_on_step(self, loop_id: Optional[int] = None, step_id: Optional[int] = None) -> None:
def _check_exit_conditions_on_step(self, loop_id: int | None = None, step_id: int | None = None) -> None:
"""Check if the loop should continue or terminate.
Raises
@@ -188,8 +188,7 @@ class LoopBase:
if self.timer.is_timeout():
logger.warning("Timeout, exiting the loop.")
raise self.LoopTerminationError("Timer timeout")
else:
logger.info(f"Timer remaining time: {self.timer.remain_time()}")
logger.info(f"Timer remaining time: {self.timer.remain_time()}")
async def _run_step(self, li: int, force_subproc: bool = False) -> None:
"""Execute a single step (next unrun step) in the workflow (async version with force_subproc option).
@@ -217,7 +216,7 @@ class LoopBase:
with logger.tag(f"Loop_{li}.{name}"):
start = datetime.now(timezone.utc)
func: Callable[..., Any] = cast(Callable[..., Any], getattr(self, name))
func: Callable[..., Any] = cast("Callable[..., Any]", getattr(self, name))
next_step_idx = si + 1
step_forward = True
@@ -233,15 +232,14 @@ class LoopBase:
# Using deepcopy is to avoid triggering errors like "RuntimeError: dictionary changed size during iteration"
# GUESS: Some content in self.loop_prev_out[li] may be in the middle of being changed.
result = await curr_loop.run_in_executor(
pool, copy.deepcopy(func), copy.deepcopy(self.loop_prev_out[li])
pool, copy.deepcopy(func), copy.deepcopy(self.loop_prev_out[li]),
)
# auto determine whether to run async or sync
elif asyncio.iscoroutinefunction(func):
result = await func(self.loop_prev_out[li])
else:
# auto determine whether to run async or sync
if asyncio.iscoroutinefunction(func):
result = await func(self.loop_prev_out[li])
else:
# Default: run sync function directly
result = func(self.loop_prev_out[li])
# Default: run sync function directly
result = func(self.loop_prev_out[li])
# Store result in the nested dictionary
self.loop_prev_out[li][name] = result
except Exception as e:
@@ -251,14 +249,13 @@ class LoopBase:
next_step_idx = self.steps.index(self.skip_loop_error_stepname)
if next_step_idx <= si:
raise RuntimeError(
f"Cannot skip backwards or to same step. Current: {si} ({name}), Target: {next_step_idx} ({self.skip_loop_error_stepname})"
f"Cannot skip backwards or to same step. Current: {si} ({name}), Target: {next_step_idx} ({self.skip_loop_error_stepname})",
) from e
# Default: jump to feedback step if exists, otherwise jump to the last step (record)
elif "feedback" in self.steps:
next_step_idx = self.steps.index("feedback")
else:
# Default: jump to feedback step if exists, otherwise jump to the last step (record)
if "feedback" in self.steps:
next_step_idx = self.steps.index("feedback")
else:
next_step_idx = len(self.steps) - 1
next_step_idx = len(self.steps) - 1
self.loop_prev_out[li][name] = None
self.loop_prev_out[li][self.EXCEPTION_KEY] = e
elif isinstance(e, self.withdraw_loop_error):
@@ -270,6 +267,11 @@ class LoopBase:
msg = "We have reset the loop instance, stop all the routines and resume."
raise self.LoopResumeError(msg) from e
else:
# Do NOT advance step_idx for unhandled exceptions (e.g. LoopResumeError
# propagating from _propose). Keeping step_idx at the current step lets
# kickoff_loop retry step 0 on the next resume instead of permanently
# corrupting the loop with a missing direct_exp_gen result.
step_forward = False
raise # re-raise unhandled exceptions
finally:
# No matter the execution succeed or not, we have to finish the following steps
@@ -404,6 +406,8 @@ class LoopBase:
self.close_pbar()
def withdraw_loop(self, loop_idx: int) -> None:
if loop_idx <= 0:
raise RuntimeError(f"Cannot withdraw loop {loop_idx}: no previous loop exists.")
prev_session_dir = self.session_folder / str(loop_idx - 1)
prev_path = min(
(p for p in prev_session_dir.glob("*_*") if p.is_file()),
@@ -496,7 +500,7 @@ class LoopBase:
session_folder = path.parent.parent
with path.open("rb") as f:
session = cast(LoopBase, pickle.load(f))
session = cast("LoopBase", pickle.load(f))
# set session folder
if checkout:
+2 -1
View File
@@ -30,7 +30,8 @@ def wait_retry(
>>> counter
2
"""
assert retry_n > 0, "retry_n should be greater than 0"
if retry_n <= 0:
raise ValueError("retry_n should be greater than 0")
def decorator(f: Callable[..., ASpecificRet]) -> Callable[..., ASpecificRet]:
def wrapper(*args: Any, **kwargs: Any) -> ASpecificRet:
+8 -7
View File
@@ -9,7 +9,6 @@ import datetime
from typing import TYPE_CHECKING
import pytz
from rdagent.core.conf import RD_AGENT_SETTINGS
from rdagent.log.timer import RD_Agent_TIMER_wrapper
@@ -84,12 +83,14 @@ class WorkflowTracker:
# Log timer status if timer is started
if self.loop_base.timer.started:
remain_time = self.loop_base.timer.remain_time()
assert remain_time is not None
mlflow.log_metric("remain_time", remain_time.total_seconds())
mlflow.log_metric(
"remain_percent",
remain_time / self.loop_base.timer.all_duration * 100,
)
if remain_time is None:
logger.warning("remain_time is None despite timer.started, skipping timer metrics")
else:
mlflow.log_metric("remain_time", remain_time.total_seconds())
mlflow.log_metric(
"remain_percent",
remain_time / self.loop_base.timer.all_duration * 100,
)
# Keep only the log_workflow_state method as it's the primary entry point now
except Exception as e:

Some files were not shown because too many files have changed in this diff Show More