import pickle from pathlib import Path from typing import List import pandas as pd from pandarallel import pandarallel from rdagent.core.conf import RD_AGENT_SETTINGS from rdagent.core.utils import multiprocessing_wrapper pandarallel.initialize(verbose=1) from rdagent.components.runner import CachedRunner from rdagent.components.runner.conf import RUNNER_SETTINGS from rdagent.core.exception import FactorEmptyError from rdagent.log import rdagent_logger as logger from rdagent.scenarios.qlib.experiment.factor_experiment import QlibFactorExperiment DIRNAME = Path(__file__).absolute().resolve().parent DIRNAME_local = Path.cwd() # class QlibFactorExpWorkspace: # def prepare(): # # create a folder; # # copy template # # place data inside the folder `combined_factors` # # # def execute(): # de = DockerEnv() # de.run(local_path=self.ws_path, entry="qrun conf.yaml") # 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] 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: exp.based_experiments[-1] = self.develop(exp.based_experiments[-1]) if RUNNER_SETTINGS.cache_result: cache_hit, result = self.get_cache_result(exp) if cache_hit: exp.result = result return exp if exp.based_experiments: SOTA_factor = None if len(exp.based_experiments) > 1: SOTA_factor = self.process_factor_data(exp.based_experiments) # Process the new factors data new_factors = self.process_factor_data(exp) if new_factors.empty: raise FactorEmptyError("No valid factor data found to merge.") # 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("No valid factor data found to merge.") 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 # Save the combined factors to the workspace with open(exp.experiment_workspace.workspace_path / "combined_factors_df.pkl", "wb") as f: pickle.dump(combined_factors, f) result = exp.experiment_workspace.execute( qlib_config_name=f"conf.yaml" if len(exp.based_experiments) == 0 else "conf_combined.yaml" ) exp.result = result if RUNNER_SETTINGS.cache_result: self.dump_cache_result(exp, result) return exp def process_factor_data(self, exp_or_list: List[QlibFactorExperiment] | QlibFactorExperiment) -> pd.DataFrame: """ Process and combine factor data from experiment implementations. Args: exp (ASpecificExp): The experiment containing factor data. Returns: pd.DataFrame: Combined factor data without NaN values. """ if isinstance(exp_or_list, QlibFactorExperiment): exp_or_list = [exp_or_list] factor_dfs = [] # Collect all exp's dataframes for exp in exp_or_list: # Iterate over sub-implementations and execute them to get each factor data message_and_df_list = multiprocessing_wrapper( [(implementation.execute, (False, "All")) for implementation in exp.sub_workspace_list], n=RD_AGENT_SETTINGS.multi_proc_n, ) for message, df in message_and_df_list: # Check if factor generation was successful if df is not None and "datetime" in df.index.names: time_diff = df.index.get_level_values("datetime").to_series().diff().dropna().unique() if pd.Timedelta(minutes=1) not in time_diff: factor_dfs.append(df) # Combine all successful factor data if factor_dfs: return pd.concat(factor_dfs, axis=1) else: raise FactorEmptyError("No valid factor data found to merge.")