Compare commits

...

33 Commits

Author SHA1 Message Date
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
38 changed files with 1597 additions and 125 deletions
+3 -3
View File
@@ -14,7 +14,7 @@ jobs:
security: security:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- name: Run Bandit (Security Scan) - name: Run Bandit (Security Scan)
uses: PyCQA/bandit-action@v1 uses: PyCQA/bandit-action@v1
@@ -25,9 +25,9 @@ jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- uses: actions/setup-python@v5 - uses: actions/setup-python@v6
with: with:
python-version: "3.10" python-version: "3.10"
cache: "pip" cache: "pip"
+2 -2
View File
@@ -36,11 +36,11 @@ jobs:
steps: steps:
# Checkout the repository to the GitHub Actions runner # Checkout the repository to the GitHub Actions runner
- name: Checkout code - 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 # Execute Codacy Analysis CLI and generate a SARIF output with the security issues identified during the analysis
- name: Run Codacy Analysis CLI - name: Run Codacy Analysis CLI
uses: codacy/codacy-analysis-cli-action@d840f886c4bd4edc059706d09c6a1586111c540b uses: codacy/codacy-analysis-cli-action@562ee3e92b8e92df8b67e0a5ff8aa8e261919c08
env: env:
JAVA_TOOL_OPTIONS: "-Dfile.encoding=UTF-8" JAVA_TOOL_OPTIONS: "-Dfile.encoding=UTF-8"
with: with:
+1 -1
View File
@@ -46,7 +46,7 @@ jobs:
name: Validate Commit Messages name: Validate Commit Messages
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
+3 -3
View File
@@ -25,10 +25,10 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v6
with: with:
python-version: "3.10" python-version: "3.10"
@@ -64,7 +64,7 @@ jobs:
- name: Upload docs artifact - name: Upload docs artifact
if: github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/main'
uses: actions/upload-pages-artifact@v3 uses: actions/upload-pages-artifact@v5
with: with:
path: docs/_build/html path: docs/_build/html
+2 -2
View File
@@ -16,10 +16,10 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v6
with: with:
python-version: "3.10" python-version: "3.10"
+4 -4
View File
@@ -19,9 +19,9 @@ jobs:
python-version: ["3.10", "3.11"] python-version: ["3.10", "3.11"]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- uses: actions/setup-python@v5 - uses: actions/setup-python@v6
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
cache: "pip" cache: "pip"
@@ -49,9 +49,9 @@ jobs:
name: Dependency Audit name: Dependency Audit
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- uses: actions/setup-python@v5 - uses: actions/setup-python@v6
with: with:
python-version: "3.10" python-version: "3.10"
cache: "pip" cache: "pip"
+2 -2
View File
@@ -19,10 +19,10 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v6
with: with:
python-version: "3.10" python-version: "3.10"
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
".": "1.2.2" ".": "1.3.5"
} }
+77
View File
@@ -1,5 +1,82 @@
# Changelog # Changelog
## [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) ## [1.2.2](https://github.com/TPTBusiness/Predix/compare/v1.2.1...v1.2.2) (2026-04-19)
+1 -1
View File
@@ -68,7 +68,7 @@ ignore_missing_imports = true
module = "llama" module = "llama"
[tool.pytest.ini_options] [tool.pytest.ini_options]
addopts = "-l -s --durations=0" addopts = "-l -s --durations=0 -m 'not slow'"
log_cli = true log_cli = true
log_cli_level = "info" log_cli_level = "info"
log_date_format = "%Y-%m-%d %H:%M:%S" log_date_format = "%Y-%m-%d %H:%M:%S"
@@ -11,16 +11,23 @@ from .vbt_backtest import (
FTMO_MAX_LEVERAGE, FTMO_MAX_LEVERAGE,
FTMO_RISK_PER_TRADE, FTMO_RISK_PER_TRADE,
OOS_START_DEFAULT, OOS_START_DEFAULT,
WF_IS_YEARS,
WF_OOS_YEARS,
WF_STEP_YEARS,
backtest_from_forward_returns, backtest_from_forward_returns,
backtest_signal, backtest_signal,
backtest_signal_ftmo, backtest_signal_ftmo,
monte_carlo_trade_pvalue,
walk_forward_rolling,
) )
__all__ = [ __all__ = [
'BacktestMetrics', 'FactorBacktester', 'ResultsDatabase', 'BacktestMetrics', 'FactorBacktester', 'ResultsDatabase',
'CorrelationAnalyzer', 'PortfolioOptimizer', 'AdvancedRiskManager', 'CorrelationAnalyzer', 'PortfolioOptimizer', 'AdvancedRiskManager',
'backtest_signal', 'backtest_signal_ftmo', 'backtest_from_forward_returns', 'backtest_signal', 'backtest_signal_ftmo', 'backtest_from_forward_returns',
'monte_carlo_trade_pvalue', 'walk_forward_rolling',
'DEFAULT_BARS_PER_YEAR', 'DEFAULT_TXN_COST_BPS', 'DEFAULT_BARS_PER_YEAR', 'DEFAULT_TXN_COST_BPS',
'FTMO_INITIAL_CAPITAL', 'FTMO_MAX_DAILY_LOSS', 'FTMO_MAX_TOTAL_LOSS', 'FTMO_INITIAL_CAPITAL', 'FTMO_MAX_DAILY_LOSS', 'FTMO_MAX_TOTAL_LOSS',
'FTMO_MAX_LEVERAGE', 'FTMO_RISK_PER_TRADE', 'OOS_START_DEFAULT', 'FTMO_MAX_LEVERAGE', 'FTMO_RISK_PER_TRADE', 'OOS_START_DEFAULT',
'WF_IS_YEARS', 'WF_OOS_YEARS', 'WF_STEP_YEARS',
] ]
@@ -342,6 +342,129 @@ def _apply_ftmo_mask(
OOS_START_DEFAULT = "2024-01-01" 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( def backtest_signal_ftmo(
close: pd.Series, close: pd.Series,
@@ -354,6 +477,8 @@ def backtest_signal_ftmo(
bars_per_year: int = DEFAULT_BARS_PER_YEAR, bars_per_year: int = DEFAULT_BARS_PER_YEAR,
forward_returns: Optional[pd.Series] = None, forward_returns: Optional[pd.Series] = None,
oos_start: Optional[str] = OOS_START_DEFAULT, oos_start: Optional[str] = OOS_START_DEFAULT,
wf_rolling: bool = False,
mc_n_permutations: int = 0,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
FTMO-compliant backtest of a strategy signal on EUR/USD. FTMO-compliant backtest of a strategy signal on EUR/USD.
@@ -385,6 +510,13 @@ def backtest_signal_ftmo(
Maximum leverage (default 30 = FTMO 1:30). Maximum leverage (default 30 = FTMO 1:30).
oos_start : str or None oos_start : str or None
Start of out-of-sample period (ISO date). None disables OOS split. 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 stop_price = stop_pips * FTMO_PIP
leverage_by_risk = risk_pct / (stop_price / eurusd_price) leverage_by_risk = risk_pct / (stop_price / eurusd_price)
@@ -440,6 +572,28 @@ def backtest_signal_ftmo(
result["is_n_bars"] = int(is_mask.sum()) result["is_n_bars"] = int(is_mask.sum())
result["oos_n_bars"] = int(oos_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 return result
@@ -51,13 +51,23 @@ class FactorAutoFixer:
self.fixes_applied = [] self.fixes_applied = []
fixed_code = code fixed_code = code
# Apply fixes in order - groupby fixes MUST come before min_periods fixes # Apply fixes in order
# NOTE: _fix_min_periods is intentionally excluded — it increased min_periods to
# match window size, which causes all-NaN output for intraday data with 96 bars/day
# (window=240 > 96 means zero valid bars per day). The LLM sets its own min_periods.
fix_methods = [ fix_methods = [
self._fix_groupby_apply_to_transform, # First: fix groupby patterns self._fix_instrument_column_access, # First: fix df['instrument'] on MultiIndex
self._fix_min_periods, # Second: fix min_periods in resulting rolling calls self._fix_instrument_loc_multiindex, # Second: fix df.loc[instrument_var] on MultiIndex
self._fix_inf_nan_handling, # Third: add inf/nan handling self._fix_zero_volume_proxy, # Third: replace zero $volume with range proxy
self._fix_data_range_processing, # Fourth: ensure full data range self._fix_reset_index_groupby, # Fourth: fix groupby(level=N) after reset_index()
self._fix_multiindex_groupby, # Fifth: ensure groupby on MultiIndex self._fix_groupby_mixed_levels, # Fifth: fix groupby(level=[int, str])
self._fix_groupby_column_on_multiindex, # Sixth: fix groupby(['instrument','date']) on MultiIndex
self._fix_chained_groupby, # Seventh: fix groupby(level=N).groupby('date') chain
self._fix_rolling_ddof, # Eighth: remove unsupported ddof kwarg
self._fix_groupby_apply_to_transform, # Ninth: fix groupby patterns
self._fix_inf_nan_handling, # Tenth: add inf/nan handling
self._fix_data_range_processing, # Eleventh: ensure full data range
self._fix_multiindex_groupby, # Twelfth: ensure groupby on MultiIndex
] ]
for fix_method in fix_methods: for fix_method in fix_methods:
@@ -75,6 +85,352 @@ class FactorAutoFixer:
return fixed_code return fixed_code
def _fix_instrument_column_access(self, code: str) -> str:
"""
Fix: df['instrument'] raises KeyError on a MultiIndex DataFrame because
'instrument' is an index level (level 1), not a column.
Replace df['instrument'] with df.index.get_level_values('instrument')
but only when the DataFrame has a MultiIndex (not after reset_index which
would have promoted it to a real column).
Also fixes df.reset_index()['instrument'] correctly since after reset_index
the column exists.
"""
fixed_code = code
# Skip if already fixed or if reset_index() is being used before the access
# We only fix bare df['instrument'] where df is the original MultiIndex frame.
# Heuristic: if the assignment lhs or context shows reset_index, leave it alone.
# Pattern: <varname>['instrument'] where varname is NOT a reset_index result
reset_vars = set(re.findall(r'(\w+)\s*=\s*\w[^=\n]*\.reset_index\(', fixed_code))
def _replace_instrument_access(m: re.Match) -> str:
var = m.group(1)
if var in reset_vars:
return m.group(0) # leave reset_index vars alone — column exists
self.fixes_applied.append(f"instrument_column: {var}['instrument'] → get_level_values(1)")
return f"{var}.index.get_level_values(1)"
# Exclude assignment targets: var['instrument'] = ... must not become
# var.index.get_level_values(1) = ... (SyntaxError: cannot assign to function call)
fixed_code = re.sub(r"(\w+)\['instrument'\](?!\s*=)", _replace_instrument_access, fixed_code)
return fixed_code
def _fix_instrument_loc_multiindex(self, code: str) -> str:
"""
Fix: df.loc[instrument_var] raises DateParseError on a (datetime, instrument)
MultiIndex because pandas tries to match the instrument string against the
datetime level (level 0).
Pattern detected: for-loops iterating over get_level_values('instrument') or
get_level_values(1) where the loop variable is then used as df.loc[loop_var].
Replacement: df.loc[instrument_var] → df.xs(instrument_var, level=1)
"""
fixed_code = code
# Find variables iterated from get_level_values('instrument') or get_level_values(1)
inst_vars = set(
re.findall(
r"for\s+(\w+)\s+in\s+.+?\.get_level_values\s*\(\s*(?:1|['\"]instrument['\"])\s*\)[^:\n]*:",
code,
)
)
if not inst_vars:
return fixed_code
for var in inst_vars:
# Replace DF.loc[var] (read) with DF.xs(var, level=1)
# Exclude write-back patterns (DF.loc[var] = ...) — leave those as-is
def _make_replacer(v: str):
def _replace(m: re.Match) -> str:
df_var = m.group(1)
self.fixes_applied.append(
f"instrument_loc: {df_var}.loc[{v}] → {df_var}.xs({v}, level=1)"
)
return f"{df_var}.xs({v}, level=1)"
return _replace
# Only match when NOT followed by ' =' (assignment)
fixed_code = re.sub(
rf"(\w+)\.loc\[\s*{re.escape(var)}\s*\](?!\s*=)",
_make_replacer(var),
fixed_code,
)
return fixed_code
def _fix_zero_volume_proxy(self, code: str) -> str:
"""
Fix: $volume is always 0 in our EUR/USD dataset (FX has no real volume).
Any factor using $volume (VWAP, volume-weighted returns, etc.) produces
all-NaN output because 0*price=0 and sum(0)/sum(0)=NaN.
Insert a guard right after pd.read_hdf() that replaces zero volume with
the intraday price-range proxy ($high - $low) so volume-weighted factors
produce meaningful signals.
"""
if "'$volume'" not in code and '"$volume"' not in code:
return code
# Already patched
if "volume proxy" in code:
return code
lines = code.splitlines()
insert_after = -1
df_var = "df"
indent = " "
for i, line in enumerate(lines):
if "read_hdf(" in line:
m = re.match(r"(\s*)(\w+)\s*=\s*", line)
if m:
indent = m.group(1)
df_var = m.group(2)
else:
m2 = re.match(r"(\s*)", line)
indent = m2.group(1) if m2 else " "
insert_after = i
break
if insert_after == -1:
return code
proxy_lines = [
f"{indent}# volume proxy: $volume is always 0 in FX data — use price-range as proxy",
f"{indent}if ({df_var}['$volume'] == 0).all():",
f"{indent} {df_var}['$volume'] = {df_var}['$high'] - {df_var}['$low']",
]
lines = lines[: insert_after + 1] + proxy_lines + lines[insert_after + 1 :]
self.fixes_applied.append("volume_proxy: replaced zero $volume with ($high - $low)")
return "\n".join(lines)
def _fix_reset_index_groupby(self, code: str) -> str:
"""
Fix: groupby(level=N) on a variable created by .reset_index() fails because
reset_index() converts the MultiIndex into regular columns, leaving a plain
RangeIndex. Replace groupby(level=N) on such variables with
groupby('instrument').
Detected pattern:
varname = <anything>.reset_index(...)
...
varname.groupby(level=0|1)
"""
fixed_code = code
# Find all variables assigned via reset_index()
reset_vars = set(re.findall(r'(\w+)\s*=\s*\w[^=\n]*\.reset_index\(', fixed_code))
for var in reset_vars:
# Replace var.groupby(level=N) with var.groupby('instrument')
pattern = rf'{re.escape(var)}\.groupby\(level\s*=\s*\d+\)'
if re.search(pattern, fixed_code):
fixed_code = re.sub(pattern, f"{var}.groupby('instrument')", fixed_code)
self.fixes_applied.append(f"reset_index_groupby: {var}.groupby(level=N) → groupby('instrument')")
return fixed_code
def _fix_groupby_mixed_levels(self, code: str) -> str:
"""
Fix: groupby(level=[int, 'str']) raises AssertionError because string level
names don't exist on an unnamed MultiIndex. Keep only integer levels.
Pattern: .groupby(level=[0, 'date']) → .groupby(level=0)
.groupby(level=[1, 'date']) → .groupby(level=1)
"""
fixed_code = code
def _keep_int_levels(m):
inner = m.group(1)
ints = re.findall(r'\b(\d+)\b', inner)
if not ints:
return m.group(0)
replacement = f'.groupby(level={ints[0]})' if len(ints) == 1 else f'.groupby(level=[{", ".join(ints)}])'
self.fixes_applied.append(f"mixed_levels: groupby(level=[...,str]) → {replacement}")
return replacement
fixed_code = re.sub(r'\.groupby\(level=\[([^\]]+)\]\)', _keep_int_levels, fixed_code)
return fixed_code
def _fix_groupby_column_on_multiindex(self, code: str) -> str:
"""
Fix: groupby(['instrument', 'date']) on a MultiIndex (datetime, instrument)
DataFrame fails with KeyError because those are index levels, not columns.
Correct replacement preserves BOTH dimensions so intraday calculations reset
per day:
var.groupby(['instrument', 'date'])
→ var.groupby([var.index.get_level_values(1), var.index.get_level_values(0).normalize()])
Single-column groupby(['instrument']) is correctly replaced with groupby(level=1).
Note: do NOT convert groupby('instrument') → groupby(level=1) here — that would
undo the reset_index_groupby fix which correctly emits groupby('instrument').
"""
fixed_code = code
# Variables created via reset_index() have a plain RangeIndex — applying
# get_level_values() on them would raise AttributeError. Skip those.
reset_vars = set(re.findall(r'(\w+)\s*=\s*\w[^=\n]*\.reset_index\(', fixed_code))
def _replace_two_col_groupby(m: re.Match, order: str) -> str:
var = m.group(1)
if var in reset_vars:
return m.group(0) # leave reset_index vars alone — RangeIndex, not MultiIndex
if order == "instrument_date":
repl = (
f"{var}.groupby([{var}.index.get_level_values(1), "
f"{var}.index.get_level_values(0).normalize()])"
)
else: # date_instrument
repl = (
f"{var}.groupby([{var}.index.get_level_values(0).normalize(), "
f"{var}.index.get_level_values(1)])"
)
self.fixes_applied.append(f"multiindex_groupby: {m.group(0)[:60]} → two-level")
return repl
# groupby(['instrument', 'date']) — capture variable name before .groupby
fixed_code = re.sub(
r'(\w+)\.groupby\(\[\'instrument\',\s*\'date\'\]\)',
lambda m: _replace_two_col_groupby(m, "instrument_date"),
fixed_code,
)
# groupby(['date', 'instrument'])
fixed_code = re.sub(
r'(\w+)\.groupby\(\[\'date\',\s*\'instrument\'\]\)',
lambda m: _replace_two_col_groupby(m, "date_instrument"),
fixed_code,
)
# single: groupby(['instrument']) → groupby(level=1), but not on reset_index vars
def _replace_single_instrument_groupby(m: re.Match) -> str:
# Look backwards to find the variable name
prefix = fixed_code[: m.start()]
var_match = re.search(r'(\w+)\s*$', prefix)
var = var_match.group(1) if var_match else ''
if var in reset_vars:
return m.group(0)
self.fixes_applied.append("multiindex_groupby: groupby(['instrument']) → groupby(level=1)")
return ".groupby(level=1)"
if re.search(r"\.groupby\(\['instrument'\]\)", fixed_code):
fixed_code = re.sub(r"\.groupby\(\['instrument'\]\)", _replace_single_instrument_groupby, fixed_code)
# groupby(level=['instrument', 'date']) — uses level= keyword with string names.
# 'date' is NOT a valid level name in our (datetime, instrument) MultiIndex;
# replace with get_level_values to normalize datetime to daily timestamps.
fixed_code = re.sub(
r"(\w+)\.groupby\(level=\['instrument',\s*'date'\]\)",
lambda m: (
self.fixes_applied.append(
f"multiindex_groupby: {m.group(0)[:60]} → two-level get_level_values"
)
or f"{m.group(1)}.groupby([{m.group(1)}.index.get_level_values(1), "
f"{m.group(1)}.index.get_level_values(0).normalize()])"
),
fixed_code,
)
# groupby(level=['date', 'instrument'])
fixed_code = re.sub(
r"(\w+)\.groupby\(level=\['date',\s*'instrument'\]\)",
lambda m: (
self.fixes_applied.append(
f"multiindex_groupby: {m.group(0)[:60]} → two-level get_level_values"
)
or f"{m.group(1)}.groupby([{m.group(1)}.index.get_level_values(0).normalize(), "
f"{m.group(1)}.index.get_level_values(1)])"
),
fixed_code,
)
# single: groupby(level=['instrument']) → groupby(level=1)
fixed_code = re.sub(
r"\.groupby\(level=\['instrument'\]\)",
lambda m: (self.fixes_applied.append("multiindex_groupby: groupby(level=['instrument']) → level=1") or ".groupby(level=1)"),
fixed_code,
)
return fixed_code
def _fix_chained_groupby(self, code: str) -> str:
"""
Fix two broken patterns the LLM generates when trying to group by (instrument, date):
Pattern A — chained groupby (runtime AttributeError):
var.groupby(level=1).groupby('date')
→ var.groupby([var.index.get_level_values(1),
var.index.get_level_values(0).normalize()])
Pattern B — keyword arg inside list (SyntaxError):
var.groupby([level=1, 'date'])
→ same two-level replacement
"""
fixed_code = code
def _two_level(var: str, tag: str) -> str:
self.fixes_applied.append(f"chained_groupby: {tag} → two-level")
return (
f"{var}.groupby([{var}.index.get_level_values(1), "
f"{var}.index.get_level_values(0).normalize()])"
)
# Pattern A: var.groupby(level=N).groupby('date')
fixed_code = re.sub(
r'(\w+)\.groupby\(level=\d+\)\.groupby\(["\']date["\']\)',
lambda m: _two_level(m.group(1), m.group(0)[:60]),
fixed_code,
)
# Pattern B: .groupby([level=N, 'date']) — SyntaxError in Python.
# The variable before .groupby may be complex (e.g. df[mask]) so we don't
# try to capture it; we use df as the index reference (always correct since
# all filtered frames share df's MultiIndex structure).
def _two_level_df(tag: str) -> str:
self.fixes_applied.append(f"chained_groupby: {tag} → two-level")
return ".groupby([df.index.get_level_values(1), df.index.get_level_values(0).normalize()])"
fixed_code = re.sub(
r'\.groupby\(\[\s*level\s*=\s*\d+\s*,\s*["\']?date["\']?\s*\]\)',
lambda m: _two_level_df(m.group(0)[:60]),
fixed_code,
)
# Also handle reversed order: ['date', level=N]
fixed_code = re.sub(
r'\.groupby\(\[\s*["\']?date["\']?\s*,\s*level\s*=\s*\d+\s*\]\)',
lambda m: _two_level_df(m.group(0)[:60]),
fixed_code,
)
return fixed_code
def _fix_rolling_ddof(self, code: str) -> str:
"""
Fix: pandas rolling() does not accept a ddof kwarg — raises TypeError.
Remove ddof from both rolling(..., ddof=N) and rolling(...).std(ddof=N).
"""
fixed_code = code
# Form 1: ddof inside rolling() — .rolling(window=N, min_periods=M, ddof=K)
def _strip_ddof_from_rolling(m):
inner = re.sub(r',?\s*ddof\s*=\s*\d+', '', m.group(1))
inner = inner.strip(', ')
self.fixes_applied.append("rolling_ddof: removed ddof from rolling()")
return f'.rolling({inner})'
fixed_code = re.sub(r'\.rolling\(([^)]*ddof\s*=\s*\d+[^)]*)\)', _strip_ddof_from_rolling, fixed_code)
# Form 2: ddof inside .std() / .var() — .std(ddof=N)
if re.search(r'\.(std|var)\([^)]*ddof\s*=\s*\d+', fixed_code):
fixed_code = re.sub(r'\.(std|var)\([^)]*ddof\s*=\s*\d+[^)]*\)', r'.\1()', fixed_code)
self.fixes_applied.append("rolling_ddof: removed ddof from std()/var()")
return fixed_code
def _fix_min_periods(self, code: str) -> str: def _fix_min_periods(self, code: str) -> str:
""" """
Fix: Ensure min_periods matches window size in rolling calculations. Fix: Ensure min_periods matches window size in rolling calculations.
@@ -325,6 +681,45 @@ class FactorAutoFixer:
fixed_code = fixed_code.replace(old_code, new_code) fixed_code = fixed_code.replace(old_code, new_code)
self.fixes_applied.append(f"groupby: fixed rolling correlation (window={window}) with reset_index") self.fixes_applied.append(f"groupby: fixed rolling correlation (window={window}) with reset_index")
# === GENERAL FIX: DF.groupby(level=N)['col'].apply(lambda x: EXPR) ===
# apply() on a grouped Series returns a MultiIndex result (extra level prepended),
# causing index shape mismatch when assigned back to df['col'].
# Replace with transform() which preserves the original index.
col_apply_pattern = re.compile(
r"(\w+)\.groupby\(level=(\d+)\)\['([^']+)'\]\.apply\((\s*lambda\s+\w+\s*:.*?)\)",
re.DOTALL,
)
for m in list(col_apply_pattern.finditer(fixed_code)):
full = m.group(0)
df_var = m.group(1)
level = m.group(2)
col = m.group(3)
lam = m.group(4).strip()
new_expr = f"{df_var}.groupby(level={level})['{col}'].transform({lam})"
fixed_code = fixed_code.replace(full, new_expr, 1)
self.fixes_applied.append(
f"groupby: {df_var}.groupby(level={level})['{col}'].apply() → transform()"
)
# === FIX: .transform(...).reset_index(level=N, drop=True) ===
# transform() already returns the same index as the input — adding reset_index()
# after it drops an index level and causes ValueError on assignment back to df['col'].
# Detected line-by-line: if a line contains both .transform( and .reset_index(level=
reset_suffix = re.compile(r'\s*\.reset_index\s*\(\s*level\s*=[^,)]+,\s*drop\s*=\s*True\s*\)\s*$')
new_lines = []
changed = False
for line in fixed_code.splitlines():
if '.transform(' in line and '.reset_index(' in line:
cleaned = reset_suffix.sub('', line)
if cleaned != line:
new_lines.append(cleaned)
changed = True
continue
new_lines.append(line)
if changed:
fixed_code = '\n'.join(new_lines)
self.fixes_applied.append("groupby: removed spurious .reset_index() after .transform()")
# Pattern: Simple groupby().apply() with rolling().method() # Pattern: Simple groupby().apply() with rolling().method()
# df.groupby(level=N).apply(lambda x: x['col'].rolling(...).method()) # df.groupby(level=N).apply(lambda x: x['col'].rolling(...).method())
apply_pattern = r"df\.groupby\(level=(\d+)\)\.apply\(\s*lambda\s+x:\s+x\['([^']+)'\]\.rolling\([^)]+\)\.(\w+)\([^)]*\)\s*\)" apply_pattern = r"df\.groupby\(level=(\d+)\)\.apply\(\s*lambda\s+x:\s+x\['([^']+)'\]\.rolling\([^)]+\)\.(\w+)\([^)]*\)\s*\)"
@@ -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 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 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 - 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 - 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. Notice that you should not add any other text before or after the json format.
@@ -123,8 +123,8 @@ model_cls = AntiSymmetricConv
if __name__ == "__main__": if __name__ == "__main__":
node_features = torch.load("node_features.pt") node_features = torch.load("node_features.pt", weights_only=True)
edge_index = torch.load("edge_index.pt") edge_index = torch.load("edge_index.pt", weights_only=True)
# Model instantiation and forward pass # Model instantiation and forward pass
model = AntiSymmetricConv(in_channels=node_features.size(-1)) model = AntiSymmetricConv(in_channels=node_features.size(-1))
@@ -78,8 +78,8 @@ model_cls = DirGNNConv
if __name__ == "__main__": if __name__ == "__main__":
node_features = torch.load("node_features.pt") node_features = torch.load("node_features.pt", weights_only=True)
edge_index = torch.load("edge_index.pt") edge_index = torch.load("edge_index.pt", weights_only=True)
# Model instantiation and forward pass # Model instantiation and forward pass
model = DirGNNConv(MessagePassing()) model = DirGNNConv(MessagePassing())
@@ -187,8 +187,8 @@ model_cls = GPSConv
if __name__ == "__main__": if __name__ == "__main__":
node_features = torch.load("node_features.pt") node_features = torch.load("node_features.pt", weights_only=True)
edge_index = torch.load("edge_index.pt") edge_index = torch.load("edge_index.pt", weights_only=True)
# Model instantiation and forward pass # Model instantiation and forward pass
model = GPSConv(channels=node_features.size(-1), conv=MessagePassing()) model = GPSConv(channels=node_features.size(-1), conv=MessagePassing())
@@ -170,8 +170,8 @@ class LINKX(torch.nn.Module):
model_cls = LINKX model_cls = LINKX
if __name__ == "__main__": if __name__ == "__main__":
node_features = torch.load("node_features.pt") node_features = torch.load("node_features.pt", weights_only=True)
edge_index = torch.load("edge_index.pt") edge_index = torch.load("edge_index.pt", weights_only=True)
# Model instantiation and forward pass # Model instantiation and forward pass
model = LINKX( model = LINKX(
@@ -102,8 +102,8 @@ class PMLP(torch.nn.Module):
model_cls = PMLP model_cls = PMLP
if __name__ == "__main__": if __name__ == "__main__":
node_features = torch.load("node_features.pt") node_features = torch.load("node_features.pt", weights_only=True)
edge_index = torch.load("edge_index.pt") edge_index = torch.load("edge_index.pt", weights_only=True)
# Model instantiation and forward pass # Model instantiation and forward pass
model = PMLP( model = PMLP(
@@ -1180,8 +1180,8 @@ model_cls = ViSNet
if __name__ == "__main__": if __name__ == "__main__":
node_features = torch.load("node_features.pt") node_features = torch.load("node_features.pt", weights_only=True)
edge_index = torch.load("edge_index.pt") edge_index = torch.load("edge_index.pt", weights_only=True)
# Model instantiation and forward pass # Model instantiation and forward pass
model = ViSNet() model = ViSNet()
@@ -125,8 +125,8 @@ class AntiSymmetricConv(torch.nn.Module):
if __name__ == "__main__": if __name__ == "__main__":
node_features = torch.load("node_features.pt") node_features = torch.load("node_features.pt", weights_only=True)
edge_index = torch.load("edge_index.pt") edge_index = torch.load("edge_index.pt", weights_only=True)
# Model instantiation and forward pass # Model instantiation and forward pass
model = AntiSymmetricConv(in_channels=node_features.size(-1)) model = AntiSymmetricConv(in_channels=node_features.size(-1))
+7 -1
View File
@@ -720,7 +720,13 @@ class APIBackend(ABC):
if finish_reason is None or finish_reason != "length": if finish_reason is None or finish_reason != "length":
break # we get a full response now. break # we get a full response now.
new_messages.append({"role": "assistant", "content": response}) # Merge into the previous assistant message if there already is one at the end.
# Appending a second consecutive assistant message causes llama-server to return 400
# ("Cannot have 2 or more assistant messages at the end of the list").
if new_messages and new_messages[-1]["role"] == "assistant":
new_messages[-1]["content"] += response
else:
new_messages.append({"role": "assistant", "content": response})
else: else:
raise RuntimeError(f"Failed to continue the conversation after {try_n} retries.") raise RuntimeError(f"Failed to continue the conversation after {try_n} retries.")
@@ -85,12 +85,12 @@ def preprocess_script():
This method applies the preprocessing steps to the training, validation, and test datasets. This method applies the preprocessing steps to the training, validation, and test datasets.
""" """
if os.path.exists("/kaggle/input/X_train.pkl"): if os.path.exists("/kaggle/input/X_train.pkl"):
X_train = pd.read_pickle("/kaggle/input/X_train.pkl") X_train = pd.read_pickle("/kaggle/input/X_train.pkl") # nosec B301
X_valid = pd.read_pickle("/kaggle/input/X_valid.pkl") X_valid = pd.read_pickle("/kaggle/input/X_valid.pkl") # nosec B301
y_train = pd.read_pickle("/kaggle/input/y_train.pkl") y_train = pd.read_pickle("/kaggle/input/y_train.pkl") # nosec B301
y_valid = pd.read_pickle("/kaggle/input/y_valid.pkl") y_valid = pd.read_pickle("/kaggle/input/y_valid.pkl") # nosec B301
X_test = pd.read_pickle("/kaggle/input/X_test.pkl") X_test = pd.read_pickle("/kaggle/input/X_test.pkl") # nosec B301
others = pd.read_pickle("/kaggle/input/others.pkl") others = pd.read_pickle("/kaggle/input/others.pkl") # nosec B301
return X_train, X_valid, y_train, y_valid, X_test, *others return X_train, X_valid, y_train, y_valid, X_test, *others
X_train, X_valid, y_train, y_valid = prepreprocess() 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. This method applies the preprocessing steps to the training, validation, and test datasets.
""" """
if os.path.exists("X_train.pkl"): if os.path.exists("X_train.pkl"):
X_train = pd.read_pickle("X_train.pkl") X_train = pd.read_pickle("X_train.pkl") # nosec B301
X_valid = pd.read_pickle("X_valid.pkl") X_valid = pd.read_pickle("X_valid.pkl") # nosec B301
y_train = pd.read_pickle("y_train.pkl") y_train = pd.read_pickle("y_train.pkl") # nosec B301
y_valid = pd.read_pickle("y_valid.pkl") y_valid = pd.read_pickle("y_valid.pkl") # nosec B301
X_test = pd.read_pickle("X_test.pkl") X_test = pd.read_pickle("X_test.pkl") # nosec B301
return X_train, X_valid, y_train, y_valid, X_test 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() 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. This method applies the preprocessing steps to the training, validation, and test datasets.
""" """
if os.path.exists("/kaggle/input/X_train.pkl"): if os.path.exists("/kaggle/input/X_train.pkl"):
X_train = pd.read_pickle("/kaggle/input/X_train.pkl") X_train = pd.read_pickle("/kaggle/input/X_train.pkl") # nosec B301
X_valid = pd.read_pickle("/kaggle/input/X_valid.pkl") X_valid = pd.read_pickle("/kaggle/input/X_valid.pkl") # nosec B301
y_train = pd.read_pickle("/kaggle/input/y_train.pkl") y_train = pd.read_pickle("/kaggle/input/y_train.pkl") # nosec B301
y_valid = pd.read_pickle("/kaggle/input/y_valid.pkl") y_valid = pd.read_pickle("/kaggle/input/y_valid.pkl") # nosec B301
X_test = pd.read_pickle("/kaggle/input/X_test.pkl") X_test = pd.read_pickle("/kaggle/input/X_test.pkl") # nosec B301
others = pd.read_pickle("/kaggle/input/others.pkl") others = pd.read_pickle("/kaggle/input/others.pkl") # nosec B301
y_train = pd.Series(y_train).reset_index(drop=True) y_train = pd.Series(y_train).reset_index(drop=True)
y_valid = pd.Series(y_valid).reset_index(drop=True) y_valid = pd.Series(y_valid).reset_index(drop=True)
+153 -29
View File
@@ -29,6 +29,83 @@ from rdagent.scenarios.qlib.experiment.model_experiment import QlibModelExperime
DIRNAME = Path(__file__).absolute().resolve().parent DIRNAME = Path(__file__).absolute().resolve().parent
DIRNAME_local = Path.cwd() 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 # TODO: supporting multiprocessing and keep previous results
@@ -391,8 +468,19 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
import numpy as np import numpy as np
try: try:
# Get workspace path # Get workspace path — factor code and result.h5 live in sub_workspace_list[0],
workspace_path = exp.experiment_workspace.workspace_path # 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: if workspace_path is None:
return None return None
@@ -409,6 +497,12 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
factor_col = factor_values.iloc[:, 0] factor_col = factor_values.iloc[:, 0]
factor_name = factor_values.columns[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 # Load source data for forward returns
data_path = ( data_path = (
Path(__file__).parent.parent.parent.parent.parent Path(__file__).parent.parent.parent.parent.parent
@@ -587,10 +681,12 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
from pathlib import Path from pathlib import Path
from rdagent.components.backtesting import ResultsDatabase 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" factor_name = "unknown"
if hasattr(exp, 'hypothesis') and exp.hypothesis is not None: if hasattr(exp, 'hypothesis') and exp.hypothesis is not None:
factor_name = getattr(exp.hypothesis, 'hypothesis', 'unknown') 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 # Check if already rejected by protection
if getattr(exp, 'rejected_by_protection', False): if getattr(exp, 'rejected_by_protection', False):
@@ -824,41 +920,74 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
""" """
Save factor time-series values as parquet for strategy building. Save factor time-series values as parquet for strategy building.
This is essential for walk-forward validation and strategy combination. Reruns the factor code on the FULL 6-year dataset so the parquet covers
the complete backtest range (not just the debug 2024 subset).
Parameters
----------
factor_name : str
Name of the factor
exp : QlibFactorExperiment
The experiment with factor values
""" """
import os as _os import os as _os
import subprocess
import shutil
import tempfile
try: try:
# Get workspace path # factor.py lives in sub_workspace_list[0], not experiment_workspace
workspace_path = exp.experiment_workspace.workspace_path 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: if workspace_path is None:
return return
result_h5 = workspace_path / "result.h5" factor_py = workspace_path / "factor.py"
if not result_h5.exists(): if not factor_py.exists():
return 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 import pandas as pd
df = pd.read_hdf(str(result_h5), key="data") with tempfile.TemporaryDirectory(prefix="predix_fullval_") as tmp_dir:
tmp = Path(tmp_dir)
shutil.copy(str(factor_py), str(tmp / "factor.py"))
shutil.copy(str(full_data), str(tmp / "intraday_pv.h5"))
ret = subprocess.run(
["python", "factor.py"],
cwd=str(tmp),
capture_output=True,
timeout=300,
)
if ret.returncode != 0:
# Fall back to debug-data result if full-data run fails
result_h5 = workspace_path / "result.h5"
if not result_h5.exists():
return
df = pd.read_hdf(str(result_h5), key="data")
else:
result_h5_full = tmp / "result.h5"
if not result_h5_full.exists():
return
df = pd.read_hdf(str(result_h5_full), key="data")
if df is None or df.empty: if df is None or df.empty:
return return
# Get the factor series (first column)
series = df.iloc[:, 0] series = df.iloc[:, 0]
series.name = factor_name 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") parallel_run_id = _os.getenv("PARALLEL_RUN_ID", "0")
if parallel_run_id != "0": if parallel_run_id != "0":
values_dir = project_root / "results" / "runs" / f"run{parallel_run_id}" / "factors" / "values" values_dir = project_root / "results" / "runs" / f"run{parallel_run_id}" / "factors" / "values"
@@ -866,16 +995,11 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
values_dir = project_root / "results" / "factors" / "values" values_dir = project_root / "results" / "factors" / "values"
values_dir.mkdir(parents=True, exist_ok=True) values_dir.mkdir(parents=True, exist_ok=True)
# Safe filename
safe_name = factor_name.replace("/", "_").replace("\\", "_").replace(" ", "_")[:100] safe_name = factor_name.replace("/", "_").replace("\\", "_").replace(" ", "_")[:100]
parquet_path = values_dir / f"{safe_name}.parquet" parquet_path = values_dir / f"{safe_name}.parquet"
series.to_frame().to_parquet(str(parquet_path))
# Save as parquet (with datetime index) except Exception:
series.to_parquet(str(parquet_path))
except Exception as e:
# Don't let factor value saving break the main workflow
pass pass
def _log_result_warnings(self, factor_name: str, result, metrics: dict) -> None: def _log_result_warnings(self, factor_name: str, result, metrics: dict) -> None:
@@ -23,14 +23,25 @@ $low: low price at 1-minute bar.
$volume: volume at 1-minute bar (tick volume for FX). $volume: volume at 1-minute bar (tick volume for FX).
## Important Notes for 1min Data ## Important Notes for 1min Data
- 96 bars = 1 trading day (24 hours for FX) - 1 bar = 1 minute (confirmed)
- 16 bars = 16 minutes - 16 bars = 16 minutes
- 4 bars = 4 minutes - 60 bars = 1 hour
- 1 bar = 1 minute - ~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 - Data range: 2020-01-01 to 2026-03-20
- Instrument: EURUSD - Instrument: EURUSD
- Timezone: UTC - 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) ## Session Times (UTC)
- Asian: 00:00-08:00 UTC (low volatility) - Asian: 00:00-08:00 UTC (low volatility)
- London: 08:00-16:00 UTC (high 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'] 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: 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 ```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}" 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: |- qlib_factor_output_format: |-
Your output should be a pandas dataframe similar to the following example information: Your output should be a pandas dataframe similar to the following example information:
<class 'pandas.core.frame.DataFrame'> <class 'pandas.core.frame.DataFrame'>
@@ -1,4 +1,5 @@
import json import json
import os
from typing import List, Tuple from typing import List, Tuple
from rdagent.components.coder.factor_coder.factor import FactorExperiment, FactorTask from rdagent.components.coder.factor_coder.factor import FactorExperiment, FactorTask
@@ -9,6 +10,47 @@ from rdagent.scenarios.qlib.experiment.model_experiment import QlibModelExperime
from rdagent.scenarios.qlib.experiment.quant_experiment import QlibQuantScenario from rdagent.scenarios.qlib.experiment.quant_experiment import QlibQuantScenario
from rdagent.utils.agent.tpl import T from rdagent.utils.agent.tpl import T
def _build_compressed_history(trace: Trace, max_history: int) -> str:
"""Return hypothesis_and_feedback string with only `max_history` entries.
Older entries beyond the last 2 are compressed to one bullet line each.
"""
if len(trace.hist) == 0:
return "No previous hypothesis and feedback available since it's the first round."
FULL_DETAIL = 2
old_hist = trace.hist[:-FULL_DETAIL] if len(trace.hist) > FULL_DETAIL else []
recent_hist = trace.hist[-FULL_DETAIL:] if len(trace.hist) > FULL_DETAIL else trace.hist
parts = []
if old_hist:
lines = ["## Earlier experiments (summarized):"]
for exp, fb in old_hist:
names = []
for task in exp.sub_tasks:
if task is not None and hasattr(task, "factor_name"):
names.append(task.factor_name)
elif task is not None and hasattr(task, "model_type"):
names.append(getattr(task, "model_type", "model"))
ic_str = ""
try:
if exp.result is not None and "IC" in exp.result.index:
ic_str = f" IC={exp.result.loc['IC']:.4f}"
except Exception:
pass
decision = "PASS" if fb.decision else "FAIL"
obs = (fb.observations or "")[:120].replace("\n", " ")
lines.append(f"- [{decision}]{ic_str} {', '.join(names) or 'unknown'}: {obs}")
parts.append("\n".join(lines))
if recent_hist:
rt = Trace(trace.scen)
rt.hist = recent_hist
parts.append(T("scenarios.qlib.prompts:hypothesis_and_feedback").r(trace=rt))
return "\n\n".join(parts)
QlibFactorHypothesis = Hypothesis QlibFactorHypothesis = Hypothesis
@@ -17,13 +59,10 @@ class QlibFactorHypothesisGen(FactorHypothesisGen):
super().__init__(scen) super().__init__(scen)
def prepare_context(self, trace: Trace) -> Tuple[dict, bool]: def prepare_context(self, trace: Trace) -> Tuple[dict, bool]:
hypothesis_and_feedback = ( max_h = int(os.environ.get("QLIB_QUANT_MAX_FACTOR_HISTORY", "20"))
T("scenarios.qlib.prompts:hypothesis_and_feedback").r( limited = Trace(trace.scen)
trace=trace, limited.hist = trace.hist[-max_h:] if len(trace.hist) > max_h else trace.hist
) hypothesis_and_feedback = _build_compressed_history(limited, max_h)
if len(trace.hist) > 0
else "No previous hypothesis and feedback available since it's the first round."
)
last_hypothesis_and_feedback = ( last_hypothesis_and_feedback = (
T("scenarios.qlib.prompts:last_hypothesis_and_feedback").r( T("scenarios.qlib.prompts:last_hypothesis_and_feedback").r(
experiment=trace.hist[-1][0], feedback=trace.hist[-1][1] experiment=trace.hist[-1][0], feedback=trace.hist[-1][1]
@@ -70,15 +109,15 @@ class QlibFactorHypothesis2Experiment(FactorHypothesis2Experiment):
if len(trace.hist) == 0: if len(trace.hist) == 0:
hypothesis_and_feedback = "No previous hypothesis and feedback available since it's the first round." hypothesis_and_feedback = "No previous hypothesis and feedback available since it's the first round."
else: else:
max_h = int(os.environ.get("QLIB_QUANT_MAX_FACTOR_HISTORY", "20"))
factor_hist = [
e for e in trace.hist
if not hasattr(e[0].hypothesis, "action") or e[0].hypothesis.action == "factor"
][-max_h:]
specific_trace = Trace(trace.scen) specific_trace = Trace(trace.scen)
for i in range(len(trace.hist) - 1, -1, -1): specific_trace.hist = factor_hist
if not hasattr(trace.hist[i][0].hypothesis, "action") or trace.hist[i][0].hypothesis.action == "factor": if specific_trace.hist:
specific_trace.hist.insert(0, trace.hist[i]) hypothesis_and_feedback = _build_compressed_history(specific_trace, max_h)
if len(specific_trace.hist) > 0:
specific_trace.hist.reverse()
hypothesis_and_feedback = T("scenarios.qlib.prompts:hypothesis_and_feedback").r(
trace=specific_trace,
)
else: else:
hypothesis_and_feedback = "No previous hypothesis and feedback available." hypothesis_and_feedback = "No previous hypothesis and feedback available."
@@ -152,9 +152,41 @@ class QlibQuantHypothesisGen(FactorAndModelHypothesisGen):
factor_inserted = True factor_inserted = True
if len(specific_trace.hist) > 0: if len(specific_trace.hist) > 0:
specific_trace.hist.reverse() specific_trace.hist.reverse()
hypothesis_and_feedback = T("scenarios.qlib.prompts:hypothesis_and_feedback").r( # Keep only the 2 most recent experiments in full detail; compress older ones
trace=specific_trace, # to brief bullet points to stay within the LLM context window.
) FULL_DETAIL_COUNT = 2
old_hist = specific_trace.hist[:-FULL_DETAIL_COUNT] if len(specific_trace.hist) > FULL_DETAIL_COUNT else []
recent_hist = specific_trace.hist[-FULL_DETAIL_COUNT:] if len(specific_trace.hist) > FULL_DETAIL_COUNT else specific_trace.hist
parts = []
if old_hist:
summary_lines = ["## Earlier experiments (summarized):"]
for exp, fb in old_hist:
factor_names = []
for task in exp.sub_tasks:
if task is not None and hasattr(task, "factor_name"):
factor_names.append(task.factor_name)
elif task is not None and hasattr(task, "model_type"):
factor_names.append(getattr(task, "model_type", "model"))
names_str = ", ".join(factor_names) if factor_names else "unknown"
ic_str = ""
try:
if exp.result is not None:
ic_val = exp.result.loc["IC"] if "IC" in exp.result.index else ""
ic_str = f" IC={ic_val:.4f}" if ic_val != "" else ""
except Exception:
pass
decision_str = "PASS" if fb.decision else "FAIL"
obs_short = (fb.observations or "")[:120].replace("\n", " ")
summary_lines.append(f"- [{decision_str}]{ic_str} {names_str}: {obs_short}")
parts.append("\n".join(summary_lines))
if recent_hist:
recent_trace = Trace(specific_trace.scen)
recent_trace.hist = recent_hist
parts.append(T("scenarios.qlib.prompts:hypothesis_and_feedback").r(trace=recent_trace))
hypothesis_and_feedback = "\n\n".join(parts)
else: else:
hypothesis_and_feedback = "No previous hypothesis and feedback available." hypothesis_and_feedback = "No previous hypothesis and feedback available."
@@ -391,7 +391,7 @@ def set_baseline():
return jsonify({"baseline_score": score, "status": "set"}) 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) init_server(task, base_model, workspace)
logger.info(f"Grading Server | task={task} | {host}:{port}") 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}...") logger.info(f"[Local Mode] Starting evaluation server on port {self.port}...")
self.server = init_server(self.task, self.base_model, self.workspace) 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 = threading.Thread(target=self._http_server.serve_forever, daemon=True)
self._thread.start() self._thread.start()
@@ -488,7 +488,7 @@ if __name__ == "__main__":
parser.add_argument("--base-model", type=str, default="") parser.add_argument("--base-model", type=str, default="")
parser.add_argument("--workspace", type=str, default=".") parser.add_argument("--workspace", type=str, default=".")
parser.add_argument("--port", type=int, default=5000) 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() args = parser.parse_args()
run_server(args.task, args.base_model, args.workspace, args.host, args.port) run_server(args.task, args.base_model, args.workspace, args.host, args.port)
+5
View File
@@ -270,6 +270,11 @@ class LoopBase:
msg = "We have reset the loop instance, stop all the routines and resume." msg = "We have reset the loop instance, stop all the routines and resume."
raise self.LoopResumeError(msg) from e raise self.LoopResumeError(msg) from e
else: else:
# Do NOT advance step_idx for unhandled exceptions (e.g. LoopResumeError
# propagating from _propose). Keeping step_idx at the current step lets
# kickoff_loop retry step 0 on the next resume instead of permanently
# corrupting the loop with a missing direct_exp_gen result.
step_forward = False
raise # re-raise unhandled exceptions raise # re-raise unhandled exceptions
finally: finally:
# No matter the execution succeed or not, we have to finish the following steps # No matter the execution succeed or not, we have to finish the following steps
+1 -1
View File
@@ -37,7 +37,7 @@ tables
tree-sitter-python tree-sitter-python
tree-sitter tree-sitter
python-dotenv python-dotenv>=1.2.2 # CVE: symlink following allows arbitrary file overwrite
# infrastructure related. # infrastructure related.
docker docker
+50
View File
@@ -198,6 +198,55 @@ def scan_factors(workspace_dir: Path, skip_evaluated: bool = True) -> List[Facto
return factors return factors
# ---------------------------------------------------------------------------
# Look-ahead bias detection for daily-constant factors
# ---------------------------------------------------------------------------
def _shift_daily_constant_factor_if_needed(factor_col: "pd.Series", factor_name: str) -> "pd.Series":
"""Detect daily-constant factors (look-ahead bias) and shift by 1 trading day."""
sample_days = factor_col.index.get_level_values("datetime").normalize().unique()
if len(sample_days) < 10:
return factor_col
rng = np.random.default_rng(42)
days_to_check = rng.choice(sample_days, size=min(50, len(sample_days)), replace=False)
constant_count = 0
for day in days_to_check:
day_mask = factor_col.index.get_level_values("datetime").normalize() == day
day_vals = factor_col[day_mask].dropna()
if len(day_vals) == 0:
continue
if day_vals.nunique() == 1:
constant_count += 1
fraction_constant = constant_count / len(days_to_check)
if fraction_constant < 0.90:
return factor_col
# Shift by 1 trading day per instrument
import logging
logging.getLogger(__name__).info(
"Factor '%s' is %.0f%% daily-constant — shifting 1 trading day to fix look-ahead bias",
factor_name, fraction_constant * 100,
)
instruments = factor_col.index.get_level_values("instrument").unique() if "instrument" in factor_col.index.names else [None]
shifted_parts = []
for instr in instruments:
if instr is not None:
mask = factor_col.index.get_level_values("instrument") == instr
col_instr = factor_col[mask]
else:
col_instr = factor_col
dates = col_instr.index.get_level_values("datetime").normalize()
trading_days = dates.unique().sort_values()
day_first = col_instr.groupby(dates).first()
day_first_shifted = day_first.shift(1)
day_first_shifted.index = pd.to_datetime(day_first_shifted.index)
day_map = day_first_shifted.reindex(pd.to_datetime(trading_days)).values
new_vals = pd.Series(
day_map[np.searchsorted(trading_days.values, dates.values)],
index=col_instr.index,
)
shifted_parts.append(new_vals)
return pd.concat(shifted_parts).sort_index()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Factor evaluator # Factor evaluator
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -263,6 +312,7 @@ def evaluate_factor_full(factor: FactorInfo, full_data: pd.DataFrame,
result = pd.read_hdf(str(result_file), key="data") result = pd.read_hdf(str(result_file), key="data")
total_count = len(result) total_count = len(result)
factor_val = result.iloc[:, 0] factor_val = result.iloc[:, 0]
factor_val = _shift_daily_constant_factor_if_needed(factor_val, factor.factor_name)
non_null_count = factor_val.notna().sum() non_null_count = factor_val.notna().sum()
if non_null_count < 1000: if non_null_count < 1000:
+49 -12
View File
@@ -250,7 +250,7 @@ Hard requirements:
- NO global mean/std always use rolling(window).mean() with shift(1) to avoid look-ahead bias""" - NO global mean/std always use rolling(window).mean() with shift(1) to avoid look-ahead bias"""
else: else:
system_prompt = f"""You are a quantitative trading expert specializing in EUR/USD intraday strategies. system_prompt = f"""You are a quantitative trading expert specializing in EUR/USD daily swing strategies.
CRITICAL RULES for {STYLE_DESC} (forward horizon: {FORWARD_BARS} bars = ~{FORWARD_BARS/60:.1f} hours): CRITICAL RULES for {STYLE_DESC} (forward horizon: {FORWARD_BARS} bars = ~{FORWARD_BARS/60:.1f} hours):
1. ONLY use the factors listed below - no others! 1. ONLY use the factors listed below - no others!
@@ -258,15 +258,27 @@ CRITICAL RULES for {STYLE_DESC} (forward horizon: {FORWARD_BARS} bars = ~{FORWAR
3. Create a pandas Series called 'signal' with values: 1 (long), -1 (short), 0 (neutral) 3. Create a pandas Series called 'signal' with values: 1 (long), -1 (short), 0 (neutral)
4. signal.index MUST match close.index 4. signal.index MUST match close.index
5. signal.name must be 'signal' 5. signal.name must be 'signal'
6. IMPORTANT: factors are DAILY values broadcast to every 1-minute bar they change once per day.
Use daily-level logic: compare today's factor value to a rolling daily mean (window 5-20 DAYS).
To get daily rolling mean: group by date, take first value per day, compute rolling, then reindex back.
Example: dates = factors[col].index.get_level_values('datetime').normalize()
daily_vals = factors[col].groupby(dates).first()
daily_mean = daily_vals.rolling(10).mean().shift(1)
daily_signal = (daily_vals > daily_mean).astype(int) * 2 - 1
signal = daily_signal.reindex(dates).values (broadcast back to minute bars)
7. The signal should change roughly once per day this produces ~250-500 trades over 6 years.
8. Keep conditions SIMPLE: one factor above/below its N-day rolling average. Avoid combining 3+ conditions.
Output ONLY valid JSON with these fields: Output ONLY valid JSON with these fields:
{{"strategy_name": "short_name", "factor_names": ["f1", "f2"], "description": "one sentence", "code": "python code"}}""" {{"strategy_name": "short_name", "factor_names": ["f1", "f2"], "description": "one sentence", "code": "python code"}}"""
user_prompt = f"""Create a EUR/USD trading strategy using these factors: user_prompt = f"""Create a EUR/USD SWING trading strategy (hold ~{FORWARD_BARS/60:.0f} hours) using these factors:
{factor_list} {factor_list}
{f'Previous feedback: {feedback}' if feedback else 'First attempt - be creative!'}""" {f'Previous feedback: {feedback}' if feedback else 'First attempt - be creative!'}
Use daily-level signal logic (factor above/below rolling daily mean). Signal changes once per day."""
api = APIBackend() api = APIBackend()
response = api.build_messages_and_create_chat_completion( response = api.build_messages_and_create_chat_completion(
@@ -371,6 +383,8 @@ signal.fillna(0).to_pickle('signal.pkl')
txn_cost_bps=TXN_COST_BPS, txn_cost_bps=TXN_COST_BPS,
forward_returns=fwd_returns, forward_returns=fwd_returns,
oos_start=OOS_START_DEFAULT, oos_start=OOS_START_DEFAULT,
wf_rolling=False, # too slow on 2M bars — run via rebacktest script instead
mc_n_permutations=50,
) )
# ============================================================================ # ============================================================================
@@ -567,9 +581,18 @@ def main(target_count=10):
progress.update(task, advance=1) progress.update(task, advance=1)
continue continue
# Check acceptance criteria — OOS must be profitable (primary filter) # Monte Carlo p-value (edge significance)
if (abs(ic) > MIN_IC and sharpe > MIN_SHARPE and trades > MIN_TRADES and dd > MAX_DRAWDOWN mc_pvalue = bt_result.get('mc_pvalue')
and oos_sharpe > 0.0 and oos_monthly > 0.0):
# Rolling walk-forward metrics
wf_consistency = bt_result.get('wf_oos_consistency')
wf_sharpe_mean = bt_result.get('wf_oos_sharpe_mean')
# Check acceptance criteria — OOS must be profitable + statistically significant
mc_ok = mc_pvalue is None or mc_pvalue < 0.20 # lenient: top 20% non-random
wf_ok = wf_consistency is None or wf_consistency >= 0.5 # ≥50% of WF windows profitable
if (abs(ic or 0) > MIN_IC and sharpe > MIN_SHARPE and trades > MIN_TRADES and dd > MAX_DRAWDOWN
and oos_sharpe > 0.0 and oos_monthly > 0.0 and mc_ok and wf_ok):
# ACCEPT # ACCEPT
strategy['real_backtest'] = bt_result strategy['real_backtest'] = bt_result
strategy['metrics'] = bt_result strategy['metrics'] = bt_result
@@ -583,7 +606,7 @@ def main(target_count=10):
'ohlcv_only': OHLCV_ONLY, 'ohlcv_only': OHLCV_ONLY,
'engine': 'ftmo_v2', 'engine': 'ftmo_v2',
'txn_cost_bps': TXN_COST_BPS, 'txn_cost_bps': TXN_COST_BPS,
# Walk-forward OOS metrics # Walk-forward OOS split
'oos_sharpe': bt_result.get('oos_sharpe'), 'oos_sharpe': bt_result.get('oos_sharpe'),
'oos_monthly_return_pct': bt_result.get('oos_monthly_return_pct'), 'oos_monthly_return_pct': bt_result.get('oos_monthly_return_pct'),
'oos_max_drawdown': bt_result.get('oos_max_drawdown'), 'oos_max_drawdown': bt_result.get('oos_max_drawdown'),
@@ -592,6 +615,15 @@ def main(target_count=10):
'is_sharpe': bt_result.get('is_sharpe'), 'is_sharpe': bt_result.get('is_sharpe'),
'is_monthly_return_pct': bt_result.get('is_monthly_return_pct'), 'is_monthly_return_pct': bt_result.get('is_monthly_return_pct'),
'oos_start': bt_result.get('oos_start'), 'oos_start': bt_result.get('oos_start'),
# Rolling walk-forward
'wf_n_windows': bt_result.get('wf_n_windows'),
'wf_oos_sharpe_mean': wf_sharpe_mean,
'wf_oos_sharpe_std': bt_result.get('wf_oos_sharpe_std'),
'wf_oos_monthly_return_mean': bt_result.get('wf_oos_monthly_return_mean'),
'wf_oos_consistency': wf_consistency,
# Monte Carlo significance
'mc_pvalue': mc_pvalue,
'mc_n_permutations': bt_result.get('mc_n_permutations'),
} }
fname = f"{int(time.time())}_{strategy['strategy_name']}.json" fname = f"{int(time.time())}_{strategy['strategy_name']}.json"
@@ -614,12 +646,17 @@ def main(target_count=10):
f"IC={ic:.4f}, Sharpe={sharpe:.3f}, Trades={trades}, DD={dd:.1%}") f"IC={ic:.4f}, Sharpe={sharpe:.3f}, Trades={trades}, DD={dd:.1%}")
else: else:
oos_info = f"OOS_Sharpe={oos_sharpe:+.2f} OOS_Mon={oos_monthly:+.2f}%" if oos_sharpe is not None else "" oos_info = f"OOS_Sharpe={oos_sharpe:+.2f} OOS_Mon={oos_monthly:+.2f}%" if oos_sharpe is not None else ""
_log.info(f"REJECTED IC={ic:.4f} Sharpe={sharpe:.2f} Trades={trades} DD={dd:.1%} {oos_info}") mc_info = f" MC_p={mc_pvalue:.2f}" if mc_pvalue is not None else ""
wf_info = f" WF_consistency={wf_consistency:.0%}" if wf_consistency is not None else ""
_ic = ic or 0; _sh = sharpe or 0; _dd = dd or 0
_log.info(f"REJECTED IC={_ic:.4f} Sharpe={_sh:.2f} Trades={trades} DD={_dd:.1%} {oos_info}{mc_info}{wf_info}")
feedback_history.append( feedback_history.append(
f"Failed: IC={ic:.4f}, Sharpe={sharpe:.2f}, Trades={trades}, DD={dd:.1%}, " f"Failed: IC={_ic:.4f}, Sharpe={_sh:.2f}, Trades={trades}, DD={_dd:.1%}, "
f"OOS_Sharpe={oos_sharpe:+.2f}, OOS_Monthly={oos_monthly:+.2f}%. " f"OOS_Sharpe={oos_sharpe:+.2f}, OOS_Monthly={oos_monthly:+.2f}%"
f"Need |IC|>{MIN_IC}, Sharpe>{MIN_SHARPE}, Trades>{MIN_TRADES}, " + (f", MC_p={mc_pvalue:.2f}" if mc_pvalue is not None else "")
f"OOS_Sharpe>0 AND OOS_Monthly>0 — strategy must generalise to unseen data (2024+)." + (f", WF_consistency={wf_consistency:.0%}" if wf_consistency is not None else "")
+ f". Need |IC|>{MIN_IC}, Sharpe>{MIN_SHARPE}, Trades>{MIN_TRADES}, "
f"OOS_Sharpe>0, OOS_Monthly>0, MC_p<0.20, WF_consistency≥50%."
) )
progress.update(task, advance=1) progress.update(task, advance=1)
+17
View File
@@ -188,6 +188,8 @@ def rebacktest_one(
close=close_a, close=close_a,
signal=signal, signal=signal,
txn_cost_bps=txn_cost_bps, txn_cost_bps=txn_cost_bps,
wf_rolling=True,
mc_n_permutations=200,
) )
result["status_detail"] = result.pop("status") result["status_detail"] = result.pop("status")
result["status"] = "ok" result["status"] = "ok"
@@ -264,6 +266,15 @@ def main() -> None:
"oos_win_rate": bt.get("oos_win_rate"), "oos_win_rate": bt.get("oos_win_rate"),
"oos_n_trades": bt.get("oos_n_trades"), "oos_n_trades": bt.get("oos_n_trades"),
"oos_start": bt.get("oos_start"), "oos_start": bt.get("oos_start"),
# Rolling walk-forward
"wf_n_windows": bt.get("wf_n_windows"),
"wf_oos_sharpe_mean": bt.get("wf_oos_sharpe_mean"),
"wf_oos_sharpe_std": bt.get("wf_oos_sharpe_std"),
"wf_oos_monthly_return_mean": bt.get("wf_oos_monthly_return_mean"),
"wf_oos_consistency": bt.get("wf_oos_consistency"),
# Monte Carlo significance
"mc_pvalue": bt.get("mc_pvalue"),
"mc_n_permutations": bt.get("mc_n_permutations"),
} }
data["sharpe_ratio"] = bt.get("sharpe") data["sharpe_ratio"] = bt.get("sharpe")
data["max_drawdown"] = bt.get("max_drawdown") data["max_drawdown"] = bt.get("max_drawdown")
@@ -299,6 +310,12 @@ def main() -> None:
"oos_monthly_pct": bt.get("oos_monthly_return_pct"), "oos_monthly_pct": bt.get("oos_monthly_return_pct"),
"oos_dd": bt.get("oos_max_drawdown"), "oos_dd": bt.get("oos_max_drawdown"),
"oos_trades": bt.get("oos_n_trades"), "oos_trades": bt.get("oos_n_trades"),
# Rolling walk-forward
"wf_n_windows": bt.get("wf_n_windows"),
"wf_oos_sharpe_mean": bt.get("wf_oos_sharpe_mean"),
"wf_oos_consistency": bt.get("wf_oos_consistency"),
# Monte Carlo
"mc_pvalue": bt.get("mc_pvalue"),
} }
if "annualized_return" in bt: if "annualized_return" in bt:
row["new_annual_return_cagr"] = bt["annualized_return"] row["new_annual_return_cagr"] = bt["annualized_return"]
+240
View File
@@ -0,0 +1,240 @@
"""
Tests for backtest_signal_ftmo and walk-forward OOS validation.
Covers:
- FTMO daily/total loss limits
- Risk-based leverage calculation
- OOS split returns independent IS and OOS metrics
- OOS uses fresh FTMO simulation (not contaminated by IS losses)
- Monte Carlo permutation test helper
"""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
from rdagent.components.backtesting.vbt_backtest import (
OOS_START_DEFAULT,
backtest_signal_ftmo,
FTMO_MAX_DAILY_LOSS,
FTMO_MAX_TOTAL_LOSS,
monte_carlo_trade_pvalue,
walk_forward_rolling,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def close_2yr() -> pd.Series:
"""~3 months of synthetic 1-min EUR/USD (enough bars for all leverage/FTMO tests)."""
np.random.seed(42)
n = 90 * 1440 # 90 days × 1440 min
idx = pd.date_range("2022-01-01", periods=n, freq="1min")
price = 1.10 + np.cumsum(np.random.randn(n) * 0.00005)
return pd.Series(price, index=idx)
@pytest.fixture
def close_6yr() -> pd.Series:
"""Synthetic data crossing the 2024-01-01 IS/OOS boundary.
120 days starting 2023-09-01 ends ~2024-01-01, giving ~30 days of OOS data.
Small enough to keep tests fast.
"""
np.random.seed(7)
n = 150 * 1440 # 2023-09-01 + 150d ≈ 2024-01-28 → ~28 days of OOS data
idx = pd.date_range("2023-09-01", periods=n, freq="1min")
price = 1.10 + np.cumsum(np.random.randn(n) * 0.00005)
return pd.Series(price, index=idx)
def _random_signal(index: pd.Index, seed: int = 0) -> pd.Series:
np.random.seed(seed)
return pd.Series(np.random.choice([-1.0, 0.0, 1.0], size=len(index)), index=index)
# ---------------------------------------------------------------------------
# FTMO leverage tests
# ---------------------------------------------------------------------------
def test_ftmo_result_contains_leverage_fields(close_2yr):
signal = _random_signal(close_2yr.index)
r = backtest_signal_ftmo(close_2yr, signal, oos_start=None)
assert "ftmo_leverage" in r
assert "ftmo_risk_pct" in r
assert "ftmo_stop_pips" in r
assert r["ftmo_leverage"] > 0
def test_ftmo_leverage_capped_at_max(close_2yr):
signal = _random_signal(close_2yr.index)
# With very tight stop (1 pip) risk_pct=0.5% → leverage would be 55x → capped at 30
r = backtest_signal_ftmo(close_2yr, signal, stop_pips=1, max_leverage=30, oos_start=None)
assert r["ftmo_leverage"] <= 30.0
def test_ftmo_zero_signal_produces_no_trades(close_2yr):
signal = pd.Series(0.0, index=close_2yr.index)
r = backtest_signal_ftmo(close_2yr, signal, oos_start=None)
assert r["n_trades"] == 0
assert r["total_return"] == 0.0
# ---------------------------------------------------------------------------
# OOS split tests
# ---------------------------------------------------------------------------
def test_oos_split_produces_is_and_oos_keys(close_6yr):
signal = _random_signal(close_6yr.index)
r = backtest_signal_ftmo(close_6yr, signal, oos_start="2024-01-01")
assert "is_sharpe" in r
assert "oos_sharpe" in r
assert "is_monthly_return_pct" in r
assert "oos_monthly_return_pct" in r
assert "is_n_bars" in r
assert "oos_n_bars" in r
assert r["oos_start"] == "2024-01-01"
def test_oos_split_bars_sum_to_total(close_6yr):
signal = _random_signal(close_6yr.index)
r = backtest_signal_ftmo(close_6yr, signal, oos_start="2024-01-01")
assert r["is_n_bars"] + r["oos_n_bars"] == len(close_6yr)
def test_oos_none_disables_split(close_6yr):
signal = _random_signal(close_6yr.index)
r = backtest_signal_ftmo(close_6yr, signal, oos_start=None)
assert "is_sharpe" not in r
assert "oos_sharpe" not in r
def test_oos_is_independent_of_is_losses(close_6yr):
"""OOS must use a fresh FTMO simulation — IS blowup must not zero OOS trades."""
# Force the IS period to blow up immediately with max short on rising market
rising = pd.Series(
np.linspace(1.0, 2.0, len(close_6yr)),
index=close_6yr.index,
)
always_short = pd.Series(-1.0, index=close_6yr.index)
r = backtest_signal_ftmo(rising, always_short, oos_start="2024-01-01")
# IS should be wiped out (total loss limit hit), but OOS must still trade
assert r.get("oos_n_trades", 0) is not None
assert r.get("oos_n_bars", 0) > 0
def test_oos_default_start_matches_constant(close_6yr):
signal = _random_signal(close_6yr.index)
r = backtest_signal_ftmo(close_6yr, signal)
assert r.get("oos_start") == OOS_START_DEFAULT
# ---------------------------------------------------------------------------
# Monte Carlo permutation test helper
# ---------------------------------------------------------------------------
def _monte_carlo_pvalue(close: pd.Series, signal: pd.Series, n_permutations: int = 200, seed: int = 0) -> float:
"""
Estimate p-value: fraction of random permutations that beat the real Sharpe.
p < 0.05 strategy has statistically significant edge.
"""
real_r = backtest_signal_ftmo(close, signal, oos_start=None)
real_sharpe = real_r.get("sharpe", 0.0) or 0.0
rng = np.random.default_rng(seed)
beat = 0
signal_vals = signal.values.copy()
for _ in range(n_permutations):
perm = rng.permutation(signal_vals)
perm_signal = pd.Series(perm, index=signal.index)
perm_r = backtest_signal_ftmo(close, perm_signal, oos_start=None)
if (perm_r.get("sharpe") or 0.0) >= real_sharpe:
beat += 1
return beat / n_permutations
@pytest.mark.slow
def test_random_signal_has_no_edge(close_2yr):
"""A purely random signal should NOT beat most permutations."""
signal = _random_signal(close_2yr.index, seed=42)
pval = _monte_carlo_pvalue(close_2yr, signal, n_permutations=50)
# Random vs random: p-value should be near 0.5 (not significant)
assert pval > 0.10, f"Random signal unexpectedly significant: p={pval:.2f}"
@pytest.mark.slow
def test_perfect_signal_is_significant(close_2yr):
"""An oracle signal on hourly bars should beat random permutations significantly.
Per-minute oracle trading is unprofitable due to FTMO transaction costs, so we
use 60-bar held positions (1h) where each directional move is large enough to
cover the spread.
"""
bar_ret = close_2yr.pct_change().fillna(0)
# Hourly oracle: sign of 60-bar future return, broadcast to all 60 minute bars
hourly_ret = bar_ret.rolling(60).sum().shift(-60).fillna(0)
perfect = pd.Series(np.sign(hourly_ret), index=close_2yr.index)
pval = _monte_carlo_pvalue(close_2yr, perfect, n_permutations=50)
assert pval < 0.30, f"Hourly oracle signal should beat random permutations: p={pval:.2f}"
# ---------------------------------------------------------------------------
# FTMO metrics in result dict
# ---------------------------------------------------------------------------
def test_ftmo_result_has_equity_and_profit(close_2yr):
signal = _random_signal(close_2yr.index)
r = backtest_signal_ftmo(close_2yr, signal, oos_start=None)
assert "ftmo_end_equity" in r
assert "ftmo_monthly_profit" in r
assert r["ftmo_end_equity"] > 0
# ---------------------------------------------------------------------------
# Monte Carlo trade permutation tests
# ---------------------------------------------------------------------------
def test_mc_pvalue_in_result(close_2yr):
signal = _random_signal(close_2yr.index)
r = backtest_signal_ftmo(close_2yr, signal, oos_start=None, mc_n_permutations=50)
assert "mc_pvalue" in r
assert 0.0 <= r["mc_pvalue"] <= 1.0
assert r["mc_n_permutations"] == 50
def test_mc_pvalue_disabled_by_default(close_2yr):
signal = _random_signal(close_2yr.index)
r = backtest_signal_ftmo(close_2yr, signal, oos_start=None)
assert "mc_pvalue" not in r
def test_mc_zero_trades_returns_one(close_2yr):
"""Zero-signal → no trades → p-value must be 1.0 (no edge)."""
trade_pnl = pd.Series([], dtype=float)
assert monte_carlo_trade_pvalue(trade_pnl, n_permutations=10) == 1.0
# ---------------------------------------------------------------------------
# Rolling walk-forward tests
# ---------------------------------------------------------------------------
def test_wf_rolling_keys_in_result(close_6yr):
signal = _random_signal(close_6yr.index)
r = backtest_signal_ftmo(close_6yr, signal, oos_start="2024-01-01", wf_rolling=True)
# With only ~150 days of data, windows may be 0 — just check key presence
assert "wf_n_windows" in r
def test_wf_rolling_disabled_by_default(close_6yr):
signal = _random_signal(close_6yr.index)
r = backtest_signal_ftmo(close_6yr, signal, oos_start="2024-01-01")
assert "wf_n_windows" not in r
def test_wf_consistency_range(close_6yr):
"""wf_oos_consistency must be in [0, 1] when windows exist."""
signal = _random_signal(close_6yr.index)
r = backtest_signal_ftmo(close_6yr, signal, oos_start="2024-01-01", wf_rolling=True)
c = r.get("wf_oos_consistency")
if c is not None:
assert 0.0 <= c <= 1.0
+249
View File
@@ -0,0 +1,249 @@
"""Tests for FactorAutoFixer — the pre-execution code patcher."""
import pytest
from rdagent.components.coder.factor_coder.auto_fixer import FactorAutoFixer
@pytest.fixture()
def fixer():
return FactorAutoFixer()
class TestResetIndexGroupby:
def test_replaces_level_groupby_on_reset_var(self, fixer):
code = "df_r = df.reset_index()\ndf_r['x'] = df_r.groupby(level=1)['$close'].mean()"
result = fixer.fix(code)
assert "groupby('instrument')" in result
def test_does_not_touch_normal_multiindex_groupby(self, fixer):
code = "df['x'] = df.groupby(level=1)['$close'].mean()"
result = fixer.fix(code)
assert "groupby(level=1)" in result
class TestGroupbyMixedLevels:
def test_strips_string_from_mixed_list(self, fixer):
result = fixer.fix("df.groupby(level=[1, 'date']).apply(fn)")
assert "groupby(level=1)" in result
def test_multiple_ints_kept(self, fixer):
result = fixer.fix("df.groupby(level=[0, 1, 'x']).apply(fn)")
assert "groupby(level=[0, 1])" in result
class TestGroupbyColumnOnMultiindex:
def test_instrument_date_becomes_two_level(self, fixer):
code = "df['v'] = df.groupby(['instrument', 'date'])['$volume'].cumsum()"
result = fixer.fix(code)
assert "get_level_values(1)" in result
assert "normalize()" in result
assert "level=1)" not in result.split("get_level_values")[0]
def test_date_instrument_becomes_two_level(self, fixer):
code = "df['v'] = df.groupby(['date', 'instrument'])['$volume'].cumsum()"
result = fixer.fix(code)
assert "get_level_values(0).normalize()" in result
assert "get_level_values(1)" in result
def test_single_instrument_becomes_level1(self, fixer):
result = fixer.fix("df.groupby(['instrument'])['x'].mean()")
assert "groupby(level=1)" in result
def test_reset_index_not_double_fixed(self, fixer):
# After reset_index fix emits groupby('instrument'), this fixer must NOT
# convert that to groupby(level=1).
code = "df_r = df.reset_index()\ndf_r['x'] = df_r.groupby(level=1)['p'].mean()"
result = fixer.fix(code)
assert "groupby('instrument')" in result
class TestChainedGroupby:
def test_chained_groupby_level_then_date(self, fixer):
code = "df.groupby(level=1).groupby('date')['price_volume'].transform('cumsum')"
result = fixer.fix(code)
assert "get_level_values(1)" in result
assert "get_level_values(0).normalize()" in result
assert ".groupby('date')" not in result
def test_chained_groupby_with_double_quotes(self, fixer):
code = 'df.groupby(level=0).groupby("date")["col"].sum()'
result = fixer.fix(code)
assert "get_level_values" in result
assert '.groupby("date")' not in result
def test_list_with_level_keyword_syntax_error(self, fixer):
# groupby([level=1, 'date']) is a SyntaxError — must be fixed before execution
code = "asian_vol = df[mask].groupby([level=1, 'date'])['log_return'].std()"
result = fixer.fix(code)
assert "get_level_values(1)" in result
assert "normalize()" in result
assert "level=1," not in result
def test_list_with_level_keyword_reversed(self, fixer):
code = "df.groupby(['date', level=1])['x'].mean()"
result = fixer.fix(code)
assert "get_level_values" in result
assert "level=1" not in result
class TestMinPeriodsNotTouched:
def test_small_min_periods_preserved(self, fixer):
# _fix_min_periods is disabled — LLM-set min_periods must not be changed.
# window=60, min_periods=1 should stay as-is (was wrongly raised to 60 before).
result = fixer.fix("df.groupby(level=1)['x'].transform(lambda x: x.rolling(window=60, min_periods=1).mean())")
assert "min_periods=1" in result
def test_large_window_min_periods_preserved(self, fixer):
# window=240 > 96 bars/day: if min_periods were set to 240 the output would be
# all-NaN for intraday data. Verify we leave it untouched.
result = fixer.fix("df['x'] = df.groupby(level=1)['y'].transform(lambda x: x.rolling(240, min_periods=10).std())")
assert "min_periods=10" in result
class TestInstrumentColumnAccess:
def test_instrument_column_replaced(self, fixer):
code = "df['group_key'] = df['instrument'] + '_' + df['day_id'].astype(str)"
result = fixer.fix(code)
assert "df.index.get_level_values(1)" in result
assert "df['instrument']" not in result
def test_reset_index_var_not_touched(self, fixer):
# After reset_index, 'instrument' IS a real column — must not be replaced
code = "df_r = df.reset_index()\nval = df_r['instrument'].unique()"
result = fixer.fix(code)
assert "df_r['instrument']" in result
assert "get_level_values" not in result
def test_groupby_after_instrument_fix(self, fixer):
# Combined: df['instrument'] in a groupby context
code = "df['key'] = df['instrument']\nout = df.groupby(df['key'])[['$close']].mean()"
result = fixer.fix(code)
assert "df['instrument']" not in result
def test_assignment_target_not_touched(self, fixer):
# df['instrument'] = <expr> is an assignment — must NOT be converted to
# df.index.get_level_values(1) = <expr> (SyntaxError)
code = "df['instrument'] = df.index.get_level_values('instrument')"
result = fixer.fix(code)
assert "df['instrument'] =" in result
class TestInstrumentLocMultiindex:
def test_loc_replaced_with_xs(self, fixer):
code = (
"for instrument in df.index.get_level_values('instrument').unique():\n"
" inst_df = df.loc[instrument].copy()\n"
)
result = fixer.fix(code)
assert "df.xs(instrument, level=1)" in result
assert "df.loc[instrument]" not in result
def test_loc_replaced_with_level1_int(self, fixer):
code = (
"for inst in df.index.get_level_values(1).unique():\n"
" data = df.loc[inst]\n"
)
result = fixer.fix(code)
assert "df.xs(inst, level=1)" in result
def test_loc_assignment_not_touched(self, fixer):
# Write-back df.loc[instrument] = ... must not be changed
code = (
"for instrument in df.index.get_level_values('instrument').unique():\n"
" df.loc[instrument] = modified\n"
)
result = fixer.fix(code)
assert "df.loc[instrument] = modified" in result
def test_non_instrument_loop_not_touched(self, fixer):
# for-loop not related to instrument levels must not be changed
code = "for date in dates:\n sub = df.loc[date]\n"
result = fixer.fix(code)
assert "df.loc[date]" in result
class TestGroupbyLevelStringNames:
def test_level_instrument_date_replaced(self, fixer):
code = "df.groupby(level=['instrument', 'date'])['col'].transform('sum')"
result = fixer.fix(code)
assert "get_level_values(1)" in result
assert "get_level_values(0).normalize()" in result
assert "level=['instrument', 'date']" not in result
def test_level_date_instrument_replaced(self, fixer):
code = "data.groupby(level=['date', 'instrument'])['x'].mean()"
result = fixer.fix(code)
assert "get_level_values(0).normalize()" in result
assert "get_level_values(1)" in result
def test_level_instrument_single_replaced(self, fixer):
code = "df.groupby(level=['instrument'])['vol'].sum()"
result = fixer.fix(code)
assert "groupby(level=1)" in result
assert "level=['instrument']" not in result
class TestGroupbyApplyToTransform:
def test_col_apply_lambda_replaced(self, fixer):
code = "df_overlap.groupby(level=1)['$close'].apply(lambda x: np.log(x / x.shift(1)))"
result = fixer.fix(code)
assert ".transform(" in result
assert ".apply(" not in result
def test_col_apply_lambda_preserves_lambda_body(self, fixer):
code = "series.groupby(level=1)['ret'].apply(lambda x: x.cumsum())"
result = fixer.fix(code)
assert "lambda x: x.cumsum()" in result
assert ".transform(" in result
def test_transform_reset_index_stripped(self, fixer):
# .transform() already preserves index — .reset_index() after it is wrong
code = "df['v'] = df.groupby(level=1)['x'].transform(lambda x: x.rolling(20).mean()).reset_index(level=0, drop=True)"
result = fixer.fix(code)
assert ".reset_index(level=0, drop=True)" not in result
assert ".transform(" in result
class TestZeroVolumeProxy:
def test_injects_proxy_when_volume_used(self, fixer):
code = (
"def calc():\n"
" df = pd.read_hdf('data.h5', key='data')\n"
" df['pv'] = df['$close'] * df['$volume']\n"
" return df[['pv']]\n"
)
result = fixer.fix(code)
assert "volume proxy" in result
assert "df['$volume'] = df['$high'] - df['$low']" in result
# Proxy must come right after read_hdf line
lines = result.splitlines()
hdf_idx = next(i for i, l in enumerate(lines) if "read_hdf" in l)
assert "volume proxy" in lines[hdf_idx + 1]
def test_no_injection_when_volume_absent(self, fixer):
code = "df = pd.read_hdf('data.h5', key='data')\ndf['x'] = df['$close'].pct_change()\n"
result = fixer.fix(code)
assert "volume proxy" not in result
def test_no_double_injection(self, fixer):
code = (
"def calc():\n"
" df = pd.read_hdf('data.h5', key='data')\n"
" # volume proxy: $volume is always 0 in FX data — use price-range as proxy\n"
" if (df['$volume'] == 0).all():\n"
" df['$volume'] = df['$high'] - df['$low']\n"
" df['pv'] = df['$close'] * df['$volume']\n"
)
result = fixer.fix(code)
assert result.count("volume proxy") == 1
class TestRollingDdof:
def test_removes_ddof_from_rolling_args(self, fixer):
result = fixer.fix("df.rolling(20, min_periods=1, ddof=1).std()")
assert "ddof" not in result
def test_removes_ddof_from_std_args(self, fixer):
result = fixer.fix("df.rolling(20).std(ddof=1)")
assert "ddof" not in result