mirror of
https://github.com/NicolasBohn/NexQuant.git
synced 2026-08-04 18:57:44 +00:00
Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff556fd228 | |||
| b23e145341 | |||
| 7d3765d0c0 | |||
| d53f4bbbeb | |||
| 5bf84ff835 | |||
| 571c902c2d | |||
| 0a47a667e4 | |||
| 8a945b7ce0 | |||
| 8f27854898 | |||
| dcd4697b75 | |||
| bc15434e02 | |||
| d44dcb7111 | |||
| 15084f593c | |||
| 20428f7d91 | |||
| 7d97d84100 | |||
| 9bc525a264 | |||
| 7cb6531c2a | |||
| 44c8af572e | |||
| b53749df7d | |||
| 3a1a3d5f77 | |||
| 13cbd42ecf | |||
| 64ed6b0cce | |||
| bf36f54159 | |||
| 79f1d34083 | |||
| 150a818e07 | |||
| b6d1caecc9 | |||
| 73e600bf25 | |||
| 9960633d01 | |||
| 3522a2eca1 | |||
| a5f091f1ca | |||
| 528d470754 | |||
| 910fbea27e | |||
| ab3f5f111d | |||
| a910d70d40 | |||
| 31a75eeb07 | |||
| 11f5dadd2d | |||
| 51a624c31e | |||
| 9947ea3928 | |||
| bc96d26371 | |||
| c6e8f3d3a3 | |||
| 35d2b81158 | |||
| 4fd5117af6 | |||
| 96d6923433 | |||
| ef12b33aca | |||
| a1e9417658 | |||
| a65ab828c4 | |||
| 840e12e6aa | |||
| 1d1b7b6984 | |||
| 4f1660b6aa | |||
| a52adf5b5a | |||
| 537f730c93 | |||
| 9c07b07995 | |||
| 9a47691420 | |||
| a370690ee8 | |||
| eaebd60d93 | |||
| 8070de3ae1 | |||
| ce806ea60b | |||
| 57e2609402 | |||
| bb32276332 | |||
| 35d03a8a0d | |||
| 9591c11702 | |||
| 7582e55bb3 | |||
| 27803e8b85 | |||
| 944af06a87 | |||
| 97e42d7a1a | |||
| 5481e83f03 | |||
| 88c4cc4a33 | |||
| 01889a6b64 | |||
| 443c6d47b2 | |||
| b10d3512df | |||
| d75cba934e | |||
| 38fa760429 | |||
| 0ce6f6ec6d | |||
| d17d424ee9 | |||
| 5d8e53d208 | |||
| 7880a9315a | |||
| 17bba1a920 | |||
| 360df4083a | |||
| e2c2fefe9a | |||
| 32f7d66e07 | |||
| 4d6ef04411 |
@@ -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"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ jobs:
|
|||||||
release-please:
|
release-please:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: googleapis/release-please-action@v4
|
- uses: googleapis/release-please-action@v5
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
config-file: release-please-config.json
|
config-file: release-please-config.json
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "1.2.2"
|
".": "1.4.2"
|
||||||
}
|
}
|
||||||
|
|||||||
+160
@@ -1,5 +1,165 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [1.4.2](https://github.com/TPTBusiness/Predix/compare/v1.4.1...v1.4.2) (2026-05-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add missing sys import and fix undefined acc_rate in factor eval ([c45f990](https://github.com/TPTBusiness/Predix/commit/c45f9908ee321400f0a19c57f1482e4cd1394a50))
|
||||||
|
|
||||||
|
## [1.4.1](https://github.com/TPTBusiness/Predix/compare/v1.4.0...v1.4.1) (2026-05-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* 15 bug fixes across orchestrator, runner, backtest, and infrastructure ([163687d](https://github.com/TPTBusiness/Predix/commit/163687d7e1c278a085d7052a3f958a3edb501e77))
|
||||||
|
* also catch ValueError in mean_variance for dimension mismatch ([ed73b72](https://github.com/TPTBusiness/Predix/commit/ed73b7253f7dc6459ee30dd81a1ce1194e46e9af))
|
||||||
|
* close log file handle, fix FTMO equity double-count, remove bare except ([76219a5](https://github.com/TPTBusiness/Predix/commit/76219a53efddaafc2b8bd48a0f76c1d4325e6ea5))
|
||||||
|
* correct project root paths and subprocess handling in parallel runner and CLI ([9735e3a](https://github.com/TPTBusiness/Predix/commit/9735e3a4d8f01e7b16fb9b185a002396a915cea4))
|
||||||
|
* filter NaN in max(), remove redundant ternary, handle non-finite vbt results ([f89fbb3](https://github.com/TPTBusiness/Predix/commit/f89fbb3421faf6ccdc8e68a911fd9db2c166120f))
|
||||||
|
* fix type annotation, remove unused parameter, improve import_class errors ([8b6ab73](https://github.com/TPTBusiness/Predix/commit/8b6ab735c05629bf6b76ddc2fd8b15617600cad7))
|
||||||
|
* resolve dead code, shell injection risk, mutable defaults, and other bugs ([afff262](https://github.com/TPTBusiness/Predix/commit/afff26287f7c4df7ddfde4e816d280fe845e11eb))
|
||||||
|
* resolve unbound variable, logger shadowing, withdraw_loop edge case, and other bugs in main scripts ([748cf9b](https://github.com/TPTBusiness/Predix/commit/748cf9b214a3e8447f1289fc4cf1e92ad6cc2f1a))
|
||||||
|
|
||||||
|
## [1.4.0](https://github.com/TPTBusiness/Predix/compare/v1.3.11...v1.4.0) (2026-05-01)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **optimizer:** add max_positions parameter to Optuna search space ([fdb4be3](https://github.com/TPTBusiness/Predix/commit/fdb4be3b3ebd93325e7821f4251148424184a40d))
|
||||||
|
|
||||||
|
## [1.3.11](https://github.com/TPTBusiness/Predix/compare/v1.3.10...v1.3.11) (2026-05-01)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **ci:** lazy import logger in predix.py and cli.py to avoid ImportError in test env ([60763e8](https://github.com/TPTBusiness/Predix/commit/60763e8eae34f41865ba8e5e65bdfde13b564b4b))
|
||||||
|
|
||||||
|
## [1.3.10](https://github.com/TPTBusiness/Predix/compare/v1.3.9...v1.3.10) (2026-05-01)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **security:** replace remaining assert statements with proper error handling ([928533d](https://github.com/TPTBusiness/Predix/commit/928533d9a81bd5062f07458fbf94d3c7fe347775))
|
||||||
|
|
||||||
|
## [1.3.9](https://github.com/TPTBusiness/Predix/compare/v1.3.8...v1.3.9) (2026-05-01)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **security:** resolve path-injection, B701, B101, B112 Bandit alerts ([20b89a0](https://github.com/TPTBusiness/Predix/commit/20b89a061843b39836e975f158404e8e2d4627cd))
|
||||||
|
|
||||||
|
## [1.3.8](https://github.com/TPTBusiness/Predix/compare/v1.3.7...v1.3.8) (2026-04-30)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **deps:** relax aiohttp constraint to >=3.13.4 for litellm compatibility ([34ab192](https://github.com/TPTBusiness/Predix/commit/34ab1923a887089eb36e5cbad6cb8df16f0333ca))
|
||||||
|
* **qlib:** correct indentation in except blocks in quant_proposal and factor_runner ([8143451](https://github.com/TPTBusiness/Predix/commit/8143451e8c0ead01c4d86d19669268c7bfb15fac))
|
||||||
|
* **security:** replace eval() with ast.literal_eval in finetune validator (B307) ([0508caf](https://github.com/TPTBusiness/Predix/commit/0508caf9140d210b823fefefa28ee535ec85a0ae))
|
||||||
|
* **security:** replace shell=True subprocess calls with list args in env.py (B602) ([2012d5a](https://github.com/TPTBusiness/Predix/commit/2012d5ae4e77cc2f1ab9a48beaaac5a74695d083))
|
||||||
|
* **security:** resolve path-injection and add nosec for safe temp paths (B108, py/path-injection) ([6727480](https://github.com/TPTBusiness/Predix/commit/67274803bd1d14e5d1df9a063f46b2edb8501a2b))
|
||||||
|
|
||||||
|
## [1.3.7](https://github.com/TPTBusiness/Predix/compare/v1.3.6...v1.3.7) (2026-04-30)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **security:** nosec for B608/B701 false positives in UI and template code ([5eb5d7e](https://github.com/TPTBusiness/Predix/commit/5eb5d7e8fdbe90e0dced83fef4e09f5a33e96b2b))
|
||||||
|
* **security:** replace eval() with ast.literal_eval and add request timeouts (B307, B113) ([3301ada](https://github.com/TPTBusiness/Predix/commit/3301ada697ca7d3afa1a188d2a76a87ae98b4529))
|
||||||
|
* **security:** replace shell=True subprocess calls with list args (B602) ([13c08f4](https://github.com/TPTBusiness/Predix/commit/13c08f4ce6813eb7c314087921ec8c0f40074bd7))
|
||||||
|
|
||||||
|
## [1.3.6](https://github.com/TPTBusiness/Predix/compare/v1.3.5...v1.3.6) (2026-04-30)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **security:** real fix for B110 (logging in factor_proposal.py [#746](https://github.com/TPTBusiness/Predix/issues/746)) ([16624e0](https://github.com/TPTBusiness/Predix/commit/16624e0bd966ae4d24c4a3eb42bbc31c11da3136))
|
||||||
|
* **security:** real fix for B110 (logging in factor_runner.py [#744](https://github.com/TPTBusiness/Predix/issues/744)) ([88cf0fb](https://github.com/TPTBusiness/Predix/commit/88cf0fb8828b11c97f2f3ae2881a4900b020c6f0))
|
||||||
|
* **security:** real fix for B110 (logging in quant_proposal.py [#741](https://github.com/TPTBusiness/Predix/issues/741)) ([7cf2a64](https://github.com/TPTBusiness/Predix/commit/7cf2a644f553b054bd4b0607ea51e5372e68d90a))
|
||||||
|
* **security:** real fix for B110 (logging in quant_proposal.py [#741](https://github.com/TPTBusiness/Predix/issues/741)) ([ef985f8](https://github.com/TPTBusiness/Predix/commit/ef985f86035d8dca707c60137e6508349a0c4ae6))
|
||||||
|
* **security:** real fix for B404/B603 (sys.executable in factor_runner.py [#745](https://github.com/TPTBusiness/Predix/issues/745)) ([819655a](https://github.com/TPTBusiness/Predix/commit/819655aaa3efa76596d60501d0e8ca365df3e5e2))
|
||||||
|
* **security:** revert broken read_pickle encoding arg in kaggle template (B301) ([3574907](https://github.com/TPTBusiness/Predix/commit/35749073c91e69f63ddaad61dae3f2b799327e63))
|
||||||
|
* **security:** validate SQL identifiers in _add_column_if_not_exists (B608) ([e10dfa2](https://github.com/TPTBusiness/Predix/commit/e10dfa2576038e911f83595d3b466c261bc0cd54))
|
||||||
|
* **security:** whitelist-validate metric column in get_top_factors (B608) ([e50519f](https://github.com/TPTBusiness/Predix/commit/e50519fe066e68aec2f19b83df4f643c3c22053d))
|
||||||
|
|
||||||
|
## [1.3.5](https://github.com/TPTBusiness/Predix/compare/v1.3.4...v1.3.5) (2026-04-27)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **auto-fixer:** add five new factor code fixes for groupby/apply errors ([449c8fd](https://github.com/TPTBusiness/Predix/commit/449c8fd70a327e604dcca122e4a134f0cca918e4))
|
||||||
|
* **auto-fixer:** add four new factor code fixes for common runtime errors ([40484f6](https://github.com/TPTBusiness/Predix/commit/40484f6d300425da481f1edd325da4acbc06ec7d))
|
||||||
|
* **auto-fixer:** add groupby([level=N,'date']) SyntaxError fix ([ca77c00](https://github.com/TPTBusiness/Predix/commit/ca77c005bea4abdd8854c1de2b0e8d03b7742161))
|
||||||
|
* **auto-fixer:** disable _fix_min_periods for intraday data ([77b0740](https://github.com/TPTBusiness/Predix/commit/77b0740f059349df7e769a378af728aa33b2070e))
|
||||||
|
* **auto-fixer:** fix chained groupby(level=N).groupby('date') pattern ([7d5fe32](https://github.com/TPTBusiness/Predix/commit/7d5fe32b31a19ce8b04bd8f5a430720fdb748f7a))
|
||||||
|
* **auto-fixer:** fix df.loc[instrument] DateParseError on MultiIndex frames ([b7860ea](https://github.com/TPTBusiness/Predix/commit/b7860eafc0ad26384947ce0510ecf4e9f3425807))
|
||||||
|
* **auto-fixer:** fix df['instrument'] KeyError on MultiIndex frames ([aad6bd1](https://github.com/TPTBusiness/Predix/commit/aad6bd1c7c720b3d486e0cf248337f32394773b1))
|
||||||
|
* **auto-fixer:** fix two assignment-target bugs in instrument column fixers ([421eedf](https://github.com/TPTBusiness/Predix/commit/421eedffed4b883c24397dc5581c019a3985277f))
|
||||||
|
* **auto-fixer:** preserve date dimension in groupby(['instrument','date']) fix ([b58fdd8](https://github.com/TPTBusiness/Predix/commit/b58fdd8be43720b5d4363e0f8de9a01591d4d2dc))
|
||||||
|
* **auto-fixer:** remove ddof from rolling() args, not only from std()/var() ([b0fc328](https://github.com/TPTBusiness/Predix/commit/b0fc328d0d4a041c65d8eeb32cb3f2bb86568406))
|
||||||
|
* **auto-fixer:** strip spurious .reset_index() after .transform() calls ([8708aae](https://github.com/TPTBusiness/Predix/commit/8708aae6e08728cda1875c775a76dc92e43576f3))
|
||||||
|
* **loop:** prevent step_idx advance on unhandled exceptions + fix consecutive assistant messages ([5ec4ad1](https://github.com/TPTBusiness/Predix/commit/5ec4ad1b96b5b99ef42bea7bb828cb1ef709a688))
|
||||||
|
|
||||||
|
## [1.3.4](https://github.com/TPTBusiness/Predix/compare/v1.3.3...v1.3.4) (2026-04-27)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **auto-fixer:** add five new factor code fixes for groupby/apply errors ([449c8fd](https://github.com/TPTBusiness/Predix/commit/449c8fd70a327e604dcca122e4a134f0cca918e4))
|
||||||
|
* **auto-fixer:** add four new factor code fixes for common runtime errors ([40484f6](https://github.com/TPTBusiness/Predix/commit/40484f6d300425da481f1edd325da4acbc06ec7d))
|
||||||
|
* **auto-fixer:** add groupby([level=N,'date']) SyntaxError fix ([ca77c00](https://github.com/TPTBusiness/Predix/commit/ca77c005bea4abdd8854c1de2b0e8d03b7742161))
|
||||||
|
* **auto-fixer:** disable _fix_min_periods for intraday data ([77b0740](https://github.com/TPTBusiness/Predix/commit/77b0740f059349df7e769a378af728aa33b2070e))
|
||||||
|
* **auto-fixer:** fix chained groupby(level=N).groupby('date') pattern ([7d5fe32](https://github.com/TPTBusiness/Predix/commit/7d5fe32b31a19ce8b04bd8f5a430720fdb748f7a))
|
||||||
|
* **auto-fixer:** fix df.loc[instrument] DateParseError on MultiIndex frames ([b7860ea](https://github.com/TPTBusiness/Predix/commit/b7860eafc0ad26384947ce0510ecf4e9f3425807))
|
||||||
|
* **auto-fixer:** fix df['instrument'] KeyError on MultiIndex frames ([aad6bd1](https://github.com/TPTBusiness/Predix/commit/aad6bd1c7c720b3d486e0cf248337f32394773b1))
|
||||||
|
* **auto-fixer:** preserve date dimension in groupby(['instrument','date']) fix ([b58fdd8](https://github.com/TPTBusiness/Predix/commit/b58fdd8be43720b5d4363e0f8de9a01591d4d2dc))
|
||||||
|
* **auto-fixer:** remove ddof from rolling() args, not only from std()/var() ([b0fc328](https://github.com/TPTBusiness/Predix/commit/b0fc328d0d4a041c65d8eeb32cb3f2bb86568406))
|
||||||
|
* **backtest:** replace broken MC permutation test with binomial win-rate test ([c38d894](https://github.com/TPTBusiness/Predix/commit/c38d89478f586825bfca5715a96ca70ccd8791a3))
|
||||||
|
* **factors:** detect and correct look-ahead bias in daily-constant factors ([eb490a4](https://github.com/TPTBusiness/Predix/commit/eb490a461b66cbd815ae53ac5205115754712432))
|
||||||
|
* **factors:** extend look-ahead rules to session factors and add intraday-factor guidance ([c24c100](https://github.com/TPTBusiness/Predix/commit/c24c100442d6487686c0578de0b32d240fcbf215))
|
||||||
|
* **loop:** compress old experiment history in proposal prompt to reduce context size ([4bf90a9](https://github.com/TPTBusiness/Predix/commit/4bf90a905ba8b2aba2a818191c19998088cccaaf))
|
||||||
|
* **loop:** prevent step_idx advance on unhandled exceptions + fix consecutive assistant messages ([5ec4ad1](https://github.com/TPTBusiness/Predix/commit/5ec4ad1b96b5b99ef42bea7bb828cb1ef709a688))
|
||||||
|
|
||||||
|
## [1.3.3](https://github.com/TPTBusiness/Predix/compare/v1.3.2...v1.3.3) (2026-04-25)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **backtest:** replace broken MC permutation test with binomial win-rate test ([c38d894](https://github.com/TPTBusiness/Predix/commit/c38d89478f586825bfca5715a96ca70ccd8791a3))
|
||||||
|
* **factors:** detect and correct look-ahead bias in daily-constant factors ([eb490a4](https://github.com/TPTBusiness/Predix/commit/eb490a461b66cbd815ae53ac5205115754712432))
|
||||||
|
* **factors:** extend look-ahead rules to session factors and add intraday-factor guidance ([c24c100](https://github.com/TPTBusiness/Predix/commit/c24c100442d6487686c0578de0b32d240fcbf215))
|
||||||
|
* **loop:** compress old experiment history in proposal prompt to reduce context size ([4bf90a9](https://github.com/TPTBusiness/Predix/commit/4bf90a905ba8b2aba2a818191c19998088cccaaf))
|
||||||
|
* **strategies:** guard against None IC in acceptance check, disable slow wf_rolling ([2197f52](https://github.com/TPTBusiness/Predix/commit/2197f52150a50ef38d9e70991d7e48c8c30caec4))
|
||||||
|
* **strategies:** handle None ic/sharpe/dd in rejected strategy log output ([ad2ad3a](https://github.com/TPTBusiness/Predix/commit/ad2ad3ab3360ea75ed3bbc90c12098b9c5cc0114))
|
||||||
|
|
||||||
|
## [1.3.2](https://github.com/TPTBusiness/Predix/compare/v1.3.1...v1.3.2) (2026-04-23)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **strategies:** guard against None IC in acceptance check, disable slow wf_rolling ([2197f52](https://github.com/TPTBusiness/Predix/commit/2197f52150a50ef38d9e70991d7e48c8c30caec4))
|
||||||
|
* **strategies:** handle None ic/sharpe/dd in rejected strategy log output ([ad2ad3a](https://github.com/TPTBusiness/Predix/commit/ad2ad3ab3360ea75ed3bbc90c12098b9c5cc0114))
|
||||||
|
|
||||||
|
## [1.3.1](https://github.com/TPTBusiness/Predix/compare/v1.3.0...v1.3.1) (2026-04-21)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **deps:** bump python-dotenv to >=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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,19 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv(Path(__file__).parent / ".env")
|
load_dotenv(Path(__file__).parent / ".env")
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
|
||||||
|
try:
|
||||||
|
from rdagent.utils.env import logger
|
||||||
|
except ImportError:
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
app = typer.Typer(help="Predix - AI Quantitative Trading Agent")
|
app = typer.Typer(help="Predix - AI Quantitative Trading Agent")
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
@@ -107,7 +115,7 @@ def _ensure_kronos_factor_in_pool(con) -> None:
|
|||||||
color = "green" if abs(ic) > 0.01 else "yellow"
|
color = "green" if abs(ic) > 0.01 else "yellow"
|
||||||
con.print(
|
con.print(
|
||||||
f" [bold {color}]Kronos Factor ready:[/bold {color}] IC={ic:.4f}, "
|
f" [bold {color}]Kronos Factor ready:[/bold {color}] IC={ic:.4f}, "
|
||||||
f"Hit-Rate={hit_rate:.1%} — added to strategy pool"
|
f"Hit-Rate={hit_rate:.1%} — added to strategy pool",
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -194,9 +202,9 @@ def quant(
|
|||||||
predix health - Check system health and configuration
|
predix health - Check system health and configuration
|
||||||
"""
|
"""
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import sys
|
|
||||||
|
|
||||||
# ---- Parallel Run Isolation ----
|
# ---- Parallel Run Isolation ----
|
||||||
# When run_id > 0, isolate all outputs (logs, results, workspace)
|
# When run_id > 0, isolate all outputs (logs, results, workspace)
|
||||||
@@ -219,10 +227,9 @@ def quant(
|
|||||||
console.print(f" [dim]Log: {log_file}[/dim]")
|
console.print(f" [dim]Log: {log_file}[/dim]")
|
||||||
console.print(f" [dim]Results: results/runs/run{run_id}/[/dim]")
|
console.print(f" [dim]Results: results/runs/run{run_id}/[/dim]")
|
||||||
console.print(f" [dim]Workspace: {workspace_dir.name}/[/dim]")
|
console.print(f" [dim]Workspace: {workspace_dir.name}/[/dim]")
|
||||||
else:
|
# Single run mode: default log file
|
||||||
# Single run mode: default log file
|
elif log_file is None:
|
||||||
if log_file is None:
|
log_file = "fin_quant.log"
|
||||||
log_file = "fin_quant.log"
|
|
||||||
|
|
||||||
# ---- Log File Setup (daily-rotated) ----
|
# ---- Log File Setup (daily-rotated) ----
|
||||||
from datetime import datetime as _dt
|
from datetime import datetime as _dt
|
||||||
@@ -230,10 +237,14 @@ def quant(
|
|||||||
_daily_dir = Path(__file__).parent / "logs" / _today
|
_daily_dir = Path(__file__).parent / "logs" / _today
|
||||||
_daily_dir.mkdir(parents=True, exist_ok=True)
|
_daily_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
_log_f = None
|
||||||
|
_orig_stdout = sys.stdout
|
||||||
|
_orig_stderr = sys.stderr
|
||||||
|
|
||||||
if log_file.lower() != "none":
|
if log_file.lower() != "none":
|
||||||
log_path = _daily_dir / log_file
|
log_path = _daily_dir / log_file
|
||||||
# Open log file for appending (raw stdout/stderr capture)
|
# Open log file for appending (raw stdout/stderr capture)
|
||||||
log_f = open(log_path, "a", encoding="utf-8")
|
_log_f = open(log_path, "a", encoding="utf-8")
|
||||||
|
|
||||||
# Redirect stdout and stderr to both console and log file
|
# Redirect stdout and stderr to both console and log file
|
||||||
class TeeWriter:
|
class TeeWriter:
|
||||||
@@ -245,18 +256,18 @@ def quant(
|
|||||||
try:
|
try:
|
||||||
s.write(data)
|
s.write(data)
|
||||||
s.flush()
|
s.flush()
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def flush(self):
|
def flush(self):
|
||||||
for s in self._streams:
|
for s in self._streams:
|
||||||
try:
|
try:
|
||||||
s.flush()
|
s.flush()
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
sys.stdout = TeeWriter(sys.__stdout__, log_f)
|
sys.stdout = TeeWriter(_orig_stdout, _log_f)
|
||||||
sys.stderr = TeeWriter(sys.__stderr__, log_f)
|
sys.stderr = TeeWriter(_orig_stderr, _log_f)
|
||||||
|
|
||||||
console.print(f"\n[dim]📝 Logging to: logs/{_today}/{log_file}[/dim]")
|
console.print(f"\n[dim]📝 Logging to: logs/{_today}/{log_file}[/dim]")
|
||||||
else:
|
else:
|
||||||
@@ -269,7 +280,7 @@ def quant(
|
|||||||
if not api_key:
|
if not api_key:
|
||||||
console.print("\n[bold red]❌ OPENROUTER_API_KEY not set in .env[/bold red]")
|
console.print("\n[bold red]❌ OPENROUTER_API_KEY not set in .env[/bold red]")
|
||||||
console.print("[yellow]Add your API key to .env:[/yellow]")
|
console.print("[yellow]Add your API key to .env:[/yellow]")
|
||||||
console.print(' OPENROUTER_API_KEY=sk-or-your-key-here')
|
console.print(" OPENROUTER_API_KEY=sk-or-your-key-here")
|
||||||
raise typer.Exit(code=1)
|
raise typer.Exit(code=1)
|
||||||
|
|
||||||
# Setup both API keys for load balancing
|
# Setup both API keys for load balancing
|
||||||
@@ -282,7 +293,7 @@ def quant(
|
|||||||
os.environ["LITELLM_PARALLEL_CALLS"] = "2"
|
os.environ["LITELLM_PARALLEL_CALLS"] = "2"
|
||||||
console.print(f"\n[bold blue]🌐 Using OpenRouter (2 API Keys):[/bold blue] [cyan]{os.environ['CHAT_MODEL']}[/cyan]")
|
console.print(f"\n[bold blue]🌐 Using OpenRouter (2 API Keys):[/bold blue] [cyan]{os.environ['CHAT_MODEL']}[/cyan]")
|
||||||
console.print(f" [dim]Keys: {api_key[:15]}*** + {api_key_2[:15]}***[/dim]")
|
console.print(f" [dim]Keys: {api_key[:15]}*** + {api_key_2[:15]}***[/dim]")
|
||||||
console.print(f" [dim]Parallel: 2 concurrent requests[/dim]")
|
console.print(" [dim]Parallel: 2 concurrent requests[/dim]")
|
||||||
else:
|
else:
|
||||||
os.environ["OPENAI_API_KEY"] = api_key
|
os.environ["OPENAI_API_KEY"] = api_key
|
||||||
console.print(f"\n[bold blue]🌐 Using OpenRouter:[/bold blue] [cyan]{os.environ['CHAT_MODEL']}[/cyan]")
|
console.print(f"\n[bold blue]🌐 Using OpenRouter:[/bold blue] [cyan]{os.environ['CHAT_MODEL']}[/cyan]")
|
||||||
@@ -300,7 +311,7 @@ def quant(
|
|||||||
# ---- Dashboards ----
|
# ---- Dashboards ----
|
||||||
if dashboard:
|
if dashboard:
|
||||||
def start_web_dashboard():
|
def start_web_dashboard():
|
||||||
console.print(f"\n[bold green]🚀 Web Dashboard: http://localhost:5000[/bold green]")
|
console.print("\n[bold green]🚀 Web Dashboard: http://localhost:5000[/bold green]")
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["python", "web/dashboard_api.py"],
|
["python", "web/dashboard_api.py"],
|
||||||
cwd=str(Path(__file__).parent),
|
cwd=str(Path(__file__).parent),
|
||||||
@@ -325,7 +336,7 @@ def quant(
|
|||||||
from rdagent.app.qlib_rd_loop.quant import main as fin_quant
|
from rdagent.app.qlib_rd_loop.quant import main as fin_quant
|
||||||
from rdagent.log.daily_log import session as _daily_session
|
from rdagent.log.daily_log import session as _daily_session
|
||||||
|
|
||||||
console.print(f"\n[bold cyan]📊 Starting EURUSD Trading Loop...[/bold cyan]\n")
|
console.print("\n[bold cyan]📊 Starting EURUSD Trading Loop...[/bold cyan]\n")
|
||||||
|
|
||||||
_ctx = {"model": model}
|
_ctx = {"model": model}
|
||||||
if run_id:
|
if run_id:
|
||||||
@@ -335,11 +346,17 @@ def quant(
|
|||||||
if step_n:
|
if step_n:
|
||||||
_ctx["steps"] = step_n
|
_ctx["steps"] = step_n
|
||||||
|
|
||||||
with _daily_session("fin_quant", **_ctx):
|
try:
|
||||||
fin_quant(
|
with _daily_session("fin_quant", **_ctx):
|
||||||
step_n=step_n,
|
fin_quant(
|
||||||
loop_n=loop_n,
|
step_n=step_n,
|
||||||
)
|
loop_n=loop_n,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if _log_f is not None:
|
||||||
|
sys.stdout = _orig_stdout
|
||||||
|
sys.stderr = _orig_stderr
|
||||||
|
_log_f.close()
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
@@ -408,8 +425,8 @@ def evaluate(
|
|||||||
predix portfolio - Select a diversified portfolio of uncorrelated factors
|
predix portfolio - Select a diversified portfolio of uncorrelated factors
|
||||||
predix quant - Generate new factors via LLM trading loop
|
predix quant - Generate new factors via LLM trading loop
|
||||||
"""
|
"""
|
||||||
from rich.panel import Panel
|
|
||||||
from rdagent.log.daily_log import session as _daily_session
|
from rdagent.log.daily_log import session as _daily_session
|
||||||
|
from rich.panel import Panel
|
||||||
|
|
||||||
console.print(Panel(
|
console.print(Panel(
|
||||||
"[bold cyan]📊 Predix Factor Evaluator[/bold cyan]\n"
|
"[bold cyan]📊 Predix Factor Evaluator[/bold cyan]\n"
|
||||||
@@ -489,11 +506,12 @@ def top(
|
|||||||
predix portfolio - Select diversified portfolio from top factors
|
predix portfolio - Select diversified portfolio from top factors
|
||||||
predix build-strategies - Combine factors into trading strategies
|
predix build-strategies - Combine factors into trading strategies
|
||||||
"""
|
"""
|
||||||
import json
|
|
||||||
import glob as glob_module
|
import glob as glob_module
|
||||||
|
import json
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from rich.table import Table
|
|
||||||
from rich.panel import Panel
|
from rich.panel import Panel
|
||||||
|
from rich.table import Table
|
||||||
|
|
||||||
factors_dir = Path(__file__).parent / "results" / "factors"
|
factors_dir = Path(__file__).parent / "results" / "factors"
|
||||||
if not factors_dir.exists():
|
if not factors_dir.exists():
|
||||||
@@ -510,6 +528,7 @@ def top(
|
|||||||
if data.get("status") == "success" and data.get("ic") is not None:
|
if data.get("status") == "success" and data.get("ic") is not None:
|
||||||
results.append(data)
|
results.append(data)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.warning("Failed to load factor file %s", f, exc_info=True)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not results:
|
if not results:
|
||||||
@@ -557,9 +576,11 @@ def top(
|
|||||||
|
|
||||||
console.print(table)
|
console.print(table)
|
||||||
|
|
||||||
# Summary
|
# Summary — filter None, NaN, and non-numeric values
|
||||||
valid_ic = [r.get("ic") for r in results if r.get("ic") is not None]
|
valid_ic = [v for v in (r.get("ic") for r in results)
|
||||||
valid_sharpe = [r.get("sharpe") for r in results if r.get("sharpe") is not None]
|
if isinstance(v, (int, float)) and v is not None and not np.isnan(v)]
|
||||||
|
valid_sharpe = [v for v in (r.get("sharpe") for r in results)
|
||||||
|
if isinstance(v, (int, float)) and v is not None and not np.isnan(v)]
|
||||||
# Filter extreme outliers for average
|
# Filter extreme outliers for average
|
||||||
valid_sharpe_filtered = [s for s in valid_sharpe if abs(s or 0) < 1e6]
|
valid_sharpe_filtered = [s for s in valid_sharpe if abs(s or 0) < 1e6]
|
||||||
|
|
||||||
@@ -634,16 +655,16 @@ def portfolio(
|
|||||||
predix top - View top factors before portfolio selection
|
predix top - View top factors before portfolio selection
|
||||||
predix build-strategies - Build strategies from selected factors
|
predix build-strategies - Build strategies from selected factors
|
||||||
"""
|
"""
|
||||||
import json
|
|
||||||
import glob as glob_module
|
import glob as glob_module
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import shutil
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from rich.table import Table
|
|
||||||
from rich.panel import Panel
|
from rich.panel import Panel
|
||||||
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn, TimeElapsedColumn
|
from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn, TimeElapsedColumn
|
||||||
|
from rich.table import Table
|
||||||
|
|
||||||
factors_dir = Path(__file__).parent / "results" / "factors"
|
factors_dir = Path(__file__).parent / "results" / "factors"
|
||||||
if not factors_dir.exists():
|
if not factors_dir.exists():
|
||||||
@@ -659,6 +680,7 @@ def portfolio(
|
|||||||
if data.get("status") == "success" and data.get("ic") is not None:
|
if data.get("status") == "success" and data.get("ic") is not None:
|
||||||
results.append(data)
|
results.append(data)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.warning("Failed to load factor file %s", f, exc_info=True)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not results:
|
if not results:
|
||||||
@@ -727,7 +749,7 @@ def portfolio(
|
|||||||
cwd=tmp_path,
|
cwd=tmp_path,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=120 # 2 min timeout per factor
|
timeout=120, # 2 min timeout per factor
|
||||||
)
|
)
|
||||||
|
|
||||||
# Read result
|
# Read result
|
||||||
@@ -788,7 +810,7 @@ def portfolio(
|
|||||||
str(i),
|
str(i),
|
||||||
cand.get("factor_name", "unknown")[:38],
|
cand.get("factor_name", "unknown")[:38],
|
||||||
f"{cand.get('ic', 0):.6f}",
|
f"{cand.get('ic', 0):.6f}",
|
||||||
f"{cand.get('sharpe', 0):.4f}" if cand.get('sharpe') else "N/A",
|
f"{cand.get('sharpe', 0):.4f}" if cand.get("sharpe") else "N/A",
|
||||||
)
|
)
|
||||||
|
|
||||||
console.print(table)
|
console.print(table)
|
||||||
@@ -806,7 +828,7 @@ def portfolio(
|
|||||||
return
|
return
|
||||||
|
|
||||||
corr_matrix = combined.corr().fillna(0)
|
corr_matrix = combined.corr().fillna(0)
|
||||||
ic_map = {cand['factor_name']: cand.get('ic', 0) for cand in candidates}
|
ic_map = {cand["factor_name"]: cand.get("ic", 0) for cand in candidates}
|
||||||
|
|
||||||
# 4. Greedy Selection
|
# 4. Greedy Selection
|
||||||
selected = []
|
selected = []
|
||||||
@@ -829,8 +851,7 @@ def portfolio(
|
|||||||
max_c = 0
|
max_c = 0
|
||||||
for sel in selected:
|
for sel in selected:
|
||||||
c = abs(corr_matrix.loc[factor, sel])
|
c = abs(corr_matrix.loc[factor, sel])
|
||||||
if c > max_c:
|
max_c = max(max_c, c)
|
||||||
max_c = c
|
|
||||||
|
|
||||||
if max_c < max_corr:
|
if max_c < max_corr:
|
||||||
selected.append(factor)
|
selected.append(factor)
|
||||||
@@ -849,23 +870,23 @@ def portfolio(
|
|||||||
|
|
||||||
for i, fname in enumerate(selected, 1):
|
for i, fname in enumerate(selected, 1):
|
||||||
# Find original data for display
|
# Find original data for display
|
||||||
data = next((c for c in candidates if c['factor_name'] == fname), {})
|
data = next((c for c in candidates if c["factor_name"] == fname), {})
|
||||||
ic = data.get('ic')
|
ic = data.get("ic")
|
||||||
sharpe = data.get('sharpe')
|
sharpe = data.get("sharpe")
|
||||||
|
|
||||||
# Calculate max corr with other selected factors
|
# Calculate max corr with other selected factors
|
||||||
max_c_val = 0
|
max_c_val = 0
|
||||||
for s in selected:
|
for s in selected:
|
||||||
if s != fname:
|
if s != fname:
|
||||||
val = abs(corr_matrix.loc[fname, s])
|
val = abs(corr_matrix.loc[fname, s])
|
||||||
if val > max_c_val: max_c_val = val
|
max_c_val = max(max_c_val, val)
|
||||||
|
|
||||||
table.add_row(
|
table.add_row(
|
||||||
str(i),
|
str(i),
|
||||||
fname[:38],
|
fname[:38],
|
||||||
f"{ic:.6f}" if ic is not None else "N/A",
|
f"{ic:.6f}" if ic is not None else "N/A",
|
||||||
f"{sharpe:.4f}" if sharpe is not None else "N/A",
|
f"{sharpe:.4f}" if sharpe is not None else "N/A",
|
||||||
f"{max_c_val:.4f}" if max_c_val > 0 else "-"
|
f"{max_c_val:.4f}" if max_c_val > 0 else "-",
|
||||||
)
|
)
|
||||||
|
|
||||||
console.print(table)
|
console.print(table)
|
||||||
@@ -875,7 +896,7 @@ def portfolio(
|
|||||||
"selected_factors": selected,
|
"selected_factors": selected,
|
||||||
"max_correlation": max_corr,
|
"max_correlation": max_corr,
|
||||||
"pool_size": top,
|
"pool_size": top,
|
||||||
"timestamp": pd.Timestamp.now().isoformat()
|
"timestamp": pd.Timestamp.now().isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
out_dir = Path(__file__).parent / "results" / "portfolio"
|
out_dir = Path(__file__).parent / "results" / "portfolio"
|
||||||
@@ -888,7 +909,7 @@ def portfolio(
|
|||||||
console.print(Panel(
|
console.print(Panel(
|
||||||
f"[bold]Portfolio saved to results/portfolio/selected_factors.json[/bold]\n"
|
f"[bold]Portfolio saved to results/portfolio/selected_factors.json[/bold]\n"
|
||||||
f"Selected {len(selected)} unique factors from {top} candidates.",
|
f"Selected {len(selected)} unique factors from {top} candidates.",
|
||||||
border_style="green"
|
border_style="green",
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
@@ -934,13 +955,12 @@ def portfolio_simple(
|
|||||||
predix top - View top factors before portfolio selection
|
predix top - View top factors before portfolio selection
|
||||||
predix build-strategies - Build strategies from selected factors
|
predix build-strategies - Build strategies from selected factors
|
||||||
"""
|
"""
|
||||||
import json
|
|
||||||
import glob as glob_module
|
import glob as glob_module
|
||||||
import re
|
import json
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from rich.table import Table
|
|
||||||
from rich.panel import Panel
|
from rich.panel import Panel
|
||||||
|
from rich.table import Table
|
||||||
|
|
||||||
factors_dir = Path(__file__).parent / "results" / "factors"
|
factors_dir = Path(__file__).parent / "results" / "factors"
|
||||||
if not factors_dir.exists():
|
if not factors_dir.exists():
|
||||||
@@ -956,6 +976,7 @@ def portfolio_simple(
|
|||||||
if data.get("status") == "success" and data.get("ic") is not None:
|
if data.get("status") == "success" and data.get("ic") is not None:
|
||||||
results.append(data)
|
results.append(data)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.warning("Failed to load factor file %s", f, exc_info=True)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not results:
|
if not results:
|
||||||
@@ -1001,7 +1022,7 @@ def portfolio_simple(
|
|||||||
best = categorized[cat][0] # Already sorted by IC
|
best = categorized[cat][0] # Already sorted by IC
|
||||||
selected.append({
|
selected.append({
|
||||||
"factor": best,
|
"factor": best,
|
||||||
"category": cat.capitalize() if cat != "other" else "Other"
|
"category": cat.capitalize() if cat != "other" else "Other",
|
||||||
})
|
})
|
||||||
|
|
||||||
# 5. Display Results
|
# 5. Display Results
|
||||||
@@ -1024,7 +1045,7 @@ def portfolio_simple(
|
|||||||
cand.get("factor_name", "unknown")[:38],
|
cand.get("factor_name", "unknown")[:38],
|
||||||
cat,
|
cat,
|
||||||
f"{cand.get('ic', 0):.6f}",
|
f"{cand.get('ic', 0):.6f}",
|
||||||
f"{cand.get('sharpe', 0):.4f}" if cand.get('sharpe') else "N/A",
|
f"{cand.get('sharpe', 0):.4f}" if cand.get("sharpe") else "N/A",
|
||||||
)
|
)
|
||||||
|
|
||||||
console.print(table)
|
console.print(table)
|
||||||
@@ -1034,7 +1055,7 @@ def portfolio_simple(
|
|||||||
"selected_factors": [item["factor"]["factor_name"] for item in selected],
|
"selected_factors": [item["factor"]["factor_name"] for item in selected],
|
||||||
"categories": {item["category"]: item["factor"]["factor_name"] for item in selected},
|
"categories": {item["category"]: item["factor"]["factor_name"] for item in selected},
|
||||||
"method": "simple_keyword_categorization",
|
"method": "simple_keyword_categorization",
|
||||||
"timestamp": str(pd.Timestamp.now().isoformat())
|
"timestamp": str(pd.Timestamp.now().isoformat()),
|
||||||
}
|
}
|
||||||
|
|
||||||
out_dir = Path(__file__).parent / "results" / "portfolio"
|
out_dir = Path(__file__).parent / "results" / "portfolio"
|
||||||
@@ -1047,7 +1068,7 @@ def portfolio_simple(
|
|||||||
console.print(Panel(
|
console.print(Panel(
|
||||||
f"[bold]Simple Portfolio saved to results/portfolio/portfolio_simple.json[/bold]\n"
|
f"[bold]Simple Portfolio saved to results/portfolio/portfolio_simple.json[/bold]\n"
|
||||||
f"Selected {len(selected)} factors across {len([c for c in categorized if categorized[c]])} categories.",
|
f"Selected {len(selected)} factors across {len([c for c in categorized if categorized[c]])} categories.",
|
||||||
border_style="green"
|
border_style="green",
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
@@ -1110,12 +1131,10 @@ def build_strategies(
|
|||||||
predix portfolio - Select diversified factors before combining
|
predix portfolio - Select diversified factors before combining
|
||||||
predix top - View top factors before building strategies
|
predix top - View top factors before building strategies
|
||||||
"""
|
"""
|
||||||
import pandas as pd
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from rich.table import Table
|
|
||||||
from rich.panel import Panel
|
|
||||||
|
|
||||||
from rdagent.scenarios.qlib.developer.strategy_builder import StrategyBuilder
|
from rdagent.scenarios.qlib.developer.strategy_builder import StrategyBuilder
|
||||||
|
from rich.panel import Panel
|
||||||
|
from rich.table import Table
|
||||||
|
|
||||||
console.print(Panel(
|
console.print(Panel(
|
||||||
"[bold cyan]🏗️ Predix Strategy Builder[/bold cyan]\n"
|
"[bold cyan]🏗️ Predix Strategy Builder[/bold cyan]\n"
|
||||||
@@ -1271,9 +1290,10 @@ def build_strategies_ai(
|
|||||||
predix quant - Generate new alpha factors via LLM trading loop
|
predix quant - Generate new alpha factors via LLM trading loop
|
||||||
predix evaluate - Evaluate factors before strategy building
|
predix evaluate - Evaluate factors before strategy building
|
||||||
"""
|
"""
|
||||||
from rich.panel import Panel
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from rich.panel import Panel
|
||||||
|
|
||||||
console.print(Panel(
|
console.print(Panel(
|
||||||
"[bold cyan]🧠 StrategyCoSTEER - AI Strategy Builder[/bold cyan]\n"
|
"[bold cyan]🧠 StrategyCoSTEER - AI Strategy Builder[/bold cyan]\n"
|
||||||
"Generating trading strategies from existing factors\n"
|
"Generating trading strategies from existing factors\n"
|
||||||
@@ -1326,8 +1346,8 @@ def build_strategies_ai(
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Load evaluated factors
|
# Load evaluated factors
|
||||||
import json
|
|
||||||
import glob as glob_module
|
import glob as glob_module
|
||||||
|
import json
|
||||||
|
|
||||||
factors = []
|
factors = []
|
||||||
for f in glob_module.glob(str(factors_dir / "*.json")):
|
for f in glob_module.glob(str(factors_dir / "*.json")):
|
||||||
@@ -1337,6 +1357,7 @@ def build_strategies_ai(
|
|||||||
if data.get("status") == "success" and data.get("ic") is not None:
|
if data.get("status") == "success" and data.get("ic") is not None:
|
||||||
factors.append(data)
|
factors.append(data)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.warning("Failed to load factor file %s", f, exc_info=True)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if len(factors) < 10:
|
if len(factors) < 10:
|
||||||
@@ -1410,15 +1431,15 @@ def build_strategies_ai(
|
|||||||
|
|
||||||
for i, r in enumerate(results, 1):
|
for i, r in enumerate(results, 1):
|
||||||
# Monthly return: use real backtest if available, else estimate
|
# Monthly return: use real backtest if available, else estimate
|
||||||
rb = r.get('real_backtest', {})
|
rb = r.get("real_backtest", {})
|
||||||
if isinstance(rb, dict) and rb.get('status') == 'success':
|
if isinstance(rb, dict) and rb.get("status") == "success":
|
||||||
monthly_pct = rb.get('monthly_return_pct', r.get('monthly_return_pct', 0))
|
monthly_pct = rb.get("monthly_return_pct", r.get("monthly_return_pct", 0))
|
||||||
n_trades = rb.get('n_trades', '-')
|
n_trades = rb.get("n_trades", "-")
|
||||||
real_ic = rb.get('ic', 0)
|
real_ic = rb.get("ic", 0)
|
||||||
else:
|
else:
|
||||||
monthly_pct = r.get('monthly_return_pct', r.get('real_monthly_return', 0))
|
monthly_pct = r.get("monthly_return_pct", r.get("real_monthly_return", 0))
|
||||||
n_trades = '-'
|
n_trades = "-"
|
||||||
real_ic = rb.get('ic', 0) if isinstance(rb, dict) else 0
|
real_ic = rb.get("ic", 0) if isinstance(rb, dict) else 0
|
||||||
|
|
||||||
table.add_row(
|
table.add_row(
|
||||||
str(i),
|
str(i),
|
||||||
@@ -1511,7 +1532,7 @@ def status():
|
|||||||
# Process check
|
# Process check
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["pgrep", "-f", "fin_quant"],
|
["pgrep", "-f", "fin_quant"],
|
||||||
capture_output=True, text=True
|
capture_output=True, text=True,
|
||||||
)
|
)
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
console.print("[bold green]✅ Trading Loop: RUNNING[/bold green]")
|
console.print("[bold green]✅ Trading Loop: RUNNING[/bold green]")
|
||||||
@@ -1529,7 +1550,7 @@ def status():
|
|||||||
factors = c.fetchone()[0]
|
factors = c.fetchone()[0]
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
console.print(f"\n📊 Results:")
|
console.print("\n📊 Results:")
|
||||||
console.print(f" Backtest runs: {runs}")
|
console.print(f" Backtest runs: {runs}")
|
||||||
console.print(f" Factors: {factors}")
|
console.print(f" Factors: {factors}")
|
||||||
|
|
||||||
@@ -1552,6 +1573,7 @@ def _load_strategies():
|
|||||||
try:
|
try:
|
||||||
raw = json.loads(p.read_text())
|
raw = json.loads(p.read_text())
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.warning("Failed to load strategy file %s", p, exc_info=True)
|
||||||
continue
|
continue
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
continue
|
continue
|
||||||
@@ -1605,6 +1627,7 @@ def best(
|
|||||||
$ predix best -n 50 --export /tmp/top.json
|
$ predix best -n 50 --export /tmp/top.json
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
items = _load_strategies()
|
items = _load_strategies()
|
||||||
@@ -1721,7 +1744,7 @@ def kronos_factor(
|
|||||||
console.print("Run data conversion first — see README Data Setup section.")
|
console.print("Run data conversion first — see README Data Setup section.")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
console.print(f"[bold]Kronos Factor Generator[/bold]")
|
console.print("[bold]Kronos Factor Generator[/bold]")
|
||||||
console.print(f" Context: [cyan]{context}[/cyan] bars | Pred: [cyan]{pred}[/cyan] bars | Device: [cyan]{_device}[/cyan]")
|
console.print(f" Context: [cyan]{context}[/cyan] bars | Pred: [cyan]{pred}[/cyan] bars | Device: [cyan]{_device}[/cyan]")
|
||||||
|
|
||||||
from rdagent.components.coder.kronos_adapter import build_kronos_factor
|
from rdagent.components.coder.kronos_adapter import build_kronos_factor
|
||||||
@@ -1804,7 +1827,7 @@ def kronos_eval(
|
|||||||
console.print(f"[red]ERROR: Data not found at {data_path}[/red]")
|
console.print(f"[red]ERROR: Data not found at {data_path}[/red]")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
console.print(f"[bold]Kronos Model Evaluator[/bold] (alongside LightGBM)")
|
console.print("[bold]Kronos Model Evaluator[/bold] (alongside LightGBM)")
|
||||||
console.print(f" Context: [cyan]{context}[/cyan] bars | Pred: [cyan]{pred}[/cyan] bars | Device: [cyan]{_device}[/cyan]")
|
console.print(f" Context: [cyan]{context}[/cyan] bars | Pred: [cyan]{pred}[/cyan] bars | Device: [cyan]{_device}[/cyan]")
|
||||||
console.print(" Running evaluation...")
|
console.print(" Running evaluation...")
|
||||||
|
|
||||||
@@ -1819,12 +1842,12 @@ def kronos_eval(
|
|||||||
batch_size=batch_size,
|
batch_size=batch_size,
|
||||||
)
|
)
|
||||||
|
|
||||||
console.print(f"\n[bold]Kronos-mini Results[/bold]")
|
console.print("\n[bold]Kronos-mini Results[/bold]")
|
||||||
console.print(f" Predictions: [cyan]{metrics['n_predictions']}[/cyan]")
|
console.print(f" Predictions: [cyan]{metrics['n_predictions']}[/cyan]")
|
||||||
console.print(f" IC (mean): [{'green' if metrics['IC_mean'] > 0.02 else 'yellow'}]{metrics['IC_mean']:.4f}[/]")
|
console.print(f" IC (mean): [{'green' if metrics['IC_mean'] > 0.02 else 'yellow'}]{metrics['IC_mean']:.4f}[/]")
|
||||||
console.print(f" IC IR: [{'green' if metrics['IC_IR'] > 0.5 else 'yellow'}]{metrics['IC_IR']:.4f}[/] (>0.5 = strong signal)")
|
console.print(f" IC IR: [{'green' if metrics['IC_IR'] > 0.5 else 'yellow'}]{metrics['IC_IR']:.4f}[/] (>0.5 = strong signal)")
|
||||||
console.print(f" Hit Rate: [{'green' if metrics['hit_rate'] > 0.52 else 'yellow'}]{metrics['hit_rate']:.2%}[/] (>50% = directionally useful)")
|
console.print(f" Hit Rate: [{'green' if metrics['hit_rate'] > 0.52 else 'yellow'}]{metrics['hit_rate']:.2%}[/] (>50% = directionally useful)")
|
||||||
console.print(f"\n[dim]Reference: LightGBM baseline IC typically 0.01–0.05 on 1-min EUR/USD[/dim]")
|
console.print("\n[dim]Reference: LightGBM baseline IC typically 0.01–0.05 on 1-min EUR/USD[/dim]")
|
||||||
|
|
||||||
import json as _json
|
import json as _json
|
||||||
out_dir = Path("results/kronos")
|
out_dir = Path("results/kronos")
|
||||||
|
|||||||
+1
-1
@@ -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"
|
||||||
|
|||||||
+125
-108
@@ -21,11 +21,17 @@ load_dotenv(".env")
|
|||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
from importlib.resources import path as rpath
|
from importlib.resources import path as rpath
|
||||||
from typing import Dict, Optional
|
from typing import Annotated
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from typing_extensions import Annotated
|
|
||||||
|
try:
|
||||||
|
from rdagent.utils.env import logger
|
||||||
|
except ImportError:
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from rdagent.app.data_science.loop import main as data_science
|
from rdagent.app.data_science.loop import main as data_science
|
||||||
from rdagent.app.finetune.llm.loop import main as llm_finetune
|
from rdagent.app.finetune.llm.loop import main as llm_finetune
|
||||||
@@ -139,10 +145,10 @@ def ds_user_interact(port=19900):
|
|||||||
|
|
||||||
@app.command(name="fin_factor")
|
@app.command(name="fin_factor")
|
||||||
def fin_factor_cli(
|
def fin_factor_cli(
|
||||||
path: Optional[str] = None,
|
path: str | None = None,
|
||||||
step_n: Optional[int] = None,
|
step_n: int | None = None,
|
||||||
loop_n: Optional[int] = None,
|
loop_n: int | None = None,
|
||||||
all_duration: Optional[str] = None,
|
all_duration: str | None = None,
|
||||||
checkout: CheckoutOption = True,
|
checkout: CheckoutOption = True,
|
||||||
):
|
):
|
||||||
fin_factor(path=path, step_n=step_n, loop_n=loop_n, all_duration=all_duration, checkout=checkout)
|
fin_factor(path=path, step_n=step_n, loop_n=loop_n, all_duration=all_duration, checkout=checkout)
|
||||||
@@ -150,10 +156,10 @@ def fin_factor_cli(
|
|||||||
|
|
||||||
@app.command(name="fin_model")
|
@app.command(name="fin_model")
|
||||||
def fin_model_cli(
|
def fin_model_cli(
|
||||||
path: Optional[str] = None,
|
path: str | None = None,
|
||||||
step_n: Optional[int] = None,
|
step_n: int | None = None,
|
||||||
loop_n: Optional[int] = None,
|
loop_n: int | None = None,
|
||||||
all_duration: Optional[str] = None,
|
all_duration: str | None = None,
|
||||||
checkout: CheckoutOption = True,
|
checkout: CheckoutOption = True,
|
||||||
):
|
):
|
||||||
fin_model(path=path, step_n=step_n, loop_n=loop_n, all_duration=all_duration, checkout=checkout)
|
fin_model(path=path, step_n=step_n, loop_n=loop_n, all_duration=all_duration, checkout=checkout)
|
||||||
@@ -161,10 +167,10 @@ def fin_model_cli(
|
|||||||
|
|
||||||
@app.command(name="fin_quant")
|
@app.command(name="fin_quant")
|
||||||
def fin_quant_cli(
|
def fin_quant_cli(
|
||||||
path: Optional[str] = None,
|
path: str | None = None,
|
||||||
step_n: Optional[int] = None,
|
step_n: int | None = None,
|
||||||
loop_n: Optional[int] = None,
|
loop_n: int | None = None,
|
||||||
all_duration: Optional[str] = None,
|
all_duration: str | None = None,
|
||||||
checkout: CheckoutOption = True,
|
checkout: CheckoutOption = True,
|
||||||
with_dashboard: bool = typer.Option(False, "--with-dashboard/-d", help="Start web dashboard automatically"),
|
with_dashboard: bool = typer.Option(False, "--with-dashboard/-d", help="Start web dashboard automatically"),
|
||||||
with_cli_dashboard: bool = typer.Option(False, "--cli-dashboard/-c", help="Show beautiful CLI dashboard"),
|
with_cli_dashboard: bool = typer.Option(False, "--cli-dashboard/-c", help="Show beautiful CLI dashboard"),
|
||||||
@@ -224,7 +230,7 @@ def fin_quant_cli(
|
|||||||
if not api_key:
|
if not api_key:
|
||||||
console.print("\n[bold red]❌ OPENROUTER_API_KEY not set in .env[/bold red]")
|
console.print("\n[bold red]❌ OPENROUTER_API_KEY not set in .env[/bold red]")
|
||||||
console.print("[yellow]Add your API key to .env and retry:[/yellow]")
|
console.print("[yellow]Add your API key to .env and retry:[/yellow]")
|
||||||
console.print(' OPENROUTER_API_KEY=sk-or-your-key-here')
|
console.print(" OPENROUTER_API_KEY=sk-or-your-key-here")
|
||||||
raise typer.Exit(code=1)
|
raise typer.Exit(code=1)
|
||||||
|
|
||||||
os.environ["OPENAI_API_KEY"] = api_key
|
os.environ["OPENAI_API_KEY"] = api_key
|
||||||
@@ -243,8 +249,8 @@ def fin_quant_cli(
|
|||||||
console.print(f" [dim]Base URL: {os.environ['OPENAI_API_BASE']}[/dim]")
|
console.print(f" [dim]Base URL: {os.environ['OPENAI_API_BASE']}[/dim]")
|
||||||
|
|
||||||
# Wait until the llama.cpp server is fully loaded before starting the pipeline
|
# Wait until the llama.cpp server is fully loaded before starting the pipeline
|
||||||
import urllib.request
|
|
||||||
import urllib.error
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
base_url = os.environ["OPENAI_API_BASE"].removesuffix("/v1").rstrip("/")
|
base_url = os.environ["OPENAI_API_BASE"].removesuffix("/v1").rstrip("/")
|
||||||
health_url = f"{base_url}/health"
|
health_url = f"{base_url}/health"
|
||||||
@@ -278,7 +284,7 @@ def fin_quant_cli(
|
|||||||
subprocess.run(
|
subprocess.run(
|
||||||
["python", "web/dashboard_api.py"],
|
["python", "web/dashboard_api.py"],
|
||||||
cwd=str(Path(__file__).parent.parent.parent),
|
cwd=str(Path(__file__).parent.parent.parent),
|
||||||
env={**os.environ, "FLASK_ENV": "development"}
|
env={**os.environ, "FLASK_ENV": "development"},
|
||||||
)
|
)
|
||||||
|
|
||||||
dashboard_thread = threading.Thread(target=start_web_dashboard, daemon=True)
|
dashboard_thread = threading.Thread(target=start_web_dashboard, daemon=True)
|
||||||
@@ -320,9 +326,9 @@ def fin_quant_cli(
|
|||||||
|
|
||||||
@app.command(name="fin_factor_report")
|
@app.command(name="fin_factor_report")
|
||||||
def fin_factor_report_cli(
|
def fin_factor_report_cli(
|
||||||
report_folder: Optional[str] = None,
|
report_folder: str | None = None,
|
||||||
path: Optional[str] = None,
|
path: str | None = None,
|
||||||
all_duration: Optional[str] = None,
|
all_duration: str | None = None,
|
||||||
checkout: CheckoutOption = True,
|
checkout: CheckoutOption = True,
|
||||||
):
|
):
|
||||||
fin_factor_report(report_folder=report_folder, path=path, all_duration=all_duration, checkout=checkout)
|
fin_factor_report(report_folder=report_folder, path=path, all_duration=all_duration, checkout=checkout)
|
||||||
@@ -335,12 +341,12 @@ def general_model_cli(report_file_path: str):
|
|||||||
|
|
||||||
@app.command(name="data_science")
|
@app.command(name="data_science")
|
||||||
def data_science_cli(
|
def data_science_cli(
|
||||||
path: Optional[str] = None,
|
path: str | None = None,
|
||||||
checkout: CheckoutOption = True,
|
checkout: CheckoutOption = True,
|
||||||
step_n: Optional[int] = None,
|
step_n: int | None = None,
|
||||||
loop_n: Optional[int] = None,
|
loop_n: int | None = None,
|
||||||
timeout: Optional[str] = None,
|
timeout: str | None = None,
|
||||||
competition: Optional[str] = None,
|
competition: str | None = None,
|
||||||
):
|
):
|
||||||
data_science(
|
data_science(
|
||||||
path=path,
|
path=path,
|
||||||
@@ -354,16 +360,16 @@ def data_science_cli(
|
|||||||
|
|
||||||
@app.command(name="llm_finetune")
|
@app.command(name="llm_finetune")
|
||||||
def llm_finetune_cli(
|
def llm_finetune_cli(
|
||||||
path: Optional[str] = None,
|
path: str | None = None,
|
||||||
checkout: CheckoutOption = True,
|
checkout: CheckoutOption = True,
|
||||||
benchmark: Optional[str] = None,
|
benchmark: str | None = None,
|
||||||
benchmark_description: Optional[str] = None,
|
benchmark_description: str | None = None,
|
||||||
dataset: Optional[str] = None,
|
dataset: str | None = None,
|
||||||
base_model: Optional[str] = None,
|
base_model: str | None = None,
|
||||||
upper_data_size_limit: Optional[int] = None,
|
upper_data_size_limit: int | None = None,
|
||||||
step_n: Optional[int] = None,
|
step_n: int | None = None,
|
||||||
loop_n: Optional[int] = None,
|
loop_n: int | None = None,
|
||||||
timeout: Optional[str] = None,
|
timeout: str | None = None,
|
||||||
):
|
):
|
||||||
llm_finetune(
|
llm_finetune(
|
||||||
path=path,
|
path=path,
|
||||||
@@ -429,6 +435,7 @@ def rl_trading_cli(
|
|||||||
rdagent rl_trading --mode backtest --no-with-protections
|
rdagent rl_trading --mode backtest --no-with-protections
|
||||||
"""
|
"""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
@@ -440,18 +447,18 @@ def rl_trading_cli(
|
|||||||
with open(config_path) as f:
|
with open(config_path) as f:
|
||||||
config = yaml.safe_load(f) or {}
|
config = yaml.safe_load(f) or {}
|
||||||
|
|
||||||
console.print(f"\n[bold blue]🤖 RL Trading Agent[/bold blue]")
|
console.print("\n[bold blue]🤖 RL Trading Agent[/bold blue]")
|
||||||
console.print(f"Mode: [cyan]{mode}[/cyan]")
|
console.print(f"Mode: [cyan]{mode}[/cyan]")
|
||||||
console.print(f"Algorithm: [cyan]{algorithm.upper()}[/cyan]")
|
console.print(f"Algorithm: [cyan]{algorithm.upper()}[/cyan]")
|
||||||
console.print(f"Protections: {'[green]Enabled[/green]' if with_protections else '[red]Disabled[/red]'}")
|
console.print(f"Protections: {'[green]Enabled[/green]' if with_protections else '[red]Disabled[/red]'}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from rdagent.components.coder.rl import RLTradingAgent, RLCosteer, TradingEnv
|
from rdagent.components.coder.rl import RLCosteer, RLTradingAgent, TradingEnv
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
console.print(f"[bold red]Error: RL components not available.[/bold red]")
|
console.print("[bold red]Error: RL components not available.[/bold red]")
|
||||||
console.print(f"Details: {e}")
|
console.print(f"Details: {e}")
|
||||||
console.print(f"\n[yellow]Install RL dependencies:[/yellow]")
|
console.print("\n[yellow]Install RL dependencies:[/yellow]")
|
||||||
console.print(f" pip install stable-baselines3 gymnasium")
|
console.print(" pip install stable-baselines3 gymnasium")
|
||||||
raise typer.Exit(code=1)
|
raise typer.Exit(code=1)
|
||||||
|
|
||||||
if mode == "train":
|
if mode == "train":
|
||||||
@@ -467,8 +474,8 @@ def rl_trading_cli(
|
|||||||
console.print("[dim]Loading market data...[/dim]")
|
console.print("[dim]Loading market data...[/dim]")
|
||||||
# TODO: Load actual data from config
|
# TODO: Load actual data from config
|
||||||
# For now, create mock environment
|
# For now, create mock environment
|
||||||
import numpy as np
|
|
||||||
import gymnasium as gym
|
import gymnasium as gym
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
# Create simple mock environment for demonstration
|
# Create simple mock environment for demonstration
|
||||||
class MockTradingEnv(gym.Env):
|
class MockTradingEnv(gym.Env):
|
||||||
@@ -504,7 +511,7 @@ def rl_trading_cli(
|
|||||||
model_path_out.parent.mkdir(parents=True, exist_ok=True)
|
model_path_out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
agent.save(model_path_out)
|
agent.save(model_path_out)
|
||||||
|
|
||||||
console.print(f"\n[bold green]✅ Training complete![/bold green]")
|
console.print("\n[bold green]✅ Training complete![/bold green]")
|
||||||
console.print(f"Model saved to: [cyan]{model_path_out}[/cyan]")
|
console.print(f"Model saved to: [cyan]{model_path_out}[/cyan]")
|
||||||
console.print(f"Algorithm: {result['algorithm']}")
|
console.print(f"Algorithm: {result['algorithm']}")
|
||||||
console.print(f"Timesteps: {result['total_timesteps']:,}")
|
console.print(f"Timesteps: {result['total_timesteps']:,}")
|
||||||
@@ -530,9 +537,9 @@ def rl_trading_cli(
|
|||||||
agent = RLTradingAgent(algorithm=algorithm.upper())
|
agent = RLTradingAgent(algorithm=algorithm.upper())
|
||||||
|
|
||||||
# Run backtest
|
# Run backtest
|
||||||
from rdagent.components.backtesting import FactorBacktester
|
|
||||||
import pandas as pd
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from rdagent.components.backtesting import FactorBacktester
|
||||||
|
|
||||||
backtester = FactorBacktester()
|
backtester = FactorBacktester()
|
||||||
|
|
||||||
@@ -541,8 +548,8 @@ def rl_trading_cli(
|
|||||||
n_steps = 500
|
n_steps = 500
|
||||||
mock_prices = pd.Series(100 + np.cumsum(np.random.randn(n_steps) * 0.5))
|
mock_prices = pd.Series(100 + np.cumsum(np.random.randn(n_steps) * 0.5))
|
||||||
mock_indicators = pd.DataFrame({
|
mock_indicators = pd.DataFrame({
|
||||||
'rsi': np.random.uniform(30, 70, n_steps),
|
"rsi": np.random.uniform(30, 70, n_steps),
|
||||||
'macd': np.random.randn(n_steps) * 0.1,
|
"macd": np.random.randn(n_steps) * 0.1,
|
||||||
})
|
})
|
||||||
|
|
||||||
console.print("[yellow]Running backtest...[/yellow]")
|
console.print("[yellow]Running backtest...[/yellow]")
|
||||||
@@ -553,7 +560,7 @@ def rl_trading_cli(
|
|||||||
enable_protections=with_protections,
|
enable_protections=with_protections,
|
||||||
)
|
)
|
||||||
|
|
||||||
console.print(f"\n[bold green]✅ Backtest complete![/bold green]")
|
console.print("\n[bold green]✅ Backtest complete![/bold green]")
|
||||||
console.print(f" Final Equity: [green]${metrics.get('final_equity', 0):,.2f}[/green]")
|
console.print(f" Final Equity: [green]${metrics.get('final_equity', 0):,.2f}[/green]")
|
||||||
console.print(f" Sharpe Ratio: {metrics.get('sharpe_ratio', 0):.3f}")
|
console.print(f" Sharpe Ratio: {metrics.get('sharpe_ratio', 0):.3f}")
|
||||||
console.print(f" Max Drawdown: {metrics.get('max_drawdown', 0):.2%}")
|
console.print(f" Max Drawdown: {metrics.get('max_drawdown', 0):.2%}")
|
||||||
@@ -627,7 +634,7 @@ def generate_strategies_cli(
|
|||||||
rdagent generate_strategies -n 3 -i 10 --optuna-trials 50 # Deep optimization
|
rdagent generate_strategies -n 3 -i 10 --optuna-trials 50 # Deep optimization
|
||||||
"""
|
"""
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TimeRemainingColumn
|
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeRemainingColumn
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
@@ -646,7 +653,7 @@ def generate_strategies_cli(
|
|||||||
raise typer.Exit(code=1)
|
raise typer.Exit(code=1)
|
||||||
|
|
||||||
console.print(f"\n[bold blue]{'='*60}[/bold blue]")
|
console.print(f"\n[bold blue]{'='*60}[/bold blue]")
|
||||||
console.print(f"[bold blue] PREDIX Strategy Generator[/bold blue]")
|
console.print("[bold blue] PREDIX Strategy Generator[/bold blue]")
|
||||||
console.print(f"[bold blue]{'='*60}[/bold blue]")
|
console.print(f"[bold blue]{'='*60}[/bold blue]")
|
||||||
console.print(f" Strategies: [cyan]{count}[/cyan]")
|
console.print(f" Strategies: [cyan]{count}[/cyan]")
|
||||||
console.print(f" Workers: [cyan]{workers}[/cyan]")
|
console.print(f" Workers: [cyan]{workers}[/cyan]")
|
||||||
@@ -673,12 +680,12 @@ def generate_strategies_cli(
|
|||||||
_slog = _dlog.setup("strategies", **_strat_ctx)
|
_slog = _dlog.setup("strategies", **_strat_ctx)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from rdagent.components.coder.strategy_orchestrator import StrategyOrchestrator
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
from rdagent.components.coder.strategy_orchestrator import StrategyOrchestrator
|
||||||
|
|
||||||
all_results = []
|
all_results = []
|
||||||
best_strategy = None
|
best_strategy = None
|
||||||
best_sharpe = float('-inf')
|
best_sharpe = float("-inf")
|
||||||
|
|
||||||
# CONTINUOUS OPTIMIZATION LOOP
|
# CONTINUOUS OPTIMIZATION LOOP
|
||||||
for iteration in range(1, max_iterations + 1):
|
for iteration in range(1, max_iterations + 1):
|
||||||
@@ -727,7 +734,7 @@ def generate_strategies_cli(
|
|||||||
|
|
||||||
# Track best strategy
|
# Track best strategy
|
||||||
for r in results:
|
for r in results:
|
||||||
sharpe = r.get("sharpe_ratio", float('-inf'))
|
sharpe = r.get("sharpe_ratio", float("-inf"))
|
||||||
if sharpe > best_sharpe:
|
if sharpe > best_sharpe:
|
||||||
best_sharpe = sharpe
|
best_sharpe = sharpe
|
||||||
best_strategy = r
|
best_strategy = r
|
||||||
@@ -747,7 +754,7 @@ def generate_strategies_cli(
|
|||||||
rejected = [r for r in results if r.get("status") == "rejected"]
|
rejected = [r for r in results if r.get("status") == "rejected"]
|
||||||
|
|
||||||
console.print(f"\n[bold green]{'='*60}[/bold green]")
|
console.print(f"\n[bold green]{'='*60}[/bold green]")
|
||||||
console.print(f"[bold green] Strategy Generation Summary[/bold green]")
|
console.print("[bold green] Strategy Generation Summary[/bold green]")
|
||||||
console.print(f"[bold green]{'='*60}[/bold green]")
|
console.print(f"[bold green]{'='*60}[/bold green]")
|
||||||
|
|
||||||
table = Table(show_header=True, header_style="bold magenta", show_lines=True)
|
table = Table(show_header=True, header_style="bold magenta", show_lines=True)
|
||||||
@@ -776,7 +783,7 @@ def generate_strategies_cli(
|
|||||||
# Show best strategy details
|
# Show best strategy details
|
||||||
if best_strategy:
|
if best_strategy:
|
||||||
console.print(f"\n[bold gold1]{'='*60}[/bold gold1]")
|
console.print(f"\n[bold gold1]{'='*60}[/bold gold1]")
|
||||||
console.print(f"[bold gold1] BEST STRATEGY[/bold gold1]")
|
console.print("[bold gold1] BEST STRATEGY[/bold gold1]")
|
||||||
console.print(f"[bold gold1]{'='*60}[/bold gold1]")
|
console.print(f"[bold gold1]{'='*60}[/bold gold1]")
|
||||||
console.print(f" Name: [cyan]{best_strategy.get('strategy_name', 'Unknown')}[/cyan]")
|
console.print(f" Name: [cyan]{best_strategy.get('strategy_name', 'Unknown')}[/cyan]")
|
||||||
console.print(f" Sharpe: [green]{best_strategy.get('sharpe_ratio', 0):.4f}[/green]")
|
console.print(f" Sharpe: [green]{best_strategy.get('sharpe_ratio', 0):.4f}[/green]")
|
||||||
@@ -784,13 +791,13 @@ def generate_strategies_cli(
|
|||||||
console.print(f" Max DD: [yellow]{best_strategy.get('max_drawdown', 0):.2%}[/yellow]")
|
console.print(f" Max DD: [yellow]{best_strategy.get('max_drawdown', 0):.2%}[/yellow]")
|
||||||
console.print(f" Win Rate: [cyan]{best_strategy.get('win_rate', 0):.2%}[/cyan]")
|
console.print(f" Win Rate: [cyan]{best_strategy.get('win_rate', 0):.2%}[/cyan]")
|
||||||
if best_strategy.get("best_params"):
|
if best_strategy.get("best_params"):
|
||||||
console.print(f"\n [bold]Optimized Parameters:[/bold]")
|
console.print("\n [bold]Optimized Parameters:[/bold]")
|
||||||
for param, val in best_strategy["best_params"].items():
|
for param, val in best_strategy["best_params"].items():
|
||||||
console.print(f" {param}: [cyan]{val}[/cyan]")
|
console.print(f" {param}: [cyan]{val}[/cyan]")
|
||||||
console.print(f"[bold gold1]{'='*60}[/bold gold1]")
|
console.print(f"[bold gold1]{'='*60}[/bold gold1]")
|
||||||
|
|
||||||
if accepted:
|
if accepted:
|
||||||
console.print(f"\n[bold]Accepted Strategies:[/bold]")
|
console.print("\n[bold]Accepted Strategies:[/bold]")
|
||||||
acc_table = Table(show_header=True, header_style="bold cyan")
|
acc_table = Table(show_header=True, header_style="bold cyan")
|
||||||
acc_table.add_column("#", width=4)
|
acc_table.add_column("#", width=4)
|
||||||
acc_table.add_column("Strategy", width=30)
|
acc_table.add_column("Strategy", width=30)
|
||||||
@@ -813,13 +820,13 @@ def generate_strategies_cli(
|
|||||||
)
|
)
|
||||||
console.print(acc_table)
|
console.print(acc_table)
|
||||||
|
|
||||||
console.print(f"\n[bold green]Strategies saved to:[/bold green] [cyan]results/strategies_new/[/cyan]")
|
console.print("\n[bold green]Strategies saved to:[/bold green] [cyan]results/strategies_new/[/cyan]")
|
||||||
console.print(f"[bold blue]{'='*60}[/bold blue]\n")
|
console.print(f"[bold blue]{'='*60}[/bold blue]\n")
|
||||||
_slog.success(f"Generated {len(all_results)} strategies ({len([r for r in all_results if r.get('status')=='accepted'])} accepted)")
|
_slog.success(f"Generated {len(all_results)} strategies ({len([r for r in all_results if r.get('status')=='accepted'])} accepted)")
|
||||||
|
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
_slog.error(f"Strategy components not available: {e}")
|
_slog.error(f"Strategy components not available: {e}")
|
||||||
console.print(f"[bold red]Error: Strategy components not available.[/bold red]")
|
console.print("[bold red]Error: Strategy components not available.[/bold red]")
|
||||||
console.print(f"Details: {e}")
|
console.print(f"Details: {e}")
|
||||||
raise typer.Exit(code=1)
|
raise typer.Exit(code=1)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -855,17 +862,18 @@ def optimize_portfolio_cli(
|
|||||||
raise typer.Exit(code=1)
|
raise typer.Exit(code=1)
|
||||||
|
|
||||||
console.print(f"\n[bold blue]{'='*60}[/bold blue]")
|
console.print(f"\n[bold blue]{'='*60}[/bold blue]")
|
||||||
console.print(f"[bold blue] PREDIX Portfolio Optimizer[/bold blue]")
|
console.print("[bold blue] PREDIX Portfolio Optimizer[/bold blue]")
|
||||||
console.print(f"[bold blue]{'='*60}[/bold blue]")
|
console.print(f"[bold blue]{'='*60}[/bold blue]")
|
||||||
console.print(f" Top N: [cyan]{top_n}[/cyan]")
|
console.print(f" Top N: [cyan]{top_n}[/cyan]")
|
||||||
console.print(f" Method: [cyan]{method}[/cyan]")
|
console.print(f" Method: [cyan]{method}[/cyan]")
|
||||||
console.print(f"[bold blue]{'='*60}[/bold blue]\n")
|
console.print(f"[bold blue]{'='*60}[/bold blue]\n")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from rdagent.components.backtesting.risk_management import PortfolioOptimizer
|
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from rdagent.components.backtesting.risk_management import PortfolioOptimizer
|
||||||
|
|
||||||
project_root = Path(__file__).parent.parent.parent
|
project_root = Path(__file__).parent.parent.parent
|
||||||
strategies_dir = project_root / "results" / "strategies_new"
|
strategies_dir = project_root / "results" / "strategies_new"
|
||||||
|
|
||||||
@@ -882,6 +890,7 @@ def optimize_portfolio_cli(
|
|||||||
if data.get("status") == "accepted":
|
if data.get("status") == "accepted":
|
||||||
strategies.append(data)
|
strategies.append(data)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.warning("Failed to load strategy file %s", f, exc_info=True)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not strategies:
|
if not strategies:
|
||||||
@@ -1001,14 +1010,15 @@ def strategies_report_cli(
|
|||||||
rdagent strategies_report -s path/to/strategy.json # Single strategy
|
rdagent strategies_report -s path/to/strategy.json # Single strategy
|
||||||
rdagent strategies_report -o custom/reports/ # Custom output dir
|
rdagent strategies_report -o custom/reports/ # Custom output dir
|
||||||
"""
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.progress import Progress, SpinnerColumn, TextColumn
|
from rich.progress import Progress, SpinnerColumn, TextColumn
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
console.print(f"\n[bold blue]{'='*60}[/bold blue]")
|
console.print(f"\n[bold blue]{'='*60}[/bold blue]")
|
||||||
console.print(f"[bold blue] PREDIX Strategy Report Generator[/bold blue]")
|
console.print("[bold blue] PREDIX Strategy Report Generator[/bold blue]")
|
||||||
console.print(f"[bold blue]{'='*60}[/bold blue]\n")
|
console.print(f"[bold blue]{'='*60}[/bold blue]\n")
|
||||||
|
|
||||||
project_root = Path(__file__).parent.parent.parent
|
project_root = Path(__file__).parent.parent.parent
|
||||||
@@ -1060,20 +1070,20 @@ def strategies_report_cli(
|
|||||||
progress.update(task, completed=1)
|
progress.update(task, completed=1)
|
||||||
|
|
||||||
console.print(f"\n[bold green]{'='*60}[/bold green]")
|
console.print(f"\n[bold green]{'='*60}[/bold green]")
|
||||||
console.print(f"[bold green] Report Generation Complete[/bold green]")
|
console.print("[bold green] Report Generation Complete[/bold green]")
|
||||||
console.print(f"[bold green]{'='*60}[/bold green]")
|
console.print(f"[bold green]{'='*60}[/bold green]")
|
||||||
console.print(f" Reports generated: [cyan]{reports_generated}/{len(strategy_files)}[/cyan]")
|
console.print(f" Reports generated: [cyan]{reports_generated}/{len(strategy_files)}[/cyan]")
|
||||||
console.print(f" Output directory: [cyan]{output_dir_path}[/cyan]")
|
console.print(f" Output directory: [cyan]{output_dir_path}[/cyan]")
|
||||||
console.print(f"[bold green]{'='*60}[/bold green]\n")
|
console.print(f"[bold green]{'='*60}[/bold green]\n")
|
||||||
|
|
||||||
|
|
||||||
def _generate_single_strategy_report(strategy_file: Path, output_dir: Path) -> Dict:
|
def _generate_single_strategy_report(strategy_file: Path, output_dir: Path) -> dict:
|
||||||
"""Generate a report for a single strategy."""
|
"""Generate a report for a single strategy."""
|
||||||
import json
|
import json
|
||||||
|
|
||||||
import matplotlib
|
import matplotlib
|
||||||
matplotlib.use("Agg") # Non-interactive backend
|
matplotlib.use("Agg") # Non-interactive backend
|
||||||
import matplotlib.pyplot as plt
|
import matplotlib.pyplot as plt
|
||||||
import seaborn as sns
|
|
||||||
|
|
||||||
with open(strategy_file, encoding="utf-8") as f:
|
with open(strategy_file, encoding="utf-8") as f:
|
||||||
strategy = json.load(f)
|
strategy = json.load(f)
|
||||||
@@ -1148,7 +1158,7 @@ if __name__ == "__main__":
|
|||||||
@app.command(name="start_llama")
|
@app.command(name="start_llama")
|
||||||
def start_llama_cli(
|
def start_llama_cli(
|
||||||
model: str = typer.Option(
|
model: str = typer.Option(
|
||||||
None, "--model", "-m", help="Path to model file"
|
None, "--model", "-m", help="Path to model file",
|
||||||
),
|
),
|
||||||
port: int = typer.Option(8081, "--port", "-p", help="Server port"),
|
port: int = typer.Option(8081, "--port", "-p", help="Server port"),
|
||||||
gpu_layers: int = typer.Option(30, "--gpu-layers", "-g", help="GPU layers"),
|
gpu_layers: int = typer.Option(30, "--gpu-layers", "-g", help="GPU layers"),
|
||||||
@@ -1170,8 +1180,6 @@ def start_llama_cli(
|
|||||||
rdagent start_llama --gpu-layers 40 --ctx-size 4096
|
rdagent start_llama --gpu-layers 40 --ctx-size 4096
|
||||||
rdagent start_llama --reasoning
|
rdagent start_llama --reasoning
|
||||||
"""
|
"""
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
model_path = model or os.getenv(
|
model_path = model or os.getenv(
|
||||||
@@ -1208,7 +1216,7 @@ def start_llama_cli(
|
|||||||
if not reasoning:
|
if not reasoning:
|
||||||
cmd.extend(["--reasoning", "off"])
|
cmd.extend(["--reasoning", "off"])
|
||||||
|
|
||||||
print(f"🚀 Starting llama.cpp server...")
|
print("🚀 Starting llama.cpp server...")
|
||||||
print(f" Model: {Path(model_path).name}")
|
print(f" Model: {Path(model_path).name}")
|
||||||
print(f" Port: {port}")
|
print(f" Port: {port}")
|
||||||
print(f" GPU Layers: {gpu_layers}")
|
print(f" GPU Layers: {gpu_layers}")
|
||||||
@@ -1241,17 +1249,16 @@ def start_loop_cli(
|
|||||||
rdagent start_loop
|
rdagent start_loop
|
||||||
rdagent start_loop --target 5 --max-wait 3600
|
rdagent start_loop --target 5 --max-wait 3600
|
||||||
"""
|
"""
|
||||||
import subprocess
|
|
||||||
import signal
|
|
||||||
import sys
|
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
import signal
|
||||||
|
import subprocess
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
script_dir = str(Path(__file__).parent.parent.parent.parent)
|
script_dir = str(Path(__file__).parent.parent.parent)
|
||||||
generator = f"python {script_dir}/scripts/predix_smart_strategy_gen.py"
|
generator = [sys.executable, f"{script_dir}/scripts/predix_smart_strategy_gen.py"]
|
||||||
logfile = f"{script_dir}/results/logs/generator_loop.log"
|
logfile = f"{script_dir}/results/logs/generator_loop.log"
|
||||||
pidfile = "/tmp/predix_loop.pid"
|
pidfile = "/tmp/predix_loop.pid" # nosec B108 — administrative PID file, single-process daemon
|
||||||
|
|
||||||
os.makedirs(f"{script_dir}/results/logs", exist_ok=True)
|
os.makedirs(f"{script_dir}/results/logs", exist_ok=True)
|
||||||
|
|
||||||
@@ -1262,12 +1269,19 @@ def start_loop_cli(
|
|||||||
with open(logfile, "a") as f:
|
with open(logfile, "a") as f:
|
||||||
f.write(line + "\n")
|
f.write(line + "\n")
|
||||||
|
|
||||||
|
child_proc = None # track current child PID for targeted cleanup
|
||||||
|
|
||||||
def cleanup(signum=None, frame=None):
|
def cleanup(signum=None, frame=None):
|
||||||
log("Received termination signal. Cleaning up...")
|
log("Received termination signal. Cleaning up...")
|
||||||
try:
|
if child_proc is not None:
|
||||||
subprocess.run(["pkill", "-f", "predix_smart_strategy_gen.py"], capture_output=True)
|
try:
|
||||||
except Exception:
|
child_proc.terminate()
|
||||||
pass
|
child_proc.wait(timeout=10)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
child_proc.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
os.remove(pidfile)
|
os.remove(pidfile)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
@@ -1313,26 +1327,32 @@ def start_loop_cli(
|
|||||||
strat_count = len(list(strat_dir.glob("*.json"))) if strat_dir.exists() else 0
|
strat_count = len(list(strat_dir.glob("*.json"))) if strat_dir.exists() else 0
|
||||||
log(f"📁 Existing strategies: {strat_count}")
|
log(f"📁 Existing strategies: {strat_count}")
|
||||||
|
|
||||||
# Kill stale processes
|
# Kill stale child from previous iteration
|
||||||
try:
|
if child_proc is not None:
|
||||||
subprocess.run(["pkill", "-9", "-f", "predix_smart_strategy_gen.py"], capture_output=True)
|
try:
|
||||||
except Exception:
|
child_proc.terminate()
|
||||||
pass
|
child_proc.wait(timeout=10)
|
||||||
time.sleep(2)
|
except subprocess.TimeoutExpired:
|
||||||
|
child_proc.kill()
|
||||||
|
child_proc.wait()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
child_proc = None
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
# Start generator
|
# Start generator
|
||||||
log("🤖 Starting generator...")
|
log("🤖 Starting generator...")
|
||||||
proc = subprocess.Popen(
|
child_proc = subprocess.Popen(
|
||||||
generator.split(),
|
generator,
|
||||||
cwd=script_dir,
|
cwd=script_dir,
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
log(f" PID: {proc.pid}")
|
log(f" PID: {child_proc.pid}")
|
||||||
|
|
||||||
# Monitor progress
|
# Monitor progress
|
||||||
elapsed = 0
|
elapsed = 0
|
||||||
while proc.poll() is None:
|
while child_proc.poll() is None:
|
||||||
time.sleep(30)
|
time.sleep(30)
|
||||||
elapsed += 30
|
elapsed += 30
|
||||||
|
|
||||||
@@ -1341,11 +1361,12 @@ def start_loop_cli(
|
|||||||
|
|
||||||
if elapsed >= max_wait:
|
if elapsed >= max_wait:
|
||||||
log(f" ⏰ Timeout after {elapsed}s. Killing...")
|
log(f" ⏰ Timeout after {elapsed}s. Killing...")
|
||||||
proc.kill()
|
child_proc.kill()
|
||||||
break
|
break
|
||||||
|
|
||||||
# Check results
|
# Check results
|
||||||
exit_code = proc.wait()
|
exit_code = child_proc.wait()
|
||||||
|
child_proc = None
|
||||||
if exit_code == 0:
|
if exit_code == 0:
|
||||||
log("✅ Generator completed successfully")
|
log("✅ Generator completed successfully")
|
||||||
elif exit_code == -9:
|
elif exit_code == -9:
|
||||||
@@ -1386,24 +1407,24 @@ def parallel_cli(
|
|||||||
rdagent parallel -n 10 -k 2
|
rdagent parallel -n 10 -k 2
|
||||||
"""
|
"""
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from rdagent.log import daily_log as _dlog
|
from rdagent.log import daily_log as _dlog
|
||||||
|
|
||||||
project_root = Path(__file__).parent.parent.parent.parent
|
project_root = Path(__file__).parent.parent.parent
|
||||||
script = project_root / "scripts" / "predix_parallel.py"
|
script = project_root / "scripts" / "predix_parallel.py"
|
||||||
|
|
||||||
if not script.exists():
|
if not script.exists():
|
||||||
typer.echo(f"❌ Script not found: {script}")
|
typer.echo(f"❌ Script not found: {script}")
|
||||||
raise typer.Exit(code=1)
|
raise typer.Exit(code=1)
|
||||||
|
|
||||||
cmd = [sys.executable, str(script), "--runs", str(runs), "--api-keys", str(api_keys), "-m", "local"]
|
cmd = [sys.executable, str(script), "--runs", str(runs), "--api-keys", str(api_keys)]
|
||||||
|
|
||||||
_plog = _dlog.setup("parallel", runs=runs, api_keys=api_keys, model="local")
|
_plog = _dlog.setup("parallel", runs=runs, api_keys=api_keys, model="local")
|
||||||
typer.echo(f"🚀 Starting {runs} parallel runs...")
|
typer.echo(f"🚀 Starting {runs} parallel runs...")
|
||||||
typer.echo(f" Script: {script}")
|
typer.echo(f" Script: {script}")
|
||||||
typer.echo(f" API Keys: {api_keys}")
|
typer.echo(f" API Keys: {api_keys}")
|
||||||
typer.echo(f" Model: local (llama.cpp)")
|
typer.echo(" Model: local (llama.cpp)")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(cmd, cwd=str(project_root))
|
result = subprocess.run(cmd, cwd=str(project_root))
|
||||||
@@ -1437,11 +1458,11 @@ def eval_all_cli(
|
|||||||
rdagent eval_all -n 500 -p 8
|
rdagent eval_all -n 500 -p 8
|
||||||
"""
|
"""
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from rdagent.log import daily_log as _dlog
|
from rdagent.log import daily_log as _dlog
|
||||||
|
|
||||||
project_root = Path(__file__).parent.parent.parent.parent
|
project_root = Path(__file__).parent.parent.parent
|
||||||
script = project_root / "scripts" / "predix_full_eval.py"
|
script = project_root / "scripts" / "predix_full_eval.py"
|
||||||
|
|
||||||
if not script.exists():
|
if not script.exists():
|
||||||
@@ -1492,10 +1513,9 @@ def batch_backtest_cli(
|
|||||||
rdagent batch_backtest --all
|
rdagent batch_backtest --all
|
||||||
"""
|
"""
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
project_root = Path(__file__).parent.parent.parent.parent
|
project_root = Path(__file__).parent.parent.parent
|
||||||
script = project_root / "scripts" / "predix_batch_backtest.py"
|
script = project_root / "scripts" / "predix_batch_backtest.py"
|
||||||
|
|
||||||
if not script.exists():
|
if not script.exists():
|
||||||
@@ -1545,10 +1565,9 @@ def simple_eval_cli(
|
|||||||
rdagent simple_eval --all
|
rdagent simple_eval --all
|
||||||
"""
|
"""
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
project_root = Path(__file__).parent.parent.parent.parent
|
project_root = Path(__file__).parent.parent.parent
|
||||||
script = project_root / "scripts" / "predix_simple_eval.py"
|
script = project_root / "scripts" / "predix_simple_eval.py"
|
||||||
|
|
||||||
if not script.exists():
|
if not script.exists():
|
||||||
@@ -1578,7 +1597,7 @@ def simple_eval_cli(
|
|||||||
@app.command(name="rebacktest")
|
@app.command(name="rebacktest")
|
||||||
def rebacktest_cli(
|
def rebacktest_cli(
|
||||||
strategies_dir: str = typer.Option(
|
strategies_dir: str = typer.Option(
|
||||||
None, "--strategies-dir", "-d", help="Directory containing strategy JSON files"
|
None, "--strategies-dir", "-d", help="Directory containing strategy JSON files",
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -1592,10 +1611,9 @@ def rebacktest_cli(
|
|||||||
rdagent rebacktest -d results/strategies_new/
|
rdagent rebacktest -d results/strategies_new/
|
||||||
"""
|
"""
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
project_root = Path(__file__).parent.parent.parent.parent
|
project_root = Path(__file__).parent.parent.parent
|
||||||
script = project_root / "scripts" / "predix_rebacktest_strategies.py"
|
script = project_root / "scripts" / "predix_rebacktest_strategies.py"
|
||||||
|
|
||||||
if not script.exists():
|
if not script.exists():
|
||||||
@@ -1620,10 +1638,10 @@ def rebacktest_cli(
|
|||||||
@app.command(name="report")
|
@app.command(name="report")
|
||||||
def report_cli(
|
def report_cli(
|
||||||
strategy_path: str = typer.Option(
|
strategy_path: str = typer.Option(
|
||||||
None, "--strategy", "-s", help="Path to single strategy JSON (default: all strategies)"
|
None, "--strategy", "-s", help="Path to single strategy JSON (default: all strategies)",
|
||||||
),
|
),
|
||||||
output: str = typer.Option(
|
output: str = typer.Option(
|
||||||
None, "--output", "-o", help="Output directory (default: results/strategy_reports/)"
|
None, "--output", "-o", help="Output directory (default: results/strategy_reports/)",
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -1646,10 +1664,9 @@ def report_cli(
|
|||||||
rdagent report -o custom/reports/
|
rdagent report -o custom/reports/
|
||||||
"""
|
"""
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
project_root = Path(__file__).parent.parent.parent.parent
|
project_root = Path(__file__).parent.parent.parent
|
||||||
script = project_root / "scripts" / "predix_strategy_report.py"
|
script = project_root / "scripts" / "predix_strategy_report.py"
|
||||||
|
|
||||||
if not script.exists():
|
if not script.exists():
|
||||||
|
|||||||
@@ -201,6 +201,5 @@ class DataScienceBasePropSetting(KaggleBasePropSetting):
|
|||||||
DS_RD_SETTING = DataScienceBasePropSetting()
|
DS_RD_SETTING = DataScienceBasePropSetting()
|
||||||
|
|
||||||
# enable_cross_trace_diversity and llm_select_hypothesis should not be true at the same time
|
# enable_cross_trace_diversity and llm_select_hypothesis should not be true at the same time
|
||||||
assert not (
|
if DS_RD_SETTING.enable_cross_trace_diversity and DS_RD_SETTING.llm_select_hypothesis:
|
||||||
DS_RD_SETTING.enable_cross_trace_diversity and DS_RD_SETTING.llm_select_hypothesis
|
raise ValueError("enable_cross_trace_diversity and llm_select_hypothesis cannot be true at the same time")
|
||||||
), "enable_cross_trace_diversity and llm_select_hypothesis cannot be true at the same time"
|
|
||||||
|
|||||||
@@ -58,18 +58,18 @@ def main(
|
|||||||
|
|
||||||
if user_target_scenario:
|
if user_target_scenario:
|
||||||
FT_RD_SETTING.user_target_scenario = user_target_scenario
|
FT_RD_SETTING.user_target_scenario = user_target_scenario
|
||||||
assert (
|
if FT_RD_SETTING.user_target_scenario is not None:
|
||||||
FT_RD_SETTING.user_target_scenario is None
|
raise ValueError("user_target_scenario is not yet supported, please specify via benchmark and benchmark_description")
|
||||||
), "user_target_scenario is not yet supported, please specify via benchmark and benchmark_description"
|
|
||||||
if upper_data_size_limit:
|
if upper_data_size_limit:
|
||||||
FT_RD_SETTING.upper_data_size_limit = upper_data_size_limit
|
FT_RD_SETTING.upper_data_size_limit = upper_data_size_limit
|
||||||
logger.info(f"Set upper_data_size_limit to {FT_RD_SETTING.upper_data_size_limit}")
|
logger.info(f"Set upper_data_size_limit to {FT_RD_SETTING.upper_data_size_limit}")
|
||||||
if benchmark and benchmark_description:
|
if benchmark and benchmark_description:
|
||||||
FT_RD_SETTING.target_benchmark = benchmark
|
FT_RD_SETTING.target_benchmark = benchmark
|
||||||
FT_RD_SETTING.benchmark_description = benchmark_description
|
FT_RD_SETTING.benchmark_description = benchmark_description
|
||||||
assert FT_RD_SETTING.user_target_scenario or (
|
if not (
|
||||||
FT_RD_SETTING.target_benchmark and FT_RD_SETTING.benchmark_description
|
FT_RD_SETTING.user_target_scenario or (FT_RD_SETTING.target_benchmark and FT_RD_SETTING.benchmark_description)
|
||||||
), "Either user_target_scenario or target_benchmark must be specified for LLM fine-tuning."
|
):
|
||||||
|
raise ValueError("Either user_target_scenario or target_benchmark must be specified for LLM fine-tuning.")
|
||||||
|
|
||||||
# Update configuration with provided parameters
|
# Update configuration with provided parameters
|
||||||
if dataset:
|
if dataset:
|
||||||
@@ -82,9 +82,8 @@ def main(
|
|||||||
model_target = FT_RD_SETTING.base_model if FT_RD_SETTING.base_model else "auto selected model"
|
model_target = FT_RD_SETTING.base_model if FT_RD_SETTING.base_model else "auto selected model"
|
||||||
|
|
||||||
# Temporary assertion until auto-selection is implemented
|
# Temporary assertion until auto-selection is implemented
|
||||||
assert (
|
if FT_RD_SETTING.base_model is None:
|
||||||
FT_RD_SETTING.base_model is not None
|
raise ValueError("Base model auto selection not yet supported, please specify via --base-model")
|
||||||
), "Base model auto selection not yet supported, please specify via --base-model"
|
|
||||||
|
|
||||||
logger.info(f"Starting LLM fine-tuning on dataset='{data_set_target}' with model='{model_target}'")
|
logger.info(f"Starting LLM fine-tuning on dataset='{data_set_target}' with model='{model_target}'")
|
||||||
|
|
||||||
|
|||||||
@@ -24,46 +24,12 @@ from rdagent.app.finetune.llm.ui.ft_summary import render_job_summary
|
|||||||
|
|
||||||
DEFAULT_LOG_BASE = "log/"
|
DEFAULT_LOG_BASE = "log/"
|
||||||
|
|
||||||
|
from rdagent.core.utils import safe_resolve_path
|
||||||
|
|
||||||
|
|
||||||
def validate_path_within_cwd(user_path: Path) -> Path:
|
def validate_path_within_cwd(user_path: Path) -> Path:
|
||||||
"""
|
|
||||||
Validate that a user-provided path is within the current working directory.
|
|
||||||
|
|
||||||
Security: This function prevents path traversal attacks by:
|
|
||||||
1. Resolving the path to its absolute canonical form
|
|
||||||
2. Verifying it's within the CWD boundary using a normalized common prefix
|
|
||||||
3. Rejecting paths outside the boundary with ValueError
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
user_path : Path
|
|
||||||
User-provided path to validate
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
Path
|
|
||||||
Resolved absolute path if valid
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
ValueError
|
|
||||||
If path is outside the current working directory
|
|
||||||
"""
|
|
||||||
safe_root = Path.cwd().resolve()
|
safe_root = Path.cwd().resolve()
|
||||||
# Expand any user home reference and resolve without requiring the path to exist.
|
return safe_resolve_path(user_path, safe_root)
|
||||||
resolved_path = user_path.expanduser().resolve(strict=False)
|
|
||||||
|
|
||||||
# Ensure the resolved path is absolute and remains within the safe root.
|
|
||||||
safe_root_str = str(safe_root)
|
|
||||||
resolved_str = str(resolved_path)
|
|
||||||
common = os.path.commonpath([safe_root_str, resolved_str])
|
|
||||||
if common != safe_root_str:
|
|
||||||
raise ValueError("Path is outside the allowed project directory")
|
|
||||||
|
|
||||||
# This will raise ValueError if resolved_path is not within safe_root
|
|
||||||
resolved_path.relative_to(safe_root)
|
|
||||||
|
|
||||||
return resolved_path
|
|
||||||
|
|
||||||
|
|
||||||
def get_job_options(base_path: Path, safe_root: Path | None = None) -> list[str]:
|
def get_job_options(base_path: Path, safe_root: Path | None = None) -> list[str]:
|
||||||
@@ -141,19 +107,14 @@ def main():
|
|||||||
st.header("Job")
|
st.header("Job")
|
||||||
base_folder = st.text_input("Base Folder", value=default_log, key="base_folder_input")
|
base_folder = st.text_input("Base Folder", value=default_log, key="base_folder_input")
|
||||||
|
|
||||||
# Normalize and validate the base folder against the configured log root
|
safe_root = Path(default_log).expanduser().resolve()
|
||||||
root_real = os.path.realpath(str(Path(default_log).expanduser()))
|
try:
|
||||||
folder_real = os.path.realpath(str(Path(base_folder).expanduser()))
|
base_path = safe_resolve_path(Path(base_folder), safe_root)
|
||||||
if folder_real == root_real or folder_real.startswith(root_real + os.sep):
|
except ValueError:
|
||||||
base_path = Path(folder_real)
|
|
||||||
safe_root = Path(root_real)
|
|
||||||
else:
|
|
||||||
st.error("Invalid base folder: must be within the configured log directory.")
|
st.error("Invalid base folder: must be within the configured log directory.")
|
||||||
safe_root = Path(root_real)
|
|
||||||
base_path = safe_root
|
base_path = safe_root
|
||||||
|
|
||||||
# base_path is validated against safe_root – nosec B614
|
job_options = get_job_options(base_path, safe_root)
|
||||||
job_options = get_job_options(base_path, safe_root) # nosec B614 – validated above
|
|
||||||
if job_options:
|
if job_options:
|
||||||
selected_job = st.selectbox("Select Job", job_options, key="job_select")
|
selected_job = st.selectbox("Select Job", job_options, key="job_select")
|
||||||
if selected_job.startswith("."):
|
if selected_job.startswith("."):
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from typing import Any
|
|||||||
import streamlit as st
|
import streamlit as st
|
||||||
|
|
||||||
from rdagent.app.finetune.llm.ui.config import EVALUATOR_CONFIG, EventType
|
from rdagent.app.finetune.llm.ui.config import EVALUATOR_CONFIG, EventType
|
||||||
|
from rdagent.core.utils import safe_resolve_path
|
||||||
from rdagent.log.storage import FileStorage
|
from rdagent.log.storage import FileStorage
|
||||||
|
|
||||||
|
|
||||||
@@ -89,11 +90,10 @@ def extract_stage(tag: str) -> str:
|
|||||||
def get_valid_sessions(log_folder: Path, safe_root: Path | None = None) -> list[str]:
|
def get_valid_sessions(log_folder: Path, safe_root: Path | None = None) -> list[str]:
|
||||||
"""Get list of valid session directories, optionally validating against a safe root."""
|
"""Get list of valid session directories, optionally validating against a safe root."""
|
||||||
if safe_root is not None:
|
if safe_root is not None:
|
||||||
root_real = os.path.realpath(str(safe_root.expanduser()))
|
try:
|
||||||
folder_real = os.path.realpath(str(log_folder.expanduser()))
|
log_folder = safe_resolve_path(log_folder, safe_root)
|
||||||
if not (folder_real == root_real or folder_real.startswith(root_real + os.sep)):
|
except ValueError:
|
||||||
return []
|
return []
|
||||||
log_folder = Path(folder_real)
|
|
||||||
|
|
||||||
if not log_folder.exists():
|
if not log_folder.exists():
|
||||||
return []
|
return []
|
||||||
@@ -373,13 +373,11 @@ def parse_event(tag: str, content: Any, timestamp: datetime) -> Event | None:
|
|||||||
@st.cache_data(ttl=300, hash_funcs={Path: str})
|
@st.cache_data(ttl=300, hash_funcs={Path: str})
|
||||||
def load_ft_session(log_path: Path, safe_root: Path | None = None) -> Session:
|
def load_ft_session(log_path: Path, safe_root: Path | None = None) -> Session:
|
||||||
"""Load events into hierarchical session structure, optionally validating against safe root."""
|
"""Load events into hierarchical session structure, optionally validating against safe root."""
|
||||||
# Validate path is within safe_root if provided
|
|
||||||
if safe_root is not None:
|
if safe_root is not None:
|
||||||
root_real = os.path.realpath(str(safe_root.expanduser()))
|
try:
|
||||||
path_real = os.path.realpath(str(log_path.expanduser()))
|
log_path = safe_resolve_path(log_path, safe_root)
|
||||||
if not (path_real == root_real or path_real.startswith(root_real + os.sep)):
|
except ValueError:
|
||||||
return Session()
|
return Session()
|
||||||
log_path = Path(path_real)
|
|
||||||
|
|
||||||
session = Session()
|
session = Session()
|
||||||
storage = FileStorage(log_path)
|
storage = FileStorage(log_path)
|
||||||
|
|||||||
@@ -4,10 +4,9 @@ Factor workflow with session control
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional
|
from typing import Any
|
||||||
|
|
||||||
import fire
|
import fire
|
||||||
|
|
||||||
from rdagent.app.qlib_rd_loop.conf import FACTOR_PROP_SETTING
|
from rdagent.app.qlib_rd_loop.conf import FACTOR_PROP_SETTING
|
||||||
from rdagent.components.workflow.rd_loop import RDLoop
|
from rdagent.components.workflow.rd_loop import RDLoop
|
||||||
from rdagent.core.exception import CoderError, FactorEmptyError
|
from rdagent.core.exception import CoderError, FactorEmptyError
|
||||||
@@ -21,20 +20,20 @@ class FactorRDLoop(RDLoop):
|
|||||||
def running(self, prev_out: dict[str, Any]):
|
def running(self, prev_out: dict[str, Any]):
|
||||||
exp = self.runner.develop(prev_out["coding"])
|
exp = self.runner.develop(prev_out["coding"])
|
||||||
if exp is None:
|
if exp is None:
|
||||||
logger.error(f"Factor extraction failed.")
|
logger.error("Factor extraction failed.")
|
||||||
raise FactorEmptyError("Factor extraction failed.")
|
raise FactorEmptyError("Factor extraction failed.")
|
||||||
logger.log_object(exp, tag="runner result")
|
logger.log_object(exp, tag="runner result")
|
||||||
return exp
|
return exp
|
||||||
|
|
||||||
|
|
||||||
def main(
|
def main(
|
||||||
path: Optional[str] = None,
|
path: str | None = None,
|
||||||
step_n: Optional[int] = None,
|
step_n: int | None = None,
|
||||||
loop_n: Optional[int] = None,
|
loop_n: int | None = None,
|
||||||
all_duration: str | None = None,
|
all_duration: str | None = None,
|
||||||
checkout: bool = True,
|
checkout: bool = True,
|
||||||
checkout_path: Optional[str] = None,
|
checkout_path: str | None = None,
|
||||||
base_features_path: Optional[str] = None,
|
base_features_path: str | None = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -47,7 +46,7 @@ def main(
|
|||||||
dotenv run -- python rdagent/app/qlib_rd_loop/factor.py $LOG_PATH/__session__/1/0_propose --step_n 1 # `step_n` is a optional paramter
|
dotenv run -- python rdagent/app/qlib_rd_loop/factor.py $LOG_PATH/__session__/1/0_propose --step_n 1 # `step_n` is a optional paramter
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if not checkout_path is None:
|
if checkout_path is not None:
|
||||||
checkout = Path(checkout_path)
|
checkout = Path(checkout_path)
|
||||||
|
|
||||||
if path is None:
|
if path is None:
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Tuple
|
from typing import Any
|
||||||
|
|
||||||
import fire
|
import fire
|
||||||
|
|
||||||
from rdagent.app.qlib_rd_loop.conf import FACTOR_FROM_REPORT_PROP_SETTING
|
from rdagent.app.qlib_rd_loop.conf import FACTOR_FROM_REPORT_PROP_SETTING
|
||||||
from rdagent.app.qlib_rd_loop.factor import FactorRDLoop
|
from rdagent.app.qlib_rd_loop.factor import FactorRDLoop
|
||||||
from rdagent.components.document_reader.document_reader import (
|
from rdagent.components.document_reader.document_reader import (
|
||||||
@@ -12,7 +11,7 @@ from rdagent.components.document_reader.document_reader import (
|
|||||||
load_and_process_pdfs_by_langchain,
|
load_and_process_pdfs_by_langchain,
|
||||||
)
|
)
|
||||||
from rdagent.core.conf import RD_AGENT_SETTINGS
|
from rdagent.core.conf import RD_AGENT_SETTINGS
|
||||||
from rdagent.core.proposal import Hypothesis, HypothesisFeedback
|
from rdagent.core.proposal import Hypothesis
|
||||||
from rdagent.log import rdagent_logger as logger
|
from rdagent.log import rdagent_logger as logger
|
||||||
from rdagent.oai.llm_utils import APIBackend
|
from rdagent.oai.llm_utils import APIBackend
|
||||||
from rdagent.scenarios.qlib.experiment.factor_experiment import QlibFactorExperiment
|
from rdagent.scenarios.qlib.experiment.factor_experiment import QlibFactorExperiment
|
||||||
@@ -36,14 +35,14 @@ def generate_hypothesis(factor_result: dict, report_content: str) -> str:
|
|||||||
"""
|
"""
|
||||||
system_prompt = T(".prompts:hypothesis_generation.system").r()
|
system_prompt = T(".prompts:hypothesis_generation.system").r()
|
||||||
user_prompt = T(".prompts:hypothesis_generation.user").r(
|
user_prompt = T(".prompts:hypothesis_generation.user").r(
|
||||||
factor_descriptions=json.dumps(factor_result), report_content=report_content
|
factor_descriptions=json.dumps(factor_result), report_content=report_content,
|
||||||
)
|
)
|
||||||
|
|
||||||
response = APIBackend().build_messages_and_create_chat_completion(
|
response = APIBackend().build_messages_and_create_chat_completion(
|
||||||
user_prompt=user_prompt,
|
user_prompt=user_prompt,
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
json_mode=True,
|
json_mode=True,
|
||||||
json_target_type=Dict[str, str],
|
json_target_type=dict[str, str],
|
||||||
)
|
)
|
||||||
|
|
||||||
response_json = json.loads(response)
|
response_json = json.loads(response)
|
||||||
@@ -99,7 +98,7 @@ class FactorReportLoop(FactorRDLoop, metaclass=LoopMeta):
|
|||||||
super().__init__(PROP_SETTING=FACTOR_FROM_REPORT_PROP_SETTING)
|
super().__init__(PROP_SETTING=FACTOR_FROM_REPORT_PROP_SETTING)
|
||||||
if report_folder is None:
|
if report_folder is None:
|
||||||
self.judge_pdf_data_items = json.load(
|
self.judge_pdf_data_items = json.load(
|
||||||
open(FACTOR_FROM_REPORT_PROP_SETTING.report_result_json_file_path, "r")
|
open(FACTOR_FROM_REPORT_PROP_SETTING.report_result_json_file_path),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.judge_pdf_data_items = [i for i in Path(report_folder).rglob("*.pdf")]
|
self.judge_pdf_data_items = [i for i in Path(report_folder).rglob("*.pdf")]
|
||||||
@@ -118,7 +117,7 @@ class FactorReportLoop(FactorRDLoop, metaclass=LoopMeta):
|
|||||||
if exp is None:
|
if exp is None:
|
||||||
self.shift_report += 1
|
self.shift_report += 1
|
||||||
self.loop_n -= 1
|
self.loop_n -= 1
|
||||||
if self.loop_n < 0: # NOTE: on every step, we self.loop_n -= 1 at first.
|
if self.loop_n < 0: # loop_n is decremented above when reports are empty; prevents infinite skipping
|
||||||
raise self.LoopTerminationError("Reach stop criterion and stop loop")
|
raise self.LoopTerminationError("Reach stop criterion and stop loop")
|
||||||
continue
|
continue
|
||||||
exp.based_experiments = [QlibFactorExperiment(sub_tasks=[], hypothesis=exp.hypothesis)] + [
|
exp.based_experiments = [QlibFactorExperiment(sub_tasks=[], hypothesis=exp.hypothesis)] + [
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import fire
|
import fire
|
||||||
|
|
||||||
from rdagent.app.qlib_rd_loop.conf import QUANT_PROP_SETTING
|
from rdagent.app.qlib_rd_loop.conf import QUANT_PROP_SETTING
|
||||||
from rdagent.components.workflow.conf import BasePropSetting
|
from rdagent.components.workflow.conf import BasePropSetting
|
||||||
from rdagent.components.workflow.rd_loop import RDLoop
|
from rdagent.components.workflow.rd_loop import RDLoop
|
||||||
@@ -44,11 +43,11 @@ class QuantRDLoop(RDLoop):
|
|||||||
logger.log_object(self.hypothesis_gen, tag="quant hypothesis generator")
|
logger.log_object(self.hypothesis_gen, tag="quant hypothesis generator")
|
||||||
|
|
||||||
self.factor_hypothesis2experiment: Hypothesis2Experiment = import_class(
|
self.factor_hypothesis2experiment: Hypothesis2Experiment = import_class(
|
||||||
PROP_SETTING.factor_hypothesis2experiment
|
PROP_SETTING.factor_hypothesis2experiment,
|
||||||
)()
|
)()
|
||||||
logger.log_object(self.factor_hypothesis2experiment, tag="factor hypothesis2experiment")
|
logger.log_object(self.factor_hypothesis2experiment, tag="factor hypothesis2experiment")
|
||||||
self.model_hypothesis2experiment: Hypothesis2Experiment = import_class(
|
self.model_hypothesis2experiment: Hypothesis2Experiment = import_class(
|
||||||
PROP_SETTING.model_hypothesis2experiment
|
PROP_SETTING.model_hypothesis2experiment,
|
||||||
)()
|
)()
|
||||||
logger.log_object(self.model_hypothesis2experiment, tag="model hypothesis2experiment")
|
logger.log_object(self.model_hypothesis2experiment, tag="model hypothesis2experiment")
|
||||||
|
|
||||||
@@ -78,7 +77,8 @@ class QuantRDLoop(RDLoop):
|
|||||||
while True:
|
while True:
|
||||||
if self.get_unfinished_loop_cnt(self.loop_idx) < RD_AGENT_SETTINGS.get_max_parallel():
|
if self.get_unfinished_loop_cnt(self.loop_idx) < RD_AGENT_SETTINGS.get_max_parallel():
|
||||||
hypo = self._propose()
|
hypo = self._propose()
|
||||||
assert hypo.action in ["factor", "model"]
|
if hypo.action not in ["factor", "model"]:
|
||||||
|
raise ValueError(f"hypo.action must be 'factor' or 'model', got {hypo.action!r}")
|
||||||
if hypo.action == "factor":
|
if hypo.action == "factor":
|
||||||
exp = self.factor_hypothesis2experiment.convert(hypo, self.trace)
|
exp = self.factor_hypothesis2experiment.convert(hypo, self.trace)
|
||||||
else:
|
else:
|
||||||
@@ -132,7 +132,6 @@ class QuantRDLoop(RDLoop):
|
|||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
project_root = Path(__file__).parent.parent.parent.parent
|
project_root = Path(__file__).parent.parent.parent.parent
|
||||||
@@ -195,11 +194,11 @@ class QuantRDLoop(RDLoop):
|
|||||||
if prev_out["direct_exp_gen"]["propose"].action == "factor":
|
if prev_out["direct_exp_gen"]["propose"].action == "factor":
|
||||||
exp = self.factor_runner.develop(prev_out["coding"])
|
exp = self.factor_runner.develop(prev_out["coding"])
|
||||||
if exp is None:
|
if exp is None:
|
||||||
logger.error(f"Factor extraction failed.")
|
logger.error("Factor extraction failed.")
|
||||||
raise FactorEmptyError("Factor extraction failed.")
|
raise FactorEmptyError("Factor extraction failed.")
|
||||||
|
|
||||||
# Increment factor count for tracking
|
# Increment factor count for tracking
|
||||||
if hasattr(self, 'trace') and hasattr(self.trace, 'increment_factor_count'):
|
if hasattr(self, "trace") and hasattr(self.trace, "increment_factor_count"):
|
||||||
self.trace.increment_factor_count()
|
self.trace.increment_factor_count()
|
||||||
|
|
||||||
# Handle failed experiments gracefully (don't break the loop)
|
# Handle failed experiments gracefully (don't break the loop)
|
||||||
@@ -210,7 +209,7 @@ class QuantRDLoop(RDLoop):
|
|||||||
factor_name = getattr(exp.hypothesis, "hypothesis", "unknown")
|
factor_name = getattr(exp.hypothesis, "hypothesis", "unknown")
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Factor '{factor_name}' failed evaluation: {reason}. "
|
f"Factor '{factor_name}' failed evaluation: {reason}. "
|
||||||
f"Continuing with next factor."
|
f"Continuing with next factor.",
|
||||||
)
|
)
|
||||||
# Return exp anyway - loop will continue
|
# Return exp anyway - loop will continue
|
||||||
elif prev_out["direct_exp_gen"]["propose"].action == "model":
|
elif prev_out["direct_exp_gen"]["propose"].action == "model":
|
||||||
@@ -219,7 +218,7 @@ class QuantRDLoop(RDLoop):
|
|||||||
return exp
|
return exp
|
||||||
|
|
||||||
def feedback(self, prev_out: dict[str, Any]):
|
def feedback(self, prev_out: dict[str, Any]):
|
||||||
e = prev_out.get(self.EXCEPTION_KEY, None)
|
e = prev_out.get(self.EXCEPTION_KEY)
|
||||||
if e is not None:
|
if e is not None:
|
||||||
feedback = HypothesisFeedback(
|
feedback = HypothesisFeedback(
|
||||||
observations=str(e),
|
observations=str(e),
|
||||||
@@ -245,11 +244,10 @@ class QuantRDLoop(RDLoop):
|
|||||||
reason=reason,
|
reason=reason,
|
||||||
decision=False,
|
decision=False,
|
||||||
)
|
)
|
||||||
else:
|
elif prev_out["direct_exp_gen"]["propose"].action == "factor":
|
||||||
if prev_out["direct_exp_gen"]["propose"].action == "factor":
|
feedback = self.factor_summarizer.generate_feedback(prev_out["running"], self.trace)
|
||||||
feedback = self.factor_summarizer.generate_feedback(prev_out["running"], self.trace)
|
elif prev_out["direct_exp_gen"]["propose"].action == "model":
|
||||||
elif prev_out["direct_exp_gen"]["propose"].action == "model":
|
feedback = self.model_summarizer.generate_feedback(prev_out["running"], self.trace)
|
||||||
feedback = self.model_summarizer.generate_feedback(prev_out["running"], self.trace)
|
|
||||||
|
|
||||||
# NOTE: DB save is handled by factor_runner.py _save_result_to_database()
|
# NOTE: DB save is handled by factor_runner.py _save_result_to_database()
|
||||||
# which runs immediately after Docker execution. No duplicate save needed here.
|
# which runs immediately after Docker execution. No duplicate save needed here.
|
||||||
@@ -258,20 +256,20 @@ class QuantRDLoop(RDLoop):
|
|||||||
factor_count = self.trace.get_factor_count()
|
factor_count = self.trace.get_factor_count()
|
||||||
|
|
||||||
# Check for auto-strategies trigger
|
# Check for auto-strategies trigger
|
||||||
auto_strategies = getattr(self, '_auto_strategies', False)
|
auto_strategies = getattr(self, "_auto_strategies", False)
|
||||||
auto_threshold = getattr(self, '_auto_strategies_threshold', 500)
|
auto_threshold = getattr(self, "_auto_strategies_threshold", 500)
|
||||||
|
|
||||||
if auto_strategies and factor_count > 0 and factor_count % auto_threshold == 0:
|
if auto_strategies and factor_count > 0 and factor_count % auto_threshold == 0:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Auto-strategy trigger: {factor_count} factors evaluated. "
|
f"Auto-strategy trigger: {factor_count} factors evaluated. "
|
||||||
f"Suggesting strategy generation now..."
|
f"Suggesting strategy generation now...",
|
||||||
)
|
)
|
||||||
self._build_strategies_with_ai()
|
self._build_strategies_with_ai()
|
||||||
elif factor_count > 0 and factor_count % 50 == 0 and not auto_strategies:
|
elif factor_count > 0 and factor_count % 50 == 0 and not auto_strategies:
|
||||||
# Standard periodic suggestion (every 50 factors)
|
# Standard periodic suggestion (every 50 factors)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Periodic check: {factor_count} factors evaluated. "
|
f"Periodic check: {factor_count} factors evaluated. "
|
||||||
f"Consider running 'rdagent generate_strategies' for AI strategy generation."
|
f"Consider running 'rdagent generate_strategies' for AI strategy generation.",
|
||||||
)
|
)
|
||||||
|
|
||||||
feedback = self._interact_feedback(feedback)
|
feedback = self._interact_feedback(feedback)
|
||||||
@@ -292,9 +290,10 @@ class QuantRDLoop(RDLoop):
|
|||||||
- Optuna hyperparameter optimization
|
- Optuna hyperparameter optimization
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from rdagent.components.coder.strategy_orchestrator import StrategyOrchestrator
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
from rdagent.components.coder.strategy_orchestrator import StrategyOrchestrator
|
||||||
|
|
||||||
# Load improved prompt
|
# Load improved prompt
|
||||||
project_root = Path(__file__).parent.parent.parent.parent
|
project_root = Path(__file__).parent.parent.parent.parent
|
||||||
@@ -322,6 +321,7 @@ class QuantRDLoop(RDLoop):
|
|||||||
if data.get("status") == "success" and data.get("ic") is not None:
|
if data.get("status") == "success" and data.get("ic") is not None:
|
||||||
factors.append(data)
|
factors.append(data)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.warning("Failed to load factor file %s", f, exc_info=True)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if len(factors) < 10:
|
if len(factors) < 10:
|
||||||
@@ -334,13 +334,13 @@ class QuantRDLoop(RDLoop):
|
|||||||
|
|
||||||
logger.info(f"StrategyOrchestrator: Building strategies from {len(top_factors)} top factors...")
|
logger.info(f"StrategyOrchestrator: Building strategies from {len(top_factors)} top factors...")
|
||||||
logger.info(f" - Using improved prompt: {improved_prompt is not None}")
|
logger.info(f" - Using improved prompt: {improved_prompt is not None}")
|
||||||
logger.info(f" - Optuna optimization: enabled (20 trials)")
|
logger.info(" - Optuna optimization: enabled (20 trials)")
|
||||||
logger.info(f" - Real OHLCV backtest: enabled")
|
logger.info(" - Real OHLCV backtest: enabled")
|
||||||
|
|
||||||
# Initialize orchestrator with Optuna
|
# Initialize orchestrator with Optuna
|
||||||
orchestrator = StrategyOrchestrator(
|
orchestrator = StrategyOrchestrator(
|
||||||
top_factors=20,
|
top_factors=20,
|
||||||
trading_style='swing',
|
trading_style="swing",
|
||||||
min_sharpe=0.5,
|
min_sharpe=0.5,
|
||||||
max_drawdown=-0.20,
|
max_drawdown=-0.20,
|
||||||
min_win_rate=0.40,
|
min_win_rate=0.40,
|
||||||
@@ -350,7 +350,7 @@ class QuantRDLoop(RDLoop):
|
|||||||
|
|
||||||
# Override with improved prompt if available
|
# Override with improved prompt if available
|
||||||
if improved_prompt:
|
if improved_prompt:
|
||||||
orchestrator.strategy_prompt = improved_prompt.get('strategy_generation', {})
|
orchestrator.strategy_prompt = improved_prompt.get("strategy_generation", {})
|
||||||
|
|
||||||
# Generate 3 strategies per cycle
|
# Generate 3 strategies per cycle
|
||||||
n_strategies = 3
|
n_strategies = 3
|
||||||
@@ -358,15 +358,18 @@ class QuantRDLoop(RDLoop):
|
|||||||
|
|
||||||
# Load top factors for generation
|
# Load top factors for generation
|
||||||
orch_factors = orchestrator.load_top_factors()
|
orch_factors = orchestrator.load_top_factors()
|
||||||
|
if len(orch_factors) < 2:
|
||||||
|
logger.warning(f"Not enough factors for strategy generation (need >= 2, got {len(orch_factors)}). Skipping.")
|
||||||
|
return
|
||||||
|
|
||||||
for i in range(n_strategies):
|
for i in range(n_strategies):
|
||||||
|
strategy_name = f"auto_gen_v{i+1}"
|
||||||
try:
|
try:
|
||||||
# Select random factor combination
|
# Select random factor combination
|
||||||
import random
|
import random
|
||||||
n_factors = random.randint(2, min(5, len(orch_factors)))
|
n_factors = random.randint(2, min(5, len(orch_factors)))
|
||||||
factor_subset = random.sample(orch_factors, n_factors)
|
factor_subset = random.sample(orch_factors, n_factors)
|
||||||
|
|
||||||
strategy_name = f"auto_gen_v{i+1}"
|
|
||||||
code = orchestrator.generate_strategy_code(factor_subset, strategy_name)
|
code = orchestrator.generate_strategy_code(factor_subset, strategy_name)
|
||||||
|
|
||||||
if code:
|
if code:
|
||||||
@@ -429,7 +432,7 @@ def main(
|
|||||||
quant_loop._auto_strategies = True
|
quant_loop._auto_strategies = True
|
||||||
quant_loop._auto_strategies_threshold = auto_strategies_threshold
|
quant_loop._auto_strategies_threshold = auto_strategies_threshold
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Auto-strategies enabled. Will trigger after {auto_strategies_threshold} factors."
|
f"Auto-strategies enabled. Will trigger after {auto_strategies_threshold} factors.",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
quant_loop._auto_strategies = False
|
quant_loop._auto_strategies = False
|
||||||
|
|||||||
@@ -16,55 +16,26 @@ from rdagent.app.rl.ui.components import render_session, render_summary
|
|||||||
from rdagent.app.rl.ui.config import ALWAYS_VISIBLE_TYPES, OPTIONAL_TYPES
|
from rdagent.app.rl.ui.config import ALWAYS_VISIBLE_TYPES, OPTIONAL_TYPES
|
||||||
from rdagent.app.rl.ui.data_loader import get_summary, get_valid_sessions, load_session
|
from rdagent.app.rl.ui.data_loader import get_summary, get_valid_sessions, load_session
|
||||||
from rdagent.app.rl.ui.rl_summary import render_job_summary
|
from rdagent.app.rl.ui.rl_summary import render_job_summary
|
||||||
|
from rdagent.core.utils import safe_resolve_path
|
||||||
|
|
||||||
DEFAULT_LOG_BASE = "log/"
|
DEFAULT_LOG_BASE = "log/"
|
||||||
|
|
||||||
|
|
||||||
def _safe_resolve(user_input: str | None, safe_root: Path) -> Path:
|
def _safe_resolve(user_input: str | None, safe_root: Path) -> Path:
|
||||||
"""
|
|
||||||
Resolve user path relative to safe_root; raise ValueError if it escapes.
|
|
||||||
|
|
||||||
Security: This function prevents path traversal attacks by:
|
|
||||||
1. Rejecting null bytes in user input
|
|
||||||
2. Rejecting Windows drive letters (C:\, D:\, etc.)
|
|
||||||
3. Rejecting absolute paths
|
|
||||||
4. Normalizing path to remove .. traversal attempts
|
|
||||||
5. Validating resolved path is within safe_root using a realpath-based check
|
|
||||||
|
|
||||||
All user-provided paths are validated before filesystem access.
|
|
||||||
"""
|
|
||||||
# Treat the provided safe_root as trusted and canonicalize it once.
|
|
||||||
safe_root = safe_root.expanduser().resolve()
|
safe_root = safe_root.expanduser().resolve()
|
||||||
|
|
||||||
# Empty input maps to the safe root directory.
|
|
||||||
if not user_input:
|
if not user_input:
|
||||||
return safe_root
|
return safe_root
|
||||||
|
|
||||||
# Security check 1: Reject null bytes (path truncation attack)
|
|
||||||
if "\x00" in user_input:
|
if "\x00" in user_input:
|
||||||
raise ValueError("Invalid path: contains null byte")
|
raise ValueError("Invalid path: contains null byte")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Security check 2: Normalize path to resolve .. and . components
|
|
||||||
normalized = os.path.normpath(user_input.strip())
|
normalized = os.path.normpath(user_input.strip())
|
||||||
|
|
||||||
# Security check 3: Reject Windows drive letters (C:\, D:\, etc.)
|
|
||||||
drive, _ = os.path.splitdrive(normalized)
|
drive, _ = os.path.splitdrive(normalized)
|
||||||
if drive:
|
if drive:
|
||||||
raise ValueError("Absolute paths with drive letters are not allowed")
|
raise ValueError("Absolute paths with drive letters are not allowed")
|
||||||
|
|
||||||
# Security check 4: Reject absolute paths (/, //server/share, etc.)
|
|
||||||
if os.path.isabs(normalized):
|
if os.path.isabs(normalized):
|
||||||
raise ValueError("Absolute paths are not allowed")
|
raise ValueError("Absolute paths are not allowed")
|
||||||
|
joined = safe_root / normalized
|
||||||
# Security check 5: Build candidate path under safe_root and fully resolve it.
|
return safe_resolve_path(joined, safe_root)
|
||||||
joined = os.path.join(str(safe_root), normalized)
|
|
||||||
resolved_candidate = os.path.realpath(joined)
|
|
||||||
|
|
||||||
# Security check 6: Validate candidate is within safe_root (prevent path traversal)
|
|
||||||
candidate_path = Path(resolved_candidate)
|
|
||||||
# Reconstruct from trusted safe_root so the returned path is root-derived.
|
|
||||||
return safe_root / candidate_path.relative_to(safe_root)
|
|
||||||
except (OSError, ValueError) as exc:
|
except (OSError, ValueError) as exc:
|
||||||
raise ValueError(f"Invalid path outside of allowed root: {user_input}") from exc
|
raise ValueError(f"Invalid path outside of allowed root: {user_input}") from exc
|
||||||
|
|
||||||
@@ -82,7 +53,7 @@ def get_job_options(base_path: Path, safe_root: Path | None = None) -> list[str]
|
|||||||
|
|
||||||
# Security fix: Validate base_path to prevent path traversal
|
# Security fix: Validate base_path to prevent path traversal
|
||||||
try:
|
try:
|
||||||
base_path_resolved = base_path.expanduser().resolve()
|
base_path_resolved = base_path.expanduser().resolve() # nosec B614 — validated against safe_root below via relative_to()
|
||||||
|
|
||||||
if safe_root is not None:
|
if safe_root is not None:
|
||||||
safe_root_resolved = safe_root.expanduser().resolve()
|
safe_root_resolved = safe_root.expanduser().resolve()
|
||||||
@@ -203,8 +174,7 @@ def main():
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
st.warning(str(e))
|
st.warning(str(e))
|
||||||
return
|
return
|
||||||
# job_path is validated by _safe_resolve() above
|
if job_path.exists():
|
||||||
if job_path.exists(): # nosec B614 – path validated by _safe_resolve
|
|
||||||
render_job_summary(job_path, safe_root, is_root=is_root_job)
|
render_job_summary(job_path, safe_root, is_root=is_root_job)
|
||||||
else:
|
else:
|
||||||
st.warning(f"Job folder not found: {job_folder}")
|
st.warning(f"Job folder not found: {job_folder}")
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from typing import Any
|
|||||||
import streamlit as st
|
import streamlit as st
|
||||||
|
|
||||||
from rdagent.app.rl.ui.config import EventType
|
from rdagent.app.rl.ui.config import EventType
|
||||||
|
from rdagent.core.utils import safe_resolve_path
|
||||||
from rdagent.log.storage import FileStorage
|
from rdagent.log.storage import FileStorage
|
||||||
|
|
||||||
|
|
||||||
@@ -76,11 +77,10 @@ def extract_stage(tag: str) -> str:
|
|||||||
def get_valid_sessions(log_folder: Path, safe_root: Path | None = None) -> list[str]:
|
def get_valid_sessions(log_folder: Path, safe_root: Path | None = None) -> list[str]:
|
||||||
"""Get list of valid session directories, optionally validating against a safe root."""
|
"""Get list of valid session directories, optionally validating against a safe root."""
|
||||||
if safe_root is not None:
|
if safe_root is not None:
|
||||||
root_real = os.path.realpath(str(safe_root.expanduser()))
|
try:
|
||||||
folder_real = os.path.realpath(str(log_folder.expanduser()))
|
log_folder = safe_resolve_path(log_folder, safe_root)
|
||||||
if not (folder_real == root_real or folder_real.startswith(root_real + os.sep)):
|
except ValueError:
|
||||||
return []
|
return []
|
||||||
log_folder = Path(folder_real)
|
|
||||||
|
|
||||||
if not log_folder.exists():
|
if not log_folder.exists():
|
||||||
return []
|
return []
|
||||||
@@ -245,13 +245,11 @@ def parse_event(tag: str, content: Any, timestamp: datetime) -> Event | None:
|
|||||||
@st.cache_data(ttl=300, hash_funcs={Path: str})
|
@st.cache_data(ttl=300, hash_funcs={Path: str})
|
||||||
def load_session(log_path: Path, safe_root: Path | None = None) -> Session:
|
def load_session(log_path: Path, safe_root: Path | None = None) -> Session:
|
||||||
"""Load events into hierarchical session structure, optionally validating against safe root."""
|
"""Load events into hierarchical session structure, optionally validating against safe root."""
|
||||||
# Validate path is within safe_root if provided
|
|
||||||
if safe_root is not None:
|
if safe_root is not None:
|
||||||
root_real = os.path.realpath(str(safe_root.expanduser()))
|
try:
|
||||||
path_real = os.path.realpath(str(log_path.expanduser()))
|
log_path = safe_resolve_path(log_path, safe_root)
|
||||||
if not (path_real == root_real or path_real.startswith(root_real + os.sep)):
|
except ValueError:
|
||||||
return Session()
|
return Session()
|
||||||
log_path = Path(path_real)
|
|
||||||
|
|
||||||
session = Session()
|
session = Session()
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from pathlib import Path
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
import streamlit as st
|
import streamlit as st
|
||||||
|
|
||||||
|
from rdagent.core.utils import safe_resolve_path
|
||||||
|
|
||||||
|
|
||||||
def is_valid_task(task_path: Path) -> bool:
|
def is_valid_task(task_path: Path) -> bool:
|
||||||
"""Check if directory is a valid RL task (has __session__ subdirectory)"""
|
"""Check if directory is a valid RL task (has __session__ subdirectory)"""
|
||||||
@@ -62,14 +64,10 @@ def get_loop_status(task_path: Path, loop_id: int) -> tuple[str, bool | None]:
|
|||||||
|
|
||||||
|
|
||||||
def _validate_job_path(job_path: Path, safe_root: Path) -> Path:
|
def _validate_job_path(job_path: Path, safe_root: Path) -> Path:
|
||||||
"""Resolve and validate that job_path stays within safe_root."""
|
|
||||||
resolved_root = safe_root.expanduser().resolve()
|
|
||||||
resolved_job = job_path.expanduser().resolve()
|
|
||||||
try:
|
try:
|
||||||
# Reconstruct from trusted root so the returned path is root-derived.
|
return safe_resolve_path(job_path, safe_root)
|
||||||
return resolved_root / resolved_job.relative_to(resolved_root)
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise ValueError(f"Job path is outside allowed root {resolved_root}")
|
raise ValueError(f"Job path is outside allowed root {safe_root}")
|
||||||
|
|
||||||
|
|
||||||
def get_max_loops(job_path: Path, safe_root: Path | None = None) -> int:
|
def get_max_loops(job_path: Path, safe_root: Path | None = None) -> int:
|
||||||
|
|||||||
@@ -54,11 +54,11 @@ def rdagent_info():
|
|||||||
current_version = importlib.metadata.version("rdagent")
|
current_version = importlib.metadata.version("rdagent")
|
||||||
logger.info(f"RD-Agent version: {current_version}")
|
logger.info(f"RD-Agent version: {current_version}")
|
||||||
api_url = f"https://api.github.com/repos/microsoft/RD-Agent/contents/requirements.txt?ref=main"
|
api_url = f"https://api.github.com/repos/microsoft/RD-Agent/contents/requirements.txt?ref=main"
|
||||||
response = requests.get(api_url)
|
response = requests.get(api_url, timeout=30)
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
files = response.json()
|
files = response.json()
|
||||||
file_url = files["download_url"]
|
file_url = files["download_url"]
|
||||||
file_response = requests.get(file_url)
|
file_response = requests.get(file_url, timeout=30)
|
||||||
if file_response.status_code == 200:
|
if file_response.status_code == 200:
|
||||||
all_file_contents = file_response.text.split("\n")
|
all_file_contents = file_response.text.split("\n")
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -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',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ class BacktestMetrics:
|
|||||||
class FactorBacktester:
|
class FactorBacktester:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.metrics = BacktestMetrics()
|
self.metrics = BacktestMetrics()
|
||||||
self.results_path = Path(__file__).parent.parent.parent / "results" / "backtests"
|
self.results_path = Path(__file__).parent.parent.parent.parent / "results" / "backtests"
|
||||||
self.results_path.mkdir(parents=True, exist_ok=True)
|
self.results_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
def run_backtest(
|
def run_backtest(
|
||||||
@@ -222,7 +222,7 @@ class FactorBacktester:
|
|||||||
|
|
||||||
# Calculate return for this step
|
# Calculate return for this step
|
||||||
if step > 0:
|
if step > 0:
|
||||||
prev_price = float(price_values[step - 1]) if step > 0 else current_price
|
prev_price = float(price_values[step - 1])
|
||||||
if prev_price > 0:
|
if prev_price > 0:
|
||||||
step_return = (current_price - prev_price) / prev_price * position
|
step_return = (current_price - prev_price) / prev_price * position
|
||||||
returns_history.append(step_return)
|
returns_history.append(step_return)
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ class ResultsDatabase:
|
|||||||
|
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
|
_ALLOWED_TABLES = frozenset({"factors", "backtest_runs", "loop_results"})
|
||||||
|
_ALLOWED_COL_TYPES = frozenset({"REAL", "TEXT", "INTEGER", "BLOB"})
|
||||||
|
|
||||||
def _add_column_if_not_exists(self, table: str, column: str, col_type: str) -> None:
|
def _add_column_if_not_exists(self, table: str, column: str, col_type: str) -> None:
|
||||||
"""
|
"""
|
||||||
Add a column to a table if it doesn't already exist.
|
Add a column to a table if it doesn't already exist.
|
||||||
@@ -78,20 +81,24 @@ class ResultsDatabase:
|
|||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
table : str
|
table : str
|
||||||
Table name
|
Table name (must be in _ALLOWED_TABLES)
|
||||||
column : str
|
column : str
|
||||||
Column name to add
|
Column name to add (alphanumeric + underscore only)
|
||||||
col_type : str
|
col_type : str
|
||||||
SQL column type (e.g., 'REAL', 'TEXT')
|
SQL column type (must be in _ALLOWED_COL_TYPES)
|
||||||
"""
|
"""
|
||||||
|
if table not in self._ALLOWED_TABLES:
|
||||||
|
raise ValueError(f"Unknown table: {table!r}")
|
||||||
|
if not column.replace("_", "").isalnum():
|
||||||
|
raise ValueError(f"Invalid column name: {column!r}")
|
||||||
|
if col_type not in self._ALLOWED_COL_TYPES:
|
||||||
|
raise ValueError(f"Invalid column type: {col_type!r}")
|
||||||
|
|
||||||
c = self.conn.cursor()
|
c = self.conn.cursor()
|
||||||
try:
|
c.execute("SELECT name FROM pragma_table_info(?)", (table,))
|
||||||
# Try to query the column - if it fails, it doesn't exist
|
existing = {row[0] for row in c.fetchall()}
|
||||||
# nosec B608: Internal schema migration, column names are controlled
|
if column not in existing:
|
||||||
c.execute(f"SELECT {column} FROM {table} LIMIT 1") # nosec B608
|
c.execute(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}")
|
||||||
except sqlite3.OperationalError:
|
|
||||||
# Column doesn't exist, add it
|
|
||||||
c.execute(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}") # nosec B608
|
|
||||||
|
|
||||||
def add_factor(self, name: str, type: str = "unknown") -> int:
|
def add_factor(self, name: str, type: str = "unknown") -> int:
|
||||||
c = self.conn.cursor()
|
c = self.conn.cursor()
|
||||||
@@ -159,7 +166,7 @@ class ResultsDatabase:
|
|||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
return c.lastrowid
|
return c.lastrowid
|
||||||
|
|
||||||
def add_loop(self, loop_idx: int, success: int, fail: int, best_ic: float = None, status: str = "completed") -> int:
|
def add_loop(self, loop_idx: int, success: int, fail: int, best_ic: float | None = None, status: str = "completed") -> int:
|
||||||
c = self.conn.cursor()
|
c = self.conn.cursor()
|
||||||
rate = success / (success + fail) if (success + fail) > 0 else 0
|
rate = success / (success + fail) if (success + fail) > 0 else 0
|
||||||
c.execute("""INSERT INTO loop_results (loop_index, factors_success, factors_fail, success_rate, best_ic, status)
|
c.execute("""INSERT INTO loop_results (loop_index, factors_success, factors_fail, success_rate, best_ic, status)
|
||||||
@@ -183,16 +190,18 @@ class ResultsDatabase:
|
|||||||
pd.DataFrame
|
pd.DataFrame
|
||||||
DataFrame with factor names and metrics
|
DataFrame with factor names and metrics
|
||||||
"""
|
"""
|
||||||
# Map shorthand to full column name
|
_ALLOWED_METRICS = frozenset({
|
||||||
|
'sharpe', 'ic', 'annual_return', 'max_drawdown',
|
||||||
|
'win_rate', 'information_ratio', 'volatility',
|
||||||
|
})
|
||||||
metric_map = {
|
metric_map = {
|
||||||
'sharpe': 'sharpe',
|
'sharpe': 'sharpe', 'ic': 'ic', 'return': 'annual_return',
|
||||||
'ic': 'ic',
|
'drawdown': 'max_drawdown', 'win_rate': 'win_rate',
|
||||||
'return': 'annual_return',
|
|
||||||
'drawdown': 'max_drawdown',
|
|
||||||
'win_rate': 'win_rate',
|
|
||||||
'information_ratio': 'information_ratio',
|
'information_ratio': 'information_ratio',
|
||||||
}
|
}
|
||||||
col = metric_map.get(metric, metric)
|
col = metric_map.get(metric, metric)
|
||||||
|
if col not in _ALLOWED_METRICS:
|
||||||
|
raise ValueError(f"Unknown metric: {metric!r}")
|
||||||
|
|
||||||
return pd.read_sql_query(
|
return pd.read_sql_query(
|
||||||
f"""SELECT factor_name, ic, sharpe, annual_return, max_drawdown,
|
f"""SELECT factor_name, ic, sharpe, annual_return, max_drawdown,
|
||||||
@@ -201,7 +210,7 @@ class ResultsDatabase:
|
|||||||
JOIN factors ON factor_id = factors.id
|
JOIN factors ON factor_id = factors.id
|
||||||
WHERE {col} IS NOT NULL
|
WHERE {col} IS NOT NULL
|
||||||
ORDER BY {col} DESC
|
ORDER BY {col} DESC
|
||||||
LIMIT ?""",
|
LIMIT ?""", # nosec B608 — col is validated against _ALLOWED_METRICS above
|
||||||
self.conn,
|
self.conn,
|
||||||
params=[limit]
|
params=[limit]
|
||||||
)
|
)
|
||||||
@@ -321,13 +330,13 @@ class ResultsDatabase:
|
|||||||
worst_drawdown = all_results['max_drawdown'].min() if total_runs > 0 and all_results['max_drawdown'].notna().any() else None
|
worst_drawdown = all_results['max_drawdown'].min() if total_runs > 0 and all_results['max_drawdown'].notna().any() else None
|
||||||
|
|
||||||
# Scan factors directory for JSON files
|
# Scan factors directory for JSON files
|
||||||
factors_dir = Path(__file__).parent.parent.parent / "results" / "factors"
|
factors_dir = Path(__file__).parent.parent.parent.parent / "results" / "factors"
|
||||||
json_factor_files = 0
|
json_factor_files = 0
|
||||||
if factors_dir.exists():
|
if factors_dir.exists():
|
||||||
json_factor_files = len(list(factors_dir.glob("*.json")))
|
json_factor_files = len(list(factors_dir.glob("*.json")))
|
||||||
|
|
||||||
# Scan failed runs
|
# Scan failed runs
|
||||||
failed_dir = Path(__file__).parent.parent.parent / "results" / "failed_runs"
|
failed_dir = Path(__file__).parent.parent.parent.parent / "results" / "failed_runs"
|
||||||
failed_runs_file = failed_dir / "failed_runs.json"
|
failed_runs_file = failed_dir / "failed_runs.json"
|
||||||
failed_runs_count = 0
|
failed_runs_count = 0
|
||||||
failed_runs_data = []
|
failed_runs_data = []
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
Predix Risk Management - Korrelation, Portfolio-Optimierung
|
Predix Risk Management - Korrelation, Portfolio-Optimierung
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from pathlib import Path
|
|
||||||
from typing import Dict, List, Optional
|
|
||||||
from datetime import datetime
|
|
||||||
import json
|
|
||||||
|
|
||||||
class CorrelationAnalyzer:
|
class CorrelationAnalyzer:
|
||||||
def __init__(self, lookback: int = 60):
|
def __init__(self, lookback: int = 60):
|
||||||
@@ -15,7 +13,7 @@ class CorrelationAnalyzer:
|
|||||||
def calculate_matrix(self, returns: pd.DataFrame) -> pd.DataFrame:
|
def calculate_matrix(self, returns: pd.DataFrame) -> pd.DataFrame:
|
||||||
return returns.dropna().corr()
|
return returns.dropna().corr()
|
||||||
|
|
||||||
def find_uncorrelated(self, corr: pd.DataFrame, threshold: float = 0.3) -> List[str]:
|
def find_uncorrelated(self, corr: pd.DataFrame, threshold: float = 0.3) -> list[str]:
|
||||||
result = []
|
result = []
|
||||||
for f in corr.columns:
|
for f in corr.columns:
|
||||||
others = [x for x in corr.columns if x != f]
|
others = [x for x in corr.columns if x != f]
|
||||||
@@ -28,7 +26,7 @@ class PortfolioOptimizer:
|
|||||||
try:
|
try:
|
||||||
w = np.linalg.inv(cov.values) @ exp_ret.values
|
w = np.linalg.inv(cov.values) @ exp_ret.values
|
||||||
return w / np.sum(w)
|
return w / np.sum(w)
|
||||||
except:
|
except (np.linalg.LinAlgError, ValueError):
|
||||||
return np.ones(len(exp_ret)) / len(exp_ret)
|
return np.ones(len(exp_ret)) / len(exp_ret)
|
||||||
|
|
||||||
def risk_parity(self, cov: pd.DataFrame, max_iter: int = 100) -> np.ndarray:
|
def risk_parity(self, cov: pd.DataFrame, max_iter: int = 100) -> np.ndarray:
|
||||||
@@ -54,17 +52,17 @@ class AdvancedRiskManager:
|
|||||||
self.corr_analyzer = CorrelationAnalyzer()
|
self.corr_analyzer = CorrelationAnalyzer()
|
||||||
self.optimizer = PortfolioOptimizer()
|
self.optimizer = PortfolioOptimizer()
|
||||||
|
|
||||||
def check_limits(self, weights: np.ndarray, vol: float, dd: float) -> Dict[str, bool]:
|
def check_limits(self, weights: np.ndarray, vol: float, dd: float) -> dict[str, bool]:
|
||||||
return {
|
return {
|
||||||
'position_limit': np.max(np.abs(weights)) <= self.max_pos,
|
"position_limit": np.max(np.abs(weights)) <= self.max_pos,
|
||||||
'leverage_limit': np.sum(np.abs(weights)) <= self.max_lev,
|
"leverage_limit": np.sum(np.abs(weights)) <= self.max_lev,
|
||||||
'drawdown_limit': abs(dd) <= self.max_dd,
|
"drawdown_limit": abs(dd) <= self.max_dd,
|
||||||
}
|
}
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
print("=== Risk Test ===")
|
print("=== Risk Test ===")
|
||||||
np.random.seed(42)
|
np.random.seed(42)
|
||||||
n, names = 252, ['Mom', 'MeanRev', 'Vol', 'Volu', 'ML']
|
n, names = 252, ["Mom", "MeanRev", "Vol", "Volu", "ML"]
|
||||||
ret = pd.DataFrame(np.random.randn(n, 5), columns=names)
|
ret = pd.DataFrame(np.random.randn(n, 5), columns=names)
|
||||||
|
|
||||||
corr = CorrelationAnalyzer().calculate_matrix(ret)
|
corr = CorrelationAnalyzer().calculate_matrix(ret)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Design goals
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
@@ -67,9 +67,8 @@ def _cross_check_with_vbt(
|
|||||||
close: pd.Series,
|
close: pd.Series,
|
||||||
position: pd.Series,
|
position: pd.Series,
|
||||||
txn_cost: float,
|
txn_cost: float,
|
||||||
manual_total_return: float,
|
|
||||||
freq: str,
|
freq: str,
|
||||||
) -> Optional[float]:
|
) -> float | None:
|
||||||
"""Run a vectorbt simulation and return its total_return for comparison."""
|
"""Run a vectorbt simulation and return its total_return for comparison."""
|
||||||
if not VBT_AVAILABLE:
|
if not VBT_AVAILABLE:
|
||||||
return None
|
return None
|
||||||
@@ -84,7 +83,8 @@ def _cross_check_with_vbt(
|
|||||||
init_cash=10_000.0,
|
init_cash=10_000.0,
|
||||||
freq=freq,
|
freq=freq,
|
||||||
)
|
)
|
||||||
return float(pf.total_return())
|
tr = float(pf.total_return())
|
||||||
|
return tr if np.isfinite(tr) else None
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -95,9 +95,9 @@ def backtest_signal(
|
|||||||
txn_cost_bps: float = DEFAULT_TXN_COST_BPS,
|
txn_cost_bps: float = DEFAULT_TXN_COST_BPS,
|
||||||
freq: str = "1min",
|
freq: str = "1min",
|
||||||
bars_per_year: int = DEFAULT_BARS_PER_YEAR,
|
bars_per_year: int = DEFAULT_BARS_PER_YEAR,
|
||||||
forward_returns: Optional[pd.Series] = None,
|
forward_returns: pd.Series | None = None,
|
||||||
cross_check: bool = False,
|
cross_check: bool = False,
|
||||||
) -> Dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Run a single-asset backtest from a position signal.
|
Run a single-asset backtest from a position signal.
|
||||||
|
|
||||||
@@ -204,7 +204,7 @@ def backtest_signal(
|
|||||||
calmar = ann_return_arith / abs(max_dd) if max_dd < 0 else 0.0
|
calmar = ann_return_arith / abs(max_dd) if max_dd < 0 else 0.0
|
||||||
|
|
||||||
trade_pnl = _compute_trade_pnl(position, strategy_returns)
|
trade_pnl = _compute_trade_pnl(position, strategy_returns)
|
||||||
n_trades = int(len(trade_pnl))
|
n_trades = len(trade_pnl)
|
||||||
n_position_changes = int((position.diff().fillna(0) != 0).sum())
|
n_position_changes = int((position.diff().fillna(0) != 0).sum())
|
||||||
|
|
||||||
if n_trades > 0:
|
if n_trades > 0:
|
||||||
@@ -216,7 +216,7 @@ def backtest_signal(
|
|||||||
win_rate = 0.0
|
win_rate = 0.0
|
||||||
profit_factor = 0.0
|
profit_factor = 0.0
|
||||||
|
|
||||||
ic: Optional[float] = None
|
ic: float | None = None
|
||||||
if forward_returns is not None:
|
if forward_returns is not None:
|
||||||
fwd = pd.to_numeric(forward_returns, errors="coerce")
|
fwd = pd.to_numeric(forward_returns, errors="coerce")
|
||||||
common = signal.index.intersection(fwd.dropna().index)
|
common = signal.index.intersection(fwd.dropna().index)
|
||||||
@@ -227,7 +227,7 @@ def backtest_signal(
|
|||||||
ic_val = float(s.corr(f))
|
ic_val = float(s.corr(f))
|
||||||
ic = ic_val if np.isfinite(ic_val) else None
|
ic = ic_val if np.isfinite(ic_val) else None
|
||||||
|
|
||||||
result: Dict[str, Any] = {
|
result: dict[str, Any] = {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"sharpe": sharpe,
|
"sharpe": sharpe,
|
||||||
"sortino": sortino,
|
"sortino": sortino,
|
||||||
@@ -244,7 +244,7 @@ def backtest_signal(
|
|||||||
"volatility": volatility,
|
"volatility": volatility,
|
||||||
"n_trades": n_trades,
|
"n_trades": n_trades,
|
||||||
"n_position_changes": n_position_changes,
|
"n_position_changes": n_position_changes,
|
||||||
"n_bars": int(len(strategy_returns)),
|
"n_bars": len(strategy_returns),
|
||||||
"n_months": float(n_months),
|
"n_months": float(n_months),
|
||||||
"signal_long": int((signal > 0).sum()),
|
"signal_long": int((signal > 0).sum()),
|
||||||
"signal_short": int((signal < 0).sum()),
|
"signal_short": int((signal < 0).sum()),
|
||||||
@@ -264,7 +264,6 @@ def backtest_signal(
|
|||||||
close=close,
|
close=close,
|
||||||
position=position,
|
position=position,
|
||||||
txn_cost=txn_cost,
|
txn_cost=txn_cost,
|
||||||
manual_total_return=total_return,
|
|
||||||
freq=freq,
|
freq=freq,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -293,7 +292,7 @@ def _apply_ftmo_mask(
|
|||||||
|
|
||||||
daily_breaches = 0
|
daily_breaches = 0
|
||||||
total_breached = False
|
total_breached = False
|
||||||
total_breach_ts: Optional[pd.Timestamp] = None
|
total_breach_ts: pd.Timestamp | None = None
|
||||||
current_day = None
|
current_day = None
|
||||||
day_start_eq = FTMO_INITIAL_CAPITAL
|
day_start_eq = FTMO_INITIAL_CAPITAL
|
||||||
|
|
||||||
@@ -308,11 +307,8 @@ def _apply_ftmo_mask(
|
|||||||
pos_i = float(signal.at[ts]) * leverage
|
pos_i = float(signal.at[ts]) * leverage
|
||||||
ret_i = float(bar_ret.get(ts, 0.0))
|
ret_i = float(bar_ret.get(ts, 0.0))
|
||||||
cost_i = abs(pos_i - pos_prev) * txn_cost
|
cost_i = abs(pos_i - pos_prev) * txn_cost
|
||||||
ret_net = pos_prev * ret_i - cost_i
|
ret_frac = pos_prev * ret_i - cost_i
|
||||||
equity = equity * (1.0 + ret_net / FTMO_INITIAL_CAPITAL * FTMO_INITIAL_CAPITAL / equity
|
equity *= 1.0 + ret_frac if equity > 0 else 1.0
|
||||||
if equity > 0 else 1.0)
|
|
||||||
# Simpler: track as fraction
|
|
||||||
equity += FTMO_INITIAL_CAPITAL * ret_net
|
|
||||||
pos_prev = pos_i
|
pos_prev = pos_i
|
||||||
|
|
||||||
if total_breached:
|
if total_breached:
|
||||||
@@ -342,6 +338,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,
|
||||||
@@ -352,9 +471,11 @@ def backtest_signal_ftmo(
|
|||||||
stop_pips: float = FTMO_STOP_PIPS,
|
stop_pips: float = FTMO_STOP_PIPS,
|
||||||
max_leverage: float = FTMO_MAX_LEVERAGE,
|
max_leverage: float = FTMO_MAX_LEVERAGE,
|
||||||
bars_per_year: int = DEFAULT_BARS_PER_YEAR,
|
bars_per_year: int = DEFAULT_BARS_PER_YEAR,
|
||||||
forward_returns: Optional[pd.Series] = None,
|
forward_returns: pd.Series | None = None,
|
||||||
oos_start: Optional[str] = OOS_START_DEFAULT,
|
oos_start: str | None = OOS_START_DEFAULT,
|
||||||
) -> Dict[str, Any]:
|
wf_rolling: bool = False,
|
||||||
|
mc_n_permutations: int = 0,
|
||||||
|
) -> 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 +506,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)
|
||||||
@@ -415,7 +543,7 @@ def backtest_signal_ftmo(
|
|||||||
is_mask = close.index < oos_ts
|
is_mask = close.index < oos_ts
|
||||||
oos_mask = close.index >= oos_ts
|
oos_mask = close.index >= oos_ts
|
||||||
|
|
||||||
def _split_bt(mask: "pd.Series[bool]", prefix: str) -> None:
|
def _split_bt(mask: pd.Series[bool], prefix: str) -> None:
|
||||||
if mask.sum() < 100:
|
if mask.sum() < 100:
|
||||||
return
|
return
|
||||||
close_s = close.loc[mask]
|
close_s = close.loc[mask]
|
||||||
@@ -440,6 +568,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
|
||||||
|
|
||||||
|
|
||||||
@@ -448,7 +598,7 @@ def backtest_from_forward_returns(
|
|||||||
forward_returns: pd.Series,
|
forward_returns: pd.Series,
|
||||||
txn_cost_bps: float = DEFAULT_TXN_COST_BPS,
|
txn_cost_bps: float = DEFAULT_TXN_COST_BPS,
|
||||||
bars_per_year: int = DEFAULT_BARS_PER_YEAR,
|
bars_per_year: int = DEFAULT_BARS_PER_YEAR,
|
||||||
) -> Dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Backtest a factor using sign(factor) as signal against forward returns.
|
Backtest a factor using sign(factor) as signal against forward returns.
|
||||||
|
|
||||||
@@ -486,7 +636,7 @@ def backtest_from_forward_returns(
|
|||||||
ic = ic_val if np.isfinite(ic_val) else 0.0
|
ic = ic_val if np.isfinite(ic_val) else 0.0
|
||||||
|
|
||||||
trade_pnl = _compute_trade_pnl(position, strategy_returns)
|
trade_pnl = _compute_trade_pnl(position, strategy_returns)
|
||||||
n_trades = int(len(trade_pnl))
|
n_trades = len(trade_pnl)
|
||||||
win_rate = float((trade_pnl > 0).mean()) if n_trades > 0 else 0.0
|
win_rate = float((trade_pnl > 0).mean()) if n_trades > 0 else 0.0
|
||||||
|
|
||||||
ann_return = float(strategy_returns.mean() * bars_per_year)
|
ann_return = float(strategy_returns.mean() * bars_per_year)
|
||||||
@@ -502,7 +652,7 @@ def backtest_from_forward_returns(
|
|||||||
"win_rate": win_rate,
|
"win_rate": win_rate,
|
||||||
"n_trades": n_trades,
|
"n_trades": n_trades,
|
||||||
"ic": ic,
|
"ic": ic,
|
||||||
"n_bars": int(len(strategy_returns)),
|
"n_bars": len(strategy_returns),
|
||||||
"txn_cost_bps": txn_cost_bps,
|
"txn_cost_bps": txn_cost_bps,
|
||||||
"bars_per_year": bars_per_year,
|
"bars_per_year": bars_per_year,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,8 +75,10 @@ class CoSTEER(Developer[Experiment]):
|
|||||||
|
|
||||||
def _get_last_fb(self) -> CoSTEERMultiFeedback:
|
def _get_last_fb(self) -> CoSTEERMultiFeedback:
|
||||||
fb = self.evolve_agent.evolving_trace[-1].feedback
|
fb = self.evolve_agent.evolving_trace[-1].feedback
|
||||||
assert fb is not None, "feedback is None"
|
if fb is None:
|
||||||
assert isinstance(fb, CoSTEERMultiFeedback), "feedback must be of type CoSTEERMultiFeedback"
|
raise AssertionError("feedback is None")
|
||||||
|
if not isinstance(fb, CoSTEERMultiFeedback):
|
||||||
|
raise TypeError("feedback must be of type CoSTEERMultiFeedback")
|
||||||
return fb
|
return fb
|
||||||
|
|
||||||
def should_use_new_evo(self, base_fb: CoSTEERMultiFeedback | None, new_fb: CoSTEERMultiFeedback) -> bool:
|
def should_use_new_evo(self, base_fb: CoSTEERMultiFeedback | None, new_fb: CoSTEERMultiFeedback) -> bool:
|
||||||
@@ -121,7 +123,8 @@ class CoSTEER(Developer[Experiment]):
|
|||||||
|
|
||||||
for evo_exp in self.evolve_agent.multistep_evolve(evo_exp, self.evaluator):
|
for evo_exp in self.evolve_agent.multistep_evolve(evo_exp, self.evaluator):
|
||||||
iteration_count += 1
|
iteration_count += 1
|
||||||
assert isinstance(evo_exp, Experiment) # multiple inheritance
|
if not isinstance(evo_exp, Experiment):
|
||||||
|
raise TypeError("evo_exp must be an instance of Experiment")
|
||||||
evo_fb = self._get_last_fb()
|
evo_fb = self._get_last_fb()
|
||||||
update_fallback = self.should_use_new_evo(
|
update_fallback = self.should_use_new_evo(
|
||||||
base_fb=fallback_evo_fb,
|
base_fb=fallback_evo_fb,
|
||||||
@@ -154,7 +157,8 @@ class CoSTEER(Developer[Experiment]):
|
|||||||
evo_exp = fallback_evo_exp
|
evo_exp = fallback_evo_exp
|
||||||
evo_exp.recover_ws_ckp()
|
evo_exp.recover_ws_ckp()
|
||||||
evo_fb = fallback_evo_fb
|
evo_fb = fallback_evo_fb
|
||||||
assert evo_fb is not None # multistep_evolve should run at least once
|
if evo_fb is None:
|
||||||
|
raise AssertionError("multistep_evolve should run at least once")
|
||||||
evo_exp = self._exp_postprocess_by_feedback(evo_exp, evo_fb)
|
evo_exp = self._exp_postprocess_by_feedback(evo_exp, evo_fb)
|
||||||
except CoderError as e:
|
except CoderError as e:
|
||||||
e.caused_by_timeout = reached_max_seconds
|
e.caused_by_timeout = reached_max_seconds
|
||||||
@@ -264,9 +268,12 @@ class CoSTEER(Developer[Experiment]):
|
|||||||
- Raise Error if it failed to handle the develop task
|
- Raise Error if it failed to handle the develop task
|
||||||
-
|
-
|
||||||
"""
|
"""
|
||||||
assert isinstance(evo, Experiment)
|
if not isinstance(evo, Experiment):
|
||||||
assert isinstance(feedback, CoSTEERMultiFeedback)
|
raise TypeError("evo must be an instance of Experiment")
|
||||||
assert len(evo.sub_workspace_list) == len(feedback)
|
if not isinstance(feedback, CoSTEERMultiFeedback):
|
||||||
|
raise TypeError("feedback must be an instance of CoSTEERMultiFeedback")
|
||||||
|
if len(evo.sub_workspace_list) != len(feedback):
|
||||||
|
raise ValueError("Length of sub_workspace_list must match length of feedback")
|
||||||
|
|
||||||
# FIXME: when whould the feedback be None?
|
# FIXME: when whould the feedback be None?
|
||||||
failed_feedbacks = [
|
failed_feedbacks = [
|
||||||
|
|||||||
@@ -122,7 +122,8 @@ class MultiProcessEvolvingStrategy(EvolvingStrategy):
|
|||||||
last_feedback = None
|
last_feedback = None
|
||||||
if len(evolving_trace) > 0:
|
if len(evolving_trace) > 0:
|
||||||
last_feedback = evolving_trace[-1].feedback
|
last_feedback = evolving_trace[-1].feedback
|
||||||
assert isinstance(last_feedback, CoSTEERMultiFeedback)
|
if not isinstance(last_feedback, CoSTEERMultiFeedback):
|
||||||
|
raise TypeError("last_feedback must be of type CoSTEERMultiFeedback")
|
||||||
|
|
||||||
# 1.找出需要evolve的task
|
# 1.找出需要evolve的task
|
||||||
to_be_finished_task_index: list[int] = []
|
to_be_finished_task_index: list[int] = []
|
||||||
|
|||||||
@@ -1028,7 +1028,8 @@ class CoSTEERKnowledgeBaseV2(EvolvingKnowledgeBase):
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
node_count = len(nodes)
|
node_count = len(nodes)
|
||||||
assert node_count >= 2, "nodes length must >=2"
|
if node_count < 2:
|
||||||
|
raise ValueError("nodes length must >=2")
|
||||||
intersection_node_list = []
|
intersection_node_list = []
|
||||||
if output_intersection_origin:
|
if output_intersection_origin:
|
||||||
origin_list = []
|
origin_list = []
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ def get_ds_env(
|
|||||||
ValueError: If the env_type is not recognized.
|
ValueError: If the env_type is not recognized.
|
||||||
"""
|
"""
|
||||||
conf = DSCoderCoSTEERSettings()
|
conf = DSCoderCoSTEERSettings()
|
||||||
assert conf_type in ["kaggle", "mlebench"], f"Unknown conf_type: {conf_type}"
|
if conf_type not in ["kaggle", "mlebench"]:
|
||||||
|
raise ValueError(f"Unknown conf_type: {conf_type}")
|
||||||
|
|
||||||
if conf.env_type == "docker":
|
if conf.env_type == "docker":
|
||||||
env_conf = DSDockerConf() if conf_type == "kaggle" else MLEBDockerConf()
|
env_conf = DSDockerConf() if conf_type == "kaggle" else MLEBDockerConf()
|
||||||
@@ -79,7 +80,8 @@ def get_clear_ws_cmd(stage: Literal["before_training", "before_inference"] = "be
|
|||||||
"""
|
"""
|
||||||
Clean the files in workspace to a specific stage
|
Clean the files in workspace to a specific stage
|
||||||
"""
|
"""
|
||||||
assert stage in ["before_training", "before_inference"], f"Unknown stage: {stage}"
|
if stage not in ["before_training", "before_inference"]:
|
||||||
|
raise ValueError(f"Unknown stage: {stage}")
|
||||||
if DS_RD_SETTING.enable_model_dump and stage == "before_training":
|
if DS_RD_SETTING.enable_model_dump and stage == "before_training":
|
||||||
cmd = "rm -r submission.csv scores.csv models trace.log"
|
cmd = "rm -r submission.csv scores.csv models trace.log"
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ File structure
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from jinja2 import Environment, StrictUndefined
|
from jinja2 import Environment, StrictUndefined, select_autoescape
|
||||||
|
|
||||||
from rdagent.app.data_science.conf import DS_RD_SETTING
|
from rdagent.app.data_science.conf import DS_RD_SETTING
|
||||||
from rdagent.components.coder.CoSTEER.evaluators import (
|
from rdagent.components.coder.CoSTEER.evaluators import (
|
||||||
@@ -88,7 +88,7 @@ class EnsembleMultiProcessEvolvingStrategy(MultiProcessEvolvingStrategy):
|
|||||||
code_spec = workspace.file_dict["spec/ensemble.md"]
|
code_spec = workspace.file_dict["spec/ensemble.md"]
|
||||||
else:
|
else:
|
||||||
test_code = (
|
test_code = (
|
||||||
Environment(undefined=StrictUndefined)
|
Environment(undefined=StrictUndefined, autoescape=select_autoescape())
|
||||||
.from_string((DIRNAME / "eval_tests" / "ensemble_test.txt").read_text())
|
.from_string((DIRNAME / "eval_tests" / "ensemble_test.txt").read_text())
|
||||||
.render(
|
.render(
|
||||||
model_names=[
|
model_names=[
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import json
|
|||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from jinja2 import Environment, StrictUndefined
|
from jinja2 import Environment, StrictUndefined, select_autoescape
|
||||||
|
|
||||||
from rdagent.app.data_science.conf import DS_RD_SETTING
|
from rdagent.app.data_science.conf import DS_RD_SETTING
|
||||||
from rdagent.components.coder.CoSTEER.evaluators import (
|
from rdagent.components.coder.CoSTEER.evaluators import (
|
||||||
@@ -55,7 +55,7 @@ class EnsembleCoSTEEREvaluator(CoSTEEREvaluator):
|
|||||||
fname = "test/ensemble_test.txt"
|
fname = "test/ensemble_test.txt"
|
||||||
test_code = (DIRNAME / "eval_tests" / "ensemble_test.txt").read_text()
|
test_code = (DIRNAME / "eval_tests" / "ensemble_test.txt").read_text()
|
||||||
test_code = (
|
test_code = (
|
||||||
Environment(undefined=StrictUndefined)
|
Environment(undefined=StrictUndefined, autoescape=select_autoescape())
|
||||||
.from_string(test_code)
|
.from_string(test_code)
|
||||||
.render(
|
.render(
|
||||||
model_names=[
|
model_names=[
|
||||||
|
|||||||
@@ -51,13 +51,23 @@ class FactorAutoFixer:
|
|||||||
self.fixes_applied = []
|
self.fixes_applied = []
|
||||||
fixed_code = code
|
fixed_code = code
|
||||||
|
|
||||||
# Apply fixes in order - groupby fixes MUST come before min_periods fixes
|
# Apply fixes in order
|
||||||
|
# NOTE: _fix_min_periods is intentionally excluded — it increased min_periods to
|
||||||
|
# match window size, which causes all-NaN output for intraday data with 96 bars/day
|
||||||
|
# (window=240 > 96 means zero valid bars per day). The LLM sets its own min_periods.
|
||||||
fix_methods = [
|
fix_methods = [
|
||||||
self._fix_groupby_apply_to_transform, # First: fix groupby patterns
|
self._fix_instrument_column_access, # First: fix df['instrument'] on MultiIndex
|
||||||
self._fix_min_periods, # Second: fix min_periods in resulting rolling calls
|
self._fix_instrument_loc_multiindex, # Second: fix df.loc[instrument_var] on MultiIndex
|
||||||
self._fix_inf_nan_handling, # Third: add inf/nan handling
|
self._fix_zero_volume_proxy, # Third: replace zero $volume with range proxy
|
||||||
self._fix_data_range_processing, # Fourth: ensure full data range
|
self._fix_reset_index_groupby, # Fourth: fix groupby(level=N) after reset_index()
|
||||||
self._fix_multiindex_groupby, # Fifth: ensure groupby on MultiIndex
|
self._fix_groupby_mixed_levels, # Fifth: fix groupby(level=[int, str])
|
||||||
|
self._fix_groupby_column_on_multiindex, # Sixth: fix groupby(['instrument','date']) on MultiIndex
|
||||||
|
self._fix_chained_groupby, # Seventh: fix groupby(level=N).groupby('date') chain
|
||||||
|
self._fix_rolling_ddof, # Eighth: remove unsupported ddof kwarg
|
||||||
|
self._fix_groupby_apply_to_transform, # Ninth: fix groupby patterns
|
||||||
|
self._fix_inf_nan_handling, # Tenth: add inf/nan handling
|
||||||
|
self._fix_data_range_processing, # Eleventh: ensure full data range
|
||||||
|
self._fix_multiindex_groupby, # Twelfth: ensure groupby on MultiIndex
|
||||||
]
|
]
|
||||||
|
|
||||||
for fix_method in fix_methods:
|
for fix_method in fix_methods:
|
||||||
@@ -75,6 +85,352 @@ class FactorAutoFixer:
|
|||||||
|
|
||||||
return fixed_code
|
return fixed_code
|
||||||
|
|
||||||
|
def _fix_instrument_column_access(self, code: str) -> str:
|
||||||
|
"""
|
||||||
|
Fix: df['instrument'] raises KeyError on a MultiIndex DataFrame because
|
||||||
|
'instrument' is an index level (level 1), not a column.
|
||||||
|
|
||||||
|
Replace df['instrument'] with df.index.get_level_values('instrument')
|
||||||
|
but only when the DataFrame has a MultiIndex (not after reset_index which
|
||||||
|
would have promoted it to a real column).
|
||||||
|
|
||||||
|
Also fixes df.reset_index()['instrument'] correctly since after reset_index
|
||||||
|
the column exists.
|
||||||
|
"""
|
||||||
|
fixed_code = code
|
||||||
|
|
||||||
|
# Skip if already fixed or if reset_index() is being used before the access
|
||||||
|
# We only fix bare df['instrument'] where df is the original MultiIndex frame.
|
||||||
|
# Heuristic: if the assignment lhs or context shows reset_index, leave it alone.
|
||||||
|
|
||||||
|
# Pattern: <varname>['instrument'] where varname is NOT a reset_index result
|
||||||
|
reset_vars = set(re.findall(r'(\w+)\s*=\s*\w[^=\n]*\.reset_index\(', fixed_code))
|
||||||
|
|
||||||
|
def _replace_instrument_access(m: re.Match) -> str:
|
||||||
|
var = m.group(1)
|
||||||
|
if var in reset_vars:
|
||||||
|
return m.group(0) # leave reset_index vars alone — column exists
|
||||||
|
self.fixes_applied.append(f"instrument_column: {var}['instrument'] → get_level_values(1)")
|
||||||
|
return f"{var}.index.get_level_values(1)"
|
||||||
|
|
||||||
|
# Exclude assignment targets: var['instrument'] = ... must not become
|
||||||
|
# var.index.get_level_values(1) = ... (SyntaxError: cannot assign to function call)
|
||||||
|
fixed_code = re.sub(r"(\w+)\['instrument'\](?!\s*=)", _replace_instrument_access, fixed_code)
|
||||||
|
|
||||||
|
return fixed_code
|
||||||
|
|
||||||
|
def _fix_instrument_loc_multiindex(self, code: str) -> str:
|
||||||
|
"""
|
||||||
|
Fix: df.loc[instrument_var] raises DateParseError on a (datetime, instrument)
|
||||||
|
MultiIndex because pandas tries to match the instrument string against the
|
||||||
|
datetime level (level 0).
|
||||||
|
|
||||||
|
Pattern detected: for-loops iterating over get_level_values('instrument') or
|
||||||
|
get_level_values(1) where the loop variable is then used as df.loc[loop_var].
|
||||||
|
|
||||||
|
Replacement: df.loc[instrument_var] → df.xs(instrument_var, level=1)
|
||||||
|
"""
|
||||||
|
fixed_code = code
|
||||||
|
|
||||||
|
# Find variables iterated from get_level_values('instrument') or get_level_values(1)
|
||||||
|
inst_vars = set(
|
||||||
|
re.findall(
|
||||||
|
r"for\s+(\w+)\s+in\s+.+?\.get_level_values\s*\(\s*(?:1|['\"]instrument['\"])\s*\)[^:\n]*:",
|
||||||
|
code,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not inst_vars:
|
||||||
|
return fixed_code
|
||||||
|
|
||||||
|
for var in inst_vars:
|
||||||
|
# Replace DF.loc[var] (read) with DF.xs(var, level=1)
|
||||||
|
# Exclude write-back patterns (DF.loc[var] = ...) — leave those as-is
|
||||||
|
def _make_replacer(v: str):
|
||||||
|
def _replace(m: re.Match) -> str:
|
||||||
|
df_var = m.group(1)
|
||||||
|
self.fixes_applied.append(
|
||||||
|
f"instrument_loc: {df_var}.loc[{v}] → {df_var}.xs({v}, level=1)"
|
||||||
|
)
|
||||||
|
return f"{df_var}.xs({v}, level=1)"
|
||||||
|
|
||||||
|
return _replace
|
||||||
|
|
||||||
|
# Only match when NOT followed by ' =' (assignment)
|
||||||
|
fixed_code = re.sub(
|
||||||
|
rf"(\w+)\.loc\[\s*{re.escape(var)}\s*\](?!\s*=)",
|
||||||
|
_make_replacer(var),
|
||||||
|
fixed_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
return fixed_code
|
||||||
|
|
||||||
|
def _fix_zero_volume_proxy(self, code: str) -> str:
|
||||||
|
"""
|
||||||
|
Fix: $volume is always 0 in our EUR/USD dataset (FX has no real volume).
|
||||||
|
Any factor using $volume (VWAP, volume-weighted returns, etc.) produces
|
||||||
|
all-NaN output because 0*price=0 and sum(0)/sum(0)=NaN.
|
||||||
|
|
||||||
|
Insert a guard right after pd.read_hdf() that replaces zero volume with
|
||||||
|
the intraday price-range proxy ($high - $low) so volume-weighted factors
|
||||||
|
produce meaningful signals.
|
||||||
|
"""
|
||||||
|
if "'$volume'" not in code and '"$volume"' not in code:
|
||||||
|
return code
|
||||||
|
|
||||||
|
# Already patched
|
||||||
|
if "volume proxy" in code:
|
||||||
|
return code
|
||||||
|
|
||||||
|
lines = code.splitlines()
|
||||||
|
insert_after = -1
|
||||||
|
df_var = "df"
|
||||||
|
indent = " "
|
||||||
|
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if "read_hdf(" in line:
|
||||||
|
m = re.match(r"(\s*)(\w+)\s*=\s*", line)
|
||||||
|
if m:
|
||||||
|
indent = m.group(1)
|
||||||
|
df_var = m.group(2)
|
||||||
|
else:
|
||||||
|
m2 = re.match(r"(\s*)", line)
|
||||||
|
indent = m2.group(1) if m2 else " "
|
||||||
|
insert_after = i
|
||||||
|
break
|
||||||
|
|
||||||
|
if insert_after == -1:
|
||||||
|
return code
|
||||||
|
|
||||||
|
proxy_lines = [
|
||||||
|
f"{indent}# volume proxy: $volume is always 0 in FX data — use price-range as proxy",
|
||||||
|
f"{indent}if ({df_var}['$volume'] == 0).all():",
|
||||||
|
f"{indent} {df_var}['$volume'] = {df_var}['$high'] - {df_var}['$low']",
|
||||||
|
]
|
||||||
|
lines = lines[: insert_after + 1] + proxy_lines + lines[insert_after + 1 :]
|
||||||
|
self.fixes_applied.append("volume_proxy: replaced zero $volume with ($high - $low)")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _fix_reset_index_groupby(self, code: str) -> str:
|
||||||
|
"""
|
||||||
|
Fix: groupby(level=N) on a variable created by .reset_index() fails because
|
||||||
|
reset_index() converts the MultiIndex into regular columns, leaving a plain
|
||||||
|
RangeIndex. Replace groupby(level=N) on such variables with
|
||||||
|
groupby('instrument').
|
||||||
|
|
||||||
|
Detected pattern:
|
||||||
|
varname = <anything>.reset_index(...)
|
||||||
|
...
|
||||||
|
varname.groupby(level=0|1)
|
||||||
|
"""
|
||||||
|
fixed_code = code
|
||||||
|
|
||||||
|
# Find all variables assigned via reset_index()
|
||||||
|
reset_vars = set(re.findall(r'(\w+)\s*=\s*\w[^=\n]*\.reset_index\(', fixed_code))
|
||||||
|
|
||||||
|
for var in reset_vars:
|
||||||
|
# Replace var.groupby(level=N) with var.groupby('instrument')
|
||||||
|
pattern = rf'{re.escape(var)}\.groupby\(level\s*=\s*\d+\)'
|
||||||
|
if re.search(pattern, fixed_code):
|
||||||
|
fixed_code = re.sub(pattern, f"{var}.groupby('instrument')", fixed_code)
|
||||||
|
self.fixes_applied.append(f"reset_index_groupby: {var}.groupby(level=N) → groupby('instrument')")
|
||||||
|
|
||||||
|
return fixed_code
|
||||||
|
|
||||||
|
def _fix_groupby_mixed_levels(self, code: str) -> str:
|
||||||
|
"""
|
||||||
|
Fix: groupby(level=[int, 'str']) raises AssertionError because string level
|
||||||
|
names don't exist on an unnamed MultiIndex. Keep only integer levels.
|
||||||
|
|
||||||
|
Pattern: .groupby(level=[0, 'date']) → .groupby(level=0)
|
||||||
|
.groupby(level=[1, 'date']) → .groupby(level=1)
|
||||||
|
"""
|
||||||
|
fixed_code = code
|
||||||
|
|
||||||
|
def _keep_int_levels(m):
|
||||||
|
inner = m.group(1)
|
||||||
|
ints = re.findall(r'\b(\d+)\b', inner)
|
||||||
|
if not ints:
|
||||||
|
return m.group(0)
|
||||||
|
replacement = f'.groupby(level={ints[0]})' if len(ints) == 1 else f'.groupby(level=[{", ".join(ints)}])'
|
||||||
|
self.fixes_applied.append(f"mixed_levels: groupby(level=[...,str]) → {replacement}")
|
||||||
|
return replacement
|
||||||
|
|
||||||
|
fixed_code = re.sub(r'\.groupby\(level=\[([^\]]+)\]\)', _keep_int_levels, fixed_code)
|
||||||
|
return fixed_code
|
||||||
|
|
||||||
|
def _fix_groupby_column_on_multiindex(self, code: str) -> str:
|
||||||
|
"""
|
||||||
|
Fix: groupby(['instrument', 'date']) on a MultiIndex (datetime, instrument)
|
||||||
|
DataFrame fails with KeyError because those are index levels, not columns.
|
||||||
|
|
||||||
|
Correct replacement preserves BOTH dimensions so intraday calculations reset
|
||||||
|
per day:
|
||||||
|
var.groupby(['instrument', 'date'])
|
||||||
|
→ var.groupby([var.index.get_level_values(1), var.index.get_level_values(0).normalize()])
|
||||||
|
|
||||||
|
Single-column groupby(['instrument']) is correctly replaced with groupby(level=1).
|
||||||
|
Note: do NOT convert groupby('instrument') → groupby(level=1) here — that would
|
||||||
|
undo the reset_index_groupby fix which correctly emits groupby('instrument').
|
||||||
|
"""
|
||||||
|
fixed_code = code
|
||||||
|
|
||||||
|
# Variables created via reset_index() have a plain RangeIndex — applying
|
||||||
|
# get_level_values() on them would raise AttributeError. Skip those.
|
||||||
|
reset_vars = set(re.findall(r'(\w+)\s*=\s*\w[^=\n]*\.reset_index\(', fixed_code))
|
||||||
|
|
||||||
|
def _replace_two_col_groupby(m: re.Match, order: str) -> str:
|
||||||
|
var = m.group(1)
|
||||||
|
if var in reset_vars:
|
||||||
|
return m.group(0) # leave reset_index vars alone — RangeIndex, not MultiIndex
|
||||||
|
if order == "instrument_date":
|
||||||
|
repl = (
|
||||||
|
f"{var}.groupby([{var}.index.get_level_values(1), "
|
||||||
|
f"{var}.index.get_level_values(0).normalize()])"
|
||||||
|
)
|
||||||
|
else: # date_instrument
|
||||||
|
repl = (
|
||||||
|
f"{var}.groupby([{var}.index.get_level_values(0).normalize(), "
|
||||||
|
f"{var}.index.get_level_values(1)])"
|
||||||
|
)
|
||||||
|
self.fixes_applied.append(f"multiindex_groupby: {m.group(0)[:60]} → two-level")
|
||||||
|
return repl
|
||||||
|
|
||||||
|
# groupby(['instrument', 'date']) — capture variable name before .groupby
|
||||||
|
fixed_code = re.sub(
|
||||||
|
r'(\w+)\.groupby\(\[\'instrument\',\s*\'date\'\]\)',
|
||||||
|
lambda m: _replace_two_col_groupby(m, "instrument_date"),
|
||||||
|
fixed_code,
|
||||||
|
)
|
||||||
|
# groupby(['date', 'instrument'])
|
||||||
|
fixed_code = re.sub(
|
||||||
|
r'(\w+)\.groupby\(\[\'date\',\s*\'instrument\'\]\)',
|
||||||
|
lambda m: _replace_two_col_groupby(m, "date_instrument"),
|
||||||
|
fixed_code,
|
||||||
|
)
|
||||||
|
# single: groupby(['instrument']) → groupby(level=1), but not on reset_index vars
|
||||||
|
def _replace_single_instrument_groupby(m: re.Match) -> str:
|
||||||
|
# Look backwards to find the variable name
|
||||||
|
prefix = fixed_code[: m.start()]
|
||||||
|
var_match = re.search(r'(\w+)\s*$', prefix)
|
||||||
|
var = var_match.group(1) if var_match else ''
|
||||||
|
if var in reset_vars:
|
||||||
|
return m.group(0)
|
||||||
|
self.fixes_applied.append("multiindex_groupby: groupby(['instrument']) → groupby(level=1)")
|
||||||
|
return ".groupby(level=1)"
|
||||||
|
|
||||||
|
if re.search(r"\.groupby\(\['instrument'\]\)", fixed_code):
|
||||||
|
fixed_code = re.sub(r"\.groupby\(\['instrument'\]\)", _replace_single_instrument_groupby, fixed_code)
|
||||||
|
|
||||||
|
# groupby(level=['instrument', 'date']) — uses level= keyword with string names.
|
||||||
|
# 'date' is NOT a valid level name in our (datetime, instrument) MultiIndex;
|
||||||
|
# replace with get_level_values to normalize datetime to daily timestamps.
|
||||||
|
fixed_code = re.sub(
|
||||||
|
r"(\w+)\.groupby\(level=\['instrument',\s*'date'\]\)",
|
||||||
|
lambda m: (
|
||||||
|
self.fixes_applied.append(
|
||||||
|
f"multiindex_groupby: {m.group(0)[:60]} → two-level get_level_values"
|
||||||
|
)
|
||||||
|
or f"{m.group(1)}.groupby([{m.group(1)}.index.get_level_values(1), "
|
||||||
|
f"{m.group(1)}.index.get_level_values(0).normalize()])"
|
||||||
|
),
|
||||||
|
fixed_code,
|
||||||
|
)
|
||||||
|
# groupby(level=['date', 'instrument'])
|
||||||
|
fixed_code = re.sub(
|
||||||
|
r"(\w+)\.groupby\(level=\['date',\s*'instrument'\]\)",
|
||||||
|
lambda m: (
|
||||||
|
self.fixes_applied.append(
|
||||||
|
f"multiindex_groupby: {m.group(0)[:60]} → two-level get_level_values"
|
||||||
|
)
|
||||||
|
or f"{m.group(1)}.groupby([{m.group(1)}.index.get_level_values(0).normalize(), "
|
||||||
|
f"{m.group(1)}.index.get_level_values(1)])"
|
||||||
|
),
|
||||||
|
fixed_code,
|
||||||
|
)
|
||||||
|
# single: groupby(level=['instrument']) → groupby(level=1)
|
||||||
|
fixed_code = re.sub(
|
||||||
|
r"\.groupby\(level=\['instrument'\]\)",
|
||||||
|
lambda m: (self.fixes_applied.append("multiindex_groupby: groupby(level=['instrument']) → level=1") or ".groupby(level=1)"),
|
||||||
|
fixed_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
return fixed_code
|
||||||
|
|
||||||
|
def _fix_chained_groupby(self, code: str) -> str:
|
||||||
|
"""
|
||||||
|
Fix two broken patterns the LLM generates when trying to group by (instrument, date):
|
||||||
|
|
||||||
|
Pattern A — chained groupby (runtime AttributeError):
|
||||||
|
var.groupby(level=1).groupby('date')
|
||||||
|
→ var.groupby([var.index.get_level_values(1),
|
||||||
|
var.index.get_level_values(0).normalize()])
|
||||||
|
|
||||||
|
Pattern B — keyword arg inside list (SyntaxError):
|
||||||
|
var.groupby([level=1, 'date'])
|
||||||
|
→ same two-level replacement
|
||||||
|
"""
|
||||||
|
fixed_code = code
|
||||||
|
|
||||||
|
def _two_level(var: str, tag: str) -> str:
|
||||||
|
self.fixes_applied.append(f"chained_groupby: {tag} → two-level")
|
||||||
|
return (
|
||||||
|
f"{var}.groupby([{var}.index.get_level_values(1), "
|
||||||
|
f"{var}.index.get_level_values(0).normalize()])"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pattern A: var.groupby(level=N).groupby('date')
|
||||||
|
fixed_code = re.sub(
|
||||||
|
r'(\w+)\.groupby\(level=\d+\)\.groupby\(["\']date["\']\)',
|
||||||
|
lambda m: _two_level(m.group(1), m.group(0)[:60]),
|
||||||
|
fixed_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pattern B: .groupby([level=N, 'date']) — SyntaxError in Python.
|
||||||
|
# The variable before .groupby may be complex (e.g. df[mask]) so we don't
|
||||||
|
# try to capture it; we use df as the index reference (always correct since
|
||||||
|
# all filtered frames share df's MultiIndex structure).
|
||||||
|
def _two_level_df(tag: str) -> str:
|
||||||
|
self.fixes_applied.append(f"chained_groupby: {tag} → two-level")
|
||||||
|
return ".groupby([df.index.get_level_values(1), df.index.get_level_values(0).normalize()])"
|
||||||
|
|
||||||
|
fixed_code = re.sub(
|
||||||
|
r'\.groupby\(\[\s*level\s*=\s*\d+\s*,\s*["\']?date["\']?\s*\]\)',
|
||||||
|
lambda m: _two_level_df(m.group(0)[:60]),
|
||||||
|
fixed_code,
|
||||||
|
)
|
||||||
|
# Also handle reversed order: ['date', level=N]
|
||||||
|
fixed_code = re.sub(
|
||||||
|
r'\.groupby\(\[\s*["\']?date["\']?\s*,\s*level\s*=\s*\d+\s*\]\)',
|
||||||
|
lambda m: _two_level_df(m.group(0)[:60]),
|
||||||
|
fixed_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
return fixed_code
|
||||||
|
|
||||||
|
def _fix_rolling_ddof(self, code: str) -> str:
|
||||||
|
"""
|
||||||
|
Fix: pandas rolling() does not accept a ddof kwarg — raises TypeError.
|
||||||
|
Remove ddof from both rolling(..., ddof=N) and rolling(...).std(ddof=N).
|
||||||
|
"""
|
||||||
|
fixed_code = code
|
||||||
|
|
||||||
|
# Form 1: ddof inside rolling() — .rolling(window=N, min_periods=M, ddof=K)
|
||||||
|
def _strip_ddof_from_rolling(m):
|
||||||
|
inner = re.sub(r',?\s*ddof\s*=\s*\d+', '', m.group(1))
|
||||||
|
inner = inner.strip(', ')
|
||||||
|
self.fixes_applied.append("rolling_ddof: removed ddof from rolling()")
|
||||||
|
return f'.rolling({inner})'
|
||||||
|
|
||||||
|
fixed_code = re.sub(r'\.rolling\(([^)]*ddof\s*=\s*\d+[^)]*)\)', _strip_ddof_from_rolling, fixed_code)
|
||||||
|
|
||||||
|
# Form 2: ddof inside .std() / .var() — .std(ddof=N)
|
||||||
|
if re.search(r'\.(std|var)\([^)]*ddof\s*=\s*\d+', fixed_code):
|
||||||
|
fixed_code = re.sub(r'\.(std|var)\([^)]*ddof\s*=\s*\d+[^)]*\)', r'.\1()', fixed_code)
|
||||||
|
self.fixes_applied.append("rolling_ddof: removed ddof from std()/var()")
|
||||||
|
|
||||||
|
return fixed_code
|
||||||
|
|
||||||
def _fix_min_periods(self, code: str) -> str:
|
def _fix_min_periods(self, code: str) -> str:
|
||||||
"""
|
"""
|
||||||
Fix: Ensure min_periods matches window size in rolling calculations.
|
Fix: Ensure min_periods matches window size in rolling calculations.
|
||||||
@@ -325,6 +681,45 @@ class FactorAutoFixer:
|
|||||||
fixed_code = fixed_code.replace(old_code, new_code)
|
fixed_code = fixed_code.replace(old_code, new_code)
|
||||||
self.fixes_applied.append(f"groupby: fixed rolling correlation (window={window}) with reset_index")
|
self.fixes_applied.append(f"groupby: fixed rolling correlation (window={window}) with reset_index")
|
||||||
|
|
||||||
|
# === GENERAL FIX: DF.groupby(level=N)['col'].apply(lambda x: EXPR) ===
|
||||||
|
# apply() on a grouped Series returns a MultiIndex result (extra level prepended),
|
||||||
|
# causing index shape mismatch when assigned back to df['col'].
|
||||||
|
# Replace with transform() which preserves the original index.
|
||||||
|
col_apply_pattern = re.compile(
|
||||||
|
r"(\w+)\.groupby\(level=(\d+)\)\['([^']+)'\]\.apply\((\s*lambda\s+\w+\s*:.*?)\)",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
for m in list(col_apply_pattern.finditer(fixed_code)):
|
||||||
|
full = m.group(0)
|
||||||
|
df_var = m.group(1)
|
||||||
|
level = m.group(2)
|
||||||
|
col = m.group(3)
|
||||||
|
lam = m.group(4).strip()
|
||||||
|
new_expr = f"{df_var}.groupby(level={level})['{col}'].transform({lam})"
|
||||||
|
fixed_code = fixed_code.replace(full, new_expr, 1)
|
||||||
|
self.fixes_applied.append(
|
||||||
|
f"groupby: {df_var}.groupby(level={level})['{col}'].apply() → transform()"
|
||||||
|
)
|
||||||
|
|
||||||
|
# === FIX: .transform(...).reset_index(level=N, drop=True) ===
|
||||||
|
# transform() already returns the same index as the input — adding reset_index()
|
||||||
|
# after it drops an index level and causes ValueError on assignment back to df['col'].
|
||||||
|
# Detected line-by-line: if a line contains both .transform( and .reset_index(level=
|
||||||
|
reset_suffix = re.compile(r'\s*\.reset_index\s*\(\s*level\s*=[^,)]+,\s*drop\s*=\s*True\s*\)\s*$')
|
||||||
|
new_lines = []
|
||||||
|
changed = False
|
||||||
|
for line in fixed_code.splitlines():
|
||||||
|
if '.transform(' in line and '.reset_index(' in line:
|
||||||
|
cleaned = reset_suffix.sub('', line)
|
||||||
|
if cleaned != line:
|
||||||
|
new_lines.append(cleaned)
|
||||||
|
changed = True
|
||||||
|
continue
|
||||||
|
new_lines.append(line)
|
||||||
|
if changed:
|
||||||
|
fixed_code = '\n'.join(new_lines)
|
||||||
|
self.fixes_applied.append("groupby: removed spurious .reset_index() after .transform()")
|
||||||
|
|
||||||
# Pattern: Simple groupby().apply() with rolling().method()
|
# Pattern: Simple groupby().apply() with rolling().method()
|
||||||
# df.groupby(level=N).apply(lambda x: x['col'].rolling(...).method())
|
# df.groupby(level=N).apply(lambda x: x['col'].rolling(...).method())
|
||||||
apply_pattern = r"df\.groupby\(level=(\d+)\)\.apply\(\s*lambda\s+x:\s+x\['([^']+)'\]\.rolling\([^)]+\)\.(\w+)\([^)]*\)\s*\)"
|
apply_pattern = r"df\.groupby\(level=(\d+)\)\.apply\(\s*lambda\s+x:\s+x\['([^']+)'\]\.rolling\([^)]+\)\.(\w+)\([^)]*\)\s*\)"
|
||||||
|
|||||||
@@ -328,12 +328,13 @@ class FactorEqualValueRatioEvaluator(FactorEvaluator):
|
|||||||
"The source dataframe is None. Please check the implementation.",
|
"The source dataframe is None. Please check the implementation.",
|
||||||
-1,
|
-1,
|
||||||
)
|
)
|
||||||
|
acc_rate = -1
|
||||||
try:
|
try:
|
||||||
close_values = gen_df.sub(gt_df).abs().lt(1e-6)
|
close_values = gen_df.sub(gt_df).abs().lt(1e-6)
|
||||||
result_int = close_values.astype(int)
|
result_int = close_values.astype(int)
|
||||||
pos_num = result_int.sum().sum()
|
pos_num = result_int.sum().sum()
|
||||||
acc_rate = pos_num / close_values.size
|
acc_rate = pos_num / close_values.size
|
||||||
except:
|
except Exception:
|
||||||
close_values = gen_df
|
close_values = gen_df
|
||||||
if close_values.all().iloc[0]:
|
if close_values.all().iloc[0]:
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -161,8 +161,7 @@ class FactorFBWorkspace(FBWorkspace):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
subprocess.check_output(
|
subprocess.check_output(
|
||||||
f"{FACTOR_COSTEER_SETTINGS.python_bin} {execution_code_path}",
|
[FACTOR_COSTEER_SETTINGS.python_bin, str(execution_code_path)],
|
||||||
shell=True,
|
|
||||||
cwd=self.workspace_path,
|
cwd=self.workspace_path,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
timeout=FACTOR_COSTEER_SETTINGS.file_based_execution_timeout,
|
timeout=FACTOR_COSTEER_SETTINGS.file_based_execution_timeout,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Two-step validation:
|
|||||||
2. Micro-batch testing - Runtime validation with small dataset
|
2. Micro-batch testing - Runtime validation with small dataset
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
@@ -229,7 +230,7 @@ class LLMConfigValidator:
|
|||||||
final_metrics = re.search(r"\{'train_runtime':[^}]+\}", stdout)
|
final_metrics = re.search(r"\{'train_runtime':[^}]+\}", stdout)
|
||||||
if final_metrics:
|
if final_metrics:
|
||||||
try:
|
try:
|
||||||
metrics = eval(final_metrics.group(0)) # Safe: only numbers and strings
|
metrics = ast.literal_eval(final_metrics.group(0))
|
||||||
result["final_metrics"] = {
|
result["final_metrics"] = {
|
||||||
"train_loss": metrics.get("train_loss"),
|
"train_loss": metrics.get("train_loss"),
|
||||||
"train_runtime": metrics.get("train_runtime"),
|
"train_runtime": metrics.get("train_runtime"),
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -58,10 +58,12 @@ class ModelCodeEvaluator(CoSTEEREvaluator):
|
|||||||
model_execution_feedback: str = "",
|
model_execution_feedback: str = "",
|
||||||
model_value_feedback: str = "",
|
model_value_feedback: str = "",
|
||||||
):
|
):
|
||||||
assert isinstance(target_task, ModelTask)
|
if not isinstance(target_task, ModelTask):
|
||||||
assert isinstance(implementation, ModelFBWorkspace)
|
raise TypeError("target_task must be of type ModelTask")
|
||||||
if gt_implementation is not None:
|
if not isinstance(implementation, ModelFBWorkspace):
|
||||||
assert isinstance(gt_implementation, ModelFBWorkspace)
|
raise TypeError("implementation must be of type ModelFBWorkspace")
|
||||||
|
if gt_implementation is not None and not isinstance(gt_implementation, ModelFBWorkspace):
|
||||||
|
raise TypeError("gt_implementation must be of type ModelFBWorkspace")
|
||||||
|
|
||||||
model_task_information = target_task.get_task_information()
|
model_task_information = target_task.get_task_information()
|
||||||
code = implementation.all_codes
|
code = implementation.all_codes
|
||||||
@@ -113,10 +115,12 @@ class ModelFinalEvaluator(CoSTEEREvaluator):
|
|||||||
model_value_feedback: str,
|
model_value_feedback: str,
|
||||||
model_code_feedback: str,
|
model_code_feedback: str,
|
||||||
):
|
):
|
||||||
assert isinstance(target_task, ModelTask)
|
if not isinstance(target_task, ModelTask):
|
||||||
assert isinstance(implementation, ModelFBWorkspace)
|
raise TypeError("target_task must be of type ModelTask")
|
||||||
if gt_implementation is not None:
|
if not isinstance(implementation, ModelFBWorkspace):
|
||||||
assert isinstance(gt_implementation, ModelFBWorkspace)
|
raise TypeError("implementation must be of type ModelFBWorkspace")
|
||||||
|
if gt_implementation is not None and not isinstance(gt_implementation, ModelFBWorkspace):
|
||||||
|
raise TypeError("gt_implementation must be of type ModelFBWorkspace")
|
||||||
|
|
||||||
system_prompt = T(".prompts:evaluator_final_feedback.system").r(
|
system_prompt = T(".prompts:evaluator_final_feedback.system").r(
|
||||||
scenario=(
|
scenario=(
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ class ModelCoSTEEREvaluator(CoSTEEREvaluator):
|
|||||||
final_feedback="This task has failed too many times, skip implementation.",
|
final_feedback="This task has failed too many times, skip implementation.",
|
||||||
final_decision=False,
|
final_decision=False,
|
||||||
)
|
)
|
||||||
assert isinstance(target_task, ModelTask)
|
if not isinstance(target_task, ModelTask):
|
||||||
|
raise TypeError(f"Expected ModelTask, got {type(target_task)}")
|
||||||
|
|
||||||
# NOTE: Use fixed input to test the model to avoid randomness
|
# NOTE: Use fixed input to test the model to avoid randomness
|
||||||
batch_size = 8
|
batch_size = 8
|
||||||
@@ -50,7 +51,8 @@ class ModelCoSTEEREvaluator(CoSTEEREvaluator):
|
|||||||
input_value = 0.4
|
input_value = 0.4
|
||||||
param_init_value = 0.6
|
param_init_value = 0.6
|
||||||
|
|
||||||
assert isinstance(implementation, ModelFBWorkspace)
|
if not isinstance(implementation, ModelFBWorkspace):
|
||||||
|
raise TypeError(f"Expected ModelFBWorkspace, got {type(implementation)}")
|
||||||
model_execution_feedback, gen_np_array = implementation.execute(
|
model_execution_feedback, gen_np_array = implementation.execute(
|
||||||
batch_size=batch_size,
|
batch_size=batch_size,
|
||||||
num_features=num_features,
|
num_features=num_features,
|
||||||
@@ -59,7 +61,8 @@ class ModelCoSTEEREvaluator(CoSTEEREvaluator):
|
|||||||
param_init_value=param_init_value,
|
param_init_value=param_init_value,
|
||||||
)
|
)
|
||||||
if gt_implementation is not None:
|
if gt_implementation is not None:
|
||||||
assert isinstance(gt_implementation, ModelFBWorkspace)
|
if not isinstance(gt_implementation, ModelFBWorkspace):
|
||||||
|
raise TypeError(f"Expected ModelFBWorkspace, got {type(gt_implementation)}")
|
||||||
_, gt_np_array = gt_implementation.execute(
|
_, gt_np_array = gt_implementation.execute(
|
||||||
batch_size=batch_size,
|
batch_size=batch_size,
|
||||||
num_features=num_features,
|
num_features=num_features,
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import pandas as pd
|
|||||||
|
|
||||||
from rdagent.log import rdagent_logger as logger
|
from rdagent.log import rdagent_logger as logger
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
_optuna_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import optuna
|
import optuna
|
||||||
@@ -292,6 +292,7 @@ class OptunaOptimizer:
|
|||||||
"volatility_lookback": trial.suggest_int("volatility_lookback", 5, 500, step=5),
|
"volatility_lookback": trial.suggest_int("volatility_lookback", 5, 500, step=5),
|
||||||
"signal_bias": trial.suggest_float("signal_bias", -1.0, 1.0, step=0.05),
|
"signal_bias": trial.suggest_float("signal_bias", -1.0, 1.0, step=0.05),
|
||||||
"max_hold_bars": trial.suggest_int("max_hold_bars", 5, 1000, step=5),
|
"max_hold_bars": trial.suggest_int("max_hold_bars", 5, 1000, step=5),
|
||||||
|
"max_positions": trial.suggest_int("max_positions", 1, 5, step=1),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Parameters that are allowed to be negative (not clamped to 0).
|
# Parameters that are allowed to be negative (not clamped to 0).
|
||||||
@@ -308,6 +309,7 @@ class OptunaOptimizer:
|
|||||||
"volatility_lookback": 1.0,
|
"volatility_lookback": 1.0,
|
||||||
"signal_bias": -1.0,
|
"signal_bias": -1.0,
|
||||||
"max_hold_bars": 1.0,
|
"max_hold_bars": 1.0,
|
||||||
|
"max_positions": 1.0,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _suggest_bounded(
|
def _suggest_bounded(
|
||||||
@@ -357,6 +359,7 @@ class OptunaOptimizer:
|
|||||||
"volatility_lookback": (center.get("volatility_lookback", 100), 30),
|
"volatility_lookback": (center.get("volatility_lookback", 100), 30),
|
||||||
"signal_bias": (center.get("signal_bias", 0.0), 0.2),
|
"signal_bias": (center.get("signal_bias", 0.0), 0.2),
|
||||||
"max_hold_bars": (center.get("max_hold_bars", 100), 50),
|
"max_hold_bars": (center.get("max_hold_bars", 100), 50),
|
||||||
|
"max_positions": (center.get("max_positions", 1), 2),
|
||||||
}
|
}
|
||||||
return {key: self._suggest_bounded(trial, key, c, hw) for key, (c, hw) in ranges.items()}
|
return {key: self._suggest_bounded(trial, key, c, hw) for key, (c, hw) in ranges.items()}
|
||||||
|
|
||||||
@@ -388,6 +391,7 @@ class OptunaOptimizer:
|
|||||||
"volatility_lookback": (center.get("volatility_lookback", 100), 10),
|
"volatility_lookback": (center.get("volatility_lookback", 100), 10),
|
||||||
"signal_bias": (center.get("signal_bias", 0.0), 0.07),
|
"signal_bias": (center.get("signal_bias", 0.0), 0.07),
|
||||||
"max_hold_bars": (center.get("max_hold_bars", 100), 17),
|
"max_hold_bars": (center.get("max_hold_bars", 100), 17),
|
||||||
|
"max_positions": (center.get("max_positions", 1), 1),
|
||||||
}
|
}
|
||||||
return {key: self._suggest_bounded(trial, key, c, hw) for key, (c, hw) in ranges.items()}
|
return {key: self._suggest_bounded(trial, key, c, hw) for key, (c, hw) in ranges.items()}
|
||||||
|
|
||||||
@@ -467,6 +471,9 @@ class OptunaOptimizer:
|
|||||||
|
|
||||||
# Max holding periods (in bars)
|
# Max holding periods (in bars)
|
||||||
"max_hold_bars": trial.suggest_int("max_hold_bars", 10, 500, step=10),
|
"max_hold_bars": trial.suggest_int("max_hold_bars", 10, 500, step=10),
|
||||||
|
|
||||||
|
# Max concurrent positions (1 = no pyramiding, 2-5 = scale-in)
|
||||||
|
"max_positions": trial.suggest_int("max_positions", 1, 5, step=1),
|
||||||
}
|
}
|
||||||
|
|
||||||
return params
|
return params
|
||||||
@@ -597,6 +604,13 @@ class OptunaOptimizer:
|
|||||||
if signal_bias != 0.0:
|
if signal_bias != 0.0:
|
||||||
signal = (signal.astype(float) + signal_bias).round().astype(int).clip(-1, 1)
|
signal = (signal.astype(float) + signal_bias).round().astype(int).clip(-1, 1)
|
||||||
|
|
||||||
|
# Apply max_positions: scale signal by position_size_pct and cap exposure
|
||||||
|
max_positions = int(params.get("max_positions", 1))
|
||||||
|
position_size_pct = float(params.get("position_size_pct", 1.0))
|
||||||
|
# Each "position" is position_size_pct of equity; total exposure capped at max_positions × size
|
||||||
|
effective_size = min(position_size_pct * max_positions, 1.0)
|
||||||
|
signal = (signal.astype(float) * effective_size).clip(-1.0, 1.0)
|
||||||
|
|
||||||
# Build a synthetic close from the factor-mean so we can route
|
# Build a synthetic close from the factor-mean so we can route
|
||||||
# through the same unified engine as every other backtest path.
|
# through the same unified engine as every other backtest path.
|
||||||
# Backtest formulas must match the orchestrator's real-OHLCV path.
|
# Backtest formulas must match the orchestrator's real-OHLCV path.
|
||||||
|
|||||||
@@ -26,22 +26,18 @@ import traceback
|
|||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import requests
|
|
||||||
|
|
||||||
from rdagent.components.prompt_loader import load_prompt
|
|
||||||
from rdagent.components.coder.optuna_optimizer import OptunaOptimizer
|
from rdagent.components.coder.optuna_optimizer import OptunaOptimizer
|
||||||
|
from rdagent.components.prompt_loader import load_prompt
|
||||||
|
|
||||||
# OHLCV data path
|
# OHLCV data path
|
||||||
OHLCV_PATH = Path(os.getenv(
|
OHLCV_PATH = Path(os.getenv(
|
||||||
'PREDIX_OHLCV_PATH',
|
"PREDIX_OHLCV_PATH",
|
||||||
'/home/nico/Predix/git_ignore_folder/factor_implementation_source_data/intraday_pv.h5'
|
"/home/nico/Predix/git_ignore_folder/factor_implementation_source_data/intraday_pv.h5",
|
||||||
))
|
))
|
||||||
from rdagent.log import rdagent_logger as logger
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -60,7 +56,7 @@ class StrategyOrchestrator:
|
|||||||
min_sharpe: float = 0.3,
|
min_sharpe: float = 0.3,
|
||||||
max_drawdown: float = -0.30,
|
max_drawdown: float = -0.30,
|
||||||
min_win_rate: float = 0.40,
|
min_win_rate: float = 0.40,
|
||||||
results_dir: Optional[str] = None,
|
results_dir: str | None = None,
|
||||||
use_optuna: bool = True,
|
use_optuna: bool = True,
|
||||||
optuna_trials: int = 20,
|
optuna_trials: int = 20,
|
||||||
continuous_optimization: bool = True,
|
continuous_optimization: bool = True,
|
||||||
@@ -118,7 +114,7 @@ class StrategyOrchestrator:
|
|||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"StrategyOrchestrator initialized: style={self.trading_style}, "
|
f"StrategyOrchestrator initialized: style={self.trading_style}, "
|
||||||
f"top_factors={self.top_factors}, min_sharpe={self.min_sharpe}"
|
f"top_factors={self.top_factors}, min_sharpe={self.min_sharpe}",
|
||||||
)
|
)
|
||||||
|
|
||||||
def load_ohlcv_close(self) -> pd.Series:
|
def load_ohlcv_close(self) -> pd.Series:
|
||||||
@@ -128,29 +124,29 @@ class StrategyOrchestrator:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ohlcv = pd.read_hdf(str(OHLCV_PATH), key='data')
|
ohlcv = pd.read_hdf(str(OHLCV_PATH), key="data")
|
||||||
if '$close' in ohlcv.columns:
|
if "$close" in ohlcv.columns:
|
||||||
close = ohlcv['$close'].dropna()
|
close = ohlcv["$close"].dropna()
|
||||||
elif 'close' in ohlcv.columns:
|
elif "close" in ohlcv.columns:
|
||||||
close = ohlcv['close'].dropna()
|
close = ohlcv["close"].dropna()
|
||||||
else:
|
else:
|
||||||
close = ohlcv.select_dtypes(include=[np.number]).iloc[:, 0].dropna()
|
close = ohlcv.select_dtypes(include=[np.number]).iloc[:, 0].dropna()
|
||||||
|
|
||||||
# Handle MultiIndex
|
# Handle MultiIndex
|
||||||
if isinstance(close.index, pd.MultiIndex):
|
if isinstance(close.index, pd.MultiIndex):
|
||||||
try:
|
try:
|
||||||
close = close.xs('EURUSD', level='instrument')
|
close = close.xs("EURUSD", level="instrument")
|
||||||
except KeyError:
|
except KeyError:
|
||||||
idx = close.index.get_level_values('instrument') == 'EURUSD'
|
idx = close.index.get_level_values("instrument") == "EURUSD"
|
||||||
close = close[idx]
|
close = close[idx]
|
||||||
close.index = close.index.droplevel('instrument')
|
close.index = close.index.droplevel("instrument")
|
||||||
|
|
||||||
return close
|
return close
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to load OHLCV data: {e}")
|
logger.warning(f"Failed to load OHLCV data: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def load_top_factors(self) -> List[Dict[str, Any]]:
|
def load_top_factors(self) -> list[dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Load top evaluated factors from JSON files.
|
Load top evaluated factors from JSON files.
|
||||||
|
|
||||||
@@ -195,7 +191,7 @@ class StrategyOrchestrator:
|
|||||||
"momentum": [], "trend": [], "volatility": [], "volume": [],
|
"momentum": [], "trend": [], "volatility": [], "volume": [],
|
||||||
"session": [], "london": [], "range": [], "vwap": [],
|
"session": [], "london": [], "range": [], "vwap": [],
|
||||||
"return": [], "ofi": [], "spread": [], "close": [],
|
"return": [], "ofi": [], "spread": [], "close": [],
|
||||||
"divergence": [], "other": []
|
"divergence": [], "other": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
for f in factors_with_files:
|
for f in factors_with_files:
|
||||||
@@ -251,7 +247,7 @@ class StrategyOrchestrator:
|
|||||||
|
|
||||||
return selected[:self.top_factors]
|
return selected[:self.top_factors]
|
||||||
|
|
||||||
def load_factor_values(self, factor_name: str) -> Optional[pd.Series]:
|
def load_factor_values(self, factor_name: str) -> pd.Series | None:
|
||||||
"""
|
"""
|
||||||
Load factor time-series values from parquet file.
|
Load factor time-series values from parquet file.
|
||||||
|
|
||||||
@@ -285,12 +281,12 @@ class StrategyOrchestrator:
|
|||||||
factor_col = df.columns[0]
|
factor_col = df.columns[0]
|
||||||
# Extract EURUSD series
|
# Extract EURUSD series
|
||||||
try:
|
try:
|
||||||
series = df.xs('EURUSD', level='instrument')[factor_col]
|
series = df.xs("EURUSD", level="instrument")[factor_col]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
# Try alternative extraction
|
# Try alternative extraction
|
||||||
df_reset = df.reset_index()
|
df_reset = df.reset_index()
|
||||||
if 'instrument' in df_reset.columns:
|
if "instrument" in df_reset.columns:
|
||||||
df_eur = df_reset[df_reset['instrument'] == 'EURUSD'].set_index('datetime')
|
df_eur = df_reset[df_reset["instrument"] == "EURUSD"].set_index("datetime")
|
||||||
series = df_eur[factor_col] if factor_col in df_eur.columns else df_eur.iloc[:, -1]
|
series = df_eur[factor_col] if factor_col in df_eur.columns else df_eur.iloc[:, -1]
|
||||||
else:
|
else:
|
||||||
series = df.iloc[:, 0]
|
series = df.iloc[:, 0]
|
||||||
@@ -298,7 +294,7 @@ class StrategyOrchestrator:
|
|||||||
series = df.iloc[:, 0]
|
series = df.iloc[:, 0]
|
||||||
|
|
||||||
# Ensure numeric
|
# Ensure numeric
|
||||||
series = pd.to_numeric(series, errors='coerce')
|
series = pd.to_numeric(series, errors="coerce")
|
||||||
series.name = factor_name
|
series.name = factor_name
|
||||||
return series
|
return series
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -307,10 +303,10 @@ class StrategyOrchestrator:
|
|||||||
|
|
||||||
def generate_strategy_code(
|
def generate_strategy_code(
|
||||||
self,
|
self,
|
||||||
factors: List[Dict[str, Any]],
|
factors: list[dict[str, Any]],
|
||||||
strategy_name: str,
|
strategy_name: str,
|
||||||
max_retries: int = 3,
|
max_retries: int = 3,
|
||||||
) -> Optional[str]:
|
) -> str | None:
|
||||||
"""
|
"""
|
||||||
Generate strategy code using LLM from factor combinations.
|
Generate strategy code using LLM from factor combinations.
|
||||||
|
|
||||||
@@ -341,6 +337,12 @@ class StrategyOrchestrator:
|
|||||||
user_prompt = user_prompt.replace("{{ additional_context }}", f"Strategy name: {strategy_name}")
|
user_prompt = user_prompt.replace("{{ additional_context }}", f"Strategy name: {strategy_name}")
|
||||||
user_prompt = user_prompt.replace("{{ trading_style }}", self.trading_style)
|
user_prompt = user_prompt.replace("{{ trading_style }}", self.trading_style)
|
||||||
user_prompt = user_prompt.replace("{{ min_sharpe }}", str(self.min_sharpe))
|
user_prompt = user_prompt.replace("{{ min_sharpe }}", str(self.min_sharpe))
|
||||||
|
|
||||||
|
if "{{" in user_prompt:
|
||||||
|
unreplaced = [w for w in user_prompt.split() if "{{" in w]
|
||||||
|
logger.warning(
|
||||||
|
f"Unreplaced template variables in prompt for '{strategy_name}': {unreplaced}"
|
||||||
|
)
|
||||||
user_prompt = user_prompt.replace("{{ max_drawdown }}", str(self.max_drawdown))
|
user_prompt = user_prompt.replace("{{ max_drawdown }}", str(self.max_drawdown))
|
||||||
system_prompt = self.strategy_prompt.get("system", "")
|
system_prompt = self.strategy_prompt.get("system", "")
|
||||||
else:
|
else:
|
||||||
@@ -371,12 +373,12 @@ class StrategyOrchestrator:
|
|||||||
last_error = f"Attempt {attempt}: LLM returned empty or invalid code"
|
last_error = f"Attempt {attempt}: LLM returned empty or invalid code"
|
||||||
logger.warning(f"LLM attempt {attempt}/{max_retries} failed: {last_error}")
|
logger.warning(f"LLM attempt {attempt}/{max_retries} failed: {last_error}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
last_error = f"Attempt {attempt}: {str(e)}"
|
last_error = f"Attempt {attempt}: {e!s}"
|
||||||
logger.warning(f"LLM attempt {attempt}/{max_retries} failed with exception: {e}")
|
logger.warning(f"LLM attempt {attempt}/{max_retries} failed with exception: {e}")
|
||||||
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"LLM strategy generation failed after {max_retries} attempts. "
|
f"LLM strategy generation failed after {max_retries} attempts. "
|
||||||
f"Last error: {last_error}"
|
f"Last error: {last_error}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fallback: generate template code programmatically
|
# Fallback: generate template code programmatically
|
||||||
@@ -385,10 +387,10 @@ class StrategyOrchestrator:
|
|||||||
|
|
||||||
def _generate_with_llm(
|
def _generate_with_llm(
|
||||||
self,
|
self,
|
||||||
context: Dict[str, Any],
|
context: dict[str, Any],
|
||||||
attempt: int = 1,
|
attempt: int = 1,
|
||||||
feedback: Optional[str] = None,
|
feedback: str | None = None,
|
||||||
) -> Optional[str]:
|
) -> str | None:
|
||||||
"""
|
"""
|
||||||
Generate strategy code using LLM with APIBackend (same as Factor Coder).
|
Generate strategy code using LLM with APIBackend (same as Factor Coder).
|
||||||
|
|
||||||
@@ -406,7 +408,6 @@ class StrategyOrchestrator:
|
|||||||
str or None
|
str or None
|
||||||
Validated Python strategy code, or None if invalid
|
Validated Python strategy code, or None if invalid
|
||||||
"""
|
"""
|
||||||
import json as json_module
|
|
||||||
|
|
||||||
# Build user message with optional feedback
|
# Build user message with optional feedback
|
||||||
user_content = context.get("user_prompt", "")
|
user_content = context.get("user_prompt", "")
|
||||||
@@ -446,14 +447,13 @@ class StrategyOrchestrator:
|
|||||||
if self._validate_python_code(code):
|
if self._validate_python_code(code):
|
||||||
logger.info(f"[DEBUG] Valid Python code extracted ({len(code)} chars)")
|
logger.info(f"[DEBUG] Valid Python code extracted ({len(code)} chars)")
|
||||||
return code
|
return code
|
||||||
else:
|
logger.warning(f"JSON 'code' field contains invalid Python (attempt {attempt}). Preview: {code[:200]}")
|
||||||
logger.warning(f"JSON 'code' field contains invalid Python (attempt {attempt}). Preview: {code[:200]}")
|
|
||||||
else:
|
else:
|
||||||
logger.warning(f"JSON parsed but no valid 'code' field found (attempt {attempt}). Keys: {list(json_data.keys())}")
|
logger.warning(f"JSON parsed but no valid 'code' field found (attempt {attempt}). Keys: {list(json_data.keys())}")
|
||||||
|
|
||||||
# === STEP 2: Fallback - Extract Python code block directly (like Factor Coder) ===
|
# === STEP 2: Fallback - Extract Python code block directly (like Factor Coder) ===
|
||||||
import re
|
import re
|
||||||
code_block_match = re.search(r'```python\s*\n(.*?)\n```', content, re.DOTALL)
|
code_block_match = re.search(r"```python\s*\n(.*?)\n```", content, re.DOTALL)
|
||||||
if code_block_match:
|
if code_block_match:
|
||||||
code = code_block_match.group(1).strip()
|
code = code_block_match.group(1).strip()
|
||||||
if code and self._validate_python_code(code):
|
if code and self._validate_python_code(code):
|
||||||
@@ -463,7 +463,7 @@ class StrategyOrchestrator:
|
|||||||
logger.warning(f"All extraction methods failed (attempt {attempt}). Response preview: {response[:200]}")
|
logger.warning(f"All extraction methods failed (attempt {attempt}). Response preview: {response[:200]}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _extract_json(self, content: str) -> Optional[Dict[str, Any]]:
|
def _extract_json(self, content: str) -> dict[str, Any] | None:
|
||||||
"""
|
"""
|
||||||
Extract JSON object from LLM response content.
|
Extract JSON object from LLM response content.
|
||||||
|
|
||||||
@@ -491,7 +491,7 @@ class StrategyOrchestrator:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# Strategy 2: Find ```json ... ``` blocks
|
# Strategy 2: Find ```json ... ``` blocks
|
||||||
json_block_match = re.search(r'```json\s*\n(.*?)\n```', content, re.DOTALL)
|
json_block_match = re.search(r"```json\s*\n(.*?)\n```", content, re.DOTALL)
|
||||||
if json_block_match:
|
if json_block_match:
|
||||||
try:
|
try:
|
||||||
return json_module.loads(json_block_match.group(1))
|
return json_module.loads(json_block_match.group(1))
|
||||||
@@ -499,7 +499,7 @@ class StrategyOrchestrator:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# Strategy 3: Find ```python ... ``` blocks (Qwen often puts JSON in python blocks)
|
# Strategy 3: Find ```python ... ``` blocks (Qwen often puts JSON in python blocks)
|
||||||
python_block_match = re.search(r'```python\s*\n(.*?)\n```', content, re.DOTALL)
|
python_block_match = re.search(r"```python\s*\n(.*?)\n```", content, re.DOTALL)
|
||||||
if python_block_match:
|
if python_block_match:
|
||||||
block = python_block_match.group(1).strip()
|
block = python_block_match.group(1).strip()
|
||||||
if block.startswith("{") and block.endswith("}"):
|
if block.startswith("{") and block.endswith("}"):
|
||||||
@@ -519,13 +519,13 @@ class StrategyOrchestrator:
|
|||||||
# Try to fix common JSON issues (trailing commas, unescaped newlines)
|
# Try to fix common JSON issues (trailing commas, unescaped newlines)
|
||||||
try:
|
try:
|
||||||
# Remove trailing commas before } or ]
|
# Remove trailing commas before } or ]
|
||||||
json_str_fixed = re.sub(r',\s*([}\]])', r'\1', json_str)
|
json_str_fixed = re.sub(r",\s*([}\]])", r"\1", json_str)
|
||||||
return json_module.loads(json_str_fixed)
|
return json_module.loads(json_str_fixed)
|
||||||
except json_module.JSONDecodeError:
|
except json_module.JSONDecodeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Strategy 5: Find ``` ... ``` blocks (any language tag)
|
# Strategy 5: Find ``` ... ``` blocks (any language tag)
|
||||||
code_block_match = re.search(r'```\w*\s*\n(.*?)\n```', content, re.DOTALL)
|
code_block_match = re.search(r"```\w*\s*\n(.*?)\n```", content, re.DOTALL)
|
||||||
if code_block_match:
|
if code_block_match:
|
||||||
block = code_block_match.group(1).strip()
|
block = code_block_match.group(1).strip()
|
||||||
if block.startswith("{") and block.endswith("}"):
|
if block.startswith("{") and block.endswith("}"):
|
||||||
@@ -536,7 +536,7 @@ class StrategyOrchestrator:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _extract_code_from_json(self, json_data: Dict[str, Any]) -> Optional[str]:
|
def _extract_code_from_json(self, json_data: dict[str, Any]) -> str | None:
|
||||||
"""
|
"""
|
||||||
Extract Python code from parsed JSON data.
|
Extract Python code from parsed JSON data.
|
||||||
|
|
||||||
@@ -559,7 +559,7 @@ class StrategyOrchestrator:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _extract_code_from_raw(self, content: str) -> Optional[str]:
|
def _extract_code_from_raw(self, content: str) -> str | None:
|
||||||
"""
|
"""
|
||||||
Extract Python code from raw (non-JSON) LLM response.
|
Extract Python code from raw (non-JSON) LLM response.
|
||||||
|
|
||||||
@@ -579,12 +579,12 @@ class StrategyOrchestrator:
|
|||||||
code = content.strip()
|
code = content.strip()
|
||||||
|
|
||||||
# Try to find ```python blocks
|
# Try to find ```python blocks
|
||||||
python_match = re.search(r'```python\s*\n(.*?)\n```', code, re.DOTALL)
|
python_match = re.search(r"```python\s*\n(.*?)\n```", code, re.DOTALL)
|
||||||
if python_match:
|
if python_match:
|
||||||
code = python_match.group(1)
|
code = python_match.group(1)
|
||||||
else:
|
else:
|
||||||
# Try generic ``` blocks
|
# Try generic ``` blocks
|
||||||
block_match = re.search(r'```\s*\n(.*?)\n```', code, re.DOTALL)
|
block_match = re.search(r"```\s*\n(.*?)\n```", code, re.DOTALL)
|
||||||
if block_match:
|
if block_match:
|
||||||
code = block_match.group(1)
|
code = block_match.group(1)
|
||||||
|
|
||||||
@@ -636,7 +636,7 @@ class StrategyOrchestrator:
|
|||||||
# Remove non-ASCII characters (emojis, etc.)
|
# Remove non-ASCII characters (emojis, etc.)
|
||||||
code = code.encode("ascii", "ignore").decode("ascii").strip()
|
code = code.encode("ascii", "ignore").decode("ascii").strip()
|
||||||
|
|
||||||
return code if code else None
|
return code or None
|
||||||
|
|
||||||
def _validate_python_code(self, code: str) -> bool:
|
def _validate_python_code(self, code: str) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -663,14 +663,14 @@ class StrategyOrchestrator:
|
|||||||
logger.debug(f"Python syntax error: {e}")
|
logger.debug(f"Python syntax error: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _generate_fallback_code(self, context: Dict[str, Any]) -> str:
|
def _generate_fallback_code(self, context: dict[str, Any]) -> str:
|
||||||
"""Generate fallback strategy code programmatically."""
|
"""Generate fallback strategy code programmatically."""
|
||||||
factor_names = context["factor_names"]
|
factor_names = context["factor_names"]
|
||||||
style_config = "daytrading" if context["trading_style"] == "daytrading" else "swing"
|
style_config = "daytrading" if context["trading_style"] == "daytrading" else "swing"
|
||||||
|
|
||||||
# Build factor assignment code
|
# Build factor assignment code
|
||||||
factor_assignments = "\n ".join(
|
factor_assignments = "\n ".join(
|
||||||
[f'"{name}": factors["{name}"]' for name in factor_names if name != "timestamp"]
|
[f'"{name}": factors["{name}"]' for name in factor_names if name != "timestamp"],
|
||||||
)
|
)
|
||||||
|
|
||||||
code = f'''"""
|
code = f'''"""
|
||||||
@@ -717,8 +717,8 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
|
|||||||
return code
|
return code
|
||||||
|
|
||||||
def evaluate_strategy(
|
def evaluate_strategy(
|
||||||
self, strategy_code: str, strategy_name: str, factors: List[Dict[str, Any]]
|
self, strategy_code: str, strategy_name: str, factors: list[dict[str, Any]],
|
||||||
) -> Dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Evaluate a strategy by executing its code and calculating metrics.
|
Evaluate a strategy by executing its code and calculating metrics.
|
||||||
|
|
||||||
@@ -755,23 +755,20 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Align factor values with common index
|
# Align factor values with common index
|
||||||
if not factor_values:
|
# Find common index across all series
|
||||||
df_factors = pd.DataFrame()
|
common_idx = None
|
||||||
else:
|
for name, s in factor_values.items():
|
||||||
# Find common index across all series
|
if common_idx is None:
|
||||||
common_idx = None
|
common_idx = s.index
|
||||||
for name, s in factor_values.items():
|
|
||||||
if common_idx is None:
|
|
||||||
common_idx = s.index
|
|
||||||
else:
|
|
||||||
common_idx = common_idx.intersection(s.index)
|
|
||||||
|
|
||||||
if common_idx is not None and len(common_idx) > 100:
|
|
||||||
df_factors = pd.DataFrame({
|
|
||||||
name: s.reindex(common_idx) for name, s in factor_values.items()
|
|
||||||
}).dropna()
|
|
||||||
else:
|
else:
|
||||||
df_factors = pd.DataFrame()
|
common_idx = common_idx.intersection(s.index)
|
||||||
|
|
||||||
|
if common_idx is not None and len(common_idx) > 100:
|
||||||
|
df_factors = pd.DataFrame({
|
||||||
|
name: s.reindex(common_idx) for name, s in factor_values.items()
|
||||||
|
}).dropna()
|
||||||
|
else:
|
||||||
|
df_factors = pd.DataFrame()
|
||||||
|
|
||||||
if len(df_factors) < 100:
|
if len(df_factors) < 100:
|
||||||
return {
|
return {
|
||||||
@@ -783,7 +780,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
|
|||||||
|
|
||||||
# Convert all factor columns to numeric
|
# Convert all factor columns to numeric
|
||||||
for col in df_factors.columns:
|
for col in df_factors.columns:
|
||||||
df_factors[col] = pd.to_numeric(df_factors[col], errors='coerce')
|
df_factors[col] = pd.to_numeric(df_factors[col], errors="coerce")
|
||||||
|
|
||||||
# Forward-fill daily factors to match OHLCV 1-min index
|
# Forward-fill daily factors to match OHLCV 1-min index
|
||||||
# Many factors are daily (1 value per day), need to ffill to 1-min
|
# Many factors are daily (1 value per day), need to ffill to 1-min
|
||||||
@@ -799,7 +796,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
|
|||||||
f"[DEBUG] {strategy_name}: data quality: "
|
f"[DEBUG] {strategy_name}: data quality: "
|
||||||
f"original_rows={original_len}, "
|
f"original_rows={original_len}, "
|
||||||
f"ffill_rows={len(df_factors) - original_len}, "
|
f"ffill_rows={len(df_factors) - original_len}, "
|
||||||
f"ffill_ratio={ffill_ratio:.2%}"
|
f"ffill_ratio={ffill_ratio:.2%}",
|
||||||
)
|
)
|
||||||
|
|
||||||
df_factors = df_factors.dropna()
|
df_factors = df_factors.dropna()
|
||||||
@@ -825,40 +822,44 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
|
|||||||
try:
|
try:
|
||||||
exec(strategy_code, {"np": np, "pd": pd, "numpy": np}, local_vars)
|
exec(strategy_code, {"np": np, "pd": pd, "numpy": np}, local_vars)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
logger.error(
|
||||||
|
f"Strategy code execution failed for '{strategy_name}': {e}\n"
|
||||||
|
f"{traceback.format_exc()[-2000:]}"
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"strategy_name": strategy_name,
|
"strategy_name": strategy_name,
|
||||||
"status": "rejected",
|
"status": "rejected",
|
||||||
"reason": f"Code execution error: {str(e)}",
|
"reason": f"Code execution error: {e!s}",
|
||||||
"factors_used": factor_names,
|
"factors_used": factor_names,
|
||||||
}
|
}
|
||||||
|
|
||||||
if "signal" not in local_vars:
|
signal = local_vars.get("signal")
|
||||||
|
if signal is None or (isinstance(signal, pd.Series) and signal.empty):
|
||||||
return {
|
return {
|
||||||
"strategy_name": strategy_name,
|
"strategy_name": strategy_name,
|
||||||
"status": "rejected",
|
"status": "rejected",
|
||||||
"reason": "Strategy did not produce 'signal' variable",
|
"reason": "Strategy did not produce valid 'signal' variable",
|
||||||
"factors_used": factor_names,
|
"factors_used": factor_names,
|
||||||
}
|
}
|
||||||
|
|
||||||
signal = local_vars["signal"]
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[DEBUG] {strategy_name}: signal stats: "
|
f"[DEBUG] {strategy_name}: signal stats: "
|
||||||
f"len={len(signal)}, "
|
f"len={len(signal)}, "
|
||||||
f"long={int((signal > 0).sum())}, "
|
f"long={int((signal > 0).sum())}, "
|
||||||
f"short={int((signal < 0).sum())}, "
|
f"short={int((signal < 0).sum())}, "
|
||||||
f"flat={int((signal == 0).sum())}, "
|
f"flat={int((signal == 0).sum())}, "
|
||||||
f"unique={signal.nunique()}"
|
f"unique={signal.nunique()}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Delegate all metric computation to the single source of truth.
|
# Delegate all metric computation to the single source of truth.
|
||||||
# Same formulas as every other backtest path in the repo.
|
# Same formulas as every other backtest path in the repo.
|
||||||
from rdagent.components.backtesting.vbt_backtest import (
|
from rdagent.components.backtesting.vbt_backtest import (
|
||||||
backtest_signal_ftmo,
|
|
||||||
DEFAULT_TXN_COST_BPS,
|
DEFAULT_TXN_COST_BPS,
|
||||||
|
backtest_signal_ftmo,
|
||||||
)
|
)
|
||||||
|
|
||||||
close = self.load_ohlcv_close()
|
# Reuse the already-loaded close from above; create a synthetic proxy if unavailable
|
||||||
if close is None:
|
if close is None:
|
||||||
logger.warning("OHLCV data unavailable, using factor-mean proxy")
|
logger.warning("OHLCV data unavailable, using factor-mean proxy")
|
||||||
proxy = df_factors.mean(axis=1).astype(float)
|
proxy = df_factors.mean(axis=1).astype(float)
|
||||||
@@ -890,7 +891,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
|
|||||||
logger.info(
|
logger.info(
|
||||||
f"[DEBUG] {strategy_name}: bt stats: "
|
f"[DEBUG] {strategy_name}: bt stats: "
|
||||||
f"sharpe={sharpe:.4f} dd={max_dd:.4f} wr={win_rate:.4f} "
|
f"sharpe={sharpe:.4f} dd={max_dd:.4f} wr={win_rate:.4f} "
|
||||||
f"trades={num_real_trades} total_ret={bt['total_return']:.4%}"
|
f"trades={num_real_trades} total_ret={bt['total_return']:.4%}",
|
||||||
)
|
)
|
||||||
|
|
||||||
metrics = {
|
metrics = {
|
||||||
@@ -920,7 +921,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
|
|||||||
f"[DEBUG] {strategy_name}: rejection breakdown: "
|
f"[DEBUG] {strategy_name}: rejection breakdown: "
|
||||||
f"sharpe={sharpe:.4f} (need>={self.min_sharpe}), "
|
f"sharpe={sharpe:.4f} (need>={self.min_sharpe}), "
|
||||||
f"dd={max_dd:.4f} (need>={self.max_drawdown}), "
|
f"dd={max_dd:.4f} (need>={self.max_drawdown}), "
|
||||||
f"wr={win_rate:.4f} (need>={self.min_win_rate})"
|
f"wr={win_rate:.4f} (need>={self.min_win_rate})",
|
||||||
)
|
)
|
||||||
metrics["reason"] = self._get_rejection_reason(sharpe, max_dd, win_rate)
|
metrics["reason"] = self._get_rejection_reason(sharpe, max_dd, win_rate)
|
||||||
|
|
||||||
@@ -932,7 +933,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
|
|||||||
return {
|
return {
|
||||||
"strategy_name": strategy_name,
|
"strategy_name": strategy_name,
|
||||||
"status": "rejected",
|
"status": "rejected",
|
||||||
"reason": f"Evaluation error: {str(e)}",
|
"reason": f"Evaluation error: {e!s}",
|
||||||
"factors_used": [],
|
"factors_used": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -951,7 +952,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
|
|||||||
reasons.append(f"Win Rate {win_rate:.2%} < {self.min_win_rate:.2%}")
|
reasons.append(f"Win Rate {win_rate:.2%} < {self.min_win_rate:.2%}")
|
||||||
return "; ".join(reasons) if reasons else "Unknown"
|
return "; ".join(reasons) if reasons else "Unknown"
|
||||||
|
|
||||||
def _generate_strategy_name(self, factors: List[Dict[str, Any]], idx: int) -> str:
|
def _generate_strategy_name(self, factors: list[dict[str, Any]], idx: int) -> str:
|
||||||
"""Generate a strategy name from its factors."""
|
"""Generate a strategy name from its factors."""
|
||||||
# Extract key words from factor names
|
# Extract key words from factor names
|
||||||
words = []
|
words = []
|
||||||
@@ -962,7 +963,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
|
|||||||
for p in parts:
|
for p in parts:
|
||||||
# Extract capitalized words
|
# Extract capitalized words
|
||||||
cap_words = [w for w in p.split() if w[0:1].isupper()]
|
cap_words = [w for w in p.split() if w[0:1].isupper()]
|
||||||
words.extend(cap_words if cap_words else [p])
|
words.extend(cap_words or [p])
|
||||||
|
|
||||||
# Take up to 3 unique words
|
# Take up to 3 unique words
|
||||||
unique_words = list(dict.fromkeys(words))[:3]
|
unique_words = list(dict.fromkeys(words))[:3]
|
||||||
@@ -975,7 +976,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
|
|||||||
count: int = 10,
|
count: int = 10,
|
||||||
workers: int = 2, # Reduced from 4 to 2 to avoid LLM server overload
|
workers: int = 2, # Reduced from 4 to 2 to avoid LLM server overload
|
||||||
progress_callback=None,
|
progress_callback=None,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Generate and evaluate trading strategies.
|
Generate and evaluate trading strategies.
|
||||||
|
|
||||||
@@ -1028,7 +1029,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
|
|||||||
logger.info(
|
logger.info(
|
||||||
f"Strategy ACCEPTED: {result['strategy_name']} | "
|
f"Strategy ACCEPTED: {result['strategy_name']} | "
|
||||||
f"Sharpe={result['sharpe_ratio']:.2f} | "
|
f"Sharpe={result['sharpe_ratio']:.2f} | "
|
||||||
f"DD={result['max_drawdown']:.2%}"
|
f"DD={result['max_drawdown']:.2%}",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Also save rejected strategies for debugging
|
# Also save rejected strategies for debugging
|
||||||
@@ -1036,7 +1037,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
f"Strategy REJECTED: {result['strategy_name']} - {result.get('reason', 'unknown')} | "
|
f"Strategy REJECTED: {result['strategy_name']} - {result.get('reason', 'unknown')} | "
|
||||||
f"Sharpe={result.get('sharpe_ratio', 'N/A')} | "
|
f"Sharpe={result.get('sharpe_ratio', 'N/A')} | "
|
||||||
f"DD={result.get('max_drawdown', 'N/A')}"
|
f"DD={result.get('max_drawdown', 'N/A')}",
|
||||||
)
|
)
|
||||||
|
|
||||||
if progress_callback:
|
if progress_callback:
|
||||||
@@ -1052,12 +1053,12 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
|
|||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Strategy generation complete: {strategies_accepted}/{strategies_generated} accepted "
|
f"Strategy generation complete: {strategies_accepted}/{strategies_generated} accepted "
|
||||||
f"({strategies_accepted/max(strategies_generated,1)*100:.1f}%)"
|
f"({strategies_accepted/max(strategies_generated,1)*100:.1f}%)",
|
||||||
)
|
)
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def _generate_strategy_configs(self, factors: List[Dict], count: int) -> List[List[Dict]]:
|
def _generate_strategy_configs(self, factors: list[dict], count: int) -> list[list[dict]]:
|
||||||
"""
|
"""
|
||||||
Generate strategy configurations from factor combinations.
|
Generate strategy configurations from factor combinations.
|
||||||
|
|
||||||
@@ -1085,7 +1086,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
|
|||||||
np.random.shuffle(configs)
|
np.random.shuffle(configs)
|
||||||
return configs[: count * 2] # Generate extras
|
return configs[: count * 2] # Generate extras
|
||||||
|
|
||||||
def _generate_and_evaluate_single(self, idx: int, factors: List[Dict]) -> Dict[str, Any]:
|
def _generate_and_evaluate_single(self, idx: int, factors: list[dict]) -> dict[str, Any]:
|
||||||
"""Generate and evaluate a single strategy."""
|
"""Generate and evaluate a single strategy."""
|
||||||
strategy_name = self._generate_strategy_name(factors, idx + 1)
|
strategy_name = self._generate_strategy_name(factors, idx + 1)
|
||||||
|
|
||||||
@@ -1108,7 +1109,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
|
|||||||
# by finding optimal entry/exit thresholds, signal smoothing, etc.
|
# by finding optimal entry/exit thresholds, signal smoothing, etc.
|
||||||
if self.use_optuna:
|
if self.use_optuna:
|
||||||
initial_status = result.get("status", "rejected")
|
initial_status = result.get("status", "rejected")
|
||||||
initial_sharpe = result.get("sharpe_ratio", float('-inf'))
|
initial_sharpe = result.get("sharpe_ratio", float("-inf"))
|
||||||
logger.info(f"Running Optuna optimization for {strategy_name} (initial: {initial_status}, Sharpe={initial_sharpe:.4f})...")
|
logger.info(f"Running Optuna optimization for {strategy_name} (initial: {initial_status}, Sharpe={initial_sharpe:.4f})...")
|
||||||
optimizer = OptunaOptimizer(n_trials=self.optuna_trials)
|
optimizer = OptunaOptimizer(n_trials=self.optuna_trials)
|
||||||
|
|
||||||
@@ -1117,7 +1118,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
|
|||||||
|
|
||||||
if factor_values is not None:
|
if factor_values is not None:
|
||||||
optimized = optimizer.optimize_strategy(result, factor_values)
|
optimized = optimizer.optimize_strategy(result, factor_values)
|
||||||
optimized_sharpe = optimized.get("sharpe_ratio", float('-inf'))
|
optimized_sharpe = optimized.get("sharpe_ratio", float("-inf"))
|
||||||
optimized_status = optimized.get("status", "rejected")
|
optimized_status = optimized.get("status", "rejected")
|
||||||
best_params = optimized.get("best_params", {})
|
best_params = optimized.get("best_params", {})
|
||||||
|
|
||||||
@@ -1126,14 +1127,14 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
|
|||||||
improvement = optimized_sharpe - initial_sharpe
|
improvement = optimized_sharpe - initial_sharpe
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Optuna {'RESCUED' if optimized_status == 'accepted' and initial_status == 'rejected' else 'improved'} "
|
f"Optuna {'RESCUED' if optimized_status == 'accepted' and initial_status == 'rejected' else 'improved'} "
|
||||||
f"{strategy_name}: Sharpe {initial_sharpe:.4f} → {optimized_sharpe:.4f} (+{improvement:.4f})"
|
f"{strategy_name}: Sharpe {initial_sharpe:.4f} → {optimized_sharpe:.4f} (+{improvement:.4f})",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Re-evaluate with best parameters to get comparable metrics
|
# Re-evaluate with best parameters to get comparable metrics
|
||||||
if best_params:
|
if best_params:
|
||||||
patched_code = self._patch_strategy_code(code, best_params)
|
patched_code = self._patch_strategy_code(code, best_params)
|
||||||
re_eval = self._evaluate_with_patched_code(patched_code, strategy_name, factors)
|
re_eval = self._evaluate_with_patched_code(patched_code, strategy_name, factors)
|
||||||
if re_eval.get("sharpe_ratio", float('-inf')) > initial_sharpe:
|
if re_eval.get("sharpe_ratio", float("-inf")) > initial_sharpe:
|
||||||
result.update(re_eval)
|
result.update(re_eval)
|
||||||
result["code"] = patched_code
|
result["code"] = patched_code
|
||||||
result["best_params"] = best_params
|
result["best_params"] = best_params
|
||||||
@@ -1142,7 +1143,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
|
|||||||
result.pop("reason", None)
|
result.pop("reason", None)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Re-evaluated {strategy_name} with best params: "
|
f"Re-evaluated {strategy_name} with best params: "
|
||||||
f"Sharpe {initial_sharpe:.4f} → {re_eval.get('sharpe_ratio', 0):.4f}"
|
f"Sharpe {initial_sharpe:.4f} → {re_eval.get('sharpe_ratio', 0):.4f}",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
result.update(optimized)
|
result.update(optimized)
|
||||||
@@ -1162,7 +1163,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _prepare_factor_values(self, factors: List[Dict]) -> Optional[pd.DataFrame]:
|
def _prepare_factor_values(self, factors: list[dict]) -> pd.DataFrame | None:
|
||||||
"""Prepare factor values DataFrame for Optuna optimization."""
|
"""Prepare factor values DataFrame for Optuna optimization."""
|
||||||
factor_values = {}
|
factor_values = {}
|
||||||
for f in factors:
|
for f in factors:
|
||||||
@@ -1181,7 +1182,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
|
|||||||
return df.dropna()
|
return df.dropna()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _patch_strategy_code(self, code: str, params: Dict[str, Any]) -> str:
|
def _patch_strategy_code(self, code: str, params: dict[str, Any]) -> str:
|
||||||
"""Patch strategy code with Optuna's best parameters."""
|
"""Patch strategy code with Optuna's best parameters."""
|
||||||
import re
|
import re
|
||||||
patched = code
|
patched = code
|
||||||
@@ -1192,26 +1193,26 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
|
|||||||
signal_window = params.get("signal_window", 3)
|
signal_window = params.get("signal_window", 3)
|
||||||
|
|
||||||
param_patterns = [
|
param_patterns = [
|
||||||
(r'entry_thresh\s*=\s*[\d.]+', f'entry_thresh = {entry_thresh}'),
|
(r"entry_thresh\s*=\s*[\d.]+", f"entry_thresh = {entry_thresh}"),
|
||||||
(r'exit_thresh\s*=\s*[\d.]+', f'exit_thresh = {exit_thresh}'),
|
(r"exit_thresh\s*=\s*[\d.]+", f"exit_thresh = {exit_thresh}"),
|
||||||
(r'window\s*=\s*\d+', f'window = {zscore_window}'),
|
(r"window\s*=\s*\d+", f"window = {zscore_window}"),
|
||||||
(r'signal_window\s*=\s*\d+', f'signal_window = {signal_window}'),
|
(r"signal_window\s*=\s*\d+", f"signal_window = {signal_window}"),
|
||||||
]
|
]
|
||||||
for pattern, replacement in param_patterns:
|
for pattern, replacement in param_patterns:
|
||||||
patched = re.sub(pattern, replacement, patched)
|
patched = re.sub(pattern, replacement, patched)
|
||||||
|
|
||||||
# Patch .rolling(N) calls for common window sizes
|
# Patch .rolling(N) calls for common window sizes
|
||||||
rolling_pattern = r'\.rolling\((\d+)\)'
|
rolling_pattern = r"\.rolling\((\d+)\)"
|
||||||
def replace_rolling(match):
|
def replace_rolling(match):
|
||||||
val = int(match.group(1))
|
val = int(match.group(1))
|
||||||
if val in (20, 30, 50, 100, 200):
|
if val in (20, 30, 50, 100, 200):
|
||||||
return f'.rolling({zscore_window})'
|
return f".rolling({zscore_window})"
|
||||||
return match.group(0)
|
return match.group(0)
|
||||||
patched = re.sub(rolling_pattern, replace_rolling, patched)
|
patched = re.sub(rolling_pattern, replace_rolling, patched)
|
||||||
|
|
||||||
return patched
|
return patched
|
||||||
|
|
||||||
def _evaluate_with_patched_code(self, patched_code: str, strategy_name: str, factors: List[Dict]) -> Dict[str, Any]:
|
def _evaluate_with_patched_code(self, patched_code: str, strategy_name: str, factors: list[dict]) -> dict[str, Any]:
|
||||||
"""Re-evaluate strategy with patched parameters using full OHLCV backtest."""
|
"""Re-evaluate strategy with patched parameters using full OHLCV backtest."""
|
||||||
try:
|
try:
|
||||||
factor_names = [f["factor_name"] for f in factors if f["factor_name"] != "timestamp"]
|
factor_names = [f["factor_name"] for f in factors if f["factor_name"] != "timestamp"]
|
||||||
@@ -1222,7 +1223,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
|
|||||||
factor_values[fname] = series
|
factor_values[fname] = series
|
||||||
|
|
||||||
if not factor_values:
|
if not factor_values:
|
||||||
return {"sharpe_ratio": float('-inf'), "status": "rejected"}
|
return {"sharpe_ratio": float("-inf"), "status": "rejected"}
|
||||||
|
|
||||||
common_idx = None
|
common_idx = None
|
||||||
for name, s in factor_values.items():
|
for name, s in factor_values.items():
|
||||||
@@ -1232,14 +1233,14 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
|
|||||||
common_idx = common_idx.intersection(s.index)
|
common_idx = common_idx.intersection(s.index)
|
||||||
|
|
||||||
if common_idx is None or len(common_idx) < 100:
|
if common_idx is None or len(common_idx) < 100:
|
||||||
return {"sharpe_ratio": float('-inf'), "status": "rejected"}
|
return {"sharpe_ratio": float("-inf"), "status": "rejected"}
|
||||||
|
|
||||||
df_factors = pd.DataFrame({
|
df_factors = pd.DataFrame({
|
||||||
name: s.reindex(common_idx) for name, s in factor_values.items()
|
name: s.reindex(common_idx) for name, s in factor_values.items()
|
||||||
}).dropna()
|
}).dropna()
|
||||||
|
|
||||||
for col in df_factors.columns:
|
for col in df_factors.columns:
|
||||||
df_factors[col] = pd.to_numeric(df_factors[col], errors='coerce')
|
df_factors[col] = pd.to_numeric(df_factors[col], errors="coerce")
|
||||||
|
|
||||||
close = self.load_ohlcv_close()
|
close = self.load_ohlcv_close()
|
||||||
if close is not None:
|
if close is not None:
|
||||||
@@ -1247,7 +1248,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
|
|||||||
|
|
||||||
df_factors = df_factors.dropna()
|
df_factors = df_factors.dropna()
|
||||||
if len(df_factors) < 1000:
|
if len(df_factors) < 1000:
|
||||||
return {"sharpe_ratio": float('-inf'), "status": "rejected"}
|
return {"sharpe_ratio": float("-inf"), "status": "rejected"}
|
||||||
|
|
||||||
if close is not None:
|
if close is not None:
|
||||||
close = close.reindex(df_factors.index)
|
close = close.reindex(df_factors.index)
|
||||||
@@ -1256,21 +1257,21 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
|
|||||||
try:
|
try:
|
||||||
exec(patched_code, {"np": np, "pd": pd, "numpy": np}, local_vars)
|
exec(patched_code, {"np": np, "pd": pd, "numpy": np}, local_vars)
|
||||||
except Exception:
|
except Exception:
|
||||||
return {"sharpe_ratio": float('-inf'), "status": "rejected"}
|
return {"sharpe_ratio": float("-inf"), "status": "rejected"}
|
||||||
|
|
||||||
if "signal" not in local_vars:
|
if "signal" not in local_vars:
|
||||||
return {"sharpe_ratio": float('-inf'), "status": "rejected"}
|
return {"sharpe_ratio": float("-inf"), "status": "rejected"}
|
||||||
|
|
||||||
signal = local_vars["signal"]
|
signal = local_vars["signal"]
|
||||||
|
|
||||||
from rdagent.components.backtesting.vbt_backtest import (
|
from rdagent.components.backtesting.vbt_backtest import (
|
||||||
backtest_signal_ftmo,
|
|
||||||
DEFAULT_TXN_COST_BPS,
|
DEFAULT_TXN_COST_BPS,
|
||||||
|
backtest_signal_ftmo,
|
||||||
)
|
)
|
||||||
|
|
||||||
close_for_bt = close.reindex(signal.index).ffill() if close is not None else None
|
close_for_bt = close.reindex(signal.index).ffill() if close is not None else None
|
||||||
if close_for_bt is None:
|
if close_for_bt is None:
|
||||||
return {"sharpe_ratio": float('-inf'), "status": "rejected"}
|
return {"sharpe_ratio": float("-inf"), "status": "rejected"}
|
||||||
|
|
||||||
bt = backtest_signal_ftmo(
|
bt = backtest_signal_ftmo(
|
||||||
close=close_for_bt,
|
close=close_for_bt,
|
||||||
@@ -1278,7 +1279,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
|
|||||||
txn_cost_bps=float(os.getenv("TXN_COST_BPS", DEFAULT_TXN_COST_BPS)),
|
txn_cost_bps=float(os.getenv("TXN_COST_BPS", DEFAULT_TXN_COST_BPS)),
|
||||||
)
|
)
|
||||||
if bt.get("status") != "success":
|
if bt.get("status") != "success":
|
||||||
return {"sharpe_ratio": float('-inf'), "status": "rejected"}
|
return {"sharpe_ratio": float("-inf"), "status": "rejected"}
|
||||||
|
|
||||||
sharpe = bt["sharpe"]
|
sharpe = bt["sharpe"]
|
||||||
max_dd = bt["max_drawdown"]
|
max_dd = bt["max_drawdown"]
|
||||||
@@ -1307,9 +1308,9 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Re-evaluation failed for {strategy_name}: {e}")
|
logger.debug(f"Re-evaluation failed for {strategy_name}: {e}")
|
||||||
return {"sharpe_ratio": float('-inf'), "status": "rejected"}
|
return {"sharpe_ratio": float("-inf"), "status": "rejected"}
|
||||||
|
|
||||||
def _save_strategy(self, result: Dict[str, Any]) -> None:
|
def _save_strategy(self, result: dict[str, Any]) -> None:
|
||||||
"""Save accepted strategy to JSON file."""
|
"""Save accepted strategy to JSON file."""
|
||||||
timestamp = int(time.time())
|
timestamp = int(time.time())
|
||||||
safe_name = result["strategy_name"].replace("/", "_").replace(" ", "_")[:60]
|
safe_name = result["strategy_name"].replace("/", "_").replace(" ", "_")[:60]
|
||||||
@@ -1325,7 +1326,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
|
|||||||
|
|
||||||
logger.info(f"Saved strategy to {filepath}")
|
logger.info(f"Saved strategy to {filepath}")
|
||||||
|
|
||||||
def get_strategy_summary(self, results: List[Dict[str, Any]]) -> Dict[str, Any]:
|
def get_strategy_summary(self, results: list[dict[str, Any]]) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Generate summary statistics from strategy generation results.
|
Generate summary statistics from strategy generation results.
|
||||||
|
|
||||||
|
|||||||
@@ -85,13 +85,16 @@ def load_and_process_one_pdf_by_azure_document_intelligence(
|
|||||||
|
|
||||||
|
|
||||||
def load_and_process_pdfs_by_azure_document_intelligence(path: Path) -> dict[str, str]:
|
def load_and_process_pdfs_by_azure_document_intelligence(path: Path) -> dict[str, str]:
|
||||||
assert RD_AGENT_SETTINGS.azure_document_intelligence_key is not None
|
if RD_AGENT_SETTINGS.azure_document_intelligence_key is None:
|
||||||
assert RD_AGENT_SETTINGS.azure_document_intelligence_endpoint is not None
|
raise AssertionError("azure_document_intelligence_key must be set")
|
||||||
|
if RD_AGENT_SETTINGS.azure_document_intelligence_endpoint is None:
|
||||||
|
raise AssertionError("azure_document_intelligence_endpoint must be set")
|
||||||
|
|
||||||
content_dict = {}
|
content_dict = {}
|
||||||
ab_path = path.resolve()
|
ab_path = path.resolve()
|
||||||
if ab_path.is_file():
|
if ab_path.is_file():
|
||||||
assert ".pdf" in ab_path.suffixes, "The file must be a PDF file."
|
if ".pdf" not in ab_path.suffixes:
|
||||||
|
raise ValueError("The file must be a PDF file.")
|
||||||
proc = load_and_process_one_pdf_by_azure_document_intelligence
|
proc = load_and_process_one_pdf_by_azure_document_intelligence
|
||||||
content_dict[str(ab_path)] = proc(
|
content_dict[str(ab_path)] = proc(
|
||||||
ab_path,
|
ab_path,
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ class UndirectedNode(Node):
|
|||||||
super().__init__(content, label, embedding)
|
super().__init__(content, label, embedding)
|
||||||
self.neighbors: set[UndirectedNode] = set()
|
self.neighbors: set[UndirectedNode] = set()
|
||||||
self.appendix = appendix # appendix stores any additional information
|
self.appendix = appendix # appendix stores any additional information
|
||||||
assert isinstance(content, str), "content must be a string"
|
if not isinstance(content, str):
|
||||||
|
raise TypeError("content must be a string")
|
||||||
|
|
||||||
def add_neighbor(self, node: UndirectedNode) -> None:
|
def add_neighbor(self, node: UndirectedNode) -> None:
|
||||||
self.neighbors.add(node)
|
self.neighbors.add(node)
|
||||||
@@ -96,7 +97,8 @@ class Graph(KnowledgeBase):
|
|||||||
APIBackend().create_embedding(input_content=contents[i : i + size]),
|
APIBackend().create_embedding(input_content=contents[i : i + size]),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert len(nodes) == len(embeddings), "nodes' length must equals embeddings' length"
|
if len(nodes) != len(embeddings):
|
||||||
|
raise ValueError("nodes' length must equal embeddings' length")
|
||||||
for node, embedding in zip(nodes, embeddings):
|
for node, embedding in zip(nodes, embeddings):
|
||||||
node.embedding = embedding
|
node.embedding = embedding
|
||||||
return nodes
|
return nodes
|
||||||
@@ -252,7 +254,8 @@ class UndirectedGraph(Graph):
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
min_nodes_count = 2
|
min_nodes_count = 2
|
||||||
assert len(nodes) >= min_nodes_count, "nodes length must >=2"
|
if len(nodes) < min_nodes_count:
|
||||||
|
raise ValueError("nodes length must >=2")
|
||||||
intersection = None
|
intersection = None
|
||||||
|
|
||||||
for node in nodes:
|
for node in nodes:
|
||||||
|
|||||||
@@ -87,7 +87,8 @@ class ModelWsLoader(WsLoader[ModelTask, ModelFBWorkspace]):
|
|||||||
self.path = Path(path)
|
self.path = Path(path)
|
||||||
|
|
||||||
def load(self, task: ModelTask) -> ModelFBWorkspace:
|
def load(self, task: ModelTask) -> ModelFBWorkspace:
|
||||||
assert task.name is not None
|
if task.name is None:
|
||||||
|
raise AssertionError("task.name should not be None")
|
||||||
mti = ModelFBWorkspace(task)
|
mti = ModelFBWorkspace(task)
|
||||||
mti.prepare()
|
mti.prepare()
|
||||||
with open(self.path / f"{task.name}.py", "r") as f:
|
with open(self.path / f"{task.name}.py", "r") as f:
|
||||||
|
|||||||
+28
-3
@@ -4,6 +4,7 @@ import functools
|
|||||||
import importlib
|
import importlib
|
||||||
import json
|
import json
|
||||||
import multiprocessing as mp
|
import multiprocessing as mp
|
||||||
|
import os
|
||||||
import pickle
|
import pickle
|
||||||
import random
|
import random
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
@@ -82,10 +83,24 @@ def import_class(class_path: str) -> Any:
|
|||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
class of `class_path`
|
class of `class_path`
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
ImportError
|
||||||
|
If module or class cannot be found.
|
||||||
"""
|
"""
|
||||||
module_path, class_name = class_path.rsplit(".", 1)
|
try:
|
||||||
module = importlib.import_module(module_path)
|
module_path, class_name = class_path.rsplit(".", 1)
|
||||||
return getattr(module, class_name)
|
except ValueError:
|
||||||
|
raise ImportError(f"Invalid class path: {class_path!r}")
|
||||||
|
try:
|
||||||
|
module = importlib.import_module(module_path)
|
||||||
|
except ModuleNotFoundError as e:
|
||||||
|
raise ImportError(f"Module not found: {module_path!r}") from e
|
||||||
|
try:
|
||||||
|
return getattr(module, class_name)
|
||||||
|
except AttributeError as e:
|
||||||
|
raise ImportError(f"Class not found: {class_name!r} in {module_path!r}") from e
|
||||||
|
|
||||||
|
|
||||||
class CacheSeedGen:
|
class CacheSeedGen:
|
||||||
@@ -208,3 +223,13 @@ def cache_with_pickle(hash_func: Callable, post_process_func: Callable | None =
|
|||||||
return cache_wrapper
|
return cache_wrapper
|
||||||
|
|
||||||
return cache_decorator
|
return cache_decorator
|
||||||
|
|
||||||
|
|
||||||
|
def safe_resolve_path(user_path: Path, safe_root: Path | None = None) -> Path:
|
||||||
|
if safe_root is not None:
|
||||||
|
root_real = os.path.realpath(str(safe_root.expanduser()))
|
||||||
|
path_real = os.path.realpath(str(user_path.expanduser())) # nosec B614 — validated against safe_root below
|
||||||
|
if not (path_real == root_real or path_real.startswith(root_real + os.sep)):
|
||||||
|
raise ValueError(f"Path {user_path} resolves to {path_real}, outside allowed root {safe_root}")
|
||||||
|
return Path(path_real)
|
||||||
|
return user_path.expanduser().resolve()
|
||||||
|
|||||||
+22
-15
@@ -27,6 +27,7 @@ Usage:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json as _json
|
import json as _json
|
||||||
|
import logging
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
@@ -36,21 +37,24 @@ from typing import Any
|
|||||||
|
|
||||||
from loguru import logger as _root
|
from loguru import logger as _root
|
||||||
|
|
||||||
# ── paths ─────────────────────────────────────────────────────────────────────
|
# ── paths ─────────────────────────────────────────────────────────────────────────────────
|
||||||
LOGS_ROOT: Path = Path(__file__).parent.parent.parent / "logs"
|
LOGS_ROOT: Path = Path(__file__).parent.parent.parent / "logs"
|
||||||
|
|
||||||
# ── format ────────────────────────────────────────────────────────────────────
|
# ── format ────────────────────────────────────────────────────────────────────────────────
|
||||||
_FILE_FMT = (
|
_FILE_FMT = (
|
||||||
"{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {extra[cmd]: <18} | {message}"
|
"{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {extra[cmd]: <18} | {message}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── internal state ─────────────────────────────────────────────────────────────
|
# ── internal state ─────────────────────────────────────────────────────────────────────────────
|
||||||
_registered: set[str] = set() # command keys that already have a file sink
|
_registered: set[str] = set() # command keys that already have a file sink
|
||||||
_all_added: bool = False # whether the combined all.log sink is active
|
_all_added: bool = False # whether the combined all.log sink is active
|
||||||
_llm_log_lock = threading.Lock() # guards concurrent writes to llm_calls.jsonl
|
_llm_log_lock = threading.Lock() # guards concurrent writes to llm_calls.jsonl
|
||||||
|
|
||||||
|
# Maximum characters stored per field in llm_calls.jsonl to prevent GB-scale files.
|
||||||
|
_LLM_CALL_MAX_CHARS = 500
|
||||||
|
|
||||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
|
||||||
|
# ── helpers ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _today_dir() -> Path:
|
def _today_dir() -> Path:
|
||||||
d = LOGS_ROOT / datetime.now().strftime("%Y-%m-%d")
|
d = LOGS_ROOT / datetime.now().strftime("%Y-%m-%d")
|
||||||
@@ -79,7 +83,7 @@ def _banner(log, title: str, meta: dict[str, Any]) -> None:
|
|||||||
log.info(sep)
|
log.info(sep)
|
||||||
|
|
||||||
|
|
||||||
# ── public API ────────────────────────────────────────────────────────────────
|
# ── public API ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def log_llm_call(
|
def log_llm_call(
|
||||||
system: str | None,
|
system: str | None,
|
||||||
@@ -88,16 +92,19 @@ def log_llm_call(
|
|||||||
start_time: Any = None,
|
start_time: Any = None,
|
||||||
end_time: Any = None,
|
end_time: Any = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Append one complete LLM call to logs/YYYY-MM-DD/llm_calls.jsonl.
|
"""Append one LLM call summary to logs/YYYY-MM-DD/llm_calls.jsonl.
|
||||||
|
|
||||||
|
Prompt/response content is capped at _LLM_CALL_MAX_CHARS to prevent
|
||||||
|
GB-scale log files from long-running loops.
|
||||||
|
|
||||||
Each line is a self-contained JSON object so the file is grep/jq-friendly:
|
Each line is a self-contained JSON object so the file is grep/jq-friendly:
|
||||||
jq 'select(.duration_ms > 5000)' logs/2026-04-17/llm_calls.jsonl
|
jq 'select(.duration_ms > 5000)' logs/2026-04-17/llm_calls.jsonl
|
||||||
"""
|
"""
|
||||||
entry: dict[str, Any] = {
|
entry: dict[str, Any] = {
|
||||||
"ts": datetime.now().isoformat(timespec="milliseconds"),
|
"ts": datetime.now().isoformat(timespec="milliseconds"),
|
||||||
"system": system or "",
|
"system": (system or "")[:_LLM_CALL_MAX_CHARS],
|
||||||
"user": user,
|
"user": user[:_LLM_CALL_MAX_CHARS],
|
||||||
"response": response,
|
"response": response[:_LLM_CALL_MAX_CHARS],
|
||||||
}
|
}
|
||||||
if start_time is not None and end_time is not None:
|
if start_time is not None and end_time is not None:
|
||||||
try:
|
try:
|
||||||
@@ -130,13 +137,13 @@ def setup(command: str, **context: Any):
|
|||||||
key = command.lower()
|
key = command.lower()
|
||||||
|
|
||||||
if key not in _registered:
|
if key not in _registered:
|
||||||
# Per-command rotating file
|
|
||||||
_root.add(
|
_root.add(
|
||||||
str(log_dir / f"{key}.log"),
|
str(log_dir / f"{key}.log"),
|
||||||
format=_FILE_FMT,
|
format=_FILE_FMT,
|
||||||
filter=lambda r, k=key: r["extra"].get("cmd", "").lower() == k,
|
filter=lambda r, k=key: r["extra"].get("cmd", "").lower() == k,
|
||||||
rotation="00:00", # new file at midnight
|
rotation="50 MB",
|
||||||
retention="30 days",
|
compression="gz",
|
||||||
|
retention="7 days",
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
enqueue=True,
|
enqueue=True,
|
||||||
backtrace=False,
|
backtrace=False,
|
||||||
@@ -145,13 +152,13 @@ def setup(command: str, **context: Any):
|
|||||||
_registered.add(key)
|
_registered.add(key)
|
||||||
|
|
||||||
if not _all_added:
|
if not _all_added:
|
||||||
# Combined log — all commands
|
|
||||||
_root.add(
|
_root.add(
|
||||||
str(log_dir / "all.log"),
|
str(log_dir / "all.log"),
|
||||||
format=_FILE_FMT,
|
format=_FILE_FMT,
|
||||||
filter=lambda r: "cmd" in r["extra"],
|
filter=lambda r: "cmd" in r["extra"],
|
||||||
rotation="00:00",
|
rotation="100 MB",
|
||||||
retention="60 days",
|
compression="gz",
|
||||||
|
retention="7 days",
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
enqueue=True,
|
enqueue=True,
|
||||||
backtrace=False,
|
backtrace=False,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,306 @@
|
|||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import pickle # nosec
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import streamlit as st
|
||||||
|
from streamlit import session_state
|
||||||
|
|
||||||
|
from rdagent.log.ui.conf import UI_SETTING
|
||||||
|
from rdagent.log.utils import extract_evoid, extract_loopid_func_name
|
||||||
|
|
||||||
|
st.set_page_config(layout="wide", page_title="debug_llm", page_icon="🎓", initial_sidebar_state="expanded")
|
||||||
|
|
||||||
|
# 获取 log_path 参数
|
||||||
|
parser = argparse.ArgumentParser(description="RD-Agent Streamlit App")
|
||||||
|
parser.add_argument("--log_dir", type=str, help="Path to the log directory")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def get_folders_sorted(log_path):
|
||||||
|
"""缓存并返回排序后的文件夹列表,并加入进度打印"""
|
||||||
|
with st.spinner("正在加载文件夹列表..."):
|
||||||
|
folders = sorted(
|
||||||
|
(folder for folder in log_path.iterdir() if folder.is_dir() and list(folder.iterdir())),
|
||||||
|
key=lambda folder: folder.stat().st_mtime,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
st.write(f"找到 {len(folders)} 个文件夹")
|
||||||
|
return [folder.name for folder in folders]
|
||||||
|
|
||||||
|
|
||||||
|
if UI_SETTING.enable_cache:
|
||||||
|
get_folders_sorted = st.cache_data(get_folders_sorted)
|
||||||
|
|
||||||
|
|
||||||
|
# 设置主日志路径
|
||||||
|
main_log_path = Path(args.log_dir) if args.log_dir else Path("./log")
|
||||||
|
if not main_log_path.exists():
|
||||||
|
st.error(f"Log dir {main_log_path} does not exist!")
|
||||||
|
st.stop()
|
||||||
|
|
||||||
|
if "data" not in session_state:
|
||||||
|
session_state.data = []
|
||||||
|
if "log_path" not in session_state:
|
||||||
|
session_state.log_path = None
|
||||||
|
|
||||||
|
tlist = []
|
||||||
|
|
||||||
|
|
||||||
|
def load_data():
|
||||||
|
"""加载数据到 session_state 并显示进度"""
|
||||||
|
log_file = main_log_path / session_state.log_path / "debug_llm.pkl"
|
||||||
|
try:
|
||||||
|
with st.spinner(f"正在加载数据文件 {log_file}..."):
|
||||||
|
start_time = time.time()
|
||||||
|
with open(log_file, "rb") as f:
|
||||||
|
session_state.data = pickle.load(f, encoding="utf-8") # nosec
|
||||||
|
st.success(f"数据加载完成!耗时 {time.time() - start_time:.2f} 秒")
|
||||||
|
st.session_state["current_loop"] = 1
|
||||||
|
except Exception as e:
|
||||||
|
session_state.data = [{"error": str(e)}]
|
||||||
|
st.error(f"加载数据失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# UI - Sidebar
|
||||||
|
with st.sidebar:
|
||||||
|
st.markdown(":blue[**Log Path**]")
|
||||||
|
manually = st.toggle("Manual Input")
|
||||||
|
if manually:
|
||||||
|
st.text_input("log path", key="log_path", label_visibility="collapsed")
|
||||||
|
else:
|
||||||
|
folders = get_folders_sorted(main_log_path)
|
||||||
|
st.selectbox(f"**Select from {main_log_path.absolute()}**", folders, key="log_path") # nosec B608 — not SQL, Bandit false positive on "Select" in UI label
|
||||||
|
|
||||||
|
if st.button("Refresh Data"):
|
||||||
|
load_data()
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
|
||||||
|
# Helper functions
|
||||||
|
def show_text(text, lang=None):
|
||||||
|
"""显示文本代码块"""
|
||||||
|
if lang:
|
||||||
|
st.code(text, language=lang, wrap_lines=True)
|
||||||
|
elif "\n" in text:
|
||||||
|
st.code(text, language="python", wrap_lines=True)
|
||||||
|
else:
|
||||||
|
st.code(text, language="html", wrap_lines=True)
|
||||||
|
|
||||||
|
|
||||||
|
def highlight_prompts_uri(uri):
|
||||||
|
"""高亮 URI 的格式"""
|
||||||
|
parts = uri.split(":")
|
||||||
|
return f"**{parts[0]}:**:green[**{parts[1]}**]"
|
||||||
|
|
||||||
|
|
||||||
|
# Display Data
|
||||||
|
progress_text = st.empty()
|
||||||
|
progress_bar = st.progress(0)
|
||||||
|
|
||||||
|
# 每页展示一个 Loop
|
||||||
|
LOOPS_PER_PAGE = 1
|
||||||
|
|
||||||
|
# 获取所有的 Loop ID
|
||||||
|
loop_groups = {}
|
||||||
|
for i, d in enumerate(session_state.data):
|
||||||
|
tag = d["tag"]
|
||||||
|
loop_id, _ = extract_loopid_func_name(tag)
|
||||||
|
if loop_id:
|
||||||
|
if loop_id not in loop_groups:
|
||||||
|
loop_groups[loop_id] = []
|
||||||
|
loop_groups[loop_id].append(d)
|
||||||
|
|
||||||
|
# 按 Loop ID 排序
|
||||||
|
sorted_loop_ids = sorted(loop_groups.keys(), key=int) # 假设 Loop ID 是数字
|
||||||
|
total_loops = len(sorted_loop_ids)
|
||||||
|
total_pages = total_loops # 每页展示一个 Loop
|
||||||
|
|
||||||
|
|
||||||
|
# simple display
|
||||||
|
# FIXME: Delete this simple UI if trace have tag(evo_id & loop_id)
|
||||||
|
# with st.sidebar:
|
||||||
|
# start = int(st.text_input("start", 0))
|
||||||
|
# end = int(st.text_input("end", 100))
|
||||||
|
# for m in session_state.data[start:end]:
|
||||||
|
# if "tpl" in m["tag"]:
|
||||||
|
# obj = m["obj"]
|
||||||
|
# uri = obj["uri"]
|
||||||
|
# tpl = obj["template"]
|
||||||
|
# cxt = obj["context"]
|
||||||
|
# rd = obj["rendered"]
|
||||||
|
# with st.expander(highlight_prompts_uri(uri), expanded=False, icon="⚙️"):
|
||||||
|
# t1, t2, t3 = st.tabs([":green[**Rendered**]", ":blue[**Template**]", ":orange[**Context**]"])
|
||||||
|
# with t1:
|
||||||
|
# show_text(rd)
|
||||||
|
# with t2:
|
||||||
|
# show_text(tpl, lang="django")
|
||||||
|
# with t3:
|
||||||
|
# st.json(cxt)
|
||||||
|
# if "llm" in m["tag"]:
|
||||||
|
# obj = m["obj"]
|
||||||
|
# system = obj.get("system", None)
|
||||||
|
# user = obj["user"]
|
||||||
|
# resp = obj["resp"]
|
||||||
|
# with st.expander(f"**LLM**", expanded=False, icon="🤖"):
|
||||||
|
# t1, t2, t3 = st.tabs([":green[**Response**]", ":blue[**User**]", ":orange[**System**]"])
|
||||||
|
# with t1:
|
||||||
|
# try:
|
||||||
|
# rdict = json.loads(resp)
|
||||||
|
# if "code" in rdict:
|
||||||
|
# code = rdict["code"]
|
||||||
|
# st.markdown(":red[**Code in response dict:**]")
|
||||||
|
# st.code(code, language="python", wrap_lines=True, line_numbers=True)
|
||||||
|
# rdict.pop("code")
|
||||||
|
# elif "spec" in rdict:
|
||||||
|
# spec = rdict["spec"]
|
||||||
|
# st.markdown(":red[**Spec in response dict:**]")
|
||||||
|
# st.markdown(spec)
|
||||||
|
# rdict.pop("spec")
|
||||||
|
# else:
|
||||||
|
# # show model codes
|
||||||
|
# showed_keys = []
|
||||||
|
# for k, v in rdict.items():
|
||||||
|
# if k.startswith("model_") and k.endswith(".py"):
|
||||||
|
# st.markdown(f":red[**{k}**]")
|
||||||
|
# st.code(v, language="python", wrap_lines=True, line_numbers=True)
|
||||||
|
# showed_keys.append(k)
|
||||||
|
# for k in showed_keys:
|
||||||
|
# rdict.pop(k)
|
||||||
|
# st.write(":red[**Other parts (except for the code or spec) in response dict:**]")
|
||||||
|
# st.json(rdict)
|
||||||
|
# except:
|
||||||
|
# st.json(resp)
|
||||||
|
# with t2:
|
||||||
|
# show_text(user)
|
||||||
|
# with t3:
|
||||||
|
# show_text(system or "No system prompt available")
|
||||||
|
|
||||||
|
|
||||||
|
if total_pages:
|
||||||
|
# 初始化 current_loop
|
||||||
|
if "current_loop" not in st.session_state:
|
||||||
|
st.session_state["current_loop"] = 1
|
||||||
|
|
||||||
|
# Loop 导航按钮
|
||||||
|
col1, col2, col3, col4, col5 = st.sidebar.columns([1.2, 1, 2, 1, 1.2])
|
||||||
|
|
||||||
|
with col1:
|
||||||
|
if st.button("|<"): # 首页
|
||||||
|
st.session_state["current_loop"] = 1
|
||||||
|
with col2:
|
||||||
|
if st.button("<") and st.session_state["current_loop"] > 1: # 上一页
|
||||||
|
st.session_state["current_loop"] -= 1
|
||||||
|
with col3:
|
||||||
|
# 下拉列表显示所有 Loop
|
||||||
|
st.session_state["current_loop"] = st.selectbox(
|
||||||
|
"选择 Loop",
|
||||||
|
options=list(range(1, total_loops + 1)),
|
||||||
|
index=st.session_state["current_loop"] - 1, # 默认选中当前 Loop
|
||||||
|
label_visibility="collapsed", # 隐藏标签
|
||||||
|
)
|
||||||
|
with col4:
|
||||||
|
if st.button("\>") and st.session_state["current_loop"] < total_loops: # 下一页
|
||||||
|
st.session_state["current_loop"] += 1
|
||||||
|
with col5:
|
||||||
|
if st.button("\>|"): # 最后一页
|
||||||
|
st.session_state["current_loop"] = total_loops
|
||||||
|
|
||||||
|
# 获取当前 Loop
|
||||||
|
current_loop = st.session_state["current_loop"]
|
||||||
|
|
||||||
|
# 渲染当前 Loop 数据
|
||||||
|
loop_id = sorted_loop_ids[current_loop - 1]
|
||||||
|
progress_text = st.empty()
|
||||||
|
progress_text.text(f"正在处理 Loop {loop_id}...")
|
||||||
|
progress_bar.progress(current_loop / total_loops, text=f"Loop :green[**{current_loop}**] / {total_loops}")
|
||||||
|
|
||||||
|
# 渲染 Loop Header
|
||||||
|
loop_anchor = f"Loop_{loop_id}"
|
||||||
|
if loop_anchor not in tlist:
|
||||||
|
tlist.append(loop_anchor)
|
||||||
|
st.header(loop_anchor, anchor=loop_anchor, divider="blue")
|
||||||
|
|
||||||
|
# 渲染当前 Loop 的所有数据
|
||||||
|
loop_data = loop_groups[loop_id]
|
||||||
|
for d in loop_data:
|
||||||
|
tag = d["tag"]
|
||||||
|
obj = d["obj"]
|
||||||
|
_, func_name = extract_loopid_func_name(tag)
|
||||||
|
evo_id = extract_evoid(tag)
|
||||||
|
|
||||||
|
func_anchor = f"loop_{loop_id}.{func_name}"
|
||||||
|
if func_anchor not in tlist:
|
||||||
|
tlist.append(func_anchor)
|
||||||
|
st.header(f"in *{func_name}*", anchor=func_anchor, divider="green")
|
||||||
|
|
||||||
|
evo_anchor = f"loop_{loop_id}.evo_step_{evo_id}"
|
||||||
|
if evo_id and evo_anchor not in tlist:
|
||||||
|
tlist.append(evo_anchor)
|
||||||
|
st.subheader(f"evo_step_{evo_id}", anchor=evo_anchor, divider="orange")
|
||||||
|
|
||||||
|
# 根据 tag 渲染内容
|
||||||
|
if "debug_exp_gen" in tag:
|
||||||
|
with st.expander(
|
||||||
|
f"Exp in :violet[**{obj.experiment_workspace.workspace_path}**]", expanded=False, icon="🧩"
|
||||||
|
):
|
||||||
|
st.write(obj)
|
||||||
|
elif "debug_tpl" in tag:
|
||||||
|
uri = obj["uri"]
|
||||||
|
tpl = obj["template"]
|
||||||
|
cxt = obj["context"]
|
||||||
|
rd = obj["rendered"]
|
||||||
|
with st.expander(highlight_prompts_uri(uri), expanded=False, icon="⚙️"):
|
||||||
|
t1, t2, t3 = st.tabs([":green[**Rendered**]", ":blue[**Template**]", ":orange[**Context**]"])
|
||||||
|
with t1:
|
||||||
|
show_text(rd)
|
||||||
|
with t2:
|
||||||
|
show_text(tpl, lang="django")
|
||||||
|
with t3:
|
||||||
|
st.json(cxt)
|
||||||
|
elif "debug_llm" in tag:
|
||||||
|
system = obj.get("system", None)
|
||||||
|
user = obj["user"]
|
||||||
|
resp = obj["resp"]
|
||||||
|
with st.expander(f"**LLM**", expanded=False, icon="🤖"):
|
||||||
|
t1, t2, t3 = st.tabs([":green[**Response**]", ":blue[**User**]", ":orange[**System**]"])
|
||||||
|
with t1:
|
||||||
|
try:
|
||||||
|
rdict = json.loads(resp)
|
||||||
|
if "code" in rdict:
|
||||||
|
code = rdict["code"]
|
||||||
|
st.markdown(":red[**Code in response dict:**]")
|
||||||
|
st.code(code, language="python", wrap_lines=True, line_numbers=True)
|
||||||
|
rdict.pop("code")
|
||||||
|
elif "spec" in rdict:
|
||||||
|
spec = rdict["spec"]
|
||||||
|
st.markdown(":red[**Spec in response dict:**]")
|
||||||
|
st.markdown(spec)
|
||||||
|
rdict.pop("spec")
|
||||||
|
else:
|
||||||
|
# show model codes
|
||||||
|
showed_keys = []
|
||||||
|
for k, v in rdict.items():
|
||||||
|
if k.startswith("model_") and k.endswith(".py"):
|
||||||
|
st.markdown(f":red[**{k}**]")
|
||||||
|
st.code(v, language="python", wrap_lines=True, line_numbers=True)
|
||||||
|
showed_keys.append(k)
|
||||||
|
for k in showed_keys:
|
||||||
|
rdict.pop(k)
|
||||||
|
st.write(":red[**Other parts (except for the code or spec) in response dict:**]")
|
||||||
|
st.json(rdict)
|
||||||
|
except:
|
||||||
|
st.json(resp)
|
||||||
|
with t2:
|
||||||
|
show_text(user)
|
||||||
|
with t3:
|
||||||
|
show_text(system or "No system prompt available")
|
||||||
|
|
||||||
|
progress_text.text("当前 Loop 数据处理完成!")
|
||||||
|
|
||||||
|
# Sidebar TOC
|
||||||
|
with st.sidebar:
|
||||||
|
toc = "\n".join([f"- [{t}](#{t})" if t.startswith("L") else f" - [{t.split('.')[1]}](#{t})" for t in tlist])
|
||||||
|
st.markdown(toc, unsafe_allow_html=True)
|
||||||
@@ -541,7 +541,8 @@ class APIBackend(ABC):
|
|||||||
**kwargs,
|
**kwargs,
|
||||||
) -> str | list[list[float]]:
|
) -> str | list[list[float]]:
|
||||||
"""This function to share operation between embedding and chat completion"""
|
"""This function to share operation between embedding and chat completion"""
|
||||||
assert not (chat_completion and embedding), "chat_completion and embedding cannot be True at the same time"
|
if chat_completion and embedding:
|
||||||
|
raise ValueError("chat_completion and embedding cannot be True at the same time")
|
||||||
max_retry = LLM_SETTINGS.max_retry if LLM_SETTINGS.max_retry is not None else max_retry
|
max_retry = LLM_SETTINGS.max_retry if LLM_SETTINGS.max_retry is not None else max_retry
|
||||||
timeout_count = 0
|
timeout_count = 0
|
||||||
violation_count = 0
|
violation_count = 0
|
||||||
@@ -720,7 +721,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.")
|
||||||
|
|
||||||
|
|||||||
@@ -36,16 +36,18 @@ def get_agent_model() -> OpenAIChatModel:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
backend = APIBackend()
|
backend = APIBackend()
|
||||||
assert isinstance(backend, LiteLLMAPIBackend), "Only LiteLLMAPIBackend is supported"
|
if not isinstance(backend, LiteLLMAPIBackend):
|
||||||
|
raise TypeError("Only LiteLLMAPIBackend is supported")
|
||||||
|
|
||||||
compl_kwargs = backend.get_complete_kwargs()
|
compl_kwargs = backend.get_complete_kwargs()
|
||||||
|
|
||||||
selected_model = compl_kwargs["model"]
|
selected_model = compl_kwargs["model"]
|
||||||
|
|
||||||
_, custom_llm_provider, _, _ = get_llm_provider(selected_model)
|
_, custom_llm_provider, _, _ = get_llm_provider(selected_model)
|
||||||
assert (
|
if custom_llm_provider not in PROVIDER_TO_ENV_MAP:
|
||||||
custom_llm_provider in PROVIDER_TO_ENV_MAP
|
raise ValueError(
|
||||||
), f"Provider {custom_llm_provider} not supported. Please add it into `PROVIDER_TO_ENV_MAP`"
|
f"Provider {custom_llm_provider} not supported. Please add it into `PROVIDER_TO_ENV_MAP`"
|
||||||
|
)
|
||||||
prefix = PROVIDER_TO_ENV_MAP[custom_llm_provider]
|
prefix = PROVIDER_TO_ENV_MAP[custom_llm_provider]
|
||||||
api_key = os.getenv(f"{prefix}_API_KEY", None)
|
api_key = os.getenv(f"{prefix}_API_KEY", None)
|
||||||
api_base = os.getenv(f"{prefix}_API_BASE", None)
|
api_base = os.getenv(f"{prefix}_API_BASE", None)
|
||||||
|
|||||||
@@ -268,7 +268,8 @@ class JsonReducer(DataReducer):
|
|||||||
parent[key] = sampled # type: ignore # parent 是 list,key 是 index, list.__setitem__(key, sampled)
|
parent[key] = sampled # type: ignore # parent 是 list,key 是 index, list.__setitem__(key, sampled)
|
||||||
self.sampled_files.extend([self.extract_filename(i) for i in sampled])
|
self.sampled_files.extend([self.extract_filename(i) for i in sampled])
|
||||||
break
|
break
|
||||||
assert len(self.sampled_files) > 0
|
if len(self.sampled_files) <= 0:
|
||||||
|
raise AssertionError("sampled_files must contain at least one file")
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def _find_all_lists(
|
def _find_all_lists(
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ from sklearn.metrics import roc_auc_score
|
|||||||
def prepare_for_auroc_metric(submission: pd.DataFrame, answers: pd.DataFrame, id_col: str, target_col: str) -> dict:
|
def prepare_for_auroc_metric(submission: pd.DataFrame, answers: pd.DataFrame, id_col: str, target_col: str) -> dict:
|
||||||
|
|
||||||
# Answers checks
|
# Answers checks
|
||||||
assert id_col in answers.columns, f"answers dataframe should have an {id_col} column"
|
if id_col not in answers.columns:
|
||||||
assert target_col in answers.columns, f"answers dataframe should have a {target_col} column"
|
raise InvalidSubmissionError(f"answers dataframe should have an {id_col} column")
|
||||||
|
if target_col not in answers.columns:
|
||||||
|
raise InvalidSubmissionError(f"answers dataframe should have a {target_col} column")
|
||||||
|
|
||||||
# Submission checks
|
# Submission checks
|
||||||
if id_col not in submission.columns:
|
if id_col not in submission.columns:
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Check if our submission file exists
|
# Check if our submission file exists
|
||||||
assert Path("submission.csv").exists(), "Error: submission.csv not found"
|
if not Path("submission.csv").exists():
|
||||||
|
raise FileNotFoundError("Error: submission.csv not found")
|
||||||
|
|
||||||
submission_lines = Path("submission.csv").read_text().splitlines()
|
submission_lines = Path("submission.csv").read_text().splitlines()
|
||||||
test_lines = Path("submission_test.csv").read_text().splitlines()
|
test_lines = Path("submission_test.csv").read_text().splitlines()
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ def prepare_for_metric(submission: pd.DataFrame, answers: pd.DataFrame) -> dict:
|
|||||||
if "price" not in submission.columns:
|
if "price" not in submission.columns:
|
||||||
raise InvalidSubmissionError("Submission DataFrame must contain 'price' columns.")
|
raise InvalidSubmissionError("Submission DataFrame must contain 'price' columns.")
|
||||||
|
|
||||||
assert "price" in answers.columns, "Answers DataFrame must contain 'price' columns."
|
if "price" not in answers.columns:
|
||||||
|
raise InvalidSubmissionError("Answers DataFrame must contain 'price' columns.")
|
||||||
|
|
||||||
if len(submission) != len(answers):
|
if len(submission) != len(answers):
|
||||||
raise InvalidSubmissionError("Submission must be the same length as the answers.")
|
raise InvalidSubmissionError("Submission must be the same length as the answers.")
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Check if our submission file exists
|
# Check if our submission file exists
|
||||||
assert Path("submission.csv").exists(), "Error: submission.csv not found"
|
if not Path("submission.csv").exists():
|
||||||
|
raise FileNotFoundError("Error: submission.csv not found")
|
||||||
|
|
||||||
submission_lines = Path("submission.csv").read_text().splitlines() # 自动生成的
|
submission_lines = Path("submission.csv").read_text().splitlines() # 自动生成的
|
||||||
test_lines = Path("submission_test.csv").read_text().splitlines() # test.csv
|
test_lines = Path("submission_test.csv").read_text().splitlines() # test.csv
|
||||||
|
|||||||
+14
-11
@@ -56,14 +56,17 @@ sparse.save_npz(public / "test" / "X.npz", X_test)
|
|||||||
sparse.save_npz(public / "train" / "X.npz", X_train)
|
sparse.save_npz(public / "train" / "X.npz", X_train)
|
||||||
df_train.to_csv(public / "train" / "ARF_12h.csv", index=False)
|
df_train.to_csv(public / "train" / "ARF_12h.csv", index=False)
|
||||||
|
|
||||||
assert (
|
if X_train.shape[0] != df_train.shape[0]:
|
||||||
X_train.shape[0] == df_train.shape[0]
|
raise ValueError(
|
||||||
), f"Mismatch: X_train rows ({X_train.shape[0]}) != df_train rows ({df_train.shape[0]})"
|
f"Mismatch: X_train rows ({X_train.shape[0]}) != df_train rows ({df_train.shape[0]})"
|
||||||
assert (
|
)
|
||||||
X_test.shape[0] == df_test.shape[0]
|
if X_test.shape[0] != df_test.shape[0]:
|
||||||
), f"Mismatch: X_test rows ({X_test.shape[0]}) != df_test rows ({df_test.shape[0]})"
|
raise ValueError(
|
||||||
assert df_test.shape[1] == 2, "Public test set should have 2 columns"
|
f"Mismatch: X_test rows ({X_test.shape[0]}) != df_test rows ({df_test.shape[0]})"
|
||||||
assert df_train.shape[1] == 3, "Public train set should have 3 columns"
|
)
|
||||||
assert len(df_train) + len(df_test) == len(
|
if df_test.shape[1] != 2:
|
||||||
df_label
|
raise ValueError("Public test set should have 2 columns")
|
||||||
), "Length of new_train and new_test should equal length of old_train"
|
if df_train.shape[1] != 3:
|
||||||
|
raise ValueError("Public train set should have 3 columns")
|
||||||
|
if len(df_train) + len(df_test) != len(df_label):
|
||||||
|
raise ValueError("Length of new_train and new_test should equal length of old_train")
|
||||||
|
|||||||
+6
-5
@@ -25,11 +25,12 @@ def prepare(raw: Path, public: Path, private: Path):
|
|||||||
new_test.to_csv(public / "test.csv", index=False)
|
new_test.to_csv(public / "test.csv", index=False)
|
||||||
|
|
||||||
# Checks
|
# Checks
|
||||||
assert new_test.shape[1] == 12, "Public test set should have 12 columns"
|
if new_test.shape[1] != 12:
|
||||||
assert new_train.shape[1] == 13, "Public train set should have 13 columns"
|
raise AssertionError("Public test set should have 12 columns")
|
||||||
assert len(new_train) + len(new_test) == len(
|
if new_train.shape[1] != 13:
|
||||||
old_train
|
raise AssertionError("Public train set should have 13 columns")
|
||||||
), "Length of new_train and new_test should equal length of old_train"
|
if len(new_train) + len(new_test) != len(old_train):
|
||||||
|
raise AssertionError("Length of new_train and new_test should equal length of old_train")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -320,7 +320,8 @@ class DataScienceRDLoop(RDLoop):
|
|||||||
# only clean current workspace without affecting other loops.
|
# only clean current workspace without affecting other loops.
|
||||||
for k in "direct_exp_gen", "coding", "running":
|
for k in "direct_exp_gen", "coding", "running":
|
||||||
if k in prev_out and prev_out[k] is not None:
|
if k in prev_out and prev_out[k] is not None:
|
||||||
assert isinstance(prev_out[k], DSExperiment)
|
if not isinstance(prev_out[k], DSExperiment):
|
||||||
|
raise TypeError(f"prev_out[{k!r}] must be an instance of DSExperiment")
|
||||||
clean_workspace(prev_out[k].experiment_workspace.workspace_path)
|
clean_workspace(prev_out[k].experiment_workspace.workspace_path)
|
||||||
|
|
||||||
# Backup the workspace (only necessary files are included)
|
# Backup the workspace (only necessary files are included)
|
||||||
|
|||||||
@@ -213,7 +213,8 @@ class DSTrace(Trace[DataScienceScen, KnowledgeBase]):
|
|||||||
self, component: COMPONENT, search_list: list[tuple[DSExperiment, ExperimentFeedback]] = []
|
self, component: COMPONENT, search_list: list[tuple[DSExperiment, ExperimentFeedback]] = []
|
||||||
) -> bool:
|
) -> bool:
|
||||||
for exp, fb in search_list:
|
for exp, fb in search_list:
|
||||||
assert isinstance(exp.hypothesis, DSHypothesis), "Hypothesis should be DSHypothesis (and not None)"
|
if not isinstance(exp.hypothesis, DSHypothesis):
|
||||||
|
raise TypeError("Hypothesis should be DSHypothesis (and not None)")
|
||||||
if exp.hypothesis.component == component and fb:
|
if exp.hypothesis.component == component and fb:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ class ExpGen2Hypothesis(DSProposalV2ExpGen):
|
|||||||
|
|
||||||
success_fb_list = list(set(trace_fbs))
|
success_fb_list = list(set(trace_fbs))
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Merge Hypothesis: select {len(success_fb_list)} from {len(trace_fbs)} SOTA experiments found in {len(leaves)} traces"
|
f"Merge Hypothesis: select {len(success_fb_list)} from {len(trace_fbs)} SOTA experiments found in {len(leaves)} traces" # nosec B608 — not SQL, Bandit false positive on "select" in log message
|
||||||
)
|
)
|
||||||
|
|
||||||
if len(success_fb_list) > 0:
|
if len(success_fb_list) > 0:
|
||||||
@@ -377,7 +377,8 @@ class ExpGen2TraceAndMergeV2(ExpGen):
|
|||||||
if DS_RD_SETTING.enable_multi_version_exp_gen:
|
if DS_RD_SETTING.enable_multi_version_exp_gen:
|
||||||
exp_gen_version_list = DS_RD_SETTING.exp_gen_version_list.split(",")
|
exp_gen_version_list = DS_RD_SETTING.exp_gen_version_list.split(",")
|
||||||
for version in exp_gen_version_list:
|
for version in exp_gen_version_list:
|
||||||
assert version in ["v3", "v2", "v1"]
|
if version not in ["v3", "v2", "v1"]:
|
||||||
|
raise ValueError(f"version must be 'v1', 'v2', or 'v3', got {version!r}")
|
||||||
|
|
||||||
if len(trace.hist) == 0:
|
if len(trace.hist) == 0:
|
||||||
# set the proposal version for the first sub-trace
|
# set the proposal version for the first sub-trace
|
||||||
|
|||||||
@@ -339,7 +339,8 @@ class DSProposalV1ExpGen(ExpGen):
|
|||||||
eda_output = sota_exp.experiment_workspace.file_dict.get("EDA.md", None)
|
eda_output = sota_exp.experiment_workspace.file_dict.get("EDA.md", None)
|
||||||
scenario_desc = trace.scen.get_scenario_all_desc(eda_output=eda_output)
|
scenario_desc = trace.scen.get_scenario_all_desc(eda_output=eda_output)
|
||||||
|
|
||||||
assert sota_exp is not None, "SOTA experiment is not provided."
|
if sota_exp is None:
|
||||||
|
raise ValueError("SOTA experiment is not provided.")
|
||||||
last_exp = trace.last_exp()
|
last_exp = trace.last_exp()
|
||||||
# exp_and_feedback = trace.hist[-1]
|
# exp_and_feedback = trace.hist[-1]
|
||||||
# last_exp = exp_and_feedback[0]
|
# last_exp = exp_and_feedback[0]
|
||||||
@@ -445,8 +446,10 @@ class DSProposalV1ExpGen(ExpGen):
|
|||||||
json_target_type=dict[str, dict[str, str | dict] | str],
|
json_target_type=dict[str, dict[str, str | dict] | str],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
assert "hypothesis_proposal" in resp_dict, "Hypothesis proposal not provided."
|
if "hypothesis_proposal" not in resp_dict:
|
||||||
assert "task_design" in resp_dict, "Task design not provided."
|
raise ValueError("Hypothesis proposal not provided.")
|
||||||
|
if "task_design" not in resp_dict:
|
||||||
|
raise ValueError("Task design not provided.")
|
||||||
task_class = component_info["task_class"]
|
task_class = component_info["task_class"]
|
||||||
hypothesis_proposal = resp_dict.get("hypothesis_proposal", {})
|
hypothesis_proposal = resp_dict.get("hypothesis_proposal", {})
|
||||||
hypothesis = DSHypothesis(
|
hypothesis = DSHypothesis(
|
||||||
@@ -1149,8 +1152,10 @@ You help users retrieve relevant knowledge from community discussions and public
|
|||||||
)
|
)
|
||||||
|
|
||||||
response_dict = json.loads(response)
|
response_dict = json.loads(response)
|
||||||
assert response_dict.get("component") in HypothesisComponent.__members__, f"Invalid component"
|
if response_dict.get("component") not in HypothesisComponent.__members__:
|
||||||
assert response_dict.get("hypothesis") is not None, f"Invalid hypothesis"
|
raise ValueError(f"Invalid component: {response_dict.get('component')}")
|
||||||
|
if response_dict.get("hypothesis") is None:
|
||||||
|
raise ValueError("Invalid hypothesis")
|
||||||
return response_dict
|
return response_dict
|
||||||
|
|
||||||
# END: for support llm-based hypothesis selection -----
|
# END: for support llm-based hypothesis selection -----
|
||||||
@@ -1253,7 +1258,8 @@ You help users retrieve relevant knowledge from community discussions and public
|
|||||||
description=task_desc,
|
description=task_desc,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(task, PipelineTask), f"Task {task_name} is not a PipelineTask, got {type(task)}"
|
if not isinstance(task, PipelineTask):
|
||||||
|
raise TypeError(f"Task {task_name} is not a PipelineTask, got {type(task)}")
|
||||||
# only for llm with response schema.(TODO: support for non-schema llm?)
|
# only for llm with response schema.(TODO: support for non-schema llm?)
|
||||||
# If the LLM provides a "packages" field (list[str]), compute runtime environment now and cache it for subsequent prompts in later loops.
|
# If the LLM provides a "packages" field (list[str]), compute runtime environment now and cache it for subsequent prompts in later loops.
|
||||||
if isinstance(task_dict, dict) and "packages" in task_dict and isinstance(task_dict["packages"], list):
|
if isinstance(task_dict, dict) and "packages" in task_dict and isinstance(task_dict["packages"], list):
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import ast
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import pickle
|
import pickle
|
||||||
@@ -292,7 +293,7 @@ class ValidationSelector(SOTAexpSelector):
|
|||||||
Sorts all valid experiments by score and returns the top N.
|
Sorts all valid experiments by score and returns the top N.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
mock_folder = f"/tmp/mock/{self.competition}"
|
mock_folder = f"/tmp/mock/{self.competition}" # nosec B108 — Docker volume mount point derived from internal competition name
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data_py_code, grade_py_code = self._prepare_validation_scripts(
|
data_py_code, grade_py_code = self._prepare_validation_scripts(
|
||||||
@@ -539,7 +540,7 @@ def process_experiment(
|
|||||||
|
|
||||||
# Run main script
|
# Run main script
|
||||||
env = get_ds_env(
|
env = get_ds_env(
|
||||||
extra_volumes={f"/tmp/mock/{competition}/{input_folder}": input_folder},
|
extra_volumes={f"/tmp/mock/{competition}/{input_folder}": input_folder}, # nosec B108 — Docker volume mount point derived from internal competition name
|
||||||
running_timeout_period=DS_RD_SETTING.full_timeout,
|
running_timeout_period=DS_RD_SETTING.full_timeout,
|
||||||
)
|
)
|
||||||
result = ws.run(env=env, entry="python main.py")
|
result = ws.run(env=env, entry="python main.py")
|
||||||
@@ -587,8 +588,8 @@ def _parsing_score(grade_stdout: str) -> Optional[float]:
|
|||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
# Priority 2: Eval dict
|
# Priority 2: safe literal eval for Python-style dicts
|
||||||
return float(eval(json_str)["score"])
|
return float(ast.literal_eval(json_str)["score"])
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -35,10 +35,11 @@ def select(X: pd.DataFrame) -> pd.DataFrame:
|
|||||||
class KGModelFeatureSelectionCoder(Developer[KGModelExperiment]):
|
class KGModelFeatureSelectionCoder(Developer[KGModelExperiment]):
|
||||||
def develop(self, exp: KGModelExperiment) -> KGModelExperiment:
|
def develop(self, exp: KGModelExperiment) -> KGModelExperiment:
|
||||||
target_model_type = exp.sub_tasks[0].model_type
|
target_model_type = exp.sub_tasks[0].model_type
|
||||||
assert target_model_type in KG_SELECT_MAPPING
|
if target_model_type not in KG_SELECT_MAPPING:
|
||||||
|
raise ValueError(f"target_model_type {target_model_type} not in KG_SELECT_MAPPING")
|
||||||
if len(exp.experiment_workspace.data_description) == 1:
|
if len(exp.experiment_workspace.data_description) == 1:
|
||||||
code = (
|
code = (
|
||||||
Environment(undefined=StrictUndefined)
|
Environment(undefined=StrictUndefined) # nosec B701 — renders Python code templates, not HTML; autoescape would corrupt code
|
||||||
.from_string(DEFAULT_SELECTION_CODE)
|
.from_string(DEFAULT_SELECTION_CODE)
|
||||||
.render(feature_index_list=None)
|
.render(feature_index_list=None)
|
||||||
)
|
)
|
||||||
@@ -62,7 +63,7 @@ class KGModelFeatureSelectionCoder(Developer[KGModelExperiment]):
|
|||||||
chosen_index_to_list_index = [i - 1 for i in chosen_index]
|
chosen_index_to_list_index = [i - 1 for i in chosen_index]
|
||||||
|
|
||||||
code = (
|
code = (
|
||||||
Environment(undefined=StrictUndefined)
|
Environment(undefined=StrictUndefined) # nosec B701 — renders Python code templates, not HTML; autoescape would corrupt code
|
||||||
.from_string(DEFAULT_SELECTION_CODE)
|
.from_string(DEFAULT_SELECTION_CODE)
|
||||||
.render(feature_index_list=chosen_index_to_list_index)
|
.render(feature_index_list=chosen_index_to_list_index)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -165,7 +165,8 @@ class KGScenario(Scenario):
|
|||||||
return data_info
|
return data_info
|
||||||
|
|
||||||
def output_format(self, tag=None) -> str:
|
def output_format(self, tag=None) -> str:
|
||||||
assert tag in [None, "feature", "model"]
|
if tag not in [None, "feature", "model"]:
|
||||||
|
raise ValueError(f"tag must be None, 'feature', or 'model', got {tag!r}")
|
||||||
feature_output_format = f"""The feature code should output following the format:
|
feature_output_format = f"""The feature code should output following the format:
|
||||||
{T(".prompts:kg_feature_output_format").r()}"""
|
{T(".prompts:kg_feature_output_format").r()}"""
|
||||||
model_output_format = f"""The model code should output following the format:\n""" + T(
|
model_output_format = f"""The model code should output following the format:\n""" + T(
|
||||||
@@ -180,7 +181,8 @@ class KGScenario(Scenario):
|
|||||||
return model_output_format
|
return model_output_format
|
||||||
|
|
||||||
def interface(self, tag=None) -> str:
|
def interface(self, tag=None) -> str:
|
||||||
assert tag in [None, "feature", "XGBoost", "RandomForest", "LightGBM", "NN"]
|
if tag not in [None, "feature", "XGBoost", "RandomForest", "LightGBM", "NN"]:
|
||||||
|
raise ValueError(f"tag must be None, 'feature', 'XGBoost', 'RandomForest', 'LightGBM', or 'NN', got {tag!r}")
|
||||||
feature_interface = f"""The feature code should follow the interface:
|
feature_interface = f"""The feature code should follow the interface:
|
||||||
{T(".prompts:kg_feature_interface").r()}"""
|
{T(".prompts:kg_feature_interface").r()}"""
|
||||||
if tag == "feature":
|
if tag == "feature":
|
||||||
@@ -195,7 +197,8 @@ class KGScenario(Scenario):
|
|||||||
return model_interface
|
return model_interface
|
||||||
|
|
||||||
def simulator(self, tag=None) -> str:
|
def simulator(self, tag=None) -> str:
|
||||||
assert tag in [None, "feature", "model"]
|
if tag not in [None, "feature", "model"]:
|
||||||
|
raise ValueError(f"tag must be None, 'feature', or 'model', got {tag!r}")
|
||||||
|
|
||||||
kg_feature_simulator = (
|
kg_feature_simulator = (
|
||||||
"The feature code will be sent to the simulator:\n" + T(".prompts:kg_feature_simulator").r()
|
"The feature code will be sent to the simulator:\n" + T(".prompts:kg_feature_simulator").r()
|
||||||
|
|||||||
+6
-6
@@ -79,12 +79,12 @@ def preprocess_script():
|
|||||||
This method applies the preprocessing steps to the training, validation, and test datasets.
|
This method applies the preprocessing steps to the training, validation, and test datasets.
|
||||||
"""
|
"""
|
||||||
if os.path.exists("/kaggle/input/X_train.pkl"):
|
if os.path.exists("/kaggle/input/X_train.pkl"):
|
||||||
X_train = pd.read_pickle("/kaggle/input/X_train.pkl")
|
X_train = pd.read_pickle("/kaggle/input/X_train.pkl") # nosec B301 — trusted Kaggle input
|
||||||
X_valid = pd.read_pickle("/kaggle/input/X_valid.pkl")
|
X_valid = pd.read_pickle("/kaggle/input/X_valid.pkl") # nosec B301
|
||||||
y_train = pd.read_pickle("/kaggle/input/y_train.pkl")
|
y_train = pd.read_pickle("/kaggle/input/y_train.pkl") # nosec B301
|
||||||
y_valid = pd.read_pickle("/kaggle/input/y_valid.pkl")
|
y_valid = pd.read_pickle("/kaggle/input/y_valid.pkl") # nosec B301
|
||||||
X_test = pd.read_pickle("/kaggle/input/X_test.pkl")
|
X_test = pd.read_pickle("/kaggle/input/X_test.pkl") # nosec B301
|
||||||
others = pd.read_pickle("/kaggle/input/others.pkl")
|
others = pd.read_pickle("/kaggle/input/others.pkl") # nosec B301
|
||||||
y_train = pd.Series(y_train).reset_index(drop=True)
|
y_train = pd.Series(y_train).reset_index(drop=True)
|
||||||
y_valid = pd.Series(y_valid).reset_index(drop=True)
|
y_valid = pd.Series(y_valid).reset_index(drop=True)
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -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()
|
||||||
|
|||||||
+5
-5
@@ -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()
|
||||||
|
|||||||
+6
-6
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,12 @@ def crawl_descriptions(
|
|||||||
content = e.get_attribute("innerHTML")
|
content = e.get_attribute("innerHTML")
|
||||||
contents.append(content)
|
contents.append(content)
|
||||||
|
|
||||||
assert len(subtitles) == len(contents) + 1 and subtitles[-1] == "Citation"
|
if not (len(subtitles) == len(contents) + 1 and subtitles[-1] == "Citation"):
|
||||||
|
raise AssertionError(
|
||||||
|
f"Expected len(contents)+1 == len(subtitles) and last subtitle == 'Citation', "
|
||||||
|
f"got len(subtitles)={len(subtitles)}, len(contents)={len(contents)}, "
|
||||||
|
f"last subtitle={subtitles[-1]!r}"
|
||||||
|
)
|
||||||
for i in range(len(subtitles) - 1):
|
for i in range(len(subtitles) - 1):
|
||||||
descriptions[subtitles[i]] = contents[i]
|
descriptions[subtitles[i]] = contents[i]
|
||||||
|
|
||||||
|
|||||||
@@ -307,7 +307,8 @@ class KGHypothesisGen(FactorAndModelHypothesisGen):
|
|||||||
class KGHypothesis2Experiment(FactorAndModelHypothesis2Experiment):
|
class KGHypothesis2Experiment(FactorAndModelHypothesis2Experiment):
|
||||||
def prepare_context(self, hypothesis: Hypothesis, trace: Trace) -> Tuple[dict, bool]:
|
def prepare_context(self, hypothesis: Hypothesis, trace: Trace) -> Tuple[dict, bool]:
|
||||||
scenario = trace.scen.get_scenario_all_desc(filtered_tag="hypothesis_and_experiment")
|
scenario = trace.scen.get_scenario_all_desc(filtered_tag="hypothesis_and_experiment")
|
||||||
assert isinstance(hypothesis, KGHypothesis)
|
if not isinstance(hypothesis, KGHypothesis):
|
||||||
|
raise TypeError("hypothesis must be an instance of KGHypothesis")
|
||||||
experiment_output_format = (
|
experiment_output_format = (
|
||||||
T("scenarios.kaggle.prompts:feature_experiment_output_format").r()
|
T("scenarios.kaggle.prompts:feature_experiment_output_format").r()
|
||||||
if hypothesis.action in [KG_ACTION_FEATURE_ENGINEERING, KG_ACTION_FEATURE_PROCESSING]
|
if hypothesis.action in [KG_ACTION_FEATURE_ENGINEERING, KG_ACTION_FEATURE_PROCESSING]
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Qlib Factor Runner - Executes factor backtests in Docker.
|
Qlib Factor Runner - Executes factor backtests in Docker.
|
||||||
|
|
||||||
@@ -9,15 +11,8 @@ NOTE: The @cache_with_pickle decorator was REMOVED from develop() because:
|
|||||||
- Docker-level caching (QlibDockerConf.enable_cache=False) is sufficient
|
- Docker-level caching (QlibDockerConf.enable_cache=False) is sufficient
|
||||||
- The pickle cache caused 240+ factor generations but ZERO Docker backtests
|
- The pickle cache caused 240+ factor generations but ZERO Docker backtests
|
||||||
"""
|
"""
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from pandarallel import pandarallel
|
|
||||||
|
|
||||||
|
|
||||||
pandarallel.initialize(verbose=1)
|
|
||||||
|
|
||||||
from rdagent.app.qlib_rd_loop.conf import FactorBasePropSetting
|
from rdagent.app.qlib_rd_loop.conf import FactorBasePropSetting
|
||||||
from rdagent.components.runner import CachedRunner
|
from rdagent.components.runner import CachedRunner
|
||||||
from rdagent.core.exception import FactorEmptyError
|
from rdagent.core.exception import FactorEmptyError
|
||||||
@@ -29,6 +24,83 @@ from rdagent.scenarios.qlib.experiment.model_experiment import QlibModelExperime
|
|||||||
DIRNAME = Path(__file__).absolute().resolve().parent
|
DIRNAME = 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
|
||||||
|
|
||||||
|
|
||||||
@@ -43,13 +115,13 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def calculate_information_coefficient(
|
def calculate_information_coefficient(
|
||||||
self, concat_feature: pd.DataFrame, SOTA_feature_column_size: int, new_feature_columns_size: int
|
self, concat_feature: pd.DataFrame, SOTA_feature_column_size: int, new_feature_columns_size: int,
|
||||||
) -> pd.DataFrame:
|
) -> pd.DataFrame:
|
||||||
res = pd.Series(index=range(SOTA_feature_column_size * new_feature_columns_size))
|
res = pd.Series(index=range(SOTA_feature_column_size * new_feature_columns_size))
|
||||||
for col1 in range(SOTA_feature_column_size):
|
for col1 in range(SOTA_feature_column_size):
|
||||||
for col2 in range(SOTA_feature_column_size, SOTA_feature_column_size + new_feature_columns_size):
|
for col2 in range(SOTA_feature_column_size, SOTA_feature_column_size + new_feature_columns_size):
|
||||||
res.loc[col1 * new_feature_columns_size + col2 - SOTA_feature_column_size] = concat_feature.iloc[
|
res.loc[col1 * new_feature_columns_size + col2 - SOTA_feature_column_size] = concat_feature.iloc[
|
||||||
:, col1
|
:, col1,
|
||||||
].corr(concat_feature.iloc[:, col2])
|
].corr(concat_feature.iloc[:, col2])
|
||||||
return res
|
return res
|
||||||
|
|
||||||
@@ -58,16 +130,21 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
# if the IC is larger than a threshold, remove the new_feature column
|
# if the IC is larger than a threshold, remove the new_feature column
|
||||||
# return the new_feature
|
# return the new_feature
|
||||||
|
|
||||||
|
from pandarallel import pandarallel
|
||||||
|
pandarallel.initialize(verbose=1)
|
||||||
|
|
||||||
concat_feature = pd.concat([SOTA_feature, new_feature], axis=1)
|
concat_feature = pd.concat([SOTA_feature, new_feature], axis=1)
|
||||||
IC_max = (
|
IC_max = (
|
||||||
concat_feature.groupby("datetime")
|
concat_feature.groupby("datetime")
|
||||||
.parallel_apply(
|
.parallel_apply(
|
||||||
lambda x: self.calculate_information_coefficient(x, SOTA_feature.shape[1], new_feature.shape[1])
|
lambda x: self.calculate_information_coefficient(x, SOTA_feature.shape[1], new_feature.shape[1]),
|
||||||
)
|
)
|
||||||
.mean()
|
.mean()
|
||||||
)
|
)
|
||||||
IC_max.index = pd.MultiIndex.from_product([range(SOTA_feature.shape[1]), range(new_feature.shape[1])])
|
IC_max.index = pd.MultiIndex.from_product([range(SOTA_feature.shape[1]), range(new_feature.shape[1])])
|
||||||
IC_max = IC_max.unstack().max(axis=0)
|
IC_max = IC_max.unstack().max(axis=0)
|
||||||
|
if not hasattr(IC_max, "index"):
|
||||||
|
return new_feature
|
||||||
return new_feature.iloc[:, IC_max[IC_max < 0.99].index]
|
return new_feature.iloc[:, IC_max[IC_max < 0.99].index]
|
||||||
|
|
||||||
def develop(self, exp: QlibFactorExperiment) -> QlibFactorExperiment:
|
def develop(self, exp: QlibFactorExperiment) -> QlibFactorExperiment:
|
||||||
@@ -82,7 +159,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
self._ensure_results_dirs()
|
self._ensure_results_dirs()
|
||||||
|
|
||||||
if exp.based_experiments and exp.based_experiments[-1].result is None:
|
if exp.based_experiments and exp.based_experiments[-1].result is None:
|
||||||
logger.info(f"Baseline experiment execution ...")
|
logger.info("Baseline experiment execution ...")
|
||||||
exp.based_experiments[-1] = self.develop(exp.based_experiments[-1])
|
exp.based_experiments[-1] = self.develop(exp.based_experiments[-1])
|
||||||
|
|
||||||
fbps = FactorBasePropSetting()
|
fbps = FactorBasePropSetting()
|
||||||
@@ -106,11 +183,11 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
base_exp for base_exp in exp.based_experiments if isinstance(base_exp, QlibFactorExperiment)
|
base_exp for base_exp in exp.based_experiments if isinstance(base_exp, QlibFactorExperiment)
|
||||||
]
|
]
|
||||||
if len(sota_factor_experiments_list) > 1:
|
if len(sota_factor_experiments_list) > 1:
|
||||||
logger.info(f"SOTA factor processing ...")
|
logger.info("SOTA factor processing ...")
|
||||||
SOTA_factor = process_factor_data(sota_factor_experiments_list)
|
SOTA_factor = process_factor_data(sota_factor_experiments_list)
|
||||||
|
|
||||||
# Process the new factors data
|
# Process the new factors data
|
||||||
logger.info(f"New factor processing ...")
|
logger.info("New factor processing ...")
|
||||||
new_factors = process_factor_data(exp)
|
new_factors = process_factor_data(exp)
|
||||||
|
|
||||||
if new_factors.empty:
|
if new_factors.empty:
|
||||||
@@ -121,7 +198,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
new_factors = self.deduplicate_new_factors(SOTA_factor, new_factors)
|
new_factors = self.deduplicate_new_factors(SOTA_factor, new_factors)
|
||||||
if new_factors.empty:
|
if new_factors.empty:
|
||||||
raise FactorEmptyError(
|
raise FactorEmptyError(
|
||||||
"The factors generated in this round are highly similar to the previous factors. Please change the direction for creating new factors."
|
"The factors generated in this round are highly similar to the previous factors. Please change the direction for creating new factors.",
|
||||||
)
|
)
|
||||||
combined_factors = pd.concat([SOTA_factor, new_factors], axis=1).dropna()
|
combined_factors = pd.concat([SOTA_factor, new_factors], axis=1).dropna()
|
||||||
else:
|
else:
|
||||||
@@ -132,7 +209,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
combined_factors = combined_factors.loc[:, ~combined_factors.columns.duplicated(keep="last")]
|
combined_factors = combined_factors.loc[:, ~combined_factors.columns.duplicated(keep="last")]
|
||||||
new_columns = pd.MultiIndex.from_product([["feature"], combined_factors.columns])
|
new_columns = pd.MultiIndex.from_product([["feature"], combined_factors.columns])
|
||||||
combined_factors.columns = new_columns
|
combined_factors.columns = new_columns
|
||||||
logger.info(f"Factor data processing completed.")
|
logger.info("Factor data processing completed.")
|
||||||
|
|
||||||
num_features = len(exp.base_features) + len(combined_factors.columns)
|
num_features = len(exp.base_features) + len(combined_factors.columns)
|
||||||
|
|
||||||
@@ -151,10 +228,10 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
sota_model_exp = base_exp
|
sota_model_exp = base_exp
|
||||||
exist_sota_model_exp = True
|
exist_sota_model_exp = True
|
||||||
break
|
break
|
||||||
logger.info(f"Experiment execution ...")
|
logger.info("Experiment execution ...")
|
||||||
if exist_sota_model_exp:
|
if exist_sota_model_exp:
|
||||||
exp.experiment_workspace.inject_files(
|
exp.experiment_workspace.inject_files(
|
||||||
**{"model.py": sota_model_exp.sub_workspace_list[0].file_dict["model.py"]}
|
**{"model.py": sota_model_exp.sub_workspace_list[0].file_dict["model.py"]},
|
||||||
)
|
)
|
||||||
sota_training_hyperparameters = sota_model_exp.sub_tasks[0].training_hyperparameters
|
sota_training_hyperparameters = sota_model_exp.sub_tasks[0].training_hyperparameters
|
||||||
if sota_training_hyperparameters:
|
if sota_training_hyperparameters:
|
||||||
@@ -165,19 +242,19 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
"early_stop": str(sota_training_hyperparameters.get("early_stop", 10)),
|
"early_stop": str(sota_training_hyperparameters.get("early_stop", 10)),
|
||||||
"batch_size": str(sota_training_hyperparameters.get("batch_size", 256)),
|
"batch_size": str(sota_training_hyperparameters.get("batch_size", 256)),
|
||||||
"weight_decay": str(sota_training_hyperparameters.get("weight_decay", 0.0001)),
|
"weight_decay": str(sota_training_hyperparameters.get("weight_decay", 0.0001)),
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
sota_model_type = sota_model_exp.sub_tasks[0].model_type
|
sota_model_type = sota_model_exp.sub_tasks[0].model_type
|
||||||
if sota_model_type == "TimeSeries":
|
if sota_model_type == "TimeSeries":
|
||||||
env_to_use.update(
|
env_to_use.update(
|
||||||
{"dataset_cls": "TSDatasetH", "num_features": num_features, "step_len": 20, "num_timesteps": 20}
|
{"dataset_cls": "TSDatasetH", "num_features": num_features, "step_len": 20, "num_timesteps": 20},
|
||||||
)
|
)
|
||||||
elif sota_model_type == "Tabular":
|
elif sota_model_type == "Tabular":
|
||||||
env_to_use.update({"dataset_cls": "DatasetH", "num_features": num_features})
|
env_to_use.update({"dataset_cls": "DatasetH", "num_features": num_features})
|
||||||
|
|
||||||
# model + combined factors
|
# model + combined factors
|
||||||
result, stdout = exp.experiment_workspace.execute(
|
result, stdout = exp.experiment_workspace.execute(
|
||||||
qlib_config_name="conf_combined_factors_sota_model.yaml", run_env=env_to_use
|
qlib_config_name="conf_combined_factors_sota_model.yaml", run_env=env_to_use,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# LGBM + combined factors
|
# LGBM + combined factors
|
||||||
@@ -186,7 +263,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
run_env=env_to_use,
|
run_env=env_to_use,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.info(f"Experiment execution ...")
|
logger.info("Experiment execution ...")
|
||||||
if exp.base_feature_codes:
|
if exp.base_feature_codes:
|
||||||
factors = process_factor_data(exp)
|
factors = process_factor_data(exp)
|
||||||
factors = factors.sort_index()
|
factors = factors.sort_index()
|
||||||
@@ -196,7 +273,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
target_path = exp.experiment_workspace.workspace_path / "combined_factors_df.parquet"
|
target_path = exp.experiment_workspace.workspace_path / "combined_factors_df.parquet"
|
||||||
# Save the combined factors to the workspace
|
# Save the combined factors to the workspace
|
||||||
factors.to_parquet(target_path, engine="pyarrow")
|
factors.to_parquet(target_path, engine="pyarrow")
|
||||||
logger.info(f"Factor data processing completed.")
|
logger.info("Factor data processing completed.")
|
||||||
result, stdout = exp.experiment_workspace.execute(
|
result, stdout = exp.experiment_workspace.execute(
|
||||||
qlib_config_name="conf_combined_factors.yaml",
|
qlib_config_name="conf_combined_factors.yaml",
|
||||||
run_env=env_to_use,
|
run_env=env_to_use,
|
||||||
@@ -209,10 +286,10 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
|
|
||||||
# Handle Qlib Docker backtest failure gracefully
|
# Handle Qlib Docker backtest failure gracefully
|
||||||
if result is None:
|
if result is None:
|
||||||
factor_name = getattr(exp.hypothesis, 'hypothesis', 'unknown')
|
factor_name = getattr(exp.hypothesis, "hypothesis", "unknown")
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Qlib Docker backtest returned None for '{factor_name}'. "
|
f"Qlib Docker backtest returned None for '{factor_name}'. "
|
||||||
f"Attempting direct factor evaluation..."
|
f"Attempting direct factor evaluation...",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Try to compute metrics directly from the factor's result.h5
|
# Try to compute metrics directly from the factor's result.h5
|
||||||
@@ -224,7 +301,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
else:
|
else:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Both Qlib Docker backtest and direct evaluation failed for '{factor_name}'. "
|
f"Both Qlib Docker backtest and direct evaluation failed for '{factor_name}'. "
|
||||||
f"Skipping this factor and continuing."
|
f"Skipping this factor and continuing.",
|
||||||
)
|
)
|
||||||
# Save failed run info for debugging
|
# Save failed run info for debugging
|
||||||
self._save_failed_run(exp, stdout, error_type="result_none")
|
self._save_failed_run(exp, stdout, error_type="result_none")
|
||||||
@@ -242,7 +319,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
if validation_result.get("has_issues"):
|
if validation_result.get("has_issues"):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Result validation warnings for factor '{getattr(exp.hypothesis, 'hypothesis', 'unknown')}': "
|
f"Result validation warnings for factor '{getattr(exp.hypothesis, 'hypothesis', 'unknown')}': "
|
||||||
f"{validation_result['warnings']}"
|
f"{validation_result['warnings']}",
|
||||||
)
|
)
|
||||||
# Save warning info for debugging
|
# Save warning info for debugging
|
||||||
self._save_failed_run(exp, stdout, error_type="validation_warnings", validation=validation_result)
|
self._save_failed_run(exp, stdout, error_type="validation_warnings", validation=validation_result)
|
||||||
@@ -293,43 +370,43 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
details = {}
|
details = {}
|
||||||
|
|
||||||
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 isinstance(result, pd.Series):
|
if isinstance(result, pd.Series):
|
||||||
# Check IC
|
# Check IC
|
||||||
ic_value = result.get('IC', None)
|
ic_value = result.get("IC", None)
|
||||||
details['ic_raw'] = ic_value
|
details["ic_raw"] = ic_value
|
||||||
if ic_value is None or (isinstance(ic_value, float) and (ic_value != ic_value)): # NaN check
|
if ic_value is None or (isinstance(ic_value, float) and (ic_value != ic_value)): # NaN check
|
||||||
warnings.append("IC is None/NaN — factor has no predictive power")
|
warnings.append("IC is None/NaN — factor has no predictive power")
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
ic_float = float(ic_value)
|
ic_float = float(ic_value)
|
||||||
details['ic'] = ic_float
|
details["ic"] = ic_float
|
||||||
if abs(ic_float) < 0.001:
|
if abs(ic_float) < 0.001:
|
||||||
warnings.append(
|
warnings.append(
|
||||||
f"IC is near zero ({ic_float:.6f}) — factor may not predict returns"
|
f"IC is near zero ({ic_float:.6f}) — factor may not predict returns",
|
||||||
)
|
)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
warnings.append(f"IC value is not numeric: {ic_value}")
|
warnings.append(f"IC value is not numeric: {ic_value}")
|
||||||
|
|
||||||
# Check positions (1day.pos)
|
# Check positions (1day.pos)
|
||||||
pos_value = result.get('1day.pos', None)
|
pos_value = result.get("1day.pos", None)
|
||||||
details['positions_raw'] = pos_value
|
details["positions_raw"] = pos_value
|
||||||
if pos_value is not None:
|
if pos_value is not None:
|
||||||
try:
|
try:
|
||||||
pos_float = float(pos_value)
|
pos_float = float(pos_value)
|
||||||
details['positions'] = pos_float
|
details["positions"] = pos_float
|
||||||
if pos_float == 0:
|
if pos_float == 0:
|
||||||
warnings.append(
|
warnings.append(
|
||||||
"1day.pos == 0 — model opened ZERO positions (stayed neutral). "
|
"1day.pos == 0 — model opened ZERO positions (stayed neutral). "
|
||||||
"Possible causes: (1) topk too high for single-asset, "
|
"Possible causes: (1) topk too high for single-asset, "
|
||||||
"(2) signal threshold too restrictive, (3) no valid predictions"
|
"(2) signal threshold too restrictive, (3) no valid predictions",
|
||||||
)
|
)
|
||||||
elif pos_float < 10:
|
elif pos_float < 10:
|
||||||
warnings.append(
|
warnings.append(
|
||||||
f"1day.pos = {pos_float:.0f} — very few positions opened. "
|
f"1day.pos = {pos_float:.0f} — very few positions opened. "
|
||||||
f"Check signal threshold and topk settings"
|
f"Check signal threshold and topk settings",
|
||||||
)
|
)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass # pos might be a string
|
pass # pos might be a string
|
||||||
@@ -337,24 +414,24 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
# Check if result is essentially empty (all values None or NaN)
|
# Check if result is essentially empty (all values None or NaN)
|
||||||
non_null_count = result.notna().sum()
|
non_null_count = result.notna().sum()
|
||||||
total_count = len(result)
|
total_count = len(result)
|
||||||
details['non_null_metrics'] = int(non_null_count)
|
details["non_null_metrics"] = int(non_null_count)
|
||||||
details['total_metrics'] = int(total_count)
|
details["total_metrics"] = int(total_count)
|
||||||
if non_null_count < 3:
|
if non_null_count < 3:
|
||||||
warnings.append(
|
warnings.append(
|
||||||
f"Result has only {non_null_count}/{total_count} non-null metrics — "
|
f"Result has only {non_null_count}/{total_count} non-null metrics — "
|
||||||
f"backtest likely produced empty results"
|
f"backtest likely produced empty results",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check for key metrics
|
# Check for key metrics
|
||||||
required_metrics = ['IC', '1day.excess_return_with_cost.shar', '1day.pos']
|
required_metrics = ["IC", "1day.excess_return_with_cost.shar", "1day.pos"]
|
||||||
for metric_name in required_metrics:
|
for metric_name in required_metrics:
|
||||||
val = result.get(metric_name, None)
|
val = result.get(metric_name, None)
|
||||||
details[f'has_{metric_name}'] = val is not None
|
details[f"has_{metric_name}"] = val is not None
|
||||||
|
|
||||||
elif isinstance(result, dict):
|
elif isinstance(result, dict):
|
||||||
# Dict-based result validation
|
# Dict-based result validation
|
||||||
ic_value = result.get('IC', result.get('ic', None))
|
ic_value = result.get("IC", result.get("ic", None))
|
||||||
details['ic_raw'] = ic_value
|
details["ic_raw"] = ic_value
|
||||||
if ic_value is None:
|
if ic_value is None:
|
||||||
warnings.append("IC is None — factor has no predictive power")
|
warnings.append("IC is None — factor has no predictive power")
|
||||||
|
|
||||||
@@ -364,7 +441,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
"details": details,
|
"details": details,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _evaluate_factor_directly(self, exp, stdout: str) -> Optional[pd.Series]:
|
def _evaluate_factor_directly(self, exp, stdout: str) -> pd.Series | None:
|
||||||
"""
|
"""
|
||||||
Evaluate factor directly from its result.h5 file when Qlib Docker fails.
|
Evaluate factor directly from its result.h5 file when Qlib Docker fails.
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -483,7 +577,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Direct evaluation: IC={ic:.6f}, Sharpe={sharpe:.4f}, "
|
f"Direct evaluation: IC={ic:.6f}, Sharpe={sharpe:.4f}, "
|
||||||
f"AnnRet={annualized_return:.4f}%, WR={win_rate:.2%}"
|
f"AnnRet={annualized_return:.4f}%, WR={win_rate:.2%}",
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -492,7 +586,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _save_failed_run(self, exp, stdout: str, error_type: str = "unknown",
|
def _save_failed_run(self, exp, stdout: str, error_type: str = "unknown",
|
||||||
validation: Optional[dict] = None) -> None:
|
validation: dict | None = None) -> None:
|
||||||
"""
|
"""
|
||||||
Save failed run information to results/failed_runs.json for debugging.
|
Save failed run information to results/failed_runs.json for debugging.
|
||||||
|
|
||||||
@@ -519,20 +613,20 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
|
|
||||||
# Get factor name
|
# Get factor name
|
||||||
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")
|
||||||
|
|
||||||
# Build failed run record
|
# Build failed run record
|
||||||
failed_record = {
|
failed_record = {
|
||||||
"timestamp": datetime.now().isoformat(),
|
"timestamp": datetime.now().isoformat(),
|
||||||
"factor_name": factor_name,
|
"factor_name": factor_name,
|
||||||
"error_type": error_type,
|
"error_type": error_type,
|
||||||
"stdout": stdout if stdout else "(empty)",
|
"stdout": stdout or "(empty)",
|
||||||
"validation": validation,
|
"validation": validation,
|
||||||
"experiment_details": {
|
"experiment_details": {
|
||||||
"base_features": list(getattr(exp, 'base_features', {}).keys()) if hasattr(exp, 'base_features') else [],
|
"base_features": list(getattr(exp, "base_features", {}).keys()) if hasattr(exp, "base_features") else [],
|
||||||
"hypothesis": getattr(exp.hypothesis, 'hypothesis', str(getattr(exp, 'hypothesis', 'N/A')))
|
"hypothesis": getattr(exp.hypothesis, "hypothesis", str(getattr(exp, "hypothesis", "N/A")))
|
||||||
if hasattr(exp, 'hypothesis') else "N/A",
|
if hasattr(exp, "hypothesis") else "N/A",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,11 +649,11 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
|
|
||||||
failed_file.write_text(
|
failed_file.write_text(
|
||||||
json.dumps(existing_records, indent=2, default=str, ensure_ascii=False),
|
json.dumps(existing_records, indent=2, default=str, ensure_ascii=False),
|
||||||
encoding="utf-8"
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Failed run saved: {factor_name} (type={error_type}) "
|
f"Failed run saved: {factor_name} (type={error_type}) "
|
||||||
f"→ {failed_file}"
|
f"→ {failed_file}",
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -582,21 +676,23 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
containing metric names like 'IC', '1day.excess_return_with_cost.shar', etc.
|
containing metric names like 'IC', '1day.excess_return_with_cost.shar', etc.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
import json
|
|
||||||
import pandas as pd
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
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):
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Factor rejected by protection, skipping DB save: "
|
f"Factor rejected by protection, skipping DB save: "
|
||||||
f"{getattr(exp, 'protection_reason', 'unknown')}"
|
f"{getattr(exp, 'protection_reason', 'unknown')}",
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -612,47 +708,47 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
# Extract metrics from result (pd.Series from qlib_res.csv)
|
# Extract metrics from result (pd.Series from qlib_res.csv)
|
||||||
metrics = {}
|
metrics = {}
|
||||||
if isinstance(result, pd.Series):
|
if isinstance(result, pd.Series):
|
||||||
metrics['ic'] = self._safe_float(result.get('IC', None))
|
metrics["ic"] = self._safe_float(result.get("IC", None))
|
||||||
metrics['sharpe_ratio'] = self._safe_float(
|
metrics["sharpe_ratio"] = self._safe_float(
|
||||||
result.get('1day.excess_return_with_cost.shar',
|
result.get("1day.excess_return_with_cost.shar",
|
||||||
result.get('1day.excess_return_with_cost.sharpe', None))
|
result.get("1day.excess_return_with_cost.sharpe", None)),
|
||||||
)
|
)
|
||||||
metrics['annualized_return'] = self._safe_float(
|
metrics["annualized_return"] = self._safe_float(
|
||||||
result.get('1day.excess_return_with_cost.annualized_return', None)
|
result.get("1day.excess_return_with_cost.annualized_return", None),
|
||||||
)
|
)
|
||||||
metrics['max_drawdown'] = self._safe_float(
|
metrics["max_drawdown"] = self._safe_float(
|
||||||
result.get('1day.excess_return_with_cost.max_drawdown', None)
|
result.get("1day.excess_return_with_cost.max_drawdown", None),
|
||||||
)
|
)
|
||||||
metrics['win_rate'] = self._safe_float(result.get('win_rate', None))
|
metrics["win_rate"] = self._safe_float(result.get("win_rate", None))
|
||||||
metrics['information_ratio'] = self._safe_float(
|
metrics["information_ratio"] = self._safe_float(
|
||||||
result.get('1day.excess_return_with_cost.information_ratio', None)
|
result.get("1day.excess_return_with_cost.information_ratio", None),
|
||||||
)
|
)
|
||||||
metrics['volatility'] = self._safe_float(
|
metrics["volatility"] = self._safe_float(
|
||||||
result.get('1day.excess_return_with_cost.std',
|
result.get("1day.excess_return_with_cost.std",
|
||||||
result.get('1day.excess_return_with_cost.volatility', None))
|
result.get("1day.excess_return_with_cost.volatility", None)),
|
||||||
)
|
)
|
||||||
# Store raw metrics for JSON export
|
# Store raw metrics for JSON export
|
||||||
metrics['raw_metrics'] = result.to_dict()
|
metrics["raw_metrics"] = result.to_dict()
|
||||||
elif isinstance(result, dict):
|
elif isinstance(result, dict):
|
||||||
metrics['ic'] = self._safe_float(result.get('IC', result.get('ic', None)))
|
metrics["ic"] = self._safe_float(result.get("IC", result.get("ic", None)))
|
||||||
metrics['sharpe_ratio'] = self._safe_float(
|
metrics["sharpe_ratio"] = self._safe_float(
|
||||||
result.get('sharpe', result.get('sharpe_ratio', None))
|
result.get("sharpe", result.get("sharpe_ratio", None)),
|
||||||
)
|
)
|
||||||
metrics['annualized_return'] = self._safe_float(result.get('annualized_return', None))
|
metrics["annualized_return"] = self._safe_float(result.get("annualized_return", None))
|
||||||
metrics['max_drawdown'] = self._safe_float(result.get('max_drawdown', None))
|
metrics["max_drawdown"] = self._safe_float(result.get("max_drawdown", None))
|
||||||
metrics['win_rate'] = self._safe_float(result.get('win_rate', None))
|
metrics["win_rate"] = self._safe_float(result.get("win_rate", None))
|
||||||
metrics['information_ratio'] = None
|
metrics["information_ratio"] = None
|
||||||
metrics['volatility'] = None
|
metrics["volatility"] = None
|
||||||
metrics['raw_metrics'] = result
|
metrics["raw_metrics"] = result
|
||||||
|
|
||||||
# Result validation before saving (warnings, not blocking)
|
# Result validation before saving (warnings, not blocking)
|
||||||
self._log_result_warnings(factor_name, result, metrics)
|
self._log_result_warnings(factor_name, result, metrics)
|
||||||
|
|
||||||
# Only save if we have at least IC or Sharpe
|
# Only save if we have at least IC or Sharpe
|
||||||
if metrics.get('ic') is None and metrics.get('sharpe_ratio') is None:
|
if metrics.get("ic") is None and metrics.get("sharpe_ratio") is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"No valid IC/Sharpe for factor '{factor_name}', skipping DB save. "
|
f"No valid IC/Sharpe for factor '{factor_name}', skipping DB save. "
|
||||||
f"IC={metrics.get('ic')}, Sharpe={metrics.get('sharpe_ratio')}"
|
f"IC={metrics.get('ic')}, Sharpe={metrics.get('sharpe_ratio')}",
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -663,19 +759,19 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
db_file = db_path / "backtest_results.db"
|
db_file = db_path / "backtest_results.db"
|
||||||
|
|
||||||
# Parallel run isolation: use run-specific subdirectory if PARALLEL_RUN_ID is set
|
# Parallel run isolation: use run-specific subdirectory if PARALLEL_RUN_ID is set
|
||||||
run_id = os.getenv("PARALLEL_RUN_ID", "0")
|
parallel_run_id = os.getenv("PARALLEL_RUN_ID", "0")
|
||||||
if run_id != "0":
|
if parallel_run_id != "0":
|
||||||
# For parallel runs, save to isolated results directory
|
# For parallel runs, save to isolated results directory
|
||||||
isolated_db_path = project_root / "results" / "runs" / f"run{run_id}" / "db"
|
isolated_db_path = project_root / "results" / "runs" / f"run{parallel_run_id}" / "db"
|
||||||
isolated_db_path.mkdir(parents=True, exist_ok=True)
|
isolated_db_path.mkdir(parents=True, exist_ok=True)
|
||||||
db_file = isolated_db_path / "backtest_results.db"
|
db_file = isolated_db_path / "backtest_results.db"
|
||||||
|
|
||||||
# Save to database
|
# Save to database
|
||||||
db = ResultsDatabase(db_path=str(db_file))
|
db = ResultsDatabase(db_path=str(db_file))
|
||||||
run_id = db.add_backtest(factor_name=factor_name[:100], metrics=metrics)
|
db_run_id = db.add_backtest(factor_name=factor_name[:100], metrics=metrics)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Factor result saved to DB: {factor_name[:60]} "
|
f"Factor result saved to DB: {factor_name[:60]} "
|
||||||
f"(IC={metrics.get('ic')}, Sharpe={metrics.get('sharpe_ratio')}, run_id={run_id})"
|
f"(IC={metrics.get('ic')}, Sharpe={metrics.get('sharpe_ratio')}, run_id={db_run_id})"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Extract factor code and description from experiment
|
# Extract factor code and description from experiment
|
||||||
@@ -683,10 +779,10 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
|
|
||||||
# Also write a JSON summary to results/factors/ for file-based access
|
# Also write a JSON summary to results/factors/ for file-based access
|
||||||
self._save_factor_json(
|
self._save_factor_json(
|
||||||
factor_name, metrics, run_id,
|
factor_name, metrics, db_run_id,
|
||||||
factor_code=factor_code,
|
factor_code=factor_code,
|
||||||
factor_description=factor_description,
|
factor_description=factor_description,
|
||||||
exp=exp
|
exp=exp,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save factor values as parquet for strategy building
|
# Save factor values as parquet for strategy building
|
||||||
@@ -698,7 +794,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
import traceback
|
import traceback
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Database save failed for factor '{getattr(exp.hypothesis, 'hypothesis', 'unknown')}': {e}\n"
|
f"Database save failed for factor '{getattr(exp.hypothesis, 'hypothesis', 'unknown')}': {e}\n"
|
||||||
f"Traceback: {traceback.format_exc()}"
|
f"Traceback: {traceback.format_exc()}",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _save_factor_json(self, factor_name: str, metrics: dict, run_id: int,
|
def _save_factor_json(self, factor_name: str, metrics: dict, run_id: int,
|
||||||
@@ -809,14 +905,14 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
factor_description = match.group(1).strip()[:500]
|
factor_description = match.group(1).strip()[:500]
|
||||||
else:
|
else:
|
||||||
# Try comments
|
# Try comments
|
||||||
lines = factor_code.split('\n')
|
lines = factor_code.split("\n")
|
||||||
desc_lines = []
|
desc_lines = []
|
||||||
for line in lines[:20]:
|
for line in lines[:20]:
|
||||||
stripped = line.strip()
|
stripped = line.strip()
|
||||||
if stripped.startswith('#') and not stripped.startswith('#!'):
|
if stripped.startswith("#") and not stripped.startswith("#!"):
|
||||||
desc_lines.append(stripped[1:].strip())
|
desc_lines.append(stripped[1:].strip())
|
||||||
if desc_lines:
|
if desc_lines:
|
||||||
factor_description = ' '.join(desc_lines)[:500]
|
factor_description = " ".join(desc_lines)[:500]
|
||||||
|
|
||||||
return factor_code, factor_description
|
return factor_code, factor_description
|
||||||
|
|
||||||
@@ -824,41 +920,80 @@ 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 shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
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(
|
||||||
|
[sys.executable, "factor.py"],
|
||||||
|
cwd=str(tmp),
|
||||||
|
capture_output=True,
|
||||||
|
timeout=300,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if ret.returncode != 0:
|
||||||
|
logger.warning(
|
||||||
|
f"Full-data factor run failed (exit {ret.returncode}): "
|
||||||
|
f"{ret.stderr[:500] if ret.stderr else '(no stderr)'}"
|
||||||
|
)
|
||||||
|
# Fall back to debug-data result if full-data run fails
|
||||||
|
result_h5 = workspace_path / "result.h5"
|
||||||
|
if not result_h5.exists():
|
||||||
|
return
|
||||||
|
df = pd.read_hdf(str(result_h5), key="data")
|
||||||
|
else:
|
||||||
|
result_h5_full = tmp / "result.h5"
|
||||||
|
if not result_h5_full.exists():
|
||||||
|
return
|
||||||
|
df = pd.read_hdf(str(result_h5_full), key="data")
|
||||||
|
|
||||||
if df is None or df.empty:
|
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,17 +1001,12 @@ 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))
|
logging.debug("Error in save_factor_values_to_parquet", exc_info=True)
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
# Don't let factor value saving break the main workflow
|
|
||||||
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:
|
||||||
"""
|
"""
|
||||||
@@ -897,7 +1027,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
warnings_list = []
|
warnings_list = []
|
||||||
|
|
||||||
# Check IC
|
# Check IC
|
||||||
ic = metrics.get('ic')
|
ic = metrics.get("ic")
|
||||||
if ic is None:
|
if ic is None:
|
||||||
warnings_list.append("IC is None — factor has no predictive power")
|
warnings_list.append("IC is None — factor has no predictive power")
|
||||||
elif abs(ic) < 0.001:
|
elif abs(ic) < 0.001:
|
||||||
@@ -905,7 +1035,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
|
|
||||||
# Check positions (1day.pos) — CRITICAL for EURUSD
|
# Check positions (1day.pos) — CRITICAL for EURUSD
|
||||||
if isinstance(result, pd.Series):
|
if isinstance(result, pd.Series):
|
||||||
pos_value = result.get('1day.pos', None)
|
pos_value = result.get("1day.pos", None)
|
||||||
if pos_value is not None:
|
if pos_value is not None:
|
||||||
try:
|
try:
|
||||||
pos_float = float(pos_value)
|
pos_float = float(pos_value)
|
||||||
@@ -913,23 +1043,23 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
warnings_list.append(
|
warnings_list.append(
|
||||||
"WARNING: 1day.pos == 0 — ZERO positions opened! "
|
"WARNING: 1day.pos == 0 — ZERO positions opened! "
|
||||||
"Model stayed completely neutral. Check Qlib config: "
|
"Model stayed completely neutral. Check Qlib config: "
|
||||||
"ensure topk=1 and market=eurusd for single-asset trading."
|
"ensure topk=1 and market=eurusd for single-asset trading.",
|
||||||
)
|
)
|
||||||
elif pos_float < 10:
|
elif pos_float < 10:
|
||||||
warnings_list.append(
|
warnings_list.append(
|
||||||
f"Low position count: 1day.pos = {pos_float:.0f} — "
|
f"Low position count: 1day.pos = {pos_float:.0f} — "
|
||||||
f"model traded very rarely"
|
f"model traded very rarely",
|
||||||
)
|
)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Check Sharpe
|
# Check Sharpe
|
||||||
sharpe = metrics.get('sharpe_ratio')
|
sharpe = metrics.get("sharpe_ratio")
|
||||||
if sharpe is not None and abs(sharpe) < 0.1:
|
if sharpe is not None and abs(sharpe) < 0.1:
|
||||||
warnings_list.append(f"Sharpe near zero ({sharpe:.4f}) — no risk-adjusted edge")
|
warnings_list.append(f"Sharpe near zero ({sharpe:.4f}) — no risk-adjusted edge")
|
||||||
|
|
||||||
# Check max drawdown
|
# Check max drawdown
|
||||||
mdd = metrics.get('max_drawdown')
|
mdd = metrics.get("max_drawdown")
|
||||||
if mdd is not None and mdd < -0.5:
|
if mdd is not None and mdd < -0.5:
|
||||||
warnings_list.append(f"Extreme drawdown: {mdd:.2%} — high risk factor")
|
warnings_list.append(f"Extreme drawdown: {mdd:.2%} — high risk factor")
|
||||||
|
|
||||||
@@ -944,7 +1074,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
f = float(value)
|
f = float(value)
|
||||||
if pd.isna(f) or f == float('inf') or f == float('-inf'):
|
if pd.isna(f) or f == float("inf") or f == float("-inf"):
|
||||||
return None
|
return None
|
||||||
return f
|
return f
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
@@ -987,7 +1117,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
|
|
||||||
if protection_result.should_block:
|
if protection_result.should_block:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Factor {factor_name} rejected by protection manager: {protection_result.reason}"
|
f"Factor {factor_name} rejected by protection manager: {protection_result.reason}",
|
||||||
)
|
)
|
||||||
# Mark factor as rejected by protection
|
# Mark factor as rejected by protection
|
||||||
exp.rejected_by_protection = True
|
exp.rejected_by_protection = True
|
||||||
@@ -1012,8 +1142,8 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
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")
|
||||||
|
|
||||||
# Build log entry
|
# Build log entry
|
||||||
log_entry = {
|
log_entry = {
|
||||||
@@ -1025,42 +1155,42 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
"annualized_return": None,
|
"annualized_return": None,
|
||||||
"max_drawdown": None,
|
"max_drawdown": None,
|
||||||
"win_rate": None,
|
"win_rate": None,
|
||||||
"rejected_by_protection": getattr(exp, 'rejected_by_protection', False),
|
"rejected_by_protection": getattr(exp, "rejected_by_protection", False),
|
||||||
"protection_reason": getattr(exp, 'protection_reason', None),
|
"protection_reason": getattr(exp, "protection_reason", None),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Extract metrics if available
|
# Extract metrics if available
|
||||||
if result is not None:
|
if result is not None:
|
||||||
if hasattr(result, 'get'): # pd.Series or dict
|
if hasattr(result, "get"): # pd.Series or dict
|
||||||
ic_val = result.get('IC', result.get('ic', None))
|
ic_val = result.get("IC", result.get("ic", None))
|
||||||
log_entry['ic'] = self._safe_float(ic_val) if ic_val is not None else None
|
log_entry["ic"] = self._safe_float(ic_val) if ic_val is not None else None
|
||||||
|
|
||||||
sharpe_val = result.get('1day.excess_return_with_cost.shar',
|
sharpe_val = result.get("1day.excess_return_with_cost.shar",
|
||||||
result.get('1day.excess_return_with_cost.sharpe',
|
result.get("1day.excess_return_with_cost.sharpe",
|
||||||
result.get('sharpe', None)))
|
result.get("sharpe", None)))
|
||||||
log_entry['sharpe'] = self._safe_float(sharpe_val) if sharpe_val is not None else None
|
log_entry["sharpe"] = self._safe_float(sharpe_val) if sharpe_val is not None else None
|
||||||
|
|
||||||
ann_ret = result.get('1day.excess_return_with_cost.annualized_return',
|
ann_ret = result.get("1day.excess_return_with_cost.annualized_return",
|
||||||
result.get('annualized_return', None))
|
result.get("annualized_return", None))
|
||||||
log_entry['annualized_return'] = self._safe_float(ann_ret) if ann_ret is not None else None
|
log_entry["annualized_return"] = self._safe_float(ann_ret) if ann_ret is not None else None
|
||||||
|
|
||||||
mdd = result.get('1day.excess_return_with_cost.max_drawdown',
|
mdd = result.get("1day.excess_return_with_cost.max_drawdown",
|
||||||
result.get('max_drawdown', None))
|
result.get("max_drawdown", None))
|
||||||
log_entry['max_drawdown'] = self._safe_float(mdd) if mdd is not None else None
|
log_entry["max_drawdown"] = self._safe_float(mdd) if mdd is not None else None
|
||||||
|
|
||||||
wr = result.get('win_rate', None)
|
wr = result.get("win_rate", None)
|
||||||
log_entry['win_rate'] = self._safe_float(wr) if wr is not None else None
|
log_entry["win_rate"] = self._safe_float(wr) if wr is not None else None
|
||||||
|
|
||||||
# Determine status
|
# Determine status
|
||||||
if log_entry['ic'] is not None or log_entry['sharpe'] is not None:
|
if log_entry["ic"] is not None or log_entry["sharpe"] is not None:
|
||||||
log_entry['status'] = "success"
|
log_entry["status"] = "success"
|
||||||
elif getattr(exp, 'rejected_by_protection', False):
|
elif getattr(exp, "rejected_by_protection", False):
|
||||||
log_entry['status'] = "rejected_protection"
|
log_entry["status"] = "rejected_protection"
|
||||||
else:
|
else:
|
||||||
log_entry['status'] = "no_valid_metrics"
|
log_entry["status"] = "no_valid_metrics"
|
||||||
else:
|
else:
|
||||||
log_entry['status'] = "execution_failed"
|
log_entry["status"] = "execution_failed"
|
||||||
log_entry['reason'] = "Result was None"
|
log_entry["reason"] = "Result was None"
|
||||||
|
|
||||||
# Write to results/logs/
|
# Write to results/logs/
|
||||||
try:
|
try:
|
||||||
@@ -1083,7 +1213,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Run log written for '{factor_name[:50]}': "
|
f"Run log written for '{factor_name[:50]}': "
|
||||||
f"status={log_entry['status']}, IC={log_entry['ic']}, Sharpe={log_entry['sharpe']}"
|
f"status={log_entry['status']}, IC={log_entry['ic']}, Sharpe={log_entry['sharpe']}",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to write run log: {e}")
|
logger.error(f"Failed to write run log: {e}")
|
||||||
|
|||||||
@@ -203,12 +203,14 @@ class QlibModelRunner(CachedRunner[QlibModelExperiment]):
|
|||||||
|
|
||||||
# Save to database
|
# Save to database
|
||||||
db = ResultsDatabase()
|
db = ResultsDatabase()
|
||||||
run_id = db.add_backtest(factor_name=factor_name[:100], metrics=metrics)
|
try:
|
||||||
logger.info(
|
run_id = db.add_backtest(factor_name=factor_name[:100], metrics=metrics)
|
||||||
f"Model result saved to DB: {factor_name[:50]} "
|
logger.info(
|
||||||
f"(IC={metrics.get('ic')}, Sharpe={metrics.get('sharpe_ratio')}, run_id={run_id})"
|
f"Model result saved to DB: {factor_name[:50]} "
|
||||||
)
|
f"(IC={metrics.get('ic')}, Sharpe={metrics.get('sharpe_ratio')}, run_id={run_id})"
|
||||||
db.close()
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Database save failed for model {getattr(exp.hypothesis, 'hypothesis', 'unknown')}: {e}")
|
logger.warning(f"Database save failed for model {getattr(exp.hypothesis, 'hypothesis', 'unknown')}: {e}")
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ class StrategyBuilder:
|
|||||||
if data.get("status") == "success" and data.get("ic") is not None:
|
if data.get("status") == "success" and data.get("ic") is not None:
|
||||||
factors.append(data)
|
factors.append(data)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.warning("Failed to load factor file %s", f, exc_info=True)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Sort by absolute IC
|
# Sort by absolute IC
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ def _build_execute_calls(exp: QlibFactorExperiment, base_feature_workspaces: lis
|
|||||||
execute_calls = []
|
execute_calls = []
|
||||||
|
|
||||||
if exp.sub_tasks:
|
if exp.sub_tasks:
|
||||||
assert isinstance(exp.prop_dev_feedback, CoSTEERMultiFeedback)
|
if not isinstance(exp.prop_dev_feedback, CoSTEERMultiFeedback):
|
||||||
|
raise TypeError("exp.prop_dev_feedback must be of type CoSTEERMultiFeedback")
|
||||||
execute_calls.extend(
|
execute_calls.extend(
|
||||||
(implementation.execute, ("All",))
|
(implementation.execute, ("All",))
|
||||||
for implementation, feedback in zip(exp.sub_workspace_list, exp.prop_dev_feedback)
|
for implementation, feedback in zip(exp.sub_workspace_list, exp.prop_dev_feedback)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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'>
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ class QlibQuantScenario(Scenario):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def background(self, tag=None) -> str:
|
def background(self, tag=None) -> str:
|
||||||
assert tag in [None, "factor", "model"]
|
if tag not in [None, "factor", "model"]:
|
||||||
|
raise ValueError(f"tag must be None, 'factor', or 'model', got {tag!r}")
|
||||||
quant_background = "The background of the scenario is as follows:\n" + T(".prompts:qlib_quant_background").r(
|
quant_background = "The background of the scenario is as follows:\n" + T(".prompts:qlib_quant_background").r(
|
||||||
runtime_environment=self.get_runtime_environment(),
|
runtime_environment=self.get_runtime_environment(),
|
||||||
)
|
)
|
||||||
@@ -83,7 +84,8 @@ class QlibQuantScenario(Scenario):
|
|||||||
return self._source_data
|
return self._source_data
|
||||||
|
|
||||||
def output_format(self, tag=None) -> str:
|
def output_format(self, tag=None) -> str:
|
||||||
assert tag in [None, "factor", "model"]
|
if tag not in [None, "factor", "model"]:
|
||||||
|
raise ValueError(f"tag must be None, 'factor', or 'model', got {tag!r}")
|
||||||
factor_output_format = (
|
factor_output_format = (
|
||||||
"The factor code should output the following format:\n" + T(".prompts:qlib_factor_output_format").r()
|
"The factor code should output the following format:\n" + T(".prompts:qlib_factor_output_format").r()
|
||||||
)
|
)
|
||||||
@@ -99,7 +101,8 @@ class QlibQuantScenario(Scenario):
|
|||||||
return model_output_format
|
return model_output_format
|
||||||
|
|
||||||
def interface(self, tag=None) -> str:
|
def interface(self, tag=None) -> str:
|
||||||
assert tag in [None, "factor", "model"]
|
if tag not in [None, "factor", "model"]:
|
||||||
|
raise ValueError(f"tag must be None, 'factor', or 'model', got {tag!r}")
|
||||||
factor_interface = (
|
factor_interface = (
|
||||||
"The factor code should be written in the following interface:\n" + T(".prompts:qlib_factor_interface").r()
|
"The factor code should be written in the following interface:\n" + T(".prompts:qlib_factor_interface").r()
|
||||||
)
|
)
|
||||||
@@ -115,7 +118,8 @@ class QlibQuantScenario(Scenario):
|
|||||||
return model_interface
|
return model_interface
|
||||||
|
|
||||||
def simulator(self, tag=None) -> str:
|
def simulator(self, tag=None) -> str:
|
||||||
assert tag in [None, "factor", "model"]
|
if tag not in [None, "factor", "model"]:
|
||||||
|
raise ValueError(f"tag must be None, 'factor', or 'model', got {tag!r}")
|
||||||
factor_simulator = "The factor code will be sent to the simulator:\n" + T(".prompts:qlib_factor_simulator").r()
|
factor_simulator = "The factor code will be sent to the simulator:\n" + T(".prompts:qlib_factor_simulator").r()
|
||||||
model_simulator = "The model code will be sent to the simulator:\n" + T(".prompts:qlib_model_simulator").r()
|
model_simulator = "The model code will be sent to the simulator:\n" + T(".prompts:qlib_model_simulator").r()
|
||||||
|
|
||||||
@@ -185,7 +189,8 @@ class QlibQuantScenario(Scenario):
|
|||||||
return common_description(action) + interface(action) + output(action) + simulator(action)
|
return common_description(action) + interface(action) + output(action) + simulator(action)
|
||||||
|
|
||||||
def get_runtime_environment(self, tag: str = None) -> str:
|
def get_runtime_environment(self, tag: str = None) -> str:
|
||||||
assert tag in [None, "factor", "model"]
|
if tag not in [None, "factor", "model"]:
|
||||||
|
raise ValueError(f"tag must be None, 'factor', or 'model', got {tag!r}")
|
||||||
|
|
||||||
if tag is None or tag == "factor":
|
if tag is None or tag == "factor":
|
||||||
# Use factor env to get the runtime environment
|
# Use factor env to get the runtime environment
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import shutil
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from jinja2 import Environment, StrictUndefined
|
from jinja2 import Environment, StrictUndefined, select_autoescape
|
||||||
|
|
||||||
from rdagent.components.coder.factor_coder.config import FACTOR_COSTEER_SETTINGS
|
from rdagent.components.coder.factor_coder.config import FACTOR_COSTEER_SETTINGS
|
||||||
from rdagent.utils.env import QTDockerEnv
|
from rdagent.utils.env import QTDockerEnv
|
||||||
@@ -21,14 +21,16 @@ def generate_data_folder_from_qlib():
|
|||||||
entry=f"python generate.py",
|
entry=f"python generate.py",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert (Path(__file__).parent / "factor_data_template" / "intraday_pv_all.h5").exists(), (
|
if not (Path(__file__).parent / "factor_data_template" / "intraday_pv_all.h5").exists():
|
||||||
"intraday_pv_all.h5 is not generated. It means rdagent/scenarios/qlib/experiment/factor_data_template/generate.py is not executed correctly. Please check the log: \n"
|
raise FileNotFoundError(
|
||||||
+ execute_log
|
"intraday_pv_all.h5 is not generated. It means rdagent/scenarios/qlib/experiment/factor_data_template/generate.py is not executed correctly. Please check the log: \n"
|
||||||
)
|
+ execute_log
|
||||||
assert (Path(__file__).parent / "factor_data_template" / "intraday_pv_debug.h5").exists(), (
|
)
|
||||||
"intraday_pv_debug.h5 is not generated. It means rdagent/scenarios/qlib/experiment/factor_data_template/generate.py is not executed correctly. Please check the log: \n"
|
if not (Path(__file__).parent / "factor_data_template" / "intraday_pv_debug.h5").exists():
|
||||||
+ execute_log
|
raise FileNotFoundError(
|
||||||
)
|
"intraday_pv_debug.h5 is not generated. It means rdagent/scenarios/qlib/experiment/factor_data_template/generate.py is not executed correctly. Please check the log: \n"
|
||||||
|
+ execute_log
|
||||||
|
)
|
||||||
|
|
||||||
Path(FACTOR_COSTEER_SETTINGS.data_folder).mkdir(parents=True, exist_ok=True)
|
Path(FACTOR_COSTEER_SETTINGS.data_folder).mkdir(parents=True, exist_ok=True)
|
||||||
shutil.copy(
|
shutil.copy(
|
||||||
@@ -67,7 +69,7 @@ def get_file_desc(p: Path, variable_list=[]) -> str:
|
|||||||
"""
|
"""
|
||||||
p = Path(p)
|
p = Path(p)
|
||||||
|
|
||||||
JJ_TPL = Environment(undefined=StrictUndefined).from_string("""
|
JJ_TPL = Environment(undefined=StrictUndefined, autoescape=select_autoescape()).from_string("""
|
||||||
# {{file_name}}
|
# {{file_name}}
|
||||||
|
|
||||||
## File Type
|
## File Type
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import logging
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from typing import List, Tuple
|
from typing import List, Tuple
|
||||||
|
|
||||||
from rdagent.components.coder.factor_coder.factor import FactorExperiment, FactorTask
|
from rdagent.components.coder.factor_coder.factor import FactorExperiment, FactorTask
|
||||||
@@ -9,6 +11,47 @@ from rdagent.scenarios.qlib.experiment.model_experiment import QlibModelExperime
|
|||||||
from rdagent.scenarios.qlib.experiment.quant_experiment import QlibQuantScenario
|
from rdagent.scenarios.qlib.experiment.quant_experiment import QlibQuantScenario
|
||||||
from rdagent.utils.agent.tpl import T
|
from rdagent.utils.agent.tpl import T
|
||||||
|
|
||||||
|
|
||||||
|
def _build_compressed_history(trace: Trace, max_history: int) -> str:
|
||||||
|
"""Return hypothesis_and_feedback string with only `max_history` entries.
|
||||||
|
|
||||||
|
Older entries beyond the last 2 are compressed to one bullet line each.
|
||||||
|
"""
|
||||||
|
if len(trace.hist) == 0:
|
||||||
|
return "No previous hypothesis and feedback available since it's the first round."
|
||||||
|
|
||||||
|
FULL_DETAIL = 2
|
||||||
|
old_hist = trace.hist[:-FULL_DETAIL] if len(trace.hist) > FULL_DETAIL else []
|
||||||
|
recent_hist = trace.hist[-FULL_DETAIL:] if len(trace.hist) > FULL_DETAIL else trace.hist
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
if old_hist:
|
||||||
|
lines = ["## Earlier experiments (summarized):"]
|
||||||
|
for exp, fb in old_hist:
|
||||||
|
names = []
|
||||||
|
for task in exp.sub_tasks:
|
||||||
|
if task is not None and hasattr(task, "factor_name"):
|
||||||
|
names.append(task.factor_name)
|
||||||
|
elif task is not None and hasattr(task, "model_type"):
|
||||||
|
names.append(getattr(task, "model_type", "model"))
|
||||||
|
ic_str = ""
|
||||||
|
try:
|
||||||
|
if exp.result is not None and "IC" in exp.result.index:
|
||||||
|
ic_str = f" IC={exp.result.loc['IC']:.4f}"
|
||||||
|
except Exception:
|
||||||
|
logging.debug("Exception caught", exc_info=True)
|
||||||
|
decision = "PASS" if fb.decision else "FAIL"
|
||||||
|
obs = (fb.observations or "")[:120].replace("\n", " ")
|
||||||
|
lines.append(f"- [{decision}]{ic_str} {', '.join(names) or 'unknown'}: {obs}")
|
||||||
|
parts.append("\n".join(lines))
|
||||||
|
|
||||||
|
if recent_hist:
|
||||||
|
rt = Trace(trace.scen)
|
||||||
|
rt.hist = recent_hist
|
||||||
|
parts.append(T("scenarios.qlib.prompts:hypothesis_and_feedback").r(trace=rt))
|
||||||
|
|
||||||
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
QlibFactorHypothesis = Hypothesis
|
QlibFactorHypothesis = Hypothesis
|
||||||
|
|
||||||
|
|
||||||
@@ -17,13 +60,10 @@ class QlibFactorHypothesisGen(FactorHypothesisGen):
|
|||||||
super().__init__(scen)
|
super().__init__(scen)
|
||||||
|
|
||||||
def prepare_context(self, trace: Trace) -> Tuple[dict, bool]:
|
def prepare_context(self, trace: Trace) -> Tuple[dict, bool]:
|
||||||
hypothesis_and_feedback = (
|
max_h = int(os.environ.get("QLIB_QUANT_MAX_FACTOR_HISTORY", "20"))
|
||||||
T("scenarios.qlib.prompts:hypothesis_and_feedback").r(
|
limited = Trace(trace.scen)
|
||||||
trace=trace,
|
limited.hist = trace.hist[-max_h:] if len(trace.hist) > max_h else trace.hist
|
||||||
)
|
hypothesis_and_feedback = _build_compressed_history(limited, max_h)
|
||||||
if len(trace.hist) > 0
|
|
||||||
else "No previous hypothesis and feedback available since it's the first round."
|
|
||||||
)
|
|
||||||
last_hypothesis_and_feedback = (
|
last_hypothesis_and_feedback = (
|
||||||
T("scenarios.qlib.prompts:last_hypothesis_and_feedback").r(
|
T("scenarios.qlib.prompts:last_hypothesis_and_feedback").r(
|
||||||
experiment=trace.hist[-1][0], feedback=trace.hist[-1][1]
|
experiment=trace.hist[-1][0], feedback=trace.hist[-1][1]
|
||||||
@@ -70,15 +110,15 @@ class QlibFactorHypothesis2Experiment(FactorHypothesis2Experiment):
|
|||||||
if len(trace.hist) == 0:
|
if len(trace.hist) == 0:
|
||||||
hypothesis_and_feedback = "No previous hypothesis and feedback available since it's the first round."
|
hypothesis_and_feedback = "No previous hypothesis and feedback available since it's the first round."
|
||||||
else:
|
else:
|
||||||
|
max_h = int(os.environ.get("QLIB_QUANT_MAX_FACTOR_HISTORY", "20"))
|
||||||
|
factor_hist = [
|
||||||
|
e for e in trace.hist
|
||||||
|
if not hasattr(e[0].hypothesis, "action") or e[0].hypothesis.action == "factor"
|
||||||
|
][-max_h:]
|
||||||
specific_trace = Trace(trace.scen)
|
specific_trace = Trace(trace.scen)
|
||||||
for i in range(len(trace.hist) - 1, -1, -1):
|
specific_trace.hist = factor_hist
|
||||||
if not hasattr(trace.hist[i][0].hypothesis, "action") or trace.hist[i][0].hypothesis.action == "factor":
|
if specific_trace.hist:
|
||||||
specific_trace.hist.insert(0, trace.hist[i])
|
hypothesis_and_feedback = _build_compressed_history(specific_trace, max_h)
|
||||||
if len(specific_trace.hist) > 0:
|
|
||||||
specific_trace.hist.reverse()
|
|
||||||
hypothesis_and_feedback = T("scenarios.qlib.prompts:hypothesis_and_feedback").r(
|
|
||||||
trace=specific_trace,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
hypothesis_and_feedback = "No previous hypothesis and feedback available."
|
hypothesis_and_feedback = "No previous hypothesis and feedback available."
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
from typing import Tuple
|
|
||||||
|
|
||||||
from rdagent.app.qlib_rd_loop.conf import QUANT_PROP_SETTING
|
from rdagent.app.qlib_rd_loop.conf import QUANT_PROP_SETTING
|
||||||
from rdagent.components.proposal import FactorAndModelHypothesisGen
|
from rdagent.components.proposal import FactorAndModelHypothesisGen
|
||||||
@@ -41,7 +41,7 @@ class QlibQuantHypothesis(Hypothesis):
|
|||||||
action: str,
|
action: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
hypothesis, reason, concise_reason, concise_observation, concise_justification, concise_knowledge
|
hypothesis, reason, concise_reason, concise_observation, concise_justification, concise_knowledge,
|
||||||
)
|
)
|
||||||
self.action = action
|
self.action = action
|
||||||
|
|
||||||
@@ -53,10 +53,10 @@ Reason: {self.reason}
|
|||||||
|
|
||||||
|
|
||||||
class QlibQuantHypothesisGen(FactorAndModelHypothesisGen):
|
class QlibQuantHypothesisGen(FactorAndModelHypothesisGen):
|
||||||
def __init__(self, scen: Scenario) -> Tuple[dict, bool]:
|
def __init__(self, scen: Scenario) -> None:
|
||||||
super().__init__(scen)
|
super().__init__(scen)
|
||||||
|
|
||||||
def prepare_context(self, trace: Trace) -> Tuple[dict, bool]:
|
def prepare_context(self, trace: Trace) -> tuple[dict, bool]:
|
||||||
|
|
||||||
# ========= Bandit ==========
|
# ========= Bandit ==========
|
||||||
if QUANT_PROP_SETTING.action_selection == "bandit":
|
if QUANT_PROP_SETTING.action_selection == "bandit":
|
||||||
@@ -84,7 +84,7 @@ class QlibQuantHypothesisGen(FactorAndModelHypothesisGen):
|
|||||||
|
|
||||||
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],
|
||||||
)
|
)
|
||||||
if len(trace.hist) > 0
|
if len(trace.hist) > 0
|
||||||
else "No previous hypothesis and feedback available since it's the first round."
|
else "No previous hypothesis and feedback available since it's the first round."
|
||||||
@@ -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:
|
||||||
|
logging.debug("Error getting IC", exc_info=True)
|
||||||
|
decision_str = "PASS" if fb.decision else "FAIL"
|
||||||
|
obs_short = (fb.observations or "")[:120].replace("\n", " ")
|
||||||
|
summary_lines.append(f"- [{decision_str}]{ic_str} {names_str}: {obs_short}")
|
||||||
|
parts.append("\n".join(summary_lines))
|
||||||
|
|
||||||
|
if recent_hist:
|
||||||
|
recent_trace = Trace(specific_trace.scen)
|
||||||
|
recent_trace.hist = recent_hist
|
||||||
|
parts.append(T("scenarios.qlib.prompts:hypothesis_and_feedback").r(trace=recent_trace))
|
||||||
|
|
||||||
|
hypothesis_and_feedback = "\n\n".join(parts)
|
||||||
else:
|
else:
|
||||||
hypothesis_and_feedback = "No previous hypothesis and feedback available."
|
hypothesis_and_feedback = "No previous hypothesis and feedback available."
|
||||||
|
|
||||||
@@ -162,7 +194,7 @@ class QlibQuantHypothesisGen(FactorAndModelHypothesisGen):
|
|||||||
for i in range(len(trace.hist) - 1, -1, -1):
|
for i in range(len(trace.hist) - 1, -1, -1):
|
||||||
if trace.hist[i][0].hypothesis.action == action:
|
if trace.hist[i][0].hypothesis.action == action:
|
||||||
last_hypothesis_and_feedback = T("scenarios.qlib.prompts:last_hypothesis_and_feedback").r(
|
last_hypothesis_and_feedback = T("scenarios.qlib.prompts:last_hypothesis_and_feedback").r(
|
||||||
experiment=trace.hist[i][0], feedback=trace.hist[i][1]
|
experiment=trace.hist[i][0], feedback=trace.hist[i][1],
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -171,7 +203,7 @@ class QlibQuantHypothesisGen(FactorAndModelHypothesisGen):
|
|||||||
for i in range(len(trace.hist) - 1, -1, -1):
|
for i in range(len(trace.hist) - 1, -1, -1):
|
||||||
if trace.hist[i][0].hypothesis.action == "model" and trace.hist[i][1].decision is True:
|
if trace.hist[i][0].hypothesis.action == "model" and trace.hist[i][1].decision is True:
|
||||||
sota_hypothesis_and_feedback = T("scenarios.qlib.prompts:sota_hypothesis_and_feedback").r(
|
sota_hypothesis_and_feedback = T("scenarios.qlib.prompts:sota_hypothesis_and_feedback").r(
|
||||||
experiment=trace.hist[i][0], feedback=trace.hist[i][1]
|
experiment=trace.hist[i][0], feedback=trace.hist[i][1],
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ def count_valid_factors() -> int:
|
|||||||
if data.get("status") == "success" and data.get("ic") is not None:
|
if data.get("status") == "success" and data.get("ic") is not None:
|
||||||
count += 1
|
count += 1
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.warning("Failed to load factor file %s", json_file, exc_info=True)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return count
|
return count
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ def submit_for_grading(grading_url: str, model_path: str) -> dict | None:
|
|||||||
def main():
|
def main():
|
||||||
MODEL_PATH = os.environ.get("MODEL_PATH")
|
MODEL_PATH = os.environ.get("MODEL_PATH")
|
||||||
DATA_PATH = os.environ.get("DATA_PATH")
|
DATA_PATH = os.environ.get("DATA_PATH")
|
||||||
OUTPUT_DIR = os.environ.get("OUTPUT_DIR", "/tmp/autorl_output")
|
OUTPUT_DIR = os.environ.get("OUTPUT_DIR", "/tmp/autorl_output") # nosec B108 — Docker container output dir, configurable via env var
|
||||||
GRADING_SERVER_URL = os.environ.get("GRADING_SERVER_URL", "")
|
GRADING_SERVER_URL = os.environ.get("GRADING_SERVER_URL", "")
|
||||||
TRAIN_RATIO = float(os.environ.get("TRAIN_RATIO", "0.05"))
|
TRAIN_RATIO = float(os.environ.get("TRAIN_RATIO", "0.05"))
|
||||||
NUM_EPOCHS = int(os.environ.get("NUM_EPOCHS", "3"))
|
NUM_EPOCHS = int(os.environ.get("NUM_EPOCHS", "3"))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ peft>=0.18.1
|
|||||||
|
|
||||||
# Evaluation
|
# Evaluation
|
||||||
opencompass==0.5.1
|
opencompass==0.5.1
|
||||||
setuptools<75 # uv venv doesn't include, opencompass depends on pkg_resources
|
setuptools>=78.1.1 # Security fix: GHSA-8g6x-3r52-4m6c (path traversal in PackageIndex.download, arbitrary file write/RCE)
|
||||||
|
|
||||||
# Inference acceleration (optional, TRL supports 0.10.2-0.12.0)
|
# Inference acceleration (optional, TRL supports 0.10.2-0.12.0)
|
||||||
# Security: Version >=0.14.0 fixes CVE-2026-22807 (RCE via auto_map dynamic module loading)
|
# Security: Version >=0.14.0 fixes CVE-2026-22807 (RCE via auto_map dynamic module loading)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from jinja2 import Environment, FunctionLoader, StrictUndefined
|
from jinja2 import Environment, FunctionLoader, StrictUndefined, select_autoescape
|
||||||
|
|
||||||
from rdagent.core.conf import RD_AGENT_SETTINGS
|
from rdagent.core.conf import RD_AGENT_SETTINGS
|
||||||
from rdagent.log import rdagent_logger as logger
|
from rdagent.log import rdagent_logger as logger
|
||||||
@@ -38,7 +38,8 @@ def load_content(uri: str, caller_dir: Path | None = None, ftype: str = "yaml")
|
|||||||
caller_dir = get_caller_dir(upshift=1)
|
caller_dir = get_caller_dir(upshift=1)
|
||||||
# Parse the URI
|
# Parse the URI
|
||||||
path_part, *yaml_trace = uri.split(":")
|
path_part, *yaml_trace = uri.split(":")
|
||||||
assert len(yaml_trace) <= 1, f"Invalid uri {uri}, only one yaml trace is allowed."
|
if len(yaml_trace) > 1:
|
||||||
|
raise ValueError(f"Invalid uri {uri}, only one yaml trace is allowed.")
|
||||||
yaml_trace = [key for yt in yaml_trace for key in yt.split(".")]
|
yaml_trace = [key for yt in yaml_trace for key in yt.split(".")]
|
||||||
|
|
||||||
# load file_path with priorities.
|
# load file_path with priorities.
|
||||||
@@ -126,7 +127,7 @@ class RDAT:
|
|||||||
# loader=FunctionLoader(load_conent) is for supporting grammar like below.
|
# loader=FunctionLoader(load_conent) is for supporting grammar like below.
|
||||||
# `{% include "scenarios.data_science.share:component_spec.DataLoadSpec" %}`
|
# `{% include "scenarios.data_science.share:component_spec.DataLoadSpec" %}`
|
||||||
rendered = (
|
rendered = (
|
||||||
Environment(undefined=StrictUndefined, loader=FunctionLoader(load_content))
|
Environment(undefined=StrictUndefined, loader=FunctionLoader(load_content), autoescape=select_autoescape())
|
||||||
.from_string(self.template)
|
.from_string(self.template)
|
||||||
.render(**context)
|
.render(**context)
|
||||||
.strip("\n")
|
.strip("\n")
|
||||||
|
|||||||
+46
-42
@@ -436,13 +436,10 @@ class Env(Generic[ASpecificEnvConf]):
|
|||||||
else:
|
else:
|
||||||
timeout_cmd = f"timeout --kill-after=10 {self.conf.running_timeout_period} {entry}"
|
timeout_cmd = f"timeout --kill-after=10 {self.conf.running_timeout_period} {entry}"
|
||||||
entry_add_timeout = (
|
entry_add_timeout = (
|
||||||
f"/bin/sh -c '" # start of the sh command
|
"/bin/sh -c '" # start of the sh command
|
||||||
+ f"{timeout_cmd}; entry_exit_code=$?; "
|
+ timeout_cmd.replace("'", "'\\''") + "; entry_exit_code=$?; "
|
||||||
+ (
|
+ (
|
||||||
f"{_get_chmod_cmd(self.conf.mount_path)}; "
|
f"{_get_chmod_cmd(self.conf.mount_path)}; "
|
||||||
# We don't have to change the permission of the cache and input folder to remove it
|
|
||||||
# + f"if [ -d {self.conf.mount_path}/cache ]; then chmod 777 {self.conf.mount_path}/cache; fi; " +
|
|
||||||
# f"if [ -d {self.conf.mount_path}/input ]; then chmod 777 {self.conf.mount_path}/input; fi; "
|
|
||||||
if isinstance(self.conf, DockerConf)
|
if isinstance(self.conf, DockerConf)
|
||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
@@ -614,13 +611,14 @@ class LocalEnv(Env[ASpecificLocalConf]):
|
|||||||
if self.conf.extra_volumes is not None:
|
if self.conf.extra_volumes is not None:
|
||||||
for lp, rp in self.conf.extra_volumes.items():
|
for lp, rp in self.conf.extra_volumes.items():
|
||||||
volumes[lp] = rp["bind"] if isinstance(rp, dict) else rp
|
volumes[lp] = rp["bind"] if isinstance(rp, dict) else rp
|
||||||
cache_path = "/tmp/sample" if "/sample/" in "".join(self.conf.extra_volumes.keys()) else "/tmp/full"
|
cache_path = "/tmp/sample" if "/sample/" in "".join(self.conf.extra_volumes.keys()) else "/tmp/full" # nosec B108 — fixed Docker volume mount point, not a user-writable temp file
|
||||||
Path(cache_path).mkdir(parents=True, exist_ok=True)
|
Path(cache_path).mkdir(parents=True, exist_ok=True)
|
||||||
volumes[cache_path] = T("scenarios.data_science.share:scen.cache_path").r()
|
volumes[cache_path] = T("scenarios.data_science.share:scen.cache_path").r()
|
||||||
for lp, rp in running_extra_volume.items():
|
for lp, rp in running_extra_volume.items():
|
||||||
volumes[lp] = rp
|
volumes[lp] = rp
|
||||||
|
|
||||||
assert local_path is not None, "local_path should not be None"
|
if local_path is None:
|
||||||
|
raise ValueError("local_path should not be None")
|
||||||
volumes = normalize_volumes(volumes, local_path)
|
volumes = normalize_volumes(volumes, local_path)
|
||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
@@ -678,7 +676,7 @@ class LocalEnv(Env[ASpecificLocalConf]):
|
|||||||
cwd = Path(local_path).resolve() if local_path else None
|
cwd = Path(local_path).resolve() if local_path else None
|
||||||
env = {k: str(v) if isinstance(v, int) else v for k, v in env.items()}
|
env = {k: str(v) if isinstance(v, int) else v for k, v in env.items()}
|
||||||
|
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen( # nosec B602 — entry is an internal command string set by LocalEnvConf, not user input
|
||||||
entry,
|
entry,
|
||||||
cwd=cwd,
|
cwd=cwd,
|
||||||
env={**os.environ, **env},
|
env={**os.environ, **env},
|
||||||
@@ -761,12 +759,15 @@ class CondaConf(LocalConf):
|
|||||||
to ensure bin_path is set correctly even if the conda env was just created.
|
to ensure bin_path is set correctly even if the conda env was just created.
|
||||||
"""
|
"""
|
||||||
conda_path_result = subprocess.run(
|
conda_path_result = subprocess.run(
|
||||||
f"conda run -n {self.conda_env_name} --no-capture-output env | grep '^PATH='",
|
["conda", "run", "-n", self.conda_env_name, "--no-capture-output", "env"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
shell=True,
|
|
||||||
)
|
)
|
||||||
self.bin_path = conda_path_result.stdout.strip().split("=")[1] if conda_path_result.returncode == 0 else ""
|
if conda_path_result.returncode == 0:
|
||||||
|
path_lines = [l for l in conda_path_result.stdout.splitlines() if l.startswith("PATH=")]
|
||||||
|
self.bin_path = path_lines[0].split("=", 1)[1] if path_lines else ""
|
||||||
|
else:
|
||||||
|
self.bin_path = ""
|
||||||
|
|
||||||
|
|
||||||
class MLECondaConf(CondaConf):
|
class MLECondaConf(CondaConf):
|
||||||
@@ -850,24 +851,22 @@ class QlibCondaEnv(LocalEnv[QlibCondaConf]):
|
|||||||
def prepare(self) -> None:
|
def prepare(self) -> None:
|
||||||
"""Prepare the conda environment if not already created."""
|
"""Prepare the conda environment if not already created."""
|
||||||
try:
|
try:
|
||||||
envs = subprocess.run("conda env list", capture_output=True, text=True, shell=True)
|
envs = subprocess.run(["conda", "env", "list"], capture_output=True, text=True)
|
||||||
if self.conf.conda_env_name not in envs.stdout:
|
if self.conf.conda_env_name not in envs.stdout:
|
||||||
print(f"[yellow]Conda env '{self.conf.conda_env_name}' not found, creating...[/yellow]")
|
print(f"[yellow]Conda env '{self.conf.conda_env_name}' not found, creating...[/yellow]")
|
||||||
subprocess.check_call(
|
subprocess.check_call(
|
||||||
f"conda create -y -n {self.conf.conda_env_name} python=3.10",
|
["conda", "create", "-y", "-n", self.conf.conda_env_name, "python=3.10"],
|
||||||
shell=True,
|
|
||||||
)
|
)
|
||||||
subprocess.check_call(
|
subprocess.check_call(
|
||||||
f"conda run -n {self.conf.conda_env_name} pip install --upgrade pip cython",
|
["conda", "run", "-n", self.conf.conda_env_name, "pip", "install", "--upgrade", "pip", "cython"],
|
||||||
shell=True,
|
|
||||||
)
|
)
|
||||||
subprocess.check_call(
|
subprocess.check_call(
|
||||||
f"conda run -n {self.conf.conda_env_name} pip install git+https://github.com/microsoft/qlib.git@2fb9380b342556ddb50a4b24e4fe8655d548b2b8",
|
["conda", "run", "-n", self.conf.conda_env_name, "pip", "install",
|
||||||
shell=True,
|
"git+https://github.com/microsoft/qlib.git@2fb9380b342556ddb50a4b24e4fe8655d548b2b8"],
|
||||||
)
|
)
|
||||||
subprocess.check_call(
|
subprocess.check_call(
|
||||||
f"conda run -n {self.conf.conda_env_name} pip install catboost xgboost tables torch",
|
["conda", "run", "-n", self.conf.conda_env_name, "pip", "install",
|
||||||
shell=True,
|
"catboost", "xgboost", "tables", "torch"],
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -888,10 +887,9 @@ def _sync_conda_cache_with_real_envs() -> None:
|
|||||||
"""Ensure the prepared cache includes environments that already exist on disk."""
|
"""Ensure the prepared cache includes environments that already exist on disk."""
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
"conda env list",
|
["conda", "env", "list"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
shell=True,
|
|
||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
except Exception as exc: # pragma: no cover - best-effort helper
|
except Exception as exc: # pragma: no cover - best-effort helper
|
||||||
@@ -924,14 +922,19 @@ def _prepare_conda_env(env_name: str, requirements_file: Path, python_version: s
|
|||||||
python_version: Python version for the environment
|
python_version: Python version for the environment
|
||||||
"""
|
"""
|
||||||
# 1. Create conda environment if not exists
|
# 1. Create conda environment if not exists
|
||||||
result = subprocess.run(f"conda env list | grep -q '^{env_name} '", shell=True)
|
env_list = subprocess.run(["conda", "env", "list"], capture_output=True, text=True, check=False)
|
||||||
if result.returncode != 0:
|
env_exists = any(
|
||||||
|
line.split()[0] == env_name
|
||||||
|
for line in env_list.stdout.splitlines()
|
||||||
|
if line and not line.startswith("#") and len(line.split()) > 0
|
||||||
|
)
|
||||||
|
if not env_exists:
|
||||||
print(f"[yellow]Creating conda env '{env_name}' (Python {python_version})...[/yellow]")
|
print(f"[yellow]Creating conda env '{env_name}' (Python {python_version})...[/yellow]")
|
||||||
subprocess.check_call(f"conda create -y -n {env_name} python={python_version}", shell=True)
|
subprocess.check_call(["conda", "create", "-y", "-n", env_name, f"python={python_version}"])
|
||||||
subprocess.check_call(f"conda run -n {env_name} pip install --upgrade pip", shell=True)
|
subprocess.check_call(["conda", "run", "-n", env_name, "pip", "install", "--upgrade", "pip"])
|
||||||
|
|
||||||
print(f"[yellow]Installing dependencies from {requirements_file.name}...[/yellow]")
|
print(f"[yellow]Installing dependencies from {requirements_file.name}...[/yellow]")
|
||||||
subprocess.check_call(f"conda run -n {env_name} pip install -r {requirements_file}", shell=True)
|
subprocess.check_call(["conda", "run", "-n", env_name, "pip", "install", "-r", str(requirements_file)])
|
||||||
print(f"[green]Conda env '{env_name}' ready[/green]")
|
print(f"[green]Conda env '{env_name}' ready[/green]")
|
||||||
|
|
||||||
_CONDA_ENV_PREPARED.add(env_name)
|
_CONDA_ENV_PREPARED.add(env_name)
|
||||||
@@ -971,8 +974,8 @@ class FTCondaEnv(LocalEnv[FTCondaConf]):
|
|||||||
# Note: flash-attn>=2.8 is required for B200 (sm_100) support
|
# Note: flash-attn>=2.8 is required for B200 (sm_100) support
|
||||||
print("[yellow]Installing flash-attn (compiling, may take a few minutes)...[/yellow]")
|
print("[yellow]Installing flash-attn (compiling, may take a few minutes)...[/yellow]")
|
||||||
subprocess.check_call(
|
subprocess.check_call(
|
||||||
f"conda run -n {self.conf.conda_env_name} pip install 'flash-attn>=2.8' --no-build-isolation --no-cache-dir",
|
["conda", "run", "-n", self.conf.conda_env_name, "pip", "install",
|
||||||
shell=True,
|
"flash-attn>=2.8", "--no-build-isolation", "--no-cache-dir"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Re-update bin_path after prepare() in case the conda env was just created
|
# Re-update bin_path after prepare() in case the conda env was just created
|
||||||
@@ -1190,7 +1193,7 @@ class DockerEnv(Env[DockerConf]):
|
|||||||
with Progress(SpinnerColumn(), TextColumn("{task.description}")) as p:
|
with Progress(SpinnerColumn(), TextColumn("{task.description}")) as p:
|
||||||
task = p.add_task("[cyan]Building image...")
|
task = p.add_task("[cyan]Building image...")
|
||||||
for part in resp_stream:
|
for part in resp_stream:
|
||||||
lines = part.decode("utf-8").split("\r\n")
|
lines = part.decode("utf-8", errors="replace").split("\r\n")
|
||||||
for line in lines:
|
for line in lines:
|
||||||
if line.strip():
|
if line.strip():
|
||||||
status_dict = json.loads(line)
|
status_dict = json.loads(line)
|
||||||
@@ -1442,7 +1445,7 @@ class DockerEnv(Env[DockerConf]):
|
|||||||
if self.conf.extra_volumes is not None:
|
if self.conf.extra_volumes is not None:
|
||||||
for lp, rp in self.conf.extra_volumes.items():
|
for lp, rp in self.conf.extra_volumes.items():
|
||||||
volumes[lp] = rp if isinstance(rp, dict) else {"bind": rp, "mode": self.conf.extra_volume_mode}
|
volumes[lp] = rp if isinstance(rp, dict) else {"bind": rp, "mode": self.conf.extra_volume_mode}
|
||||||
cache_path = "/tmp/sample" if "/sample/" in "".join(self.conf.extra_volumes.keys()) else "/tmp/full"
|
cache_path = "/tmp/sample" if "/sample/" in "".join(self.conf.extra_volumes.keys()) else "/tmp/full" # nosec B108 — fixed Docker volume mount point, not a user-writable temp file
|
||||||
Path(cache_path).mkdir(parents=True, exist_ok=True)
|
Path(cache_path).mkdir(parents=True, exist_ok=True)
|
||||||
volumes[cache_path] = {
|
volumes[cache_path] = {
|
||||||
"bind": T("scenarios.data_science.share:scen.cache_path").r(),
|
"bind": T("scenarios.data_science.share:scen.cache_path").r(),
|
||||||
@@ -1471,7 +1474,8 @@ class DockerEnv(Env[DockerConf]):
|
|||||||
cpu_count=self.conf.cpu_count, # Set CPU limit
|
cpu_count=self.conf.cpu_count, # Set CPU limit
|
||||||
**self._gpu_kwargs(client),
|
**self._gpu_kwargs(client),
|
||||||
)
|
)
|
||||||
assert container is not None # Ensure container was created successfully
|
if container is None:
|
||||||
|
raise AssertionError("Docker container was not created successfully")
|
||||||
logs = container.logs(stream=True)
|
logs = container.logs(stream=True)
|
||||||
print(Rule("[bold green]Docker Logs Begin[/bold green]", style="dark_orange"))
|
print(Rule("[bold green]Docker Logs Begin[/bold green]", style="dark_orange"))
|
||||||
table = Table(title="Run Info", show_header=False)
|
table = Table(title="Run Info", show_header=False)
|
||||||
@@ -1521,8 +1525,8 @@ class DockerEnv(Env[DockerConf]):
|
|||||||
class QTDockerEnv(DockerEnv):
|
class QTDockerEnv(DockerEnv):
|
||||||
"""Qlib Torch Docker"""
|
"""Qlib Torch Docker"""
|
||||||
|
|
||||||
def __init__(self, conf: DockerConf = QlibDockerConf()):
|
def __init__(self, conf: DockerConf | None = None):
|
||||||
super().__init__(conf)
|
super().__init__(conf if conf is not None else QlibDockerConf())
|
||||||
|
|
||||||
def prepare(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
|
def prepare(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
|
||||||
"""
|
"""
|
||||||
@@ -1541,15 +1545,15 @@ class QTDockerEnv(DockerEnv):
|
|||||||
class KGDockerEnv(DockerEnv):
|
class KGDockerEnv(DockerEnv):
|
||||||
"""Kaggle Competition Docker"""
|
"""Kaggle Competition Docker"""
|
||||||
|
|
||||||
def __init__(self, competition: str | None = None, conf: DockerConf = KGDockerConf()):
|
def __init__(self, competition: str | None = None, conf: DockerConf | None = None):
|
||||||
super().__init__(conf)
|
super().__init__(conf if conf is not None else KGDockerConf())
|
||||||
|
|
||||||
|
|
||||||
class MLEBDockerEnv(DockerEnv):
|
class MLEBDockerEnv(DockerEnv):
|
||||||
"""MLEBench Docker"""
|
"""MLEBench Docker"""
|
||||||
|
|
||||||
def __init__(self, conf: DockerConf = MLEBDockerConf()):
|
def __init__(self, conf: DockerConf | None = None):
|
||||||
super().__init__(conf)
|
super().__init__(conf if conf is not None else MLEBDockerConf())
|
||||||
|
|
||||||
|
|
||||||
class FTDockerEnv(DockerEnv):
|
class FTDockerEnv(DockerEnv):
|
||||||
@@ -1565,8 +1569,8 @@ class FTDockerEnv(DockerEnv):
|
|||||||
export FT_DOCKER_save_logs_to_file=false # disable log file
|
export FT_DOCKER_save_logs_to_file=false # disable log file
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, conf: DockerConf = FTDockerConf()):
|
def __init__(self, conf: DockerConf | None = None):
|
||||||
super().__init__(conf)
|
super().__init__(conf if conf is not None else FTDockerConf())
|
||||||
|
|
||||||
|
|
||||||
class BenchmarkDockerEnv(DockerEnv):
|
class BenchmarkDockerEnv(DockerEnv):
|
||||||
@@ -1583,5 +1587,5 @@ class BenchmarkDockerEnv(DockerEnv):
|
|||||||
export BENCHMARK_DOCKER_terminal_tail_lines=100 # show last 100 lines
|
export BENCHMARK_DOCKER_terminal_tail_lines=100 # show last 100 lines
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, conf: DockerConf = BenchmarkDockerConf()):
|
def __init__(self, conf: DockerConf | None = None):
|
||||||
super().__init__(conf)
|
super().__init__(conf if conf is not None else BenchmarkDockerConf())
|
||||||
|
|||||||
@@ -15,19 +15,19 @@ import multiprocessing.queues
|
|||||||
import os
|
import os
|
||||||
import pickle
|
import pickle
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Optional, Union, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
from tqdm.auto import tqdm
|
|
||||||
|
|
||||||
from rdagent.core.conf import RD_AGENT_SETTINGS
|
from rdagent.core.conf import RD_AGENT_SETTINGS
|
||||||
from rdagent.log import rdagent_logger as logger
|
from rdagent.log import rdagent_logger as logger
|
||||||
from rdagent.log.conf import LOG_SETTINGS
|
from rdagent.log.conf import LOG_SETTINGS
|
||||||
from rdagent.log.timer import RD_Agent_TIMER_wrapper, RDAgentTimer
|
from rdagent.log.timer import RD_Agent_TIMER_wrapper, RDAgentTimer
|
||||||
from rdagent.utils.workflow.tracking import WorkflowTracker
|
from rdagent.utils.workflow.tracking import WorkflowTracker
|
||||||
|
from tqdm.auto import tqdm
|
||||||
|
|
||||||
|
|
||||||
class LoopMeta(type):
|
class LoopMeta(type):
|
||||||
@@ -98,7 +98,7 @@ class LoopBase:
|
|||||||
skip_loop_error: tuple[type[BaseException], ...] = () # you can define a list of error that will skip current loop
|
skip_loop_error: tuple[type[BaseException], ...] = () # you can define a list of error that will skip current loop
|
||||||
skip_loop_error_stepname: str | None = None # if skip_loop_error exception happens, what's the next step to work on
|
skip_loop_error_stepname: str | None = None # if skip_loop_error exception happens, what's the next step to work on
|
||||||
withdraw_loop_error: tuple[
|
withdraw_loop_error: tuple[
|
||||||
type[BaseException], ...
|
type[BaseException], ...,
|
||||||
] = () # you can define a list of error that will withdraw current loop
|
] = () # you can define a list of error that will withdraw current loop
|
||||||
|
|
||||||
EXCEPTION_KEY = "_EXCEPTION"
|
EXCEPTION_KEY = "_EXCEPTION"
|
||||||
@@ -129,8 +129,8 @@ class LoopBase:
|
|||||||
self.tracker = WorkflowTracker(self) # Initialize tracker with this LoopBase instance
|
self.tracker = WorkflowTracker(self) # Initialize tracker with this LoopBase instance
|
||||||
|
|
||||||
# progress control
|
# progress control
|
||||||
self.loop_n: Optional[int] = None # remain loop count
|
self.loop_n: int | None = None # remain loop count
|
||||||
self.step_n: Optional[int] = None # remain step count
|
self.step_n: int | None = None # remain step count
|
||||||
|
|
||||||
self.semaphores: dict[str, asyncio.Semaphore] = {}
|
self.semaphores: dict[str, asyncio.Semaphore] = {}
|
||||||
|
|
||||||
@@ -169,7 +169,7 @@ class LoopBase:
|
|||||||
self._pbar.close()
|
self._pbar.close()
|
||||||
del self._pbar
|
del self._pbar
|
||||||
|
|
||||||
def _check_exit_conditions_on_step(self, loop_id: Optional[int] = None, step_id: Optional[int] = None) -> None:
|
def _check_exit_conditions_on_step(self, loop_id: int | None = None, step_id: int | None = None) -> None:
|
||||||
"""Check if the loop should continue or terminate.
|
"""Check if the loop should continue or terminate.
|
||||||
|
|
||||||
Raises
|
Raises
|
||||||
@@ -188,8 +188,7 @@ class LoopBase:
|
|||||||
if self.timer.is_timeout():
|
if self.timer.is_timeout():
|
||||||
logger.warning("Timeout, exiting the loop.")
|
logger.warning("Timeout, exiting the loop.")
|
||||||
raise self.LoopTerminationError("Timer timeout")
|
raise self.LoopTerminationError("Timer timeout")
|
||||||
else:
|
logger.info(f"Timer remaining time: {self.timer.remain_time()}")
|
||||||
logger.info(f"Timer remaining time: {self.timer.remain_time()}")
|
|
||||||
|
|
||||||
async def _run_step(self, li: int, force_subproc: bool = False) -> None:
|
async def _run_step(self, li: int, force_subproc: bool = False) -> None:
|
||||||
"""Execute a single step (next unrun step) in the workflow (async version with force_subproc option).
|
"""Execute a single step (next unrun step) in the workflow (async version with force_subproc option).
|
||||||
@@ -217,7 +216,7 @@ class LoopBase:
|
|||||||
|
|
||||||
with logger.tag(f"Loop_{li}.{name}"):
|
with logger.tag(f"Loop_{li}.{name}"):
|
||||||
start = datetime.now(timezone.utc)
|
start = datetime.now(timezone.utc)
|
||||||
func: Callable[..., Any] = cast(Callable[..., Any], getattr(self, name))
|
func: Callable[..., Any] = cast("Callable[..., Any]", getattr(self, name))
|
||||||
|
|
||||||
next_step_idx = si + 1
|
next_step_idx = si + 1
|
||||||
step_forward = True
|
step_forward = True
|
||||||
@@ -233,15 +232,14 @@ class LoopBase:
|
|||||||
# Using deepcopy is to avoid triggering errors like "RuntimeError: dictionary changed size during iteration"
|
# Using deepcopy is to avoid triggering errors like "RuntimeError: dictionary changed size during iteration"
|
||||||
# GUESS: Some content in self.loop_prev_out[li] may be in the middle of being changed.
|
# GUESS: Some content in self.loop_prev_out[li] may be in the middle of being changed.
|
||||||
result = await curr_loop.run_in_executor(
|
result = await curr_loop.run_in_executor(
|
||||||
pool, copy.deepcopy(func), copy.deepcopy(self.loop_prev_out[li])
|
pool, copy.deepcopy(func), copy.deepcopy(self.loop_prev_out[li]),
|
||||||
)
|
)
|
||||||
|
# auto determine whether to run async or sync
|
||||||
|
elif asyncio.iscoroutinefunction(func):
|
||||||
|
result = await func(self.loop_prev_out[li])
|
||||||
else:
|
else:
|
||||||
# auto determine whether to run async or sync
|
# Default: run sync function directly
|
||||||
if asyncio.iscoroutinefunction(func):
|
result = func(self.loop_prev_out[li])
|
||||||
result = await func(self.loop_prev_out[li])
|
|
||||||
else:
|
|
||||||
# Default: run sync function directly
|
|
||||||
result = func(self.loop_prev_out[li])
|
|
||||||
# Store result in the nested dictionary
|
# Store result in the nested dictionary
|
||||||
self.loop_prev_out[li][name] = result
|
self.loop_prev_out[li][name] = result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -251,14 +249,13 @@ class LoopBase:
|
|||||||
next_step_idx = self.steps.index(self.skip_loop_error_stepname)
|
next_step_idx = self.steps.index(self.skip_loop_error_stepname)
|
||||||
if next_step_idx <= si:
|
if next_step_idx <= si:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Cannot skip backwards or to same step. Current: {si} ({name}), Target: {next_step_idx} ({self.skip_loop_error_stepname})"
|
f"Cannot skip backwards or to same step. Current: {si} ({name}), Target: {next_step_idx} ({self.skip_loop_error_stepname})",
|
||||||
) from e
|
) from e
|
||||||
|
# Default: jump to feedback step if exists, otherwise jump to the last step (record)
|
||||||
|
elif "feedback" in self.steps:
|
||||||
|
next_step_idx = self.steps.index("feedback")
|
||||||
else:
|
else:
|
||||||
# Default: jump to feedback step if exists, otherwise jump to the last step (record)
|
next_step_idx = len(self.steps) - 1
|
||||||
if "feedback" in self.steps:
|
|
||||||
next_step_idx = self.steps.index("feedback")
|
|
||||||
else:
|
|
||||||
next_step_idx = len(self.steps) - 1
|
|
||||||
self.loop_prev_out[li][name] = None
|
self.loop_prev_out[li][name] = None
|
||||||
self.loop_prev_out[li][self.EXCEPTION_KEY] = e
|
self.loop_prev_out[li][self.EXCEPTION_KEY] = e
|
||||||
elif isinstance(e, self.withdraw_loop_error):
|
elif isinstance(e, self.withdraw_loop_error):
|
||||||
@@ -270,6 +267,11 @@ class LoopBase:
|
|||||||
msg = "We have reset the loop instance, stop all the routines and resume."
|
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
|
||||||
@@ -404,6 +406,8 @@ class LoopBase:
|
|||||||
self.close_pbar()
|
self.close_pbar()
|
||||||
|
|
||||||
def withdraw_loop(self, loop_idx: int) -> None:
|
def withdraw_loop(self, loop_idx: int) -> None:
|
||||||
|
if loop_idx <= 0:
|
||||||
|
raise RuntimeError(f"Cannot withdraw loop {loop_idx}: no previous loop exists.")
|
||||||
prev_session_dir = self.session_folder / str(loop_idx - 1)
|
prev_session_dir = self.session_folder / str(loop_idx - 1)
|
||||||
prev_path = min(
|
prev_path = min(
|
||||||
(p for p in prev_session_dir.glob("*_*") if p.is_file()),
|
(p for p in prev_session_dir.glob("*_*") if p.is_file()),
|
||||||
@@ -496,7 +500,7 @@ class LoopBase:
|
|||||||
session_folder = path.parent.parent
|
session_folder = path.parent.parent
|
||||||
|
|
||||||
with path.open("rb") as f:
|
with path.open("rb") as f:
|
||||||
session = cast(LoopBase, pickle.load(f))
|
session = cast("LoopBase", pickle.load(f))
|
||||||
|
|
||||||
# set session folder
|
# set session folder
|
||||||
if checkout:
|
if checkout:
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ def wait_retry(
|
|||||||
>>> counter
|
>>> counter
|
||||||
2
|
2
|
||||||
"""
|
"""
|
||||||
assert retry_n > 0, "retry_n should be greater than 0"
|
if retry_n <= 0:
|
||||||
|
raise ValueError("retry_n should be greater than 0")
|
||||||
|
|
||||||
def decorator(f: Callable[..., ASpecificRet]) -> Callable[..., ASpecificRet]:
|
def decorator(f: Callable[..., ASpecificRet]) -> Callable[..., ASpecificRet]:
|
||||||
def wrapper(*args: Any, **kwargs: Any) -> ASpecificRet:
|
def wrapper(*args: Any, **kwargs: Any) -> ASpecificRet:
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import datetime
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
|
|
||||||
from rdagent.core.conf import RD_AGENT_SETTINGS
|
from rdagent.core.conf import RD_AGENT_SETTINGS
|
||||||
from rdagent.log.timer import RD_Agent_TIMER_wrapper
|
from rdagent.log.timer import RD_Agent_TIMER_wrapper
|
||||||
|
|
||||||
@@ -84,12 +83,14 @@ class WorkflowTracker:
|
|||||||
# Log timer status if timer is started
|
# Log timer status if timer is started
|
||||||
if self.loop_base.timer.started:
|
if self.loop_base.timer.started:
|
||||||
remain_time = self.loop_base.timer.remain_time()
|
remain_time = self.loop_base.timer.remain_time()
|
||||||
assert remain_time is not None
|
if remain_time is None:
|
||||||
mlflow.log_metric("remain_time", remain_time.total_seconds())
|
logger.warning("remain_time is None despite timer.started, skipping timer metrics")
|
||||||
mlflow.log_metric(
|
else:
|
||||||
"remain_percent",
|
mlflow.log_metric("remain_time", remain_time.total_seconds())
|
||||||
remain_time / self.loop_base.timer.all_duration * 100,
|
mlflow.log_metric(
|
||||||
)
|
"remain_percent",
|
||||||
|
remain_time / self.loop_base.timer.all_duration * 100,
|
||||||
|
)
|
||||||
|
|
||||||
# Keep only the log_workflow_state method as it's the primary entry point now
|
# Keep only the log_workflow_state method as it's the primary entry point now
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user