fix: resolve dead code, shell injection risk, mutable defaults, and other bugs

- strategy_orchestrator.py: remove unreachable dead 'if not factor_values' after early return
- strategy_orchestrator.py: eliminate duplicate OHLVC load in evaluate_strategy
- env.py: escape single-quotes in Docker entry to prevent shell injection (CWE-78)
- env.py: replace mutable default args with None pattern in DockerEnv subclasses
- factor_runner.py: move pandarallel.initialize() from import-time to lazy init
This commit is contained in:
TPTBusiness
2026-05-02 23:21:38 +02:00
parent 20428f7d91
commit 15084f593c
3 changed files with 156 additions and 167 deletions
@@ -749,18 +749,15 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
}
# Align factor values with common index
if not factor_values:
df_factors = pd.DataFrame()
else:
# Find common index across all series
common_idx = None
for name, s in factor_values.items():
if common_idx is None:
common_idx = s.index
else:
common_idx = common_idx.intersection(s.index)
# Find common index across all series
common_idx = None
for name, s in factor_values.items():
if common_idx is None:
common_idx = s.index
else:
common_idx = common_idx.intersection(s.index)
if common_idx is not None and len(common_idx) > 100:
if common_idx is not None and len(common_idx) > 100:
df_factors = pd.DataFrame({
name: s.reindex(common_idx) for name, s in factor_values.items()
}).dropna()
@@ -826,16 +823,15 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
"factors_used": factor_names,
}
if "signal" not in local_vars:
signal = local_vars.get("signal")
if signal is None or (isinstance(signal, pd.Series) and signal.empty):
return {
"strategy_name": strategy_name,
"status": "rejected",
"reason": "Strategy did not produce 'signal' variable",
"reason": "Strategy did not produce valid 'signal' variable",
"factors_used": factor_names,
}
signal = local_vars["signal"]
logger.info(
f"[DEBUG] {strategy_name}: signal stats: "
f"len={len(signal)}, "
@@ -852,7 +848,7 @@ signal = signal.rolling(window=3, min_periods=1).mean().round().astype(int)
backtest_signal_ftmo,
)
close = self.load_ohlcv_close()
# Reuse the already-loaded close from above; create a synthetic proxy if unavailable
if close is None:
logger.warning("OHLCV data unavailable, using factor-mean proxy")
proxy = df_factors.mean(axis=1).astype(float)
+132 -136
View File
@@ -1,7 +1,7 @@
import sys
import os
import logging
import os
from pathlib import Path
"""
Qlib Factor Runner - Executes factor backtests in Docker.
@@ -11,15 +11,8 @@ NOTE: The @cache_with_pickle decorator was REMOVED from develop() because:
- Docker-level caching (QlibDockerConf.enable_cache=False) is sufficient
- The pickle cache caused 240+ factor generations but ZERO Docker backtests
"""
from pathlib import Path
from typing import Optional
import pandas as pd
from pandarallel import pandarallel
pandarallel.initialize(verbose=1)
from rdagent.app.qlib_rd_loop.conf import FactorBasePropSetting
from rdagent.components.runner import CachedRunner
from rdagent.core.exception import FactorEmptyError
@@ -74,7 +67,7 @@ def _shift_daily_constant_factor_if_needed(factor_col: "pd.Series", factor_name:
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."
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
@@ -122,13 +115,13 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
"""
def calculate_information_coefficient(
self, concat_feature: pd.DataFrame, SOTA_feature_column_size: int, new_feature_columns_size: int
self, concat_feature: pd.DataFrame, SOTA_feature_column_size: int, new_feature_columns_size: int,
) -> pd.DataFrame:
res = pd.Series(index=range(SOTA_feature_column_size * new_feature_columns_size))
for col1 in range(SOTA_feature_column_size):
for col2 in range(SOTA_feature_column_size, SOTA_feature_column_size + new_feature_columns_size):
res.loc[col1 * new_feature_columns_size + col2 - SOTA_feature_column_size] = concat_feature.iloc[
:, col1
:, col1,
].corr(concat_feature.iloc[:, col2])
return res
@@ -137,11 +130,14 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
# if the IC is larger than a threshold, remove the new_feature column
# return the new_feature
from pandarallel import pandarallel
pandarallel.initialize(verbose=1)
concat_feature = pd.concat([SOTA_feature, new_feature], axis=1)
IC_max = (
concat_feature.groupby("datetime")
.parallel_apply(
lambda x: self.calculate_information_coefficient(x, SOTA_feature.shape[1], new_feature.shape[1])
lambda x: self.calculate_information_coefficient(x, SOTA_feature.shape[1], new_feature.shape[1]),
)
.mean()
)
@@ -161,7 +157,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
self._ensure_results_dirs()
if exp.based_experiments and exp.based_experiments[-1].result is None:
logger.info(f"Baseline experiment execution ...")
logger.info("Baseline experiment execution ...")
exp.based_experiments[-1] = self.develop(exp.based_experiments[-1])
fbps = FactorBasePropSetting()
@@ -185,11 +181,11 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
base_exp for base_exp in exp.based_experiments if isinstance(base_exp, QlibFactorExperiment)
]
if len(sota_factor_experiments_list) > 1:
logger.info(f"SOTA factor processing ...")
logger.info("SOTA factor processing ...")
SOTA_factor = process_factor_data(sota_factor_experiments_list)
# Process the new factors data
logger.info(f"New factor processing ...")
logger.info("New factor processing ...")
new_factors = process_factor_data(exp)
if new_factors.empty:
@@ -200,7 +196,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
new_factors = self.deduplicate_new_factors(SOTA_factor, new_factors)
if new_factors.empty:
raise FactorEmptyError(
"The factors generated in this round are highly similar to the previous factors. Please change the direction for creating new factors."
"The factors generated in this round are highly similar to the previous factors. Please change the direction for creating new factors.",
)
combined_factors = pd.concat([SOTA_factor, new_factors], axis=1).dropna()
else:
@@ -211,7 +207,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
combined_factors = combined_factors.loc[:, ~combined_factors.columns.duplicated(keep="last")]
new_columns = pd.MultiIndex.from_product([["feature"], combined_factors.columns])
combined_factors.columns = new_columns
logger.info(f"Factor data processing completed.")
logger.info("Factor data processing completed.")
num_features = len(exp.base_features) + len(combined_factors.columns)
@@ -230,10 +226,10 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
sota_model_exp = base_exp
exist_sota_model_exp = True
break
logger.info(f"Experiment execution ...")
logger.info("Experiment execution ...")
if exist_sota_model_exp:
exp.experiment_workspace.inject_files(
**{"model.py": sota_model_exp.sub_workspace_list[0].file_dict["model.py"]}
**{"model.py": sota_model_exp.sub_workspace_list[0].file_dict["model.py"]},
)
sota_training_hyperparameters = sota_model_exp.sub_tasks[0].training_hyperparameters
if sota_training_hyperparameters:
@@ -244,19 +240,19 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
"early_stop": str(sota_training_hyperparameters.get("early_stop", 10)),
"batch_size": str(sota_training_hyperparameters.get("batch_size", 256)),
"weight_decay": str(sota_training_hyperparameters.get("weight_decay", 0.0001)),
}
},
)
sota_model_type = sota_model_exp.sub_tasks[0].model_type
if sota_model_type == "TimeSeries":
env_to_use.update(
{"dataset_cls": "TSDatasetH", "num_features": num_features, "step_len": 20, "num_timesteps": 20}
{"dataset_cls": "TSDatasetH", "num_features": num_features, "step_len": 20, "num_timesteps": 20},
)
elif sota_model_type == "Tabular":
env_to_use.update({"dataset_cls": "DatasetH", "num_features": num_features})
# model + combined factors
result, stdout = exp.experiment_workspace.execute(
qlib_config_name="conf_combined_factors_sota_model.yaml", run_env=env_to_use
qlib_config_name="conf_combined_factors_sota_model.yaml", run_env=env_to_use,
)
else:
# LGBM + combined factors
@@ -265,7 +261,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
run_env=env_to_use,
)
else:
logger.info(f"Experiment execution ...")
logger.info("Experiment execution ...")
if exp.base_feature_codes:
factors = process_factor_data(exp)
factors = factors.sort_index()
@@ -275,7 +271,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
target_path = exp.experiment_workspace.workspace_path / "combined_factors_df.parquet"
# Save the combined factors to the workspace
factors.to_parquet(target_path, engine="pyarrow")
logger.info(f"Factor data processing completed.")
logger.info("Factor data processing completed.")
result, stdout = exp.experiment_workspace.execute(
qlib_config_name="conf_combined_factors.yaml",
run_env=env_to_use,
@@ -288,10 +284,10 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
# Handle Qlib Docker backtest failure gracefully
if result is None:
factor_name = getattr(exp.hypothesis, 'hypothesis', 'unknown')
factor_name = getattr(exp.hypothesis, "hypothesis", "unknown")
logger.warning(
f"Qlib Docker backtest returned None for '{factor_name}'. "
f"Attempting direct factor evaluation..."
f"Attempting direct factor evaluation...",
)
# Try to compute metrics directly from the factor's result.h5
@@ -303,7 +299,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
else:
logger.error(
f"Both Qlib Docker backtest and direct evaluation failed for '{factor_name}'. "
f"Skipping this factor and continuing."
f"Skipping this factor and continuing.",
)
# Save failed run info for debugging
self._save_failed_run(exp, stdout, error_type="result_none")
@@ -321,7 +317,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
if validation_result.get("has_issues"):
logger.warning(
f"Result validation warnings for factor '{getattr(exp.hypothesis, 'hypothesis', 'unknown')}': "
f"{validation_result['warnings']}"
f"{validation_result['warnings']}",
)
# Save warning info for debugging
self._save_failed_run(exp, stdout, error_type="validation_warnings", validation=validation_result)
@@ -372,43 +368,43 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
details = {}
factor_name = "unknown"
if hasattr(exp, 'hypothesis') and exp.hypothesis is not None:
factor_name = getattr(exp.hypothesis, 'hypothesis', 'unknown')
if hasattr(exp, "hypothesis") and exp.hypothesis is not None:
factor_name = getattr(exp.hypothesis, "hypothesis", "unknown")
if isinstance(result, pd.Series):
# Check IC
ic_value = result.get('IC', None)
details['ic_raw'] = ic_value
ic_value = result.get("IC", None)
details["ic_raw"] = ic_value
if ic_value is None or (isinstance(ic_value, float) and (ic_value != ic_value)): # NaN check
warnings.append("IC is None/NaN — factor has no predictive power")
else:
try:
ic_float = float(ic_value)
details['ic'] = ic_float
details["ic"] = ic_float
if abs(ic_float) < 0.001:
warnings.append(
f"IC is near zero ({ic_float:.6f}) — factor may not predict returns"
f"IC is near zero ({ic_float:.6f}) — factor may not predict returns",
)
except (ValueError, TypeError):
warnings.append(f"IC value is not numeric: {ic_value}")
# Check positions (1day.pos)
pos_value = result.get('1day.pos', None)
details['positions_raw'] = pos_value
pos_value = result.get("1day.pos", None)
details["positions_raw"] = pos_value
if pos_value is not None:
try:
pos_float = float(pos_value)
details['positions'] = pos_float
details["positions"] = pos_float
if pos_float == 0:
warnings.append(
"1day.pos == 0 — model opened ZERO positions (stayed neutral). "
"Possible causes: (1) topk too high for single-asset, "
"(2) signal threshold too restrictive, (3) no valid predictions"
"(2) signal threshold too restrictive, (3) no valid predictions",
)
elif pos_float < 10:
warnings.append(
f"1day.pos = {pos_float:.0f} — very few positions opened. "
f"Check signal threshold and topk settings"
f"Check signal threshold and topk settings",
)
except (ValueError, TypeError):
pass # pos might be a string
@@ -416,24 +412,24 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
# Check if result is essentially empty (all values None or NaN)
non_null_count = result.notna().sum()
total_count = len(result)
details['non_null_metrics'] = int(non_null_count)
details['total_metrics'] = int(total_count)
details["non_null_metrics"] = int(non_null_count)
details["total_metrics"] = int(total_count)
if non_null_count < 3:
warnings.append(
f"Result has only {non_null_count}/{total_count} non-null metrics — "
f"backtest likely produced empty results"
f"backtest likely produced empty results",
)
# Check for key metrics
required_metrics = ['IC', '1day.excess_return_with_cost.shar', '1day.pos']
required_metrics = ["IC", "1day.excess_return_with_cost.shar", "1day.pos"]
for metric_name in required_metrics:
val = result.get(metric_name, None)
details[f'has_{metric_name}'] = val is not None
details[f"has_{metric_name}"] = val is not None
elif isinstance(result, dict):
# Dict-based result validation
ic_value = result.get('IC', result.get('ic', None))
details['ic_raw'] = ic_value
ic_value = result.get("IC", result.get("ic", None))
details["ic_raw"] = ic_value
if ic_value is None:
warnings.append("IC is None — factor has no predictive power")
@@ -443,7 +439,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
"details": details,
}
def _evaluate_factor_directly(self, exp, stdout: str) -> Optional[pd.Series]:
def _evaluate_factor_directly(self, exp, stdout: str) -> pd.Series | None:
"""
Evaluate factor directly from its result.h5 file when Qlib Docker fails.
@@ -475,7 +471,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
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'):
if ws is not None and hasattr(ws, "workspace_path"):
candidate = ws.workspace_path / "result.h5"
if candidate.exists():
workspace_path = ws.workspace_path
@@ -579,7 +575,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
logger.info(
f"Direct evaluation: IC={ic:.6f}, Sharpe={sharpe:.4f}, "
f"AnnRet={annualized_return:.4f}%, WR={win_rate:.2%}"
f"AnnRet={annualized_return:.4f}%, WR={win_rate:.2%}",
)
return result
@@ -588,7 +584,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
return None
def _save_failed_run(self, exp, stdout: str, error_type: str = "unknown",
validation: Optional[dict] = None) -> None:
validation: dict | None = None) -> None:
"""
Save failed run information to results/failed_runs.json for debugging.
@@ -615,20 +611,20 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
# Get factor name
factor_name = "unknown"
if hasattr(exp, 'hypothesis') and exp.hypothesis is not None:
factor_name = getattr(exp.hypothesis, 'hypothesis', 'unknown')
if hasattr(exp, "hypothesis") and exp.hypothesis is not None:
factor_name = getattr(exp.hypothesis, "hypothesis", "unknown")
# Build failed run record
failed_record = {
"timestamp": datetime.now().isoformat(),
"factor_name": factor_name,
"error_type": error_type,
"stdout": stdout if stdout else "(empty)",
"stdout": stdout or "(empty)",
"validation": validation,
"experiment_details": {
"base_features": list(getattr(exp, 'base_features', {}).keys()) if hasattr(exp, 'base_features') else [],
"hypothesis": getattr(exp.hypothesis, 'hypothesis', str(getattr(exp, 'hypothesis', 'N/A')))
if hasattr(exp, 'hypothesis') else "N/A",
"base_features": list(getattr(exp, "base_features", {}).keys()) if hasattr(exp, "base_features") else [],
"hypothesis": getattr(exp.hypothesis, "hypothesis", str(getattr(exp, "hypothesis", "N/A")))
if hasattr(exp, "hypothesis") else "N/A",
},
}
@@ -651,11 +647,11 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
failed_file.write_text(
json.dumps(existing_records, indent=2, default=str, ensure_ascii=False),
encoding="utf-8"
encoding="utf-8",
)
logger.info(
f"Failed run saved: {factor_name} (type={error_type}) "
f"{failed_file}"
f"{failed_file}",
)
except Exception as e:
@@ -678,23 +674,23 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
containing metric names like 'IC', '1day.excess_return_with_cost.shar', etc.
"""
try:
import json
import pandas as pd
from pathlib import Path
import pandas as pd
from rdagent.components.backtesting import ResultsDatabase
# Get factor name: prefer hypothesis, fallback to result Series 'factor_name' key
factor_name = "unknown"
if hasattr(exp, 'hypothesis') and exp.hypothesis is not None:
factor_name = getattr(exp.hypothesis, 'hypothesis', 'unknown')
if factor_name == 'unknown' and isinstance(result, pd.Series) and 'factor_name' in result.index:
factor_name = str(result['factor_name'])
if hasattr(exp, "hypothesis") and exp.hypothesis is not None:
factor_name = getattr(exp.hypothesis, "hypothesis", "unknown")
if factor_name == "unknown" and isinstance(result, pd.Series) and "factor_name" in result.index:
factor_name = str(result["factor_name"])
# Check if already rejected by protection
if getattr(exp, 'rejected_by_protection', False):
if getattr(exp, "rejected_by_protection", False):
logger.info(
f"Factor rejected by protection, skipping DB save: "
f"{getattr(exp, 'protection_reason', 'unknown')}"
f"{getattr(exp, 'protection_reason', 'unknown')}",
)
return
@@ -710,47 +706,47 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
# Extract metrics from result (pd.Series from qlib_res.csv)
metrics = {}
if isinstance(result, pd.Series):
metrics['ic'] = self._safe_float(result.get('IC', None))
metrics['sharpe_ratio'] = self._safe_float(
result.get('1day.excess_return_with_cost.shar',
result.get('1day.excess_return_with_cost.sharpe', None))
metrics["ic"] = self._safe_float(result.get("IC", None))
metrics["sharpe_ratio"] = self._safe_float(
result.get("1day.excess_return_with_cost.shar",
result.get("1day.excess_return_with_cost.sharpe", None)),
)
metrics['annualized_return'] = self._safe_float(
result.get('1day.excess_return_with_cost.annualized_return', None)
metrics["annualized_return"] = self._safe_float(
result.get("1day.excess_return_with_cost.annualized_return", None),
)
metrics['max_drawdown'] = self._safe_float(
result.get('1day.excess_return_with_cost.max_drawdown', None)
metrics["max_drawdown"] = self._safe_float(
result.get("1day.excess_return_with_cost.max_drawdown", None),
)
metrics['win_rate'] = self._safe_float(result.get('win_rate', None))
metrics['information_ratio'] = self._safe_float(
result.get('1day.excess_return_with_cost.information_ratio', None)
metrics["win_rate"] = self._safe_float(result.get("win_rate", None))
metrics["information_ratio"] = self._safe_float(
result.get("1day.excess_return_with_cost.information_ratio", None),
)
metrics['volatility'] = self._safe_float(
result.get('1day.excess_return_with_cost.std',
result.get('1day.excess_return_with_cost.volatility', None))
metrics["volatility"] = self._safe_float(
result.get("1day.excess_return_with_cost.std",
result.get("1day.excess_return_with_cost.volatility", None)),
)
# Store raw metrics for JSON export
metrics['raw_metrics'] = result.to_dict()
metrics["raw_metrics"] = result.to_dict()
elif isinstance(result, dict):
metrics['ic'] = self._safe_float(result.get('IC', result.get('ic', None)))
metrics['sharpe_ratio'] = self._safe_float(
result.get('sharpe', result.get('sharpe_ratio', None))
metrics["ic"] = self._safe_float(result.get("IC", result.get("ic", None)))
metrics["sharpe_ratio"] = self._safe_float(
result.get("sharpe", result.get("sharpe_ratio", None)),
)
metrics['annualized_return'] = self._safe_float(result.get('annualized_return', None))
metrics['max_drawdown'] = self._safe_float(result.get('max_drawdown', None))
metrics['win_rate'] = self._safe_float(result.get('win_rate', None))
metrics['information_ratio'] = None
metrics['volatility'] = None
metrics['raw_metrics'] = result
metrics["annualized_return"] = self._safe_float(result.get("annualized_return", None))
metrics["max_drawdown"] = self._safe_float(result.get("max_drawdown", None))
metrics["win_rate"] = self._safe_float(result.get("win_rate", None))
metrics["information_ratio"] = None
metrics["volatility"] = None
metrics["raw_metrics"] = result
# Result validation before saving (warnings, not blocking)
self._log_result_warnings(factor_name, result, metrics)
# Only save if we have at least IC or Sharpe
if metrics.get('ic') is None and metrics.get('sharpe_ratio') is None:
if metrics.get("ic") is None and metrics.get("sharpe_ratio") is None:
logger.warning(
f"No valid IC/Sharpe for factor '{factor_name}', skipping DB save. "
f"IC={metrics.get('ic')}, Sharpe={metrics.get('sharpe_ratio')}"
f"IC={metrics.get('ic')}, Sharpe={metrics.get('sharpe_ratio')}",
)
return
@@ -773,7 +769,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
run_id = db.add_backtest(factor_name=factor_name[:100], metrics=metrics)
logger.info(
f"Factor result saved to DB: {factor_name[:60]} "
f"(IC={metrics.get('ic')}, Sharpe={metrics.get('sharpe_ratio')}, run_id={run_id})"
f"(IC={metrics.get('ic')}, Sharpe={metrics.get('sharpe_ratio')}, run_id={run_id})",
)
# Extract factor code and description from experiment
@@ -784,7 +780,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
factor_name, metrics, run_id,
factor_code=factor_code,
factor_description=factor_description,
exp=exp
exp=exp,
)
# Save factor values as parquet for strategy building
@@ -796,7 +792,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
import traceback
logger.error(
f"Database save failed for factor '{getattr(exp.hypothesis, 'hypothesis', 'unknown')}': {e}\n"
f"Traceback: {traceback.format_exc()}"
f"Traceback: {traceback.format_exc()}",
)
def _save_factor_json(self, factor_name: str, metrics: dict, run_id: int,
@@ -907,14 +903,14 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
factor_description = match.group(1).strip()[:500]
else:
# Try comments
lines = factor_code.split('\n')
lines = factor_code.split("\n")
desc_lines = []
for line in lines[:20]:
stripped = line.strip()
if stripped.startswith('#') and not stripped.startswith('#!'):
if stripped.startswith("#") and not stripped.startswith("#!"):
desc_lines.append(stripped[1:].strip())
if desc_lines:
factor_description = ' '.join(desc_lines)[:500]
factor_description = " ".join(desc_lines)[:500]
return factor_code, factor_description
@@ -926,8 +922,8 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
the complete backtest range (not just the debug 2024 subset).
"""
import os as _os
import subprocess
import shutil
import subprocess
import tempfile
try:
@@ -935,7 +931,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
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'):
if ws is not None and hasattr(ws, "workspace_path"):
fp = ws.workspace_path / "factor.py"
if fp.exists():
workspace_path = ws.workspace_path
@@ -1023,7 +1019,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
warnings_list = []
# Check IC
ic = metrics.get('ic')
ic = metrics.get("ic")
if ic is None:
warnings_list.append("IC is None — factor has no predictive power")
elif abs(ic) < 0.001:
@@ -1031,7 +1027,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
# Check positions (1day.pos) — CRITICAL for EURUSD
if isinstance(result, pd.Series):
pos_value = result.get('1day.pos', None)
pos_value = result.get("1day.pos", None)
if pos_value is not None:
try:
pos_float = float(pos_value)
@@ -1039,23 +1035,23 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
warnings_list.append(
"WARNING: 1day.pos == 0 — ZERO positions opened! "
"Model stayed completely neutral. Check Qlib config: "
"ensure topk=1 and market=eurusd for single-asset trading."
"ensure topk=1 and market=eurusd for single-asset trading.",
)
elif pos_float < 10:
warnings_list.append(
f"Low position count: 1day.pos = {pos_float:.0f}"
f"model traded very rarely"
f"model traded very rarely",
)
except (ValueError, TypeError):
pass
# Check Sharpe
sharpe = metrics.get('sharpe_ratio')
sharpe = metrics.get("sharpe_ratio")
if sharpe is not None and abs(sharpe) < 0.1:
warnings_list.append(f"Sharpe near zero ({sharpe:.4f}) — no risk-adjusted edge")
# Check max drawdown
mdd = metrics.get('max_drawdown')
mdd = metrics.get("max_drawdown")
if mdd is not None and mdd < -0.5:
warnings_list.append(f"Extreme drawdown: {mdd:.2%} — high risk factor")
@@ -1070,7 +1066,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
return None
try:
f = float(value)
if pd.isna(f) or f == float('inf') or f == float('-inf'):
if pd.isna(f) or f == float("inf") or f == float("-inf"):
return None
return f
except (ValueError, TypeError):
@@ -1113,7 +1109,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
if protection_result.should_block:
logger.warning(
f"Factor {factor_name} rejected by protection manager: {protection_result.reason}"
f"Factor {factor_name} rejected by protection manager: {protection_result.reason}",
)
# Mark factor as rejected by protection
exp.rejected_by_protection = True
@@ -1138,8 +1134,8 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
from pathlib import Path
factor_name = "unknown"
if hasattr(exp, 'hypothesis') and exp.hypothesis is not None:
factor_name = getattr(exp.hypothesis, 'hypothesis', 'unknown')
if hasattr(exp, "hypothesis") and exp.hypothesis is not None:
factor_name = getattr(exp.hypothesis, "hypothesis", "unknown")
# Build log entry
log_entry = {
@@ -1151,42 +1147,42 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
"annualized_return": None,
"max_drawdown": None,
"win_rate": None,
"rejected_by_protection": getattr(exp, 'rejected_by_protection', False),
"protection_reason": getattr(exp, 'protection_reason', None),
"rejected_by_protection": getattr(exp, "rejected_by_protection", False),
"protection_reason": getattr(exp, "protection_reason", None),
}
# Extract metrics if available
if result is not None:
if hasattr(result, 'get'): # pd.Series or dict
ic_val = result.get('IC', result.get('ic', None))
log_entry['ic'] = self._safe_float(ic_val) if ic_val is not None else None
if hasattr(result, "get"): # pd.Series or dict
ic_val = result.get("IC", result.get("ic", None))
log_entry["ic"] = self._safe_float(ic_val) if ic_val is not None else None
sharpe_val = result.get('1day.excess_return_with_cost.shar',
result.get('1day.excess_return_with_cost.sharpe',
result.get('sharpe', None)))
log_entry['sharpe'] = self._safe_float(sharpe_val) if sharpe_val is not None else None
sharpe_val = result.get("1day.excess_return_with_cost.shar",
result.get("1day.excess_return_with_cost.sharpe",
result.get("sharpe", None)))
log_entry["sharpe"] = self._safe_float(sharpe_val) if sharpe_val is not None else None
ann_ret = result.get('1day.excess_return_with_cost.annualized_return',
result.get('annualized_return', None))
log_entry['annualized_return'] = self._safe_float(ann_ret) if ann_ret is not None else None
ann_ret = result.get("1day.excess_return_with_cost.annualized_return",
result.get("annualized_return", None))
log_entry["annualized_return"] = self._safe_float(ann_ret) if ann_ret is not None else None
mdd = result.get('1day.excess_return_with_cost.max_drawdown',
result.get('max_drawdown', None))
log_entry['max_drawdown'] = self._safe_float(mdd) if mdd is not None else None
mdd = result.get("1day.excess_return_with_cost.max_drawdown",
result.get("max_drawdown", None))
log_entry["max_drawdown"] = self._safe_float(mdd) if mdd is not None else None
wr = result.get('win_rate', None)
log_entry['win_rate'] = self._safe_float(wr) if wr is not None else None
wr = result.get("win_rate", None)
log_entry["win_rate"] = self._safe_float(wr) if wr is not None else None
# Determine status
if log_entry['ic'] is not None or log_entry['sharpe'] is not None:
log_entry['status'] = "success"
elif getattr(exp, 'rejected_by_protection', False):
log_entry['status'] = "rejected_protection"
if log_entry["ic"] is not None or log_entry["sharpe"] is not None:
log_entry["status"] = "success"
elif getattr(exp, "rejected_by_protection", False):
log_entry["status"] = "rejected_protection"
else:
log_entry['status'] = "no_valid_metrics"
log_entry["status"] = "no_valid_metrics"
else:
log_entry['status'] = "execution_failed"
log_entry['reason'] = "Result was None"
log_entry["status"] = "execution_failed"
log_entry["reason"] = "Result was None"
# Write to results/logs/
try:
@@ -1209,7 +1205,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
logger.info(
f"Run log written for '{factor_name[:50]}': "
f"status={log_entry['status']}, IC={log_entry['ic']}, Sharpe={log_entry['sharpe']}"
f"status={log_entry['status']}, IC={log_entry['ic']}, Sharpe={log_entry['sharpe']}",
)
except Exception as e:
logger.error(f"Failed to write run log: {e}")
+12 -15
View File
@@ -436,13 +436,10 @@ class Env(Generic[ASpecificEnvConf]):
else:
timeout_cmd = f"timeout --kill-after=10 {self.conf.running_timeout_period} {entry}"
entry_add_timeout = (
f"/bin/sh -c '" # start of the sh command
+ f"{timeout_cmd}; entry_exit_code=$?; "
"/bin/sh -c '" # start of the sh command
+ timeout_cmd.replace("'", "'\\''") + "; entry_exit_code=$?; "
+ (
f"{_get_chmod_cmd(self.conf.mount_path)}; "
# We don't have to change the permission of the cache and input folder to remove it
# + f"if [ -d {self.conf.mount_path}/cache ]; then chmod 777 {self.conf.mount_path}/cache; fi; " +
# f"if [ -d {self.conf.mount_path}/input ]; then chmod 777 {self.conf.mount_path}/input; fi; "
if isinstance(self.conf, DockerConf)
else ""
)
@@ -1524,8 +1521,8 @@ class DockerEnv(Env[DockerConf]):
class QTDockerEnv(DockerEnv):
"""Qlib Torch Docker"""
def __init__(self, conf: DockerConf = QlibDockerConf()):
super().__init__(conf)
def __init__(self, conf: DockerConf | None = None):
super().__init__(conf if conf is not None else QlibDockerConf())
def prepare(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
"""
@@ -1544,15 +1541,15 @@ class QTDockerEnv(DockerEnv):
class KGDockerEnv(DockerEnv):
"""Kaggle Competition Docker"""
def __init__(self, competition: str | None = None, conf: DockerConf = KGDockerConf()):
super().__init__(conf)
def __init__(self, competition: str | None = None, conf: DockerConf | None = None):
super().__init__(conf if conf is not None else KGDockerConf())
class MLEBDockerEnv(DockerEnv):
"""MLEBench Docker"""
def __init__(self, conf: DockerConf = MLEBDockerConf()):
super().__init__(conf)
def __init__(self, conf: DockerConf | None = None):
super().__init__(conf if conf is not None else MLEBDockerConf())
class FTDockerEnv(DockerEnv):
@@ -1568,8 +1565,8 @@ class FTDockerEnv(DockerEnv):
export FT_DOCKER_save_logs_to_file=false # disable log file
"""
def __init__(self, conf: DockerConf = FTDockerConf()):
super().__init__(conf)
def __init__(self, conf: DockerConf | None = None):
super().__init__(conf if conf is not None else FTDockerConf())
class BenchmarkDockerEnv(DockerEnv):
@@ -1586,5 +1583,5 @@ class BenchmarkDockerEnv(DockerEnv):
export BENCHMARK_DOCKER_terminal_tail_lines=100 # show last 100 lines
"""
def __init__(self, conf: DockerConf = BenchmarkDockerConf()):
super().__init__(conf)
def __init__(self, conf: DockerConf | None = None):
super().__init__(conf if conf is not None else BenchmarkDockerConf())