mirror of
https://github.com/NicolasBohn/NexQuant.git
synced 2026-07-29 16:37:43 +00:00
Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Run Bandit (Security Scan)
|
||||
uses: PyCQA/bandit-action@v1
|
||||
@@ -25,9 +25,9 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.10"
|
||||
cache: "pip"
|
||||
|
||||
@@ -36,11 +36,11 @@ jobs:
|
||||
steps:
|
||||
# Checkout the repository to the GitHub Actions runner
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
# Execute Codacy Analysis CLI and generate a SARIF output with the security issues identified during the analysis
|
||||
- name: Run Codacy Analysis CLI
|
||||
uses: codacy/codacy-analysis-cli-action@d840f886c4bd4edc059706d09c6a1586111c540b
|
||||
uses: codacy/codacy-analysis-cli-action@562ee3e92b8e92df8b67e0a5ff8aa8e261919c08
|
||||
env:
|
||||
JAVA_TOOL_OPTIONS: "-Dfile.encoding=UTF-8"
|
||||
with:
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
name: Validate Commit Messages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -25,10 +25,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
|
||||
- name: Upload docs artifact
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: docs/_build/html
|
||||
|
||||
|
||||
@@ -16,10 +16,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
release-please:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: googleapis/release-please-action@v4
|
||||
- uses: googleapis/release-please-action@v5
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
config-file: release-please-config.json
|
||||
|
||||
@@ -19,9 +19,9 @@ jobs:
|
||||
python-version: ["3.10", "3.11"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "pip"
|
||||
@@ -49,9 +49,9 @@ jobs:
|
||||
name: Dependency Audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.10"
|
||||
cache: "pip"
|
||||
|
||||
@@ -19,10 +19,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "1.2.2"
|
||||
".": "1.3.7"
|
||||
}
|
||||
|
||||
+100
@@ -1,5 +1,105 @@
|
||||
# Changelog
|
||||
|
||||
## [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
-1
@@ -68,7 +68,7 @@ ignore_missing_imports = true
|
||||
module = "llama"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "-l -s --durations=0"
|
||||
addopts = "-l -s --durations=0 -m 'not slow'"
|
||||
log_cli = true
|
||||
log_cli_level = "info"
|
||||
log_date_format = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
@@ -54,11 +54,11 @@ def rdagent_info():
|
||||
current_version = importlib.metadata.version("rdagent")
|
||||
logger.info(f"RD-Agent version: {current_version}")
|
||||
api_url = f"https://api.github.com/repos/microsoft/RD-Agent/contents/requirements.txt?ref=main"
|
||||
response = requests.get(api_url)
|
||||
response = requests.get(api_url, timeout=30)
|
||||
if response.status_code == 200:
|
||||
files = response.json()
|
||||
file_url = files["download_url"]
|
||||
file_response = requests.get(file_url)
|
||||
file_response = requests.get(file_url, timeout=30)
|
||||
if file_response.status_code == 200:
|
||||
all_file_contents = file_response.text.split("\n")
|
||||
else:
|
||||
|
||||
@@ -11,16 +11,23 @@ from .vbt_backtest import (
|
||||
FTMO_MAX_LEVERAGE,
|
||||
FTMO_RISK_PER_TRADE,
|
||||
OOS_START_DEFAULT,
|
||||
WF_IS_YEARS,
|
||||
WF_OOS_YEARS,
|
||||
WF_STEP_YEARS,
|
||||
backtest_from_forward_returns,
|
||||
backtest_signal,
|
||||
backtest_signal_ftmo,
|
||||
monte_carlo_trade_pvalue,
|
||||
walk_forward_rolling,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'BacktestMetrics', 'FactorBacktester', 'ResultsDatabase',
|
||||
'CorrelationAnalyzer', 'PortfolioOptimizer', 'AdvancedRiskManager',
|
||||
'backtest_signal', 'backtest_signal_ftmo', 'backtest_from_forward_returns',
|
||||
'monte_carlo_trade_pvalue', 'walk_forward_rolling',
|
||||
'DEFAULT_BARS_PER_YEAR', 'DEFAULT_TXN_COST_BPS',
|
||||
'FTMO_INITIAL_CAPITAL', 'FTMO_MAX_DAILY_LOSS', 'FTMO_MAX_TOTAL_LOSS',
|
||||
'FTMO_MAX_LEVERAGE', 'FTMO_RISK_PER_TRADE', 'OOS_START_DEFAULT',
|
||||
'WF_IS_YEARS', 'WF_OOS_YEARS', 'WF_STEP_YEARS',
|
||||
]
|
||||
|
||||
@@ -71,6 +71,9 @@ class ResultsDatabase:
|
||||
|
||||
self.conn.commit()
|
||||
|
||||
_ALLOWED_TABLES = frozenset({"factors", "backtest_runs", "loop_results"})
|
||||
_ALLOWED_COL_TYPES = frozenset({"REAL", "TEXT", "INTEGER", "BLOB"})
|
||||
|
||||
def _add_column_if_not_exists(self, table: str, column: str, col_type: str) -> None:
|
||||
"""
|
||||
Add a column to a table if it doesn't already exist.
|
||||
@@ -78,20 +81,24 @@ class ResultsDatabase:
|
||||
Parameters
|
||||
----------
|
||||
table : str
|
||||
Table name
|
||||
Table name (must be in _ALLOWED_TABLES)
|
||||
column : str
|
||||
Column name to add
|
||||
Column name to add (alphanumeric + underscore only)
|
||||
col_type : str
|
||||
SQL column type (e.g., 'REAL', 'TEXT')
|
||||
SQL column type (must be in _ALLOWED_COL_TYPES)
|
||||
"""
|
||||
if table not in self._ALLOWED_TABLES:
|
||||
raise ValueError(f"Unknown table: {table!r}")
|
||||
if not column.replace("_", "").isalnum():
|
||||
raise ValueError(f"Invalid column name: {column!r}")
|
||||
if col_type not in self._ALLOWED_COL_TYPES:
|
||||
raise ValueError(f"Invalid column type: {col_type!r}")
|
||||
|
||||
c = self.conn.cursor()
|
||||
try:
|
||||
# Try to query the column - if it fails, it doesn't exist
|
||||
# nosec B608: Internal schema migration, column names are controlled
|
||||
c.execute(f"SELECT {column} FROM {table} LIMIT 1") # nosec B608
|
||||
except sqlite3.OperationalError:
|
||||
# Column doesn't exist, add it
|
||||
c.execute(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}") # nosec B608
|
||||
c.execute("SELECT name FROM pragma_table_info(?)", (table,))
|
||||
existing = {row[0] for row in c.fetchall()}
|
||||
if column not in existing:
|
||||
c.execute(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}")
|
||||
|
||||
def add_factor(self, name: str, type: str = "unknown") -> int:
|
||||
c = self.conn.cursor()
|
||||
@@ -183,16 +190,18 @@ class ResultsDatabase:
|
||||
pd.DataFrame
|
||||
DataFrame with factor names and metrics
|
||||
"""
|
||||
# Map shorthand to full column name
|
||||
_ALLOWED_METRICS = frozenset({
|
||||
'sharpe', 'ic', 'annual_return', 'max_drawdown',
|
||||
'win_rate', 'information_ratio', 'volatility',
|
||||
})
|
||||
metric_map = {
|
||||
'sharpe': 'sharpe',
|
||||
'ic': 'ic',
|
||||
'return': 'annual_return',
|
||||
'drawdown': 'max_drawdown',
|
||||
'win_rate': 'win_rate',
|
||||
'sharpe': 'sharpe', 'ic': 'ic', 'return': 'annual_return',
|
||||
'drawdown': 'max_drawdown', 'win_rate': 'win_rate',
|
||||
'information_ratio': 'information_ratio',
|
||||
}
|
||||
col = metric_map.get(metric, metric)
|
||||
if col not in _ALLOWED_METRICS:
|
||||
raise ValueError(f"Unknown metric: {metric!r}")
|
||||
|
||||
return pd.read_sql_query(
|
||||
f"""SELECT factor_name, ic, sharpe, annual_return, max_drawdown,
|
||||
@@ -201,7 +210,7 @@ class ResultsDatabase:
|
||||
JOIN factors ON factor_id = factors.id
|
||||
WHERE {col} IS NOT NULL
|
||||
ORDER BY {col} DESC
|
||||
LIMIT ?""",
|
||||
LIMIT ?""", # nosec B608 — col is validated against _ALLOWED_METRICS above
|
||||
self.conn,
|
||||
params=[limit]
|
||||
)
|
||||
|
||||
@@ -342,6 +342,129 @@ def _apply_ftmo_mask(
|
||||
|
||||
OOS_START_DEFAULT = "2024-01-01"
|
||||
|
||||
# Rolling walk-forward default windows (IS years, OOS years, step years)
|
||||
WF_IS_YEARS = 3
|
||||
WF_OOS_YEARS = 1
|
||||
WF_STEP_YEARS = 1
|
||||
|
||||
|
||||
def monte_carlo_trade_pvalue(
|
||||
trade_pnl: pd.Series,
|
||||
n_permutations: int = 1000,
|
||||
seed: int = 0,
|
||||
) -> float:
|
||||
"""
|
||||
Monte Carlo permutation test on trade-level P&L.
|
||||
|
||||
Runs a one-sided binomial test on trade-level win rate.
|
||||
|
||||
Tests H0: win_rate = 0.5 (random trading) against H1: win_rate > 0.5.
|
||||
The ``n_permutations`` parameter is kept for API compatibility but is unused.
|
||||
|
||||
p < 0.05 → win rate is significantly above 50%, indicating a genuine per-trade edge.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
trade_pnl : pd.Series
|
||||
Per-trade net returns (output of ``_compute_trade_pnl``).
|
||||
n_permutations : int
|
||||
Number of random permutations (default 1000).
|
||||
seed : int
|
||||
RNG seed for reproducibility.
|
||||
|
||||
Returns
|
||||
-------
|
||||
float
|
||||
p-value in [0, 1]. Lower is better.
|
||||
"""
|
||||
if len(trade_pnl) < 2:
|
||||
return 1.0
|
||||
trades = trade_pnl.values.copy()
|
||||
# Binomial test: is the win rate significantly above 50%?
|
||||
# p = probability of observing >= n_wins out of n_trades under null (win_rate=0.5).
|
||||
# Low p → strategy has a significant positive edge per trade.
|
||||
from scipy.stats import binomtest
|
||||
n_wins = int((trades > 0).sum())
|
||||
n_total = len(trades)
|
||||
result = binomtest(n_wins, n_total, p=0.5, alternative="greater")
|
||||
return float(result.pvalue)
|
||||
|
||||
|
||||
def walk_forward_rolling(
|
||||
close: pd.Series,
|
||||
signal: pd.Series,
|
||||
leverage: float,
|
||||
txn_cost_bps: float = DEFAULT_TXN_COST_BPS,
|
||||
bars_per_year: int = DEFAULT_BARS_PER_YEAR,
|
||||
is_years: int = WF_IS_YEARS,
|
||||
oos_years: int = WF_OOS_YEARS,
|
||||
step_years: int = WF_STEP_YEARS,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Rolling walk-forward validation: multiple IS/OOS windows shifted by ``step_years``.
|
||||
|
||||
Each window runs an independent FTMO simulation on the IS and OOS slices.
|
||||
Produces aggregate OOS statistics to measure cross-time consistency.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict with keys:
|
||||
wf_n_windows, wf_oos_sharpe_mean, wf_oos_sharpe_std,
|
||||
wf_oos_monthly_return_mean, wf_oos_consistency (fraction of windows
|
||||
with OOS Sharpe > 0), wf_windows (list of per-window dicts)
|
||||
"""
|
||||
if not isinstance(close.index, pd.DatetimeIndex):
|
||||
return {"wf_n_windows": 0}
|
||||
|
||||
start_year = close.index[0].year
|
||||
end_year = close.index[-1].year
|
||||
|
||||
windows = []
|
||||
yr = start_year
|
||||
while True:
|
||||
is_start = pd.Timestamp(f"{yr}-01-01")
|
||||
is_end = pd.Timestamp(f"{yr + is_years}-01-01")
|
||||
oos_end = pd.Timestamp(f"{yr + is_years + oos_years}-01-01")
|
||||
if oos_end.year > end_year + 1:
|
||||
break
|
||||
is_mask = (close.index >= is_start) & (close.index < is_end)
|
||||
oos_mask = (close.index >= is_end) & (close.index < oos_end)
|
||||
if is_mask.sum() < 1000 or oos_mask.sum() < 1000:
|
||||
yr += step_years
|
||||
continue
|
||||
|
||||
window: Dict[str, Any] = {
|
||||
"is_start": str(is_start.date()),
|
||||
"is_end": str(is_end.date()),
|
||||
"oos_start": str(is_end.date()),
|
||||
"oos_end": str(oos_end.date()),
|
||||
}
|
||||
for mask, prefix in [(is_mask, "is"), (oos_mask, "oos")]:
|
||||
close_s = close.loc[mask]
|
||||
signal_s = signal.loc[mask]
|
||||
masked_s, _ = _apply_ftmo_mask(signal_s, close_s, leverage, txn_cost_bps)
|
||||
r = backtest_signal(close=close_s, signal=masked_s,
|
||||
txn_cost_bps=txn_cost_bps, bars_per_year=bars_per_year)
|
||||
window[f"{prefix}_sharpe"] = r.get("sharpe", 0.0)
|
||||
window[f"{prefix}_monthly_return_pct"] = r.get("monthly_return_pct", 0.0)
|
||||
window[f"{prefix}_n_trades"] = r.get("n_trades", 0)
|
||||
windows.append(window)
|
||||
yr += step_years
|
||||
|
||||
if not windows:
|
||||
return {"wf_n_windows": 0}
|
||||
|
||||
oos_sharpes = [w["oos_sharpe"] for w in windows]
|
||||
oos_monthly = [w["oos_monthly_return_pct"] for w in windows]
|
||||
return {
|
||||
"wf_n_windows": len(windows),
|
||||
"wf_oos_sharpe_mean": float(np.mean(oos_sharpes)),
|
||||
"wf_oos_sharpe_std": float(np.std(oos_sharpes)),
|
||||
"wf_oos_monthly_return_mean": float(np.mean(oos_monthly)),
|
||||
"wf_oos_consistency": float(np.mean([s > 0 for s in oos_sharpes])),
|
||||
"wf_windows": windows,
|
||||
}
|
||||
|
||||
|
||||
def backtest_signal_ftmo(
|
||||
close: pd.Series,
|
||||
@@ -354,6 +477,8 @@ def backtest_signal_ftmo(
|
||||
bars_per_year: int = DEFAULT_BARS_PER_YEAR,
|
||||
forward_returns: Optional[pd.Series] = None,
|
||||
oos_start: Optional[str] = OOS_START_DEFAULT,
|
||||
wf_rolling: bool = False,
|
||||
mc_n_permutations: int = 0,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
FTMO-compliant backtest of a strategy signal on EUR/USD.
|
||||
@@ -385,6 +510,13 @@ def backtest_signal_ftmo(
|
||||
Maximum leverage (default 30 = FTMO 1:30).
|
||||
oos_start : str or None
|
||||
Start of out-of-sample period (ISO date). None disables OOS split.
|
||||
wf_rolling : bool
|
||||
If True, run rolling walk-forward validation (multiple IS/OOS windows).
|
||||
Results are stored under ``wf_*`` keys. Default False.
|
||||
mc_n_permutations : int
|
||||
Number of Monte Carlo trade permutations. 0 = disabled (default).
|
||||
When > 0, computes ``mc_pvalue``: fraction of permuted sequences whose
|
||||
total return >= real total return. p < 0.05 indicates a genuine edge.
|
||||
"""
|
||||
stop_price = stop_pips * FTMO_PIP
|
||||
leverage_by_risk = risk_pct / (stop_price / eurusd_price)
|
||||
@@ -440,6 +572,28 @@ def backtest_signal_ftmo(
|
||||
result["is_n_bars"] = int(is_mask.sum())
|
||||
result["oos_n_bars"] = int(oos_mask.sum())
|
||||
|
||||
# Rolling walk-forward validation
|
||||
if wf_rolling:
|
||||
wf = walk_forward_rolling(
|
||||
close=close,
|
||||
signal=signal,
|
||||
leverage=leverage,
|
||||
txn_cost_bps=txn_cost_bps,
|
||||
bars_per_year=bars_per_year,
|
||||
)
|
||||
result.update(wf)
|
||||
|
||||
# Monte Carlo trade permutation test
|
||||
if mc_n_permutations > 0:
|
||||
position = masked_signal.shift(1).fillna(0)
|
||||
bar_ret = close.pct_change().fillna(0)
|
||||
txn_cost = txn_cost_bps / 10_000.0
|
||||
position_change = position.diff().abs().fillna(position.abs())
|
||||
strat_ret = position * bar_ret - position_change * txn_cost
|
||||
trade_pnl = _compute_trade_pnl(position, strat_ret)
|
||||
result["mc_pvalue"] = monte_carlo_trade_pvalue(trade_pnl, mc_n_permutations)
|
||||
result["mc_n_permutations"] = mc_n_permutations
|
||||
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -51,13 +51,23 @@ class FactorAutoFixer:
|
||||
self.fixes_applied = []
|
||||
fixed_code = code
|
||||
|
||||
# Apply fixes in order - groupby fixes MUST come before min_periods fixes
|
||||
# Apply fixes in order
|
||||
# NOTE: _fix_min_periods is intentionally excluded — it increased min_periods to
|
||||
# match window size, which causes all-NaN output for intraday data with 96 bars/day
|
||||
# (window=240 > 96 means zero valid bars per day). The LLM sets its own min_periods.
|
||||
fix_methods = [
|
||||
self._fix_groupby_apply_to_transform, # First: fix groupby patterns
|
||||
self._fix_min_periods, # Second: fix min_periods in resulting rolling calls
|
||||
self._fix_inf_nan_handling, # Third: add inf/nan handling
|
||||
self._fix_data_range_processing, # Fourth: ensure full data range
|
||||
self._fix_multiindex_groupby, # Fifth: ensure groupby on MultiIndex
|
||||
self._fix_instrument_column_access, # First: fix df['instrument'] on MultiIndex
|
||||
self._fix_instrument_loc_multiindex, # Second: fix df.loc[instrument_var] on MultiIndex
|
||||
self._fix_zero_volume_proxy, # Third: replace zero $volume with range proxy
|
||||
self._fix_reset_index_groupby, # Fourth: fix groupby(level=N) after reset_index()
|
||||
self._fix_groupby_mixed_levels, # Fifth: fix groupby(level=[int, str])
|
||||
self._fix_groupby_column_on_multiindex, # Sixth: fix groupby(['instrument','date']) on MultiIndex
|
||||
self._fix_chained_groupby, # Seventh: fix groupby(level=N).groupby('date') chain
|
||||
self._fix_rolling_ddof, # Eighth: remove unsupported ddof kwarg
|
||||
self._fix_groupby_apply_to_transform, # Ninth: fix groupby patterns
|
||||
self._fix_inf_nan_handling, # Tenth: add inf/nan handling
|
||||
self._fix_data_range_processing, # Eleventh: ensure full data range
|
||||
self._fix_multiindex_groupby, # Twelfth: ensure groupby on MultiIndex
|
||||
]
|
||||
|
||||
for fix_method in fix_methods:
|
||||
@@ -75,6 +85,352 @@ class FactorAutoFixer:
|
||||
|
||||
return fixed_code
|
||||
|
||||
def _fix_instrument_column_access(self, code: str) -> str:
|
||||
"""
|
||||
Fix: df['instrument'] raises KeyError on a MultiIndex DataFrame because
|
||||
'instrument' is an index level (level 1), not a column.
|
||||
|
||||
Replace df['instrument'] with df.index.get_level_values('instrument')
|
||||
but only when the DataFrame has a MultiIndex (not after reset_index which
|
||||
would have promoted it to a real column).
|
||||
|
||||
Also fixes df.reset_index()['instrument'] correctly since after reset_index
|
||||
the column exists.
|
||||
"""
|
||||
fixed_code = code
|
||||
|
||||
# Skip if already fixed or if reset_index() is being used before the access
|
||||
# We only fix bare df['instrument'] where df is the original MultiIndex frame.
|
||||
# Heuristic: if the assignment lhs or context shows reset_index, leave it alone.
|
||||
|
||||
# Pattern: <varname>['instrument'] where varname is NOT a reset_index result
|
||||
reset_vars = set(re.findall(r'(\w+)\s*=\s*\w[^=\n]*\.reset_index\(', fixed_code))
|
||||
|
||||
def _replace_instrument_access(m: re.Match) -> str:
|
||||
var = m.group(1)
|
||||
if var in reset_vars:
|
||||
return m.group(0) # leave reset_index vars alone — column exists
|
||||
self.fixes_applied.append(f"instrument_column: {var}['instrument'] → get_level_values(1)")
|
||||
return f"{var}.index.get_level_values(1)"
|
||||
|
||||
# Exclude assignment targets: var['instrument'] = ... must not become
|
||||
# var.index.get_level_values(1) = ... (SyntaxError: cannot assign to function call)
|
||||
fixed_code = re.sub(r"(\w+)\['instrument'\](?!\s*=)", _replace_instrument_access, fixed_code)
|
||||
|
||||
return fixed_code
|
||||
|
||||
def _fix_instrument_loc_multiindex(self, code: str) -> str:
|
||||
"""
|
||||
Fix: df.loc[instrument_var] raises DateParseError on a (datetime, instrument)
|
||||
MultiIndex because pandas tries to match the instrument string against the
|
||||
datetime level (level 0).
|
||||
|
||||
Pattern detected: for-loops iterating over get_level_values('instrument') or
|
||||
get_level_values(1) where the loop variable is then used as df.loc[loop_var].
|
||||
|
||||
Replacement: df.loc[instrument_var] → df.xs(instrument_var, level=1)
|
||||
"""
|
||||
fixed_code = code
|
||||
|
||||
# Find variables iterated from get_level_values('instrument') or get_level_values(1)
|
||||
inst_vars = set(
|
||||
re.findall(
|
||||
r"for\s+(\w+)\s+in\s+.+?\.get_level_values\s*\(\s*(?:1|['\"]instrument['\"])\s*\)[^:\n]*:",
|
||||
code,
|
||||
)
|
||||
)
|
||||
|
||||
if not inst_vars:
|
||||
return fixed_code
|
||||
|
||||
for var in inst_vars:
|
||||
# Replace DF.loc[var] (read) with DF.xs(var, level=1)
|
||||
# Exclude write-back patterns (DF.loc[var] = ...) — leave those as-is
|
||||
def _make_replacer(v: str):
|
||||
def _replace(m: re.Match) -> str:
|
||||
df_var = m.group(1)
|
||||
self.fixes_applied.append(
|
||||
f"instrument_loc: {df_var}.loc[{v}] → {df_var}.xs({v}, level=1)"
|
||||
)
|
||||
return f"{df_var}.xs({v}, level=1)"
|
||||
|
||||
return _replace
|
||||
|
||||
# Only match when NOT followed by ' =' (assignment)
|
||||
fixed_code = re.sub(
|
||||
rf"(\w+)\.loc\[\s*{re.escape(var)}\s*\](?!\s*=)",
|
||||
_make_replacer(var),
|
||||
fixed_code,
|
||||
)
|
||||
|
||||
return fixed_code
|
||||
|
||||
def _fix_zero_volume_proxy(self, code: str) -> str:
|
||||
"""
|
||||
Fix: $volume is always 0 in our EUR/USD dataset (FX has no real volume).
|
||||
Any factor using $volume (VWAP, volume-weighted returns, etc.) produces
|
||||
all-NaN output because 0*price=0 and sum(0)/sum(0)=NaN.
|
||||
|
||||
Insert a guard right after pd.read_hdf() that replaces zero volume with
|
||||
the intraday price-range proxy ($high - $low) so volume-weighted factors
|
||||
produce meaningful signals.
|
||||
"""
|
||||
if "'$volume'" not in code and '"$volume"' not in code:
|
||||
return code
|
||||
|
||||
# Already patched
|
||||
if "volume proxy" in code:
|
||||
return code
|
||||
|
||||
lines = code.splitlines()
|
||||
insert_after = -1
|
||||
df_var = "df"
|
||||
indent = " "
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
if "read_hdf(" in line:
|
||||
m = re.match(r"(\s*)(\w+)\s*=\s*", line)
|
||||
if m:
|
||||
indent = m.group(1)
|
||||
df_var = m.group(2)
|
||||
else:
|
||||
m2 = re.match(r"(\s*)", line)
|
||||
indent = m2.group(1) if m2 else " "
|
||||
insert_after = i
|
||||
break
|
||||
|
||||
if insert_after == -1:
|
||||
return code
|
||||
|
||||
proxy_lines = [
|
||||
f"{indent}# volume proxy: $volume is always 0 in FX data — use price-range as proxy",
|
||||
f"{indent}if ({df_var}['$volume'] == 0).all():",
|
||||
f"{indent} {df_var}['$volume'] = {df_var}['$high'] - {df_var}['$low']",
|
||||
]
|
||||
lines = lines[: insert_after + 1] + proxy_lines + lines[insert_after + 1 :]
|
||||
self.fixes_applied.append("volume_proxy: replaced zero $volume with ($high - $low)")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _fix_reset_index_groupby(self, code: str) -> str:
|
||||
"""
|
||||
Fix: groupby(level=N) on a variable created by .reset_index() fails because
|
||||
reset_index() converts the MultiIndex into regular columns, leaving a plain
|
||||
RangeIndex. Replace groupby(level=N) on such variables with
|
||||
groupby('instrument').
|
||||
|
||||
Detected pattern:
|
||||
varname = <anything>.reset_index(...)
|
||||
...
|
||||
varname.groupby(level=0|1)
|
||||
"""
|
||||
fixed_code = code
|
||||
|
||||
# Find all variables assigned via reset_index()
|
||||
reset_vars = set(re.findall(r'(\w+)\s*=\s*\w[^=\n]*\.reset_index\(', fixed_code))
|
||||
|
||||
for var in reset_vars:
|
||||
# Replace var.groupby(level=N) with var.groupby('instrument')
|
||||
pattern = rf'{re.escape(var)}\.groupby\(level\s*=\s*\d+\)'
|
||||
if re.search(pattern, fixed_code):
|
||||
fixed_code = re.sub(pattern, f"{var}.groupby('instrument')", fixed_code)
|
||||
self.fixes_applied.append(f"reset_index_groupby: {var}.groupby(level=N) → groupby('instrument')")
|
||||
|
||||
return fixed_code
|
||||
|
||||
def _fix_groupby_mixed_levels(self, code: str) -> str:
|
||||
"""
|
||||
Fix: groupby(level=[int, 'str']) raises AssertionError because string level
|
||||
names don't exist on an unnamed MultiIndex. Keep only integer levels.
|
||||
|
||||
Pattern: .groupby(level=[0, 'date']) → .groupby(level=0)
|
||||
.groupby(level=[1, 'date']) → .groupby(level=1)
|
||||
"""
|
||||
fixed_code = code
|
||||
|
||||
def _keep_int_levels(m):
|
||||
inner = m.group(1)
|
||||
ints = re.findall(r'\b(\d+)\b', inner)
|
||||
if not ints:
|
||||
return m.group(0)
|
||||
replacement = f'.groupby(level={ints[0]})' if len(ints) == 1 else f'.groupby(level=[{", ".join(ints)}])'
|
||||
self.fixes_applied.append(f"mixed_levels: groupby(level=[...,str]) → {replacement}")
|
||||
return replacement
|
||||
|
||||
fixed_code = re.sub(r'\.groupby\(level=\[([^\]]+)\]\)', _keep_int_levels, fixed_code)
|
||||
return fixed_code
|
||||
|
||||
def _fix_groupby_column_on_multiindex(self, code: str) -> str:
|
||||
"""
|
||||
Fix: groupby(['instrument', 'date']) on a MultiIndex (datetime, instrument)
|
||||
DataFrame fails with KeyError because those are index levels, not columns.
|
||||
|
||||
Correct replacement preserves BOTH dimensions so intraday calculations reset
|
||||
per day:
|
||||
var.groupby(['instrument', 'date'])
|
||||
→ var.groupby([var.index.get_level_values(1), var.index.get_level_values(0).normalize()])
|
||||
|
||||
Single-column groupby(['instrument']) is correctly replaced with groupby(level=1).
|
||||
Note: do NOT convert groupby('instrument') → groupby(level=1) here — that would
|
||||
undo the reset_index_groupby fix which correctly emits groupby('instrument').
|
||||
"""
|
||||
fixed_code = code
|
||||
|
||||
# Variables created via reset_index() have a plain RangeIndex — applying
|
||||
# get_level_values() on them would raise AttributeError. Skip those.
|
||||
reset_vars = set(re.findall(r'(\w+)\s*=\s*\w[^=\n]*\.reset_index\(', fixed_code))
|
||||
|
||||
def _replace_two_col_groupby(m: re.Match, order: str) -> str:
|
||||
var = m.group(1)
|
||||
if var in reset_vars:
|
||||
return m.group(0) # leave reset_index vars alone — RangeIndex, not MultiIndex
|
||||
if order == "instrument_date":
|
||||
repl = (
|
||||
f"{var}.groupby([{var}.index.get_level_values(1), "
|
||||
f"{var}.index.get_level_values(0).normalize()])"
|
||||
)
|
||||
else: # date_instrument
|
||||
repl = (
|
||||
f"{var}.groupby([{var}.index.get_level_values(0).normalize(), "
|
||||
f"{var}.index.get_level_values(1)])"
|
||||
)
|
||||
self.fixes_applied.append(f"multiindex_groupby: {m.group(0)[:60]} → two-level")
|
||||
return repl
|
||||
|
||||
# groupby(['instrument', 'date']) — capture variable name before .groupby
|
||||
fixed_code = re.sub(
|
||||
r'(\w+)\.groupby\(\[\'instrument\',\s*\'date\'\]\)',
|
||||
lambda m: _replace_two_col_groupby(m, "instrument_date"),
|
||||
fixed_code,
|
||||
)
|
||||
# groupby(['date', 'instrument'])
|
||||
fixed_code = re.sub(
|
||||
r'(\w+)\.groupby\(\[\'date\',\s*\'instrument\'\]\)',
|
||||
lambda m: _replace_two_col_groupby(m, "date_instrument"),
|
||||
fixed_code,
|
||||
)
|
||||
# single: groupby(['instrument']) → groupby(level=1), but not on reset_index vars
|
||||
def _replace_single_instrument_groupby(m: re.Match) -> str:
|
||||
# Look backwards to find the variable name
|
||||
prefix = fixed_code[: m.start()]
|
||||
var_match = re.search(r'(\w+)\s*$', prefix)
|
||||
var = var_match.group(1) if var_match else ''
|
||||
if var in reset_vars:
|
||||
return m.group(0)
|
||||
self.fixes_applied.append("multiindex_groupby: groupby(['instrument']) → groupby(level=1)")
|
||||
return ".groupby(level=1)"
|
||||
|
||||
if re.search(r"\.groupby\(\['instrument'\]\)", fixed_code):
|
||||
fixed_code = re.sub(r"\.groupby\(\['instrument'\]\)", _replace_single_instrument_groupby, fixed_code)
|
||||
|
||||
# groupby(level=['instrument', 'date']) — uses level= keyword with string names.
|
||||
# 'date' is NOT a valid level name in our (datetime, instrument) MultiIndex;
|
||||
# replace with get_level_values to normalize datetime to daily timestamps.
|
||||
fixed_code = re.sub(
|
||||
r"(\w+)\.groupby\(level=\['instrument',\s*'date'\]\)",
|
||||
lambda m: (
|
||||
self.fixes_applied.append(
|
||||
f"multiindex_groupby: {m.group(0)[:60]} → two-level get_level_values"
|
||||
)
|
||||
or f"{m.group(1)}.groupby([{m.group(1)}.index.get_level_values(1), "
|
||||
f"{m.group(1)}.index.get_level_values(0).normalize()])"
|
||||
),
|
||||
fixed_code,
|
||||
)
|
||||
# groupby(level=['date', 'instrument'])
|
||||
fixed_code = re.sub(
|
||||
r"(\w+)\.groupby\(level=\['date',\s*'instrument'\]\)",
|
||||
lambda m: (
|
||||
self.fixes_applied.append(
|
||||
f"multiindex_groupby: {m.group(0)[:60]} → two-level get_level_values"
|
||||
)
|
||||
or f"{m.group(1)}.groupby([{m.group(1)}.index.get_level_values(0).normalize(), "
|
||||
f"{m.group(1)}.index.get_level_values(1)])"
|
||||
),
|
||||
fixed_code,
|
||||
)
|
||||
# single: groupby(level=['instrument']) → groupby(level=1)
|
||||
fixed_code = re.sub(
|
||||
r"\.groupby\(level=\['instrument'\]\)",
|
||||
lambda m: (self.fixes_applied.append("multiindex_groupby: groupby(level=['instrument']) → level=1") or ".groupby(level=1)"),
|
||||
fixed_code,
|
||||
)
|
||||
|
||||
return fixed_code
|
||||
|
||||
def _fix_chained_groupby(self, code: str) -> str:
|
||||
"""
|
||||
Fix two broken patterns the LLM generates when trying to group by (instrument, date):
|
||||
|
||||
Pattern A — chained groupby (runtime AttributeError):
|
||||
var.groupby(level=1).groupby('date')
|
||||
→ var.groupby([var.index.get_level_values(1),
|
||||
var.index.get_level_values(0).normalize()])
|
||||
|
||||
Pattern B — keyword arg inside list (SyntaxError):
|
||||
var.groupby([level=1, 'date'])
|
||||
→ same two-level replacement
|
||||
"""
|
||||
fixed_code = code
|
||||
|
||||
def _two_level(var: str, tag: str) -> str:
|
||||
self.fixes_applied.append(f"chained_groupby: {tag} → two-level")
|
||||
return (
|
||||
f"{var}.groupby([{var}.index.get_level_values(1), "
|
||||
f"{var}.index.get_level_values(0).normalize()])"
|
||||
)
|
||||
|
||||
# Pattern A: var.groupby(level=N).groupby('date')
|
||||
fixed_code = re.sub(
|
||||
r'(\w+)\.groupby\(level=\d+\)\.groupby\(["\']date["\']\)',
|
||||
lambda m: _two_level(m.group(1), m.group(0)[:60]),
|
||||
fixed_code,
|
||||
)
|
||||
|
||||
# Pattern B: .groupby([level=N, 'date']) — SyntaxError in Python.
|
||||
# The variable before .groupby may be complex (e.g. df[mask]) so we don't
|
||||
# try to capture it; we use df as the index reference (always correct since
|
||||
# all filtered frames share df's MultiIndex structure).
|
||||
def _two_level_df(tag: str) -> str:
|
||||
self.fixes_applied.append(f"chained_groupby: {tag} → two-level")
|
||||
return ".groupby([df.index.get_level_values(1), df.index.get_level_values(0).normalize()])"
|
||||
|
||||
fixed_code = re.sub(
|
||||
r'\.groupby\(\[\s*level\s*=\s*\d+\s*,\s*["\']?date["\']?\s*\]\)',
|
||||
lambda m: _two_level_df(m.group(0)[:60]),
|
||||
fixed_code,
|
||||
)
|
||||
# Also handle reversed order: ['date', level=N]
|
||||
fixed_code = re.sub(
|
||||
r'\.groupby\(\[\s*["\']?date["\']?\s*,\s*level\s*=\s*\d+\s*\]\)',
|
||||
lambda m: _two_level_df(m.group(0)[:60]),
|
||||
fixed_code,
|
||||
)
|
||||
|
||||
return fixed_code
|
||||
|
||||
def _fix_rolling_ddof(self, code: str) -> str:
|
||||
"""
|
||||
Fix: pandas rolling() does not accept a ddof kwarg — raises TypeError.
|
||||
Remove ddof from both rolling(..., ddof=N) and rolling(...).std(ddof=N).
|
||||
"""
|
||||
fixed_code = code
|
||||
|
||||
# Form 1: ddof inside rolling() — .rolling(window=N, min_periods=M, ddof=K)
|
||||
def _strip_ddof_from_rolling(m):
|
||||
inner = re.sub(r',?\s*ddof\s*=\s*\d+', '', m.group(1))
|
||||
inner = inner.strip(', ')
|
||||
self.fixes_applied.append("rolling_ddof: removed ddof from rolling()")
|
||||
return f'.rolling({inner})'
|
||||
|
||||
fixed_code = re.sub(r'\.rolling\(([^)]*ddof\s*=\s*\d+[^)]*)\)', _strip_ddof_from_rolling, fixed_code)
|
||||
|
||||
# Form 2: ddof inside .std() / .var() — .std(ddof=N)
|
||||
if re.search(r'\.(std|var)\([^)]*ddof\s*=\s*\d+', fixed_code):
|
||||
fixed_code = re.sub(r'\.(std|var)\([^)]*ddof\s*=\s*\d+[^)]*\)', r'.\1()', fixed_code)
|
||||
self.fixes_applied.append("rolling_ddof: removed ddof from std()/var()")
|
||||
|
||||
return fixed_code
|
||||
|
||||
def _fix_min_periods(self, code: str) -> str:
|
||||
"""
|
||||
Fix: Ensure min_periods matches window size in rolling calculations.
|
||||
@@ -325,6 +681,45 @@ class FactorAutoFixer:
|
||||
fixed_code = fixed_code.replace(old_code, new_code)
|
||||
self.fixes_applied.append(f"groupby: fixed rolling correlation (window={window}) with reset_index")
|
||||
|
||||
# === GENERAL FIX: DF.groupby(level=N)['col'].apply(lambda x: EXPR) ===
|
||||
# apply() on a grouped Series returns a MultiIndex result (extra level prepended),
|
||||
# causing index shape mismatch when assigned back to df['col'].
|
||||
# Replace with transform() which preserves the original index.
|
||||
col_apply_pattern = re.compile(
|
||||
r"(\w+)\.groupby\(level=(\d+)\)\['([^']+)'\]\.apply\((\s*lambda\s+\w+\s*:.*?)\)",
|
||||
re.DOTALL,
|
||||
)
|
||||
for m in list(col_apply_pattern.finditer(fixed_code)):
|
||||
full = m.group(0)
|
||||
df_var = m.group(1)
|
||||
level = m.group(2)
|
||||
col = m.group(3)
|
||||
lam = m.group(4).strip()
|
||||
new_expr = f"{df_var}.groupby(level={level})['{col}'].transform({lam})"
|
||||
fixed_code = fixed_code.replace(full, new_expr, 1)
|
||||
self.fixes_applied.append(
|
||||
f"groupby: {df_var}.groupby(level={level})['{col}'].apply() → transform()"
|
||||
)
|
||||
|
||||
# === FIX: .transform(...).reset_index(level=N, drop=True) ===
|
||||
# transform() already returns the same index as the input — adding reset_index()
|
||||
# after it drops an index level and causes ValueError on assignment back to df['col'].
|
||||
# Detected line-by-line: if a line contains both .transform( and .reset_index(level=
|
||||
reset_suffix = re.compile(r'\s*\.reset_index\s*\(\s*level\s*=[^,)]+,\s*drop\s*=\s*True\s*\)\s*$')
|
||||
new_lines = []
|
||||
changed = False
|
||||
for line in fixed_code.splitlines():
|
||||
if '.transform(' in line and '.reset_index(' in line:
|
||||
cleaned = reset_suffix.sub('', line)
|
||||
if cleaned != line:
|
||||
new_lines.append(cleaned)
|
||||
changed = True
|
||||
continue
|
||||
new_lines.append(line)
|
||||
if changed:
|
||||
fixed_code = '\n'.join(new_lines)
|
||||
self.fixes_applied.append("groupby: removed spurious .reset_index() after .transform()")
|
||||
|
||||
# Pattern: Simple groupby().apply() with rolling().method()
|
||||
# df.groupby(level=N).apply(lambda x: x['col'].rolling(...).method())
|
||||
apply_pattern = r"df\.groupby\(level=(\d+)\)\.apply\(\s*lambda\s+x:\s+x\['([^']+)'\]\.rolling\([^)]+\)\.(\w+)\([^)]*\)\s*\)"
|
||||
|
||||
@@ -161,8 +161,7 @@ class FactorFBWorkspace(FBWorkspace):
|
||||
|
||||
try:
|
||||
subprocess.check_output(
|
||||
f"{FACTOR_COSTEER_SETTINGS.python_bin} {execution_code_path}",
|
||||
shell=True,
|
||||
[FACTOR_COSTEER_SETTINGS.python_bin, str(execution_code_path)],
|
||||
cwd=self.workspace_path,
|
||||
stderr=subprocess.STDOUT,
|
||||
timeout=FACTOR_COSTEER_SETTINGS.file_based_execution_timeout,
|
||||
|
||||
@@ -53,7 +53,7 @@ evolving_strategy_factor_implementation_v1_system: |-
|
||||
- ALWAYS use `min_periods=N` where N equals the window size in rolling calculations (e.g., `.rolling(20, min_periods=20)`)
|
||||
- ALWAYS handle infinite values after division: `.replace([np.inf, -np.inf], np.nan)` before saving results
|
||||
- ALWAYS use `groupby(level=1)` or `groupby('instrument')` before rolling operations on MultiIndex dataframes
|
||||
- Process the COMPLETE date range (2020-2026), do NOT filter by date
|
||||
- Process the COMPLETE date range available in the HDF5 file (do NOT filter by date — the file may contain 2024 debug data or full 2020-2026 data)
|
||||
- Use `groupby().transform()` instead of `groupby().apply()` for single-column assignments
|
||||
|
||||
Notice that you should not add any other text before or after the json format.
|
||||
|
||||
@@ -123,8 +123,8 @@ model_cls = AntiSymmetricConv
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
node_features = torch.load("node_features.pt")
|
||||
edge_index = torch.load("edge_index.pt")
|
||||
node_features = torch.load("node_features.pt", weights_only=True)
|
||||
edge_index = torch.load("edge_index.pt", weights_only=True)
|
||||
|
||||
# Model instantiation and forward pass
|
||||
model = AntiSymmetricConv(in_channels=node_features.size(-1))
|
||||
|
||||
@@ -78,8 +78,8 @@ model_cls = DirGNNConv
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
node_features = torch.load("node_features.pt")
|
||||
edge_index = torch.load("edge_index.pt")
|
||||
node_features = torch.load("node_features.pt", weights_only=True)
|
||||
edge_index = torch.load("edge_index.pt", weights_only=True)
|
||||
|
||||
# Model instantiation and forward pass
|
||||
model = DirGNNConv(MessagePassing())
|
||||
|
||||
@@ -187,8 +187,8 @@ model_cls = GPSConv
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
node_features = torch.load("node_features.pt")
|
||||
edge_index = torch.load("edge_index.pt")
|
||||
node_features = torch.load("node_features.pt", weights_only=True)
|
||||
edge_index = torch.load("edge_index.pt", weights_only=True)
|
||||
|
||||
# Model instantiation and forward pass
|
||||
model = GPSConv(channels=node_features.size(-1), conv=MessagePassing())
|
||||
|
||||
@@ -170,8 +170,8 @@ class LINKX(torch.nn.Module):
|
||||
model_cls = LINKX
|
||||
|
||||
if __name__ == "__main__":
|
||||
node_features = torch.load("node_features.pt")
|
||||
edge_index = torch.load("edge_index.pt")
|
||||
node_features = torch.load("node_features.pt", weights_only=True)
|
||||
edge_index = torch.load("edge_index.pt", weights_only=True)
|
||||
|
||||
# Model instantiation and forward pass
|
||||
model = LINKX(
|
||||
|
||||
@@ -102,8 +102,8 @@ class PMLP(torch.nn.Module):
|
||||
model_cls = PMLP
|
||||
|
||||
if __name__ == "__main__":
|
||||
node_features = torch.load("node_features.pt")
|
||||
edge_index = torch.load("edge_index.pt")
|
||||
node_features = torch.load("node_features.pt", weights_only=True)
|
||||
edge_index = torch.load("edge_index.pt", weights_only=True)
|
||||
|
||||
# Model instantiation and forward pass
|
||||
model = PMLP(
|
||||
|
||||
@@ -1180,8 +1180,8 @@ model_cls = ViSNet
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
node_features = torch.load("node_features.pt")
|
||||
edge_index = torch.load("edge_index.pt")
|
||||
node_features = torch.load("node_features.pt", weights_only=True)
|
||||
edge_index = torch.load("edge_index.pt", weights_only=True)
|
||||
|
||||
# Model instantiation and forward pass
|
||||
model = ViSNet()
|
||||
|
||||
@@ -125,8 +125,8 @@ class AntiSymmetricConv(torch.nn.Module):
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
node_features = torch.load("node_features.pt")
|
||||
edge_index = torch.load("edge_index.pt")
|
||||
node_features = torch.load("node_features.pt", weights_only=True)
|
||||
edge_index = torch.load("edge_index.pt", weights_only=True)
|
||||
|
||||
# Model instantiation and forward pass
|
||||
model = AntiSymmetricConv(in_channels=node_features.size(-1))
|
||||
|
||||
+22
-15
@@ -27,6 +27,7 @@ Usage:
|
||||
from __future__ import annotations
|
||||
|
||||
import json as _json
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
@@ -36,21 +37,24 @@ from typing import Any
|
||||
|
||||
from loguru import logger as _root
|
||||
|
||||
# ── paths ─────────────────────────────────────────────────────────────────────
|
||||
# ── paths ─────────────────────────────────────────────────────────────────────────────────
|
||||
LOGS_ROOT: Path = Path(__file__).parent.parent.parent / "logs"
|
||||
|
||||
# ── format ────────────────────────────────────────────────────────────────────
|
||||
# ── format ────────────────────────────────────────────────────────────────────────────────
|
||||
_FILE_FMT = (
|
||||
"{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {extra[cmd]: <18} | {message}"
|
||||
)
|
||||
|
||||
# ── internal state ─────────────────────────────────────────────────────────────
|
||||
# ── internal state ─────────────────────────────────────────────────────────────────────────────
|
||||
_registered: set[str] = set() # command keys that already have a file sink
|
||||
_all_added: bool = False # whether the combined all.log sink is active
|
||||
_llm_log_lock = threading.Lock() # guards concurrent writes to llm_calls.jsonl
|
||||
|
||||
# Maximum characters stored per field in llm_calls.jsonl to prevent GB-scale files.
|
||||
_LLM_CALL_MAX_CHARS = 500
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
# ── helpers ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _today_dir() -> Path:
|
||||
d = LOGS_ROOT / datetime.now().strftime("%Y-%m-%d")
|
||||
@@ -79,7 +83,7 @@ def _banner(log, title: str, meta: dict[str, Any]) -> None:
|
||||
log.info(sep)
|
||||
|
||||
|
||||
# ── public API ────────────────────────────────────────────────────────────────
|
||||
# ── public API ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def log_llm_call(
|
||||
system: str | None,
|
||||
@@ -88,16 +92,19 @@ def log_llm_call(
|
||||
start_time: Any = None,
|
||||
end_time: Any = None,
|
||||
) -> None:
|
||||
"""Append one complete LLM call to logs/YYYY-MM-DD/llm_calls.jsonl.
|
||||
"""Append one LLM call summary to logs/YYYY-MM-DD/llm_calls.jsonl.
|
||||
|
||||
Prompt/response content is capped at _LLM_CALL_MAX_CHARS to prevent
|
||||
GB-scale log files from long-running loops.
|
||||
|
||||
Each line is a self-contained JSON object so the file is grep/jq-friendly:
|
||||
jq 'select(.duration_ms > 5000)' logs/2026-04-17/llm_calls.jsonl
|
||||
"""
|
||||
entry: dict[str, Any] = {
|
||||
"ts": datetime.now().isoformat(timespec="milliseconds"),
|
||||
"system": system or "",
|
||||
"user": user,
|
||||
"response": response,
|
||||
"system": (system or "")[:_LLM_CALL_MAX_CHARS],
|
||||
"user": user[:_LLM_CALL_MAX_CHARS],
|
||||
"response": response[:_LLM_CALL_MAX_CHARS],
|
||||
}
|
||||
if start_time is not None and end_time is not None:
|
||||
try:
|
||||
@@ -130,13 +137,13 @@ def setup(command: str, **context: Any):
|
||||
key = command.lower()
|
||||
|
||||
if key not in _registered:
|
||||
# Per-command rotating file
|
||||
_root.add(
|
||||
str(log_dir / f"{key}.log"),
|
||||
format=_FILE_FMT,
|
||||
filter=lambda r, k=key: r["extra"].get("cmd", "").lower() == k,
|
||||
rotation="00:00", # new file at midnight
|
||||
retention="30 days",
|
||||
rotation="50 MB",
|
||||
compression="gz",
|
||||
retention="7 days",
|
||||
encoding="utf-8",
|
||||
enqueue=True,
|
||||
backtrace=False,
|
||||
@@ -145,13 +152,13 @@ def setup(command: str, **context: Any):
|
||||
_registered.add(key)
|
||||
|
||||
if not _all_added:
|
||||
# Combined log — all commands
|
||||
_root.add(
|
||||
str(log_dir / "all.log"),
|
||||
format=_FILE_FMT,
|
||||
filter=lambda r: "cmd" in r["extra"],
|
||||
rotation="00:00",
|
||||
retention="60 days",
|
||||
rotation="100 MB",
|
||||
compression="gz",
|
||||
retention="7 days",
|
||||
encoding="utf-8",
|
||||
enqueue=True,
|
||||
backtrace=False,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -720,7 +720,13 @@ class APIBackend(ABC):
|
||||
|
||||
if finish_reason is None or finish_reason != "length":
|
||||
break # we get a full response now.
|
||||
new_messages.append({"role": "assistant", "content": response})
|
||||
# Merge into the previous assistant message if there already is one at the end.
|
||||
# Appending a second consecutive assistant message causes llama-server to return 400
|
||||
# ("Cannot have 2 or more assistant messages at the end of the list").
|
||||
if new_messages and new_messages[-1]["role"] == "assistant":
|
||||
new_messages[-1]["content"] += response
|
||||
else:
|
||||
new_messages.append({"role": "assistant", "content": response})
|
||||
else:
|
||||
raise RuntimeError(f"Failed to continue the conversation after {try_n} retries.")
|
||||
|
||||
|
||||
@@ -182,7 +182,7 @@ class ExpGen2Hypothesis(DSProposalV2ExpGen):
|
||||
|
||||
success_fb_list = list(set(trace_fbs))
|
||||
logger.info(
|
||||
f"Merge Hypothesis: select {len(success_fb_list)} from {len(trace_fbs)} SOTA experiments found in {len(leaves)} traces"
|
||||
f"Merge Hypothesis: select {len(success_fb_list)} from {len(trace_fbs)} SOTA experiments found in {len(leaves)} traces" # nosec B608 — not SQL, Bandit false positive on "select" in log message
|
||||
)
|
||||
|
||||
if len(success_fb_list) > 0:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import pickle
|
||||
@@ -587,8 +588,8 @@ def _parsing_score(grade_stdout: str) -> Optional[float]:
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
# Priority 2: Eval dict
|
||||
return float(eval(json_str)["score"])
|
||||
# Priority 2: safe literal eval for Python-style dicts
|
||||
return float(ast.literal_eval(json_str)["score"])
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
|
||||
@@ -38,7 +38,7 @@ class KGModelFeatureSelectionCoder(Developer[KGModelExperiment]):
|
||||
assert target_model_type in KG_SELECT_MAPPING
|
||||
if len(exp.experiment_workspace.data_description) == 1:
|
||||
code = (
|
||||
Environment(undefined=StrictUndefined)
|
||||
Environment(undefined=StrictUndefined) # nosec B701 — renders Python code templates, not HTML; autoescape would corrupt code
|
||||
.from_string(DEFAULT_SELECTION_CODE)
|
||||
.render(feature_index_list=None)
|
||||
)
|
||||
@@ -62,7 +62,7 @@ class KGModelFeatureSelectionCoder(Developer[KGModelExperiment]):
|
||||
chosen_index_to_list_index = [i - 1 for i in chosen_index]
|
||||
|
||||
code = (
|
||||
Environment(undefined=StrictUndefined)
|
||||
Environment(undefined=StrictUndefined) # nosec B701 — renders Python code templates, not HTML; autoescape would corrupt code
|
||||
.from_string(DEFAULT_SELECTION_CODE)
|
||||
.render(feature_index_list=chosen_index_to_list_index)
|
||||
)
|
||||
|
||||
+6
-6
@@ -79,12 +79,12 @@ def preprocess_script():
|
||||
This method applies the preprocessing steps to the training, validation, and test datasets.
|
||||
"""
|
||||
if os.path.exists("/kaggle/input/X_train.pkl"):
|
||||
X_train = pd.read_pickle("/kaggle/input/X_train.pkl")
|
||||
X_valid = pd.read_pickle("/kaggle/input/X_valid.pkl")
|
||||
y_train = pd.read_pickle("/kaggle/input/y_train.pkl")
|
||||
y_valid = pd.read_pickle("/kaggle/input/y_valid.pkl")
|
||||
X_test = pd.read_pickle("/kaggle/input/X_test.pkl")
|
||||
others = pd.read_pickle("/kaggle/input/others.pkl")
|
||||
X_train = pd.read_pickle("/kaggle/input/X_train.pkl") # nosec B301 — trusted Kaggle input
|
||||
X_valid = pd.read_pickle("/kaggle/input/X_valid.pkl") # nosec B301
|
||||
y_train = pd.read_pickle("/kaggle/input/y_train.pkl") # nosec B301
|
||||
y_valid = pd.read_pickle("/kaggle/input/y_valid.pkl") # nosec B301
|
||||
X_test = pd.read_pickle("/kaggle/input/X_test.pkl") # nosec B301
|
||||
others = pd.read_pickle("/kaggle/input/others.pkl") # nosec B301
|
||||
y_train = pd.Series(y_train).reset_index(drop=True)
|
||||
y_valid = pd.Series(y_valid).reset_index(drop=True)
|
||||
|
||||
|
||||
+6
-6
@@ -85,12 +85,12 @@ def preprocess_script():
|
||||
This method applies the preprocessing steps to the training, validation, and test datasets.
|
||||
"""
|
||||
if os.path.exists("/kaggle/input/X_train.pkl"):
|
||||
X_train = pd.read_pickle("/kaggle/input/X_train.pkl")
|
||||
X_valid = pd.read_pickle("/kaggle/input/X_valid.pkl")
|
||||
y_train = pd.read_pickle("/kaggle/input/y_train.pkl")
|
||||
y_valid = pd.read_pickle("/kaggle/input/y_valid.pkl")
|
||||
X_test = pd.read_pickle("/kaggle/input/X_test.pkl")
|
||||
others = pd.read_pickle("/kaggle/input/others.pkl")
|
||||
X_train = pd.read_pickle("/kaggle/input/X_train.pkl") # nosec B301
|
||||
X_valid = pd.read_pickle("/kaggle/input/X_valid.pkl") # nosec B301
|
||||
y_train = pd.read_pickle("/kaggle/input/y_train.pkl") # nosec B301
|
||||
y_valid = pd.read_pickle("/kaggle/input/y_valid.pkl") # nosec B301
|
||||
X_test = pd.read_pickle("/kaggle/input/X_test.pkl") # nosec B301
|
||||
others = pd.read_pickle("/kaggle/input/others.pkl") # nosec B301
|
||||
|
||||
return X_train, X_valid, y_train, y_valid, X_test, *others
|
||||
X_train, X_valid, y_train, y_valid = prepreprocess()
|
||||
|
||||
+5
-5
@@ -82,11 +82,11 @@ def preprocess_script():
|
||||
This method applies the preprocessing steps to the training, validation, and test datasets.
|
||||
"""
|
||||
if os.path.exists("X_train.pkl"):
|
||||
X_train = pd.read_pickle("X_train.pkl")
|
||||
X_valid = pd.read_pickle("X_valid.pkl")
|
||||
y_train = pd.read_pickle("y_train.pkl")
|
||||
y_valid = pd.read_pickle("y_valid.pkl")
|
||||
X_test = pd.read_pickle("X_test.pkl")
|
||||
X_train = pd.read_pickle("X_train.pkl") # nosec B301
|
||||
X_valid = pd.read_pickle("X_valid.pkl") # nosec B301
|
||||
y_train = pd.read_pickle("y_train.pkl") # nosec B301
|
||||
y_valid = pd.read_pickle("y_valid.pkl") # nosec B301
|
||||
X_test = pd.read_pickle("X_test.pkl") # nosec B301
|
||||
return X_train, X_valid, y_train, y_valid, X_test
|
||||
|
||||
X_train, X_valid, y_train, y_valid, test, status_encoder, test_ids = prepreprocess()
|
||||
|
||||
+6
-6
@@ -73,12 +73,12 @@ def preprocess_script():
|
||||
This method applies the preprocessing steps to the training, validation, and test datasets.
|
||||
"""
|
||||
if os.path.exists("/kaggle/input/X_train.pkl"):
|
||||
X_train = pd.read_pickle("/kaggle/input/X_train.pkl")
|
||||
X_valid = pd.read_pickle("/kaggle/input/X_valid.pkl")
|
||||
y_train = pd.read_pickle("/kaggle/input/y_train.pkl")
|
||||
y_valid = pd.read_pickle("/kaggle/input/y_valid.pkl")
|
||||
X_test = pd.read_pickle("/kaggle/input/X_test.pkl")
|
||||
others = pd.read_pickle("/kaggle/input/others.pkl")
|
||||
X_train = pd.read_pickle("/kaggle/input/X_train.pkl") # nosec B301
|
||||
X_valid = pd.read_pickle("/kaggle/input/X_valid.pkl") # nosec B301
|
||||
y_train = pd.read_pickle("/kaggle/input/y_train.pkl") # nosec B301
|
||||
y_valid = pd.read_pickle("/kaggle/input/y_valid.pkl") # nosec B301
|
||||
X_test = pd.read_pickle("/kaggle/input/X_test.pkl") # nosec B301
|
||||
others = pd.read_pickle("/kaggle/input/others.pkl") # nosec B301
|
||||
y_train = pd.Series(y_train).reset_index(drop=True)
|
||||
y_valid = pd.Series(y_valid).reset_index(drop=True)
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
from pathlib import Path
|
||||
"""
|
||||
Qlib Factor Runner - Executes factor backtests in Docker.
|
||||
@@ -29,6 +31,83 @@ from rdagent.scenarios.qlib.experiment.model_experiment import QlibModelExperime
|
||||
DIRNAME = Path(__file__).absolute().resolve().parent
|
||||
DIRNAME_local = Path.cwd()
|
||||
|
||||
|
||||
def _shift_daily_constant_factor_if_needed(factor_col: "pd.Series", factor_name: str) -> "pd.Series":
|
||||
"""Detect and fix look-ahead bias in daily-constant factors.
|
||||
|
||||
A factor is "daily-constant" when every minute bar within the same calendar
|
||||
day carries an identical value. This happens when LLM code computes a daily
|
||||
aggregate (e.g. today's log return) and forward-fills it across all intraday
|
||||
bars without shifting — meaning the end-of-day value is visible at 00:00.
|
||||
|
||||
Fix: shift by one trading day so that the value assigned to day T is the
|
||||
aggregate computed from day T-1, eliminating the forward-looking information.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
notnull = factor_col.dropna()
|
||||
if len(notnull) < 200:
|
||||
return factor_col
|
||||
|
||||
datetimes = notnull.index.get_level_values("datetime")
|
||||
dates = datetimes.normalize()
|
||||
|
||||
# Sample up to 50 random days and check intra-day uniqueness
|
||||
unique_dates = pd.Series(dates.unique())
|
||||
sample_dates = unique_dates.sample(min(50, len(unique_dates)), random_state=42)
|
||||
|
||||
daily_unique_counts = []
|
||||
for d in sample_dates:
|
||||
mask = dates == d
|
||||
vals = notnull.values[mask]
|
||||
if len(vals) > 1:
|
||||
daily_unique_counts.append(len(np.unique(vals[~np.isnan(vals)])))
|
||||
|
||||
if not daily_unique_counts:
|
||||
return factor_col
|
||||
|
||||
# If >90% of sampled days have exactly 1 unique value → daily-constant
|
||||
fraction_constant = sum(1 for c in daily_unique_counts if c == 1) / len(daily_unique_counts)
|
||||
if fraction_constant < 0.90:
|
||||
return factor_col # Intraday factor — no shift needed
|
||||
|
||||
logger.warning(
|
||||
f"[LookAheadFix] Factor '{factor_name}' is daily-constant "
|
||||
f"({fraction_constant:.0%} of days). Applying 1-day shift to remove look-ahead bias."
|
||||
)
|
||||
|
||||
# Shift: for each instrument, map daily values forward by 1 trading day
|
||||
instruments = factor_col.index.get_level_values("instrument").unique()
|
||||
shifted_parts = []
|
||||
for inst in instruments:
|
||||
inst_series = factor_col.xs(inst, level="instrument")
|
||||
# Get one value per calendar day (the first non-null bar)
|
||||
inst_dt = inst_series.index.normalize()
|
||||
daily_vals = inst_series.groupby(inst_dt).first()
|
||||
# Shift by 1 day
|
||||
daily_vals_shifted = daily_vals.shift(1)
|
||||
# Forward-fill back to minute bars
|
||||
minute_idx = inst_series.index
|
||||
minute_dates = minute_idx.normalize()
|
||||
shifted_minute = minute_dates.map(daily_vals_shifted)
|
||||
shifted_s = pd.Series(
|
||||
shifted_minute.values,
|
||||
index=pd.MultiIndex.from_arrays(
|
||||
[inst_series.index, [inst] * len(inst_series)],
|
||||
names=["datetime", "instrument"],
|
||||
),
|
||||
name=factor_col.name,
|
||||
)
|
||||
shifted_parts.append(shifted_s)
|
||||
|
||||
return pd.concat(shifted_parts).sort_index()
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"[LookAheadFix] Could not apply daily shift for '{factor_name}': {e}")
|
||||
return factor_col
|
||||
|
||||
|
||||
# TODO: supporting multiprocessing and keep previous results
|
||||
|
||||
|
||||
@@ -391,8 +470,19 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
# Get workspace path
|
||||
workspace_path = exp.experiment_workspace.workspace_path
|
||||
# Get workspace path — factor code and result.h5 live in sub_workspace_list[0],
|
||||
# not in experiment_workspace (which is the Qlib template workspace).
|
||||
workspace_path = None
|
||||
if exp.sub_workspace_list:
|
||||
for ws in exp.sub_workspace_list:
|
||||
if ws is not None and hasattr(ws, 'workspace_path'):
|
||||
candidate = ws.workspace_path / "result.h5"
|
||||
if candidate.exists():
|
||||
workspace_path = ws.workspace_path
|
||||
break
|
||||
if workspace_path is None:
|
||||
# Fallback to experiment_workspace
|
||||
workspace_path = exp.experiment_workspace.workspace_path
|
||||
if workspace_path is None:
|
||||
return None
|
||||
|
||||
@@ -409,6 +499,12 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
||||
factor_col = factor_values.iloc[:, 0]
|
||||
factor_name = factor_values.columns[0]
|
||||
|
||||
# Detect and fix look-ahead bias in daily-constant factors.
|
||||
# If a factor has the same value for all minute bars within each calendar day
|
||||
# it was computed from same-day data (e.g. today's close return at 00:00).
|
||||
# Fix: shift by 1 trading day so value at day T = aggregate of day T-1.
|
||||
factor_col = _shift_daily_constant_factor_if_needed(factor_col, factor_name)
|
||||
|
||||
# Load source data for forward returns
|
||||
data_path = (
|
||||
Path(__file__).parent.parent.parent.parent.parent
|
||||
@@ -587,10 +683,12 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
||||
from pathlib import Path
|
||||
from rdagent.components.backtesting import ResultsDatabase
|
||||
|
||||
# Get factor name from hypothesis
|
||||
# Get factor name: prefer hypothesis, fallback to result Series 'factor_name' key
|
||||
factor_name = "unknown"
|
||||
if hasattr(exp, 'hypothesis') and exp.hypothesis is not None:
|
||||
factor_name = getattr(exp.hypothesis, 'hypothesis', 'unknown')
|
||||
if factor_name == 'unknown' and isinstance(result, pd.Series) and 'factor_name' in result.index:
|
||||
factor_name = str(result['factor_name'])
|
||||
|
||||
# Check if already rejected by protection
|
||||
if getattr(exp, 'rejected_by_protection', False):
|
||||
@@ -824,41 +922,74 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
||||
"""
|
||||
Save factor time-series values as parquet for strategy building.
|
||||
|
||||
This is essential for walk-forward validation and strategy combination.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
factor_name : str
|
||||
Name of the factor
|
||||
exp : QlibFactorExperiment
|
||||
The experiment with factor values
|
||||
Reruns the factor code on the FULL 6-year dataset so the parquet covers
|
||||
the complete backtest range (not just the debug 2024 subset).
|
||||
"""
|
||||
import os as _os
|
||||
import subprocess
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
try:
|
||||
# Get workspace path
|
||||
workspace_path = exp.experiment_workspace.workspace_path
|
||||
# factor.py lives in sub_workspace_list[0], not experiment_workspace
|
||||
workspace_path = None
|
||||
if exp.sub_workspace_list:
|
||||
for ws in exp.sub_workspace_list:
|
||||
if ws is not None and hasattr(ws, 'workspace_path'):
|
||||
fp = ws.workspace_path / "factor.py"
|
||||
if fp.exists():
|
||||
workspace_path = ws.workspace_path
|
||||
break
|
||||
if workspace_path is None:
|
||||
workspace_path = exp.experiment_workspace.workspace_path
|
||||
if workspace_path is None:
|
||||
return
|
||||
|
||||
result_h5 = workspace_path / "result.h5"
|
||||
if not result_h5.exists():
|
||||
factor_py = workspace_path / "factor.py"
|
||||
if not factor_py.exists():
|
||||
return
|
||||
|
||||
# Read factor values
|
||||
project_root = Path(__file__).parent.parent.parent.parent.parent
|
||||
full_data = (
|
||||
project_root
|
||||
/ "git_ignore_folder"
|
||||
/ "factor_implementation_source_data"
|
||||
/ "intraday_pv.h5"
|
||||
)
|
||||
if not full_data.exists():
|
||||
return
|
||||
|
||||
# Run factor code on full data in a temp workspace
|
||||
import pandas as pd
|
||||
df = pd.read_hdf(str(result_h5), key="data")
|
||||
with tempfile.TemporaryDirectory(prefix="predix_fullval_") as tmp_dir:
|
||||
tmp = Path(tmp_dir)
|
||||
shutil.copy(str(factor_py), str(tmp / "factor.py"))
|
||||
shutil.copy(str(full_data), str(tmp / "intraday_pv.h5"))
|
||||
|
||||
ret = subprocess.run(
|
||||
["sys.executable", "factor.py"],
|
||||
cwd=str(tmp),
|
||||
capture_output=True,
|
||||
timeout=300,
|
||||
)
|
||||
if ret.returncode != 0:
|
||||
# Fall back to debug-data result if full-data run fails
|
||||
result_h5 = workspace_path / "result.h5"
|
||||
if not result_h5.exists():
|
||||
return
|
||||
df = pd.read_hdf(str(result_h5), key="data")
|
||||
else:
|
||||
result_h5_full = tmp / "result.h5"
|
||||
if not result_h5_full.exists():
|
||||
return
|
||||
df = pd.read_hdf(str(result_h5_full), key="data")
|
||||
|
||||
if df is None or df.empty:
|
||||
return
|
||||
|
||||
# Get the factor series (first column)
|
||||
series = df.iloc[:, 0]
|
||||
series.name = factor_name
|
||||
|
||||
# Save to results/factors/values/
|
||||
project_root = Path(__file__).parent.parent.parent.parent.parent
|
||||
|
||||
# Parallel run isolation
|
||||
parallel_run_id = _os.getenv("PARALLEL_RUN_ID", "0")
|
||||
if parallel_run_id != "0":
|
||||
values_dir = project_root / "results" / "runs" / f"run{parallel_run_id}" / "factors" / "values"
|
||||
@@ -866,17 +997,12 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
||||
values_dir = project_root / "results" / "factors" / "values"
|
||||
|
||||
values_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Safe filename
|
||||
safe_name = factor_name.replace("/", "_").replace("\\", "_").replace(" ", "_")[:100]
|
||||
parquet_path = values_dir / f"{safe_name}.parquet"
|
||||
series.to_frame().to_parquet(str(parquet_path))
|
||||
|
||||
# Save as parquet (with datetime index)
|
||||
series.to_parquet(str(parquet_path))
|
||||
|
||||
except Exception as e:
|
||||
# Don't let factor value saving break the main workflow
|
||||
pass
|
||||
except Exception:
|
||||
logging.debug("Error in save_factor_values_to_parquet", exc_info=True)
|
||||
|
||||
def _log_result_warnings(self, factor_name: str, result, metrics: dict) -> None:
|
||||
"""
|
||||
|
||||
@@ -23,14 +23,25 @@ $low: low price at 1-minute bar.
|
||||
$volume: volume at 1-minute bar (tick volume for FX).
|
||||
|
||||
## Important Notes for 1min Data
|
||||
- 96 bars = 1 trading day (24 hours for FX)
|
||||
- 1 bar = 1 minute (confirmed)
|
||||
- 16 bars = 16 minutes
|
||||
- 4 bars = 4 minutes
|
||||
- 1 bar = 1 minute
|
||||
- 60 bars = 1 hour
|
||||
- ~1440 bars = 1 full trading day (FX trades nearly 24h, Mon 00:00 - Fri 22:00 UTC approx.)
|
||||
- Typical bars per calendar day: ~1200-1440 (varies by weekday, holidays have fewer)
|
||||
- Do NOT assume 96 bars/day — the actual count depends on the date
|
||||
- Data range: 2020-01-01 to 2026-03-20
|
||||
- Instrument: EURUSD
|
||||
- Timezone: UTC
|
||||
|
||||
## IMPORTANT: Bars per Day Correction
|
||||
The dataset has approximately 1440 bars per full trading day (1 bar = 1 minute, ~24h of FX trading).
|
||||
Some older documentation incorrectly stated "96 bars = 1 day" — this is WRONG. Always use:
|
||||
- 60 bars = 1 hour
|
||||
- 480 bars = 8 hours (London session 08:00-16:00 UTC)
|
||||
- 180 bars = 3 hours (London/NY overlap 13:00-16:00 UTC)
|
||||
Use datetime hour filtering (e.g., `df[df.index.get_level_values('datetime').hour.between(8, 15)]`)
|
||||
to select session bars — do NOT use bar-count offsets to define sessions.
|
||||
|
||||
## Session Times (UTC)
|
||||
- Asian: 00:00-08:00 UTC (low volatility)
|
||||
- London: 08:00-16:00 UTC (high volatility)
|
||||
|
||||
@@ -104,7 +104,7 @@ qlib_factor_strategy: |-
|
||||
result_df.columns = ['daily_volume_price_divergence']
|
||||
```
|
||||
|
||||
4. **Process ALL data — do not filter dates**: The source HDF5 contains data from 2020-01-01 to 2026-03-20. Do NOT filter to a single year. If your output has only 314 entries (one year of daily data), the factor will be rejected. Expected output: ~1500+ daily entries for 2020-2026.
|
||||
4. **Process ALL data — do not filter dates**: The source HDF5 contains data from 2020-01-01 to 2026-03-20 (development runs may use a 2024-only debug dataset with ~300 entries, which is acceptable). Do NOT filter to a single year in your code. Write your code to process whatever date range is available in the HDF5 file — do not hardcode date filters. Expected output for production data: ~1500+ daily entries for 2020-2026. Expected output for debug data: ~300 daily entries for 2024. Both are valid.
|
||||
|
||||
5. **Use `transform()` instead of `apply()` for per-group calculations**: `transform()` preserves the original index while `apply()` may reduce the number of rows unexpectedly:
|
||||
```python
|
||||
@@ -121,6 +121,35 @@ qlib_factor_strategy: |-
|
||||
assert result_df.index.names == ['datetime', 'instrument'], f"Index names must be ['datetime', 'instrument'], got {result_df.index.names}"
|
||||
```
|
||||
|
||||
7. **NEVER use same-day aggregations as the factor value — always shift by 1 day**: If your factor computes a daily aggregate (e.g. daily close return, daily OHLC range, daily volume), that aggregate is only known at end-of-day. Using it at the start of the same day is look-ahead bias. You MUST shift the daily aggregate by 1 day before forward-filling to minute bars:
|
||||
```python
|
||||
# WRONG: look-ahead bias! Today's close return is not known at 00:00
|
||||
daily_ret = df['$close'].groupby(level='instrument').resample('1D', level='datetime').last().pct_change()
|
||||
result_df['my_factor'] = daily_ret.groupby(level='instrument').transform(lambda x: x.reindex(df.index.get_level_values('datetime'), method='ffill'))
|
||||
|
||||
# CORRECT: shift by 1 trading day so factor value at day T = aggregate of day T-1
|
||||
daily_close = df.groupby([df.index.get_level_values('datetime').normalize(), df.index.get_level_values('instrument')])['$close'].last()
|
||||
daily_close.index.names = ['date', 'instrument']
|
||||
daily_ret = daily_close.groupby(level='instrument').pct_change().shift(1) # <-- shift(1) is MANDATORY
|
||||
# then map back to minute bars via ffill
|
||||
```
|
||||
This rule applies to ALL daily aggregations: returns, OHLC stats, volume, momentum, slopes, etc.
|
||||
**Session-based aggregations (London, NY, Asian session returns) are also daily aggregations** — the London
|
||||
session (08:00-16:00 UTC) ends at 16:00, so its return must be shifted by 1 day before use.
|
||||
Intraday rolling factors (e.g. 30-min rolling std computed at bar t using only bars t-N..t-1) do NOT need this shift.
|
||||
|
||||
8. **PREFER pure intraday rolling factors**: Factors that use only a trailing window of recent bars (e.g.
|
||||
rolling(30).mean() of returns, RSI(14), Bollinger Band z-score) have NO look-ahead risk and vary every
|
||||
minute. These are the best candidates for short-horizon (60-180 bar) prediction. Examples:
|
||||
- Rolling 15-min / 30-min / 60-min return momentum (15, 30, 60 bars respectively)
|
||||
- Rolling volatility (std of returns over 20-60 bars)
|
||||
- Distance of close from N-bar moving average (z-score)
|
||||
- RSI or similar oscillators computed on 1-min bars
|
||||
- VWAP deviation (requires volume — use $volume column)
|
||||
Always use `.shift(1)` on the lagged window (e.g. `rolling(N).mean().shift(1)`) to avoid using the
|
||||
current bar's own price in its own feature value.
|
||||
NOTE: 1 bar = 1 minute. The data has ~1440 bars per full trading day. Do NOT use 96 as a day proxy.
|
||||
|
||||
qlib_factor_output_format: |-
|
||||
Your output should be a pandas dataframe similar to the following example information:
|
||||
<class 'pandas.core.frame.DataFrame'>
|
||||
|
||||
@@ -67,7 +67,7 @@ def get_file_desc(p: Path, variable_list=[]) -> str:
|
||||
"""
|
||||
p = Path(p)
|
||||
|
||||
JJ_TPL = Environment(undefined=StrictUndefined).from_string("""
|
||||
JJ_TPL = Environment(undefined=StrictUndefined).from_string(""" # nosec B701 — renders plain text description, not HTML; autoescape not applicable
|
||||
# {{file_name}}
|
||||
|
||||
## File Type
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import logging
|
||||
import json
|
||||
import os
|
||||
from typing import List, Tuple
|
||||
|
||||
from rdagent.components.coder.factor_coder.factor import FactorExperiment, FactorTask
|
||||
@@ -9,6 +11,47 @@ from rdagent.scenarios.qlib.experiment.model_experiment import QlibModelExperime
|
||||
from rdagent.scenarios.qlib.experiment.quant_experiment import QlibQuantScenario
|
||||
from rdagent.utils.agent.tpl import T
|
||||
|
||||
|
||||
def _build_compressed_history(trace: Trace, max_history: int) -> str:
|
||||
"""Return hypothesis_and_feedback string with only `max_history` entries.
|
||||
|
||||
Older entries beyond the last 2 are compressed to one bullet line each.
|
||||
"""
|
||||
if len(trace.hist) == 0:
|
||||
return "No previous hypothesis and feedback available since it's the first round."
|
||||
|
||||
FULL_DETAIL = 2
|
||||
old_hist = trace.hist[:-FULL_DETAIL] if len(trace.hist) > FULL_DETAIL else []
|
||||
recent_hist = trace.hist[-FULL_DETAIL:] if len(trace.hist) > FULL_DETAIL else trace.hist
|
||||
|
||||
parts = []
|
||||
if old_hist:
|
||||
lines = ["## Earlier experiments (summarized):"]
|
||||
for exp, fb in old_hist:
|
||||
names = []
|
||||
for task in exp.sub_tasks:
|
||||
if task is not None and hasattr(task, "factor_name"):
|
||||
names.append(task.factor_name)
|
||||
elif task is not None and hasattr(task, "model_type"):
|
||||
names.append(getattr(task, "model_type", "model"))
|
||||
ic_str = ""
|
||||
try:
|
||||
if exp.result is not None and "IC" in exp.result.index:
|
||||
ic_str = f" IC={exp.result.loc['IC']:.4f}"
|
||||
except Exception:
|
||||
logging.debug("Exception caught", exc_info=True)
|
||||
decision = "PASS" if fb.decision else "FAIL"
|
||||
obs = (fb.observations or "")[:120].replace("\n", " ")
|
||||
lines.append(f"- [{decision}]{ic_str} {', '.join(names) or 'unknown'}: {obs}")
|
||||
parts.append("\n".join(lines))
|
||||
|
||||
if recent_hist:
|
||||
rt = Trace(trace.scen)
|
||||
rt.hist = recent_hist
|
||||
parts.append(T("scenarios.qlib.prompts:hypothesis_and_feedback").r(trace=rt))
|
||||
|
||||
return "\n\n".join(parts)
|
||||
|
||||
QlibFactorHypothesis = Hypothesis
|
||||
|
||||
|
||||
@@ -17,13 +60,10 @@ class QlibFactorHypothesisGen(FactorHypothesisGen):
|
||||
super().__init__(scen)
|
||||
|
||||
def prepare_context(self, trace: Trace) -> Tuple[dict, bool]:
|
||||
hypothesis_and_feedback = (
|
||||
T("scenarios.qlib.prompts:hypothesis_and_feedback").r(
|
||||
trace=trace,
|
||||
)
|
||||
if len(trace.hist) > 0
|
||||
else "No previous hypothesis and feedback available since it's the first round."
|
||||
)
|
||||
max_h = int(os.environ.get("QLIB_QUANT_MAX_FACTOR_HISTORY", "20"))
|
||||
limited = Trace(trace.scen)
|
||||
limited.hist = trace.hist[-max_h:] if len(trace.hist) > max_h else trace.hist
|
||||
hypothesis_and_feedback = _build_compressed_history(limited, max_h)
|
||||
last_hypothesis_and_feedback = (
|
||||
T("scenarios.qlib.prompts:last_hypothesis_and_feedback").r(
|
||||
experiment=trace.hist[-1][0], feedback=trace.hist[-1][1]
|
||||
@@ -70,15 +110,15 @@ class QlibFactorHypothesis2Experiment(FactorHypothesis2Experiment):
|
||||
if len(trace.hist) == 0:
|
||||
hypothesis_and_feedback = "No previous hypothesis and feedback available since it's the first round."
|
||||
else:
|
||||
max_h = int(os.environ.get("QLIB_QUANT_MAX_FACTOR_HISTORY", "20"))
|
||||
factor_hist = [
|
||||
e for e in trace.hist
|
||||
if not hasattr(e[0].hypothesis, "action") or e[0].hypothesis.action == "factor"
|
||||
][-max_h:]
|
||||
specific_trace = Trace(trace.scen)
|
||||
for i in range(len(trace.hist) - 1, -1, -1):
|
||||
if not hasattr(trace.hist[i][0].hypothesis, "action") or trace.hist[i][0].hypothesis.action == "factor":
|
||||
specific_trace.hist.insert(0, trace.hist[i])
|
||||
if len(specific_trace.hist) > 0:
|
||||
specific_trace.hist.reverse()
|
||||
hypothesis_and_feedback = T("scenarios.qlib.prompts:hypothesis_and_feedback").r(
|
||||
trace=specific_trace,
|
||||
)
|
||||
specific_trace.hist = factor_hist
|
||||
if specific_trace.hist:
|
||||
hypothesis_and_feedback = _build_compressed_history(specific_trace, max_h)
|
||||
else:
|
||||
hypothesis_and_feedback = "No previous hypothesis and feedback available."
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
@@ -152,9 +153,41 @@ class QlibQuantHypothesisGen(FactorAndModelHypothesisGen):
|
||||
factor_inserted = True
|
||||
if len(specific_trace.hist) > 0:
|
||||
specific_trace.hist.reverse()
|
||||
hypothesis_and_feedback = T("scenarios.qlib.prompts:hypothesis_and_feedback").r(
|
||||
trace=specific_trace,
|
||||
)
|
||||
# Keep only the 2 most recent experiments in full detail; compress older ones
|
||||
# to brief bullet points to stay within the LLM context window.
|
||||
FULL_DETAIL_COUNT = 2
|
||||
old_hist = specific_trace.hist[:-FULL_DETAIL_COUNT] if len(specific_trace.hist) > FULL_DETAIL_COUNT else []
|
||||
recent_hist = specific_trace.hist[-FULL_DETAIL_COUNT:] if len(specific_trace.hist) > FULL_DETAIL_COUNT else specific_trace.hist
|
||||
|
||||
parts = []
|
||||
if old_hist:
|
||||
summary_lines = ["## Earlier experiments (summarized):"]
|
||||
for exp, fb in old_hist:
|
||||
factor_names = []
|
||||
for task in exp.sub_tasks:
|
||||
if task is not None and hasattr(task, "factor_name"):
|
||||
factor_names.append(task.factor_name)
|
||||
elif task is not None and hasattr(task, "model_type"):
|
||||
factor_names.append(getattr(task, "model_type", "model"))
|
||||
names_str = ", ".join(factor_names) if factor_names else "unknown"
|
||||
ic_str = ""
|
||||
try:
|
||||
if exp.result is not None:
|
||||
ic_val = exp.result.loc["IC"] if "IC" in exp.result.index else ""
|
||||
ic_str = f" IC={ic_val:.4f}" if ic_val != "" else ""
|
||||
except Exception:
|
||||
logging.debug("Error getting IC", exc_info=True)
|
||||
decision_str = "PASS" if fb.decision else "FAIL"
|
||||
obs_short = (fb.observations or "")[:120].replace("\n", " ")
|
||||
summary_lines.append(f"- [{decision_str}]{ic_str} {names_str}: {obs_short}")
|
||||
parts.append("\n".join(summary_lines))
|
||||
|
||||
if recent_hist:
|
||||
recent_trace = Trace(specific_trace.scen)
|
||||
recent_trace.hist = recent_hist
|
||||
parts.append(T("scenarios.qlib.prompts:hypothesis_and_feedback").r(trace=recent_trace))
|
||||
|
||||
hypothesis_and_feedback = "\n\n".join(parts)
|
||||
else:
|
||||
hypothesis_and_feedback = "No previous hypothesis and feedback available."
|
||||
|
||||
|
||||
@@ -391,7 +391,7 @@ def set_baseline():
|
||||
return jsonify({"baseline_score": score, "status": "set"})
|
||||
|
||||
|
||||
def run_server(task: str, base_model: str, workspace: str, host: str = "0.0.0.0", port: int = 5000):
|
||||
def run_server(task: str, base_model: str, workspace: str, host: str = "127.0.0.1", port: int = 5000):
|
||||
"""启动服务器"""
|
||||
init_server(task, base_model, workspace)
|
||||
logger.info(f"Grading Server | task={task} | {host}:{port}")
|
||||
@@ -435,7 +435,7 @@ class LocalServerContext(GradingServerContext):
|
||||
logger.info(f"[Local Mode] Starting evaluation server on port {self.port}...")
|
||||
self.server = init_server(self.task, self.base_model, self.workspace)
|
||||
|
||||
self._http_server = make_server("0.0.0.0", self.port, app, threaded=True)
|
||||
self._http_server = make_server("0.0.0.0", self.port, app, threaded=True) # nosec B104 — intentional: Docker sandbox requires all-interface binding
|
||||
self._thread = threading.Thread(target=self._http_server.serve_forever, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
@@ -488,7 +488,7 @@ if __name__ == "__main__":
|
||||
parser.add_argument("--base-model", type=str, default="")
|
||||
parser.add_argument("--workspace", type=str, default=".")
|
||||
parser.add_argument("--port", type=int, default=5000)
|
||||
parser.add_argument("--host", type=str, default="0.0.0.0")
|
||||
parser.add_argument("--host", type=str, default="127.0.0.1")
|
||||
args = parser.parse_args()
|
||||
|
||||
run_server(args.task, args.base_model, args.workspace, args.host, args.port)
|
||||
|
||||
@@ -9,7 +9,7 @@ peft>=0.18.1
|
||||
|
||||
# Evaluation
|
||||
opencompass==0.5.1
|
||||
setuptools<75 # uv venv doesn't include, opencompass depends on pkg_resources
|
||||
setuptools>=78.1.1 # Security fix: GHSA-8g6x-3r52-4m6c (path traversal in PackageIndex.download, arbitrary file write/RCE)
|
||||
|
||||
# Inference acceleration (optional, TRL supports 0.10.2-0.12.0)
|
||||
# Security: Version >=0.14.0 fixes CVE-2026-22807 (RCE via auto_map dynamic module loading)
|
||||
|
||||
@@ -850,24 +850,22 @@ class QlibCondaEnv(LocalEnv[QlibCondaConf]):
|
||||
def prepare(self) -> None:
|
||||
"""Prepare the conda environment if not already created."""
|
||||
try:
|
||||
envs = subprocess.run("conda env list", capture_output=True, text=True, shell=True)
|
||||
envs = subprocess.run(["conda", "env", "list"], capture_output=True, text=True)
|
||||
if self.conf.conda_env_name not in envs.stdout:
|
||||
print(f"[yellow]Conda env '{self.conf.conda_env_name}' not found, creating...[/yellow]")
|
||||
subprocess.check_call(
|
||||
f"conda create -y -n {self.conf.conda_env_name} python=3.10",
|
||||
shell=True,
|
||||
["conda", "create", "-y", "-n", self.conf.conda_env_name, "python=3.10"],
|
||||
)
|
||||
subprocess.check_call(
|
||||
f"conda run -n {self.conf.conda_env_name} pip install --upgrade pip cython",
|
||||
shell=True,
|
||||
["conda", "run", "-n", self.conf.conda_env_name, "pip", "install", "--upgrade", "pip", "cython"],
|
||||
)
|
||||
subprocess.check_call(
|
||||
f"conda run -n {self.conf.conda_env_name} pip install git+https://github.com/microsoft/qlib.git@2fb9380b342556ddb50a4b24e4fe8655d548b2b8",
|
||||
shell=True,
|
||||
["conda", "run", "-n", self.conf.conda_env_name, "pip", "install",
|
||||
"git+https://github.com/microsoft/qlib.git@2fb9380b342556ddb50a4b24e4fe8655d548b2b8"],
|
||||
)
|
||||
subprocess.check_call(
|
||||
f"conda run -n {self.conf.conda_env_name} pip install catboost xgboost tables torch",
|
||||
shell=True,
|
||||
["conda", "run", "-n", self.conf.conda_env_name, "pip", "install",
|
||||
"catboost", "xgboost", "tables", "torch"],
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -270,6 +270,11 @@ class LoopBase:
|
||||
msg = "We have reset the loop instance, stop all the routines and resume."
|
||||
raise self.LoopResumeError(msg) from e
|
||||
else:
|
||||
# Do NOT advance step_idx for unhandled exceptions (e.g. LoopResumeError
|
||||
# propagating from _propose). Keeping step_idx at the current step lets
|
||||
# kickoff_loop retry step 0 on the next resume instead of permanently
|
||||
# corrupting the loop with a missing direct_exp_gen result.
|
||||
step_forward = False
|
||||
raise # re-raise unhandled exceptions
|
||||
finally:
|
||||
# No matter the execution succeed or not, we have to finish the following steps
|
||||
|
||||
+5
-5
@@ -9,8 +9,8 @@ psutil
|
||||
fire
|
||||
fuzzywuzzy
|
||||
openai
|
||||
litellm>=1.73 # to support `from litellm import get_valid_models`
|
||||
aiohttp>=3.13.4 # CVE-2026-22815, CVE-2026-34515, CVE-2026-34516, CVE-2026-34525
|
||||
litellm>=1.83.14 # to support `from litellm import get_valid_models`
|
||||
aiohttp>=3.13.5 # CVE-2026-22815, CVE-2026-34515, CVE-2026-34516, CVE-2026-34525
|
||||
azure.identity
|
||||
pyarrow
|
||||
rich
|
||||
@@ -37,7 +37,7 @@ tables
|
||||
tree-sitter-python
|
||||
tree-sitter
|
||||
|
||||
python-dotenv
|
||||
python-dotenv>=1.2.2 # CVE: symlink following allows arbitrary file overwrite
|
||||
|
||||
# infrastructure related.
|
||||
docker
|
||||
@@ -98,8 +98,8 @@ optuna>=3.5.0
|
||||
beautifulsoup4>=4.12.0
|
||||
|
||||
# ML Training Pipeline
|
||||
lightgbm>=3.3.0
|
||||
scipy>=1.9.0
|
||||
lightgbm>=3.3.5
|
||||
scipy>=1.15.3
|
||||
|
||||
# RL Trading (optional - system works without these)
|
||||
# Install for full RL training: pip install stable-baselines3[extra] gymnasium
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
# Only install if you want to use full PPO/A2C/SAC training.
|
||||
|
||||
# Core RL library
|
||||
stable-baselines3[extra]>=2.0.0
|
||||
stable-baselines3[extra]>=2.8.0
|
||||
|
||||
# Gymnasium environment (OpenAI Gym successor)
|
||||
gymnasium>=0.29.0
|
||||
|
||||
@@ -198,6 +198,55 @@ def scan_factors(workspace_dir: Path, skip_evaluated: bool = True) -> List[Facto
|
||||
return factors
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Look-ahead bias detection for daily-constant factors
|
||||
# ---------------------------------------------------------------------------
|
||||
def _shift_daily_constant_factor_if_needed(factor_col: "pd.Series", factor_name: str) -> "pd.Series":
|
||||
"""Detect daily-constant factors (look-ahead bias) and shift by 1 trading day."""
|
||||
sample_days = factor_col.index.get_level_values("datetime").normalize().unique()
|
||||
if len(sample_days) < 10:
|
||||
return factor_col
|
||||
rng = np.random.default_rng(42)
|
||||
days_to_check = rng.choice(sample_days, size=min(50, len(sample_days)), replace=False)
|
||||
constant_count = 0
|
||||
for day in days_to_check:
|
||||
day_mask = factor_col.index.get_level_values("datetime").normalize() == day
|
||||
day_vals = factor_col[day_mask].dropna()
|
||||
if len(day_vals) == 0:
|
||||
continue
|
||||
if day_vals.nunique() == 1:
|
||||
constant_count += 1
|
||||
fraction_constant = constant_count / len(days_to_check)
|
||||
if fraction_constant < 0.90:
|
||||
return factor_col
|
||||
# Shift by 1 trading day per instrument
|
||||
import logging
|
||||
logging.getLogger(__name__).info(
|
||||
"Factor '%s' is %.0f%% daily-constant — shifting 1 trading day to fix look-ahead bias",
|
||||
factor_name, fraction_constant * 100,
|
||||
)
|
||||
instruments = factor_col.index.get_level_values("instrument").unique() if "instrument" in factor_col.index.names else [None]
|
||||
shifted_parts = []
|
||||
for instr in instruments:
|
||||
if instr is not None:
|
||||
mask = factor_col.index.get_level_values("instrument") == instr
|
||||
col_instr = factor_col[mask]
|
||||
else:
|
||||
col_instr = factor_col
|
||||
dates = col_instr.index.get_level_values("datetime").normalize()
|
||||
trading_days = dates.unique().sort_values()
|
||||
day_first = col_instr.groupby(dates).first()
|
||||
day_first_shifted = day_first.shift(1)
|
||||
day_first_shifted.index = pd.to_datetime(day_first_shifted.index)
|
||||
day_map = day_first_shifted.reindex(pd.to_datetime(trading_days)).values
|
||||
new_vals = pd.Series(
|
||||
day_map[np.searchsorted(trading_days.values, dates.values)],
|
||||
index=col_instr.index,
|
||||
)
|
||||
shifted_parts.append(new_vals)
|
||||
return pd.concat(shifted_parts).sort_index()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Factor evaluator
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -263,6 +312,7 @@ def evaluate_factor_full(factor: FactorInfo, full_data: pd.DataFrame,
|
||||
result = pd.read_hdf(str(result_file), key="data")
|
||||
total_count = len(result)
|
||||
factor_val = result.iloc[:, 0]
|
||||
factor_val = _shift_daily_constant_factor_if_needed(factor_val, factor.factor_name)
|
||||
non_null_count = factor_val.notna().sum()
|
||||
|
||||
if non_null_count < 1000:
|
||||
|
||||
@@ -250,7 +250,7 @@ Hard requirements:
|
||||
- NO global mean/std — always use rolling(window).mean() with shift(1) to avoid look-ahead bias"""
|
||||
|
||||
else:
|
||||
system_prompt = f"""You are a quantitative trading expert specializing in EUR/USD intraday strategies.
|
||||
system_prompt = f"""You are a quantitative trading expert specializing in EUR/USD daily swing strategies.
|
||||
|
||||
CRITICAL RULES for {STYLE_DESC} (forward horizon: {FORWARD_BARS} bars = ~{FORWARD_BARS/60:.1f} hours):
|
||||
1. ONLY use the factors listed below - no others!
|
||||
@@ -258,15 +258,27 @@ CRITICAL RULES for {STYLE_DESC} (forward horizon: {FORWARD_BARS} bars = ~{FORWAR
|
||||
3. Create a pandas Series called 'signal' with values: 1 (long), -1 (short), 0 (neutral)
|
||||
4. signal.index MUST match close.index
|
||||
5. signal.name must be 'signal'
|
||||
6. IMPORTANT: factors are DAILY values broadcast to every 1-minute bar — they change once per day.
|
||||
Use daily-level logic: compare today's factor value to a rolling daily mean (window 5-20 DAYS).
|
||||
To get daily rolling mean: group by date, take first value per day, compute rolling, then reindex back.
|
||||
Example: dates = factors[col].index.get_level_values('datetime').normalize()
|
||||
daily_vals = factors[col].groupby(dates).first()
|
||||
daily_mean = daily_vals.rolling(10).mean().shift(1)
|
||||
daily_signal = (daily_vals > daily_mean).astype(int) * 2 - 1
|
||||
signal = daily_signal.reindex(dates).values (broadcast back to minute bars)
|
||||
7. The signal should change roughly once per day — this produces ~250-500 trades over 6 years.
|
||||
8. Keep conditions SIMPLE: one factor above/below its N-day rolling average. Avoid combining 3+ conditions.
|
||||
|
||||
Output ONLY valid JSON with these fields:
|
||||
{{"strategy_name": "short_name", "factor_names": ["f1", "f2"], "description": "one sentence", "code": "python code"}}"""
|
||||
|
||||
user_prompt = f"""Create a EUR/USD trading strategy using these factors:
|
||||
user_prompt = f"""Create a EUR/USD SWING trading strategy (hold ~{FORWARD_BARS/60:.0f} hours) using these factors:
|
||||
|
||||
{factor_list}
|
||||
|
||||
{f'Previous feedback: {feedback}' if feedback else 'First attempt - be creative!'}"""
|
||||
{f'Previous feedback: {feedback}' if feedback else 'First attempt - be creative!'}
|
||||
|
||||
Use daily-level signal logic (factor above/below rolling daily mean). Signal changes once per day."""
|
||||
|
||||
api = APIBackend()
|
||||
response = api.build_messages_and_create_chat_completion(
|
||||
@@ -371,6 +383,8 @@ signal.fillna(0).to_pickle('signal.pkl')
|
||||
txn_cost_bps=TXN_COST_BPS,
|
||||
forward_returns=fwd_returns,
|
||||
oos_start=OOS_START_DEFAULT,
|
||||
wf_rolling=False, # too slow on 2M bars — run via rebacktest script instead
|
||||
mc_n_permutations=50,
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
@@ -567,9 +581,18 @@ def main(target_count=10):
|
||||
progress.update(task, advance=1)
|
||||
continue
|
||||
|
||||
# Check acceptance criteria — OOS must be profitable (primary filter)
|
||||
if (abs(ic) > MIN_IC and sharpe > MIN_SHARPE and trades > MIN_TRADES and dd > MAX_DRAWDOWN
|
||||
and oos_sharpe > 0.0 and oos_monthly > 0.0):
|
||||
# Monte Carlo p-value (edge significance)
|
||||
mc_pvalue = bt_result.get('mc_pvalue')
|
||||
|
||||
# Rolling walk-forward metrics
|
||||
wf_consistency = bt_result.get('wf_oos_consistency')
|
||||
wf_sharpe_mean = bt_result.get('wf_oos_sharpe_mean')
|
||||
|
||||
# Check acceptance criteria — OOS must be profitable + statistically significant
|
||||
mc_ok = mc_pvalue is None or mc_pvalue < 0.20 # lenient: top 20% non-random
|
||||
wf_ok = wf_consistency is None or wf_consistency >= 0.5 # ≥50% of WF windows profitable
|
||||
if (abs(ic or 0) > MIN_IC and sharpe > MIN_SHARPE and trades > MIN_TRADES and dd > MAX_DRAWDOWN
|
||||
and oos_sharpe > 0.0 and oos_monthly > 0.0 and mc_ok and wf_ok):
|
||||
# ACCEPT
|
||||
strategy['real_backtest'] = bt_result
|
||||
strategy['metrics'] = bt_result
|
||||
@@ -583,7 +606,7 @@ def main(target_count=10):
|
||||
'ohlcv_only': OHLCV_ONLY,
|
||||
'engine': 'ftmo_v2',
|
||||
'txn_cost_bps': TXN_COST_BPS,
|
||||
# Walk-forward OOS metrics
|
||||
# Walk-forward OOS split
|
||||
'oos_sharpe': bt_result.get('oos_sharpe'),
|
||||
'oos_monthly_return_pct': bt_result.get('oos_monthly_return_pct'),
|
||||
'oos_max_drawdown': bt_result.get('oos_max_drawdown'),
|
||||
@@ -592,6 +615,15 @@ def main(target_count=10):
|
||||
'is_sharpe': bt_result.get('is_sharpe'),
|
||||
'is_monthly_return_pct': bt_result.get('is_monthly_return_pct'),
|
||||
'oos_start': bt_result.get('oos_start'),
|
||||
# Rolling walk-forward
|
||||
'wf_n_windows': bt_result.get('wf_n_windows'),
|
||||
'wf_oos_sharpe_mean': wf_sharpe_mean,
|
||||
'wf_oos_sharpe_std': bt_result.get('wf_oos_sharpe_std'),
|
||||
'wf_oos_monthly_return_mean': bt_result.get('wf_oos_monthly_return_mean'),
|
||||
'wf_oos_consistency': wf_consistency,
|
||||
# Monte Carlo significance
|
||||
'mc_pvalue': mc_pvalue,
|
||||
'mc_n_permutations': bt_result.get('mc_n_permutations'),
|
||||
}
|
||||
|
||||
fname = f"{int(time.time())}_{strategy['strategy_name']}.json"
|
||||
@@ -614,12 +646,17 @@ def main(target_count=10):
|
||||
f"IC={ic:.4f}, Sharpe={sharpe:.3f}, Trades={trades}, DD={dd:.1%}")
|
||||
else:
|
||||
oos_info = f"OOS_Sharpe={oos_sharpe:+.2f} OOS_Mon={oos_monthly:+.2f}%" if oos_sharpe is not None else ""
|
||||
_log.info(f"REJECTED IC={ic:.4f} Sharpe={sharpe:.2f} Trades={trades} DD={dd:.1%} {oos_info}")
|
||||
mc_info = f" MC_p={mc_pvalue:.2f}" if mc_pvalue is not None else ""
|
||||
wf_info = f" WF_consistency={wf_consistency:.0%}" if wf_consistency is not None else ""
|
||||
_ic = ic or 0; _sh = sharpe or 0; _dd = dd or 0
|
||||
_log.info(f"REJECTED IC={_ic:.4f} Sharpe={_sh:.2f} Trades={trades} DD={_dd:.1%} {oos_info}{mc_info}{wf_info}")
|
||||
feedback_history.append(
|
||||
f"Failed: IC={ic:.4f}, Sharpe={sharpe:.2f}, Trades={trades}, DD={dd:.1%}, "
|
||||
f"OOS_Sharpe={oos_sharpe:+.2f}, OOS_Monthly={oos_monthly:+.2f}%. "
|
||||
f"Need |IC|>{MIN_IC}, Sharpe>{MIN_SHARPE}, Trades>{MIN_TRADES}, "
|
||||
f"OOS_Sharpe>0 AND OOS_Monthly>0 — strategy must generalise to unseen data (2024+)."
|
||||
f"Failed: IC={_ic:.4f}, Sharpe={_sh:.2f}, Trades={trades}, DD={_dd:.1%}, "
|
||||
f"OOS_Sharpe={oos_sharpe:+.2f}, OOS_Monthly={oos_monthly:+.2f}%"
|
||||
+ (f", MC_p={mc_pvalue:.2f}" if mc_pvalue is not None else "")
|
||||
+ (f", WF_consistency={wf_consistency:.0%}" if wf_consistency is not None else "")
|
||||
+ f". Need |IC|>{MIN_IC}, Sharpe>{MIN_SHARPE}, Trades>{MIN_TRADES}, "
|
||||
f"OOS_Sharpe>0, OOS_Monthly>0, MC_p<0.20, WF_consistency≥50%."
|
||||
)
|
||||
|
||||
progress.update(task, advance=1)
|
||||
|
||||
@@ -188,6 +188,8 @@ def rebacktest_one(
|
||||
close=close_a,
|
||||
signal=signal,
|
||||
txn_cost_bps=txn_cost_bps,
|
||||
wf_rolling=True,
|
||||
mc_n_permutations=200,
|
||||
)
|
||||
result["status_detail"] = result.pop("status")
|
||||
result["status"] = "ok"
|
||||
@@ -264,6 +266,15 @@ def main() -> None:
|
||||
"oos_win_rate": bt.get("oos_win_rate"),
|
||||
"oos_n_trades": bt.get("oos_n_trades"),
|
||||
"oos_start": bt.get("oos_start"),
|
||||
# Rolling walk-forward
|
||||
"wf_n_windows": bt.get("wf_n_windows"),
|
||||
"wf_oos_sharpe_mean": bt.get("wf_oos_sharpe_mean"),
|
||||
"wf_oos_sharpe_std": bt.get("wf_oos_sharpe_std"),
|
||||
"wf_oos_monthly_return_mean": bt.get("wf_oos_monthly_return_mean"),
|
||||
"wf_oos_consistency": bt.get("wf_oos_consistency"),
|
||||
# Monte Carlo significance
|
||||
"mc_pvalue": bt.get("mc_pvalue"),
|
||||
"mc_n_permutations": bt.get("mc_n_permutations"),
|
||||
}
|
||||
data["sharpe_ratio"] = bt.get("sharpe")
|
||||
data["max_drawdown"] = bt.get("max_drawdown")
|
||||
@@ -299,6 +310,12 @@ def main() -> None:
|
||||
"oos_monthly_pct": bt.get("oos_monthly_return_pct"),
|
||||
"oos_dd": bt.get("oos_max_drawdown"),
|
||||
"oos_trades": bt.get("oos_n_trades"),
|
||||
# Rolling walk-forward
|
||||
"wf_n_windows": bt.get("wf_n_windows"),
|
||||
"wf_oos_sharpe_mean": bt.get("wf_oos_sharpe_mean"),
|
||||
"wf_oos_consistency": bt.get("wf_oos_consistency"),
|
||||
# Monte Carlo
|
||||
"mc_pvalue": bt.get("mc_pvalue"),
|
||||
}
|
||||
if "annualized_return" in bt:
|
||||
row["new_annual_return_cagr"] = bt["annualized_return"]
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
Tests for backtest_signal_ftmo and walk-forward OOS validation.
|
||||
|
||||
Covers:
|
||||
- FTMO daily/total loss limits
|
||||
- Risk-based leverage calculation
|
||||
- OOS split returns independent IS and OOS metrics
|
||||
- OOS uses fresh FTMO simulation (not contaminated by IS losses)
|
||||
- Monte Carlo permutation test helper
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from rdagent.components.backtesting.vbt_backtest import (
|
||||
OOS_START_DEFAULT,
|
||||
backtest_signal_ftmo,
|
||||
FTMO_MAX_DAILY_LOSS,
|
||||
FTMO_MAX_TOTAL_LOSS,
|
||||
monte_carlo_trade_pvalue,
|
||||
walk_forward_rolling,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.fixture
|
||||
def close_2yr() -> pd.Series:
|
||||
"""~3 months of synthetic 1-min EUR/USD (enough bars for all leverage/FTMO tests)."""
|
||||
np.random.seed(42)
|
||||
n = 90 * 1440 # 90 days × 1440 min
|
||||
idx = pd.date_range("2022-01-01", periods=n, freq="1min")
|
||||
price = 1.10 + np.cumsum(np.random.randn(n) * 0.00005)
|
||||
return pd.Series(price, index=idx)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def close_6yr() -> pd.Series:
|
||||
"""Synthetic data crossing the 2024-01-01 IS/OOS boundary.
|
||||
|
||||
120 days starting 2023-09-01 → ends ~2024-01-01, giving ~30 days of OOS data.
|
||||
Small enough to keep tests fast.
|
||||
"""
|
||||
np.random.seed(7)
|
||||
n = 150 * 1440 # 2023-09-01 + 150d ≈ 2024-01-28 → ~28 days of OOS data
|
||||
idx = pd.date_range("2023-09-01", periods=n, freq="1min")
|
||||
price = 1.10 + np.cumsum(np.random.randn(n) * 0.00005)
|
||||
return pd.Series(price, index=idx)
|
||||
|
||||
|
||||
def _random_signal(index: pd.Index, seed: int = 0) -> pd.Series:
|
||||
np.random.seed(seed)
|
||||
return pd.Series(np.random.choice([-1.0, 0.0, 1.0], size=len(index)), index=index)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FTMO leverage tests
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_ftmo_result_contains_leverage_fields(close_2yr):
|
||||
signal = _random_signal(close_2yr.index)
|
||||
r = backtest_signal_ftmo(close_2yr, signal, oos_start=None)
|
||||
assert "ftmo_leverage" in r
|
||||
assert "ftmo_risk_pct" in r
|
||||
assert "ftmo_stop_pips" in r
|
||||
assert r["ftmo_leverage"] > 0
|
||||
|
||||
|
||||
def test_ftmo_leverage_capped_at_max(close_2yr):
|
||||
signal = _random_signal(close_2yr.index)
|
||||
# With very tight stop (1 pip) risk_pct=0.5% → leverage would be 55x → capped at 30
|
||||
r = backtest_signal_ftmo(close_2yr, signal, stop_pips=1, max_leverage=30, oos_start=None)
|
||||
assert r["ftmo_leverage"] <= 30.0
|
||||
|
||||
|
||||
def test_ftmo_zero_signal_produces_no_trades(close_2yr):
|
||||
signal = pd.Series(0.0, index=close_2yr.index)
|
||||
r = backtest_signal_ftmo(close_2yr, signal, oos_start=None)
|
||||
assert r["n_trades"] == 0
|
||||
assert r["total_return"] == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OOS split tests
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_oos_split_produces_is_and_oos_keys(close_6yr):
|
||||
signal = _random_signal(close_6yr.index)
|
||||
r = backtest_signal_ftmo(close_6yr, signal, oos_start="2024-01-01")
|
||||
|
||||
assert "is_sharpe" in r
|
||||
assert "oos_sharpe" in r
|
||||
assert "is_monthly_return_pct" in r
|
||||
assert "oos_monthly_return_pct" in r
|
||||
assert "is_n_bars" in r
|
||||
assert "oos_n_bars" in r
|
||||
assert r["oos_start"] == "2024-01-01"
|
||||
|
||||
|
||||
def test_oos_split_bars_sum_to_total(close_6yr):
|
||||
signal = _random_signal(close_6yr.index)
|
||||
r = backtest_signal_ftmo(close_6yr, signal, oos_start="2024-01-01")
|
||||
assert r["is_n_bars"] + r["oos_n_bars"] == len(close_6yr)
|
||||
|
||||
|
||||
def test_oos_none_disables_split(close_6yr):
|
||||
signal = _random_signal(close_6yr.index)
|
||||
r = backtest_signal_ftmo(close_6yr, signal, oos_start=None)
|
||||
assert "is_sharpe" not in r
|
||||
assert "oos_sharpe" not in r
|
||||
|
||||
|
||||
def test_oos_is_independent_of_is_losses(close_6yr):
|
||||
"""OOS must use a fresh FTMO simulation — IS blowup must not zero OOS trades."""
|
||||
# Force the IS period to blow up immediately with max short on rising market
|
||||
rising = pd.Series(
|
||||
np.linspace(1.0, 2.0, len(close_6yr)),
|
||||
index=close_6yr.index,
|
||||
)
|
||||
always_short = pd.Series(-1.0, index=close_6yr.index)
|
||||
|
||||
r = backtest_signal_ftmo(rising, always_short, oos_start="2024-01-01")
|
||||
|
||||
# IS should be wiped out (total loss limit hit), but OOS must still trade
|
||||
assert r.get("oos_n_trades", 0) is not None
|
||||
assert r.get("oos_n_bars", 0) > 0
|
||||
|
||||
|
||||
def test_oos_default_start_matches_constant(close_6yr):
|
||||
signal = _random_signal(close_6yr.index)
|
||||
r = backtest_signal_ftmo(close_6yr, signal)
|
||||
assert r.get("oos_start") == OOS_START_DEFAULT
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Monte Carlo permutation test helper
|
||||
# ---------------------------------------------------------------------------
|
||||
def _monte_carlo_pvalue(close: pd.Series, signal: pd.Series, n_permutations: int = 200, seed: int = 0) -> float:
|
||||
"""
|
||||
Estimate p-value: fraction of random permutations that beat the real Sharpe.
|
||||
p < 0.05 → strategy has statistically significant edge.
|
||||
"""
|
||||
real_r = backtest_signal_ftmo(close, signal, oos_start=None)
|
||||
real_sharpe = real_r.get("sharpe", 0.0) or 0.0
|
||||
|
||||
rng = np.random.default_rng(seed)
|
||||
beat = 0
|
||||
signal_vals = signal.values.copy()
|
||||
for _ in range(n_permutations):
|
||||
perm = rng.permutation(signal_vals)
|
||||
perm_signal = pd.Series(perm, index=signal.index)
|
||||
perm_r = backtest_signal_ftmo(close, perm_signal, oos_start=None)
|
||||
if (perm_r.get("sharpe") or 0.0) >= real_sharpe:
|
||||
beat += 1
|
||||
return beat / n_permutations
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_random_signal_has_no_edge(close_2yr):
|
||||
"""A purely random signal should NOT beat most permutations."""
|
||||
signal = _random_signal(close_2yr.index, seed=42)
|
||||
pval = _monte_carlo_pvalue(close_2yr, signal, n_permutations=50)
|
||||
# Random vs random: p-value should be near 0.5 (not significant)
|
||||
assert pval > 0.10, f"Random signal unexpectedly significant: p={pval:.2f}"
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_perfect_signal_is_significant(close_2yr):
|
||||
"""An oracle signal on hourly bars should beat random permutations significantly.
|
||||
|
||||
Per-minute oracle trading is unprofitable due to FTMO transaction costs, so we
|
||||
use 60-bar held positions (≈1h) where each directional move is large enough to
|
||||
cover the spread.
|
||||
"""
|
||||
bar_ret = close_2yr.pct_change().fillna(0)
|
||||
# Hourly oracle: sign of 60-bar future return, broadcast to all 60 minute bars
|
||||
hourly_ret = bar_ret.rolling(60).sum().shift(-60).fillna(0)
|
||||
perfect = pd.Series(np.sign(hourly_ret), index=close_2yr.index)
|
||||
pval = _monte_carlo_pvalue(close_2yr, perfect, n_permutations=50)
|
||||
assert pval < 0.30, f"Hourly oracle signal should beat random permutations: p={pval:.2f}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FTMO metrics in result dict
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_ftmo_result_has_equity_and_profit(close_2yr):
|
||||
signal = _random_signal(close_2yr.index)
|
||||
r = backtest_signal_ftmo(close_2yr, signal, oos_start=None)
|
||||
assert "ftmo_end_equity" in r
|
||||
assert "ftmo_monthly_profit" in r
|
||||
assert r["ftmo_end_equity"] > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Monte Carlo trade permutation tests
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_mc_pvalue_in_result(close_2yr):
|
||||
signal = _random_signal(close_2yr.index)
|
||||
r = backtest_signal_ftmo(close_2yr, signal, oos_start=None, mc_n_permutations=50)
|
||||
assert "mc_pvalue" in r
|
||||
assert 0.0 <= r["mc_pvalue"] <= 1.0
|
||||
assert r["mc_n_permutations"] == 50
|
||||
|
||||
|
||||
def test_mc_pvalue_disabled_by_default(close_2yr):
|
||||
signal = _random_signal(close_2yr.index)
|
||||
r = backtest_signal_ftmo(close_2yr, signal, oos_start=None)
|
||||
assert "mc_pvalue" not in r
|
||||
|
||||
|
||||
def test_mc_zero_trades_returns_one(close_2yr):
|
||||
"""Zero-signal → no trades → p-value must be 1.0 (no edge)."""
|
||||
trade_pnl = pd.Series([], dtype=float)
|
||||
assert monte_carlo_trade_pvalue(trade_pnl, n_permutations=10) == 1.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rolling walk-forward tests
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_wf_rolling_keys_in_result(close_6yr):
|
||||
signal = _random_signal(close_6yr.index)
|
||||
r = backtest_signal_ftmo(close_6yr, signal, oos_start="2024-01-01", wf_rolling=True)
|
||||
# With only ~150 days of data, windows may be 0 — just check key presence
|
||||
assert "wf_n_windows" in r
|
||||
|
||||
|
||||
def test_wf_rolling_disabled_by_default(close_6yr):
|
||||
signal = _random_signal(close_6yr.index)
|
||||
r = backtest_signal_ftmo(close_6yr, signal, oos_start="2024-01-01")
|
||||
assert "wf_n_windows" not in r
|
||||
|
||||
|
||||
def test_wf_consistency_range(close_6yr):
|
||||
"""wf_oos_consistency must be in [0, 1] when windows exist."""
|
||||
signal = _random_signal(close_6yr.index)
|
||||
r = backtest_signal_ftmo(close_6yr, signal, oos_start="2024-01-01", wf_rolling=True)
|
||||
c = r.get("wf_oos_consistency")
|
||||
if c is not None:
|
||||
assert 0.0 <= c <= 1.0
|
||||
@@ -0,0 +1,249 @@
|
||||
"""Tests for FactorAutoFixer — the pre-execution code patcher."""
|
||||
|
||||
import pytest
|
||||
|
||||
from rdagent.components.coder.factor_coder.auto_fixer import FactorAutoFixer
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fixer():
|
||||
return FactorAutoFixer()
|
||||
|
||||
|
||||
class TestResetIndexGroupby:
|
||||
def test_replaces_level_groupby_on_reset_var(self, fixer):
|
||||
code = "df_r = df.reset_index()\ndf_r['x'] = df_r.groupby(level=1)['$close'].mean()"
|
||||
result = fixer.fix(code)
|
||||
assert "groupby('instrument')" in result
|
||||
|
||||
def test_does_not_touch_normal_multiindex_groupby(self, fixer):
|
||||
code = "df['x'] = df.groupby(level=1)['$close'].mean()"
|
||||
result = fixer.fix(code)
|
||||
assert "groupby(level=1)" in result
|
||||
|
||||
|
||||
class TestGroupbyMixedLevels:
|
||||
def test_strips_string_from_mixed_list(self, fixer):
|
||||
result = fixer.fix("df.groupby(level=[1, 'date']).apply(fn)")
|
||||
assert "groupby(level=1)" in result
|
||||
|
||||
def test_multiple_ints_kept(self, fixer):
|
||||
result = fixer.fix("df.groupby(level=[0, 1, 'x']).apply(fn)")
|
||||
assert "groupby(level=[0, 1])" in result
|
||||
|
||||
|
||||
class TestGroupbyColumnOnMultiindex:
|
||||
def test_instrument_date_becomes_two_level(self, fixer):
|
||||
code = "df['v'] = df.groupby(['instrument', 'date'])['$volume'].cumsum()"
|
||||
result = fixer.fix(code)
|
||||
assert "get_level_values(1)" in result
|
||||
assert "normalize()" in result
|
||||
assert "level=1)" not in result.split("get_level_values")[0]
|
||||
|
||||
def test_date_instrument_becomes_two_level(self, fixer):
|
||||
code = "df['v'] = df.groupby(['date', 'instrument'])['$volume'].cumsum()"
|
||||
result = fixer.fix(code)
|
||||
assert "get_level_values(0).normalize()" in result
|
||||
assert "get_level_values(1)" in result
|
||||
|
||||
def test_single_instrument_becomes_level1(self, fixer):
|
||||
result = fixer.fix("df.groupby(['instrument'])['x'].mean()")
|
||||
assert "groupby(level=1)" in result
|
||||
|
||||
def test_reset_index_not_double_fixed(self, fixer):
|
||||
# After reset_index fix emits groupby('instrument'), this fixer must NOT
|
||||
# convert that to groupby(level=1).
|
||||
code = "df_r = df.reset_index()\ndf_r['x'] = df_r.groupby(level=1)['p'].mean()"
|
||||
result = fixer.fix(code)
|
||||
assert "groupby('instrument')" in result
|
||||
|
||||
|
||||
class TestChainedGroupby:
|
||||
def test_chained_groupby_level_then_date(self, fixer):
|
||||
code = "df.groupby(level=1).groupby('date')['price_volume'].transform('cumsum')"
|
||||
result = fixer.fix(code)
|
||||
assert "get_level_values(1)" in result
|
||||
assert "get_level_values(0).normalize()" in result
|
||||
assert ".groupby('date')" not in result
|
||||
|
||||
def test_chained_groupby_with_double_quotes(self, fixer):
|
||||
code = 'df.groupby(level=0).groupby("date")["col"].sum()'
|
||||
result = fixer.fix(code)
|
||||
assert "get_level_values" in result
|
||||
assert '.groupby("date")' not in result
|
||||
|
||||
def test_list_with_level_keyword_syntax_error(self, fixer):
|
||||
# groupby([level=1, 'date']) is a SyntaxError — must be fixed before execution
|
||||
code = "asian_vol = df[mask].groupby([level=1, 'date'])['log_return'].std()"
|
||||
result = fixer.fix(code)
|
||||
assert "get_level_values(1)" in result
|
||||
assert "normalize()" in result
|
||||
assert "level=1," not in result
|
||||
|
||||
def test_list_with_level_keyword_reversed(self, fixer):
|
||||
code = "df.groupby(['date', level=1])['x'].mean()"
|
||||
result = fixer.fix(code)
|
||||
assert "get_level_values" in result
|
||||
assert "level=1" not in result
|
||||
|
||||
|
||||
class TestMinPeriodsNotTouched:
|
||||
def test_small_min_periods_preserved(self, fixer):
|
||||
# _fix_min_periods is disabled — LLM-set min_periods must not be changed.
|
||||
# window=60, min_periods=1 should stay as-is (was wrongly raised to 60 before).
|
||||
result = fixer.fix("df.groupby(level=1)['x'].transform(lambda x: x.rolling(window=60, min_periods=1).mean())")
|
||||
assert "min_periods=1" in result
|
||||
|
||||
def test_large_window_min_periods_preserved(self, fixer):
|
||||
# window=240 > 96 bars/day: if min_periods were set to 240 the output would be
|
||||
# all-NaN for intraday data. Verify we leave it untouched.
|
||||
result = fixer.fix("df['x'] = df.groupby(level=1)['y'].transform(lambda x: x.rolling(240, min_periods=10).std())")
|
||||
assert "min_periods=10" in result
|
||||
|
||||
|
||||
class TestInstrumentColumnAccess:
|
||||
def test_instrument_column_replaced(self, fixer):
|
||||
code = "df['group_key'] = df['instrument'] + '_' + df['day_id'].astype(str)"
|
||||
result = fixer.fix(code)
|
||||
assert "df.index.get_level_values(1)" in result
|
||||
assert "df['instrument']" not in result
|
||||
|
||||
def test_reset_index_var_not_touched(self, fixer):
|
||||
# After reset_index, 'instrument' IS a real column — must not be replaced
|
||||
code = "df_r = df.reset_index()\nval = df_r['instrument'].unique()"
|
||||
result = fixer.fix(code)
|
||||
assert "df_r['instrument']" in result
|
||||
assert "get_level_values" not in result
|
||||
|
||||
def test_groupby_after_instrument_fix(self, fixer):
|
||||
# Combined: df['instrument'] in a groupby context
|
||||
code = "df['key'] = df['instrument']\nout = df.groupby(df['key'])[['$close']].mean()"
|
||||
result = fixer.fix(code)
|
||||
assert "df['instrument']" not in result
|
||||
|
||||
def test_assignment_target_not_touched(self, fixer):
|
||||
# df['instrument'] = <expr> is an assignment — must NOT be converted to
|
||||
# df.index.get_level_values(1) = <expr> (SyntaxError)
|
||||
code = "df['instrument'] = df.index.get_level_values('instrument')"
|
||||
result = fixer.fix(code)
|
||||
assert "df['instrument'] =" in result
|
||||
|
||||
|
||||
class TestInstrumentLocMultiindex:
|
||||
def test_loc_replaced_with_xs(self, fixer):
|
||||
code = (
|
||||
"for instrument in df.index.get_level_values('instrument').unique():\n"
|
||||
" inst_df = df.loc[instrument].copy()\n"
|
||||
)
|
||||
result = fixer.fix(code)
|
||||
assert "df.xs(instrument, level=1)" in result
|
||||
assert "df.loc[instrument]" not in result
|
||||
|
||||
def test_loc_replaced_with_level1_int(self, fixer):
|
||||
code = (
|
||||
"for inst in df.index.get_level_values(1).unique():\n"
|
||||
" data = df.loc[inst]\n"
|
||||
)
|
||||
result = fixer.fix(code)
|
||||
assert "df.xs(inst, level=1)" in result
|
||||
|
||||
def test_loc_assignment_not_touched(self, fixer):
|
||||
# Write-back df.loc[instrument] = ... must not be changed
|
||||
code = (
|
||||
"for instrument in df.index.get_level_values('instrument').unique():\n"
|
||||
" df.loc[instrument] = modified\n"
|
||||
)
|
||||
result = fixer.fix(code)
|
||||
assert "df.loc[instrument] = modified" in result
|
||||
|
||||
def test_non_instrument_loop_not_touched(self, fixer):
|
||||
# for-loop not related to instrument levels must not be changed
|
||||
code = "for date in dates:\n sub = df.loc[date]\n"
|
||||
result = fixer.fix(code)
|
||||
assert "df.loc[date]" in result
|
||||
|
||||
|
||||
class TestGroupbyLevelStringNames:
|
||||
def test_level_instrument_date_replaced(self, fixer):
|
||||
code = "df.groupby(level=['instrument', 'date'])['col'].transform('sum')"
|
||||
result = fixer.fix(code)
|
||||
assert "get_level_values(1)" in result
|
||||
assert "get_level_values(0).normalize()" in result
|
||||
assert "level=['instrument', 'date']" not in result
|
||||
|
||||
def test_level_date_instrument_replaced(self, fixer):
|
||||
code = "data.groupby(level=['date', 'instrument'])['x'].mean()"
|
||||
result = fixer.fix(code)
|
||||
assert "get_level_values(0).normalize()" in result
|
||||
assert "get_level_values(1)" in result
|
||||
|
||||
def test_level_instrument_single_replaced(self, fixer):
|
||||
code = "df.groupby(level=['instrument'])['vol'].sum()"
|
||||
result = fixer.fix(code)
|
||||
assert "groupby(level=1)" in result
|
||||
assert "level=['instrument']" not in result
|
||||
|
||||
|
||||
class TestGroupbyApplyToTransform:
|
||||
def test_col_apply_lambda_replaced(self, fixer):
|
||||
code = "df_overlap.groupby(level=1)['$close'].apply(lambda x: np.log(x / x.shift(1)))"
|
||||
result = fixer.fix(code)
|
||||
assert ".transform(" in result
|
||||
assert ".apply(" not in result
|
||||
|
||||
def test_col_apply_lambda_preserves_lambda_body(self, fixer):
|
||||
code = "series.groupby(level=1)['ret'].apply(lambda x: x.cumsum())"
|
||||
result = fixer.fix(code)
|
||||
assert "lambda x: x.cumsum()" in result
|
||||
assert ".transform(" in result
|
||||
|
||||
def test_transform_reset_index_stripped(self, fixer):
|
||||
# .transform() already preserves index — .reset_index() after it is wrong
|
||||
code = "df['v'] = df.groupby(level=1)['x'].transform(lambda x: x.rolling(20).mean()).reset_index(level=0, drop=True)"
|
||||
result = fixer.fix(code)
|
||||
assert ".reset_index(level=0, drop=True)" not in result
|
||||
assert ".transform(" in result
|
||||
|
||||
|
||||
class TestZeroVolumeProxy:
|
||||
def test_injects_proxy_when_volume_used(self, fixer):
|
||||
code = (
|
||||
"def calc():\n"
|
||||
" df = pd.read_hdf('data.h5', key='data')\n"
|
||||
" df['pv'] = df['$close'] * df['$volume']\n"
|
||||
" return df[['pv']]\n"
|
||||
)
|
||||
result = fixer.fix(code)
|
||||
assert "volume proxy" in result
|
||||
assert "df['$volume'] = df['$high'] - df['$low']" in result
|
||||
# Proxy must come right after read_hdf line
|
||||
lines = result.splitlines()
|
||||
hdf_idx = next(i for i, l in enumerate(lines) if "read_hdf" in l)
|
||||
assert "volume proxy" in lines[hdf_idx + 1]
|
||||
|
||||
def test_no_injection_when_volume_absent(self, fixer):
|
||||
code = "df = pd.read_hdf('data.h5', key='data')\ndf['x'] = df['$close'].pct_change()\n"
|
||||
result = fixer.fix(code)
|
||||
assert "volume proxy" not in result
|
||||
|
||||
def test_no_double_injection(self, fixer):
|
||||
code = (
|
||||
"def calc():\n"
|
||||
" df = pd.read_hdf('data.h5', key='data')\n"
|
||||
" # volume proxy: $volume is always 0 in FX data — use price-range as proxy\n"
|
||||
" if (df['$volume'] == 0).all():\n"
|
||||
" df['$volume'] = df['$high'] - df['$low']\n"
|
||||
" df['pv'] = df['$close'] * df['$volume']\n"
|
||||
)
|
||||
result = fixer.fix(code)
|
||||
assert result.count("volume proxy") == 1
|
||||
|
||||
|
||||
class TestRollingDdof:
|
||||
def test_removes_ddof_from_rolling_args(self, fixer):
|
||||
result = fixer.fix("df.rolling(20, min_periods=1, ddof=1).std()")
|
||||
assert "ddof" not in result
|
||||
|
||||
def test_removes_ddof_from_std_args(self, fixer):
|
||||
result = fixer.fix("df.rolling(20).std(ddof=1)")
|
||||
assert "ddof" not in result
|
||||
Reference in New Issue
Block a user