mirror of
https://github.com/NicolasBohn/NexQuant.git
synced 2026-08-04 02:37:44 +00:00
Compare commits
44 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 |
@@ -12,7 +12,7 @@ jobs:
|
|||||||
release-please:
|
release-please:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: googleapis/release-please-action@v4
|
- uses: googleapis/release-please-action@v5
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
config-file: release-please-config.json
|
config-file: release-please-config.json
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "1.3.0"
|
".": "1.3.7"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,93 @@
|
|||||||
# Changelog
|
# 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)
|
## [1.3.0](https://github.com/TPTBusiness/Predix/compare/v1.2.2...v1.3.0) (2026-04-21)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -54,11 +54,11 @@ def rdagent_info():
|
|||||||
current_version = importlib.metadata.version("rdagent")
|
current_version = importlib.metadata.version("rdagent")
|
||||||
logger.info(f"RD-Agent version: {current_version}")
|
logger.info(f"RD-Agent version: {current_version}")
|
||||||
api_url = f"https://api.github.com/repos/microsoft/RD-Agent/contents/requirements.txt?ref=main"
|
api_url = f"https://api.github.com/repos/microsoft/RD-Agent/contents/requirements.txt?ref=main"
|
||||||
response = requests.get(api_url)
|
response = requests.get(api_url, timeout=30)
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
files = response.json()
|
files = response.json()
|
||||||
file_url = files["download_url"]
|
file_url = files["download_url"]
|
||||||
file_response = requests.get(file_url)
|
file_response = requests.get(file_url, timeout=30)
|
||||||
if file_response.status_code == 200:
|
if file_response.status_code == 200:
|
||||||
all_file_contents = file_response.text.split("\n")
|
all_file_contents = file_response.text.split("\n")
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ class ResultsDatabase:
|
|||||||
|
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
|
_ALLOWED_TABLES = frozenset({"factors", "backtest_runs", "loop_results"})
|
||||||
|
_ALLOWED_COL_TYPES = frozenset({"REAL", "TEXT", "INTEGER", "BLOB"})
|
||||||
|
|
||||||
def _add_column_if_not_exists(self, table: str, column: str, col_type: str) -> None:
|
def _add_column_if_not_exists(self, table: str, column: str, col_type: str) -> None:
|
||||||
"""
|
"""
|
||||||
Add a column to a table if it doesn't already exist.
|
Add a column to a table if it doesn't already exist.
|
||||||
@@ -78,20 +81,24 @@ class ResultsDatabase:
|
|||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
table : str
|
table : str
|
||||||
Table name
|
Table name (must be in _ALLOWED_TABLES)
|
||||||
column : str
|
column : str
|
||||||
Column name to add
|
Column name to add (alphanumeric + underscore only)
|
||||||
col_type : str
|
col_type : str
|
||||||
SQL column type (e.g., 'REAL', 'TEXT')
|
SQL column type (must be in _ALLOWED_COL_TYPES)
|
||||||
"""
|
"""
|
||||||
|
if table not in self._ALLOWED_TABLES:
|
||||||
|
raise ValueError(f"Unknown table: {table!r}")
|
||||||
|
if not column.replace("_", "").isalnum():
|
||||||
|
raise ValueError(f"Invalid column name: {column!r}")
|
||||||
|
if col_type not in self._ALLOWED_COL_TYPES:
|
||||||
|
raise ValueError(f"Invalid column type: {col_type!r}")
|
||||||
|
|
||||||
c = self.conn.cursor()
|
c = self.conn.cursor()
|
||||||
try:
|
c.execute("SELECT name FROM pragma_table_info(?)", (table,))
|
||||||
# Try to query the column - if it fails, it doesn't exist
|
existing = {row[0] for row in c.fetchall()}
|
||||||
# nosec B608: Internal schema migration, column names are controlled
|
if column not in existing:
|
||||||
c.execute(f"SELECT {column} FROM {table} LIMIT 1") # nosec B608
|
c.execute(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}")
|
||||||
except sqlite3.OperationalError:
|
|
||||||
# Column doesn't exist, add it
|
|
||||||
c.execute(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}") # nosec B608
|
|
||||||
|
|
||||||
def add_factor(self, name: str, type: str = "unknown") -> int:
|
def add_factor(self, name: str, type: str = "unknown") -> int:
|
||||||
c = self.conn.cursor()
|
c = self.conn.cursor()
|
||||||
@@ -183,16 +190,18 @@ class ResultsDatabase:
|
|||||||
pd.DataFrame
|
pd.DataFrame
|
||||||
DataFrame with factor names and metrics
|
DataFrame with factor names and metrics
|
||||||
"""
|
"""
|
||||||
# Map shorthand to full column name
|
_ALLOWED_METRICS = frozenset({
|
||||||
|
'sharpe', 'ic', 'annual_return', 'max_drawdown',
|
||||||
|
'win_rate', 'information_ratio', 'volatility',
|
||||||
|
})
|
||||||
metric_map = {
|
metric_map = {
|
||||||
'sharpe': 'sharpe',
|
'sharpe': 'sharpe', 'ic': 'ic', 'return': 'annual_return',
|
||||||
'ic': 'ic',
|
'drawdown': 'max_drawdown', 'win_rate': 'win_rate',
|
||||||
'return': 'annual_return',
|
|
||||||
'drawdown': 'max_drawdown',
|
|
||||||
'win_rate': 'win_rate',
|
|
||||||
'information_ratio': 'information_ratio',
|
'information_ratio': 'information_ratio',
|
||||||
}
|
}
|
||||||
col = metric_map.get(metric, metric)
|
col = metric_map.get(metric, metric)
|
||||||
|
if col not in _ALLOWED_METRICS:
|
||||||
|
raise ValueError(f"Unknown metric: {metric!r}")
|
||||||
|
|
||||||
return pd.read_sql_query(
|
return pd.read_sql_query(
|
||||||
f"""SELECT factor_name, ic, sharpe, annual_return, max_drawdown,
|
f"""SELECT factor_name, ic, sharpe, annual_return, max_drawdown,
|
||||||
@@ -201,7 +210,7 @@ class ResultsDatabase:
|
|||||||
JOIN factors ON factor_id = factors.id
|
JOIN factors ON factor_id = factors.id
|
||||||
WHERE {col} IS NOT NULL
|
WHERE {col} IS NOT NULL
|
||||||
ORDER BY {col} DESC
|
ORDER BY {col} DESC
|
||||||
LIMIT ?""",
|
LIMIT ?""", # nosec B608 — col is validated against _ALLOWED_METRICS above
|
||||||
self.conn,
|
self.conn,
|
||||||
params=[limit]
|
params=[limit]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -356,11 +356,12 @@ def monte_carlo_trade_pvalue(
|
|||||||
"""
|
"""
|
||||||
Monte Carlo permutation test on trade-level P&L.
|
Monte Carlo permutation test on trade-level P&L.
|
||||||
|
|
||||||
Shuffles the order of trade returns ``n_permutations`` times and computes
|
Runs a one-sided binomial test on trade-level win rate.
|
||||||
the fraction of runs whose total return is >= the real total return.
|
|
||||||
|
|
||||||
p < 0.05 → strategy has a statistically significant edge (real return
|
Tests H0: win_rate = 0.5 (random trading) against H1: win_rate > 0.5.
|
||||||
beats 95% of random sequences with the same set of trades).
|
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
|
Parameters
|
||||||
----------
|
----------
|
||||||
@@ -379,14 +380,14 @@ def monte_carlo_trade_pvalue(
|
|||||||
if len(trade_pnl) < 2:
|
if len(trade_pnl) < 2:
|
||||||
return 1.0
|
return 1.0
|
||||||
trades = trade_pnl.values.copy()
|
trades = trade_pnl.values.copy()
|
||||||
real_total = float(trades.sum())
|
# Binomial test: is the win rate significantly above 50%?
|
||||||
rng = np.random.default_rng(seed)
|
# p = probability of observing >= n_wins out of n_trades under null (win_rate=0.5).
|
||||||
beat = 0
|
# Low p → strategy has a significant positive edge per trade.
|
||||||
for _ in range(n_permutations):
|
from scipy.stats import binomtest
|
||||||
perm = rng.permutation(trades)
|
n_wins = int((trades > 0).sum())
|
||||||
if perm.sum() >= real_total:
|
n_total = len(trades)
|
||||||
beat += 1
|
result = binomtest(n_wins, n_total, p=0.5, alternative="greater")
|
||||||
return beat / n_permutations
|
return float(result.pvalue)
|
||||||
|
|
||||||
|
|
||||||
def walk_forward_rolling(
|
def walk_forward_rolling(
|
||||||
|
|||||||
@@ -51,13 +51,23 @@ class FactorAutoFixer:
|
|||||||
self.fixes_applied = []
|
self.fixes_applied = []
|
||||||
fixed_code = code
|
fixed_code = code
|
||||||
|
|
||||||
# Apply fixes in order - groupby fixes MUST come before min_periods fixes
|
# Apply fixes in order
|
||||||
|
# NOTE: _fix_min_periods is intentionally excluded — it increased min_periods to
|
||||||
|
# match window size, which causes all-NaN output for intraday data with 96 bars/day
|
||||||
|
# (window=240 > 96 means zero valid bars per day). The LLM sets its own min_periods.
|
||||||
fix_methods = [
|
fix_methods = [
|
||||||
self._fix_groupby_apply_to_transform, # First: fix groupby patterns
|
self._fix_instrument_column_access, # First: fix df['instrument'] on MultiIndex
|
||||||
self._fix_min_periods, # Second: fix min_periods in resulting rolling calls
|
self._fix_instrument_loc_multiindex, # Second: fix df.loc[instrument_var] on MultiIndex
|
||||||
self._fix_inf_nan_handling, # Third: add inf/nan handling
|
self._fix_zero_volume_proxy, # Third: replace zero $volume with range proxy
|
||||||
self._fix_data_range_processing, # Fourth: ensure full data range
|
self._fix_reset_index_groupby, # Fourth: fix groupby(level=N) after reset_index()
|
||||||
self._fix_multiindex_groupby, # Fifth: ensure groupby on MultiIndex
|
self._fix_groupby_mixed_levels, # Fifth: fix groupby(level=[int, str])
|
||||||
|
self._fix_groupby_column_on_multiindex, # Sixth: fix groupby(['instrument','date']) on MultiIndex
|
||||||
|
self._fix_chained_groupby, # Seventh: fix groupby(level=N).groupby('date') chain
|
||||||
|
self._fix_rolling_ddof, # Eighth: remove unsupported ddof kwarg
|
||||||
|
self._fix_groupby_apply_to_transform, # Ninth: fix groupby patterns
|
||||||
|
self._fix_inf_nan_handling, # Tenth: add inf/nan handling
|
||||||
|
self._fix_data_range_processing, # Eleventh: ensure full data range
|
||||||
|
self._fix_multiindex_groupby, # Twelfth: ensure groupby on MultiIndex
|
||||||
]
|
]
|
||||||
|
|
||||||
for fix_method in fix_methods:
|
for fix_method in fix_methods:
|
||||||
@@ -75,6 +85,352 @@ class FactorAutoFixer:
|
|||||||
|
|
||||||
return fixed_code
|
return fixed_code
|
||||||
|
|
||||||
|
def _fix_instrument_column_access(self, code: str) -> str:
|
||||||
|
"""
|
||||||
|
Fix: df['instrument'] raises KeyError on a MultiIndex DataFrame because
|
||||||
|
'instrument' is an index level (level 1), not a column.
|
||||||
|
|
||||||
|
Replace df['instrument'] with df.index.get_level_values('instrument')
|
||||||
|
but only when the DataFrame has a MultiIndex (not after reset_index which
|
||||||
|
would have promoted it to a real column).
|
||||||
|
|
||||||
|
Also fixes df.reset_index()['instrument'] correctly since after reset_index
|
||||||
|
the column exists.
|
||||||
|
"""
|
||||||
|
fixed_code = code
|
||||||
|
|
||||||
|
# Skip if already fixed or if reset_index() is being used before the access
|
||||||
|
# We only fix bare df['instrument'] where df is the original MultiIndex frame.
|
||||||
|
# Heuristic: if the assignment lhs or context shows reset_index, leave it alone.
|
||||||
|
|
||||||
|
# Pattern: <varname>['instrument'] where varname is NOT a reset_index result
|
||||||
|
reset_vars = set(re.findall(r'(\w+)\s*=\s*\w[^=\n]*\.reset_index\(', fixed_code))
|
||||||
|
|
||||||
|
def _replace_instrument_access(m: re.Match) -> str:
|
||||||
|
var = m.group(1)
|
||||||
|
if var in reset_vars:
|
||||||
|
return m.group(0) # leave reset_index vars alone — column exists
|
||||||
|
self.fixes_applied.append(f"instrument_column: {var}['instrument'] → get_level_values(1)")
|
||||||
|
return f"{var}.index.get_level_values(1)"
|
||||||
|
|
||||||
|
# Exclude assignment targets: var['instrument'] = ... must not become
|
||||||
|
# var.index.get_level_values(1) = ... (SyntaxError: cannot assign to function call)
|
||||||
|
fixed_code = re.sub(r"(\w+)\['instrument'\](?!\s*=)", _replace_instrument_access, fixed_code)
|
||||||
|
|
||||||
|
return fixed_code
|
||||||
|
|
||||||
|
def _fix_instrument_loc_multiindex(self, code: str) -> str:
|
||||||
|
"""
|
||||||
|
Fix: df.loc[instrument_var] raises DateParseError on a (datetime, instrument)
|
||||||
|
MultiIndex because pandas tries to match the instrument string against the
|
||||||
|
datetime level (level 0).
|
||||||
|
|
||||||
|
Pattern detected: for-loops iterating over get_level_values('instrument') or
|
||||||
|
get_level_values(1) where the loop variable is then used as df.loc[loop_var].
|
||||||
|
|
||||||
|
Replacement: df.loc[instrument_var] → df.xs(instrument_var, level=1)
|
||||||
|
"""
|
||||||
|
fixed_code = code
|
||||||
|
|
||||||
|
# Find variables iterated from get_level_values('instrument') or get_level_values(1)
|
||||||
|
inst_vars = set(
|
||||||
|
re.findall(
|
||||||
|
r"for\s+(\w+)\s+in\s+.+?\.get_level_values\s*\(\s*(?:1|['\"]instrument['\"])\s*\)[^:\n]*:",
|
||||||
|
code,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not inst_vars:
|
||||||
|
return fixed_code
|
||||||
|
|
||||||
|
for var in inst_vars:
|
||||||
|
# Replace DF.loc[var] (read) with DF.xs(var, level=1)
|
||||||
|
# Exclude write-back patterns (DF.loc[var] = ...) — leave those as-is
|
||||||
|
def _make_replacer(v: str):
|
||||||
|
def _replace(m: re.Match) -> str:
|
||||||
|
df_var = m.group(1)
|
||||||
|
self.fixes_applied.append(
|
||||||
|
f"instrument_loc: {df_var}.loc[{v}] → {df_var}.xs({v}, level=1)"
|
||||||
|
)
|
||||||
|
return f"{df_var}.xs({v}, level=1)"
|
||||||
|
|
||||||
|
return _replace
|
||||||
|
|
||||||
|
# Only match when NOT followed by ' =' (assignment)
|
||||||
|
fixed_code = re.sub(
|
||||||
|
rf"(\w+)\.loc\[\s*{re.escape(var)}\s*\](?!\s*=)",
|
||||||
|
_make_replacer(var),
|
||||||
|
fixed_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
return fixed_code
|
||||||
|
|
||||||
|
def _fix_zero_volume_proxy(self, code: str) -> str:
|
||||||
|
"""
|
||||||
|
Fix: $volume is always 0 in our EUR/USD dataset (FX has no real volume).
|
||||||
|
Any factor using $volume (VWAP, volume-weighted returns, etc.) produces
|
||||||
|
all-NaN output because 0*price=0 and sum(0)/sum(0)=NaN.
|
||||||
|
|
||||||
|
Insert a guard right after pd.read_hdf() that replaces zero volume with
|
||||||
|
the intraday price-range proxy ($high - $low) so volume-weighted factors
|
||||||
|
produce meaningful signals.
|
||||||
|
"""
|
||||||
|
if "'$volume'" not in code and '"$volume"' not in code:
|
||||||
|
return code
|
||||||
|
|
||||||
|
# Already patched
|
||||||
|
if "volume proxy" in code:
|
||||||
|
return code
|
||||||
|
|
||||||
|
lines = code.splitlines()
|
||||||
|
insert_after = -1
|
||||||
|
df_var = "df"
|
||||||
|
indent = " "
|
||||||
|
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if "read_hdf(" in line:
|
||||||
|
m = re.match(r"(\s*)(\w+)\s*=\s*", line)
|
||||||
|
if m:
|
||||||
|
indent = m.group(1)
|
||||||
|
df_var = m.group(2)
|
||||||
|
else:
|
||||||
|
m2 = re.match(r"(\s*)", line)
|
||||||
|
indent = m2.group(1) if m2 else " "
|
||||||
|
insert_after = i
|
||||||
|
break
|
||||||
|
|
||||||
|
if insert_after == -1:
|
||||||
|
return code
|
||||||
|
|
||||||
|
proxy_lines = [
|
||||||
|
f"{indent}# volume proxy: $volume is always 0 in FX data — use price-range as proxy",
|
||||||
|
f"{indent}if ({df_var}['$volume'] == 0).all():",
|
||||||
|
f"{indent} {df_var}['$volume'] = {df_var}['$high'] - {df_var}['$low']",
|
||||||
|
]
|
||||||
|
lines = lines[: insert_after + 1] + proxy_lines + lines[insert_after + 1 :]
|
||||||
|
self.fixes_applied.append("volume_proxy: replaced zero $volume with ($high - $low)")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _fix_reset_index_groupby(self, code: str) -> str:
|
||||||
|
"""
|
||||||
|
Fix: groupby(level=N) on a variable created by .reset_index() fails because
|
||||||
|
reset_index() converts the MultiIndex into regular columns, leaving a plain
|
||||||
|
RangeIndex. Replace groupby(level=N) on such variables with
|
||||||
|
groupby('instrument').
|
||||||
|
|
||||||
|
Detected pattern:
|
||||||
|
varname = <anything>.reset_index(...)
|
||||||
|
...
|
||||||
|
varname.groupby(level=0|1)
|
||||||
|
"""
|
||||||
|
fixed_code = code
|
||||||
|
|
||||||
|
# Find all variables assigned via reset_index()
|
||||||
|
reset_vars = set(re.findall(r'(\w+)\s*=\s*\w[^=\n]*\.reset_index\(', fixed_code))
|
||||||
|
|
||||||
|
for var in reset_vars:
|
||||||
|
# Replace var.groupby(level=N) with var.groupby('instrument')
|
||||||
|
pattern = rf'{re.escape(var)}\.groupby\(level\s*=\s*\d+\)'
|
||||||
|
if re.search(pattern, fixed_code):
|
||||||
|
fixed_code = re.sub(pattern, f"{var}.groupby('instrument')", fixed_code)
|
||||||
|
self.fixes_applied.append(f"reset_index_groupby: {var}.groupby(level=N) → groupby('instrument')")
|
||||||
|
|
||||||
|
return fixed_code
|
||||||
|
|
||||||
|
def _fix_groupby_mixed_levels(self, code: str) -> str:
|
||||||
|
"""
|
||||||
|
Fix: groupby(level=[int, 'str']) raises AssertionError because string level
|
||||||
|
names don't exist on an unnamed MultiIndex. Keep only integer levels.
|
||||||
|
|
||||||
|
Pattern: .groupby(level=[0, 'date']) → .groupby(level=0)
|
||||||
|
.groupby(level=[1, 'date']) → .groupby(level=1)
|
||||||
|
"""
|
||||||
|
fixed_code = code
|
||||||
|
|
||||||
|
def _keep_int_levels(m):
|
||||||
|
inner = m.group(1)
|
||||||
|
ints = re.findall(r'\b(\d+)\b', inner)
|
||||||
|
if not ints:
|
||||||
|
return m.group(0)
|
||||||
|
replacement = f'.groupby(level={ints[0]})' if len(ints) == 1 else f'.groupby(level=[{", ".join(ints)}])'
|
||||||
|
self.fixes_applied.append(f"mixed_levels: groupby(level=[...,str]) → {replacement}")
|
||||||
|
return replacement
|
||||||
|
|
||||||
|
fixed_code = re.sub(r'\.groupby\(level=\[([^\]]+)\]\)', _keep_int_levels, fixed_code)
|
||||||
|
return fixed_code
|
||||||
|
|
||||||
|
def _fix_groupby_column_on_multiindex(self, code: str) -> str:
|
||||||
|
"""
|
||||||
|
Fix: groupby(['instrument', 'date']) on a MultiIndex (datetime, instrument)
|
||||||
|
DataFrame fails with KeyError because those are index levels, not columns.
|
||||||
|
|
||||||
|
Correct replacement preserves BOTH dimensions so intraday calculations reset
|
||||||
|
per day:
|
||||||
|
var.groupby(['instrument', 'date'])
|
||||||
|
→ var.groupby([var.index.get_level_values(1), var.index.get_level_values(0).normalize()])
|
||||||
|
|
||||||
|
Single-column groupby(['instrument']) is correctly replaced with groupby(level=1).
|
||||||
|
Note: do NOT convert groupby('instrument') → groupby(level=1) here — that would
|
||||||
|
undo the reset_index_groupby fix which correctly emits groupby('instrument').
|
||||||
|
"""
|
||||||
|
fixed_code = code
|
||||||
|
|
||||||
|
# Variables created via reset_index() have a plain RangeIndex — applying
|
||||||
|
# get_level_values() on them would raise AttributeError. Skip those.
|
||||||
|
reset_vars = set(re.findall(r'(\w+)\s*=\s*\w[^=\n]*\.reset_index\(', fixed_code))
|
||||||
|
|
||||||
|
def _replace_two_col_groupby(m: re.Match, order: str) -> str:
|
||||||
|
var = m.group(1)
|
||||||
|
if var in reset_vars:
|
||||||
|
return m.group(0) # leave reset_index vars alone — RangeIndex, not MultiIndex
|
||||||
|
if order == "instrument_date":
|
||||||
|
repl = (
|
||||||
|
f"{var}.groupby([{var}.index.get_level_values(1), "
|
||||||
|
f"{var}.index.get_level_values(0).normalize()])"
|
||||||
|
)
|
||||||
|
else: # date_instrument
|
||||||
|
repl = (
|
||||||
|
f"{var}.groupby([{var}.index.get_level_values(0).normalize(), "
|
||||||
|
f"{var}.index.get_level_values(1)])"
|
||||||
|
)
|
||||||
|
self.fixes_applied.append(f"multiindex_groupby: {m.group(0)[:60]} → two-level")
|
||||||
|
return repl
|
||||||
|
|
||||||
|
# groupby(['instrument', 'date']) — capture variable name before .groupby
|
||||||
|
fixed_code = re.sub(
|
||||||
|
r'(\w+)\.groupby\(\[\'instrument\',\s*\'date\'\]\)',
|
||||||
|
lambda m: _replace_two_col_groupby(m, "instrument_date"),
|
||||||
|
fixed_code,
|
||||||
|
)
|
||||||
|
# groupby(['date', 'instrument'])
|
||||||
|
fixed_code = re.sub(
|
||||||
|
r'(\w+)\.groupby\(\[\'date\',\s*\'instrument\'\]\)',
|
||||||
|
lambda m: _replace_two_col_groupby(m, "date_instrument"),
|
||||||
|
fixed_code,
|
||||||
|
)
|
||||||
|
# single: groupby(['instrument']) → groupby(level=1), but not on reset_index vars
|
||||||
|
def _replace_single_instrument_groupby(m: re.Match) -> str:
|
||||||
|
# Look backwards to find the variable name
|
||||||
|
prefix = fixed_code[: m.start()]
|
||||||
|
var_match = re.search(r'(\w+)\s*$', prefix)
|
||||||
|
var = var_match.group(1) if var_match else ''
|
||||||
|
if var in reset_vars:
|
||||||
|
return m.group(0)
|
||||||
|
self.fixes_applied.append("multiindex_groupby: groupby(['instrument']) → groupby(level=1)")
|
||||||
|
return ".groupby(level=1)"
|
||||||
|
|
||||||
|
if re.search(r"\.groupby\(\['instrument'\]\)", fixed_code):
|
||||||
|
fixed_code = re.sub(r"\.groupby\(\['instrument'\]\)", _replace_single_instrument_groupby, fixed_code)
|
||||||
|
|
||||||
|
# groupby(level=['instrument', 'date']) — uses level= keyword with string names.
|
||||||
|
# 'date' is NOT a valid level name in our (datetime, instrument) MultiIndex;
|
||||||
|
# replace with get_level_values to normalize datetime to daily timestamps.
|
||||||
|
fixed_code = re.sub(
|
||||||
|
r"(\w+)\.groupby\(level=\['instrument',\s*'date'\]\)",
|
||||||
|
lambda m: (
|
||||||
|
self.fixes_applied.append(
|
||||||
|
f"multiindex_groupby: {m.group(0)[:60]} → two-level get_level_values"
|
||||||
|
)
|
||||||
|
or f"{m.group(1)}.groupby([{m.group(1)}.index.get_level_values(1), "
|
||||||
|
f"{m.group(1)}.index.get_level_values(0).normalize()])"
|
||||||
|
),
|
||||||
|
fixed_code,
|
||||||
|
)
|
||||||
|
# groupby(level=['date', 'instrument'])
|
||||||
|
fixed_code = re.sub(
|
||||||
|
r"(\w+)\.groupby\(level=\['date',\s*'instrument'\]\)",
|
||||||
|
lambda m: (
|
||||||
|
self.fixes_applied.append(
|
||||||
|
f"multiindex_groupby: {m.group(0)[:60]} → two-level get_level_values"
|
||||||
|
)
|
||||||
|
or f"{m.group(1)}.groupby([{m.group(1)}.index.get_level_values(0).normalize(), "
|
||||||
|
f"{m.group(1)}.index.get_level_values(1)])"
|
||||||
|
),
|
||||||
|
fixed_code,
|
||||||
|
)
|
||||||
|
# single: groupby(level=['instrument']) → groupby(level=1)
|
||||||
|
fixed_code = re.sub(
|
||||||
|
r"\.groupby\(level=\['instrument'\]\)",
|
||||||
|
lambda m: (self.fixes_applied.append("multiindex_groupby: groupby(level=['instrument']) → level=1") or ".groupby(level=1)"),
|
||||||
|
fixed_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
return fixed_code
|
||||||
|
|
||||||
|
def _fix_chained_groupby(self, code: str) -> str:
|
||||||
|
"""
|
||||||
|
Fix two broken patterns the LLM generates when trying to group by (instrument, date):
|
||||||
|
|
||||||
|
Pattern A — chained groupby (runtime AttributeError):
|
||||||
|
var.groupby(level=1).groupby('date')
|
||||||
|
→ var.groupby([var.index.get_level_values(1),
|
||||||
|
var.index.get_level_values(0).normalize()])
|
||||||
|
|
||||||
|
Pattern B — keyword arg inside list (SyntaxError):
|
||||||
|
var.groupby([level=1, 'date'])
|
||||||
|
→ same two-level replacement
|
||||||
|
"""
|
||||||
|
fixed_code = code
|
||||||
|
|
||||||
|
def _two_level(var: str, tag: str) -> str:
|
||||||
|
self.fixes_applied.append(f"chained_groupby: {tag} → two-level")
|
||||||
|
return (
|
||||||
|
f"{var}.groupby([{var}.index.get_level_values(1), "
|
||||||
|
f"{var}.index.get_level_values(0).normalize()])"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pattern A: var.groupby(level=N).groupby('date')
|
||||||
|
fixed_code = re.sub(
|
||||||
|
r'(\w+)\.groupby\(level=\d+\)\.groupby\(["\']date["\']\)',
|
||||||
|
lambda m: _two_level(m.group(1), m.group(0)[:60]),
|
||||||
|
fixed_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pattern B: .groupby([level=N, 'date']) — SyntaxError in Python.
|
||||||
|
# The variable before .groupby may be complex (e.g. df[mask]) so we don't
|
||||||
|
# try to capture it; we use df as the index reference (always correct since
|
||||||
|
# all filtered frames share df's MultiIndex structure).
|
||||||
|
def _two_level_df(tag: str) -> str:
|
||||||
|
self.fixes_applied.append(f"chained_groupby: {tag} → two-level")
|
||||||
|
return ".groupby([df.index.get_level_values(1), df.index.get_level_values(0).normalize()])"
|
||||||
|
|
||||||
|
fixed_code = re.sub(
|
||||||
|
r'\.groupby\(\[\s*level\s*=\s*\d+\s*,\s*["\']?date["\']?\s*\]\)',
|
||||||
|
lambda m: _two_level_df(m.group(0)[:60]),
|
||||||
|
fixed_code,
|
||||||
|
)
|
||||||
|
# Also handle reversed order: ['date', level=N]
|
||||||
|
fixed_code = re.sub(
|
||||||
|
r'\.groupby\(\[\s*["\']?date["\']?\s*,\s*level\s*=\s*\d+\s*\]\)',
|
||||||
|
lambda m: _two_level_df(m.group(0)[:60]),
|
||||||
|
fixed_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
return fixed_code
|
||||||
|
|
||||||
|
def _fix_rolling_ddof(self, code: str) -> str:
|
||||||
|
"""
|
||||||
|
Fix: pandas rolling() does not accept a ddof kwarg — raises TypeError.
|
||||||
|
Remove ddof from both rolling(..., ddof=N) and rolling(...).std(ddof=N).
|
||||||
|
"""
|
||||||
|
fixed_code = code
|
||||||
|
|
||||||
|
# Form 1: ddof inside rolling() — .rolling(window=N, min_periods=M, ddof=K)
|
||||||
|
def _strip_ddof_from_rolling(m):
|
||||||
|
inner = re.sub(r',?\s*ddof\s*=\s*\d+', '', m.group(1))
|
||||||
|
inner = inner.strip(', ')
|
||||||
|
self.fixes_applied.append("rolling_ddof: removed ddof from rolling()")
|
||||||
|
return f'.rolling({inner})'
|
||||||
|
|
||||||
|
fixed_code = re.sub(r'\.rolling\(([^)]*ddof\s*=\s*\d+[^)]*)\)', _strip_ddof_from_rolling, fixed_code)
|
||||||
|
|
||||||
|
# Form 2: ddof inside .std() / .var() — .std(ddof=N)
|
||||||
|
if re.search(r'\.(std|var)\([^)]*ddof\s*=\s*\d+', fixed_code):
|
||||||
|
fixed_code = re.sub(r'\.(std|var)\([^)]*ddof\s*=\s*\d+[^)]*\)', r'.\1()', fixed_code)
|
||||||
|
self.fixes_applied.append("rolling_ddof: removed ddof from std()/var()")
|
||||||
|
|
||||||
|
return fixed_code
|
||||||
|
|
||||||
def _fix_min_periods(self, code: str) -> str:
|
def _fix_min_periods(self, code: str) -> str:
|
||||||
"""
|
"""
|
||||||
Fix: Ensure min_periods matches window size in rolling calculations.
|
Fix: Ensure min_periods matches window size in rolling calculations.
|
||||||
@@ -325,6 +681,45 @@ class FactorAutoFixer:
|
|||||||
fixed_code = fixed_code.replace(old_code, new_code)
|
fixed_code = fixed_code.replace(old_code, new_code)
|
||||||
self.fixes_applied.append(f"groupby: fixed rolling correlation (window={window}) with reset_index")
|
self.fixes_applied.append(f"groupby: fixed rolling correlation (window={window}) with reset_index")
|
||||||
|
|
||||||
|
# === GENERAL FIX: DF.groupby(level=N)['col'].apply(lambda x: EXPR) ===
|
||||||
|
# apply() on a grouped Series returns a MultiIndex result (extra level prepended),
|
||||||
|
# causing index shape mismatch when assigned back to df['col'].
|
||||||
|
# Replace with transform() which preserves the original index.
|
||||||
|
col_apply_pattern = re.compile(
|
||||||
|
r"(\w+)\.groupby\(level=(\d+)\)\['([^']+)'\]\.apply\((\s*lambda\s+\w+\s*:.*?)\)",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
for m in list(col_apply_pattern.finditer(fixed_code)):
|
||||||
|
full = m.group(0)
|
||||||
|
df_var = m.group(1)
|
||||||
|
level = m.group(2)
|
||||||
|
col = m.group(3)
|
||||||
|
lam = m.group(4).strip()
|
||||||
|
new_expr = f"{df_var}.groupby(level={level})['{col}'].transform({lam})"
|
||||||
|
fixed_code = fixed_code.replace(full, new_expr, 1)
|
||||||
|
self.fixes_applied.append(
|
||||||
|
f"groupby: {df_var}.groupby(level={level})['{col}'].apply() → transform()"
|
||||||
|
)
|
||||||
|
|
||||||
|
# === FIX: .transform(...).reset_index(level=N, drop=True) ===
|
||||||
|
# transform() already returns the same index as the input — adding reset_index()
|
||||||
|
# after it drops an index level and causes ValueError on assignment back to df['col'].
|
||||||
|
# Detected line-by-line: if a line contains both .transform( and .reset_index(level=
|
||||||
|
reset_suffix = re.compile(r'\s*\.reset_index\s*\(\s*level\s*=[^,)]+,\s*drop\s*=\s*True\s*\)\s*$')
|
||||||
|
new_lines = []
|
||||||
|
changed = False
|
||||||
|
for line in fixed_code.splitlines():
|
||||||
|
if '.transform(' in line and '.reset_index(' in line:
|
||||||
|
cleaned = reset_suffix.sub('', line)
|
||||||
|
if cleaned != line:
|
||||||
|
new_lines.append(cleaned)
|
||||||
|
changed = True
|
||||||
|
continue
|
||||||
|
new_lines.append(line)
|
||||||
|
if changed:
|
||||||
|
fixed_code = '\n'.join(new_lines)
|
||||||
|
self.fixes_applied.append("groupby: removed spurious .reset_index() after .transform()")
|
||||||
|
|
||||||
# Pattern: Simple groupby().apply() with rolling().method()
|
# Pattern: Simple groupby().apply() with rolling().method()
|
||||||
# df.groupby(level=N).apply(lambda x: x['col'].rolling(...).method())
|
# df.groupby(level=N).apply(lambda x: x['col'].rolling(...).method())
|
||||||
apply_pattern = r"df\.groupby\(level=(\d+)\)\.apply\(\s*lambda\s+x:\s+x\['([^']+)'\]\.rolling\([^)]+\)\.(\w+)\([^)]*\)\s*\)"
|
apply_pattern = r"df\.groupby\(level=(\d+)\)\.apply\(\s*lambda\s+x:\s+x\['([^']+)'\]\.rolling\([^)]+\)\.(\w+)\([^)]*\)\s*\)"
|
||||||
|
|||||||
@@ -161,8 +161,7 @@ class FactorFBWorkspace(FBWorkspace):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
subprocess.check_output(
|
subprocess.check_output(
|
||||||
f"{FACTOR_COSTEER_SETTINGS.python_bin} {execution_code_path}",
|
[FACTOR_COSTEER_SETTINGS.python_bin, str(execution_code_path)],
|
||||||
shell=True,
|
|
||||||
cwd=self.workspace_path,
|
cwd=self.workspace_path,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
timeout=FACTOR_COSTEER_SETTINGS.file_based_execution_timeout,
|
timeout=FACTOR_COSTEER_SETTINGS.file_based_execution_timeout,
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ evolving_strategy_factor_implementation_v1_system: |-
|
|||||||
- ALWAYS use `min_periods=N` where N equals the window size in rolling calculations (e.g., `.rolling(20, min_periods=20)`)
|
- ALWAYS use `min_periods=N` where N equals the window size in rolling calculations (e.g., `.rolling(20, min_periods=20)`)
|
||||||
- ALWAYS handle infinite values after division: `.replace([np.inf, -np.inf], np.nan)` before saving results
|
- ALWAYS handle infinite values after division: `.replace([np.inf, -np.inf], np.nan)` before saving results
|
||||||
- ALWAYS use `groupby(level=1)` or `groupby('instrument')` before rolling operations on MultiIndex dataframes
|
- ALWAYS use `groupby(level=1)` or `groupby('instrument')` before rolling operations on MultiIndex dataframes
|
||||||
- Process the COMPLETE date range (2020-2026), do NOT filter by date
|
- Process the COMPLETE date range available in the HDF5 file (do NOT filter by date — the file may contain 2024 debug data or full 2020-2026 data)
|
||||||
- Use `groupby().transform()` instead of `groupby().apply()` for single-column assignments
|
- Use `groupby().transform()` instead of `groupby().apply()` for single-column assignments
|
||||||
|
|
||||||
Notice that you should not add any other text before or after the json format.
|
Notice that you should not add any other text before or after the json format.
|
||||||
|
|||||||
+22
-15
@@ -27,6 +27,7 @@ Usage:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json as _json
|
import json as _json
|
||||||
|
import logging
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
@@ -36,21 +37,24 @@ from typing import Any
|
|||||||
|
|
||||||
from loguru import logger as _root
|
from loguru import logger as _root
|
||||||
|
|
||||||
# ── paths ─────────────────────────────────────────────────────────────────────
|
# ── paths ─────────────────────────────────────────────────────────────────────────────────
|
||||||
LOGS_ROOT: Path = Path(__file__).parent.parent.parent / "logs"
|
LOGS_ROOT: Path = Path(__file__).parent.parent.parent / "logs"
|
||||||
|
|
||||||
# ── format ────────────────────────────────────────────────────────────────────
|
# ── format ────────────────────────────────────────────────────────────────────────────────
|
||||||
_FILE_FMT = (
|
_FILE_FMT = (
|
||||||
"{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {extra[cmd]: <18} | {message}"
|
"{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {extra[cmd]: <18} | {message}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── internal state ─────────────────────────────────────────────────────────────
|
# ── internal state ─────────────────────────────────────────────────────────────────────────────
|
||||||
_registered: set[str] = set() # command keys that already have a file sink
|
_registered: set[str] = set() # command keys that already have a file sink
|
||||||
_all_added: bool = False # whether the combined all.log sink is active
|
_all_added: bool = False # whether the combined all.log sink is active
|
||||||
_llm_log_lock = threading.Lock() # guards concurrent writes to llm_calls.jsonl
|
_llm_log_lock = threading.Lock() # guards concurrent writes to llm_calls.jsonl
|
||||||
|
|
||||||
|
# Maximum characters stored per field in llm_calls.jsonl to prevent GB-scale files.
|
||||||
|
_LLM_CALL_MAX_CHARS = 500
|
||||||
|
|
||||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
|
||||||
|
# ── helpers ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _today_dir() -> Path:
|
def _today_dir() -> Path:
|
||||||
d = LOGS_ROOT / datetime.now().strftime("%Y-%m-%d")
|
d = LOGS_ROOT / datetime.now().strftime("%Y-%m-%d")
|
||||||
@@ -79,7 +83,7 @@ def _banner(log, title: str, meta: dict[str, Any]) -> None:
|
|||||||
log.info(sep)
|
log.info(sep)
|
||||||
|
|
||||||
|
|
||||||
# ── public API ────────────────────────────────────────────────────────────────
|
# ── public API ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def log_llm_call(
|
def log_llm_call(
|
||||||
system: str | None,
|
system: str | None,
|
||||||
@@ -88,16 +92,19 @@ def log_llm_call(
|
|||||||
start_time: Any = None,
|
start_time: Any = None,
|
||||||
end_time: Any = None,
|
end_time: Any = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Append one complete LLM call to logs/YYYY-MM-DD/llm_calls.jsonl.
|
"""Append one LLM call summary to logs/YYYY-MM-DD/llm_calls.jsonl.
|
||||||
|
|
||||||
|
Prompt/response content is capped at _LLM_CALL_MAX_CHARS to prevent
|
||||||
|
GB-scale log files from long-running loops.
|
||||||
|
|
||||||
Each line is a self-contained JSON object so the file is grep/jq-friendly:
|
Each line is a self-contained JSON object so the file is grep/jq-friendly:
|
||||||
jq 'select(.duration_ms > 5000)' logs/2026-04-17/llm_calls.jsonl
|
jq 'select(.duration_ms > 5000)' logs/2026-04-17/llm_calls.jsonl
|
||||||
"""
|
"""
|
||||||
entry: dict[str, Any] = {
|
entry: dict[str, Any] = {
|
||||||
"ts": datetime.now().isoformat(timespec="milliseconds"),
|
"ts": datetime.now().isoformat(timespec="milliseconds"),
|
||||||
"system": system or "",
|
"system": (system or "")[:_LLM_CALL_MAX_CHARS],
|
||||||
"user": user,
|
"user": user[:_LLM_CALL_MAX_CHARS],
|
||||||
"response": response,
|
"response": response[:_LLM_CALL_MAX_CHARS],
|
||||||
}
|
}
|
||||||
if start_time is not None and end_time is not None:
|
if start_time is not None and end_time is not None:
|
||||||
try:
|
try:
|
||||||
@@ -130,13 +137,13 @@ def setup(command: str, **context: Any):
|
|||||||
key = command.lower()
|
key = command.lower()
|
||||||
|
|
||||||
if key not in _registered:
|
if key not in _registered:
|
||||||
# Per-command rotating file
|
|
||||||
_root.add(
|
_root.add(
|
||||||
str(log_dir / f"{key}.log"),
|
str(log_dir / f"{key}.log"),
|
||||||
format=_FILE_FMT,
|
format=_FILE_FMT,
|
||||||
filter=lambda r, k=key: r["extra"].get("cmd", "").lower() == k,
|
filter=lambda r, k=key: r["extra"].get("cmd", "").lower() == k,
|
||||||
rotation="00:00", # new file at midnight
|
rotation="50 MB",
|
||||||
retention="30 days",
|
compression="gz",
|
||||||
|
retention="7 days",
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
enqueue=True,
|
enqueue=True,
|
||||||
backtrace=False,
|
backtrace=False,
|
||||||
@@ -145,13 +152,13 @@ def setup(command: str, **context: Any):
|
|||||||
_registered.add(key)
|
_registered.add(key)
|
||||||
|
|
||||||
if not _all_added:
|
if not _all_added:
|
||||||
# Combined log — all commands
|
|
||||||
_root.add(
|
_root.add(
|
||||||
str(log_dir / "all.log"),
|
str(log_dir / "all.log"),
|
||||||
format=_FILE_FMT,
|
format=_FILE_FMT,
|
||||||
filter=lambda r: "cmd" in r["extra"],
|
filter=lambda r: "cmd" in r["extra"],
|
||||||
rotation="00:00",
|
rotation="100 MB",
|
||||||
retention="60 days",
|
compression="gz",
|
||||||
|
retention="7 days",
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
enqueue=True,
|
enqueue=True,
|
||||||
backtrace=False,
|
backtrace=False,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,306 @@
|
|||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import pickle # nosec
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import streamlit as st
|
||||||
|
from streamlit import session_state
|
||||||
|
|
||||||
|
from rdagent.log.ui.conf import UI_SETTING
|
||||||
|
from rdagent.log.utils import extract_evoid, extract_loopid_func_name
|
||||||
|
|
||||||
|
st.set_page_config(layout="wide", page_title="debug_llm", page_icon="🎓", initial_sidebar_state="expanded")
|
||||||
|
|
||||||
|
# 获取 log_path 参数
|
||||||
|
parser = argparse.ArgumentParser(description="RD-Agent Streamlit App")
|
||||||
|
parser.add_argument("--log_dir", type=str, help="Path to the log directory")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def get_folders_sorted(log_path):
|
||||||
|
"""缓存并返回排序后的文件夹列表,并加入进度打印"""
|
||||||
|
with st.spinner("正在加载文件夹列表..."):
|
||||||
|
folders = sorted(
|
||||||
|
(folder for folder in log_path.iterdir() if folder.is_dir() and list(folder.iterdir())),
|
||||||
|
key=lambda folder: folder.stat().st_mtime,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
st.write(f"找到 {len(folders)} 个文件夹")
|
||||||
|
return [folder.name for folder in folders]
|
||||||
|
|
||||||
|
|
||||||
|
if UI_SETTING.enable_cache:
|
||||||
|
get_folders_sorted = st.cache_data(get_folders_sorted)
|
||||||
|
|
||||||
|
|
||||||
|
# 设置主日志路径
|
||||||
|
main_log_path = Path(args.log_dir) if args.log_dir else Path("./log")
|
||||||
|
if not main_log_path.exists():
|
||||||
|
st.error(f"Log dir {main_log_path} does not exist!")
|
||||||
|
st.stop()
|
||||||
|
|
||||||
|
if "data" not in session_state:
|
||||||
|
session_state.data = []
|
||||||
|
if "log_path" not in session_state:
|
||||||
|
session_state.log_path = None
|
||||||
|
|
||||||
|
tlist = []
|
||||||
|
|
||||||
|
|
||||||
|
def load_data():
|
||||||
|
"""加载数据到 session_state 并显示进度"""
|
||||||
|
log_file = main_log_path / session_state.log_path / "debug_llm.pkl"
|
||||||
|
try:
|
||||||
|
with st.spinner(f"正在加载数据文件 {log_file}..."):
|
||||||
|
start_time = time.time()
|
||||||
|
with open(log_file, "rb") as f:
|
||||||
|
session_state.data = pickle.load(f, encoding="utf-8") # nosec
|
||||||
|
st.success(f"数据加载完成!耗时 {time.time() - start_time:.2f} 秒")
|
||||||
|
st.session_state["current_loop"] = 1
|
||||||
|
except Exception as e:
|
||||||
|
session_state.data = [{"error": str(e)}]
|
||||||
|
st.error(f"加载数据失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# UI - Sidebar
|
||||||
|
with st.sidebar:
|
||||||
|
st.markdown(":blue[**Log Path**]")
|
||||||
|
manually = st.toggle("Manual Input")
|
||||||
|
if manually:
|
||||||
|
st.text_input("log path", key="log_path", label_visibility="collapsed")
|
||||||
|
else:
|
||||||
|
folders = get_folders_sorted(main_log_path)
|
||||||
|
st.selectbox(f"**Select from {main_log_path.absolute()}**", folders, key="log_path") # nosec B608 — not SQL, Bandit false positive on "Select" in UI label
|
||||||
|
|
||||||
|
if st.button("Refresh Data"):
|
||||||
|
load_data()
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
|
||||||
|
# Helper functions
|
||||||
|
def show_text(text, lang=None):
|
||||||
|
"""显示文本代码块"""
|
||||||
|
if lang:
|
||||||
|
st.code(text, language=lang, wrap_lines=True)
|
||||||
|
elif "\n" in text:
|
||||||
|
st.code(text, language="python", wrap_lines=True)
|
||||||
|
else:
|
||||||
|
st.code(text, language="html", wrap_lines=True)
|
||||||
|
|
||||||
|
|
||||||
|
def highlight_prompts_uri(uri):
|
||||||
|
"""高亮 URI 的格式"""
|
||||||
|
parts = uri.split(":")
|
||||||
|
return f"**{parts[0]}:**:green[**{parts[1]}**]"
|
||||||
|
|
||||||
|
|
||||||
|
# Display Data
|
||||||
|
progress_text = st.empty()
|
||||||
|
progress_bar = st.progress(0)
|
||||||
|
|
||||||
|
# 每页展示一个 Loop
|
||||||
|
LOOPS_PER_PAGE = 1
|
||||||
|
|
||||||
|
# 获取所有的 Loop ID
|
||||||
|
loop_groups = {}
|
||||||
|
for i, d in enumerate(session_state.data):
|
||||||
|
tag = d["tag"]
|
||||||
|
loop_id, _ = extract_loopid_func_name(tag)
|
||||||
|
if loop_id:
|
||||||
|
if loop_id not in loop_groups:
|
||||||
|
loop_groups[loop_id] = []
|
||||||
|
loop_groups[loop_id].append(d)
|
||||||
|
|
||||||
|
# 按 Loop ID 排序
|
||||||
|
sorted_loop_ids = sorted(loop_groups.keys(), key=int) # 假设 Loop ID 是数字
|
||||||
|
total_loops = len(sorted_loop_ids)
|
||||||
|
total_pages = total_loops # 每页展示一个 Loop
|
||||||
|
|
||||||
|
|
||||||
|
# simple display
|
||||||
|
# FIXME: Delete this simple UI if trace have tag(evo_id & loop_id)
|
||||||
|
# with st.sidebar:
|
||||||
|
# start = int(st.text_input("start", 0))
|
||||||
|
# end = int(st.text_input("end", 100))
|
||||||
|
# for m in session_state.data[start:end]:
|
||||||
|
# if "tpl" in m["tag"]:
|
||||||
|
# obj = m["obj"]
|
||||||
|
# uri = obj["uri"]
|
||||||
|
# tpl = obj["template"]
|
||||||
|
# cxt = obj["context"]
|
||||||
|
# rd = obj["rendered"]
|
||||||
|
# with st.expander(highlight_prompts_uri(uri), expanded=False, icon="⚙️"):
|
||||||
|
# t1, t2, t3 = st.tabs([":green[**Rendered**]", ":blue[**Template**]", ":orange[**Context**]"])
|
||||||
|
# with t1:
|
||||||
|
# show_text(rd)
|
||||||
|
# with t2:
|
||||||
|
# show_text(tpl, lang="django")
|
||||||
|
# with t3:
|
||||||
|
# st.json(cxt)
|
||||||
|
# if "llm" in m["tag"]:
|
||||||
|
# obj = m["obj"]
|
||||||
|
# system = obj.get("system", None)
|
||||||
|
# user = obj["user"]
|
||||||
|
# resp = obj["resp"]
|
||||||
|
# with st.expander(f"**LLM**", expanded=False, icon="🤖"):
|
||||||
|
# t1, t2, t3 = st.tabs([":green[**Response**]", ":blue[**User**]", ":orange[**System**]"])
|
||||||
|
# with t1:
|
||||||
|
# try:
|
||||||
|
# rdict = json.loads(resp)
|
||||||
|
# if "code" in rdict:
|
||||||
|
# code = rdict["code"]
|
||||||
|
# st.markdown(":red[**Code in response dict:**]")
|
||||||
|
# st.code(code, language="python", wrap_lines=True, line_numbers=True)
|
||||||
|
# rdict.pop("code")
|
||||||
|
# elif "spec" in rdict:
|
||||||
|
# spec = rdict["spec"]
|
||||||
|
# st.markdown(":red[**Spec in response dict:**]")
|
||||||
|
# st.markdown(spec)
|
||||||
|
# rdict.pop("spec")
|
||||||
|
# else:
|
||||||
|
# # show model codes
|
||||||
|
# showed_keys = []
|
||||||
|
# for k, v in rdict.items():
|
||||||
|
# if k.startswith("model_") and k.endswith(".py"):
|
||||||
|
# st.markdown(f":red[**{k}**]")
|
||||||
|
# st.code(v, language="python", wrap_lines=True, line_numbers=True)
|
||||||
|
# showed_keys.append(k)
|
||||||
|
# for k in showed_keys:
|
||||||
|
# rdict.pop(k)
|
||||||
|
# st.write(":red[**Other parts (except for the code or spec) in response dict:**]")
|
||||||
|
# st.json(rdict)
|
||||||
|
# except:
|
||||||
|
# st.json(resp)
|
||||||
|
# with t2:
|
||||||
|
# show_text(user)
|
||||||
|
# with t3:
|
||||||
|
# show_text(system or "No system prompt available")
|
||||||
|
|
||||||
|
|
||||||
|
if total_pages:
|
||||||
|
# 初始化 current_loop
|
||||||
|
if "current_loop" not in st.session_state:
|
||||||
|
st.session_state["current_loop"] = 1
|
||||||
|
|
||||||
|
# Loop 导航按钮
|
||||||
|
col1, col2, col3, col4, col5 = st.sidebar.columns([1.2, 1, 2, 1, 1.2])
|
||||||
|
|
||||||
|
with col1:
|
||||||
|
if st.button("|<"): # 首页
|
||||||
|
st.session_state["current_loop"] = 1
|
||||||
|
with col2:
|
||||||
|
if st.button("<") and st.session_state["current_loop"] > 1: # 上一页
|
||||||
|
st.session_state["current_loop"] -= 1
|
||||||
|
with col3:
|
||||||
|
# 下拉列表显示所有 Loop
|
||||||
|
st.session_state["current_loop"] = st.selectbox(
|
||||||
|
"选择 Loop",
|
||||||
|
options=list(range(1, total_loops + 1)),
|
||||||
|
index=st.session_state["current_loop"] - 1, # 默认选中当前 Loop
|
||||||
|
label_visibility="collapsed", # 隐藏标签
|
||||||
|
)
|
||||||
|
with col4:
|
||||||
|
if st.button("\>") and st.session_state["current_loop"] < total_loops: # 下一页
|
||||||
|
st.session_state["current_loop"] += 1
|
||||||
|
with col5:
|
||||||
|
if st.button("\>|"): # 最后一页
|
||||||
|
st.session_state["current_loop"] = total_loops
|
||||||
|
|
||||||
|
# 获取当前 Loop
|
||||||
|
current_loop = st.session_state["current_loop"]
|
||||||
|
|
||||||
|
# 渲染当前 Loop 数据
|
||||||
|
loop_id = sorted_loop_ids[current_loop - 1]
|
||||||
|
progress_text = st.empty()
|
||||||
|
progress_text.text(f"正在处理 Loop {loop_id}...")
|
||||||
|
progress_bar.progress(current_loop / total_loops, text=f"Loop :green[**{current_loop}**] / {total_loops}")
|
||||||
|
|
||||||
|
# 渲染 Loop Header
|
||||||
|
loop_anchor = f"Loop_{loop_id}"
|
||||||
|
if loop_anchor not in tlist:
|
||||||
|
tlist.append(loop_anchor)
|
||||||
|
st.header(loop_anchor, anchor=loop_anchor, divider="blue")
|
||||||
|
|
||||||
|
# 渲染当前 Loop 的所有数据
|
||||||
|
loop_data = loop_groups[loop_id]
|
||||||
|
for d in loop_data:
|
||||||
|
tag = d["tag"]
|
||||||
|
obj = d["obj"]
|
||||||
|
_, func_name = extract_loopid_func_name(tag)
|
||||||
|
evo_id = extract_evoid(tag)
|
||||||
|
|
||||||
|
func_anchor = f"loop_{loop_id}.{func_name}"
|
||||||
|
if func_anchor not in tlist:
|
||||||
|
tlist.append(func_anchor)
|
||||||
|
st.header(f"in *{func_name}*", anchor=func_anchor, divider="green")
|
||||||
|
|
||||||
|
evo_anchor = f"loop_{loop_id}.evo_step_{evo_id}"
|
||||||
|
if evo_id and evo_anchor not in tlist:
|
||||||
|
tlist.append(evo_anchor)
|
||||||
|
st.subheader(f"evo_step_{evo_id}", anchor=evo_anchor, divider="orange")
|
||||||
|
|
||||||
|
# 根据 tag 渲染内容
|
||||||
|
if "debug_exp_gen" in tag:
|
||||||
|
with st.expander(
|
||||||
|
f"Exp in :violet[**{obj.experiment_workspace.workspace_path}**]", expanded=False, icon="🧩"
|
||||||
|
):
|
||||||
|
st.write(obj)
|
||||||
|
elif "debug_tpl" in tag:
|
||||||
|
uri = obj["uri"]
|
||||||
|
tpl = obj["template"]
|
||||||
|
cxt = obj["context"]
|
||||||
|
rd = obj["rendered"]
|
||||||
|
with st.expander(highlight_prompts_uri(uri), expanded=False, icon="⚙️"):
|
||||||
|
t1, t2, t3 = st.tabs([":green[**Rendered**]", ":blue[**Template**]", ":orange[**Context**]"])
|
||||||
|
with t1:
|
||||||
|
show_text(rd)
|
||||||
|
with t2:
|
||||||
|
show_text(tpl, lang="django")
|
||||||
|
with t3:
|
||||||
|
st.json(cxt)
|
||||||
|
elif "debug_llm" in tag:
|
||||||
|
system = obj.get("system", None)
|
||||||
|
user = obj["user"]
|
||||||
|
resp = obj["resp"]
|
||||||
|
with st.expander(f"**LLM**", expanded=False, icon="🤖"):
|
||||||
|
t1, t2, t3 = st.tabs([":green[**Response**]", ":blue[**User**]", ":orange[**System**]"])
|
||||||
|
with t1:
|
||||||
|
try:
|
||||||
|
rdict = json.loads(resp)
|
||||||
|
if "code" in rdict:
|
||||||
|
code = rdict["code"]
|
||||||
|
st.markdown(":red[**Code in response dict:**]")
|
||||||
|
st.code(code, language="python", wrap_lines=True, line_numbers=True)
|
||||||
|
rdict.pop("code")
|
||||||
|
elif "spec" in rdict:
|
||||||
|
spec = rdict["spec"]
|
||||||
|
st.markdown(":red[**Spec in response dict:**]")
|
||||||
|
st.markdown(spec)
|
||||||
|
rdict.pop("spec")
|
||||||
|
else:
|
||||||
|
# show model codes
|
||||||
|
showed_keys = []
|
||||||
|
for k, v in rdict.items():
|
||||||
|
if k.startswith("model_") and k.endswith(".py"):
|
||||||
|
st.markdown(f":red[**{k}**]")
|
||||||
|
st.code(v, language="python", wrap_lines=True, line_numbers=True)
|
||||||
|
showed_keys.append(k)
|
||||||
|
for k in showed_keys:
|
||||||
|
rdict.pop(k)
|
||||||
|
st.write(":red[**Other parts (except for the code or spec) in response dict:**]")
|
||||||
|
st.json(rdict)
|
||||||
|
except:
|
||||||
|
st.json(resp)
|
||||||
|
with t2:
|
||||||
|
show_text(user)
|
||||||
|
with t3:
|
||||||
|
show_text(system or "No system prompt available")
|
||||||
|
|
||||||
|
progress_text.text("当前 Loop 数据处理完成!")
|
||||||
|
|
||||||
|
# Sidebar TOC
|
||||||
|
with st.sidebar:
|
||||||
|
toc = "\n".join([f"- [{t}](#{t})" if t.startswith("L") else f" - [{t.split('.')[1]}](#{t})" for t in tlist])
|
||||||
|
st.markdown(toc, unsafe_allow_html=True)
|
||||||
@@ -720,7 +720,13 @@ class APIBackend(ABC):
|
|||||||
|
|
||||||
if finish_reason is None or finish_reason != "length":
|
if finish_reason is None or finish_reason != "length":
|
||||||
break # we get a full response now.
|
break # we get a full response now.
|
||||||
new_messages.append({"role": "assistant", "content": response})
|
# Merge into the previous assistant message if there already is one at the end.
|
||||||
|
# Appending a second consecutive assistant message causes llama-server to return 400
|
||||||
|
# ("Cannot have 2 or more assistant messages at the end of the list").
|
||||||
|
if new_messages and new_messages[-1]["role"] == "assistant":
|
||||||
|
new_messages[-1]["content"] += response
|
||||||
|
else:
|
||||||
|
new_messages.append({"role": "assistant", "content": response})
|
||||||
else:
|
else:
|
||||||
raise RuntimeError(f"Failed to continue the conversation after {try_n} retries.")
|
raise RuntimeError(f"Failed to continue the conversation after {try_n} retries.")
|
||||||
|
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ class ExpGen2Hypothesis(DSProposalV2ExpGen):
|
|||||||
|
|
||||||
success_fb_list = list(set(trace_fbs))
|
success_fb_list = list(set(trace_fbs))
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Merge Hypothesis: select {len(success_fb_list)} from {len(trace_fbs)} SOTA experiments found in {len(leaves)} traces"
|
f"Merge Hypothesis: select {len(success_fb_list)} from {len(trace_fbs)} SOTA experiments found in {len(leaves)} traces" # nosec B608 — not SQL, Bandit false positive on "select" in log message
|
||||||
)
|
)
|
||||||
|
|
||||||
if len(success_fb_list) > 0:
|
if len(success_fb_list) > 0:
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import ast
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import pickle
|
import pickle
|
||||||
@@ -587,8 +588,8 @@ def _parsing_score(grade_stdout: str) -> Optional[float]:
|
|||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
# Priority 2: Eval dict
|
# Priority 2: safe literal eval for Python-style dicts
|
||||||
return float(eval(json_str)["score"])
|
return float(ast.literal_eval(json_str)["score"])
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class KGModelFeatureSelectionCoder(Developer[KGModelExperiment]):
|
|||||||
assert target_model_type in KG_SELECT_MAPPING
|
assert target_model_type in KG_SELECT_MAPPING
|
||||||
if len(exp.experiment_workspace.data_description) == 1:
|
if len(exp.experiment_workspace.data_description) == 1:
|
||||||
code = (
|
code = (
|
||||||
Environment(undefined=StrictUndefined)
|
Environment(undefined=StrictUndefined) # nosec B701 — renders Python code templates, not HTML; autoescape would corrupt code
|
||||||
.from_string(DEFAULT_SELECTION_CODE)
|
.from_string(DEFAULT_SELECTION_CODE)
|
||||||
.render(feature_index_list=None)
|
.render(feature_index_list=None)
|
||||||
)
|
)
|
||||||
@@ -62,7 +62,7 @@ class KGModelFeatureSelectionCoder(Developer[KGModelExperiment]):
|
|||||||
chosen_index_to_list_index = [i - 1 for i in chosen_index]
|
chosen_index_to_list_index = [i - 1 for i in chosen_index]
|
||||||
|
|
||||||
code = (
|
code = (
|
||||||
Environment(undefined=StrictUndefined)
|
Environment(undefined=StrictUndefined) # nosec B701 — renders Python code templates, not HTML; autoescape would corrupt code
|
||||||
.from_string(DEFAULT_SELECTION_CODE)
|
.from_string(DEFAULT_SELECTION_CODE)
|
||||||
.render(feature_index_list=chosen_index_to_list_index)
|
.render(feature_index_list=chosen_index_to_list_index)
|
||||||
)
|
)
|
||||||
|
|||||||
+6
-6
@@ -79,12 +79,12 @@ def preprocess_script():
|
|||||||
This method applies the preprocessing steps to the training, validation, and test datasets.
|
This method applies the preprocessing steps to the training, validation, and test datasets.
|
||||||
"""
|
"""
|
||||||
if os.path.exists("/kaggle/input/X_train.pkl"):
|
if os.path.exists("/kaggle/input/X_train.pkl"):
|
||||||
X_train = pd.read_pickle("/kaggle/input/X_train.pkl")
|
X_train = pd.read_pickle("/kaggle/input/X_train.pkl") # nosec B301 — trusted Kaggle input
|
||||||
X_valid = pd.read_pickle("/kaggle/input/X_valid.pkl")
|
X_valid = pd.read_pickle("/kaggle/input/X_valid.pkl") # nosec B301
|
||||||
y_train = pd.read_pickle("/kaggle/input/y_train.pkl")
|
y_train = pd.read_pickle("/kaggle/input/y_train.pkl") # nosec B301
|
||||||
y_valid = pd.read_pickle("/kaggle/input/y_valid.pkl")
|
y_valid = pd.read_pickle("/kaggle/input/y_valid.pkl") # nosec B301
|
||||||
X_test = pd.read_pickle("/kaggle/input/X_test.pkl")
|
X_test = pd.read_pickle("/kaggle/input/X_test.pkl") # nosec B301
|
||||||
others = pd.read_pickle("/kaggle/input/others.pkl")
|
others = pd.read_pickle("/kaggle/input/others.pkl") # nosec B301
|
||||||
y_train = pd.Series(y_train).reset_index(drop=True)
|
y_train = pd.Series(y_train).reset_index(drop=True)
|
||||||
y_valid = pd.Series(y_valid).reset_index(drop=True)
|
y_valid = pd.Series(y_valid).reset_index(drop=True)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import sys
|
||||||
import os
|
import os
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
"""
|
"""
|
||||||
Qlib Factor Runner - Executes factor backtests in Docker.
|
Qlib Factor Runner - Executes factor backtests in Docker.
|
||||||
@@ -29,6 +31,83 @@ from rdagent.scenarios.qlib.experiment.model_experiment import QlibModelExperime
|
|||||||
DIRNAME = Path(__file__).absolute().resolve().parent
|
DIRNAME = Path(__file__).absolute().resolve().parent
|
||||||
DIRNAME_local = Path.cwd()
|
DIRNAME_local = Path.cwd()
|
||||||
|
|
||||||
|
|
||||||
|
def _shift_daily_constant_factor_if_needed(factor_col: "pd.Series", factor_name: str) -> "pd.Series":
|
||||||
|
"""Detect and fix look-ahead bias in daily-constant factors.
|
||||||
|
|
||||||
|
A factor is "daily-constant" when every minute bar within the same calendar
|
||||||
|
day carries an identical value. This happens when LLM code computes a daily
|
||||||
|
aggregate (e.g. today's log return) and forward-fills it across all intraday
|
||||||
|
bars without shifting — meaning the end-of-day value is visible at 00:00.
|
||||||
|
|
||||||
|
Fix: shift by one trading day so that the value assigned to day T is the
|
||||||
|
aggregate computed from day T-1, eliminating the forward-looking information.
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
try:
|
||||||
|
notnull = factor_col.dropna()
|
||||||
|
if len(notnull) < 200:
|
||||||
|
return factor_col
|
||||||
|
|
||||||
|
datetimes = notnull.index.get_level_values("datetime")
|
||||||
|
dates = datetimes.normalize()
|
||||||
|
|
||||||
|
# Sample up to 50 random days and check intra-day uniqueness
|
||||||
|
unique_dates = pd.Series(dates.unique())
|
||||||
|
sample_dates = unique_dates.sample(min(50, len(unique_dates)), random_state=42)
|
||||||
|
|
||||||
|
daily_unique_counts = []
|
||||||
|
for d in sample_dates:
|
||||||
|
mask = dates == d
|
||||||
|
vals = notnull.values[mask]
|
||||||
|
if len(vals) > 1:
|
||||||
|
daily_unique_counts.append(len(np.unique(vals[~np.isnan(vals)])))
|
||||||
|
|
||||||
|
if not daily_unique_counts:
|
||||||
|
return factor_col
|
||||||
|
|
||||||
|
# If >90% of sampled days have exactly 1 unique value → daily-constant
|
||||||
|
fraction_constant = sum(1 for c in daily_unique_counts if c == 1) / len(daily_unique_counts)
|
||||||
|
if fraction_constant < 0.90:
|
||||||
|
return factor_col # Intraday factor — no shift needed
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
f"[LookAheadFix] Factor '{factor_name}' is daily-constant "
|
||||||
|
f"({fraction_constant:.0%} of days). Applying 1-day shift to remove look-ahead bias."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Shift: for each instrument, map daily values forward by 1 trading day
|
||||||
|
instruments = factor_col.index.get_level_values("instrument").unique()
|
||||||
|
shifted_parts = []
|
||||||
|
for inst in instruments:
|
||||||
|
inst_series = factor_col.xs(inst, level="instrument")
|
||||||
|
# Get one value per calendar day (the first non-null bar)
|
||||||
|
inst_dt = inst_series.index.normalize()
|
||||||
|
daily_vals = inst_series.groupby(inst_dt).first()
|
||||||
|
# Shift by 1 day
|
||||||
|
daily_vals_shifted = daily_vals.shift(1)
|
||||||
|
# Forward-fill back to minute bars
|
||||||
|
minute_idx = inst_series.index
|
||||||
|
minute_dates = minute_idx.normalize()
|
||||||
|
shifted_minute = minute_dates.map(daily_vals_shifted)
|
||||||
|
shifted_s = pd.Series(
|
||||||
|
shifted_minute.values,
|
||||||
|
index=pd.MultiIndex.from_arrays(
|
||||||
|
[inst_series.index, [inst] * len(inst_series)],
|
||||||
|
names=["datetime", "instrument"],
|
||||||
|
),
|
||||||
|
name=factor_col.name,
|
||||||
|
)
|
||||||
|
shifted_parts.append(shifted_s)
|
||||||
|
|
||||||
|
return pd.concat(shifted_parts).sort_index()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[LookAheadFix] Could not apply daily shift for '{factor_name}': {e}")
|
||||||
|
return factor_col
|
||||||
|
|
||||||
|
|
||||||
# TODO: supporting multiprocessing and keep previous results
|
# TODO: supporting multiprocessing and keep previous results
|
||||||
|
|
||||||
|
|
||||||
@@ -391,8 +470,19 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get workspace path
|
# Get workspace path — factor code and result.h5 live in sub_workspace_list[0],
|
||||||
workspace_path = exp.experiment_workspace.workspace_path
|
# not in experiment_workspace (which is the Qlib template workspace).
|
||||||
|
workspace_path = None
|
||||||
|
if exp.sub_workspace_list:
|
||||||
|
for ws in exp.sub_workspace_list:
|
||||||
|
if ws is not None and hasattr(ws, 'workspace_path'):
|
||||||
|
candidate = ws.workspace_path / "result.h5"
|
||||||
|
if candidate.exists():
|
||||||
|
workspace_path = ws.workspace_path
|
||||||
|
break
|
||||||
|
if workspace_path is None:
|
||||||
|
# Fallback to experiment_workspace
|
||||||
|
workspace_path = exp.experiment_workspace.workspace_path
|
||||||
if workspace_path is None:
|
if workspace_path is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -409,6 +499,12 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
factor_col = factor_values.iloc[:, 0]
|
factor_col = factor_values.iloc[:, 0]
|
||||||
factor_name = factor_values.columns[0]
|
factor_name = factor_values.columns[0]
|
||||||
|
|
||||||
|
# Detect and fix look-ahead bias in daily-constant factors.
|
||||||
|
# If a factor has the same value for all minute bars within each calendar day
|
||||||
|
# it was computed from same-day data (e.g. today's close return at 00:00).
|
||||||
|
# Fix: shift by 1 trading day so value at day T = aggregate of day T-1.
|
||||||
|
factor_col = _shift_daily_constant_factor_if_needed(factor_col, factor_name)
|
||||||
|
|
||||||
# Load source data for forward returns
|
# Load source data for forward returns
|
||||||
data_path = (
|
data_path = (
|
||||||
Path(__file__).parent.parent.parent.parent.parent
|
Path(__file__).parent.parent.parent.parent.parent
|
||||||
@@ -587,10 +683,12 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from rdagent.components.backtesting import ResultsDatabase
|
from rdagent.components.backtesting import ResultsDatabase
|
||||||
|
|
||||||
# Get factor name from hypothesis
|
# Get factor name: prefer hypothesis, fallback to result Series 'factor_name' key
|
||||||
factor_name = "unknown"
|
factor_name = "unknown"
|
||||||
if hasattr(exp, 'hypothesis') and exp.hypothesis is not None:
|
if hasattr(exp, 'hypothesis') and exp.hypothesis is not None:
|
||||||
factor_name = getattr(exp.hypothesis, 'hypothesis', 'unknown')
|
factor_name = getattr(exp.hypothesis, 'hypothesis', 'unknown')
|
||||||
|
if factor_name == 'unknown' and isinstance(result, pd.Series) and 'factor_name' in result.index:
|
||||||
|
factor_name = str(result['factor_name'])
|
||||||
|
|
||||||
# Check if already rejected by protection
|
# Check if already rejected by protection
|
||||||
if getattr(exp, 'rejected_by_protection', False):
|
if getattr(exp, 'rejected_by_protection', False):
|
||||||
@@ -824,41 +922,74 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
"""
|
"""
|
||||||
Save factor time-series values as parquet for strategy building.
|
Save factor time-series values as parquet for strategy building.
|
||||||
|
|
||||||
This is essential for walk-forward validation and strategy combination.
|
Reruns the factor code on the FULL 6-year dataset so the parquet covers
|
||||||
|
the complete backtest range (not just the debug 2024 subset).
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
factor_name : str
|
|
||||||
Name of the factor
|
|
||||||
exp : QlibFactorExperiment
|
|
||||||
The experiment with factor values
|
|
||||||
"""
|
"""
|
||||||
import os as _os
|
import os as _os
|
||||||
|
import subprocess
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get workspace path
|
# factor.py lives in sub_workspace_list[0], not experiment_workspace
|
||||||
workspace_path = exp.experiment_workspace.workspace_path
|
workspace_path = None
|
||||||
|
if exp.sub_workspace_list:
|
||||||
|
for ws in exp.sub_workspace_list:
|
||||||
|
if ws is not None and hasattr(ws, 'workspace_path'):
|
||||||
|
fp = ws.workspace_path / "factor.py"
|
||||||
|
if fp.exists():
|
||||||
|
workspace_path = ws.workspace_path
|
||||||
|
break
|
||||||
|
if workspace_path is None:
|
||||||
|
workspace_path = exp.experiment_workspace.workspace_path
|
||||||
if workspace_path is None:
|
if workspace_path is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
result_h5 = workspace_path / "result.h5"
|
factor_py = workspace_path / "factor.py"
|
||||||
if not result_h5.exists():
|
if not factor_py.exists():
|
||||||
return
|
return
|
||||||
|
|
||||||
# Read factor values
|
project_root = Path(__file__).parent.parent.parent.parent.parent
|
||||||
|
full_data = (
|
||||||
|
project_root
|
||||||
|
/ "git_ignore_folder"
|
||||||
|
/ "factor_implementation_source_data"
|
||||||
|
/ "intraday_pv.h5"
|
||||||
|
)
|
||||||
|
if not full_data.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
# Run factor code on full data in a temp workspace
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
df = pd.read_hdf(str(result_h5), key="data")
|
with tempfile.TemporaryDirectory(prefix="predix_fullval_") as tmp_dir:
|
||||||
|
tmp = Path(tmp_dir)
|
||||||
|
shutil.copy(str(factor_py), str(tmp / "factor.py"))
|
||||||
|
shutil.copy(str(full_data), str(tmp / "intraday_pv.h5"))
|
||||||
|
|
||||||
|
ret = subprocess.run(
|
||||||
|
["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:
|
if df is None or df.empty:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get the factor series (first column)
|
|
||||||
series = df.iloc[:, 0]
|
series = df.iloc[:, 0]
|
||||||
series.name = factor_name
|
series.name = factor_name
|
||||||
|
|
||||||
# Save to results/factors/values/
|
|
||||||
project_root = Path(__file__).parent.parent.parent.parent.parent
|
|
||||||
|
|
||||||
# Parallel run isolation
|
|
||||||
parallel_run_id = _os.getenv("PARALLEL_RUN_ID", "0")
|
parallel_run_id = _os.getenv("PARALLEL_RUN_ID", "0")
|
||||||
if parallel_run_id != "0":
|
if parallel_run_id != "0":
|
||||||
values_dir = project_root / "results" / "runs" / f"run{parallel_run_id}" / "factors" / "values"
|
values_dir = project_root / "results" / "runs" / f"run{parallel_run_id}" / "factors" / "values"
|
||||||
@@ -866,17 +997,12 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
values_dir = project_root / "results" / "factors" / "values"
|
values_dir = project_root / "results" / "factors" / "values"
|
||||||
|
|
||||||
values_dir.mkdir(parents=True, exist_ok=True)
|
values_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Safe filename
|
|
||||||
safe_name = factor_name.replace("/", "_").replace("\\", "_").replace(" ", "_")[:100]
|
safe_name = factor_name.replace("/", "_").replace("\\", "_").replace(" ", "_")[:100]
|
||||||
parquet_path = values_dir / f"{safe_name}.parquet"
|
parquet_path = values_dir / f"{safe_name}.parquet"
|
||||||
|
series.to_frame().to_parquet(str(parquet_path))
|
||||||
|
|
||||||
# Save as parquet (with datetime index)
|
except Exception:
|
||||||
series.to_parquet(str(parquet_path))
|
logging.debug("Error in save_factor_values_to_parquet", exc_info=True)
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
# Don't let factor value saving break the main workflow
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _log_result_warnings(self, factor_name: str, result, metrics: dict) -> None:
|
def _log_result_warnings(self, factor_name: str, result, metrics: dict) -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -23,14 +23,25 @@ $low: low price at 1-minute bar.
|
|||||||
$volume: volume at 1-minute bar (tick volume for FX).
|
$volume: volume at 1-minute bar (tick volume for FX).
|
||||||
|
|
||||||
## Important Notes for 1min Data
|
## Important Notes for 1min Data
|
||||||
- 96 bars = 1 trading day (24 hours for FX)
|
- 1 bar = 1 minute (confirmed)
|
||||||
- 16 bars = 16 minutes
|
- 16 bars = 16 minutes
|
||||||
- 4 bars = 4 minutes
|
- 60 bars = 1 hour
|
||||||
- 1 bar = 1 minute
|
- ~1440 bars = 1 full trading day (FX trades nearly 24h, Mon 00:00 - Fri 22:00 UTC approx.)
|
||||||
|
- Typical bars per calendar day: ~1200-1440 (varies by weekday, holidays have fewer)
|
||||||
|
- Do NOT assume 96 bars/day — the actual count depends on the date
|
||||||
- Data range: 2020-01-01 to 2026-03-20
|
- Data range: 2020-01-01 to 2026-03-20
|
||||||
- Instrument: EURUSD
|
- Instrument: EURUSD
|
||||||
- Timezone: UTC
|
- Timezone: UTC
|
||||||
|
|
||||||
|
## IMPORTANT: Bars per Day Correction
|
||||||
|
The dataset has approximately 1440 bars per full trading day (1 bar = 1 minute, ~24h of FX trading).
|
||||||
|
Some older documentation incorrectly stated "96 bars = 1 day" — this is WRONG. Always use:
|
||||||
|
- 60 bars = 1 hour
|
||||||
|
- 480 bars = 8 hours (London session 08:00-16:00 UTC)
|
||||||
|
- 180 bars = 3 hours (London/NY overlap 13:00-16:00 UTC)
|
||||||
|
Use datetime hour filtering (e.g., `df[df.index.get_level_values('datetime').hour.between(8, 15)]`)
|
||||||
|
to select session bars — do NOT use bar-count offsets to define sessions.
|
||||||
|
|
||||||
## Session Times (UTC)
|
## Session Times (UTC)
|
||||||
- Asian: 00:00-08:00 UTC (low volatility)
|
- Asian: 00:00-08:00 UTC (low volatility)
|
||||||
- London: 08:00-16:00 UTC (high volatility)
|
- London: 08:00-16:00 UTC (high volatility)
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ qlib_factor_strategy: |-
|
|||||||
result_df.columns = ['daily_volume_price_divergence']
|
result_df.columns = ['daily_volume_price_divergence']
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Process ALL data — do not filter dates**: The source HDF5 contains data from 2020-01-01 to 2026-03-20. Do NOT filter to a single year. If your output has only 314 entries (one year of daily data), the factor will be rejected. Expected output: ~1500+ daily entries for 2020-2026.
|
4. **Process ALL data — do not filter dates**: The source HDF5 contains data from 2020-01-01 to 2026-03-20 (development runs may use a 2024-only debug dataset with ~300 entries, which is acceptable). Do NOT filter to a single year in your code. Write your code to process whatever date range is available in the HDF5 file — do not hardcode date filters. Expected output for production data: ~1500+ daily entries for 2020-2026. Expected output for debug data: ~300 daily entries for 2024. Both are valid.
|
||||||
|
|
||||||
5. **Use `transform()` instead of `apply()` for per-group calculations**: `transform()` preserves the original index while `apply()` may reduce the number of rows unexpectedly:
|
5. **Use `transform()` instead of `apply()` for per-group calculations**: `transform()` preserves the original index while `apply()` may reduce the number of rows unexpectedly:
|
||||||
```python
|
```python
|
||||||
@@ -121,6 +121,35 @@ qlib_factor_strategy: |-
|
|||||||
assert result_df.index.names == ['datetime', 'instrument'], f"Index names must be ['datetime', 'instrument'], got {result_df.index.names}"
|
assert result_df.index.names == ['datetime', 'instrument'], f"Index names must be ['datetime', 'instrument'], got {result_df.index.names}"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
7. **NEVER use same-day aggregations as the factor value — always shift by 1 day**: If your factor computes a daily aggregate (e.g. daily close return, daily OHLC range, daily volume), that aggregate is only known at end-of-day. Using it at the start of the same day is look-ahead bias. You MUST shift the daily aggregate by 1 day before forward-filling to minute bars:
|
||||||
|
```python
|
||||||
|
# WRONG: look-ahead bias! Today's close return is not known at 00:00
|
||||||
|
daily_ret = df['$close'].groupby(level='instrument').resample('1D', level='datetime').last().pct_change()
|
||||||
|
result_df['my_factor'] = daily_ret.groupby(level='instrument').transform(lambda x: x.reindex(df.index.get_level_values('datetime'), method='ffill'))
|
||||||
|
|
||||||
|
# CORRECT: shift by 1 trading day so factor value at day T = aggregate of day T-1
|
||||||
|
daily_close = df.groupby([df.index.get_level_values('datetime').normalize(), df.index.get_level_values('instrument')])['$close'].last()
|
||||||
|
daily_close.index.names = ['date', 'instrument']
|
||||||
|
daily_ret = daily_close.groupby(level='instrument').pct_change().shift(1) # <-- shift(1) is MANDATORY
|
||||||
|
# then map back to minute bars via ffill
|
||||||
|
```
|
||||||
|
This rule applies to ALL daily aggregations: returns, OHLC stats, volume, momentum, slopes, etc.
|
||||||
|
**Session-based aggregations (London, NY, Asian session returns) are also daily aggregations** — the London
|
||||||
|
session (08:00-16:00 UTC) ends at 16:00, so its return must be shifted by 1 day before use.
|
||||||
|
Intraday rolling factors (e.g. 30-min rolling std computed at bar t using only bars t-N..t-1) do NOT need this shift.
|
||||||
|
|
||||||
|
8. **PREFER pure intraday rolling factors**: Factors that use only a trailing window of recent bars (e.g.
|
||||||
|
rolling(30).mean() of returns, RSI(14), Bollinger Band z-score) have NO look-ahead risk and vary every
|
||||||
|
minute. These are the best candidates for short-horizon (60-180 bar) prediction. Examples:
|
||||||
|
- Rolling 15-min / 30-min / 60-min return momentum (15, 30, 60 bars respectively)
|
||||||
|
- Rolling volatility (std of returns over 20-60 bars)
|
||||||
|
- Distance of close from N-bar moving average (z-score)
|
||||||
|
- RSI or similar oscillators computed on 1-min bars
|
||||||
|
- VWAP deviation (requires volume — use $volume column)
|
||||||
|
Always use `.shift(1)` on the lagged window (e.g. `rolling(N).mean().shift(1)`) to avoid using the
|
||||||
|
current bar's own price in its own feature value.
|
||||||
|
NOTE: 1 bar = 1 minute. The data has ~1440 bars per full trading day. Do NOT use 96 as a day proxy.
|
||||||
|
|
||||||
qlib_factor_output_format: |-
|
qlib_factor_output_format: |-
|
||||||
Your output should be a pandas dataframe similar to the following example information:
|
Your output should be a pandas dataframe similar to the following example information:
|
||||||
<class 'pandas.core.frame.DataFrame'>
|
<class 'pandas.core.frame.DataFrame'>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ def get_file_desc(p: Path, variable_list=[]) -> str:
|
|||||||
"""
|
"""
|
||||||
p = Path(p)
|
p = Path(p)
|
||||||
|
|
||||||
JJ_TPL = Environment(undefined=StrictUndefined).from_string("""
|
JJ_TPL = Environment(undefined=StrictUndefined).from_string(""" # nosec B701 — renders plain text description, not HTML; autoescape not applicable
|
||||||
# {{file_name}}
|
# {{file_name}}
|
||||||
|
|
||||||
## File Type
|
## File Type
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import logging
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from typing import List, Tuple
|
from typing import List, Tuple
|
||||||
|
|
||||||
from rdagent.components.coder.factor_coder.factor import FactorExperiment, FactorTask
|
from rdagent.components.coder.factor_coder.factor import FactorExperiment, FactorTask
|
||||||
@@ -9,6 +11,47 @@ from rdagent.scenarios.qlib.experiment.model_experiment import QlibModelExperime
|
|||||||
from rdagent.scenarios.qlib.experiment.quant_experiment import QlibQuantScenario
|
from rdagent.scenarios.qlib.experiment.quant_experiment import QlibQuantScenario
|
||||||
from rdagent.utils.agent.tpl import T
|
from rdagent.utils.agent.tpl import T
|
||||||
|
|
||||||
|
|
||||||
|
def _build_compressed_history(trace: Trace, max_history: int) -> str:
|
||||||
|
"""Return hypothesis_and_feedback string with only `max_history` entries.
|
||||||
|
|
||||||
|
Older entries beyond the last 2 are compressed to one bullet line each.
|
||||||
|
"""
|
||||||
|
if len(trace.hist) == 0:
|
||||||
|
return "No previous hypothesis and feedback available since it's the first round."
|
||||||
|
|
||||||
|
FULL_DETAIL = 2
|
||||||
|
old_hist = trace.hist[:-FULL_DETAIL] if len(trace.hist) > FULL_DETAIL else []
|
||||||
|
recent_hist = trace.hist[-FULL_DETAIL:] if len(trace.hist) > FULL_DETAIL else trace.hist
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
if old_hist:
|
||||||
|
lines = ["## Earlier experiments (summarized):"]
|
||||||
|
for exp, fb in old_hist:
|
||||||
|
names = []
|
||||||
|
for task in exp.sub_tasks:
|
||||||
|
if task is not None and hasattr(task, "factor_name"):
|
||||||
|
names.append(task.factor_name)
|
||||||
|
elif task is not None and hasattr(task, "model_type"):
|
||||||
|
names.append(getattr(task, "model_type", "model"))
|
||||||
|
ic_str = ""
|
||||||
|
try:
|
||||||
|
if exp.result is not None and "IC" in exp.result.index:
|
||||||
|
ic_str = f" IC={exp.result.loc['IC']:.4f}"
|
||||||
|
except Exception:
|
||||||
|
logging.debug("Exception caught", exc_info=True)
|
||||||
|
decision = "PASS" if fb.decision else "FAIL"
|
||||||
|
obs = (fb.observations or "")[:120].replace("\n", " ")
|
||||||
|
lines.append(f"- [{decision}]{ic_str} {', '.join(names) or 'unknown'}: {obs}")
|
||||||
|
parts.append("\n".join(lines))
|
||||||
|
|
||||||
|
if recent_hist:
|
||||||
|
rt = Trace(trace.scen)
|
||||||
|
rt.hist = recent_hist
|
||||||
|
parts.append(T("scenarios.qlib.prompts:hypothesis_and_feedback").r(trace=rt))
|
||||||
|
|
||||||
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
QlibFactorHypothesis = Hypothesis
|
QlibFactorHypothesis = Hypothesis
|
||||||
|
|
||||||
|
|
||||||
@@ -17,13 +60,10 @@ class QlibFactorHypothesisGen(FactorHypothesisGen):
|
|||||||
super().__init__(scen)
|
super().__init__(scen)
|
||||||
|
|
||||||
def prepare_context(self, trace: Trace) -> Tuple[dict, bool]:
|
def prepare_context(self, trace: Trace) -> Tuple[dict, bool]:
|
||||||
hypothesis_and_feedback = (
|
max_h = int(os.environ.get("QLIB_QUANT_MAX_FACTOR_HISTORY", "20"))
|
||||||
T("scenarios.qlib.prompts:hypothesis_and_feedback").r(
|
limited = Trace(trace.scen)
|
||||||
trace=trace,
|
limited.hist = trace.hist[-max_h:] if len(trace.hist) > max_h else trace.hist
|
||||||
)
|
hypothesis_and_feedback = _build_compressed_history(limited, max_h)
|
||||||
if len(trace.hist) > 0
|
|
||||||
else "No previous hypothesis and feedback available since it's the first round."
|
|
||||||
)
|
|
||||||
last_hypothesis_and_feedback = (
|
last_hypothesis_and_feedback = (
|
||||||
T("scenarios.qlib.prompts:last_hypothesis_and_feedback").r(
|
T("scenarios.qlib.prompts:last_hypothesis_and_feedback").r(
|
||||||
experiment=trace.hist[-1][0], feedback=trace.hist[-1][1]
|
experiment=trace.hist[-1][0], feedback=trace.hist[-1][1]
|
||||||
@@ -70,15 +110,15 @@ class QlibFactorHypothesis2Experiment(FactorHypothesis2Experiment):
|
|||||||
if len(trace.hist) == 0:
|
if len(trace.hist) == 0:
|
||||||
hypothesis_and_feedback = "No previous hypothesis and feedback available since it's the first round."
|
hypothesis_and_feedback = "No previous hypothesis and feedback available since it's the first round."
|
||||||
else:
|
else:
|
||||||
|
max_h = int(os.environ.get("QLIB_QUANT_MAX_FACTOR_HISTORY", "20"))
|
||||||
|
factor_hist = [
|
||||||
|
e for e in trace.hist
|
||||||
|
if not hasattr(e[0].hypothesis, "action") or e[0].hypothesis.action == "factor"
|
||||||
|
][-max_h:]
|
||||||
specific_trace = Trace(trace.scen)
|
specific_trace = Trace(trace.scen)
|
||||||
for i in range(len(trace.hist) - 1, -1, -1):
|
specific_trace.hist = factor_hist
|
||||||
if not hasattr(trace.hist[i][0].hypothesis, "action") or trace.hist[i][0].hypothesis.action == "factor":
|
if specific_trace.hist:
|
||||||
specific_trace.hist.insert(0, trace.hist[i])
|
hypothesis_and_feedback = _build_compressed_history(specific_trace, max_h)
|
||||||
if len(specific_trace.hist) > 0:
|
|
||||||
specific_trace.hist.reverse()
|
|
||||||
hypothesis_and_feedback = T("scenarios.qlib.prompts:hypothesis_and_feedback").r(
|
|
||||||
trace=specific_trace,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
hypothesis_and_feedback = "No previous hypothesis and feedback available."
|
hypothesis_and_feedback = "No previous hypothesis and feedback available."
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import logging
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
@@ -152,9 +153,41 @@ class QlibQuantHypothesisGen(FactorAndModelHypothesisGen):
|
|||||||
factor_inserted = True
|
factor_inserted = True
|
||||||
if len(specific_trace.hist) > 0:
|
if len(specific_trace.hist) > 0:
|
||||||
specific_trace.hist.reverse()
|
specific_trace.hist.reverse()
|
||||||
hypothesis_and_feedback = T("scenarios.qlib.prompts:hypothesis_and_feedback").r(
|
# Keep only the 2 most recent experiments in full detail; compress older ones
|
||||||
trace=specific_trace,
|
# to brief bullet points to stay within the LLM context window.
|
||||||
)
|
FULL_DETAIL_COUNT = 2
|
||||||
|
old_hist = specific_trace.hist[:-FULL_DETAIL_COUNT] if len(specific_trace.hist) > FULL_DETAIL_COUNT else []
|
||||||
|
recent_hist = specific_trace.hist[-FULL_DETAIL_COUNT:] if len(specific_trace.hist) > FULL_DETAIL_COUNT else specific_trace.hist
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
if old_hist:
|
||||||
|
summary_lines = ["## Earlier experiments (summarized):"]
|
||||||
|
for exp, fb in old_hist:
|
||||||
|
factor_names = []
|
||||||
|
for task in exp.sub_tasks:
|
||||||
|
if task is not None and hasattr(task, "factor_name"):
|
||||||
|
factor_names.append(task.factor_name)
|
||||||
|
elif task is not None and hasattr(task, "model_type"):
|
||||||
|
factor_names.append(getattr(task, "model_type", "model"))
|
||||||
|
names_str = ", ".join(factor_names) if factor_names else "unknown"
|
||||||
|
ic_str = ""
|
||||||
|
try:
|
||||||
|
if exp.result is not None:
|
||||||
|
ic_val = exp.result.loc["IC"] if "IC" in exp.result.index else ""
|
||||||
|
ic_str = f" IC={ic_val:.4f}" if ic_val != "" else ""
|
||||||
|
except Exception:
|
||||||
|
logging.debug("Error getting IC", exc_info=True)
|
||||||
|
decision_str = "PASS" if fb.decision else "FAIL"
|
||||||
|
obs_short = (fb.observations or "")[:120].replace("\n", " ")
|
||||||
|
summary_lines.append(f"- [{decision_str}]{ic_str} {names_str}: {obs_short}")
|
||||||
|
parts.append("\n".join(summary_lines))
|
||||||
|
|
||||||
|
if recent_hist:
|
||||||
|
recent_trace = Trace(specific_trace.scen)
|
||||||
|
recent_trace.hist = recent_hist
|
||||||
|
parts.append(T("scenarios.qlib.prompts:hypothesis_and_feedback").r(trace=recent_trace))
|
||||||
|
|
||||||
|
hypothesis_and_feedback = "\n\n".join(parts)
|
||||||
else:
|
else:
|
||||||
hypothesis_and_feedback = "No previous hypothesis and feedback available."
|
hypothesis_and_feedback = "No previous hypothesis and feedback available."
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ peft>=0.18.1
|
|||||||
|
|
||||||
# Evaluation
|
# Evaluation
|
||||||
opencompass==0.5.1
|
opencompass==0.5.1
|
||||||
setuptools<75 # uv venv doesn't include, opencompass depends on pkg_resources
|
setuptools>=78.1.1 # Security fix: GHSA-8g6x-3r52-4m6c (path traversal in PackageIndex.download, arbitrary file write/RCE)
|
||||||
|
|
||||||
# Inference acceleration (optional, TRL supports 0.10.2-0.12.0)
|
# Inference acceleration (optional, TRL supports 0.10.2-0.12.0)
|
||||||
# Security: Version >=0.14.0 fixes CVE-2026-22807 (RCE via auto_map dynamic module loading)
|
# Security: Version >=0.14.0 fixes CVE-2026-22807 (RCE via auto_map dynamic module loading)
|
||||||
|
|||||||
@@ -850,24 +850,22 @@ class QlibCondaEnv(LocalEnv[QlibCondaConf]):
|
|||||||
def prepare(self) -> None:
|
def prepare(self) -> None:
|
||||||
"""Prepare the conda environment if not already created."""
|
"""Prepare the conda environment if not already created."""
|
||||||
try:
|
try:
|
||||||
envs = subprocess.run("conda env list", capture_output=True, text=True, shell=True)
|
envs = subprocess.run(["conda", "env", "list"], capture_output=True, text=True)
|
||||||
if self.conf.conda_env_name not in envs.stdout:
|
if self.conf.conda_env_name not in envs.stdout:
|
||||||
print(f"[yellow]Conda env '{self.conf.conda_env_name}' not found, creating...[/yellow]")
|
print(f"[yellow]Conda env '{self.conf.conda_env_name}' not found, creating...[/yellow]")
|
||||||
subprocess.check_call(
|
subprocess.check_call(
|
||||||
f"conda create -y -n {self.conf.conda_env_name} python=3.10",
|
["conda", "create", "-y", "-n", self.conf.conda_env_name, "python=3.10"],
|
||||||
shell=True,
|
|
||||||
)
|
)
|
||||||
subprocess.check_call(
|
subprocess.check_call(
|
||||||
f"conda run -n {self.conf.conda_env_name} pip install --upgrade pip cython",
|
["conda", "run", "-n", self.conf.conda_env_name, "pip", "install", "--upgrade", "pip", "cython"],
|
||||||
shell=True,
|
|
||||||
)
|
)
|
||||||
subprocess.check_call(
|
subprocess.check_call(
|
||||||
f"conda run -n {self.conf.conda_env_name} pip install git+https://github.com/microsoft/qlib.git@2fb9380b342556ddb50a4b24e4fe8655d548b2b8",
|
["conda", "run", "-n", self.conf.conda_env_name, "pip", "install",
|
||||||
shell=True,
|
"git+https://github.com/microsoft/qlib.git@2fb9380b342556ddb50a4b24e4fe8655d548b2b8"],
|
||||||
)
|
)
|
||||||
subprocess.check_call(
|
subprocess.check_call(
|
||||||
f"conda run -n {self.conf.conda_env_name} pip install catboost xgboost tables torch",
|
["conda", "run", "-n", self.conf.conda_env_name, "pip", "install",
|
||||||
shell=True,
|
"catboost", "xgboost", "tables", "torch"],
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -270,6 +270,11 @@ class LoopBase:
|
|||||||
msg = "We have reset the loop instance, stop all the routines and resume."
|
msg = "We have reset the loop instance, stop all the routines and resume."
|
||||||
raise self.LoopResumeError(msg) from e
|
raise self.LoopResumeError(msg) from e
|
||||||
else:
|
else:
|
||||||
|
# Do NOT advance step_idx for unhandled exceptions (e.g. LoopResumeError
|
||||||
|
# propagating from _propose). Keeping step_idx at the current step lets
|
||||||
|
# kickoff_loop retry step 0 on the next resume instead of permanently
|
||||||
|
# corrupting the loop with a missing direct_exp_gen result.
|
||||||
|
step_forward = False
|
||||||
raise # re-raise unhandled exceptions
|
raise # re-raise unhandled exceptions
|
||||||
finally:
|
finally:
|
||||||
# No matter the execution succeed or not, we have to finish the following steps
|
# No matter the execution succeed or not, we have to finish the following steps
|
||||||
|
|||||||
+4
-4
@@ -9,8 +9,8 @@ psutil
|
|||||||
fire
|
fire
|
||||||
fuzzywuzzy
|
fuzzywuzzy
|
||||||
openai
|
openai
|
||||||
litellm>=1.73 # to support `from litellm import get_valid_models`
|
litellm>=1.83.14 # to support `from litellm import get_valid_models`
|
||||||
aiohttp>=3.13.4 # CVE-2026-22815, CVE-2026-34515, CVE-2026-34516, CVE-2026-34525
|
aiohttp>=3.13.5 # CVE-2026-22815, CVE-2026-34515, CVE-2026-34516, CVE-2026-34525
|
||||||
azure.identity
|
azure.identity
|
||||||
pyarrow
|
pyarrow
|
||||||
rich
|
rich
|
||||||
@@ -98,8 +98,8 @@ optuna>=3.5.0
|
|||||||
beautifulsoup4>=4.12.0
|
beautifulsoup4>=4.12.0
|
||||||
|
|
||||||
# ML Training Pipeline
|
# ML Training Pipeline
|
||||||
lightgbm>=3.3.0
|
lightgbm>=3.3.5
|
||||||
scipy>=1.9.0
|
scipy>=1.15.3
|
||||||
|
|
||||||
# RL Trading (optional - system works without these)
|
# RL Trading (optional - system works without these)
|
||||||
# Install for full RL training: pip install stable-baselines3[extra] gymnasium
|
# Install for full RL training: pip install stable-baselines3[extra] gymnasium
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
# Only install if you want to use full PPO/A2C/SAC training.
|
# Only install if you want to use full PPO/A2C/SAC training.
|
||||||
|
|
||||||
# Core RL library
|
# Core RL library
|
||||||
stable-baselines3[extra]>=2.0.0
|
stable-baselines3[extra]>=2.8.0
|
||||||
|
|
||||||
# Gymnasium environment (OpenAI Gym successor)
|
# Gymnasium environment (OpenAI Gym successor)
|
||||||
gymnasium>=0.29.0
|
gymnasium>=0.29.0
|
||||||
|
|||||||
@@ -198,6 +198,55 @@ def scan_factors(workspace_dir: Path, skip_evaluated: bool = True) -> List[Facto
|
|||||||
return factors
|
return factors
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Look-ahead bias detection for daily-constant factors
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _shift_daily_constant_factor_if_needed(factor_col: "pd.Series", factor_name: str) -> "pd.Series":
|
||||||
|
"""Detect daily-constant factors (look-ahead bias) and shift by 1 trading day."""
|
||||||
|
sample_days = factor_col.index.get_level_values("datetime").normalize().unique()
|
||||||
|
if len(sample_days) < 10:
|
||||||
|
return factor_col
|
||||||
|
rng = np.random.default_rng(42)
|
||||||
|
days_to_check = rng.choice(sample_days, size=min(50, len(sample_days)), replace=False)
|
||||||
|
constant_count = 0
|
||||||
|
for day in days_to_check:
|
||||||
|
day_mask = factor_col.index.get_level_values("datetime").normalize() == day
|
||||||
|
day_vals = factor_col[day_mask].dropna()
|
||||||
|
if len(day_vals) == 0:
|
||||||
|
continue
|
||||||
|
if day_vals.nunique() == 1:
|
||||||
|
constant_count += 1
|
||||||
|
fraction_constant = constant_count / len(days_to_check)
|
||||||
|
if fraction_constant < 0.90:
|
||||||
|
return factor_col
|
||||||
|
# Shift by 1 trading day per instrument
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).info(
|
||||||
|
"Factor '%s' is %.0f%% daily-constant — shifting 1 trading day to fix look-ahead bias",
|
||||||
|
factor_name, fraction_constant * 100,
|
||||||
|
)
|
||||||
|
instruments = factor_col.index.get_level_values("instrument").unique() if "instrument" in factor_col.index.names else [None]
|
||||||
|
shifted_parts = []
|
||||||
|
for instr in instruments:
|
||||||
|
if instr is not None:
|
||||||
|
mask = factor_col.index.get_level_values("instrument") == instr
|
||||||
|
col_instr = factor_col[mask]
|
||||||
|
else:
|
||||||
|
col_instr = factor_col
|
||||||
|
dates = col_instr.index.get_level_values("datetime").normalize()
|
||||||
|
trading_days = dates.unique().sort_values()
|
||||||
|
day_first = col_instr.groupby(dates).first()
|
||||||
|
day_first_shifted = day_first.shift(1)
|
||||||
|
day_first_shifted.index = pd.to_datetime(day_first_shifted.index)
|
||||||
|
day_map = day_first_shifted.reindex(pd.to_datetime(trading_days)).values
|
||||||
|
new_vals = pd.Series(
|
||||||
|
day_map[np.searchsorted(trading_days.values, dates.values)],
|
||||||
|
index=col_instr.index,
|
||||||
|
)
|
||||||
|
shifted_parts.append(new_vals)
|
||||||
|
return pd.concat(shifted_parts).sort_index()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Factor evaluator
|
# Factor evaluator
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -263,6 +312,7 @@ def evaluate_factor_full(factor: FactorInfo, full_data: pd.DataFrame,
|
|||||||
result = pd.read_hdf(str(result_file), key="data")
|
result = pd.read_hdf(str(result_file), key="data")
|
||||||
total_count = len(result)
|
total_count = len(result)
|
||||||
factor_val = result.iloc[:, 0]
|
factor_val = result.iloc[:, 0]
|
||||||
|
factor_val = _shift_daily_constant_factor_if_needed(factor_val, factor.factor_name)
|
||||||
non_null_count = factor_val.notna().sum()
|
non_null_count = factor_val.notna().sum()
|
||||||
|
|
||||||
if non_null_count < 1000:
|
if non_null_count < 1000:
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ Hard requirements:
|
|||||||
- NO global mean/std — always use rolling(window).mean() with shift(1) to avoid look-ahead bias"""
|
- NO global mean/std — always use rolling(window).mean() with shift(1) to avoid look-ahead bias"""
|
||||||
|
|
||||||
else:
|
else:
|
||||||
system_prompt = f"""You are a quantitative trading expert specializing in EUR/USD intraday strategies.
|
system_prompt = f"""You are a quantitative trading expert specializing in EUR/USD daily swing strategies.
|
||||||
|
|
||||||
CRITICAL RULES for {STYLE_DESC} (forward horizon: {FORWARD_BARS} bars = ~{FORWARD_BARS/60:.1f} hours):
|
CRITICAL RULES for {STYLE_DESC} (forward horizon: {FORWARD_BARS} bars = ~{FORWARD_BARS/60:.1f} hours):
|
||||||
1. ONLY use the factors listed below - no others!
|
1. ONLY use the factors listed below - no others!
|
||||||
@@ -258,15 +258,27 @@ CRITICAL RULES for {STYLE_DESC} (forward horizon: {FORWARD_BARS} bars = ~{FORWAR
|
|||||||
3. Create a pandas Series called 'signal' with values: 1 (long), -1 (short), 0 (neutral)
|
3. Create a pandas Series called 'signal' with values: 1 (long), -1 (short), 0 (neutral)
|
||||||
4. signal.index MUST match close.index
|
4. signal.index MUST match close.index
|
||||||
5. signal.name must be 'signal'
|
5. signal.name must be 'signal'
|
||||||
|
6. IMPORTANT: factors are DAILY values broadcast to every 1-minute bar — they change once per day.
|
||||||
|
Use daily-level logic: compare today's factor value to a rolling daily mean (window 5-20 DAYS).
|
||||||
|
To get daily rolling mean: group by date, take first value per day, compute rolling, then reindex back.
|
||||||
|
Example: dates = factors[col].index.get_level_values('datetime').normalize()
|
||||||
|
daily_vals = factors[col].groupby(dates).first()
|
||||||
|
daily_mean = daily_vals.rolling(10).mean().shift(1)
|
||||||
|
daily_signal = (daily_vals > daily_mean).astype(int) * 2 - 1
|
||||||
|
signal = daily_signal.reindex(dates).values (broadcast back to minute bars)
|
||||||
|
7. The signal should change roughly once per day — this produces ~250-500 trades over 6 years.
|
||||||
|
8. Keep conditions SIMPLE: one factor above/below its N-day rolling average. Avoid combining 3+ conditions.
|
||||||
|
|
||||||
Output ONLY valid JSON with these fields:
|
Output ONLY valid JSON with these fields:
|
||||||
{{"strategy_name": "short_name", "factor_names": ["f1", "f2"], "description": "one sentence", "code": "python code"}}"""
|
{{"strategy_name": "short_name", "factor_names": ["f1", "f2"], "description": "one sentence", "code": "python code"}}"""
|
||||||
|
|
||||||
user_prompt = f"""Create a EUR/USD trading strategy using these factors:
|
user_prompt = f"""Create a EUR/USD SWING trading strategy (hold ~{FORWARD_BARS/60:.0f} hours) using these factors:
|
||||||
|
|
||||||
{factor_list}
|
{factor_list}
|
||||||
|
|
||||||
{f'Previous feedback: {feedback}' if feedback else 'First attempt - be creative!'}"""
|
{f'Previous feedback: {feedback}' if feedback else 'First attempt - be creative!'}
|
||||||
|
|
||||||
|
Use daily-level signal logic (factor above/below rolling daily mean). Signal changes once per day."""
|
||||||
|
|
||||||
api = APIBackend()
|
api = APIBackend()
|
||||||
response = api.build_messages_and_create_chat_completion(
|
response = api.build_messages_and_create_chat_completion(
|
||||||
@@ -371,8 +383,8 @@ signal.fillna(0).to_pickle('signal.pkl')
|
|||||||
txn_cost_bps=TXN_COST_BPS,
|
txn_cost_bps=TXN_COST_BPS,
|
||||||
forward_returns=fwd_returns,
|
forward_returns=fwd_returns,
|
||||||
oos_start=OOS_START_DEFAULT,
|
oos_start=OOS_START_DEFAULT,
|
||||||
wf_rolling=True,
|
wf_rolling=False, # too slow on 2M bars — run via rebacktest script instead
|
||||||
mc_n_permutations=200,
|
mc_n_permutations=50,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -579,7 +591,7 @@ def main(target_count=10):
|
|||||||
# Check acceptance criteria — OOS must be profitable + statistically significant
|
# 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
|
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
|
wf_ok = wf_consistency is None or wf_consistency >= 0.5 # ≥50% of WF windows profitable
|
||||||
if (abs(ic) > MIN_IC and sharpe > MIN_SHARPE and trades > MIN_TRADES and dd > MAX_DRAWDOWN
|
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):
|
and oos_sharpe > 0.0 and oos_monthly > 0.0 and mc_ok and wf_ok):
|
||||||
# ACCEPT
|
# ACCEPT
|
||||||
strategy['real_backtest'] = bt_result
|
strategy['real_backtest'] = bt_result
|
||||||
@@ -636,9 +648,10 @@ def main(target_count=10):
|
|||||||
oos_info = f"OOS_Sharpe={oos_sharpe:+.2f} OOS_Mon={oos_monthly:+.2f}%" if oos_sharpe is not None else ""
|
oos_info = f"OOS_Sharpe={oos_sharpe:+.2f} OOS_Mon={oos_monthly:+.2f}%" if oos_sharpe is not None else ""
|
||||||
mc_info = f" MC_p={mc_pvalue:.2f}" if mc_pvalue is not None else ""
|
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 ""
|
wf_info = f" WF_consistency={wf_consistency:.0%}" if wf_consistency is not None else ""
|
||||||
_log.info(f"REJECTED IC={ic:.4f} Sharpe={sharpe:.2f} Trades={trades} DD={dd:.1%} {oos_info}{mc_info}{wf_info}")
|
_ic = ic or 0; _sh = sharpe or 0; _dd = dd or 0
|
||||||
|
_log.info(f"REJECTED IC={_ic:.4f} Sharpe={_sh:.2f} Trades={trades} DD={_dd:.1%} {oos_info}{mc_info}{wf_info}")
|
||||||
feedback_history.append(
|
feedback_history.append(
|
||||||
f"Failed: IC={ic:.4f}, Sharpe={sharpe:.2f}, Trades={trades}, DD={dd:.1%}, "
|
f"Failed: IC={_ic:.4f}, Sharpe={_sh:.2f}, Trades={trades}, DD={_dd:.1%}, "
|
||||||
f"OOS_Sharpe={oos_sharpe:+.2f}, OOS_Monthly={oos_monthly:+.2f}%"
|
f"OOS_Sharpe={oos_sharpe:+.2f}, OOS_Monthly={oos_monthly:+.2f}%"
|
||||||
+ (f", MC_p={mc_pvalue:.2f}" if mc_pvalue is not None else "")
|
+ (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", WF_consistency={wf_consistency:.0%}" if wf_consistency is not None else "")
|
||||||
|
|||||||
@@ -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