mirror of
https://github.com/NicolasBohn/NexQuant.git
synced 2026-08-04 02:37:44 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 944af06a87 | |||
| 97e42d7a1a | |||
| 5481e83f03 | |||
| 88c4cc4a33 | |||
| 01889a6b64 | |||
| 443c6d47b2 | |||
| b10d3512df | |||
| d75cba934e | |||
| 38fa760429 |
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "1.3.0"
|
".": "1.3.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,32 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -29,6 +29,83 @@ from rdagent.scenarios.qlib.experiment.model_experiment import QlibModelExperime
|
|||||||
DIRNAME = Path(__file__).absolute().resolve().parent
|
DIRNAME = Path(__file__).absolute().resolve().parent
|
||||||
DIRNAME_local = Path.cwd()
|
DIRNAME_local = Path.cwd()
|
||||||
|
|
||||||
|
|
||||||
|
def _shift_daily_constant_factor_if_needed(factor_col: "pd.Series", factor_name: str) -> "pd.Series":
|
||||||
|
"""Detect and fix look-ahead bias in daily-constant factors.
|
||||||
|
|
||||||
|
A factor is "daily-constant" when every minute bar within the same calendar
|
||||||
|
day carries an identical value. This happens when LLM code computes a daily
|
||||||
|
aggregate (e.g. today's log return) and forward-fills it across all intraday
|
||||||
|
bars without shifting — meaning the end-of-day value is visible at 00:00.
|
||||||
|
|
||||||
|
Fix: shift by one trading day so that the value assigned to day T is the
|
||||||
|
aggregate computed from day T-1, eliminating the forward-looking information.
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
try:
|
||||||
|
notnull = factor_col.dropna()
|
||||||
|
if len(notnull) < 200:
|
||||||
|
return factor_col
|
||||||
|
|
||||||
|
datetimes = notnull.index.get_level_values("datetime")
|
||||||
|
dates = datetimes.normalize()
|
||||||
|
|
||||||
|
# Sample up to 50 random days and check intra-day uniqueness
|
||||||
|
unique_dates = pd.Series(dates.unique())
|
||||||
|
sample_dates = unique_dates.sample(min(50, len(unique_dates)), random_state=42)
|
||||||
|
|
||||||
|
daily_unique_counts = []
|
||||||
|
for d in sample_dates:
|
||||||
|
mask = dates == d
|
||||||
|
vals = notnull.values[mask]
|
||||||
|
if len(vals) > 1:
|
||||||
|
daily_unique_counts.append(len(np.unique(vals[~np.isnan(vals)])))
|
||||||
|
|
||||||
|
if not daily_unique_counts:
|
||||||
|
return factor_col
|
||||||
|
|
||||||
|
# If >90% of sampled days have exactly 1 unique value → daily-constant
|
||||||
|
fraction_constant = sum(1 for c in daily_unique_counts if c == 1) / len(daily_unique_counts)
|
||||||
|
if fraction_constant < 0.90:
|
||||||
|
return factor_col # Intraday factor — no shift needed
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
f"[LookAheadFix] Factor '{factor_name}' is daily-constant "
|
||||||
|
f"({fraction_constant:.0%} of days). Applying 1-day shift to remove look-ahead bias."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Shift: for each instrument, map daily values forward by 1 trading day
|
||||||
|
instruments = factor_col.index.get_level_values("instrument").unique()
|
||||||
|
shifted_parts = []
|
||||||
|
for inst in instruments:
|
||||||
|
inst_series = factor_col.xs(inst, level="instrument")
|
||||||
|
# Get one value per calendar day (the first non-null bar)
|
||||||
|
inst_dt = inst_series.index.normalize()
|
||||||
|
daily_vals = inst_series.groupby(inst_dt).first()
|
||||||
|
# Shift by 1 day
|
||||||
|
daily_vals_shifted = daily_vals.shift(1)
|
||||||
|
# Forward-fill back to minute bars
|
||||||
|
minute_idx = inst_series.index
|
||||||
|
minute_dates = minute_idx.normalize()
|
||||||
|
shifted_minute = minute_dates.map(daily_vals_shifted)
|
||||||
|
shifted_s = pd.Series(
|
||||||
|
shifted_minute.values,
|
||||||
|
index=pd.MultiIndex.from_arrays(
|
||||||
|
[inst_series.index, [inst] * len(inst_series)],
|
||||||
|
names=["datetime", "instrument"],
|
||||||
|
),
|
||||||
|
name=factor_col.name,
|
||||||
|
)
|
||||||
|
shifted_parts.append(shifted_s)
|
||||||
|
|
||||||
|
return pd.concat(shifted_parts).sort_index()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[LookAheadFix] Could not apply daily shift for '{factor_name}': {e}")
|
||||||
|
return factor_col
|
||||||
|
|
||||||
|
|
||||||
# TODO: supporting multiprocessing and keep previous results
|
# TODO: supporting multiprocessing and keep previous results
|
||||||
|
|
||||||
|
|
||||||
@@ -391,8 +468,19 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get workspace path
|
# Get workspace path — factor code and result.h5 live in sub_workspace_list[0],
|
||||||
workspace_path = exp.experiment_workspace.workspace_path
|
# not in experiment_workspace (which is the Qlib template workspace).
|
||||||
|
workspace_path = None
|
||||||
|
if exp.sub_workspace_list:
|
||||||
|
for ws in exp.sub_workspace_list:
|
||||||
|
if ws is not None and hasattr(ws, 'workspace_path'):
|
||||||
|
candidate = ws.workspace_path / "result.h5"
|
||||||
|
if candidate.exists():
|
||||||
|
workspace_path = ws.workspace_path
|
||||||
|
break
|
||||||
|
if workspace_path is None:
|
||||||
|
# Fallback to experiment_workspace
|
||||||
|
workspace_path = exp.experiment_workspace.workspace_path
|
||||||
if workspace_path is None:
|
if workspace_path is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -409,6 +497,12 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
factor_col = factor_values.iloc[:, 0]
|
factor_col = factor_values.iloc[:, 0]
|
||||||
factor_name = factor_values.columns[0]
|
factor_name = factor_values.columns[0]
|
||||||
|
|
||||||
|
# Detect and fix look-ahead bias in daily-constant factors.
|
||||||
|
# If a factor has the same value for all minute bars within each calendar day
|
||||||
|
# it was computed from same-day data (e.g. today's close return at 00:00).
|
||||||
|
# Fix: shift by 1 trading day so value at day T = aggregate of day T-1.
|
||||||
|
factor_col = _shift_daily_constant_factor_if_needed(factor_col, factor_name)
|
||||||
|
|
||||||
# Load source data for forward returns
|
# Load source data for forward returns
|
||||||
data_path = (
|
data_path = (
|
||||||
Path(__file__).parent.parent.parent.parent.parent
|
Path(__file__).parent.parent.parent.parent.parent
|
||||||
@@ -587,10 +681,12 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from rdagent.components.backtesting import ResultsDatabase
|
from rdagent.components.backtesting import ResultsDatabase
|
||||||
|
|
||||||
# Get factor name from hypothesis
|
# Get factor name: prefer hypothesis, fallback to result Series 'factor_name' key
|
||||||
factor_name = "unknown"
|
factor_name = "unknown"
|
||||||
if hasattr(exp, 'hypothesis') and exp.hypothesis is not None:
|
if hasattr(exp, 'hypothesis') and exp.hypothesis is not None:
|
||||||
factor_name = getattr(exp.hypothesis, 'hypothesis', 'unknown')
|
factor_name = getattr(exp.hypothesis, 'hypothesis', 'unknown')
|
||||||
|
if factor_name == 'unknown' and isinstance(result, pd.Series) and 'factor_name' in result.index:
|
||||||
|
factor_name = str(result['factor_name'])
|
||||||
|
|
||||||
# Check if already rejected by protection
|
# Check if already rejected by protection
|
||||||
if getattr(exp, 'rejected_by_protection', False):
|
if getattr(exp, 'rejected_by_protection', False):
|
||||||
@@ -824,41 +920,74 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
"""
|
"""
|
||||||
Save factor time-series values as parquet for strategy building.
|
Save factor time-series values as parquet for strategy building.
|
||||||
|
|
||||||
This is essential for walk-forward validation and strategy combination.
|
Reruns the factor code on the FULL 6-year dataset so the parquet covers
|
||||||
|
the complete backtest range (not just the debug 2024 subset).
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
factor_name : str
|
|
||||||
Name of the factor
|
|
||||||
exp : QlibFactorExperiment
|
|
||||||
The experiment with factor values
|
|
||||||
"""
|
"""
|
||||||
import os as _os
|
import os as _os
|
||||||
|
import subprocess
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get workspace path
|
# factor.py lives in sub_workspace_list[0], not experiment_workspace
|
||||||
workspace_path = exp.experiment_workspace.workspace_path
|
workspace_path = None
|
||||||
|
if exp.sub_workspace_list:
|
||||||
|
for ws in exp.sub_workspace_list:
|
||||||
|
if ws is not None and hasattr(ws, 'workspace_path'):
|
||||||
|
fp = ws.workspace_path / "factor.py"
|
||||||
|
if fp.exists():
|
||||||
|
workspace_path = ws.workspace_path
|
||||||
|
break
|
||||||
|
if workspace_path is None:
|
||||||
|
workspace_path = exp.experiment_workspace.workspace_path
|
||||||
if workspace_path is None:
|
if workspace_path is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
result_h5 = workspace_path / "result.h5"
|
factor_py = workspace_path / "factor.py"
|
||||||
if not result_h5.exists():
|
if not factor_py.exists():
|
||||||
return
|
return
|
||||||
|
|
||||||
# Read factor values
|
project_root = Path(__file__).parent.parent.parent.parent.parent
|
||||||
|
full_data = (
|
||||||
|
project_root
|
||||||
|
/ "git_ignore_folder"
|
||||||
|
/ "factor_implementation_source_data"
|
||||||
|
/ "intraday_pv.h5"
|
||||||
|
)
|
||||||
|
if not full_data.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
# Run factor code on full data in a temp workspace
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
df = pd.read_hdf(str(result_h5), key="data")
|
with tempfile.TemporaryDirectory(prefix="predix_fullval_") as tmp_dir:
|
||||||
|
tmp = Path(tmp_dir)
|
||||||
|
shutil.copy(str(factor_py), str(tmp / "factor.py"))
|
||||||
|
shutil.copy(str(full_data), str(tmp / "intraday_pv.h5"))
|
||||||
|
|
||||||
|
ret = subprocess.run(
|
||||||
|
["python", "factor.py"],
|
||||||
|
cwd=str(tmp),
|
||||||
|
capture_output=True,
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
if ret.returncode != 0:
|
||||||
|
# Fall back to debug-data result if full-data run fails
|
||||||
|
result_h5 = workspace_path / "result.h5"
|
||||||
|
if not result_h5.exists():
|
||||||
|
return
|
||||||
|
df = pd.read_hdf(str(result_h5), key="data")
|
||||||
|
else:
|
||||||
|
result_h5_full = tmp / "result.h5"
|
||||||
|
if not result_h5_full.exists():
|
||||||
|
return
|
||||||
|
df = pd.read_hdf(str(result_h5_full), key="data")
|
||||||
|
|
||||||
if df is None or df.empty:
|
if df is None or df.empty:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get the factor series (first column)
|
|
||||||
series = df.iloc[:, 0]
|
series = df.iloc[:, 0]
|
||||||
series.name = factor_name
|
series.name = factor_name
|
||||||
|
|
||||||
# Save to results/factors/values/
|
|
||||||
project_root = Path(__file__).parent.parent.parent.parent.parent
|
|
||||||
|
|
||||||
# Parallel run isolation
|
|
||||||
parallel_run_id = _os.getenv("PARALLEL_RUN_ID", "0")
|
parallel_run_id = _os.getenv("PARALLEL_RUN_ID", "0")
|
||||||
if parallel_run_id != "0":
|
if parallel_run_id != "0":
|
||||||
values_dir = project_root / "results" / "runs" / f"run{parallel_run_id}" / "factors" / "values"
|
values_dir = project_root / "results" / "runs" / f"run{parallel_run_id}" / "factors" / "values"
|
||||||
@@ -866,16 +995,11 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|||||||
values_dir = project_root / "results" / "factors" / "values"
|
values_dir = project_root / "results" / "factors" / "values"
|
||||||
|
|
||||||
values_dir.mkdir(parents=True, exist_ok=True)
|
values_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Safe filename
|
|
||||||
safe_name = factor_name.replace("/", "_").replace("\\", "_").replace(" ", "_")[:100]
|
safe_name = factor_name.replace("/", "_").replace("\\", "_").replace(" ", "_")[:100]
|
||||||
parquet_path = values_dir / f"{safe_name}.parquet"
|
parquet_path = values_dir / f"{safe_name}.parquet"
|
||||||
|
series.to_frame().to_parquet(str(parquet_path))
|
||||||
|
|
||||||
# Save as parquet (with datetime index)
|
except Exception:
|
||||||
series.to_parquet(str(parquet_path))
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
# Don't let factor value saving break the main workflow
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _log_result_warnings(self, factor_name: str, result, metrics: dict) -> None:
|
def _log_result_warnings(self, factor_name: str, result, metrics: dict) -> None:
|
||||||
|
|||||||
@@ -23,14 +23,25 @@ $low: low price at 1-minute bar.
|
|||||||
$volume: volume at 1-minute bar (tick volume for FX).
|
$volume: volume at 1-minute bar (tick volume for FX).
|
||||||
|
|
||||||
## Important Notes for 1min Data
|
## Important Notes for 1min Data
|
||||||
- 96 bars = 1 trading day (24 hours for FX)
|
- 1 bar = 1 minute (confirmed)
|
||||||
- 16 bars = 16 minutes
|
- 16 bars = 16 minutes
|
||||||
- 4 bars = 4 minutes
|
- 60 bars = 1 hour
|
||||||
- 1 bar = 1 minute
|
- ~1440 bars = 1 full trading day (FX trades nearly 24h, Mon 00:00 - Fri 22:00 UTC approx.)
|
||||||
|
- Typical bars per calendar day: ~1200-1440 (varies by weekday, holidays have fewer)
|
||||||
|
- Do NOT assume 96 bars/day — the actual count depends on the date
|
||||||
- Data range: 2020-01-01 to 2026-03-20
|
- Data range: 2020-01-01 to 2026-03-20
|
||||||
- Instrument: EURUSD
|
- Instrument: EURUSD
|
||||||
- Timezone: UTC
|
- Timezone: UTC
|
||||||
|
|
||||||
|
## IMPORTANT: Bars per Day Correction
|
||||||
|
The dataset has approximately 1440 bars per full trading day (1 bar = 1 minute, ~24h of FX trading).
|
||||||
|
Some older documentation incorrectly stated "96 bars = 1 day" — this is WRONG. Always use:
|
||||||
|
- 60 bars = 1 hour
|
||||||
|
- 480 bars = 8 hours (London session 08:00-16:00 UTC)
|
||||||
|
- 180 bars = 3 hours (London/NY overlap 13:00-16:00 UTC)
|
||||||
|
Use datetime hour filtering (e.g., `df[df.index.get_level_values('datetime').hour.between(8, 15)]`)
|
||||||
|
to select session bars — do NOT use bar-count offsets to define sessions.
|
||||||
|
|
||||||
## Session Times (UTC)
|
## Session Times (UTC)
|
||||||
- Asian: 00:00-08:00 UTC (low volatility)
|
- Asian: 00:00-08:00 UTC (low volatility)
|
||||||
- London: 08:00-16:00 UTC (high volatility)
|
- London: 08:00-16:00 UTC (high volatility)
|
||||||
|
|||||||
@@ -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'>
|
||||||
|
|||||||
@@ -152,9 +152,41 @@ class QlibQuantHypothesisGen(FactorAndModelHypothesisGen):
|
|||||||
factor_inserted = True
|
factor_inserted = True
|
||||||
if len(specific_trace.hist) > 0:
|
if len(specific_trace.hist) > 0:
|
||||||
specific_trace.hist.reverse()
|
specific_trace.hist.reverse()
|
||||||
hypothesis_and_feedback = T("scenarios.qlib.prompts:hypothesis_and_feedback").r(
|
# Keep only the 2 most recent experiments in full detail; compress older ones
|
||||||
trace=specific_trace,
|
# to brief bullet points to stay within the LLM context window.
|
||||||
)
|
FULL_DETAIL_COUNT = 2
|
||||||
|
old_hist = specific_trace.hist[:-FULL_DETAIL_COUNT] if len(specific_trace.hist) > FULL_DETAIL_COUNT else []
|
||||||
|
recent_hist = specific_trace.hist[-FULL_DETAIL_COUNT:] if len(specific_trace.hist) > FULL_DETAIL_COUNT else specific_trace.hist
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
if old_hist:
|
||||||
|
summary_lines = ["## Earlier experiments (summarized):"]
|
||||||
|
for exp, fb in old_hist:
|
||||||
|
factor_names = []
|
||||||
|
for task in exp.sub_tasks:
|
||||||
|
if task is not None and hasattr(task, "factor_name"):
|
||||||
|
factor_names.append(task.factor_name)
|
||||||
|
elif task is not None and hasattr(task, "model_type"):
|
||||||
|
factor_names.append(getattr(task, "model_type", "model"))
|
||||||
|
names_str = ", ".join(factor_names) if factor_names else "unknown"
|
||||||
|
ic_str = ""
|
||||||
|
try:
|
||||||
|
if exp.result is not None:
|
||||||
|
ic_val = exp.result.loc["IC"] if "IC" in exp.result.index else ""
|
||||||
|
ic_str = f" IC={ic_val:.4f}" if ic_val != "" else ""
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
decision_str = "PASS" if fb.decision else "FAIL"
|
||||||
|
obs_short = (fb.observations or "")[:120].replace("\n", " ")
|
||||||
|
summary_lines.append(f"- [{decision_str}]{ic_str} {names_str}: {obs_short}")
|
||||||
|
parts.append("\n".join(summary_lines))
|
||||||
|
|
||||||
|
if recent_hist:
|
||||||
|
recent_trace = Trace(specific_trace.scen)
|
||||||
|
recent_trace.hist = recent_hist
|
||||||
|
parts.append(T("scenarios.qlib.prompts:hypothesis_and_feedback").r(trace=recent_trace))
|
||||||
|
|
||||||
|
hypothesis_and_feedback = "\n\n".join(parts)
|
||||||
else:
|
else:
|
||||||
hypothesis_and_feedback = "No previous hypothesis and feedback available."
|
hypothesis_and_feedback = "No previous hypothesis and feedback available."
|
||||||
|
|
||||||
|
|||||||
@@ -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 "")
|
||||||
|
|||||||
Reference in New Issue
Block a user