| """Main GP factor mining loop using qlib data.""" |
|
|
| from __future__ import annotations |
|
|
| import pickle |
| import random |
| from pathlib import Path |
|
|
| import numpy as np |
| import pandas as pd |
| import torch |
| from tqdm import tqdm |
|
|
| from data_pipeline.init_qlib import init_qlib |
| from data_pipeline.load_data import load_instruments |
| from factor_engine.gp.config import load_gp_config |
| from factor_engine.gp.evolution import crossover, mutate, tournament_selection, tree_too_large |
| from factor_engine.gp.fitness import FitnessConfig, calculate_fitness, factor_report, sampled_spearman_corr_torch |
| from factor_engine.gp.operators import generate_random_tree |
| from factor_engine.gp.qlib_engine import QlibTensorDataEngine |
|
|
|
|
| def _append_csv(path: Path, rows: list[dict]): |
| df = pd.DataFrame(rows) |
| df.to_csv(path, mode="a" if path.exists() else "w", header=not path.exists(), index=False, encoding="utf-8-sig") |
|
|
|
|
| def _save_population(path: Path, population, generation_offset: int, cfg: dict): |
| with open(path, "wb") as f: |
| pickle.dump({"population": population, "generation_offset": generation_offset, "config": cfg}, f) |
|
|
|
|
| def _load_population(path: Path): |
| if not path.exists(): |
| return None, 0 |
| with open(path, "rb") as f: |
| payload = pickle.load(f) |
| return payload["population"], int(payload.get("generation_offset", 0)) |
|
|
|
|
| def _export_ml_features(engine, target, top_trees, output_path: Path): |
| if not top_trees: |
| print("No orthogonal factors selected for ML export.") |
| return |
|
|
| dates_col = np.repeat(engine.times, len(engine.symbols)) |
| symbols_col = np.tile(engine.symbols, len(engine.times)) |
| ml_df = pd.DataFrame({"date": dates_col, "symbol": symbols_col}) |
| ml_df["target_return"] = target.detach().cpu().numpy().flatten(order="F") |
|
|
| formulas = {} |
| for i, tree in enumerate(top_trees, start=1): |
| factor = tree.evaluate(engine) |
| ml_df[f"factor_{i}"] = factor.detach().cpu().numpy().flatten(order="F") |
| formulas[f"factor_{i}"] = str(tree) |
|
|
| ml_df = ml_df.replace([np.inf, -np.inf], np.nan).dropna().reset_index(drop=True) |
| ml_df.to_csv(output_path, index=False, encoding="utf-8-sig") |
| ml_df.to_parquet(output_path.with_suffix(".parquet"), index=False) |
| pd.Series(formulas, name="formula").to_csv(output_path.with_name("factor_formulas.csv"), header=True) |
| print(f"ML features saved: {output_path} ({len(top_trees)} factors)") |
|
|
|
|
| def _select_low_corr_trees(engine, candidate_pairs, train_mask, top_k, threshold): |
| selected_trees, selected_factors = [], [] |
| for fit, tree in candidate_pairs: |
| if len(selected_trees) >= top_k: |
| break |
| factor = tree.evaluate(engine) |
| duplicate = False |
| for old in selected_factors: |
| c = sampled_spearman_corr_torch(factor, old, mask=train_mask, max_points=30_000) |
| if not np.isnan(c) and abs(c) >= threshold: |
| duplicate = True |
| break |
| if not duplicate: |
| selected_trees.append(tree.clone()) |
| selected_factors.append(factor.detach().clone()) |
| return selected_trees |
|
|
|
|
| def run_gp_mining( |
| gp_config_path: str | None = None, |
| base_config_path: str | None = None, |
| ) -> Path: |
| """Run GP factor mining end-to-end on qlib data.""" |
| cfg_bundle = load_gp_config(gp_config_path, base_config_path) |
| gp_cfg = cfg_bundle["gp"] |
| base_cfg = cfg_bundle["base"] |
| out_dir: Path = cfg_bundle["output_dir"] |
| out_dir.mkdir(parents=True, exist_ok=True) |
|
|
| seed = int(gp_cfg.get("seed", 42)) |
| random.seed(seed) |
| np.random.seed(seed) |
| torch.manual_seed(seed) |
|
|
| device = gp_cfg.get("device", "auto") |
| if device == "auto": |
| device = "cuda" if torch.cuda.is_available() else "cpu" |
|
|
| init_qlib(base_config_path) |
| market = base_cfg["qlib"].get("market", "csi300") |
| instruments = load_instruments(market) |
|
|
| engine = QlibTensorDataEngine( |
| instruments=instruments, |
| start_time=base_cfg["data"]["start_time"], |
| end_time=base_cfg["data"]["end_time"], |
| freq=base_cfg["data"].get("freq", "day"), |
| forward_steps=gp_cfg.get("forward_steps", base_cfg["label"].get("forward_days", 5)), |
| splits=base_cfg.get("splits"), |
| device=device, |
| qlib_fields=base_cfg["data"].get("fields"), |
| ) |
|
|
| fitness_cfg = FitnessConfig( |
| min_stocks=gp_cfg.get("min_stocks", 50), |
| min_tree_nodes=gp_cfg.get("min_tree_nodes", 3), |
| depth_penalty=gp_cfg.get("depth_penalty", 0.006), |
| node_penalty=gp_cfg.get("node_penalty", 0.0012), |
| duplicate_penalty=gp_cfg.get("duplicate_penalty", 0.05), |
| elite_corr_fatal=gp_cfg.get("elite_corr_fatal", 0.95), |
| elite_corr_hard=gp_cfg.get("elite_corr_hard", 0.85), |
| elite_corr_mid=gp_cfg.get("elite_corr_mid", 0.75), |
| elite_corr_soft=gp_cfg.get("elite_corr_soft", 0.65), |
| export_corr_threshold=gp_cfg.get("export_corr_threshold", 0.80), |
| ) |
|
|
| pop_size = gp_cfg.get("population_size", 200) |
| max_init_depth = gp_cfg.get("max_init_depth", 4) |
| max_tree_depth = gp_cfg.get("max_tree_depth", 8) |
| max_tree_nodes = gp_cfg.get("max_tree_nodes", 60) |
| generations = gp_cfg.get("generations_per_run", 10) |
| elite_size = gp_cfg.get("elite_size", 20) |
| top_k = gp_cfg.get("top_k_export", 30) |
|
|
| population_path = out_dir / "population.pkl" |
| factor_zoo_path = out_dir / "factor_zoo.csv" |
| ml_feature_path = out_dir / "ML_Features_qlib.csv" |
|
|
| target = engine.get_data("target_return") |
| train_mask = engine.get_data("train_mask") |
| valid_mask = engine.get_data("valid_mask") |
| test_mask = engine.get_data("test_mask") |
|
|
| population, generation_offset = _load_population(population_path) |
| if population is None: |
| population = [generate_random_tree(1, max_init_depth) for _ in range(pop_size)] |
| generation_offset = 0 |
| else: |
| population = population[:pop_size] + [ |
| generate_random_tree(1, max_init_depth) for _ in range(max(0, pop_size - len(population))) |
| ] |
|
|
| formula_seen = set() |
| if factor_zoo_path.exists(): |
| zoo = pd.read_csv(factor_zoo_path) |
| if "formula" in zoo.columns: |
| formula_seen = set(zoo["formula"].astype(str)) |
|
|
| elite_cache = [] |
| max_elite_cache = gp_cfg.get("max_elite_cache", 60) |
|
|
| for local_gen in range(1, generations + 1): |
| global_gen = generation_offset + local_gen |
| print(f"\n========== Generation {global_gen} ==========") |
|
|
| fitnesses, records = [], [] |
| for tree in tqdm(population, desc="Evaluating"): |
| if tree_too_large(tree, max_tree_depth, max_tree_nodes): |
| fitnesses.append(-999.0) |
| continue |
| factor = tree.evaluate(engine) |
| fit, is_rpt, oos_rpt = calculate_fitness( |
| tree, factor, target, train_mask, test_mask, fitness_cfg, formula_seen, elite_cache |
| ) |
| fitnesses.append(fit) |
| records.append({ |
| "generation": global_gen, |
| "fitness": fit, |
| "is_icir": is_rpt["icir"], |
| "is_ic_mean": is_rpt["ic_mean"], |
| "oos_icir": oos_rpt["icir"], |
| "formula": str(tree), |
| "depth": tree.get_depth(), |
| "nodes": tree.get_size(), |
| }) |
|
|
| fitnesses = np.array(fitnesses, dtype=float) |
| sorted_idx = np.argsort(fitnesses)[::-1] |
| best_tree = population[int(sorted_idx[0])].clone() |
| best_factor = best_tree.evaluate(engine) |
|
|
| best_is = factor_report(best_factor, target, train_mask, fitness_cfg.min_stocks) |
| best_valid = factor_report(best_factor, target, valid_mask, fitness_cfg.min_stocks) |
| best_test = factor_report(best_factor, target, test_mask, fitness_cfg.min_stocks) |
|
|
| if not np.isnan(best_is["icir"]): |
| if not any( |
| not np.isnan(c := sampled_spearman_corr_torch(best_factor, old, train_mask)) |
| and abs(c) >= fitness_cfg.elite_corr_hard |
| for old in elite_cache |
| ): |
| elite_cache.append(best_factor.detach().clone()) |
| if len(elite_cache) > max_elite_cache: |
| elite_cache.pop(0) |
|
|
| print(f"Best fitness={fitnesses[sorted_idx[0]]:.4f} | IS ICIR={best_is['icir']:.4f} | VALID ICIR={best_valid['icir']:.4f} | TEST ICIR={best_test['icir']:.4f}") |
| print(f"Formula: {best_tree}") |
|
|
| _append_csv(factor_zoo_path, sorted(records, key=lambda x: x["fitness"], reverse=True)[:top_k]) |
|
|
| new_population = [population[int(i)].clone() for i in sorted_idx[:elite_size]] |
| new_population += [generate_random_tree(1, max_init_depth) for _ in range(int(pop_size * gp_cfg.get("random_immigrant_rate", 0.15)))] |
|
|
| while len(new_population) < pop_size: |
| r = random.random() |
| if r < gp_cfg.get("crossover_rate", 0.5): |
| child = crossover(tournament_selection(population, fitnesses), tournament_selection(population, fitnesses)) |
| elif r < gp_cfg.get("crossover_rate", 0.5) + gp_cfg.get("mutation_rate", 0.35): |
| child = mutate(tournament_selection(population, fitnesses), max_init_depth) |
| else: |
| child = tournament_selection(population, fitnesses) |
| if tree_too_large(child, max_tree_depth, max_tree_nodes): |
| child = generate_random_tree(1, max_init_depth) |
| new_population.append(child) |
|
|
| population = new_population |
| _save_population(population_path, population, global_gen, gp_cfg) |
| if device == "cuda": |
| torch.cuda.empty_cache() |
|
|
| if factor_zoo_path.exists(): |
| zoo = pd.read_csv(factor_zoo_path).drop_duplicates("formula").sort_values("fitness", ascending=False) |
| zoo.to_csv(out_dir / "top_factors.csv", index=False, encoding="utf-8-sig") |
|
|
| final_pairs = [] |
| for tree in tqdm(population, desc="Final export"): |
| if tree_too_large(tree, max_tree_depth, max_tree_nodes): |
| continue |
| factor = tree.evaluate(engine) |
| fit, _, _ = calculate_fitness(tree, factor, target, train_mask, test_mask, fitness_cfg) |
| final_pairs.append((fit, tree.clone())) |
|
|
| top_trees = _select_low_corr_trees( |
| engine, |
| sorted(final_pairs, key=lambda x: x[0], reverse=True), |
| train_mask, |
| top_k, |
| fitness_cfg.export_corr_threshold, |
| ) |
| if top_trees: |
| _export_ml_features(engine, target, top_trees, ml_feature_path) |
|
|
| print(f"\nGP mining complete. Outputs: {out_dir}") |
| return out_dir |
|
|