mirror of
https://github.com/NicolasBohn/NexQuant.git
synced 2026-08-07 12:07:43 +00:00
8a27581931
Connect Protection Manager, Results Database, model_loader, and Technical Indicators to the main fin_quant trading loop. P0 - CRITICAL INTEGRATIONS: 1. PROTECTION MANAGER in factor_runner.py - Automatic protection check after every backtest - Factors with >15% drawdown are rejected - Cooldown, stoploss guard, low performance filters active - Error handling: workflow continues if protection fails 2. RESULTS DATABASE in quant.py - Auto-save experiment results to SQLite after each loop - Stores: IC, Sharpe, Max DD, Annualized Return, Win Rate - Queryable via ResultsDatabase API - Error handling: warning logged, workflow continues P1 - IMPORTANT INTEGRATIONS: 3. MODEL LOADER in model_coder.py - Loads models/local/ as baseline reference for LLM - Transformer, TCN, PatchTST, CNN+LSTM now used as starting point - LLM can improve upon existing models instead of from scratch 4. TECHNICAL INDICATORS in factor_coder.py - RSI, MACD, Bollinger Bands, CCI, ATR available to LLM - Import paths and usage examples in prompts - Better factor generation with professional indicators TESTS (32 new, ALL PASS): - 23 integration tests in test/qlib/test_fin_quant_integration.py - 9 enhanced integration tests in test/integration/test_all_features.py - All 183 tests pass (122 backtesting + 29 qlib + 32 new) Modified files: - rdagent/app/qlib_rd_loop/quant.py: Results Database integration - rdagent/scenarios/qlib/developer/factor_runner.py: Protection Manager - rdagent/scenarios/qlib/developer/model_coder.py: model_loader baseline - rdagent/scenarios/qlib/developer/factor_coder.py: Technical indicators - test/qlib/test_fin_quant_integration.py: NEW - 23 integration tests - test/integration/test_all_features.py: 9 enhanced tests
253 lines
12 KiB
Python
253 lines
12 KiB
Python
from pathlib import Path
|
|
|
|
import pandas as pd
|
|
from pandarallel import pandarallel
|
|
|
|
from rdagent.core.conf import RD_AGENT_SETTINGS
|
|
from rdagent.core.utils import cache_with_pickle
|
|
|
|
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
|
|
from rdagent.log import rdagent_logger as logger
|
|
from rdagent.scenarios.qlib.developer.utils import process_factor_data
|
|
from rdagent.scenarios.qlib.experiment.factor_experiment import QlibFactorExperiment
|
|
from rdagent.scenarios.qlib.experiment.model_experiment import QlibModelExperiment
|
|
|
|
DIRNAME = Path(__file__).absolute().resolve().parent
|
|
DIRNAME_local = Path.cwd()
|
|
|
|
# TODO: supporting multiprocessing and keep previous results
|
|
|
|
|
|
class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
|
|
"""
|
|
Docker run
|
|
Everything in a folder
|
|
- config.yaml
|
|
- price-volume data dumper
|
|
- `data.py` + Adaptor to Factor implementation
|
|
- results in `mlflow`
|
|
"""
|
|
|
|
def calculate_information_coefficient(
|
|
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
|
|
].corr(concat_feature.iloc[:, col2])
|
|
return res
|
|
|
|
def deduplicate_new_factors(self, SOTA_feature: pd.DataFrame, new_feature: pd.DataFrame) -> pd.DataFrame:
|
|
# calculate the IC between each column of SOTA_feature and new_feature
|
|
# if the IC is larger than a threshold, remove the new_feature column
|
|
# return the new_feature
|
|
|
|
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])
|
|
)
|
|
.mean()
|
|
)
|
|
IC_max.index = pd.MultiIndex.from_product([range(SOTA_feature.shape[1]), range(new_feature.shape[1])])
|
|
IC_max = IC_max.unstack().max(axis=0)
|
|
return new_feature.iloc[:, IC_max[IC_max < 0.99].index]
|
|
|
|
@cache_with_pickle(CachedRunner.get_cache_key, CachedRunner.assign_cached_result)
|
|
def develop(self, exp: QlibFactorExperiment) -> QlibFactorExperiment:
|
|
"""
|
|
Generate the experiment by processing and combining factor data,
|
|
then passing the combined data to Docker for backtest results.
|
|
"""
|
|
if exp.based_experiments and exp.based_experiments[-1].result is None:
|
|
logger.info(f"Baseline experiment execution ...")
|
|
exp.based_experiments[-1] = self.develop(exp.based_experiments[-1])
|
|
|
|
fbps = FactorBasePropSetting()
|
|
env_to_use = {
|
|
"PYTHONPATH": "./",
|
|
"train_start": fbps.train_start,
|
|
"train_end": fbps.train_end,
|
|
"valid_start": fbps.valid_start,
|
|
"valid_end": fbps.valid_end,
|
|
"test_start": fbps.test_start,
|
|
"feature_names": str(list(exp.base_features.keys())),
|
|
"feature_expressions": str(list(exp.base_features.values())),
|
|
}
|
|
if fbps.test_end is not None:
|
|
env_to_use.update({"test_end": fbps.test_end})
|
|
|
|
if exp.based_experiments:
|
|
SOTA_factor = None
|
|
# Filter and retain only QlibFactorExperiment instances
|
|
sota_factor_experiments_list = [
|
|
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 ...")
|
|
SOTA_factor = process_factor_data(sota_factor_experiments_list)
|
|
|
|
# Process the new factors data
|
|
logger.info(f"New factor processing ...")
|
|
new_factors = process_factor_data(exp)
|
|
|
|
if new_factors.empty:
|
|
raise FactorEmptyError("Factors failed to run on the full sample, this round of experiment failed.")
|
|
|
|
# Combine the SOTA factor and new factors if SOTA factor exists
|
|
if SOTA_factor is not None and not SOTA_factor.empty:
|
|
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."
|
|
)
|
|
combined_factors = pd.concat([SOTA_factor, new_factors], axis=1).dropna()
|
|
else:
|
|
combined_factors = new_factors
|
|
|
|
# Sort and nest the combined factors under 'feature'
|
|
combined_factors = combined_factors.sort_index()
|
|
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.")
|
|
|
|
num_features = len(exp.base_features) + len(combined_factors.columns)
|
|
|
|
# Due to the rdagent and qlib docker image in the numpy version of the difference,
|
|
# the `combined_factors_df.pkl` file could not be loaded correctly in qlib dokcer,
|
|
# so we changed the file type of `combined_factors_df` from pkl to parquet.
|
|
target_path = exp.experiment_workspace.workspace_path / "combined_factors_df.parquet"
|
|
|
|
# Save the combined factors to the workspace
|
|
combined_factors.to_parquet(target_path, engine="pyarrow")
|
|
|
|
# If model exp exists in the previous experiment
|
|
exist_sota_model_exp = False
|
|
for base_exp in reversed(exp.based_experiments):
|
|
if isinstance(base_exp, QlibModelExperiment):
|
|
sota_model_exp = base_exp
|
|
exist_sota_model_exp = True
|
|
break
|
|
logger.info(f"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"]}
|
|
)
|
|
sota_training_hyperparameters = sota_model_exp.sub_tasks[0].training_hyperparameters
|
|
if sota_training_hyperparameters:
|
|
env_to_use.update(
|
|
{
|
|
"n_epochs": str(sota_training_hyperparameters.get("n_epochs", "100")),
|
|
"lr": str(sota_training_hyperparameters.get("lr", "2e-4")),
|
|
"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}
|
|
)
|
|
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
|
|
)
|
|
else:
|
|
# LGBM + combined factors
|
|
result, stdout = exp.experiment_workspace.execute(
|
|
qlib_config_name="conf_combined_factors.yaml",
|
|
run_env=env_to_use,
|
|
)
|
|
else:
|
|
logger.info(f"Experiment execution ...")
|
|
if exp.base_feature_codes:
|
|
factors = process_factor_data(exp)
|
|
factors = factors.sort_index()
|
|
factors = factors.loc[:, ~factors.columns.duplicated(keep="last")]
|
|
new_columns = pd.MultiIndex.from_product([["feature"], factors.columns])
|
|
factors.columns = new_columns
|
|
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.")
|
|
result, stdout = exp.experiment_workspace.execute(
|
|
qlib_config_name="conf_combined_factors.yaml",
|
|
run_env=env_to_use,
|
|
)
|
|
else:
|
|
result, stdout = exp.experiment_workspace.execute(
|
|
qlib_config_name="conf_baseline.yaml",
|
|
run_env=env_to_use,
|
|
)
|
|
|
|
if result is None:
|
|
logger.error(f"Failed to run this experiment, because {stdout}")
|
|
raise FactorEmptyError(f"Failed to run this experiment, because {stdout}")
|
|
|
|
exp.result = result
|
|
exp.stdout = stdout
|
|
|
|
# Protection Manager: Check if factor passes risk criteria
|
|
try:
|
|
self._run_protection_check(exp, result)
|
|
except Exception as e:
|
|
logger.warning(f"Protection check failed for factor {exp.hypothesis.hypothesis}: {e}")
|
|
# Don't block the workflow, just log the warning
|
|
|
|
return exp
|
|
|
|
def _run_protection_check(self, exp, result: dict) -> None:
|
|
"""
|
|
Run protection checks on backtest results.
|
|
|
|
Parameters
|
|
----------
|
|
exp : QlibFactorExperiment
|
|
The experiment with backtest results
|
|
result : dict
|
|
Backtest metrics dictionary
|
|
"""
|
|
from rdagent.components.backtesting.protections import ProtectionManager
|
|
|
|
protection_manager = ProtectionManager()
|
|
protection_manager.create_default_protections()
|
|
|
|
# Extract returns and equity curve from backtest results
|
|
returns = result.get("returns", [])
|
|
timestamps = result.get("timestamps", [])
|
|
current_equity = result.get("final_equity", 100000)
|
|
peak_equity = result.get("peak_equity", current_equity)
|
|
|
|
# Get factor name from hypothesis
|
|
factor_name = "unknown"
|
|
if hasattr(exp, "hypothesis") and exp.hypothesis is not None:
|
|
factor_name = getattr(exp.hypothesis, "hypothesis", "unknown")
|
|
|
|
protection_result = protection_manager.check_all(
|
|
returns=returns,
|
|
timestamps=timestamps,
|
|
current_equity=current_equity,
|
|
peak_equity=peak_equity,
|
|
factor_name=factor_name,
|
|
)
|
|
|
|
if protection_result.should_block:
|
|
logger.warning(
|
|
f"Factor {factor_name} rejected by protection manager: {protection_result.reason}"
|
|
)
|
|
# Mark factor as rejected by protection
|
|
exp.rejected_by_protection = True
|
|
exp.protection_reason = protection_result.reason
|